# -*- 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//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/', 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//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//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')