Files
2026-02-22 01:22:18 -05:00

1444 lines
66 KiB
Python

# -*- coding: utf-8 -*-
from odoo import http, fields, _
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
from odoo.exceptions import AccessError, MissingError, ValidationError
import json
import base64
import logging
from datetime import datetime
from markupsafe import Markup
_logger = logging.getLogger(__name__)
class AssessmentPortal(CustomerPortal):
"""Portal controller for Assessments"""
@http.route(['/my/assessments', '/my/assessments/page/<int:page>'], type='http', auth='user', website=True)
def portal_assessments(self, page=1, search='', state='', sortby='date', **kw):
"""List of assessments"""
partner = request.env.user.partner_id
user = request.env.user
if not partner.is_authorizer and not partner.is_sales_rep_portal:
return request.redirect('/my')
Assessment = request.env['fusion.assessment'].sudo()
# Build domain based on role
domain = []
if partner.is_authorizer and partner.is_sales_rep_portal:
domain = ['|', ('authorizer_id', '=', partner.id), ('sales_rep_id', '=', user.id)]
elif partner.is_authorizer:
domain = [('authorizer_id', '=', partner.id)]
elif partner.is_sales_rep_portal:
domain = [('sales_rep_id', '=', user.id)]
# Add state filter
if state:
domain.append(('state', '=', state))
# Add search filter
if search:
domain = domain + [
'|', '|',
('client_name', 'ilike', search),
('reference', 'ilike', search),
('client_email', 'ilike', search),
]
# Sorting
sortings = {
'date': {'label': _('Date'), 'order': 'assessment_date desc'},
'name': {'label': _('Client'), 'order': 'client_name'},
'reference': {'label': _('Reference'), 'order': 'reference'},
'state': {'label': _('Status'), 'order': 'state'},
}
order = sortings.get(sortby, sortings['date'])['order']
# Pager
assessment_count = Assessment.search_count(domain)
pager = portal_pager(
url='/my/assessments',
url_args={'search': search, 'state': state, 'sortby': sortby},
total=assessment_count,
page=page,
step=20,
)
# Get assessments
assessments = Assessment.search(domain, order=order, limit=20, offset=pager['offset'])
# State options for filter
state_options = [
('', _('All')),
('draft', _('In Progress')),
('pending_signature', _('Pending Signatures')),
('completed', _('Completed')),
('cancelled', _('Cancelled')),
]
values = {
'assessments': assessments,
'pager': pager,
'search': search,
'state': state,
'state_options': state_options,
'sortby': sortby,
'sortings': sortings,
'page_name': 'assessments',
}
return request.render('fusion_authorizer_portal.portal_assessments', values)
@http.route('/my/assessment/new', type='http', auth='user', website=True)
def portal_assessment_new(self, **kw):
"""Start a new assessment"""
partner = request.env.user.partner_id
user = request.env.user
if not partner.is_authorizer and not partner.is_sales_rep_portal:
return request.redirect('/my')
# Get list of authorizers for dropdown (if sales rep starting assessment)
authorizers = request.env['res.partner'].sudo().search([
('is_authorizer', '=', True),
])
values = {
'partner': partner,
'user': user,
'authorizers': authorizers,
'countries': request.env['res.country'].sudo().search([]),
'default_country': request.env.ref('base.ca', raise_if_not_found=False),
'page_name': 'assessment_new',
}
return request.render('fusion_authorizer_portal.portal_assessment_form', values)
@http.route('/my/assessment/<int:assessment_id>', type='http', auth='user', website=True)
def portal_assessment_view(self, assessment_id, **kw):
"""View/edit an assessment"""
partner = request.env.user.partner_id
user = request.env.user
if not partner.is_authorizer and not partner.is_sales_rep_portal:
return request.redirect('/my')
try:
assessment = request.env['fusion.assessment'].sudo().browse(assessment_id)
if not assessment.exists():
raise MissingError(_('Assessment not found.'))
# Check access
has_access = (
(partner.is_authorizer and assessment.authorizer_id.id == partner.id) or
(partner.is_sales_rep_portal and assessment.sales_rep_id.id == user.id)
)
if not has_access:
raise AccessError(_('You do not have access to this assessment.'))
except (AccessError, MissingError):
return request.redirect('/my/assessments')
# Get list of authorizers for dropdown
authorizers = request.env['res.partner'].sudo().search([
('is_authorizer', '=', True),
])
# Get assessment photos
photos = request.env['ir.attachment'].sudo().search([
('res_model', '=', 'fusion.assessment'),
('res_id', '=', assessment.id),
('mimetype', 'like', 'image/%'),
])
values = {
'assessment': assessment,
'partner': partner,
'user': user,
'authorizers': authorizers,
'countries': request.env['res.country'].sudo().search([]),
'page_name': 'assessment_edit',
'is_readonly': assessment.state in ['completed', 'cancelled'],
'photos': photos,
}
return request.render('fusion_authorizer_portal.portal_assessment_form', values)
@http.route('/my/assessment/save', type='http', auth='user', website=True, methods=['POST'], csrf=True)
def portal_assessment_save(self, assessment_id=None, **kw):
"""Save assessment data (create or update)"""
partner = request.env.user.partner_id
user = request.env.user
if not partner.is_authorizer and not partner.is_sales_rep_portal:
return request.redirect('/my')
Assessment = request.env['fusion.assessment'].sudo()
# Prepare values
vals = {
'client_name': kw.get('client_name', ''),
'client_first_name': kw.get('client_first_name', ''),
'client_last_name': kw.get('client_last_name', ''),
'client_street': kw.get('client_street', ''),
'client_unit': kw.get('client_unit', ''),
'client_city': kw.get('client_city', ''),
'client_state': kw.get('client_state', 'Ontario'),
'client_postal_code': kw.get('client_postal_code', ''),
'client_phone': kw.get('client_phone', ''),
'client_mobile': kw.get('client_mobile', ''),
'client_email': kw.get('client_email', ''),
'client_reference_1': kw.get('client_reference_1', ''),
'client_reference_2': kw.get('client_reference_2', ''),
'assessment_location': kw.get('assessment_location', 'home'),
'assessment_location_notes': kw.get('assessment_location_notes', ''),
}
# Wheelchair specifications
float_fields = [
'seat_width', 'seat_depth', 'seat_to_floor_height', 'back_height',
'armrest_height', 'footrest_length', 'overall_width', 'overall_length',
'overall_height', 'seat_angle', 'back_angle', 'client_weight', 'client_height'
]
for field in float_fields:
if kw.get(field):
try:
vals[field] = float(kw.get(field))
except (ValueError, TypeError):
pass
# Selection fields
selection_fields = ['cushion_type', 'backrest_type', 'frame_type', 'wheel_type']
for field in selection_fields:
if kw.get(field):
vals[field] = kw.get(field)
# Text fields
text_fields = ['cushion_notes', 'backrest_notes', 'frame_notes', 'wheel_notes',
'mobility_notes', 'accessibility_notes', 'special_requirements', 'diagnosis']
for field in text_fields:
if kw.get(field):
vals[field] = kw.get(field)
# Authorizer
if kw.get('authorizer_id'):
try:
vals['authorizer_id'] = int(kw.get('authorizer_id'))
except (ValueError, TypeError):
pass
# Country
if kw.get('client_country_id'):
try:
vals['client_country_id'] = int(kw.get('client_country_id'))
except (ValueError, TypeError):
pass
try:
if assessment_id and assessment_id != 'None':
# Update existing
assessment = Assessment.browse(int(assessment_id))
if not assessment.exists():
raise MissingError(_('Assessment not found.'))
# Check access
has_access = (
(partner.is_authorizer and assessment.authorizer_id.id == partner.id) or
(partner.is_sales_rep_portal and assessment.sales_rep_id.id == user.id)
)
if not has_access:
raise AccessError(_('You do not have access to this assessment.'))
if assessment.state not in ['draft', 'pending_signature']:
raise ValidationError(_('Cannot modify a completed or cancelled assessment.'))
assessment.write(vals)
_logger.info(f"Updated assessment {assessment.reference}")
else:
# Create new
vals['sales_rep_id'] = user.id
if partner.is_authorizer:
vals['authorizer_id'] = partner.id
assessment = Assessment.create(vals)
_logger.info(f"Created new assessment {assessment.reference}")
# Redirect based on action
action = kw.get('action', 'save')
if action == 'save_signatures':
return request.redirect(f'/my/assessment/{assessment.id}/signatures')
elif action == 'save_exit':
return request.redirect('/my/assessments')
else:
return request.redirect(f'/my/assessment/{assessment.id}')
except Exception as e:
_logger.error(f"Error saving assessment: {e}")
return request.redirect('/my/assessments')
@http.route('/my/assessment/<int:assessment_id>/signatures', type='http', auth='user', website=True)
def portal_assessment_signatures(self, assessment_id, **kw):
"""Signature capture page"""
partner = request.env.user.partner_id
user = request.env.user
if not partner.is_authorizer and not partner.is_sales_rep_portal:
return request.redirect('/my')
try:
assessment = request.env['fusion.assessment'].sudo().browse(assessment_id)
if not assessment.exists():
raise MissingError(_('Assessment not found.'))
# Check access
has_access = (
(partner.is_authorizer and assessment.authorizer_id.id == partner.id) or
(partner.is_sales_rep_portal and assessment.sales_rep_id.id == user.id)
)
if not has_access:
raise AccessError(_('You do not have access to this assessment.'))
except (AccessError, MissingError):
return request.redirect('/my/assessments')
values = {
'assessment': assessment,
'partner': partner,
'page_name': 'assessment_signatures',
}
return request.render('fusion_authorizer_portal.portal_assessment_signatures', values)
@http.route('/my/assessment/<int:assessment_id>/save_signature', type='jsonrpc', auth='user')
def portal_save_signature(self, assessment_id, signature_type='', signature_data='', signer_name='', **kw):
"""Save a signature (AJAX)"""
partner = request.env.user.partner_id
user = request.env.user
if not partner.is_authorizer and not partner.is_sales_rep_portal:
return {'success': False, 'error': 'Access denied'}
try:
assessment = request.env['fusion.assessment'].sudo().browse(assessment_id)
if not assessment.exists():
return {'success': False, 'error': 'Assessment not found'}
# Check access
has_access = (
(partner.is_authorizer and assessment.authorizer_id.id == partner.id) or
(partner.is_sales_rep_portal and assessment.sales_rep_id.id == user.id)
)
if not has_access:
return {'success': False, 'error': 'Access denied'}
if not signature_data:
return {'success': False, 'error': 'No signature data provided'}
# Remove data URL prefix if present
if signature_data.startswith('data:image'):
signature_data = signature_data.split(',')[1]
vals = {}
if signature_type == 'page_11':
vals = {
'signature_page_11': signature_data,
'signature_page_11_name': signer_name,
'signature_page_11_date': datetime.now(),
}
elif signature_type == 'page_12':
vals = {
'signature_page_12': signature_data,
'signature_page_12_name': signer_name,
'signature_page_12_date': datetime.now(),
}
else:
return {'success': False, 'error': 'Invalid signature type'}
assessment.write(vals)
# Update state if needed
if assessment.state == 'draft':
assessment.state = 'pending_signature'
return {
'success': True,
'signatures_complete': assessment.signatures_complete,
}
except Exception as e:
_logger.error(f"Error saving signature: {e}")
return {'success': False, 'error': str(e)}
@http.route('/my/assessment/<int:assessment_id>/complete', type='http', auth='user', website=True, methods=['POST'], csrf=True)
def portal_assessment_complete(self, assessment_id, **kw):
"""Complete the assessment"""
partner = request.env.user.partner_id
user = request.env.user
if not partner.is_authorizer and not partner.is_sales_rep_portal:
return request.redirect('/my')
try:
assessment = request.env['fusion.assessment'].sudo().browse(assessment_id)
if not assessment.exists():
raise MissingError(_('Assessment not found.'))
# Check access
has_access = (
(partner.is_authorizer and assessment.authorizer_id.id == partner.id) or
(partner.is_sales_rep_portal and assessment.sales_rep_id.id == user.id)
)
if not has_access:
raise AccessError(_('You do not have access to this assessment.'))
# Complete the assessment
result = assessment.action_complete()
# Redirect to the created sale order or assessments list
if assessment.sale_order_id:
return request.redirect('/my/assessments?message=completed')
else:
return request.redirect('/my/assessments')
except ValidationError as e:
_logger.warning(f"Validation error completing assessment: {e}")
return request.redirect(f'/my/assessment/{assessment_id}/signatures?error=signatures_required')
except Exception as e:
_logger.error(f"Error completing assessment: {e}")
return request.redirect(f'/my/assessment/{assessment_id}?error=1')
# ==========================================================================
# EXPRESS ASSESSMENT FORM ROUTES
# ==========================================================================
@http.route('/my/assessment/express', type='http', auth='user', website=True)
def portal_assessment_express_new(self, **kw):
"""Start a new express assessment (Page 1 - Equipment Selection)"""
partner = request.env.user.partner_id
user = request.env.user
if not partner.is_sales_rep_portal:
return request.redirect('/my')
# Get list of authorizers for dropdown
authorizers = request.env['res.partner'].sudo().search([
('is_authorizer', '=', True),
], order='name')
# JSON-safe authorizer list for searchable dropdown (Markup so t-out won't escape)
authorizers_json = Markup(json.dumps([
{'id': a.id, 'name': a.name, 'email': a.email or ''}
for a in authorizers
]))
# Get existing clients for dropdown
clients = request.env['res.partner'].sudo().search([
('customer_rank', '>', 0),
], order='name', limit=500)
# Get Google Maps API key
ICP = request.env['ir.config_parameter'].sudo()
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
values = {
'partner': partner,
'user': user,
'authorizers': authorizers,
'authorizers_json': authorizers_json,
'clients': clients,
'countries': request.env['res.country'].sudo().search([]),
'provinces': self._get_canadian_provinces(),
'default_country': request.env.ref('base.ca', raise_if_not_found=False),
'page_name': 'assessment_express',
'current_page': 1,
'total_pages': 2,
'assessment': None,
'google_maps_api_key': google_maps_api_key,
}
return request.render('fusion_authorizer_portal.portal_assessment_express', values)
@http.route('/my/assessment/express/<int:assessment_id>', type='http', auth='user', website=True)
def portal_assessment_express_edit(self, assessment_id, page=1, **kw):
"""Continue/edit an express assessment"""
partner = request.env.user.partner_id
user = request.env.user
if not partner.is_sales_rep_portal:
return request.redirect('/my')
try:
assessment = request.env['fusion.assessment'].sudo().browse(assessment_id)
if not assessment.exists():
raise MissingError(_('Assessment not found.'))
# Check access - must be the sales rep who created it
if assessment.sales_rep_id.id != user.id:
raise AccessError(_('You do not have access to this assessment.'))
if assessment.state in ['cancelled']:
return request.redirect('/my/assessments')
except (AccessError, MissingError):
return request.redirect('/my/assessments')
# Get list of authorizers for dropdown
authorizers = request.env['res.partner'].sudo().search([
('is_authorizer', '=', True),
], order='name')
# JSON-safe authorizer list for searchable dropdown (Markup so t-out won't escape)
authorizers_json = Markup(json.dumps([
{'id': a.id, 'name': a.name, 'email': a.email or ''}
for a in authorizers
]))
# Get existing clients for dropdown
clients = request.env['res.partner'].sudo().search([
('customer_rank', '>', 0),
], order='name', limit=500)
try:
current_page = int(page)
except (ValueError, TypeError):
current_page = 1
# Get Google Maps API key
ICP = request.env['ir.config_parameter'].sudo()
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
values = {
'partner': partner,
'user': user,
'assessment': assessment,
'authorizers': authorizers,
'authorizers_json': authorizers_json,
'clients': clients,
'countries': request.env['res.country'].sudo().search([]),
'provinces': self._get_canadian_provinces(),
'default_country': request.env.ref('base.ca', raise_if_not_found=False),
'page_name': 'assessment_express',
'current_page': current_page,
'total_pages': 2,
'google_maps_api_key': google_maps_api_key,
}
return request.render('fusion_authorizer_portal.portal_assessment_express', values)
@http.route('/my/assessment/express/save', type='http', auth='user', website=True, methods=['POST'], csrf=True)
def portal_assessment_express_save(self, **kw):
"""Save express assessment data (create or update)"""
partner = request.env.user.partner_id
user = request.env.user
if not partner.is_sales_rep_portal:
return request.redirect('/my')
Assessment = request.env['fusion.assessment'].sudo()
assessment_id = kw.get('assessment_id')
current_page = int(kw.get('current_page', 1))
action = kw.get('action', 'next') # next, back, save, submit
# Build values from form
vals = self._build_express_assessment_vals(kw)
try:
if assessment_id and assessment_id != 'None' and assessment_id != '':
# Update existing
assessment = Assessment.browse(int(assessment_id))
if not assessment.exists():
raise MissingError(_('Assessment not found.'))
if assessment.sales_rep_id.id != user.id:
raise AccessError(_('You do not have access to this assessment.'))
if assessment.state == 'completed':
# Allow updating ONLY consent/signature fields on completed assessments
consent_fields = {
'consent_signed_by', 'consent_declaration_accepted', 'consent_date',
'agent_relationship', 'agent_first_name', 'agent_last_name',
'agent_middle_initial', 'agent_unit', 'agent_street_number',
'agent_street_name', 'agent_city', 'agent_province',
'agent_postal_code', 'agent_home_phone', 'agent_business_phone',
'agent_phone_ext',
}
consent_vals = {k: v for k, v in vals.items() if k in consent_fields}
if consent_vals:
assessment.write(consent_vals)
_logger.info(f"Updated consent fields on completed assessment {assessment.reference}")
elif assessment.state == 'cancelled':
raise ValidationError(_('Cannot modify a cancelled assessment.'))
else:
# Draft - allow full update
assessment.write(vals)
_logger.info(f"Updated express assessment {assessment.reference}")
else:
# Create new
vals['sales_rep_id'] = user.id
vals['state'] = 'draft'
assessment = Assessment.create(vals)
_logger.info(f"Created new express assessment {assessment.reference}")
# Handle photo uploads
uploaded_photos = request.httprequest.files.getlist('assessment_photos')
if uploaded_photos:
for photo_file in uploaded_photos:
if photo_file and photo_file.filename:
try:
file_content = photo_file.read()
file_base64 = base64.b64encode(file_content)
# Create attachment linked to assessment
attachment = request.env['ir.attachment'].sudo().create({
'name': photo_file.filename,
'type': 'binary',
'datas': file_base64,
'res_model': 'fusion.assessment',
'res_id': assessment.id,
'mimetype': photo_file.content_type or 'image/jpeg',
})
_logger.info(f"Uploaded assessment photo: {photo_file.filename}")
except Exception as e:
_logger.error(f"Error uploading photo {photo_file.filename}: {e}")
# ===== Handle Page 11 signature capture =====
signature_data = kw.get('signature_page_11_data', '')
if signature_data and signature_data.startswith('data:image/'):
try:
# Strip data URL prefix: "data:image/png;base64,..."
sig_base64 = signature_data.split(',', 1)[1]
sig_vals = {
'signature_page_11': sig_base64,
'signature_page_11_date': fields.Datetime.now(),
}
# Set signer name
if kw.get('consent_signed_by') == 'agent' and kw.get('agent_first_name'):
sig_vals['signature_page_11_name'] = (
f"{kw.get('agent_first_name', '')} {kw.get('agent_last_name', '')}"
).strip()
else:
sig_vals['signature_page_11_name'] = (
f"{kw.get('client_first_name', '')} {kw.get('client_last_name', '')}"
).strip()
assessment.write(sig_vals)
_logger.info(f"Saved Page 11 signature for assessment {assessment.reference}")
except Exception as e:
_logger.error(f"Error saving Page 11 signature: {e}")
# Handle navigation
if action == 'submit':
# If already completed, we just saved consent/signature above -- redirect with success
if assessment.state == 'completed':
# Generate filled PDF if signature was added
if assessment.signature_page_11 and assessment.consent_declaration_accepted:
try:
pdf_bytes = assessment.generate_template_pdf('Page 11')
if pdf_bytes:
import base64 as b64
assessment.write({
'signed_page_11_pdf': b64.b64encode(pdf_bytes),
'signed_page_11_pdf_filename': f'ADP_Page11_{assessment.reference}.pdf',
})
# Update sale order too
# Issue 8 fix: bypass document lock since this is a portal
# re-signing on an already-completed assessment (SO may have
# progressed past 'submitted' where the lock kicks in)
if assessment.sale_order_id:
assessment.sale_order_id.with_context(
skip_document_lock_validation=True
).write({
'x_fc_signed_pages_11_12': b64.b64encode(pdf_bytes),
'x_fc_signed_pages_filename': f'ADP_Page11_{assessment.reference}.pdf',
})
_logger.info(f"Generated Page 11 PDF for completed assessment {assessment.reference}")
except Exception as pdf_e:
_logger.warning(f"PDF generation failed (non-blocking): {pdf_e}")
# Post consent & signature info to sale order chatter
if assessment.sale_order_id and assessment.signature_page_11:
try:
from markupsafe import Markup
signer = assessment.signature_page_11_name or 'Unknown'
signed_by = 'Applicant' if assessment.consent_signed_by == 'applicant' else 'Agent'
consent_date = str(assessment.consent_date) if assessment.consent_date else 'N/A'
# Create signature as attachment
sig_att = request.env['ir.attachment'].sudo().create({
'name': f'Page11_Signature_{assessment.reference}.png',
'type': 'binary',
'datas': assessment.signature_page_11,
'res_model': 'sale.order',
'res_id': assessment.sale_order_id.id,
'mimetype': 'image/png',
})
body = Markup(
'<div class="alert alert-success" role="alert">'
'<h5 class="alert-heading"><i class="fa fa-file-text"/> Page 11 Consent &amp; Signature</h5>'
'<ul class="mb-0">'
f'<li><strong>Signed by:</strong> {signer} ({signed_by})</li>'
f'<li><strong>Consent date:</strong> {consent_date}</li>'
f'<li><strong>Declaration accepted:</strong> Yes</li>'
'</ul>'
'</div>'
)
assessment.sale_order_id.message_post(
body=body,
message_type='comment',
subtype_xmlid='mail.mt_note',
attachment_ids=[sig_att.id],
)
_logger.info(f"Posted Page 11 consent info to SO {assessment.sale_order_id.name}")
except Exception as chat_e:
_logger.warning(f"Failed to post consent to chatter: {chat_e}")
so_id = assessment.sale_order_id.id if assessment.sale_order_id else ''
return request.redirect(f'/my/assessments?message=completed&so={so_id}')
# Complete the express assessment
try:
sale_order = assessment.action_complete_express()
# Post assessment photos to sale order chatter
photo_attachments = request.env['ir.attachment'].sudo().search([
('res_model', '=', 'fusion.assessment'),
('res_id', '=', assessment.id),
('mimetype', 'like', 'image/%'),
])
if photo_attachments:
# Copy attachments to sale order
attachment_ids = []
for att in photo_attachments:
new_att = att.copy({
'res_model': 'sale.order',
'res_id': sale_order.id,
})
attachment_ids.append(new_att.id)
# Post message to chatter with photos
sale_order.message_post(
body=f"<p><strong>Assessment Photos</strong><br/>Photos from assessment {assessment.reference} by {request.env.user.name}</p>",
message_type='comment',
subtype_xmlid='mail.mt_comment',
attachment_ids=attachment_ids,
)
_logger.info(f"Posted {len(attachment_ids)} assessment photos to sale order {sale_order.name}")
# Process loaner checkout if loaner data was submitted
loaner_product_id = kw.get('loaner_product_id')
loaner_checkout_flag = kw.get('loaner_checkout', '0')
if loaner_product_id and loaner_checkout_flag == '1':
try:
loaner_vals = {
'product_id': int(loaner_product_id),
'sale_order_id': sale_order.id,
'partner_id': sale_order.partner_id.id,
'loaner_period_days': int(kw.get('loaner_period_days', 7)),
'checkout_condition': kw.get('loaner_condition', 'good'),
'checkout_notes': kw.get('loaner_notes', ''),
'sales_rep_id': request.env.user.id,
}
if sale_order.x_fc_authorizer_id:
loaner_vals['authorizer_id'] = sale_order.x_fc_authorizer_id.id
if sale_order.partner_shipping_id:
loaner_vals['delivery_address'] = sale_order.partner_shipping_id.contact_address
loaner_lot = kw.get('loaner_lot_id')
if loaner_lot:
loaner_vals['lot_id'] = int(loaner_lot)
checkout = request.env['fusion.loaner.checkout'].sudo().create(loaner_vals)
checkout.action_checkout()
_logger.info(f"Created loaner checkout {checkout.name} for SO {sale_order.name}")
except Exception as le:
_logger.error(f"Error creating loaner checkout: {le}")
# ===== Generate filled Page 11 PDF if signature exists =====
if assessment.signature_page_11 and assessment.consent_declaration_accepted:
try:
pdf_bytes = assessment.generate_template_pdf('Page 11')
if pdf_bytes:
import base64 as b64
assessment.write({
'signed_page_11_pdf': b64.b64encode(pdf_bytes),
'signed_page_11_pdf_filename': f'ADP_Page11_{assessment.reference}.pdf',
})
# Also store on sale order
# Issue 8 fix: bypass document lock for portal writes
sale_order.with_context(
skip_document_lock_validation=True
).write({
'x_fc_signed_pages_11_12': b64.b64encode(pdf_bytes),
'x_fc_signed_pages_filename': f'ADP_Page11_{assessment.reference}.pdf',
})
_logger.info(f"Generated Page 11 PDF for assessment {assessment.reference}")
except Exception as pdf_e:
_logger.warning(f"PDF generation failed (non-blocking): {pdf_e}")
return request.redirect(f'/my/assessments?message=completed&so={sale_order.id}')
except Exception as e:
_logger.error(f"Error completing express assessment: {e}")
return request.redirect(f'/my/assessment/express/{assessment.id}?error={str(e)}')
elif action == 'start_over':
# Cancel and start fresh
if assessment_id and assessment_id != 'None':
assessment.unlink()
return request.redirect('/my/assessment/express')
else:
# Just save
return request.redirect(f'/my/assessment/express/{assessment.id}?page={current_page}')
except Exception as e:
_logger.error(f"Error saving express assessment: {e}")
if assessment_id and assessment_id != 'None':
return request.redirect(f'/my/assessment/express/{assessment_id}?page={current_page}&error=1')
return request.redirect('/my/assessment/express?error=1')
def _build_express_assessment_vals(self, kw):
"""Build values dict from express form POST data"""
vals = {}
# Equipment type
if kw.get('equipment_type'):
vals['equipment_type'] = kw.get('equipment_type')
# Equipment sub-types
if kw.get('rollator_type'):
vals['rollator_type'] = kw.get('rollator_type')
if kw.get('wheelchair_type'):
vals['wheelchair_type'] = kw.get('wheelchair_type')
if kw.get('powerchair_type'):
vals['powerchair_type'] = kw.get('powerchair_type')
# Float measurements
float_fields = [
'rollator_handle_height', 'rollator_seat_height',
'seat_width', 'seat_depth', 'seat_to_floor_height', 'back_height',
'legrest_length', 'cane_height', 'client_weight',
]
for field in float_fields:
if kw.get(field):
try:
vals[field] = float(kw.get(field))
except (ValueError, TypeError):
pass
# Checkbox options - collect as comma-separated strings
# Rollator addons
rollator_addons = kw.getlist('rollator_addons') if hasattr(kw, 'getlist') else []
if not rollator_addons and 'rollator_addons' in kw:
rollator_addons = [kw.get('rollator_addons')] if kw.get('rollator_addons') else []
if rollator_addons:
vals['rollator_addons'] = ', '.join(rollator_addons)
# Wheelchair options
frame_options = kw.getlist('frame_options') if hasattr(kw, 'getlist') else []
if not frame_options and 'frame_options' in kw:
frame_options = [kw.get('frame_options')] if kw.get('frame_options') else []
if frame_options:
vals['frame_options'] = ', '.join(frame_options)
wheel_options = kw.getlist('wheel_options') if hasattr(kw, 'getlist') else []
if not wheel_options and 'wheel_options' in kw:
wheel_options = [kw.get('wheel_options')] if kw.get('wheel_options') else []
if wheel_options:
vals['wheel_options'] = ', '.join(wheel_options)
legrest_options = kw.getlist('legrest_options') if hasattr(kw, 'getlist') else []
if not legrest_options and 'legrest_options' in kw:
legrest_options = [kw.get('legrest_options')] if kw.get('legrest_options') else []
if legrest_options:
vals['legrest_options'] = ', '.join(legrest_options)
additional_adp_options = kw.getlist('additional_adp_options') if hasattr(kw, 'getlist') else []
if not additional_adp_options and 'additional_adp_options' in kw:
additional_adp_options = [kw.get('additional_adp_options')] if kw.get('additional_adp_options') else []
if additional_adp_options:
vals['additional_adp_options'] = ', '.join(additional_adp_options)
# Powerchair options
powerchair_options = kw.getlist('powerchair_options') if hasattr(kw, 'getlist') else []
if not powerchair_options and 'powerchair_options' in kw:
powerchair_options = [kw.get('powerchair_options')] if kw.get('powerchair_options') else []
if powerchair_options:
vals['powerchair_options'] = ', '.join(powerchair_options)
specialty_controls = kw.getlist('specialty_controls') if hasattr(kw, 'getlist') else []
if not specialty_controls and 'specialty_controls' in kw:
specialty_controls = [kw.get('specialty_controls')] if kw.get('specialty_controls') else []
if specialty_controls:
vals['specialty_controls'] = ', '.join(specialty_controls)
# Seatbelt type
if kw.get('seatbelt_type'):
vals['seatbelt_type'] = kw.get('seatbelt_type')
# Additional customization
if kw.get('additional_customization'):
vals['additional_customization'] = kw.get('additional_customization')
# Cushion and backrest
if kw.get('cushion_info'):
vals['cushion_info'] = kw.get('cushion_info')
if kw.get('backrest_info'):
vals['backrest_info'] = kw.get('backrest_info')
# Client type
if kw.get('client_type'):
vals['client_type'] = kw.get('client_type')
# Client info (Page 2)
if kw.get('client_first_name'):
vals['client_first_name'] = kw.get('client_first_name')
if kw.get('client_middle_name'):
vals['client_middle_name'] = kw.get('client_middle_name')
if kw.get('client_last_name'):
vals['client_last_name'] = kw.get('client_last_name')
# Build full client name
name_parts = []
if kw.get('client_first_name'):
name_parts.append(kw.get('client_first_name'))
if kw.get('client_middle_name'):
name_parts.append(kw.get('client_middle_name'))
if kw.get('client_last_name'):
name_parts.append(kw.get('client_last_name'))
if name_parts:
vals['client_name'] = ' '.join(name_parts)
# Health card
if kw.get('client_health_card'):
vals['client_health_card'] = kw.get('client_health_card')
if kw.get('client_health_card_version'):
vals['client_health_card_version'] = kw.get('client_health_card_version')
# Address
if kw.get('client_street'):
vals['client_street'] = kw.get('client_street')
if kw.get('client_unit'):
vals['client_unit'] = kw.get('client_unit')
if kw.get('client_city'):
vals['client_city'] = kw.get('client_city')
if kw.get('client_state'):
vals['client_state'] = kw.get('client_state')
if kw.get('client_postal_code'):
vals['client_postal_code'] = kw.get('client_postal_code')
if kw.get('client_country_id'):
try:
vals['client_country_id'] = int(kw.get('client_country_id'))
except (ValueError, TypeError):
pass
# Contact
if kw.get('client_phone'):
vals['client_phone'] = kw.get('client_phone')
if kw.get('client_email'):
vals['client_email'] = kw.get('client_email')
# Dates
date_fields = ['assessment_start_date', 'assessment_end_date', 'claim_authorization_date', 'previous_funding_date']
for field in date_fields:
if kw.get(field):
try:
vals[field] = kw.get(field)
except (ValueError, TypeError):
pass
# Reason for application
if kw.get('reason_for_application'):
vals['reason_for_application'] = kw.get('reason_for_application')
# Authorizer
if kw.get('authorizer_id'):
try:
vals['authorizer_id'] = int(kw.get('authorizer_id'))
except (ValueError, TypeError):
pass
# Existing partner selection
if kw.get('partner_id'):
try:
partner_id = int(kw.get('partner_id'))
if partner_id > 0:
vals['partner_id'] = partner_id
vals['create_new_partner'] = False
else:
vals['create_new_partner'] = True
except (ValueError, TypeError):
vals['create_new_partner'] = True
# ===== PAGE 11: Consent & Declaration fields =====
if kw.get('consent_signed_by'):
vals['consent_signed_by'] = kw.get('consent_signed_by')
if kw.get('consent_declaration_accepted'):
vals['consent_declaration_accepted'] = True
if kw.get('consent_date'):
try:
vals['consent_date'] = kw.get('consent_date')
except (ValueError, TypeError):
pass
# Agent fields (only relevant when consent_signed_by == 'agent')
agent_fields = [
'agent_relationship', 'agent_first_name', 'agent_last_name',
'agent_middle_initial', 'agent_unit', 'agent_street_number',
'agent_street_name', 'agent_city', 'agent_province',
'agent_postal_code', 'agent_home_phone', 'agent_business_phone',
'agent_phone_ext',
]
for field in agent_fields:
if kw.get(field):
vals[field] = kw.get(field)
return vals
def _get_canadian_provinces(self):
"""Return list of Canadian provinces for dropdown"""
return [
('Ontario', 'Ontario'),
('Quebec', 'Quebec'),
('British Columbia', 'British Columbia'),
('Alberta', 'Alberta'),
('Manitoba', 'Manitoba'),
('Saskatchewan', 'Saskatchewan'),
('Nova Scotia', 'Nova Scotia'),
('New Brunswick', 'New Brunswick'),
('Newfoundland and Labrador', 'Newfoundland and Labrador'),
('Prince Edward Island', 'Prince Edward Island'),
('Northwest Territories', 'Northwest Territories'),
('Yukon', 'Yukon'),
('Nunavut', 'Nunavut'),
]
# =========================================================================
# LOANER PORTAL ROUTES
# =========================================================================
@http.route('/my/loaner/categories', type='jsonrpc', auth='user', website=True)
def portal_loaner_categories(self, **kw):
"""Return loaner product categories."""
parent = request.env.ref('fusion_claims.product_category_loaner', raise_if_not_found=False)
if not parent:
return []
categories = request.env['product.category'].sudo().search([
('parent_id', '=', parent.id),
], order='name')
return [{'id': c.id, 'name': c.name} for c in categories]
@http.route('/my/loaner/products', type='jsonrpc', auth='user', website=True)
def portal_loaner_products(self, **kw):
"""Return available loaner products and their serial numbers."""
domain = [('x_fc_can_be_loaned', '=', True)]
category_id = kw.get('category_id')
if category_id:
domain.append(('categ_id', '=', int(category_id)))
products = request.env['product.product'].sudo().search(domain)
loaner_location = request.env.ref('fusion_claims.stock_location_loaner', raise_if_not_found=False)
result = []
for p in products:
lots = []
if loaner_location:
quants = request.env['stock.quant'].sudo().search([
('product_id', '=', p.id),
('location_id', '=', loaner_location.id),
('quantity', '>', 0),
])
for q in quants:
if q.lot_id:
lots.append({'id': q.lot_id.id, 'name': q.lot_id.name})
result.append({
'id': p.id,
'name': p.name,
'category_id': p.categ_id.id,
'period_days': p.product_tmpl_id.x_fc_loaner_period_days or 7,
'lots': lots,
})
return result
@http.route('/my/loaner/locations', type='jsonrpc', auth='user', website=True)
def portal_loaner_locations(self, **kw):
"""Return internal stock locations for return."""
locations = request.env['stock.location'].sudo().search([
('usage', '=', 'internal'),
('company_id', '=', request.env.company.id),
])
return [{'id': loc.id, 'name': loc.complete_name} for loc in locations]
@http.route('/my/loaner/checkout', type='jsonrpc', auth='user', website=True)
def portal_loaner_checkout(self, **kw):
"""Checkout a loaner from the portal."""
partner = request.env.user.partner_id
if not partner.is_sales_rep_portal and not partner.is_authorizer:
return {'error': 'Unauthorized'}
product_id = int(kw.get('product_id', 0))
lot_id = int(kw.get('lot_id', 0)) if kw.get('lot_id') else False
sale_order_id = int(kw.get('sale_order_id', 0)) if kw.get('sale_order_id') else False
client_id = int(kw.get('client_id', 0)) if kw.get('client_id') else False
loaner_period = int(kw.get('loaner_period_days', 7))
condition = kw.get('checkout_condition', 'good')
notes = kw.get('checkout_notes', '')
if not product_id:
return {'error': 'Product is required'}
vals = {
'product_id': product_id,
'loaner_period_days': loaner_period,
'checkout_condition': condition,
'checkout_notes': notes,
'sales_rep_id': request.env.user.id,
}
if lot_id:
vals['lot_id'] = lot_id
if sale_order_id:
so = request.env['sale.order'].sudo().browse(sale_order_id)
if so.exists():
vals['sale_order_id'] = so.id
vals['partner_id'] = so.partner_id.id
vals['authorizer_id'] = so.x_fc_authorizer_id.id if so.x_fc_authorizer_id else False
vals['delivery_address'] = so.partner_shipping_id.contact_address if so.partner_shipping_id else ''
if client_id and not vals.get('partner_id'):
vals['partner_id'] = client_id
if not vals.get('partner_id'):
return {'error': 'Client is required'}
try:
checkout = request.env['fusion.loaner.checkout'].sudo().create(vals)
checkout.action_checkout()
return {
'success': True,
'checkout_id': checkout.id,
'name': checkout.name,
'message': f'Loaner {checkout.name} checked out successfully',
}
except Exception as e:
_logger.error(f"Loaner checkout error: {e}")
return {'error': str(e)}
@http.route('/my/loaner/create-product', type='jsonrpc', auth='user', website=True)
def portal_loaner_create_product(self, **kw):
"""Quick-create a loaner product with serial number from the portal."""
partner = request.env.user.partner_id
if not partner.is_sales_rep_portal and not partner.is_authorizer:
return {'error': 'Unauthorized'}
product_name = kw.get('product_name', '').strip()
serial_number = kw.get('serial_number', '').strip()
if not product_name:
return {'error': 'Product name is required'}
if not serial_number:
return {'error': 'Serial number is required'}
try:
# Use provided category or default to Loaner Equipment
category_id = kw.get('category_id')
if category_id:
category = request.env['product.category'].sudo().browse(int(category_id))
if not category.exists():
category = None
else:
category = None
if not category:
category = request.env.ref('fusion_claims.product_category_loaner', raise_if_not_found=False)
if not category:
category = request.env['product.category'].sudo().search([
('name', '=', 'Loaner Equipment'),
], limit=1)
if not category:
category = request.env['product.category'].sudo().create({
'name': 'Loaner Equipment',
})
# Create product template
product_tmpl = request.env['product.template'].sudo().create({
'name': product_name,
'type': 'consu',
'tracking': 'serial',
'categ_id': category.id,
'x_fc_can_be_loaned': True,
'x_fc_loaner_period_days': 7,
'sale_ok': False,
'purchase_ok': False,
})
product = product_tmpl.product_variant_id
# Create serial number (lot)
lot = request.env['stock.lot'].sudo().create({
'name': serial_number,
'product_id': product.id,
'company_id': request.env.company.id,
})
# Add stock in loaner location
loaner_location = request.env.ref('fusion_claims.stock_location_loaner', raise_if_not_found=False)
if loaner_location:
request.env['stock.quant'].sudo().create({
'product_id': product.id,
'location_id': loaner_location.id,
'lot_id': lot.id,
'quantity': 1,
})
return {
'success': True,
'product_id': product.id,
'product_name': product.name,
'lot_id': lot.id,
'lot_name': lot.name,
}
except Exception as e:
_logger.error(f"Loaner product creation error: {e}")
return {'error': str(e)}
@http.route('/my/loaner/return', type='jsonrpc', auth='user', website=True)
def portal_loaner_return(self, **kw):
"""Return/pickup a loaner from the portal."""
partner = request.env.user.partner_id
if not partner.is_sales_rep_portal and not partner.is_authorizer:
return {'error': 'Unauthorized'}
checkout_id = int(kw.get('checkout_id', 0))
return_condition = kw.get('return_condition', 'good')
return_notes = kw.get('return_notes', '')
return_location_id = int(kw.get('return_location_id', 0)) if kw.get('return_location_id') else None
if not checkout_id:
return {'error': 'Checkout ID is required'}
try:
checkout = request.env['fusion.loaner.checkout'].sudo().browse(checkout_id)
if not checkout.exists():
return {'error': 'Checkout not found'}
if checkout.state not in ('checked_out', 'overdue', 'rental_pending'):
return {'error': 'This loaner is not currently checked out'}
checkout.action_process_return(
return_condition=return_condition,
return_notes=return_notes,
return_location_id=return_location_id,
)
return {
'success': True,
'message': f'Loaner {checkout.name} returned successfully',
}
except Exception as e:
_logger.error(f"Loaner return error: {e}")
return {'error': str(e)}
# ==========================================================================
# PUBLIC ASSESSMENT BOOKING
# ==========================================================================
@http.route('/book-assessment', type='http', auth='public', website=True, sitemap=True)
def portal_book_assessment(self, **kw):
"""Public page for booking an accessibility assessment."""
# Get available sales reps for assignment
SalesGroup = request.env.ref('sales_team.group_sale_salesman', raise_if_not_found=False)
sales_reps = []
if SalesGroup:
sales_reps = request.env['res.users'].sudo().search([
('groups_id', 'in', [SalesGroup.id]),
('active', '=', True),
])
assessment_types = [
('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'),
]
values = {
'assessment_types': assessment_types,
'sales_reps': sales_reps,
'success': kw.get('success'),
'error': kw.get('error'),
}
return request.render('fusion_authorizer_portal.portal_book_assessment', values)
@http.route('/book-assessment/submit', type='http', auth='public', website=True, methods=['POST'], csrf=True)
def portal_book_assessment_submit(self, **kw):
"""Process assessment booking form submission."""
try:
# Validate required fields
if not kw.get('client_name') or not kw.get('client_phone'):
return request.redirect('/book-assessment?error=Please+provide+client+name+and+phone+number')
if not kw.get('assessment_type'):
return request.redirect('/book-assessment?error=Please+select+an+assessment+type')
Assessment = request.env['fusion.accessibility.assessment'].sudo()
# Determine booking source
booking_source = 'portal'
if kw.get('booking_source'):
booking_source = kw['booking_source']
# Parse date
assessment_date = False
if kw.get('assessment_date'):
try:
assessment_date = fields.Date.from_string(kw['assessment_date'])
except Exception:
assessment_date = False
# Determine sales rep
sales_rep_id = False
if kw.get('sales_rep_id'):
try:
sales_rep_id = int(kw['sales_rep_id'])
except (ValueError, TypeError):
pass
# Build address string
address_parts = []
if kw.get('client_street'):
address_parts.append(kw['client_street'])
if kw.get('client_city'):
address_parts.append(kw['client_city'])
if kw.get('client_province'):
address_parts.append(kw['client_province'])
if kw.get('client_postal'):
address_parts.append(kw['client_postal'])
vals = {
'assessment_type': kw['assessment_type'],
'client_name': kw['client_name'],
'client_phone': kw.get('client_phone', ''),
'client_email': kw.get('client_email', ''),
'client_address': ', '.join(address_parts) if address_parts else '',
'client_address_street': kw.get('client_street', ''),
'client_address_city': kw.get('client_city', ''),
'client_address_province': kw.get('client_province', ''),
'client_address_postal': kw.get('client_postal', ''),
'assessment_date': assessment_date,
'booking_source': booking_source,
'modification_requested': kw.get('modification_requested', ''),
}
if sales_rep_id:
vals['sales_rep_id'] = sales_rep_id
# Link authorizer if provided
if kw.get('authorizer_name') and kw.get('authorizer_email'):
Partner = request.env['res.partner'].sudo()
authorizer = Partner.search([('email', '=', kw['authorizer_email'])], limit=1)
if not authorizer:
authorizer = Partner.create({
'name': kw['authorizer_name'],
'email': kw['authorizer_email'],
'phone': kw.get('authorizer_phone', ''),
'is_authorizer': True,
})
vals['authorizer_id'] = authorizer.id
assessment = Assessment.create(vals)
# Create calendar event for the sales rep
if assessment_date and sales_rep_id:
try:
from datetime import datetime as dt, timedelta
# Default: 10 AM, 1.5 hour duration
start = dt.combine(assessment_date, dt.min.time().replace(hour=10))
stop = start + timedelta(hours=1, minutes=30)
event = request.env['calendar.event'].sudo().create({
'name': f'Assessment: {kw["client_name"]} ({kw.get("client_city", "")})',
'start': fields.Datetime.to_string(start),
'stop': fields.Datetime.to_string(stop),
'user_id': sales_rep_id,
'location': vals.get('client_address', ''),
'description': (
f'Accessibility Assessment Booking\n'
f'Client: {kw["client_name"]}\n'
f'Phone: {kw.get("client_phone", "")}\n'
f'Type: {kw["assessment_type"]}\n'
f'Request: {kw.get("modification_requested", "")}'
),
'partner_ids': [(4, request.env['res.users'].sudo().browse(sales_rep_id).partner_id.id)],
})
assessment.write({'calendar_event_id': event.id})
except Exception as e:
_logger.error(f"Failed to create calendar event: {e}")
# Send authorizer notification email
if assessment.authorizer_id and assessment.authorizer_id.email:
try:
company = request.env.company
body_html = assessment._email_build(
title='Assessment Scheduled',
summary=f'An accessibility assessment has been booked for '
f'<strong>{kw["client_name"]}</strong>.',
email_type='info',
sections=[('Booking Details', [
('Client', kw['client_name']),
('Phone', kw.get('client_phone', '')),
('Address', vals.get('client_address', '')),
('Assessment Type', dict(Assessment._fields['assessment_type'].selection).get(kw['assessment_type'], '')),
('Date', str(assessment_date) if assessment_date else 'TBD'),
('Requested', kw.get('modification_requested', '')),
])],
note='This booking was made through the online portal.',
sender_name=company.name,
)
# Replace footer
body_html = body_html.replace(
'This is an automated notification from the ADP Claims Management System.',
'This is an automated notification from the Accessibility Case Management System.',
)
request.env['mail.mail'].sudo().create({
'subject': f'Assessment Booked - {kw["client_name"]}',
'body_html': body_html,
'email_to': assessment.authorizer_id.email,
'model': 'fusion.accessibility.assessment',
'res_id': assessment.id,
}).send()
except Exception as e:
_logger.error(f"Failed to send authorizer notification: {e}")
# Send Twilio SMS to client
if kw.get('client_phone'):
try:
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_claims.twilio_enabled', 'False').lower() in ('true', '1', 'yes'):
import requests as req
account_sid = ICP.get_param('fusion_claims.twilio_account_sid', '')
auth_token = ICP.get_param('fusion_claims.twilio_auth_token', '')
from_number = ICP.get_param('fusion_claims.twilio_phone_number', '')
company_phone = request.env.company.phone or ''
date_str = str(assessment_date) if assessment_date else 'a date to be confirmed'
sms_body = (
f"Hi {kw['client_name']}, your accessibility assessment with "
f"Westin Healthcare has been booked for {date_str}. "
f"For questions, call {company_phone}."
)
if all([account_sid, auth_token, from_number]):
url = f'https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json'
req.post(url, data={
'To': kw['client_phone'],
'From': from_number,
'Body': sms_body,
}, auth=(account_sid, auth_token), timeout=10)
assessment.write({'sms_confirmation_sent': True})
except Exception as e:
_logger.error(f"Failed to send SMS: {e}")
return request.redirect('/book-assessment?success=1')
except Exception as e:
_logger.error(f"Assessment booking error: {e}")
return request.redirect(f'/book-assessment?error={str(e)}')