Files
Odoo-Modules/fusion_repairs/wizard/repair_intake_wizard.py
gsinghpal 750c7068e2 fix(fusion_repairs): activity-create access error + dashboard landing
Two complaints from the first hands-on test:

1) Submit button raised "Access Error (Document type: Activity,
   Operation: create)" - the wizard called the intake service WITHOUT
   sudo so the mail.activity records the service schedules tripped on
   the activity ACL (admin's group chain does not auto-grant activity
   create on repair.order without sudo). Both portal controllers
   already sudo'd; the wizard now does too. x_fc_intake_user_id
   preserves audit identity regardless.

   Verified end-to-end as gsingh@westinhealthcare.com (admin):
     Created: BR-WA/RO/00025
     Activities: 2
     Source: backend_wizard
     Intake user: gsingh@westinhealthcare.com

2) "Real dashboard with dedicated pages would have been nice" - the
   main menu opened the wizard directly as a modal. Restructured so
   the menu lands on a proper kanban dashboard of service calls,
   matching the standard Odoo app pattern:

   Fusion Repairs (app icon)
     - Service Calls         <- dashboard kanban (default landing)
     - New Service Call      <- wizard (still a modal, accessed from menu OR kanban's New button)
     - All Repair Orders     <- native Odoo repair list (full backend)
     - Maintenance Contracts
     - Configuration
         - Equipment Categories / Intake Templates / Service Catalogue / Repair Warranties

   New view_fusion_repair_dashboard_kanban shows urgency badges (red /
   amber / grey), category, scheduled date, intake source pill, and
   a 3rd-party warning. Default group_by=state.

   New view_fusion_repair_dashboard_search adds quick filters: Today,
   This Week, Safety/Urgent, Third-Party, Open, plus per-source filters
   and Group By (Status / Urgency / Category / Intake Source).

   Wizard remains target='new' (modal) so submitting drops the user
   back to the kanban they came from with the new repair visible.

Bumped version to 19.0.1.0.2 to bust the asset bundle hash.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 22:38:27 -04:00

202 lines
6.9 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Backend intake wizard.
A simple Phase 1 transient model that captures one-or-many equipment items
per call, then delegates to fusion.repair.intake.service to create the
repair.order(s). The shared service guarantees identical behaviour to the
sales rep portal and the public client portal added in later phases.
Multi-equipment per call is supported via the equipment_ids One2many.
"""
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class RepairIntakeWizard(models.TransientModel):
_name = 'fusion.repair.intake.wizard'
_description = 'Repair Intake Wizard'
# ------------------------------------------------------------------
# CALLER / CLIENT
# ------------------------------------------------------------------
intake_user_id = fields.Many2one(
'res.users',
string='Taken By',
default=lambda self: self.env.user,
required=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Client',
required=True,
help='Existing client. Use the create-and-edit dialog to add a new contact.',
)
partner_phone = fields.Char(
related='partner_id.phone',
string='Phone',
readonly=True,
)
# ------------------------------------------------------------------
# EQUIPMENT (one-or-many)
# ------------------------------------------------------------------
equipment_ids = fields.One2many(
'fusion.repair.intake.wizard.equipment',
'wizard_id',
string='Equipment Items',
required=True,
)
# ------------------------------------------------------------------
# SUBMIT
# ------------------------------------------------------------------
def action_submit(self):
self.ensure_one()
if not self.equipment_ids:
raise UserError(_('Please add at least one piece of equipment.'))
payload = {
'partner_id': self.partner_id.id,
'intake_user_id': self.intake_user_id.id,
'equipment_items': [self._equipment_payload(eq) for eq in self.equipment_ids],
}
# sudo() so sub-operations (mail.activity, mail.mail, fusion.technician.task)
# never trip on permission checks - x_fc_intake_user_id preserves audit identity.
repairs = self.env['fusion.repair.intake.service'].sudo().create_repair_orders(
payload, source='backend_wizard',
)
if len(repairs) == 1:
return {
'type': 'ir.actions.act_window',
'name': repairs.name,
'res_model': 'repair.order',
'view_mode': 'form',
'res_id': repairs.id,
}
return {
'type': 'ir.actions.act_window',
'name': _('Service Calls Created (%(count)s)', count=len(repairs)),
'res_model': 'repair.order',
'view_mode': 'list,form',
'domain': [('id', 'in', repairs.ids)],
}
def _equipment_payload(self, eq):
"""Render an equipment record as a dict the intake service expects."""
return {
'product_id': eq.product_id.id or False,
'lot_id': eq.lot_id.id or False,
'repair_category_id': eq.repair_category_id.id or False,
'intake_template_id': eq.intake_template_id.id or False,
'third_party': eq.third_party,
'urgency': eq.urgency,
'issue_summary': eq.issue_summary or '',
'issue_category': eq.issue_category or '',
'internal_notes': eq.internal_notes or '',
'schedule_date': eq.scheduled_date or False,
'photo_attachment_ids': eq.photo_ids.ids if eq.photo_ids else [],
'answers': [], # Phase 1 wizard doesn't expose per-question answer rows yet
}
class RepairIntakeWizardEquipment(models.TransientModel):
"""A single piece of equipment captured in the wizard.
Multiple lines = multi-equipment intake (one repair.order per line).
"""
_name = 'fusion.repair.intake.wizard.equipment'
_description = 'Repair Intake Wizard - Equipment Line'
_order = 'sequence, id'
wizard_id = fields.Many2one(
'fusion.repair.intake.wizard',
string='Wizard',
required=True,
ondelete='cascade',
)
sequence = fields.Integer(default=10)
# Equipment identification
repair_category_id = fields.Many2one(
'fusion.repair.product.category',
string='Category',
required=True,
)
product_id = fields.Many2one(
'product.product',
string='Product',
help='Specific product if known. Leave blank for generic equipment.',
)
lot_id = fields.Many2one(
'stock.lot',
string='Serial Number',
domain="[('product_id', '=', product_id)]",
help='Lot or serial number if known.',
)
third_party = fields.Boolean(
string='Not Purchased From Us',
help='Tick if this equipment was bought elsewhere - we still service it but '
'warranty is not honoured and a service call-out fee applies.',
)
# Intake context
intake_template_id = fields.Many2one(
'fusion.repair.intake.template',
string='Question Template',
help='Defaults to the template configured on the category if left blank.',
)
# Triage
urgency = fields.Selection(
[('normal', 'Normal'), ('urgent', 'Urgent'), ('safety', 'Safety Issue')],
string='Urgency',
default='normal',
required=True,
)
scheduled_date = fields.Datetime(
string='Preferred Date',
default=fields.Datetime.now,
)
issue_summary = fields.Char(
string='Issue Summary',
help='One-line summary of what is wrong (e.g. "stairlift stops halfway up").',
)
issue_category = fields.Char(
string='Symptom Category',
help='Optional symptom tag for catalogue matching (e.g. "battery", "motor").',
)
internal_notes = fields.Text(string='Internal Notes')
photo_ids = fields.Many2many(
'ir.attachment',
'fusion_repair_intake_wizard_eq_photo_rel',
'eq_id',
'attachment_id',
string='Photos / Videos',
)
@api.onchange('repair_category_id')
def _onchange_repair_category_id(self):
"""Pre-fill the intake template from the category default."""
if self.repair_category_id and not self.intake_template_id:
self.intake_template_id = self.repair_category_id.intake_template_id
@api.onchange('product_id')
def _onchange_product_id(self):
"""Pre-fill the category from the product if defined."""
if self.product_id and not self.repair_category_id:
cat = self.product_id.product_tmpl_id.x_fc_repair_category_id
if cat:
self.repair_category_id = cat