# -*- coding: utf-8 -*- from odoo import api, fields, models, _ from odoo.exceptions import UserError from markupsafe import Markup, escape import logging _logger = logging.getLogger(__name__) class ResPartner(models.Model): _inherit = 'res.partner' # Portal Role Flags is_authorizer = fields.Boolean( string='Is Authorizer', default=False, help='Check if this partner is an Authorizer (OT/Therapist) who can access the Authorizer Portal', ) is_sales_rep_portal = fields.Boolean( string='Is Sales Rep (Portal)', default=False, help='Check if this partner is a Sales Rep who can access the Sales Rep Portal', ) is_client_portal = fields.Boolean( string='Is Client (Portal)', default=False, help='Check if this partner can access the Funding Claims Portal to view their claims', ) is_technician_portal = fields.Boolean( string='Is Technician (Portal)', default=False, help='Check if this partner is a Field Technician who can access the Technician Portal for deliveries', ) # Computed field for assigned deliveries (for technicians) assigned_delivery_count = fields.Integer( string='Assigned Deliveries', compute='_compute_assigned_delivery_count', help='Number of sale orders assigned to this partner as delivery technician', ) # Geocoding coordinates (for travel time calculations) x_fc_latitude = fields.Float( string='Latitude', digits=(10, 7), help='GPS latitude of the partner address (auto-geocoded)', ) x_fc_longitude = fields.Float( string='Longitude', digits=(10, 7), help='GPS longitude of the partner address (auto-geocoded)', ) # Link to portal user account authorizer_portal_user_id = fields.Many2one( 'res.users', string='Portal User Account', help='The portal user account linked to this authorizer/sales rep', copy=False, ) # Portal access status tracking portal_access_status = fields.Selection( selection=[ ('no_access', 'No Access'), ('invited', 'Invited'), ('active', 'Active'), ], string='Portal Status', compute='_compute_portal_access_status', store=True, help='Tracks portal access: No Access = no portal user, Invited = user created but never logged in, Active = user has logged in', ) # Computed counts assigned_case_count = fields.Integer( string='Assigned Cases', compute='_compute_assigned_case_count', help='Number of sale orders assigned to this partner as authorizer', ) assessment_count = fields.Integer( string='Assessments', compute='_compute_assessment_count', help='Number of assessments linked to this partner', ) @api.depends('authorizer_portal_user_id', 'authorizer_portal_user_id.login_date') def _compute_portal_access_status(self): """Compute portal access status based on user account and login history.""" for partner in self: if not partner.authorizer_portal_user_id: partner.portal_access_status = 'no_access' elif partner.authorizer_portal_user_id.login_date: partner.portal_access_status = 'active' else: partner.portal_access_status = 'invited' @api.depends('is_authorizer') def _compute_assigned_case_count(self): """Count sale orders where this partner is the authorizer""" SaleOrder = self.env['sale.order'].sudo() for partner in self: if partner.is_authorizer: # Use x_fc_authorizer_id field from fusion_claims domain = [('x_fc_authorizer_id', '=', partner.id)] partner.assigned_case_count = SaleOrder.search_count(domain) else: partner.assigned_case_count = 0 @api.depends('is_authorizer', 'is_sales_rep_portal') def _compute_assessment_count(self): """Count assessments where this partner is involved""" Assessment = self.env['fusion.assessment'].sudo() for partner in self: count = 0 if partner.is_authorizer: count += Assessment.search_count([('authorizer_id', '=', partner.id)]) if partner.is_sales_rep_portal and partner.authorizer_portal_user_id: count += Assessment.search_count([('sales_rep_id', '=', partner.authorizer_portal_user_id.id)]) partner.assessment_count = count @api.depends('is_technician_portal') def _compute_assigned_delivery_count(self): """Count sale orders assigned to this partner as delivery technician""" SaleOrder = self.env['sale.order'].sudo() for partner in self: if partner.is_technician_portal and partner.authorizer_portal_user_id: # Technicians are linked via user_id in x_fc_delivery_technician_ids domain = [('x_fc_delivery_technician_ids', 'in', [partner.authorizer_portal_user_id.id])] partner.assigned_delivery_count = SaleOrder.search_count(domain) else: partner.assigned_delivery_count = 0 def _assign_portal_role_groups(self, portal_user): """Assign role-specific portal groups to a portal user based on contact checkboxes.""" groups_to_add = [] if self.is_technician_portal: g = self.env.ref('fusion_authorizer_portal.group_technician_portal', raise_if_not_found=False) if g and g not in portal_user.group_ids: groups_to_add.append((4, g.id)) if self.is_authorizer: g = self.env.ref('fusion_authorizer_portal.group_authorizer_portal', raise_if_not_found=False) if g and g not in portal_user.group_ids: groups_to_add.append((4, g.id)) if self.is_sales_rep_portal: g = self.env.ref('fusion_authorizer_portal.group_sales_rep_portal', raise_if_not_found=False) if g and g not in portal_user.group_ids: groups_to_add.append((4, g.id)) if groups_to_add: portal_user.sudo().write({'group_ids': groups_to_add}) def _assign_internal_role_groups(self, internal_user): """Assign backend groups to an internal user based on contact checkboxes. Also sets x_fc_is_field_staff so the user appears in technician/staff dropdowns. Returns list of group names that were added.""" added = [] needs_field_staff = False if self.is_technician_portal: # Add Field Technician group g = self.env.ref('fusion_claims.group_field_technician', raise_if_not_found=False) if g and g not in internal_user.group_ids: internal_user.sudo().write({'group_ids': [(4, g.id)]}) added.append('Field Technician') needs_field_staff = True if self.is_sales_rep_portal: # Internal sales reps don't need a portal group but should show in staff dropdowns added.append('Sales Rep (internal)') needs_field_staff = True if self.is_authorizer: # Internal authorizers already have full backend access added.append('Authorizer (internal)') # Mark as field staff so they appear in technician/delivery dropdowns if needs_field_staff and hasattr(internal_user, 'x_fc_is_field_staff'): if not internal_user.x_fc_is_field_staff: internal_user.sudo().write({'x_fc_is_field_staff': True}) added.append('Field Staff') return added def action_grant_portal_access(self): """Grant portal access to this partner, or update permissions for existing users.""" self.ensure_one() if not self.email: raise UserError(_('Please set an email address before granting portal access.')) email_normalized = self.email.strip().lower() # ── Step 1: Find existing user ── # Search by partner_id first (direct link) existing_user = self.env['res.users'].sudo().search([ ('partner_id', '=', self.id), ], limit=1) # If not found by partner, search by email (handles internal users # whose auto-created partner is different from this contact) if not existing_user: existing_user = self.env['res.users'].sudo().search([ '|', ('login', '=ilike', email_normalized), ('email', '=ilike', email_normalized), ], limit=1) # ── Step 2: Handle existing user ── if existing_user: from datetime import datetime self.authorizer_portal_user_id = existing_user if not existing_user.share: # ── INTERNAL user: assign backend groups, do NOT add portal ── groups_added = self._assign_internal_role_groups(existing_user) groups_text = ', '.join(groups_added) if groups_added else 'No new groups needed' chatter_msg = Markup( '
' '
' ' Internal User — Permissions Updated' '
' '
' '' f'' f'' f'' f'' f'' f'' '
User:{escape(existing_user.name)} (ID: {existing_user.id})
Login:{escape(existing_user.login)}
Type:Internal (backend) user
Groups added:{escape(groups_text)}
Updated by:{escape(self.env.user.name)}
Updated at:{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
' '
' '
' ) self.message_post(body=chatter_msg, message_type='notification', subtype_xmlid='mail.mt_note') notify_msg = _('Internal user detected. Backend permissions updated: %s') % groups_text else: # ── Existing PORTAL user: ensure role groups are set ── portal_group = self.env.ref('base.group_portal', raise_if_not_found=False) if portal_group and portal_group not in existing_user.group_ids: existing_user.sudo().write({'group_ids': [(4, portal_group.id)]}) self._assign_portal_role_groups(existing_user) chatter_msg = Markup( '
' '
' ' Portal Access — Roles Updated' '
' '
' '' f'' f'' f'' f'' f'' '
Status:Portal user exists — roles updated
User:{escape(existing_user.name)} (ID: {existing_user.id})
Login:{escape(existing_user.login)}
Checked by:{escape(self.env.user.name)}
Checked at:{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
' '
' '
' ) self.message_post(body=chatter_msg, message_type='notification', subtype_xmlid='mail.mt_note') notify_msg = _('Portal user already exists — role groups updated (User ID: %s).') % existing_user.id return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Access Updated'), 'message': notify_msg, 'type': 'info', 'sticky': False, } } # No existing user found - create portal user directly portal_group = self.env.ref('base.group_portal', raise_if_not_found=False) if not portal_group: raise UserError(_('Portal group not found. Please contact administrator.')) try: # Create user without groups first (Odoo 17+ compatibility) portal_user = self.env['res.users'].sudo().with_context(no_reset_password=True, knowledge_skip_onboarding_article=True).create({ 'name': self.name, 'login': email_normalized, 'email': self.email, 'partner_id': self.id, 'active': True, }) # Add portal group after creation portal_user.sudo().write({ 'group_ids': [(6, 0, [portal_group.id])], }) # Assign role-specific portal groups based on contact checkboxes self._assign_portal_role_groups(portal_user) self.authorizer_portal_user_id = portal_user # Create welcome Knowledge article for the user self._create_welcome_article(portal_user) # Send professional portal invitation email email_sent = False try: email_sent = self._send_portal_invitation_email(portal_user) except Exception as mail_error: _logger.warning(f"Could not send portal invitation email: {mail_error}") # Post message in chatter sent_by = self.env.user.name from datetime import datetime sent_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S') if email_sent: status_text = 'Invitation email sent successfully' border_color = '#28a745' header_bg = '#28a745' body_bg = '#f0fff0' else: status_text = 'User created but email could not be sent' border_color = '#fd7e14' header_bg = '#fd7e14' body_bg = '#fff8f0' chatter_msg = Markup( f'
' f'
' ' Portal Access Granted' '
' f'
' '' f'' f'' f'' f'' f'' '
Email:{self.email}
Sent by:{sent_by}
Sent at:{sent_at}
User ID:{portal_user.id}
Status:{status_text}
' '
' '
' ) self.message_post(body=chatter_msg, message_type='notification', subtype_xmlid='mail.mt_note') return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Portal Access Granted'), 'message': _('Portal user created for %s. A password reset email has been sent.') % self.email, 'type': 'success', 'sticky': False, } } except Exception as e: _logger.error(f"Failed to create portal user: {e}") raise UserError(_('Failed to create portal user: %s') % str(e)) def _create_welcome_article(self, portal_user): """Create a role-specific welcome Knowledge article for the new portal user. Determines the role from partner flags and renders the matching template. The article is private to the user and set as a favorite. """ self.ensure_one() # Check if Knowledge module is installed if 'knowledge.article' not in self.env: _logger.info("Knowledge module not installed, skipping welcome article") return # Determine role and template if self.is_technician_portal: template_xmlid = 'fusion_authorizer_portal.welcome_article_technician' icon = '🔧' title = f"Welcome {self.name} - Technician Portal" elif self.is_authorizer: template_xmlid = 'fusion_authorizer_portal.welcome_article_authorizer' icon = '📋' title = f"Welcome {self.name} - Authorizer Portal" elif self.is_sales_rep_portal: template_xmlid = 'fusion_authorizer_portal.welcome_article_sales_rep' icon = '💼' title = f"Welcome {self.name} - Sales Portal" elif self.is_client_portal: template_xmlid = 'fusion_authorizer_portal.welcome_article_client' icon = '👤' title = f"Welcome {self.name}" else: template_xmlid = 'fusion_authorizer_portal.welcome_article_client' icon = '👋' title = f"Welcome {self.name}" company = self.env.company render_ctx = { 'user_name': self.name or 'Valued Partner', 'company_name': company.name or 'Our Company', 'company_email': company.email or '', 'company_phone': company.phone or '', } try: body = self.env['ir.qweb']._render( template_xmlid, render_ctx, minimal_qcontext=True, raise_if_not_found=False, ) if not body: _logger.warning(f"Welcome article template not found: {template_xmlid}") return article = self.env['knowledge.article'].sudo().create({ 'name': title, 'icon': icon, 'body': body, 'internal_permission': 'none', 'is_article_visible_by_everyone': False, 'article_member_ids': [(0, 0, { 'partner_id': self.id, 'permission': 'write', })], 'favorite_ids': [(0, 0, { 'sequence': 0, 'user_id': portal_user.id, })], }) _logger.info(f"Created welcome article '{title}' (ID: {article.id}) for {self.name}") except Exception as e: _logger.warning(f"Failed to create welcome article for {self.name}: {e}") def _send_portal_invitation_email(self, portal_user, is_resend=False): """Send a professional portal invitation email to the partner. Generates a signup URL and sends a branded invitation email instead of the generic Odoo password reset email. Returns True if email was sent successfully, False otherwise. """ self.ensure_one() # Generate signup token and build URL partner = portal_user.sudo().partner_id # Set signup type to 'signup' - this auto-logs in after password is set partner.signup_prepare(signup_type='signup') # Use Odoo's built-in URL generation with signup_email context # so the email is pre-filled and user just sets password signup_urls = partner.with_context( signup_valid=True, create_user=True, )._get_signup_url_for_action() signup_url = signup_urls.get(partner.id) if not signup_url: base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') signup_url = f"{base_url}/web/reset_password" _logger.warning(f"Could not generate signup URL for {self.email}, using generic reset page") company = self.env.company company_name = company.name or 'Our Company' base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') partner_name = self.name or 'Valued Partner' subject = f"You're Invited to the {company_name} Portal" if not is_resend else f"Portal Access Reminder - {company_name}" invite_text = 'We are pleased to invite you' if not is_resend else 'This is a reminder that you have been invited' body_html = ( f'
' f'
' f'
' f'

{company_name}

' f'

Portal Invitation

' f'

' f'Dear {partner_name}, {invite_text} to access the {company_name} Portal.

' f'

' f'With the portal you can:

' f'' f'

' f'' f'Accept Invitation & Set Password

' f'

' f'If the button does not work, copy this link: ' f'{signup_url}

' f'
' f'

' f'After setting your password, access the portal anytime at: ' f'{base_url}/my

' f'

' f'Best regards,
{company_name} Team

' f'
' f'
' f'

' f'This is an automated message from {company_name}.

' ) mail_values = { 'subject': subject, 'body_html': body_html, 'email_to': self.email, 'email_from': company.email or self.env.user.email or 'noreply@example.com', 'auto_delete': True, } try: mail = self.env['mail.mail'].sudo().create(mail_values) mail.send() _logger.info(f"Portal invitation email sent to {self.email}") return True except Exception as e: _logger.error(f"Failed to send portal invitation email to {self.email}: {e}") return False def action_resend_portal_invitation(self): """Resend portal invitation email to an existing portal user.""" self.ensure_one() if not self.authorizer_portal_user_id: raise UserError(_('No portal user found for this contact. Use "Send Portal Invitation" instead.')) portal_user = self.authorizer_portal_user_id # Send professional portal invitation email email_sent = False try: email_sent = self._send_portal_invitation_email(portal_user, is_resend=True) except Exception as mail_error: _logger.warning(f"Could not send portal invitation email: {mail_error}") # Post in chatter from datetime import datetime sent_by = self.env.user.name sent_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S') if email_sent: chatter_msg = Markup( '
' '
' ' Portal Invitation Resent' '
' '
' '' f'' f'' f'' f'' '' '' '
Email:{self.email}
Sent by:{sent_by}
Sent at:{sent_at}
User ID:{portal_user.id}
Status:Invitation email resent successfully
' '
' '
' ) else: chatter_msg = Markup( '
' '
' ' Portal Invitation Resend Attempted' '
' '
' '' f'' f'' f'' '' '' '
Email:{self.email}
Sent by:{sent_by}
Sent at:{sent_at}
Status:Email could not be sent - check mail configuration
' '
' '
' ) self.message_post(body=chatter_msg, message_type='notification', subtype_xmlid='mail.mt_note') return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Portal Invitation Resent') if email_sent else _('Email Failed'), 'message': _('Portal invitation resent to %s.') % self.email if email_sent else _('Could not send email. Check mail configuration.'), 'type': 'success' if email_sent else 'warning', 'sticky': False, } } def action_view_assigned_cases(self): """Open the list of assigned sale orders""" self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': _('Assigned Cases'), 'res_model': 'sale.order', 'view_mode': 'list,form', 'domain': [('x_fc_authorizer_id', '=', self.id)], 'context': {'default_x_fc_authorizer_id': self.id}, } def action_view_assessments(self): """Open the list of assessments for this partner""" self.ensure_one() domain = [] if self.is_authorizer: domain = [('authorizer_id', '=', self.id)] elif self.is_sales_rep_portal and self.authorizer_portal_user_id: domain = [('sales_rep_id', '=', self.authorizer_portal_user_id.id)] return { 'type': 'ir.actions.act_window', 'name': _('Assessments'), 'res_model': 'fusion.assessment', 'view_mode': 'list,form', 'domain': domain, } # ==================== BATCH ACTIONS ==================== def action_mark_as_authorizer(self): """Batch action to mark selected contacts as authorizers""" self.write({'is_authorizer': True}) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Authorizers Updated'), 'message': _('%d contact(s) marked as authorizer.') % len(self), 'type': 'success', 'sticky': False, } } def action_batch_send_portal_invitation(self): """Batch action to send portal invitations to selected authorizers""" sent_count = 0 skipped_no_email = 0 skipped_not_authorizer = 0 skipped_has_access = 0 errors = [] for partner in self: if not partner.is_authorizer: skipped_not_authorizer += 1 continue if not partner.email: skipped_no_email += 1 continue if partner.authorizer_portal_user_id: skipped_has_access += 1 continue try: partner.action_grant_portal_access() sent_count += 1 except Exception as e: errors.append(f"{partner.name}: {str(e)}") # Build result message messages = [] if sent_count: messages.append(_('%d invitation(s) sent successfully.') % sent_count) if skipped_not_authorizer: messages.append(_('%d skipped (not marked as authorizer).') % skipped_not_authorizer) if skipped_no_email: messages.append(_('%d skipped (no email).') % skipped_no_email) if skipped_has_access: messages.append(_('%d skipped (already has portal access).') % skipped_has_access) if errors: messages.append(_('%d error(s) occurred.') % len(errors)) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Portal Invitations'), 'message': ' '.join(messages), 'type': 'success' if sent_count and not errors else 'warning' if not errors else 'danger', 'sticky': True if errors else False, } } def action_mark_and_send_invitation(self): """Combined action: mark as authorizer and send invitation""" self.action_mark_as_authorizer() return self.action_batch_send_portal_invitation() def action_view_assigned_deliveries(self): """Open the list of assigned deliveries for technician""" self.ensure_one() if not self.authorizer_portal_user_id: raise UserError(_('This partner does not have a portal user account.')) return { 'type': 'ir.actions.act_window', 'name': _('Assigned Deliveries'), 'res_model': 'sale.order', 'view_mode': 'list,form', 'domain': [('x_fc_delivery_technician_ids', 'in', [self.authorizer_portal_user_id.id])], } def action_mark_as_technician(self): """Batch action to mark selected contacts as technicians""" self.write({'is_technician_portal': True}) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Technicians Updated'), 'message': _('%d contact(s) marked as technician.') % len(self), 'type': 'success', 'sticky': False, } } # ------------------------------------------------------------------ # GEOCODING # ------------------------------------------------------------------ def _geocode_address(self): """Geocode partner address using Google Geocoding API and cache lat/lng.""" import requests as http_requests api_key = self.env['ir.config_parameter'].sudo().get_param( 'fusion_claims.google_maps_api_key', '' ) if not api_key: return for partner in self: parts = [partner.street, partner.city, partner.state_id.name if partner.state_id else '', partner.zip] address = ', '.join([p for p in parts if p]) if not address: continue try: resp = http_requests.get( 'https://maps.googleapis.com/maps/api/geocode/json', params={'address': address, 'key': api_key, 'region': 'ca'}, timeout=10, ) data = resp.json() if data.get('status') == 'OK' and data.get('results'): loc = data['results'][0]['geometry']['location'] partner.write({ 'x_fc_latitude': loc['lat'], 'x_fc_longitude': loc['lng'], }) except Exception as e: _logger.warning(f"Geocoding failed for partner {partner.id}: {e}") def write(self, vals): """Override write to auto-geocode when address changes.""" res = super().write(vals) address_fields = {'street', 'city', 'state_id', 'zip', 'country_id'} if address_fields & set(vals.keys()): # Check if distance matrix is enabled before geocoding enabled = self.env['ir.config_parameter'].sudo().get_param( 'fusion_claims.google_distance_matrix_enabled', False ) if enabled: self._geocode_address() return res