# -*- coding: utf-8 -*- # Copyright 2024-2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) """Troubleshooting flowchart designer-time models. Three models cooperate: fusion.repair.flowchart one chart per (category, symptom) -> fusion.repair.flowchart.node one row per visual node -> fusion.repair.flowchart.edge directed edges between nodes The full visual layout (positions, drawflow internal state) is also serialised to flowchart.canvas_layout as JSON so the Drawflow designer round-trips without needing per-node x/y stored separately. The structured node/edge records exist for queries (e.g. "show me all flowcharts that mention 'control board' in any node"), reporting, runtime traversal, and so that the runner can operate without parsing the canvas JSON. """ import json import logging from odoo import _, api, fields, models from odoo.exceptions import UserError, ValidationError _logger = logging.getLogger(__name__) NODE_TYPES = [ ('question', 'Question'), ('suggestion', 'Suggestion'), ('info', 'Info'), ('outcome', 'Outcome'), ] OUTCOME_KINDS = [ ('resolved', 'Resolved on Call'), ('escalate', 'Escalate to Technician'), ('order_part', 'Order Part'), ] class FusionRepairFlowchart(models.Model): _name = 'fusion.repair.flowchart' _inherit = ['mail.thread'] _description = 'Repair Troubleshooting Flowchart' _order = 'category_id, symptom_class_id, name' name = fields.Char(required=True, translate=True, tracking=True) category_id = fields.Many2one( 'fusion.repair.product.category', string='Equipment Category', required=True, index=True, ondelete='cascade', tracking=True, ) symptom_class_id = fields.Many2one( 'fusion.repair.symptom.class', string='Symptom', required=True, index=True, ondelete='cascade', domain="[('category_id', '=', category_id)]", tracking=True, ) description = fields.Html(translate=True) active = fields.Boolean(default=True, tracking=True) published = fields.Boolean( default=False, tracking=True, help='Only published flowcharts are offered to CS during intake. Draft ' 'charts are visible to admins for editing but never run.', ) version = fields.Integer( default=1, readonly=True, copy=False, help='Bumped on every save in the designer; lets us snapshot which ' 'version a flowchart.run was based on.', ) canvas_layout = fields.Text( string='Designer State', help='Drawflow JSON for the visual designer canvas - positions, zoom, ' 'connection curves. Round-tripped by the designer UI.', ) node_ids = fields.One2many('fusion.repair.flowchart.node', 'flowchart_id') edge_ids = fields.One2many('fusion.repair.flowchart.edge', 'flowchart_id') node_count = fields.Integer(compute='_compute_counts') edge_count = fields.Integer(compute='_compute_counts') start_node_id = fields.Many2one( 'fusion.repair.flowchart.node', string='Start Node', compute='_compute_start_node', store=True, help='Auto-computed - whichever node has is_start=True. Validation ' 'enforces exactly one.', ) company_id = fields.Many2one( 'res.company', default=lambda self: self.env.company, ) _active_per_pair_unique = models.Constraint( # NULLs treated as distinct so multiple draft (active=False) charts # can coexist; only one active+published chart per pair is the # business rule we care about - enforced via Python below. 'unique(category_id, symptom_class_id, company_id, published)', 'Only one published flowchart per (category, symptom) per company. ' 'Unpublish or duplicate before publishing another.', ) @api.depends('node_ids', 'edge_ids') def _compute_counts(self): for r in self: r.node_count = len(r.node_ids) r.edge_count = len(r.edge_ids) @api.depends('node_ids.is_start') def _compute_start_node(self): for r in self: r.start_node_id = r.node_ids.filtered(lambda n: n.is_start)[:1] # ------------------------------------------------------------------ # ACTIONS # ------------------------------------------------------------------ def action_open_designer(self): """Open the OWL Drawflow designer as a client action.""" self.ensure_one() return { 'type': 'ir.actions.client', 'tag': 'fusion_repair_flowchart_designer', 'name': _('Flowchart Designer - %s') % self.name, 'params': {'flowchart_id': self.id}, } def action_publish(self): for r in self: r._validate_publishable() r.published = True r.message_post(body=_('Flowchart published. CS will now see it for ' 'new intakes on this (category, symptom).')) def action_unpublish(self): for r in self: r.published = False def action_duplicate_as_draft(self): self.ensure_one() copy = self.copy({ 'name': _('%s (copy)') % self.name, 'published': False, 'version': 1, }) return { 'type': 'ir.actions.act_window', 'name': copy.name, 'res_model': self._name, 'view_mode': 'form', 'res_id': copy.id, } # ------------------------------------------------------------------ # VALIDATION # ------------------------------------------------------------------ def _validate_publishable(self): for r in self: starts = r.node_ids.filtered(lambda n: n.is_start) if len(starts) != 1: raise ValidationError(_( 'Flowchart "%s" must have exactly one start node ' '(found %d). Edit in the designer and try again.' ) % (r.name, len(starts))) # Every non-outcome node should have at least one outgoing edge. for n in r.node_ids: if n.node_type != 'outcome' and not r.edge_ids.filtered( lambda e: e.source_node_id == n ): raise ValidationError(_( 'Node "%s" is not an outcome but has no outgoing edges. ' 'Connect it to the next step or change its type to ' 'Outcome.' ) % n.name) # ------------------------------------------------------------------ # DESIGNER RPC API (called from the OWL component) # ------------------------------------------------------------------ @api.model def designer_load(self, flowchart_id): """Return everything the designer needs to render this chart.""" chart = self.browse(flowchart_id).exists() if not chart: raise UserError(_('Flowchart not found.')) return { 'id': chart.id, 'name': chart.name, 'category_id': chart.category_id.id, 'category_name': chart.category_id.name, 'symptom_class_id': chart.symptom_class_id.id, 'symptom_name': chart.symptom_class_id.name, 'published': chart.published, 'version': chart.version, 'canvas_layout': chart.canvas_layout or '', 'nodes': [n._designer_dict() for n in chart.node_ids], 'edges': [e._designer_dict() for e in chart.edge_ids], } def designer_save(self, payload): """Replace the chart's nodes + edges with the designer's snapshot. payload = { 'canvas_layout': '', 'nodes': [{'client_id': str, 'name': ..., 'node_type': ..., 'content_html': ..., 'media_ids': [int], 'is_start': bool, 'outcome_kind': ..., 'canvas_x': int, 'canvas_y': int}, ...], 'edges': [{'source_client_id': str, 'target_client_id': str, 'label': str, 'sequence': int}, ...], } Returns the saved chart (designer_load shape). """ self.ensure_one() # Atomic replace - simplest and safest. Edges depend on nodes so # delete edges first. self.edge_ids.unlink() self.node_ids.unlink() client_to_db = {} Node = self.env['fusion.repair.flowchart.node'].sudo() for nd in payload.get('nodes', []): rec = Node.create({ 'flowchart_id': self.id, 'name': nd.get('name') or _('Untitled'), 'node_type': nd.get('node_type') or 'question', 'content_html': nd.get('content_html') or False, 'is_start': bool(nd.get('is_start')), 'outcome_kind': nd.get('outcome_kind') or False, 'canvas_x': int(nd.get('canvas_x') or 0), 'canvas_y': int(nd.get('canvas_y') or 0), 'media_ids': [(6, 0, nd.get('media_ids') or [])], }) client_to_db[nd.get('client_id')] = rec.id Edge = self.env['fusion.repair.flowchart.edge'].sudo() for ed in payload.get('edges', []): src = client_to_db.get(ed.get('source_client_id')) tgt = client_to_db.get(ed.get('target_client_id')) if not src or not tgt: _logger.warning( 'Skipping edge with unknown client ids: %s -> %s', ed.get('source_client_id'), ed.get('target_client_id'), ) continue Edge.create({ 'flowchart_id': self.id, 'source_node_id': src, 'target_node_id': tgt, 'label': ed.get('label') or '', 'sequence': int(ed.get('sequence') or 10), }) self.write({ 'canvas_layout': payload.get('canvas_layout') or '', 'version': self.version + 1, }) # Soft-validate (don't raise) - the designer can publish later. try: self._validate_publishable() except ValidationError as e: _logger.info('Saved chart %s but it is not yet publishable: %s', self.name, e) return self.designer_load(self.id) class FusionRepairFlowchartNode(models.Model): _name = 'fusion.repair.flowchart.node' _description = 'Flowchart Node' _order = 'flowchart_id, sequence, id' flowchart_id = fields.Many2one( 'fusion.repair.flowchart', required=True, ondelete='cascade', index=True, ) name = fields.Char(required=True, translate=True) sequence = fields.Integer(default=10) node_type = fields.Selection( NODE_TYPES, default='question', required=True, ) content_html = fields.Html( translate=True, sanitize=True, sanitize_overridable=True, help='Rich text shown to CS when this node is the current step. ' 'Photos / videos go in media_ids instead.', ) media_ids = fields.Many2many( 'ir.attachment', 'fusion_repair_flowchart_node_media_rel', 'node_id', 'attachment_id', string='Media', help='Photos and short videos that illustrate this step.', ) is_start = fields.Boolean( string='Start', help='Exactly one node per flowchart must be the start.', ) outcome_kind = fields.Selection( OUTCOME_KINDS, string='Outcome Kind', help='Required when node_type is Outcome - determines what happens ' 'when the run reaches this node.', ) # Designer-only - persisted for round-trip but not used by the runtime. canvas_x = fields.Integer(default=0) canvas_y = fields.Integer(default=0) outgoing_edge_ids = fields.One2many( 'fusion.repair.flowchart.edge', 'source_node_id', string='Outgoing Edges', ) incoming_edge_ids = fields.One2many( 'fusion.repair.flowchart.edge', 'target_node_id', string='Incoming Edges', ) def _designer_dict(self): self.ensure_one() return { 'id': self.id, 'name': self.name, 'node_type': self.node_type, 'content_html': self.content_html or '', 'is_start': self.is_start, 'outcome_kind': self.outcome_kind or '', 'canvas_x': self.canvas_x, 'canvas_y': self.canvas_y, 'media_ids': self.media_ids.ids, 'media': [{ 'id': a.id, 'name': a.name, 'mimetype': a.mimetype, 'url': f'/web/image/{a.id}', } for a in self.media_ids], } class FusionRepairFlowchartEdge(models.Model): _name = 'fusion.repair.flowchart.edge' _description = 'Flowchart Edge' _order = 'source_node_id, sequence, id' flowchart_id = fields.Many2one( 'fusion.repair.flowchart', required=True, ondelete='cascade', index=True, ) source_node_id = fields.Many2one( 'fusion.repair.flowchart.node', required=True, ondelete='cascade', index=True, ) target_node_id = fields.Many2one( 'fusion.repair.flowchart.node', required=True, ondelete='cascade', index=True, ) label = fields.Char( translate=True, help='Shown on the button in the runner ("Yes", "No", ' '"Wheel turns freely"...).', ) sequence = fields.Integer(default=10) def _designer_dict(self): self.ensure_one() return { 'id': self.id, 'source_node_id': self.source_node_id.id, 'target_node_id': self.target_node_id.id, 'label': self.label or '', 'sequence': self.sequence, }