- fusion_claims: separated field service logic, updated controllers/views - fusion_tasks: updated task views and map integration - fusion_authorizer_portal: added page 11 signing, schedule booking, migrations - fusion_shipping: new standalone shipping module (Canada Post, FedEx, DHL, Purolator) - fusion_ltc_management: new standalone LTC management module
768 lines
36 KiB
Python
768 lines
36 KiB
Python
# -*- 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_tasks.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(
|
|
'<div style="border: 1px solid #6f42c1; border-radius: 4px; overflow: hidden; margin: 8px 0;">'
|
|
'<div style="background: #6f42c1; color: white; padding: 10px 12px; font-weight: 600;">'
|
|
'<i class="fa fa-user-circle"></i> Internal User — Permissions Updated'
|
|
'</div>'
|
|
'<div style="padding: 12px; background: #f8f5ff;">'
|
|
'<table style="font-size: 13px; width: 100%;">'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">User:</td><td>{escape(existing_user.name)} (ID: {existing_user.id})</td></tr>'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Login:</td><td>{escape(existing_user.login)}</td></tr>'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Type:</td><td>Internal (backend) user</td></tr>'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Groups added:</td><td>{escape(groups_text)}</td></tr>'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Updated by:</td><td>{escape(self.env.user.name)}</td></tr>'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Updated at:</td><td>{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</td></tr>'
|
|
'</table>'
|
|
'</div>'
|
|
'</div>'
|
|
)
|
|
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(
|
|
'<div style="border: 1px solid #17a2b8; border-radius: 4px; overflow: hidden; margin: 8px 0;">'
|
|
'<div style="background: #17a2b8; color: white; padding: 10px 12px; font-weight: 600;">'
|
|
'<i class="fa fa-check-circle"></i> Portal Access — Roles Updated'
|
|
'</div>'
|
|
'<div style="padding: 12px; background: #f0f9ff;">'
|
|
'<table style="font-size: 13px; width: 100%;">'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Status:</td><td>Portal user exists — roles updated</td></tr>'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">User:</td><td>{escape(existing_user.name)} (ID: {existing_user.id})</td></tr>'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Login:</td><td>{escape(existing_user.login)}</td></tr>'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Checked by:</td><td>{escape(self.env.user.name)}</td></tr>'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Checked at:</td><td>{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</td></tr>'
|
|
'</table>'
|
|
'</div>'
|
|
'</div>'
|
|
)
|
|
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 = '<span style="color: green;">Invitation email sent successfully</span>'
|
|
border_color = '#28a745'
|
|
header_bg = '#28a745'
|
|
body_bg = '#f0fff0'
|
|
else:
|
|
status_text = '<span style="color: orange;">User created but email could not be sent</span>'
|
|
border_color = '#fd7e14'
|
|
header_bg = '#fd7e14'
|
|
body_bg = '#fff8f0'
|
|
|
|
chatter_msg = Markup(
|
|
f'<div style="border: 1px solid {border_color}; border-radius: 4px; overflow: hidden; margin: 8px 0;">'
|
|
f'<div style="background: {header_bg}; color: white; padding: 10px 12px; font-weight: 600;">'
|
|
'<i class="fa fa-envelope"></i> Portal Access Granted'
|
|
'</div>'
|
|
f'<div style="padding: 12px; background: {body_bg};">'
|
|
'<table style="font-size: 13px; width: 100%;">'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Email:</td><td>{self.email}</td></tr>'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Sent by:</td><td>{sent_by}</td></tr>'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Sent at:</td><td>{sent_at}</td></tr>'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">User ID:</td><td>{portal_user.id}</td></tr>'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Status:</td><td>{status_text}</td></tr>'
|
|
'</table>'
|
|
'</div>'
|
|
'</div>'
|
|
)
|
|
|
|
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'<div style="font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Arial,sans-serif;'
|
|
f'max-width:600px;margin:0 auto;color:#2d3748;">'
|
|
f'<div style="height:4px;background-color:#2B6CB0;"></div>'
|
|
f'<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">'
|
|
f'<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;'
|
|
f'text-transform:uppercase;margin:0 0 24px 0;">{company_name}</p>'
|
|
f'<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;">Portal Invitation</h2>'
|
|
f'<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">'
|
|
f'Dear {partner_name}, {invite_text} to access the <strong>{company_name} Portal</strong>.</p>'
|
|
f'<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:0 0 20px 0;">'
|
|
f'With the portal you can:</p>'
|
|
f'<ul style="color:#2d3748;font-size:14px;line-height:1.8;margin:0 0 24px 0;padding-left:20px;">'
|
|
f'<li>View and manage your assigned cases</li>'
|
|
f'<li>Complete assessments online</li>'
|
|
f'<li>Track application status and progress</li>'
|
|
f'<li>Access important documents</li></ul>'
|
|
f'<p style="text-align:center;margin:28px 0;">'
|
|
f'<a href="{signup_url}" style="display:inline-block;background:#2B6CB0;color:#ffffff;'
|
|
f'padding:12px 28px;text-decoration:none;border-radius:6px;font-size:14px;font-weight:600;">'
|
|
f'Accept Invitation & Set Password</a></p>'
|
|
f'<p style="font-size:12px;color:#718096;text-align:center;margin:0 0 20px 0;">'
|
|
f'If the button does not work, copy this link: '
|
|
f'<a href="{signup_url}" style="color:#2B6CB0;word-break:break-all;">{signup_url}</a></p>'
|
|
f'<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">'
|
|
f'<p style="margin:0;font-size:14px;color:#2d3748;">'
|
|
f'After setting your password, access the portal anytime at: '
|
|
f'<a href="{base_url}/my" style="color:#2B6CB0;">{base_url}/my</a></p></div>'
|
|
f'<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">'
|
|
f'Best regards,<br/><strong>{company_name} Team</strong></p>'
|
|
f'</div>'
|
|
f'<div style="padding:16px 28px;text-align:center;">'
|
|
f'<p style="color:#a0aec0;font-size:11px;margin:0;">'
|
|
f'This is an automated message from {company_name}.</p></div></div>'
|
|
)
|
|
|
|
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(
|
|
'<div style="border: 1px solid #17a2b8; border-radius: 4px; overflow: hidden; margin: 8px 0;">'
|
|
'<div style="background: #17a2b8; color: white; padding: 10px 12px; font-weight: 600;">'
|
|
'<i class="fa fa-refresh"></i> Portal Invitation Resent'
|
|
'</div>'
|
|
'<div style="padding: 12px; background: #f0f9ff;">'
|
|
'<table style="font-size: 13px; width: 100%%;">'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Email:</td><td>{self.email}</td></tr>'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Sent by:</td><td>{sent_by}</td></tr>'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Sent at:</td><td>{sent_at}</td></tr>'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">User ID:</td><td>{portal_user.id}</td></tr>'
|
|
'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Status:</td>'
|
|
'<td style="color: green;">Invitation email resent successfully</td></tr>'
|
|
'</table>'
|
|
'</div>'
|
|
'</div>'
|
|
)
|
|
else:
|
|
chatter_msg = Markup(
|
|
'<div style="border: 1px solid #fd7e14; border-radius: 4px; overflow: hidden; margin: 8px 0;">'
|
|
'<div style="background: #fd7e14; color: white; padding: 10px 12px; font-weight: 600;">'
|
|
'<i class="fa fa-refresh"></i> Portal Invitation Resend Attempted'
|
|
'</div>'
|
|
'<div style="padding: 12px; background: #fff8f0;">'
|
|
'<table style="font-size: 13px; width: 100%%;">'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Email:</td><td>{self.email}</td></tr>'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Sent by:</td><td>{sent_by}</td></tr>'
|
|
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Sent at:</td><td>{sent_at}</td></tr>'
|
|
'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Status:</td>'
|
|
'<td style="color: orange;">Email could not be sent - check mail configuration</td></tr>'
|
|
'</table>'
|
|
'</div>'
|
|
'</div>'
|
|
)
|
|
|
|
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',
|
|
'views': [(False, 'list'), (False, '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',
|
|
'views': [(False, 'list'), (False, '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',
|
|
'views': [(False, 'list'), (False, '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
|