Files
Odoo-Modules/Work in Progress/fusion_quotations/controllers/portal_quotation.py
gsinghpal fc3c966484 changes
2026-03-13 12:38:28 -04:00

537 lines
24 KiB
Python

# -*- coding: utf-8 -*-
from odoo import http, _
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal
import json
import logging
_logger = logging.getLogger(__name__)
class QuotationPortal(CustomerPortal):
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
if 'wc_assessment_count' in counters:
Assessment = request.env['fusion.wc.assessment'].sudo()
values['wc_assessment_count'] = Assessment.search_count([
('sales_rep_id', '=', request.env.user.id),
])
return values
# =========================================================================
# ASSESSMENT LIST
# =========================================================================
@http.route('/my/quotation/builder', type='http', auth='user', website=True)
def quotation_builder_list(self, **kw):
Assessment = request.env['fusion.wc.assessment'].sudo()
assessments = Assessment.search(
[('sales_rep_id', '=', request.env.user.id)],
order='create_date desc',
limit=50,
)
# Equipment types with active flows — for "New Assessment" dropdown
Flow = request.env['fusion.wc.config.flow'].sudo()
active_flow_etypes = Flow.search([
('state', '=', 'active'),
]).mapped('equipment_type')
all_equip_types = request.env['fusion.equipment.type'].sudo().search(
[('active', '=', True)], order='sequence')
equipment_types = all_equip_types.filtered(
lambda et: et.code in active_flow_etypes)
# Build lookup dict: code → name for template display
equip_type_map = {et.code: et.name for et in all_equip_types}
return request.render('fusion_quotations.portal_quotation_list', {
'assessments': assessments,
'equipment_types': equipment_types,
'equip_type_map': equip_type_map,
'page_name': 'wc_assessments',
})
# =========================================================================
# HELPER: build template context values
# =========================================================================
def _get_form_context(self, assessment=None, requested_equipment_type=None):
"""Return common template context for the assessment form.
Args:
assessment: existing assessment record, or None for new.
requested_equipment_type: equipment type code from query param
(only used when assessment is None — new form).
"""
from types import SimpleNamespace
sections = request.env['fusion.wc.section'].sudo().search(
[('active', '=', True), ('parent_id', '=', False)],
order='sequence',
)
countries = request.env['res.country'].sudo().search([])
provinces = request.env['res.country.state'].sudo().search(
[('country_id.code', '=', 'CA')])
# Equipment types — only show types that have at least one active config flow
Flow = request.env['fusion.wc.config.flow'].sudo()
all_equip_types = request.env['fusion.equipment.type'].sudo().search(
[('active', '=', True)], order='sequence')
active_flow_etypes = Flow.search([
('state', '=', 'active'),
]).mapped('equipment_type')
equipment_types = all_equip_types.filtered(
lambda et: et.code in active_flow_etypes)
# ── Flow steps ──
# Determine the equipment type: assessment field > query param > first available > fallback
FlowStep = request.env['fusion.wc.config.flow.step'].sudo()
if assessment:
equipment_type = assessment.equipment_type
elif requested_equipment_type and requested_equipment_type in active_flow_etypes:
equipment_type = requested_equipment_type
elif active_flow_etypes:
# Default to first available type with an active flow
first_type = equipment_types[:1]
equipment_type = first_type.code if first_type else 'manual_wheelchair'
else:
equipment_type = 'manual_wheelchair'
flow = Flow.search([
('equipment_type', '=', equipment_type),
('state', '=', 'active'),
], limit=1)
# Auto-create default steps for active flows that have none
if flow and not flow.step_ids:
try:
flow.action_create_default_steps()
except Exception:
_logger.warning("Failed to auto-create steps for flow %s", flow.id)
flow_steps = flow.step_ids.sorted('sequence') if flow else FlowStep.browse()
# If no flow found, try any active flow as fallback
if not flow_steps:
flow = Flow.search([('state', '=', 'active')], limit=1)
if flow:
if not flow.step_ids:
try:
flow.action_create_default_steps()
except Exception:
pass
flow_steps = flow.step_ids.sorted('sequence')
# Final fallback: SimpleNamespace objects matching step record interface
if not flow_steps:
flow_steps = [
SimpleNamespace(id=0, name='Client & Equipment', step_type='client_info',
icon='fa-user', section_code='', fields_json='', help_text='', is_required=True),
SimpleNamespace(id=0, name='Measurements', step_type='measurements',
icon='fa-ruler', section_code='', fields_json='', help_text='', is_required=True),
SimpleNamespace(id=0, name='Frame', step_type='product_select',
icon='fa-wheelchair', section_code='frame', fields_json='', help_text='', is_required=True),
SimpleNamespace(id=0, name='Seating', step_type='options',
icon='fa-chair', section_code='seating', fields_json='', help_text='', is_required=True),
SimpleNamespace(id=0, name='Options', step_type='options',
icon='fa-list', section_code='mw_adp_options,mw_accessories', fields_json='', help_text='', is_required=True),
SimpleNamespace(id=0, name='Review', step_type='review',
icon='fa-clipboard', section_code='', fields_json='', help_text='', is_required=True),
]
# Parse fields_json for dynamic measurement/custom steps
step_fields = {}
for step in flow_steps:
fj = step.fields_json if hasattr(step, 'fields_json') else ''
if fj:
try:
step_fields[step.id] = json.loads(fj)
except (ValueError, TypeError):
step_fields[step.id] = []
# Pre-build a dict of field values so QWeb can look them up by name
field_values = {}
if assessment:
# Standard wheelchair measurement fields
for fname in [
'seat_width', 'seat_depth', 'finished_seat_to_floor_height',
'back_cane_height', 'finished_back_height',
'finished_leg_rest_length', 'client_weight',
'seat_width_unit', 'seat_depth_unit', 'seat_to_floor_unit',
'cane_height_unit', 'back_height_unit', 'leg_rest_unit',
'client_weight_unit',
]:
field_values[fname] = assessment[fname] or ''
# Equipment-specific data from JSON (for non-wheelchair types)
if assessment.equipment_data_json:
try:
equipment_data = json.loads(assessment.equipment_data_json)
if isinstance(equipment_data, dict):
field_values.update(equipment_data)
except (ValueError, TypeError):
pass
return {
'assessment': assessment,
'sections': sections,
'countries': countries,
'provinces': provinces,
'field_values': field_values,
'equipment_types': equipment_types,
'equipment_type': equipment_type,
'flow': flow,
'flow_steps': flow_steps,
'step_fields': step_fields,
}
# =========================================================================
# NEW ASSESSMENT FORM
# =========================================================================
@http.route('/my/quotation/builder/new', type='http', auth='user', website=True)
def quotation_builder_new(self, **kw):
ctx = self._get_form_context(
assessment=None,
requested_equipment_type=kw.get('equipment_type'),
)
ctx['page_name'] = 'wc_assessment_new'
return request.render('fusion_quotations.portal_quotation_form', ctx)
# =========================================================================
# EDIT EXISTING ASSESSMENT
# =========================================================================
@http.route('/my/quotation/builder/<int:assessment_id>/edit',
type='http', auth='user', website=True)
def quotation_builder_edit(self, assessment_id, **kw):
Assessment = request.env['fusion.wc.assessment'].sudo()
assessment = Assessment.browse(assessment_id)
if not assessment.exists() or assessment.sales_rep_id.id != request.env.user.id:
return request.redirect('/my/quotation/builder')
ctx = self._get_form_context(assessment=assessment)
ctx['page_name'] = 'wc_assessment_edit'
return request.render('fusion_quotations.portal_quotation_form', ctx)
# =========================================================================
# SAVE ASSESSMENT (FINAL SUBMIT)
# =========================================================================
@http.route('/my/quotation/builder/save', type='http', auth='user',
website=True, methods=['POST'], csrf=True)
def quotation_builder_save(self, **post):
Assessment = request.env['fusion.wc.assessment'].sudo()
assessment_id = int(post.get('assessment_id') or 0)
vals = self._extract_assessment_vals(post)
if assessment_id:
assessment = Assessment.browse(assessment_id)
if assessment.exists() and assessment.sales_rep_id.id == request.env.user.id:
assessment.write(vals)
else:
vals['sales_rep_id'] = request.env.user.id
assessment = Assessment.create(vals)
# Process selected items (lines)
self._process_assessment_lines(assessment, post)
# Check if user wants to generate quotation
if post.get('action') == 'generate':
try:
assessment.action_generate_quotation()
return request.redirect(
f'/my/quotation/builder/{assessment.id}/edit?success=quotation_generated')
except Exception as e:
_logger.error("Quotation generation failed: %s", str(e))
return request.redirect(
f'/my/quotation/builder/{assessment.id}/edit?error={str(e)}')
return request.redirect(
f'/my/quotation/builder/{assessment.id}/edit?success=saved')
# Known POST keys that map to standard assessment fields (not dynamic)
_KNOWN_POST_KEYS = {
'csrf_token', 'assessment_id', 'current_step', 'action', 'access_token',
'line_product_ids', 'line_section_ids', 'line_build_types',
'line_quantities', 'line_rationales',
'partner_id', 'create_new_partner',
'client_first_name', 'client_last_name', 'client_phone', 'client_email',
'client_street', 'client_street2', 'client_city', 'client_state_id',
'client_zip', 'client_country_id', 'client_dob', 'client_health_card',
'equipment_type', 'wheelchair_type', 'powerchair_type',
'client_type', 'build_type', 'reason_for_application', 'authorizer_id',
'seat_width', 'seat_depth', 'finished_seat_to_floor_height',
'back_cane_height', 'finished_back_height', 'finished_leg_rest_length',
'client_weight',
'seat_width_unit', 'seat_depth_unit', 'seat_to_floor_unit',
'cane_height_unit', 'back_height_unit', 'leg_rest_unit',
'client_weight_unit',
'frame_product_tmpl_id', 'frame_product_id', 'frame_notes', 'notes',
}
def _extract_assessment_vals(self, post):
"""Extract assessment field values from POST data."""
vals = {}
# Client
partner_id = post.get('partner_id')
if partner_id and partner_id != '0':
vals['partner_id'] = int(partner_id)
vals['create_new_partner'] = False
else:
vals['create_new_partner'] = True
vals['client_first_name'] = post.get('client_first_name', '')
vals['client_last_name'] = post.get('client_last_name', '')
vals['client_phone'] = post.get('client_phone', '')
vals['client_email'] = post.get('client_email', '')
vals['client_street'] = post.get('client_street', '')
vals['client_street2'] = post.get('client_street2', '')
vals['client_city'] = post.get('client_city', '')
state_id = post.get('client_state_id')
if state_id:
vals['client_state_id'] = int(state_id)
vals['client_zip'] = post.get('client_zip', '')
country_id = post.get('client_country_id')
if country_id:
vals['client_country_id'] = int(country_id)
dob = post.get('client_dob')
if dob:
vals['client_dob'] = dob
vals['client_health_card'] = post.get('client_health_card', '')
# Equipment
vals['equipment_type'] = post.get('equipment_type', 'manual_wheelchair')
vals['wheelchair_type'] = post.get('wheelchair_type', '')
vals['powerchair_type'] = post.get('powerchair_type', '')
vals['client_type'] = post.get('client_type', 'reg')
vals['build_type'] = post.get('build_type', 'modular')
vals['reason_for_application'] = post.get('reason_for_application', '')
# Authorizer
authorizer_id = post.get('authorizer_id')
if authorizer_id:
vals['authorizer_id'] = int(authorizer_id)
# Measurements
for field in ['seat_width', 'seat_depth', 'finished_seat_to_floor_height',
'back_cane_height', 'finished_back_height',
'finished_leg_rest_length', 'client_weight']:
val = post.get(field)
if val:
try:
vals[field] = float(val)
except (ValueError, TypeError):
pass
# Measurement units
for field in ['seat_width_unit', 'seat_depth_unit', 'seat_to_floor_unit',
'cane_height_unit', 'back_height_unit', 'leg_rest_unit',
'client_weight_unit']:
val = post.get(field)
if val:
vals[field] = val
# Frame
frame_tmpl = post.get('frame_product_tmpl_id')
if frame_tmpl:
vals['frame_product_tmpl_id'] = int(frame_tmpl)
frame_product = post.get('frame_product_id')
if frame_product:
vals['frame_product_id'] = int(frame_product)
vals['frame_notes'] = post.get('frame_notes', '')
vals['notes'] = post.get('notes', '')
# Current step
step = post.get('current_step')
if step:
vals['current_step'] = int(step)
# ── Dynamic / equipment-specific fields → equipment_data_json ──
# Collect any POST keys not in the known set — these come from
# dynamic measurement / custom steps defined via fields_json
equipment_data = {}
for key, value in post.items():
if key not in self._KNOWN_POST_KEYS and not key.startswith('section_'):
# Skip empty values
if value:
equipment_data[key] = value
if equipment_data:
# Merge with existing equipment_data_json if editing
assessment_id = int(post.get('assessment_id') or 0)
if assessment_id:
assessment = request.env['fusion.wc.assessment'].sudo().browse(assessment_id)
if assessment.exists() and assessment.equipment_data_json:
try:
existing = json.loads(assessment.equipment_data_json)
if isinstance(existing, dict):
existing.update(equipment_data)
equipment_data = existing
except (ValueError, TypeError):
pass
vals['equipment_data_json'] = json.dumps(equipment_data)
return vals
def _process_assessment_lines(self, assessment, post):
"""Process selected products from the form into assessment lines."""
AssessmentLine = request.env['fusion.wc.assessment.line'].sudo()
# Remove existing non-upcharge lines (will be recreated from form)
assessment.line_ids.filtered(lambda l: not l.is_upcharge).unlink()
# Parse lines from POST data
# Format: line_product_ids = comma-separated product IDs
# line_sections = comma-separated section IDs (parallel array)
# line_build_types = comma-separated build types (parallel array)
line_products = post.get('line_product_ids', '').split(',')
line_sections = post.get('line_section_ids', '').split(',')
line_build_types = post.get('line_build_types', '').split(',')
line_quantities = post.get('line_quantities', '').split(',')
line_rationales = post.get('line_rationales', '').split('|||')
for idx, tmpl_id_str in enumerate(line_products):
if not tmpl_id_str.strip():
continue
try:
tmpl_id = int(tmpl_id_str.strip())
except (ValueError, TypeError):
continue
# Resolve product template → first active variant
tmpl = request.env['product.template'].sudo().browse(tmpl_id)
if not tmpl.exists():
continue
product = tmpl.product_variant_ids[:1]
if not product:
continue
section_id = False
if idx < len(line_sections) and line_sections[idx].strip():
try:
section_id = int(line_sections[idx].strip())
except (ValueError, TypeError):
pass
build_type = False
if idx < len(line_build_types) and line_build_types[idx].strip():
build_type = line_build_types[idx].strip()
quantity = 1.0
if idx < len(line_quantities) and line_quantities[idx].strip():
try:
quantity = float(line_quantities[idx].strip())
except (ValueError, TypeError):
pass
rationale = ''
if idx < len(line_rationales):
rationale = line_rationales[idx].strip()
tmpl = product.product_tmpl_id
AssessmentLine.create({
'assessment_id': assessment.id,
'section_id': section_id,
'product_id': product.id,
'product_name': product.display_name,
'quantity': quantity,
'adp_device_code': tmpl.x_fc_adp_device_code or '',
'adp_price': tmpl.x_fc_adp_price or 0.0,
'unit_price': product.lst_price,
'build_type': build_type if build_type in ('modular', 'custom_fabricated') else False,
'clinical_rationale': rationale,
})
class QuotationPublic(http.Controller):
"""Public (token-based) access to the assessment form — no login required."""
def _validate_token(self, token):
"""Look up and validate an assessment by its access token."""
assessment = request.env['fusion.wc.assessment'].sudo().search([
('access_token', '=', token),
], limit=1)
if not assessment:
return None, 'not_found'
if assessment.state == 'cancelled':
return assessment, 'cancelled'
return assessment, 'ok'
# ------------------------------------------------------------------
# Public form — full portal chrome
# ------------------------------------------------------------------
@http.route('/quotation/form/<string:token>', type='http',
auth='public', website=True, sitemap=False)
def public_form_view(self, token, **kw):
"""Render the assessment form for public (unauthenticated) users."""
assessment, status = self._validate_token(token)
if status != 'ok':
return request.render('fusion_quotations.public_form_invalid', {
'status': status,
})
portal_ctrl = QuotationPortal()
ctx = portal_ctrl._get_form_context(
assessment=assessment,
requested_equipment_type=kw.get('equipment_type'),
)
ctx['page_name'] = 'wc_assessment_public'
ctx['is_public'] = True
ctx['access_token'] = token
return request.render('fusion_quotations.portal_quotation_form', ctx)
# ------------------------------------------------------------------
# Embeddable form — no portal chrome, iframe-friendly
# ------------------------------------------------------------------
@http.route('/quotation/form/<string:token>/embed', type='http',
auth='public', website=True, sitemap=False)
def embed_form_view(self, token, **kw):
"""Render a minimal, iframe-embeddable version of the form."""
assessment, status = self._validate_token(token)
if status != 'ok':
return request.render('fusion_quotations.public_form_invalid', {
'status': status,
})
portal_ctrl = QuotationPortal()
ctx = portal_ctrl._get_form_context(assessment=assessment)
ctx['page_name'] = 'wc_assessment_embed'
ctx['is_public'] = True
ctx['is_embed'] = True
ctx['access_token'] = token
resp = request.render('fusion_quotations.portal_quotation_form_embed', ctx)
resp.headers['X-Frame-Options'] = 'ALLOWALL'
resp.headers['Content-Security-Policy'] = ''
return resp
# ------------------------------------------------------------------
# Public form save (POST)
# ------------------------------------------------------------------
@http.route('/quotation/form/<string:token>/save', type='http',
auth='public', website=True, methods=['POST'],
csrf=True, sitemap=False)
def public_form_save(self, token, **post):
"""Save form data submitted from the public/embed form."""
assessment, status = self._validate_token(token)
if status != 'ok' or not assessment:
return request.redirect('/')
if assessment.state not in ('draft', 'review'):
return request.redirect(f'/quotation/form/{token}?error=readonly')
portal_ctrl = QuotationPortal()
vals = portal_ctrl._extract_assessment_vals(post)
assessment.sudo().write(vals)
portal_ctrl._process_assessment_lines(assessment, post)
if post.get('action') == 'generate':
try:
assessment.sudo().action_generate_quotation()
return request.redirect(
f'/quotation/form/{token}?success=quotation_generated')
except Exception as e:
_logger.error("Public quotation generation failed: %s", str(e))
return request.redirect(
f'/quotation/form/{token}?error={str(e)}')
return request.redirect(f'/quotation/form/{token}?success=saved')