# -*- coding: utf-8 -*- import logging import math from datetime import timedelta from markupsafe import Markup from odoo import api, fields, models, _ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class FusionAccessibilityAssessment(models.Model): _name = 'fusion.accessibility.assessment' _description = 'Accessibility Assessment' _inherit = ['mail.thread', 'mail.activity.mixin', 'fusion.email.builder.mixin'] _order = 'assessment_date desc, id desc' _rec_name = 'display_name' # ========================================================================== # COMMON FIELDS (all assessment types) # ========================================================================== reference = fields.Char( string='Reference', readonly=True, copy=False, default=lambda self: _('New'), ) display_name = fields.Char( compute='_compute_display_name', store=True, ) assessment_type = fields.Selection( selection=[ ('stairlift_straight', 'Straight Stair Lift'), ('stairlift_curved', 'Curved Stair Lift'), ('vpl', 'Vertical Platform Lift'), ('ceiling_lift', 'Ceiling Lift'), ('ramp', 'Custom Ramp'), ('bathroom', 'Bathroom Modification'), ('tub_cutout', 'Tub Cutout'), ], string='Assessment Type', required=True, tracking=True, ) state = fields.Selection( selection=[ ('draft', 'Draft'), ('completed', 'Completed'), ('cancelled', 'Cancelled'), ], string='Status', default='draft', tracking=True, ) # Client Information client_name = fields.Char(string='Client Name', required=True) client_address = fields.Char(string='Address') client_unit = fields.Char(string='Unit/Apt/Suite') client_address_street = fields.Char(string='Street') client_address_city = fields.Char(string='City') client_address_province = fields.Char(string='Province') client_address_postal = fields.Char(string='Postal Code') client_phone = fields.Char(string='Phone') client_email = fields.Char(string='Email') # Booking fields booking_source = fields.Selection( selection=[ ('phone_authorizer', 'Phone - Authorizer'), ('phone_client', 'Phone - Client'), ('walk_in', 'Walk-In'), ('portal', 'Online Booking'), ], string='Booking Source', default='phone_client', help='How the assessment was booked', ) modification_requested = fields.Text( string='Modification Requested', help='What the client or authorizer is looking for', ) sms_confirmation_sent = fields.Boolean( string='SMS Confirmation Sent', default=False, ) calendar_event_id = fields.Many2one( 'calendar.event', string='Calendar Event', readonly=True, copy=False, ) # Relationships sales_rep_id = fields.Many2one( 'res.users', string='Sales Rep', default=lambda self: self.env.user, tracking=True, ) authorizer_id = fields.Many2one( 'res.partner', string='Authorizer/OT', tracking=True, help='The Occupational Therapist or Authorizer for this assessment', ) partner_id = fields.Many2one( 'res.partner', string='Client Partner', help='Linked partner record (created on completion)', ) sale_order_id = fields.Many2one( 'sale.order', string='Created Sale Order', readonly=True, copy=False, ) # Dates assessment_date = fields.Date( string='Assessment Date', default=fields.Date.today, ) # General Notes notes = fields.Text(string='General Notes') # ========================================================================== # STAIR LIFT - STRAIGHT FIELDS # ========================================================================== stair_steps = fields.Integer(string='Number of Steps') stair_nose_to_nose = fields.Float(string='Nose to Nose Distance (inches)') stair_side = fields.Selection( selection=[('left', 'Left'), ('right', 'Right')], string='Installation Side', ) stair_style = fields.Selection( selection=[ ('standard', 'Standard Stair Lift'), ('slide_track', 'Slide Track Stair Lift'), ('foldable_hinge', 'Foldable Hinge Stair Lift'), ], string='Stair Lift Style', ) stair_power_swivel_upstairs = fields.Boolean(string='Power Swivel (Upstairs)') stair_power_folding_footrest = fields.Boolean(string='Power Folding Footrest') stair_calculated_length = fields.Float( string='Calculated Track Length (inches)', compute='_compute_stair_straight_length', store=True, ) stair_manual_length_override = fields.Float(string='Manual Length Override (inches)') stair_final_length = fields.Float( string='Final Track Length (inches)', compute='_compute_stair_final_length', store=True, ) # ========================================================================== # STAIR LIFT - CURVED FIELDS # ========================================================================== stair_curved_steps = fields.Integer(string='Number of Steps (Curved)') stair_curves_count = fields.Integer(string='Number of Curves') # Top Landing Options stair_top_landing_type = fields.Selection( selection=[ ('none', 'Standard (No special landing)'), ('90_exit', '90° Exit'), ('90_parking', '90° Parking'), ('180_parking', '180° Parking'), ('flush_landing', 'Flush Landing'), ('vertical_overrun', 'Vertical Overrun (Custom)'), ], string='Top Landing Type', default='none', help='Type of landing at the top of the staircase', ) top_overrun_custom_length = fields.Float( string='Top Overrun Length (inches)', help='Custom overrun length when Vertical Overrun is selected', ) # Bottom Landing Options stair_bottom_landing_type = fields.Selection( selection=[ ('none', 'Standard (No special landing)'), ('90_park', '90° Park'), ('180_park', '180° Park'), ('drop_nose', 'Drop Nose Landing'), ('short_vertical', 'Short Vertical Start'), ('horizontal_overrun', 'Horizontal Overrun (Custom)'), ], string='Bottom Landing Type', default='none', help='Type of landing at the bottom of the staircase', ) bottom_overrun_custom_length = fields.Float( string='Bottom Overrun Length (inches)', help='Custom overrun length when Horizontal Overrun is selected', ) # Legacy fields kept for backwards compatibility stair_has_drop_nose = fields.Boolean(string='Has Drop Nose (Legacy)') stair_parking_type = fields.Selection( selection=[ ('none', 'No Parking'), ('90_degree', '90° Parking (+2 feet)'), ('180_degree', '180° Parking (+4 feet)'), ], string='Parking Type (Legacy)', default='none', ) stair_power_swivel_downstairs = fields.Boolean(string='Power Swivel (Downstairs)') stair_auto_folding_footrest = fields.Boolean(string='Automatic Folding Footrest') stair_auto_folding_hinge = fields.Boolean(string='Automatic Folding Hinge') stair_auto_folding_seat = fields.Boolean(string='Automatic Folding Seat') stair_custom_color = fields.Boolean(string='Customizable Colored Seat') stair_additional_charging = fields.Boolean(string='Additional Charging Station') stair_charging_with_remote = fields.Boolean(string='Charging Station with Remote') stair_curved_calculated_length = fields.Float( string='Calculated Track Length (inches)', compute='_compute_stair_curved_length', store=True, ) stair_curved_manual_override = fields.Float(string='Manual Length Override (inches)') stair_curved_final_length = fields.Float( string='Final Track Length (inches)', compute='_compute_stair_curved_final_length', store=True, ) # ========================================================================== # VERTICAL PLATFORM LIFT (VPL) FIELDS # ========================================================================== vpl_room_width = fields.Float(string='Room Width (inches)') vpl_room_depth = fields.Float(string='Room Depth (inches)') vpl_rise_height = fields.Float(string='Total Rise Height (inches)') vpl_has_existing_platform = fields.Boolean(string='Existing Platform Available') vpl_concrete_depth = fields.Float(string='Concrete Depth (inches)', help='Minimum 4 inches required') vpl_model_type = fields.Selection( selection=[ ('ac', 'AC Model (Dedicated 15-amp breaker required)'), ('dc', 'DC Model (No dedicated breaker required)'), ], string='Model Type', ) vpl_has_nearby_plug = fields.Boolean(string='Power Plug Nearby') vpl_plug_specs = fields.Char(string='Plug Specifications', default='110V / 15-amp') vpl_needs_plug_install = fields.Boolean(string='Needs Plug Installation') vpl_needs_certification = fields.Boolean(string='Needs City Certification') vpl_certification_notes = fields.Text(string='Certification Notes') # ========================================================================== # CEILING LIFT FIELDS # ========================================================================== ceiling_track_length = fields.Float(string='Total Track Length (feet)') ceiling_movement_type = fields.Selection( selection=[ ('manual', 'Manual Movement (left-to-right)'), ('powered', 'Powered Movement (left-to-right)'), ], string='Horizontal Movement Type', help='All ceiling lifts move up/down with power. This is for left-to-right movement.', ) ceiling_charging_throughout = fields.Boolean( string='Charging Throughout Track', help='Charging available throughout the track instead of one location', ) ceiling_carry_bar = fields.Boolean(string='Carry Bar') ceiling_additional_slings = fields.Integer(string='Additional Slings Needed') # ========================================================================== # CUSTOM RAMP FIELDS # ========================================================================== ramp_height = fields.Float(string='Total Height (inches from ground)') ramp_ground_incline = fields.Float(string='Ground Incline (degrees)', help='Optional - if ground is inclined') ramp_at_door = fields.Boolean(string='Ramp at Door', help='Requires 5ft landing at door') ramp_calculated_length = fields.Float( string='Calculated Ramp Length (inches)', compute='_compute_ramp_length', store=True, help='Ontario Building Code: 12 inches length per 1 inch height', ) ramp_landings_needed = fields.Integer( string='Landings Needed', compute='_compute_ramp_landings', store=True, help='Landing required every 30 feet (minimum 5 feet each)', ) ramp_total_length = fields.Float( string='Total Length with Landings (inches)', compute='_compute_ramp_total_length', store=True, ) ramp_handrail_height = fields.Float( string='Handrail Height (inches)', default=32.0, help='Minimum 32 inches required', ) ramp_manual_override = fields.Float(string='Manual Length Override (inches)') # ========================================================================== # BATHROOM MODIFICATION FIELDS # ========================================================================== bathroom_description = fields.Text( string='Modification Description', help='Describe all bathroom modifications needed', ) # ========================================================================== # TUB CUTOUT FIELDS # ========================================================================== tub_internal_height = fields.Float(string='Internal Height of Tub (inches)') tub_external_height = fields.Float(string='External Height of Tub (inches)') tub_additional_supplies = fields.Text(string='Additional Supplies Needed') # ========================================================================== # COMPUTED FIELDS # ========================================================================== @api.depends('reference', 'assessment_type', 'client_name') def _compute_display_name(self): type_labels = dict(self._fields['assessment_type'].selection) for rec in self: type_label = type_labels.get(rec.assessment_type, '') rec.display_name = f"{rec.reference or 'New'} - {type_label} - {rec.client_name or ''}" @api.depends('stair_steps', 'stair_nose_to_nose') def _compute_stair_straight_length(self): """Straight stair lift: (steps × nose_to_nose) + 13" top landing""" for rec in self: if rec.stair_steps and rec.stair_nose_to_nose: rec.stair_calculated_length = (rec.stair_steps * rec.stair_nose_to_nose) + 13 else: rec.stair_calculated_length = 0 @api.depends('stair_calculated_length', 'stair_manual_length_override') def _compute_stair_final_length(self): """Use manual override if provided, otherwise use calculated""" for rec in self: if rec.stair_manual_length_override: rec.stair_final_length = rec.stair_manual_length_override else: rec.stair_final_length = rec.stair_calculated_length @api.depends('stair_curved_steps', 'stair_curves_count', 'stair_top_landing_type', 'stair_bottom_landing_type', 'top_overrun_custom_length', 'bottom_overrun_custom_length') def _compute_stair_curved_length(self): """Curved stair lift calculation: - 12" per step - 16" per curve - Top landing type additions (or custom overrun) - Bottom landing type additions (or custom overrun) """ # Track length additions for each landing type (in inches) # Note: vertical_overrun and horizontal_overrun use custom lengths TOP_LANDING_LENGTHS = { 'none': 0, '90_exit': 24, # 2 feet '90_parking': 24, # 2 feet '180_parking': 48, # 4 feet 'flush_landing': 12, # 1 foot } BOTTOM_LANDING_LENGTHS = { 'none': 0, '90_park': 24, # 2 feet '180_park': 48, # 4 feet 'drop_nose': 12, # 1 foot 'short_vertical': 12, # 1 foot } for rec in self: if rec.stair_curved_steps: base_length = rec.stair_curved_steps * 12 # 12" per step curves_length = (rec.stair_curves_count or 0) * 16 # 16" per curve # Top landing length - use custom if overrun selected if rec.stair_top_landing_type == 'vertical_overrun': top_landing = rec.top_overrun_custom_length or 0 else: top_landing = TOP_LANDING_LENGTHS.get(rec.stair_top_landing_type or 'none', 0) # Bottom landing length - use custom if overrun selected if rec.stair_bottom_landing_type == 'horizontal_overrun': bottom_landing = rec.bottom_overrun_custom_length or 0 else: bottom_landing = BOTTOM_LANDING_LENGTHS.get(rec.stair_bottom_landing_type or 'none', 0) rec.stair_curved_calculated_length = ( base_length + curves_length + top_landing + bottom_landing ) else: rec.stair_curved_calculated_length = 0 @api.depends('stair_curved_calculated_length', 'stair_curved_manual_override') def _compute_stair_curved_final_length(self): """Use manual override if provided, otherwise use calculated""" for rec in self: if rec.stair_curved_manual_override: rec.stair_curved_final_length = rec.stair_curved_manual_override else: rec.stair_curved_final_length = rec.stair_curved_calculated_length @api.depends('ramp_height') def _compute_ramp_length(self): """Ontario Building Code: 12 inches length per 1 inch height (1:12 ratio)""" for rec in self: if rec.ramp_height: rec.ramp_calculated_length = rec.ramp_height * 12 else: rec.ramp_calculated_length = 0 @api.depends('ramp_calculated_length') def _compute_ramp_landings(self): """Landing required every 30 feet (360 inches)""" for rec in self: if rec.ramp_calculated_length: # Calculate how many landings are needed (every 30 feet = 360 inches) rec.ramp_landings_needed = math.ceil(rec.ramp_calculated_length / 360) else: rec.ramp_landings_needed = 0 @api.depends('ramp_calculated_length', 'ramp_landings_needed', 'ramp_at_door') def _compute_ramp_total_length(self): """Total length including landings (5 feet = 60 inches each)""" for rec in self: base_length = rec.ramp_calculated_length or 0 landings_length = (rec.ramp_landings_needed or 0) * 60 # 5 feet per landing door_landing = 60 if rec.ramp_at_door else 0 # 5 feet at door rec.ramp_total_length = base_length + landings_length + door_landing # ========================================================================== # CRUD METHODS # ========================================================================== @api.model_create_multi def create(self, vals_list): for vals in vals_list: if vals.get('reference', _('New')) == _('New'): vals['reference'] = self.env['ir.sequence'].next_by_code( 'fusion.accessibility.assessment' ) or _('New') return super().create(vals_list) # ========================================================================== # BUSINESS LOGIC # ========================================================================== def action_complete(self): """Complete the assessment and create a Sale Order""" self.ensure_one() if not self.client_name: raise UserError(_('Please enter the client name.')) # Create or find partner partner = self._ensure_partner() # Create draft sale order sale_order = self._create_draft_sale_order(partner) # Add tag based on assessment type self._add_assessment_tag(sale_order) # Copy photos from assessment to sale order chatter self._copy_photos_to_sale_order(sale_order) # Update state self.write({ 'state': 'completed', 'sale_order_id': sale_order.id, 'partner_id': partner.id, }) # Send email notification to office self._send_completion_email(sale_order) # Schedule follow-up activity for sales rep self._schedule_followup_activity(sale_order) _logger.info(f"Completed accessibility assessment {self.reference}, created SO {sale_order.name}") return sale_order def _add_assessment_tag(self, sale_order): """Add a tag to the sale order based on assessment type""" self.ensure_one() # Map assessment types to tag names (ALL CAPS) tag_map = { 'stairlift_straight': 'STRAIGHT STAIR LIFT', 'stairlift_curved': 'CURVED STAIR LIFT', 'vpl': 'VERTICAL PLATFORM LIFT', 'ceiling_lift': 'CEILING LIFT', 'ramp': 'CUSTOM RAMP', 'bathroom': 'BATHROOM MODIFICATION', 'tub_cutout': 'TUB CUTOUT', } tag_name = tag_map.get(self.assessment_type) if not tag_name: return # Find or create the tag Tag = self.env['crm.tag'].sudo() tag = Tag.search([('name', '=', tag_name)], limit=1) if not tag: tag = Tag.create({'name': tag_name}) _logger.info(f"Created new tag: {tag_name}") # Add tag to sale order if hasattr(sale_order, 'tag_ids'): sale_order.write({'tag_ids': [(4, tag.id)]}) _logger.info(f"Added tag '{tag_name}' to SO {sale_order.name}") def _copy_photos_to_sale_order(self, sale_order): """Copy assessment photos to sale order chatter""" self.ensure_one() Attachment = self.env['ir.attachment'].sudo() # Find photos attached to this assessment photos = Attachment.search([ ('res_model', '=', 'fusion.accessibility.assessment'), ('res_id', '=', self.id), ('mimetype', 'like', 'image/%'), ]) if not photos: return # Copy attachments to sale order and post in chatter attachment_ids = [] for photo in photos: new_attachment = photo.copy({ 'res_model': 'sale.order', 'res_id': sale_order.id, }) attachment_ids.append(new_attachment.id) if attachment_ids: type_labels = dict(self._fields['assessment_type'].selection) type_label = type_labels.get(self.assessment_type, 'Accessibility') sale_order.message_post( body=Markup(f'''
Assessment Photos
{len(attachment_ids)} photo(s) from {type_label} Assessment ({self.reference})
'''), message_type='comment', subtype_xmlid='mail.mt_note', attachment_ids=attachment_ids, ) _logger.info(f"Copied {len(attachment_ids)} photos to SO {sale_order.name}") def _send_completion_email(self, sale_order): """Send email notification to office about assessment completion""" self.ensure_one() ICP = self.env['ir.config_parameter'].sudo() # Check if email notifications are enabled if not ICP.get_param('fusion_claims.enable_email_notifications', 'True') == 'True': return # Get office notification emails from company company = self.env.company office_partners = company.sudo().x_fc_office_notification_ids email_list = [p.email for p in office_partners if p.email] office_emails = ', '.join(email_list) if not office_emails: _logger.warning("No office notification recipients configured for accessibility assessment completion") return type_labels = dict(self._fields['assessment_type'].selection) type_label = type_labels.get(self.assessment_type, 'Accessibility') body = self._email_build( title='Accessibility Assessment Completed', summary=f'A new {type_label.lower()} assessment has been completed for ' f'{self.client_name}. A sale order has been created.', email_type='info', sections=[('Assessment Details', [ ('Type', type_label), ('Reference', self.reference), ('Client', self.client_name), ('Sales Rep', self.sales_rep_id.name if self.sales_rep_id else 'N/A'), ('Sale Order', sale_order.name), ])], button_url=f'{sale_order.get_base_url()}/web#id={sale_order.id}&model=sale.order&view_type=form', button_text='View Sale Order', ) # Send email mail_values = { 'subject': f'Accessibility Assessment Completed: {type_label} - {self.client_name}', 'body_html': body, 'email_to': office_emails, 'email_from': self.env.company.email or 'noreply@example.com', } try: mail = self.env['mail.mail'].sudo().create(mail_values) mail.send() _logger.info(f"Sent accessibility assessment completion email to {office_emails}") except Exception as e: _logger.error(f"Failed to send assessment completion email: {e}") def _schedule_followup_activity(self, sale_order): """Schedule a follow-up activity for the sales rep""" self.ensure_one() if not self.sales_rep_id: return type_labels = dict(self._fields['assessment_type'].selection) type_label = type_labels.get(self.assessment_type, 'Accessibility') # Get the "To Do" activity type activity_type = self.env.ref('mail.mail_activity_data_todo', raise_if_not_found=False) if not activity_type: _logger.warning("Could not find 'To Do' activity type") return # Schedule activity for tomorrow due_date = fields.Date.today() + timedelta(days=1) try: sale_order.activity_schedule( activity_type_id=activity_type.id, date_deadline=due_date, user_id=self.sales_rep_id.id, summary=f'Follow up on {type_label} Assessment', note=f'Assessment {self.reference} for {self.client_name} has been completed. Please follow up with the client.', ) _logger.info(f"Scheduled follow-up activity for {self.sales_rep_id.name} on SO {sale_order.name}") except Exception as e: _logger.error(f"Failed to schedule follow-up activity: {e}") def _ensure_partner(self): """Find or create a partner for the client""" self.ensure_one() Partner = self.env['res.partner'].sudo() # First, try to find existing partner by email if self.client_email: existing = Partner.search([('email', '=ilike', self.client_email)], limit=1) if existing: return existing # Create new partner partner_vals = { 'name': self.client_name, 'email': self.client_email, 'phone': self.client_phone, 'street': self.client_address_street or self.client_address, 'street2': self.client_unit or False, 'city': self.client_address_city, 'zip': self.client_address_postal, 'customer_rank': 1, } # Set province/state if provided if self.client_address_province: state = self.env['res.country.state'].sudo().search([ ('code', '=ilike', self.client_address_province), ('country_id.code', '=', 'CA'), ], limit=1) if state: partner_vals['state_id'] = state.id partner_vals['country_id'] = state.country_id.id else: # Default to Canada canada = self.env.ref('base.ca', raise_if_not_found=False) if canada: partner_vals['country_id'] = canada.id partner = Partner.create(partner_vals) _logger.info(f"Created partner {partner.name} from accessibility assessment {self.reference}") return partner def _create_draft_sale_order(self, partner): """Create a draft sale order from the assessment""" self.ensure_one() SaleOrder = self.env['sale.order'].sudo() type_labels = dict(self._fields['assessment_type'].selection) type_label = type_labels.get(self.assessment_type, 'Accessibility') so_vals = { 'partner_id': partner.id, 'user_id': self.sales_rep_id.id if self.sales_rep_id else self.env.user.id, 'state': 'draft', 'origin': f'Accessibility: {self.reference} ({type_label})', 'x_fc_sale_type': 'direct_private', # Accessibility items typically private pay } sale_order = SaleOrder.create(so_vals) _logger.info(f"Created draft sale order {sale_order.name} from accessibility assessment {self.reference}") # Post assessment details to chatter assessment_html = self._format_assessment_html_table() sale_order.message_post( body=Markup(assessment_html), message_type='comment', subtype_xmlid='mail.mt_note', ) return sale_order def _format_assessment_html_table(self): """Format assessment details as HTML for chatter""" type_labels = dict(self._fields['assessment_type'].selection) type_label = type_labels.get(self.assessment_type, 'Unknown') html = f''' ' return html def action_cancel(self): """Cancel the assessment""" self.ensure_one() self.write({'state': 'cancelled'}) def action_reset_to_draft(self): """Reset to draft state""" self.ensure_one() self.write({'state': 'draft'})