Initial commit
This commit is contained in:
10
fusion_authorizer_portal/models/__init__.py
Normal file
10
fusion_authorizer_portal/models/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import res_partner
|
||||
from . import res_users
|
||||
from . import authorizer_comment
|
||||
from . import adp_document
|
||||
from . import assessment
|
||||
from . import accessibility_assessment
|
||||
from . import sale_order
|
||||
from . import pdf_template
|
||||
874
fusion_authorizer_portal/models/accessibility_assessment.py
Normal file
874
fusion_authorizer_portal/models/accessibility_assessment.py
Normal file
@@ -0,0 +1,874 @@
|
||||
# -*- 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'''
|
||||
<div class="alert alert-secondary">
|
||||
<strong><i class="fa fa-camera"></i> Assessment Photos</strong><br/>
|
||||
{len(attachment_ids)} photo(s) from {type_label} Assessment ({self.reference})
|
||||
</div>
|
||||
'''),
|
||||
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'<strong>{self.client_name}</strong>. 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'''
|
||||
<div class="alert alert-info" role="alert">
|
||||
<h5 class="alert-heading"><i class="fa fa-wheelchair"></i> Accessibility Assessment: {type_label}</h5>
|
||||
<p><strong>Reference:</strong> {self.reference}<br/>
|
||||
<strong>Client:</strong> {self.client_name}<br/>
|
||||
<strong>Address:</strong> {self.client_address or 'N/A'}<br/>
|
||||
<strong>Date:</strong> {self.assessment_date}</p>
|
||||
'''
|
||||
|
||||
# Add type-specific details
|
||||
if self.assessment_type == 'stairlift_straight':
|
||||
html += f'''
|
||||
<hr>
|
||||
<p><strong>Straight Stair Lift Details:</strong></p>
|
||||
<ul>
|
||||
<li>Steps: {self.stair_steps or 'N/A'}</li>
|
||||
<li>Nose to Nose: {self.stair_nose_to_nose or 0}" per step</li>
|
||||
<li>Installation Side: {self.stair_side or 'N/A'}</li>
|
||||
<li>Style: {dict(self._fields['stair_style'].selection or {}).get(self.stair_style, 'N/A')}</li>
|
||||
<li>Calculated Track Length: {self.stair_calculated_length:.1f}"</li>
|
||||
<li>Final Track Length: {self.stair_final_length:.1f}"</li>
|
||||
</ul>
|
||||
<p><strong>Features:</strong></p>
|
||||
<ul>
|
||||
{'<li>Power Swivel (Upstairs)</li>' if self.stair_power_swivel_upstairs else ''}
|
||||
{'<li>Power Folding Footrest</li>' if self.stair_power_folding_footrest else ''}
|
||||
</ul>
|
||||
'''
|
||||
|
||||
elif self.assessment_type == 'stairlift_curved':
|
||||
# Format landing types for display
|
||||
top_landing_display = dict(self._fields['stair_top_landing_type'].selection or {}).get(self.stair_top_landing_type, 'Standard')
|
||||
bottom_landing_display = dict(self._fields['stair_bottom_landing_type'].selection or {}).get(self.stair_bottom_landing_type, 'Standard')
|
||||
|
||||
# Add custom overrun values if applicable
|
||||
if self.stair_top_landing_type == 'vertical_overrun' and self.top_overrun_custom_length:
|
||||
top_landing_display += f' ({self.top_overrun_custom_length:.1f}")'
|
||||
if self.stair_bottom_landing_type == 'horizontal_overrun' and self.bottom_overrun_custom_length:
|
||||
bottom_landing_display += f' ({self.bottom_overrun_custom_length:.1f}")'
|
||||
|
||||
html += f'''
|
||||
<hr>
|
||||
<p><strong>Curved Stair Lift Details:</strong></p>
|
||||
<ul>
|
||||
<li>Steps: {self.stair_curved_steps or 'N/A'}</li>
|
||||
<li>Number of Curves: {self.stair_curves_count or 0}</li>
|
||||
<li>Top Landing: {top_landing_display}</li>
|
||||
<li>Bottom Landing: {bottom_landing_display}</li>
|
||||
<li>Calculated Track Length: {self.stair_curved_calculated_length:.1f}"</li>
|
||||
<li>Final Track Length: {self.stair_curved_final_length:.1f}"</li>
|
||||
</ul>
|
||||
<p><strong>Features:</strong></p>
|
||||
<ul>
|
||||
{'<li>Power Swivel (Upstairs)</li>' if self.stair_power_swivel_upstairs else ''}
|
||||
{'<li>Power Swivel (Downstairs)</li>' if self.stair_power_swivel_downstairs else ''}
|
||||
{'<li>Auto Folding Footrest</li>' if self.stair_auto_folding_footrest else ''}
|
||||
{'<li>Auto Folding Hinge</li>' if self.stair_auto_folding_hinge else ''}
|
||||
{'<li>Auto Folding Seat</li>' if self.stair_auto_folding_seat else ''}
|
||||
{'<li>Customizable Color</li>' if self.stair_custom_color else ''}
|
||||
{'<li>Additional Charging Station</li>' if self.stair_additional_charging else ''}
|
||||
{'<li>Charging with Remote</li>' if self.stair_charging_with_remote else ''}
|
||||
</ul>
|
||||
'''
|
||||
|
||||
elif self.assessment_type == 'vpl':
|
||||
html += f'''
|
||||
<hr>
|
||||
<p><strong>Vertical Platform Lift Details:</strong></p>
|
||||
<ul>
|
||||
<li>Room Dimensions: {self.vpl_room_width or 0}" W x {self.vpl_room_depth or 0}" D</li>
|
||||
<li>Rise Height: {self.vpl_rise_height or 0}"</li>
|
||||
<li>Existing Platform: {'Yes' if self.vpl_has_existing_platform else 'No'}</li>
|
||||
<li>Concrete Depth: {self.vpl_concrete_depth or 0}" (min 4" required)</li>
|
||||
<li>Model Type: {dict(self._fields['vpl_model_type'].selection or {}).get(self.vpl_model_type, 'N/A')}</li>
|
||||
<li>Power Plug Nearby: {'Yes' if self.vpl_has_nearby_plug else 'No'}</li>
|
||||
<li>Needs Plug Installation: {'Yes' if self.vpl_needs_plug_install else 'No'}</li>
|
||||
<li>Needs Certification: {'Yes' if self.vpl_needs_certification else 'No'}</li>
|
||||
</ul>
|
||||
'''
|
||||
|
||||
elif self.assessment_type == 'ceiling_lift':
|
||||
html += f'''
|
||||
<hr>
|
||||
<p><strong>Ceiling Lift Details:</strong></p>
|
||||
<ul>
|
||||
<li>Track Length: {self.ceiling_track_length or 0} feet</li>
|
||||
<li>Movement Type: {dict(self._fields['ceiling_movement_type'].selection or {}).get(self.ceiling_movement_type, 'N/A')}</li>
|
||||
<li>Charging Throughout Track: {'Yes' if self.ceiling_charging_throughout else 'No'}</li>
|
||||
<li>Carry Bar: {'Yes' if self.ceiling_carry_bar else 'No'}</li>
|
||||
<li>Additional Slings: {self.ceiling_additional_slings or 0}</li>
|
||||
</ul>
|
||||
'''
|
||||
|
||||
elif self.assessment_type == 'ramp':
|
||||
html += f'''
|
||||
<hr>
|
||||
<p><strong>Custom Ramp Details:</strong></p>
|
||||
<ul>
|
||||
<li>Height: {self.ramp_height or 0}" from ground</li>
|
||||
<li>Ground Incline: {self.ramp_ground_incline or 0}°</li>
|
||||
<li>At Door: {'Yes (5ft landing required)' if self.ramp_at_door else 'No'}</li>
|
||||
<li>Calculated Ramp Length: {self.ramp_calculated_length:.1f}" ({self.ramp_calculated_length/12:.1f} ft)</li>
|
||||
<li>Landings Needed: {self.ramp_landings_needed or 0} (5ft each)</li>
|
||||
<li>Total Length with Landings: {self.ramp_total_length:.1f}" ({self.ramp_total_length/12:.1f} ft)</li>
|
||||
<li>Handrail Height: {self.ramp_handrail_height or 32}"</li>
|
||||
</ul>
|
||||
'''
|
||||
|
||||
elif self.assessment_type == 'bathroom':
|
||||
html += f'''
|
||||
<hr>
|
||||
<p><strong>Bathroom Modification Description:</strong></p>
|
||||
<p>{self.bathroom_description or 'No description provided.'}</p>
|
||||
'''
|
||||
|
||||
elif self.assessment_type == 'tub_cutout':
|
||||
html += f'''
|
||||
<hr>
|
||||
<p><strong>Tub Cutout Details:</strong></p>
|
||||
<ul>
|
||||
<li>Internal Height: {self.tub_internal_height or 0}"</li>
|
||||
<li>External Height: {self.tub_external_height or 0}"</li>
|
||||
</ul>
|
||||
<p><strong>Additional Supplies:</strong></p>
|
||||
<p>{self.tub_additional_supplies or 'None specified.'}</p>
|
||||
'''
|
||||
|
||||
# Add general notes
|
||||
if self.notes:
|
||||
html += f'''
|
||||
<hr>
|
||||
<p><strong>Notes:</strong></p>
|
||||
<p>{self.notes}</p>
|
||||
'''
|
||||
|
||||
html += '</div>'
|
||||
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'})
|
||||
183
fusion_authorizer_portal/models/adp_document.py
Normal file
183
fusion_authorizer_portal/models/adp_document.py
Normal file
@@ -0,0 +1,183 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
import base64
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ADPDocument(models.Model):
|
||||
_name = 'fusion.adp.document'
|
||||
_description = 'ADP Application Document'
|
||||
_order = 'upload_date desc, revision desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
# Relationships
|
||||
sale_order_id = fields.Many2one(
|
||||
'sale.order',
|
||||
string='Sale Order',
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
|
||||
assessment_id = fields.Many2one(
|
||||
'fusion.assessment',
|
||||
string='Assessment',
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Document Type
|
||||
document_type = fields.Selection([
|
||||
('full_application', 'Full ADP Application (14 pages)'),
|
||||
('pages_11_12', 'Pages 11 & 12 (Signature Pages)'),
|
||||
('page_11', 'Page 11 Only (Authorizer Signature)'),
|
||||
('page_12', 'Page 12 Only (Client Signature)'),
|
||||
('submitted_final', 'Final Submitted Application'),
|
||||
('assessment_report', 'Assessment Report'),
|
||||
('assessment_signed', 'Signed Pages from Assessment'),
|
||||
('other', 'Other Document'),
|
||||
], string='Document Type', required=True, default='full_application')
|
||||
|
||||
# File Data
|
||||
file = fields.Binary(
|
||||
string='File',
|
||||
required=True,
|
||||
attachment=True,
|
||||
)
|
||||
|
||||
filename = fields.Char(
|
||||
string='Filename',
|
||||
required=True,
|
||||
)
|
||||
|
||||
file_size = fields.Integer(
|
||||
string='File Size (bytes)',
|
||||
compute='_compute_file_size',
|
||||
store=True,
|
||||
)
|
||||
|
||||
mimetype = fields.Char(
|
||||
string='MIME Type',
|
||||
default='application/pdf',
|
||||
)
|
||||
|
||||
# Revision Tracking
|
||||
revision = fields.Integer(
|
||||
string='Revision',
|
||||
default=1,
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
revision_note = fields.Text(
|
||||
string='Revision Note',
|
||||
help='Notes about what changed in this revision',
|
||||
)
|
||||
|
||||
is_current = fields.Boolean(
|
||||
string='Is Current Version',
|
||||
default=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Upload Information
|
||||
uploaded_by = fields.Many2one(
|
||||
'res.users',
|
||||
string='Uploaded By',
|
||||
default=lambda self: self.env.user,
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
upload_date = fields.Datetime(
|
||||
string='Upload Date',
|
||||
default=fields.Datetime.now,
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
source = fields.Selection([
|
||||
('authorizer', 'Authorizer Portal'),
|
||||
('sales_rep', 'Sales Rep Portal'),
|
||||
('internal', 'Internal User'),
|
||||
('assessment', 'Assessment Form'),
|
||||
], string='Source', default='internal')
|
||||
|
||||
# Display
|
||||
display_name = fields.Char(
|
||||
string='Display Name',
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.depends('file')
|
||||
def _compute_file_size(self):
|
||||
for doc in self:
|
||||
if doc.file:
|
||||
doc.file_size = len(base64.b64decode(doc.file))
|
||||
else:
|
||||
doc.file_size = 0
|
||||
|
||||
@api.depends('document_type', 'filename', 'revision')
|
||||
def _compute_display_name(self):
|
||||
type_labels = dict(self._fields['document_type'].selection)
|
||||
for doc in self:
|
||||
type_label = type_labels.get(doc.document_type, doc.document_type)
|
||||
doc.display_name = f"{type_label} - v{doc.revision} ({doc.filename or 'No file'})"
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Override create to handle revision numbering"""
|
||||
for vals in vals_list:
|
||||
# Find existing documents of the same type for the same order/assessment
|
||||
domain = [('document_type', '=', vals.get('document_type'))]
|
||||
|
||||
if vals.get('sale_order_id'):
|
||||
domain.append(('sale_order_id', '=', vals.get('sale_order_id')))
|
||||
if vals.get('assessment_id'):
|
||||
domain.append(('assessment_id', '=', vals.get('assessment_id')))
|
||||
|
||||
existing = self.search(domain, order='revision desc', limit=1)
|
||||
|
||||
if existing:
|
||||
# Mark existing as not current and increment revision
|
||||
existing.is_current = False
|
||||
vals['revision'] = existing.revision + 1
|
||||
else:
|
||||
vals['revision'] = 1
|
||||
|
||||
vals['is_current'] = True
|
||||
|
||||
return super().create(vals_list)
|
||||
|
||||
def action_download(self):
|
||||
"""Download the document"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': f'/web/content/{self._name}/{self.id}/file/{self.filename}?download=true',
|
||||
'target': 'self',
|
||||
}
|
||||
|
||||
def get_document_url(self):
|
||||
"""Get the download URL for portal access"""
|
||||
self.ensure_one()
|
||||
return f'/my/authorizer/document/{self.id}/download'
|
||||
|
||||
@api.model
|
||||
def get_documents_for_order(self, sale_order_id, document_type=None, current_only=True):
|
||||
"""Get documents for a sale order, optionally filtered by type"""
|
||||
domain = [('sale_order_id', '=', sale_order_id)]
|
||||
if document_type:
|
||||
domain.append(('document_type', '=', document_type))
|
||||
if current_only:
|
||||
domain.append(('is_current', '=', True))
|
||||
return self.search(domain, order='document_type, revision desc')
|
||||
|
||||
@api.model
|
||||
def get_revision_history(self, sale_order_id, document_type):
|
||||
"""Get all revisions of a specific document type"""
|
||||
return self.search([
|
||||
('sale_order_id', '=', sale_order_id),
|
||||
('document_type', '=', document_type),
|
||||
], order='revision desc')
|
||||
1621
fusion_authorizer_portal/models/assessment.py
Normal file
1621
fusion_authorizer_portal/models/assessment.py
Normal file
File diff suppressed because it is too large
Load Diff
85
fusion_authorizer_portal/models/authorizer_comment.py
Normal file
85
fusion_authorizer_portal/models/authorizer_comment.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthorizerComment(models.Model):
|
||||
_name = 'fusion.authorizer.comment'
|
||||
_description = 'Authorizer/Sales Rep Comment'
|
||||
_order = 'create_date desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
sale_order_id = fields.Many2one(
|
||||
'sale.order',
|
||||
string='Sale Order',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
|
||||
assessment_id = fields.Many2one(
|
||||
'fusion.assessment',
|
||||
string='Assessment',
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
|
||||
author_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Author',
|
||||
required=True,
|
||||
default=lambda self: self.env.user.partner_id,
|
||||
index=True,
|
||||
)
|
||||
|
||||
author_user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Author User',
|
||||
default=lambda self: self.env.user,
|
||||
index=True,
|
||||
)
|
||||
|
||||
comment = fields.Text(
|
||||
string='Comment',
|
||||
required=True,
|
||||
)
|
||||
|
||||
comment_type = fields.Selection([
|
||||
('general', 'General Comment'),
|
||||
('question', 'Question'),
|
||||
('update', 'Status Update'),
|
||||
('internal', 'Internal Note'),
|
||||
], string='Type', default='general')
|
||||
|
||||
is_internal = fields.Boolean(
|
||||
string='Internal Only',
|
||||
default=False,
|
||||
help='If checked, this comment will not be visible to portal users',
|
||||
)
|
||||
|
||||
display_name = fields.Char(
|
||||
string='Display Name',
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.depends('author_id', 'create_date')
|
||||
def _compute_display_name(self):
|
||||
for comment in self:
|
||||
if comment.author_id and comment.create_date:
|
||||
comment.display_name = f"{comment.author_id.name} - {comment.create_date.strftime('%Y-%m-%d %H:%M')}"
|
||||
else:
|
||||
comment.display_name = _('New Comment')
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Override create to set author from current user if not provided"""
|
||||
for vals in vals_list:
|
||||
if not vals.get('author_id'):
|
||||
vals['author_id'] = self.env.user.partner_id.id
|
||||
if not vals.get('author_user_id'):
|
||||
vals['author_user_id'] = self.env.user.id
|
||||
return super().create(vals_list)
|
||||
322
fusion_authorizer_portal/models/pdf_template.py
Normal file
322
fusion_authorizer_portal/models/pdf_template.py
Normal file
@@ -0,0 +1,322 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Fusion PDF Template Engine
|
||||
# Generic system for filling any funding agency's PDF forms
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from io import BytesIO
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionPdfTemplate(models.Model):
|
||||
_name = 'fusion.pdf.template'
|
||||
_description = 'PDF Form Template'
|
||||
_order = 'category, name'
|
||||
|
||||
name = fields.Char(string='Template Name', required=True)
|
||||
category = fields.Selection([
|
||||
('adp', 'ADP - Assistive Devices Program'),
|
||||
('mod', 'March of Dimes'),
|
||||
('odsp', 'ODSP'),
|
||||
('hardship', 'Hardship Funding'),
|
||||
('other', 'Other'),
|
||||
], string='Funding Agency', required=True, default='adp')
|
||||
version = fields.Char(string='Form Version', default='1.0')
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('active', 'Active'),
|
||||
('archived', 'Archived'),
|
||||
], string='Status', default='draft', tracking=True)
|
||||
|
||||
# The actual PDF template file
|
||||
pdf_file = fields.Binary(string='PDF Template', required=True, attachment=True)
|
||||
pdf_filename = fields.Char(string='PDF Filename')
|
||||
page_count = fields.Integer(
|
||||
string='Page Count',
|
||||
compute='_compute_page_count',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# Page preview images for the visual editor
|
||||
preview_ids = fields.One2many(
|
||||
'fusion.pdf.template.preview', 'template_id',
|
||||
string='Page Previews',
|
||||
)
|
||||
|
||||
# Field positions configured via the visual editor
|
||||
field_ids = fields.One2many(
|
||||
'fusion.pdf.template.field', 'template_id',
|
||||
string='Template Fields',
|
||||
)
|
||||
field_count = fields.Integer(
|
||||
string='Fields',
|
||||
compute='_compute_field_count',
|
||||
)
|
||||
|
||||
notes = fields.Text(
|
||||
string='Notes',
|
||||
help='Usage notes, which assessments/forms use this template',
|
||||
)
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if 'pdf_file' in vals and vals['pdf_file']:
|
||||
for rec in self:
|
||||
try:
|
||||
rec.action_generate_previews()
|
||||
except Exception as e:
|
||||
_logger.warning("Auto preview generation failed for %s: %s", rec.name, e)
|
||||
return res
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super().create(vals_list)
|
||||
for rec in records:
|
||||
if rec.pdf_file:
|
||||
try:
|
||||
rec.action_generate_previews()
|
||||
except Exception as e:
|
||||
_logger.warning("Auto preview generation failed for %s: %s", rec.name, e)
|
||||
return records
|
||||
|
||||
@api.depends('pdf_file')
|
||||
def _compute_page_count(self):
|
||||
for rec in self:
|
||||
if rec.pdf_file:
|
||||
try:
|
||||
from odoo.tools.pdf import PdfFileReader
|
||||
pdf_data = base64.b64decode(rec.pdf_file)
|
||||
reader = PdfFileReader(BytesIO(pdf_data))
|
||||
rec.page_count = reader.getNumPages()
|
||||
except Exception as e:
|
||||
_logger.warning("Could not read PDF page count: %s", e)
|
||||
rec.page_count = 0
|
||||
else:
|
||||
rec.page_count = 0
|
||||
|
||||
def action_generate_previews(self):
|
||||
"""Generate PNG preview images from the PDF using poppler (pdftoppm).
|
||||
Falls back gracefully if the PDF is protected or poppler is not available.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.pdf_file:
|
||||
raise UserError(_('Please upload a PDF file first.'))
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
pdf_data = base64.b64decode(self.pdf_file)
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
pdf_path = os.path.join(tmpdir, 'template.pdf')
|
||||
with open(pdf_path, 'wb') as f:
|
||||
f.write(pdf_data)
|
||||
|
||||
# Use pdftoppm to convert each page to PNG
|
||||
result = subprocess.run(
|
||||
['pdftoppm', '-png', '-r', '200', pdf_path, os.path.join(tmpdir, 'page')],
|
||||
capture_output=True, timeout=30,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode('utf-8', errors='replace')
|
||||
_logger.warning("pdftoppm failed: %s", stderr)
|
||||
raise UserError(_(
|
||||
'Could not generate previews automatically. '
|
||||
'The PDF may be protected. Please upload preview images manually '
|
||||
'in the Page Previews tab (screenshots of each page).'
|
||||
))
|
||||
|
||||
# Find generated PNG files
|
||||
png_files = sorted([
|
||||
f for f in os.listdir(tmpdir)
|
||||
if f.startswith('page-') and f.endswith('.png')
|
||||
])
|
||||
|
||||
if not png_files:
|
||||
raise UserError(_('No pages were generated. Please upload preview images manually.'))
|
||||
|
||||
# Delete existing previews
|
||||
self.preview_ids.unlink()
|
||||
|
||||
# Create preview records
|
||||
for idx, png_file in enumerate(png_files):
|
||||
png_path = os.path.join(tmpdir, png_file)
|
||||
with open(png_path, 'rb') as f:
|
||||
image_data = base64.b64encode(f.read())
|
||||
|
||||
self.env['fusion.pdf.template.preview'].create({
|
||||
'template_id': self.id,
|
||||
'page': idx + 1,
|
||||
'image': image_data,
|
||||
'image_filename': f'page_{idx + 1}.png',
|
||||
})
|
||||
|
||||
_logger.info("Generated %d preview images for template %s", len(png_files), self.name)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
raise UserError(_('PDF conversion timed out. Please upload preview images manually.'))
|
||||
except FileNotFoundError:
|
||||
raise UserError(_(
|
||||
'poppler-utils (pdftoppm) is not installed on the server. '
|
||||
'Please upload preview images manually in the Page Previews tab.'
|
||||
))
|
||||
|
||||
@api.depends('field_ids')
|
||||
def _compute_field_count(self):
|
||||
for rec in self:
|
||||
rec.field_count = len(rec.field_ids)
|
||||
|
||||
def action_activate(self):
|
||||
"""Set template to active."""
|
||||
self.ensure_one()
|
||||
if not self.pdf_file:
|
||||
raise UserError(_('Please upload a PDF file before activating.'))
|
||||
self.state = 'active'
|
||||
|
||||
def action_archive(self):
|
||||
"""Archive the template."""
|
||||
self.ensure_one()
|
||||
self.state = 'archived'
|
||||
|
||||
def action_reset_draft(self):
|
||||
"""Reset to draft."""
|
||||
self.ensure_one()
|
||||
self.state = 'draft'
|
||||
|
||||
def action_open_field_editor(self):
|
||||
"""Open the visual field position editor."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': f'/fusion/pdf-editor/{self.id}',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def generate_filled_pdf(self, context_data, signatures=None):
|
||||
"""Generate a filled PDF using this template and the provided data.
|
||||
|
||||
Args:
|
||||
context_data: flat dict of {field_key: value}
|
||||
signatures: dict of {field_key: binary_png} for signature fields
|
||||
|
||||
Returns:
|
||||
bytes of the filled PDF
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.pdf_file:
|
||||
raise UserError(_('Template has no PDF file.'))
|
||||
if self.state != 'active':
|
||||
_logger.warning("Generating PDF from non-active template %s", self.name)
|
||||
|
||||
from ..utils.pdf_filler import PDFTemplateFiller
|
||||
|
||||
template_bytes = base64.b64decode(self.pdf_file)
|
||||
|
||||
# Build fields_by_page dict
|
||||
fields_by_page = {}
|
||||
for field in self.field_ids.filtered(lambda f: f.is_active):
|
||||
page = field.page
|
||||
if page not in fields_by_page:
|
||||
fields_by_page[page] = []
|
||||
fields_by_page[page].append({
|
||||
'field_name': field.name,
|
||||
'field_key': field.field_key or field.name,
|
||||
'pos_x': field.pos_x,
|
||||
'pos_y': field.pos_y,
|
||||
'width': field.width,
|
||||
'height': field.height,
|
||||
'field_type': field.field_type,
|
||||
'font_size': field.font_size,
|
||||
'font_name': field.font_name or 'Helvetica',
|
||||
})
|
||||
|
||||
return PDFTemplateFiller.fill_template(
|
||||
template_bytes, fields_by_page, context_data, signatures
|
||||
)
|
||||
|
||||
|
||||
class FusionPdfTemplatePreview(models.Model):
|
||||
_name = 'fusion.pdf.template.preview'
|
||||
_description = 'PDF Template Page Preview'
|
||||
_order = 'page'
|
||||
|
||||
template_id = fields.Many2one(
|
||||
'fusion.pdf.template', string='Template',
|
||||
required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
page = fields.Integer(string='Page Number', required=True, default=1)
|
||||
image = fields.Binary(string='Page Image (PNG)', attachment=True)
|
||||
image_filename = fields.Char(string='Image Filename')
|
||||
|
||||
|
||||
class FusionPdfTemplateField(models.Model):
|
||||
_name = 'fusion.pdf.template.field'
|
||||
_description = 'PDF Template Field'
|
||||
_order = 'page, sequence'
|
||||
|
||||
template_id = fields.Many2one(
|
||||
'fusion.pdf.template', string='Template',
|
||||
required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
name = fields.Char(
|
||||
string='Field Name', required=True,
|
||||
help='Internal identifier, e.g. client_last_name',
|
||||
)
|
||||
label = fields.Char(
|
||||
string='Display Label',
|
||||
help='Human-readable label shown in the editor, e.g. "Last Name"',
|
||||
)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
page = fields.Integer(string='Page', default=1, required=True)
|
||||
|
||||
# Percentage-based positioning (0.0 to 1.0) -- same as sign.item
|
||||
pos_x = fields.Float(
|
||||
string='Position X', digits=(4, 3),
|
||||
help='Horizontal position as ratio (0.0 = left edge, 1.0 = right edge)',
|
||||
)
|
||||
pos_y = fields.Float(
|
||||
string='Position Y', digits=(4, 3),
|
||||
help='Vertical position as ratio (0.0 = top edge, 1.0 = bottom edge)',
|
||||
)
|
||||
width = fields.Float(
|
||||
string='Width', digits=(4, 3), default=0.150,
|
||||
help='Width as ratio of page width',
|
||||
)
|
||||
height = fields.Float(
|
||||
string='Height', digits=(4, 3), default=0.015,
|
||||
help='Height as ratio of page height',
|
||||
)
|
||||
|
||||
# Rendering settings
|
||||
field_type = fields.Selection([
|
||||
('text', 'Text'),
|
||||
('checkbox', 'Checkbox'),
|
||||
('signature', 'Signature Image'),
|
||||
('date', 'Date'),
|
||||
], string='Field Type', default='text', required=True)
|
||||
font_size = fields.Float(string='Font Size', default=10.0)
|
||||
font_name = fields.Selection([
|
||||
('Helvetica', 'Helvetica'),
|
||||
('Courier', 'Courier'),
|
||||
('Times-Roman', 'Times Roman'),
|
||||
], string='Font', default='Helvetica')
|
||||
|
||||
# Data mapping
|
||||
field_key = fields.Char(
|
||||
string='Data Key',
|
||||
help='Key to look up in the data context dict.\n'
|
||||
'Examples: client_last_name, client_health_card, consent_date, signature_page_11\n'
|
||||
'The generating code passes a flat dict of all available data.',
|
||||
)
|
||||
default_value = fields.Char(
|
||||
string='Default Value',
|
||||
help='Fallback value if field_key returns empty',
|
||||
)
|
||||
is_active = fields.Boolean(string='Active', default=True)
|
||||
764
fusion_authorizer_portal/models/res_partner.py
Normal file
764
fusion_authorizer_portal/models/res_partner.py
Normal file
@@ -0,0 +1,764 @@
|
||||
# -*- 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(
|
||||
'<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',
|
||||
'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
|
||||
119
fusion_authorizer_portal/models/res_users.py
Normal file
119
fusion_authorizer_portal/models/res_users.py
Normal file
@@ -0,0 +1,119 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, models, _
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PortalWizardUser(models.TransientModel):
|
||||
"""Override standard portal wizard to handle internal users with Fusion roles."""
|
||||
_inherit = 'portal.wizard.user'
|
||||
|
||||
def action_grant_access(self):
|
||||
"""Override: Handle Fusion portal roles when granting portal access.
|
||||
- Internal users with Fusion roles: assign backend groups, skip portal.
|
||||
- Portal users with Fusion roles: standard flow + assign role groups.
|
||||
"""
|
||||
self.ensure_one()
|
||||
partner = self.partner_id
|
||||
|
||||
# Check if the partner has any Fusion portal flags
|
||||
has_fusion_role = getattr(partner, 'is_technician_portal', False) or \
|
||||
getattr(partner, 'is_authorizer', False) or \
|
||||
getattr(partner, 'is_sales_rep_portal', False)
|
||||
|
||||
# Find the linked user
|
||||
user = self.user_id
|
||||
if user and user._is_internal() and has_fusion_role:
|
||||
# Internal user with Fusion roles -- assign backend groups, no portal
|
||||
partner._assign_internal_role_groups(user)
|
||||
partner.authorizer_portal_user_id = user
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Internal User Updated'),
|
||||
'message': _('%s is an internal user. Backend permissions updated (no portal access needed).') % partner.name,
|
||||
'type': 'info',
|
||||
'sticky': True,
|
||||
}
|
||||
}
|
||||
|
||||
# Standard Odoo portal flow (creates user, sends email, etc.)
|
||||
result = super().action_grant_access()
|
||||
|
||||
# After standard flow, assign Fusion portal role groups
|
||||
if has_fusion_role:
|
||||
portal_user = self.user_id
|
||||
if not portal_user:
|
||||
# Fallback: find the user that was just created
|
||||
portal_user = self.env['res.users'].sudo().search([
|
||||
('partner_id', '=', partner.id),
|
||||
('share', '=', True),
|
||||
('active', '=', True),
|
||||
], limit=1)
|
||||
if portal_user:
|
||||
partner._assign_portal_role_groups(portal_user)
|
||||
if not partner.authorizer_portal_user_id:
|
||||
partner.authorizer_portal_user_id = portal_user
|
||||
_logger.info("Assigned Fusion portal role groups to user %s (partner: %s)",
|
||||
portal_user.login, partner.name)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
def _generate_tutorial_articles(self):
|
||||
"""Override to create custom welcome articles for internal staff
|
||||
instead of the default Odoo Knowledge onboarding article.
|
||||
"""
|
||||
if 'knowledge.article' not in self.env:
|
||||
return super()._generate_tutorial_articles()
|
||||
|
||||
for user in self:
|
||||
company = user.company_id or self.env.company
|
||||
render_ctx = {
|
||||
'user_name': user.name or 'Team Member',
|
||||
'company_name': company.name or 'Our Company',
|
||||
'company_email': company.email or '',
|
||||
'company_phone': company.phone or '',
|
||||
}
|
||||
|
||||
try:
|
||||
body = self.env['ir.qweb']._render(
|
||||
'fusion_authorizer_portal.welcome_article_internal',
|
||||
render_ctx,
|
||||
minimal_qcontext=True,
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
|
||||
if not body:
|
||||
_logger.warning("Internal staff welcome template not found, using default")
|
||||
return super()._generate_tutorial_articles()
|
||||
|
||||
self.env['knowledge.article'].sudo().create({
|
||||
'name': f"Welcome {user.name} - {company.name}",
|
||||
'icon': '🏢',
|
||||
'body': body,
|
||||
'internal_permission': 'none',
|
||||
'is_article_visible_by_everyone': False,
|
||||
'article_member_ids': [(0, 0, {
|
||||
'partner_id': user.partner_id.id,
|
||||
'permission': 'write',
|
||||
})],
|
||||
'favorite_ids': [(0, 0, {
|
||||
'sequence': 0,
|
||||
'user_id': user.id,
|
||||
})],
|
||||
})
|
||||
|
||||
_logger.info(f"Created custom welcome article for internal user {user.name}")
|
||||
|
||||
except Exception as e:
|
||||
_logger.warning(f"Failed to create custom welcome article for {user.name}: {e}")
|
||||
# Fall back to default
|
||||
super(ResUsers, user)._generate_tutorial_articles()
|
||||
250
fusion_authorizer_portal/models/sale_order.py
Normal file
250
fusion_authorizer_portal/models/sale_order.py
Normal file
@@ -0,0 +1,250 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
# Comments from portal users
|
||||
portal_comment_ids = fields.One2many(
|
||||
'fusion.authorizer.comment',
|
||||
'sale_order_id',
|
||||
string='Portal Comments',
|
||||
)
|
||||
|
||||
portal_comment_count = fields.Integer(
|
||||
string='Comment Count',
|
||||
compute='_compute_portal_comment_count',
|
||||
)
|
||||
|
||||
# Documents uploaded via portal
|
||||
portal_document_ids = fields.One2many(
|
||||
'fusion.adp.document',
|
||||
'sale_order_id',
|
||||
string='Portal Documents',
|
||||
)
|
||||
|
||||
portal_document_count = fields.Integer(
|
||||
string='Document Count',
|
||||
compute='_compute_portal_document_count',
|
||||
)
|
||||
|
||||
# Link to assessment
|
||||
assessment_id = fields.Many2one(
|
||||
'fusion.assessment',
|
||||
string='Source Assessment',
|
||||
readonly=True,
|
||||
help='The assessment that created this sale order',
|
||||
)
|
||||
|
||||
# Authorizer helper field (consolidates multiple possible fields)
|
||||
portal_authorizer_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Authorizer (Portal)',
|
||||
compute='_compute_portal_authorizer_id',
|
||||
store=True,
|
||||
help='Consolidated authorizer field for portal access',
|
||||
)
|
||||
|
||||
@api.depends('portal_comment_ids')
|
||||
def _compute_portal_comment_count(self):
|
||||
for order in self:
|
||||
order.portal_comment_count = len(order.portal_comment_ids)
|
||||
|
||||
@api.depends('portal_document_ids')
|
||||
def _compute_portal_document_count(self):
|
||||
for order in self:
|
||||
order.portal_document_count = len(order.portal_document_ids)
|
||||
|
||||
@api.depends('x_fc_authorizer_id')
|
||||
def _compute_portal_authorizer_id(self):
|
||||
"""Get authorizer from x_fc_authorizer_id field"""
|
||||
for order in self:
|
||||
order.portal_authorizer_id = order.x_fc_authorizer_id
|
||||
|
||||
def write(self, vals):
|
||||
"""Override write to send notification when authorizer is assigned."""
|
||||
old_authorizers = {
|
||||
order.id: order.x_fc_authorizer_id.id if order.x_fc_authorizer_id else False
|
||||
for order in self
|
||||
}
|
||||
|
||||
result = super().write(vals)
|
||||
|
||||
# Check for authorizer changes
|
||||
if 'x_fc_authorizer_id' in vals:
|
||||
for order in self:
|
||||
old_auth = old_authorizers.get(order.id)
|
||||
new_auth = vals.get('x_fc_authorizer_id')
|
||||
if new_auth and new_auth != old_auth:
|
||||
order._send_authorizer_assignment_notification()
|
||||
|
||||
# NOTE: Generic status change notifications removed.
|
||||
# Each status transition already sends its own detailed email
|
||||
# from fusion_claims (approval, denial, submission, billed, etc.)
|
||||
# A generic "status changed" email on top was redundant and lacked detail.
|
||||
|
||||
return result
|
||||
|
||||
def action_message_authorizer(self):
|
||||
"""Open composer to send message to authorizer only"""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_authorizer_id:
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Message Authorizer',
|
||||
'res_model': 'mail.compose.message',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_model': 'sale.order',
|
||||
'default_res_ids': [self.id],
|
||||
'default_partner_ids': [self.x_fc_authorizer_id.id],
|
||||
'default_composition_mode': 'comment',
|
||||
'default_subtype_xmlid': 'mail.mt_note',
|
||||
},
|
||||
}
|
||||
|
||||
def _send_authorizer_assignment_notification(self):
|
||||
"""Send email when an authorizer is assigned to the order"""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.x_fc_authorizer_id or not self.x_fc_authorizer_id.email:
|
||||
return
|
||||
|
||||
try:
|
||||
template = self.env.ref('fusion_authorizer_portal.mail_template_case_assigned', raise_if_not_found=False)
|
||||
if template:
|
||||
template.send_mail(self.id, force_send=False)
|
||||
_logger.info(f"Sent case assignment notification to {self.x_fc_authorizer_id.email} for {self.name}")
|
||||
except Exception as e:
|
||||
_logger.error(f"Failed to send authorizer assignment notification: {e}")
|
||||
|
||||
# _send_status_change_notification removed -- redundant.
|
||||
# Each workflow transition in fusion_claims sends its own detailed email.
|
||||
|
||||
def action_view_portal_comments(self):
|
||||
"""View portal comments"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Portal Comments'),
|
||||
'res_model': 'fusion.authorizer.comment',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('sale_order_id', '=', self.id)],
|
||||
'context': {'default_sale_order_id': self.id},
|
||||
}
|
||||
|
||||
def action_view_portal_documents(self):
|
||||
"""View portal documents"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Portal Documents'),
|
||||
'res_model': 'fusion.adp.document',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('sale_order_id', '=', self.id)],
|
||||
'context': {'default_sale_order_id': self.id},
|
||||
}
|
||||
|
||||
def get_portal_display_data(self):
|
||||
"""Get data for portal display, excluding sensitive information"""
|
||||
self.ensure_one()
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'date_order': self.date_order,
|
||||
'state': self.state,
|
||||
'state_display': dict(self._fields['state'].selection).get(self.state, self.state),
|
||||
'partner_name': self.partner_id.name if self.partner_id else '',
|
||||
'partner_address': self._get_partner_address_display(),
|
||||
'client_reference_1': self.x_fc_client_ref_1 or '',
|
||||
'client_reference_2': self.x_fc_client_ref_2 or '',
|
||||
'claim_number': self.x_fc_claim_number or '',
|
||||
'authorizer_name': self.x_fc_authorizer_id.name if self.x_fc_authorizer_id else '',
|
||||
'sales_rep_name': self.user_id.name if self.user_id else '',
|
||||
'product_lines': self._get_product_lines_for_portal(),
|
||||
'comment_count': self.portal_comment_count,
|
||||
'document_count': self.portal_document_count,
|
||||
}
|
||||
|
||||
def _get_partner_address_display(self):
|
||||
"""Get formatted partner address for display"""
|
||||
if not self.partner_id:
|
||||
return ''
|
||||
|
||||
parts = []
|
||||
if self.partner_id.street:
|
||||
parts.append(self.partner_id.street)
|
||||
if self.partner_id.city:
|
||||
city_part = self.partner_id.city
|
||||
if self.partner_id.state_id:
|
||||
city_part += f", {self.partner_id.state_id.name}"
|
||||
if self.partner_id.zip:
|
||||
city_part += f" {self.partner_id.zip}"
|
||||
parts.append(city_part)
|
||||
|
||||
return ', '.join(parts)
|
||||
|
||||
def _get_product_lines_for_portal(self):
|
||||
"""Get product lines for portal display (excluding costs)"""
|
||||
lines = []
|
||||
for line in self.order_line:
|
||||
lines.append({
|
||||
'id': line.id,
|
||||
'product_name': line.product_id.name if line.product_id else line.name,
|
||||
'quantity': line.product_uom_qty,
|
||||
'uom': line.product_uom_id.name if line.product_uom_id else '',
|
||||
'adp_code': line.x_fc_adp_device_code or '' if hasattr(line, 'x_fc_adp_device_code') else '',
|
||||
'device_type': '',
|
||||
'serial_number': line.x_fc_serial_number or '' if hasattr(line, 'x_fc_serial_number') else '',
|
||||
})
|
||||
return lines
|
||||
|
||||
@api.model
|
||||
def get_authorizer_portal_cases(self, partner_id, search_query=None, limit=100, offset=0):
|
||||
"""Get cases for authorizer portal with optional search"""
|
||||
domain = [('x_fc_authorizer_id', '=', partner_id)]
|
||||
|
||||
# Add search if provided
|
||||
if search_query:
|
||||
search_domain = self._build_search_domain(search_query)
|
||||
domain = ['&'] + domain + search_domain
|
||||
|
||||
orders = self.sudo().search(domain, limit=limit, offset=offset, order='date_order desc, id desc')
|
||||
return orders
|
||||
|
||||
@api.model
|
||||
def get_sales_rep_portal_cases(self, user_id, search_query=None, limit=100, offset=0):
|
||||
"""Get cases for sales rep portal with optional search"""
|
||||
domain = [('user_id', '=', user_id)]
|
||||
|
||||
# Add search if provided
|
||||
if search_query:
|
||||
search_domain = self._build_search_domain(search_query)
|
||||
domain = domain + search_domain
|
||||
|
||||
orders = self.sudo().search(domain, limit=limit, offset=offset, order='date_order desc, id desc')
|
||||
return orders
|
||||
|
||||
def _build_search_domain(self, query):
|
||||
"""Build search domain for portal search"""
|
||||
if not query or len(query) < 2:
|
||||
return []
|
||||
|
||||
search_domain = [
|
||||
'|', '|', '|', '|',
|
||||
('partner_id.name', 'ilike', query),
|
||||
('x_fc_claim_number', 'ilike', query),
|
||||
('x_fc_client_ref_1', 'ilike', query),
|
||||
('x_fc_client_ref_2', 'ilike', query),
|
||||
]
|
||||
|
||||
return search_domain
|
||||
Reference in New Issue
Block a user