Files
Odoo-Modules/fusion_plating/fusion_plating_quality/controllers/fp_quality_dashboard.py
gsinghpal e271908109 feat(quality_dashboard): populate section items (Task 3)
_fetch_section_items pulls top-5 open records per type, ranked
overdue-first by oldest create_date. _build_item shapes each row
with id/name/customer/subtitle/urgency/open_action. _resolve_partner
defensively walks partner_id -> job_id.partner_id -> ncr_id.partner_id
per type. _build_subtitle generates the human-readable second line.

Tests cover empty list, 5-cap on 8-record set, and required item
keys (id/name/customer/subtitle/urgency/open_action).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:20:50 -04:00

228 lines
9.3 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Quality Dashboard snapshot endpoint.
Spec: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md
Replaces the old /fp/quality/dashboard/counts router-style endpoint
with a single /fp/quality/dashboard/snapshot that returns:
- banner: up to 6 most urgent items across all types (red "Needs
Attention Today" surface)
- sections: 6 per-type sections in canonical order, each with top-5
items + open/overdue counts
"""
import logging
from datetime import timedelta
from odoo import fields, http
from odoo.http import request
_logger = logging.getLogger(__name__)
# Canonical section order — drives sections[] in the response.
# Sections whose model isn't installed are omitted (no error).
SECTION_ORDER = ['cert', 'hold', 'ncr', 'rma', 'capa', 'check']
# Per-type config: model name, kanban xmlid, display label, icon.
TYPE_CONFIG = {
'cert': {'model': 'fp.certificate',
'label': 'Certificates', 'icon': '🏷️',
'kanban_xmlid': 'fusion_plating_certificates.action_fp_certificate'},
'hold': {'model': 'fusion.plating.quality.hold',
'label': 'Holds', 'icon': '🛑',
'kanban_xmlid': 'fusion_plating_quality.action_fusion_plating_quality_hold'},
'ncr': {'model': 'fusion.plating.ncr',
'label': 'NCRs', 'icon': '🔬',
'kanban_xmlid': 'fusion_plating_quality.action_fusion_plating_ncr'},
'rma': {'model': 'fusion.plating.rma',
'label': 'RMAs', 'icon': '↩️',
'kanban_xmlid': 'fusion_plating_quality.action_fusion_plating_rma'},
'capa': {'model': 'fusion.plating.capa',
'label': 'CAPAs', 'icon': '📋',
'kanban_xmlid': 'fusion_plating_quality.action_fusion_plating_capa'},
'check': {'model': 'fusion.plating.quality.check',
'label': 'QC Checks', 'icon': '',
'kanban_xmlid': 'fusion_plating_quality.action_fusion_plating_quality_check'},
}
# Per-type "overdue" thresholds (reused from the old counts endpoint —
# battle-tested). CAPA branches on due_date < today via use_due_date.
OVERDUE_THRESHOLDS = {
'cert': {'days': 1, 'use_due_date': False,
'state_domain': [('state', '=', 'draft')]},
'hold': {'days': 3, 'use_due_date': False,
'state_domain': [('state', 'in', ('on_hold', 'under_review'))]},
'ncr': {'days': 7, 'use_due_date': False,
'state_domain': [('state', 'in',
('open', 'containment', 'disposition'))]},
'rma': {'days': 5, 'use_due_date': False,
'state_domain': [('state', '=', 'received')]},
'capa': {'days': None, 'use_due_date': True,
'state_domain': [('state', 'not in',
('closed', 'effective'))]},
'check': {'days': 1, 'use_due_date': False,
'state_domain': [('state', '=', 'pending')]},
}
class FpQualityDashboardSnapshot:
"""Builds the dashboard snapshot in one pass.
Per Rule 13m, all cross-module field reads are sudo'd so non-admin
users (Sales Reps viewing the dashboard) don't AccessError on
partner_id.x_fc_rush / part_catalog_id.name / customer_spec_id.code.
The Open-action navigates via standard act_window which re-enforces
ACL on click (Rule 24 / D15).
"""
BANNER_MAX = 6
SECTION_TOP_N = 5
def __init__(self, env):
self.env = env
self.now = fields.Datetime.now()
self.today = fields.Date.context_today(env.user)
def build(self):
sections = []
for type_code in SECTION_ORDER:
section = self._build_section(type_code)
if section is None:
continue # model not installed — omit
sections.append(section)
# Banner placeholder for Task 2 — fully implemented in Task 4.
banner = {'items': [], 'all_clear': True, 'total_matching': 0}
return {
'banner': banner,
'sections': sections,
'computed_at': self.now.isoformat(),
}
def _build_section(self, type_code):
"""Return section dict with top-N items + counts.
Returns None when the model isn't installed."""
cfg = TYPE_CONFIG[type_code]
if cfg['model'] not in self.env:
return None
# Open/overdue counts using the existing state filters
state_dom = list(OVERDUE_THRESHOLDS[type_code]['state_domain'])
Model = self.env[cfg['model']].sudo()
open_count = Model.search_count(state_dom)
overdue_count = self._overdue_count(type_code, Model)
return {
'type': type_code,
'label': cfg['label'],
'icon': cfg['icon'],
'open': open_count,
'overdue': overdue_count,
'items': self._fetch_section_items(type_code, Model),
'open_kanban_xmlid': cfg['kanban_xmlid'],
}
def _overdue_count(self, type_code, Model):
"""Count overdue per the OVERDUE_THRESHOLDS dispatch table."""
cfg = OVERDUE_THRESHOLDS[type_code]
dom = list(cfg['state_domain'])
if cfg['use_due_date']:
dom += [('due_date', '<', self.today),
('due_date', '!=', False)]
else:
cutoff = self.now - timedelta(days=cfg['days'])
dom += [('create_date', '<', cutoff)]
return Model.search_count(dom)
def _fetch_section_items(self, type_code, Model):
"""Top-N open records for the type, ordered by urgency
(overdue first by oldest create_date, then by create_date asc).
Returns list of item dicts in the snapshot shape.
"""
state_dom = list(OVERDUE_THRESHOLDS[type_code]['state_domain'])
# We need urgency labels per record — fetch open set, sort in
# Python by (overdue_flag, age) so the top-N reflects urgency.
recs = Model.search(state_dom, limit=200) # safety cap
if not recs:
return []
overdue_ids = set(self._overdue_ids(type_code, Model))
# Sort: overdue first (by oldest create_date), then non-overdue
# by oldest create_date.
def rank_key(r):
is_od = r.id in overdue_ids
return (0 if is_od else 1, r.create_date or self.now)
recs_sorted = sorted(recs, key=rank_key)
top = recs_sorted[:self.SECTION_TOP_N]
return [self._build_item(type_code, r, r.id in overdue_ids)
for r in top]
def _overdue_ids(self, type_code, Model):
"""IDs of overdue records for the type — reuses _overdue_count
domain logic."""
cfg = OVERDUE_THRESHOLDS[type_code]
dom = list(cfg['state_domain'])
if cfg['use_due_date']:
dom += [('due_date', '<', self.today),
('due_date', '!=', False)]
else:
cutoff = self.now - timedelta(days=cfg['days'])
dom += [('create_date', '<', cutoff)]
return Model.search(dom).ids
def _build_item(self, type_code, rec, is_overdue):
"""Shape one record into the snapshot item dict."""
cfg = TYPE_CONFIG[type_code]
# Customer name — partner_id direct, OR via job_id for check
partner = self._resolve_partner(rec)
return {
'id': rec.id,
'name': rec.display_name or rec.name or f'#{rec.id}',
'customer': partner.name if partner else '',
'subtitle': self._build_subtitle(type_code, rec, is_overdue),
'urgency': 'overdue' if is_overdue else 'normal',
'critical_badge': None, # populated in Task 4
'open_action': {
'res_model': cfg['model'],
'res_id': rec.id,
},
}
def _resolve_partner(self, rec):
"""Walk to partner: direct partner_id, or via job_id (checks),
or via ncr_id (capa). Defensive on missing fields."""
if 'partner_id' in rec._fields and rec.partner_id:
return rec.partner_id
if 'job_id' in rec._fields and rec.job_id \
and 'partner_id' in rec.job_id._fields:
return rec.job_id.partner_id
if 'ncr_id' in rec._fields and rec.ncr_id \
and 'partner_id' in rec.ncr_id._fields:
return rec.ncr_id.partner_id
return False
def _build_subtitle(self, type_code, rec, is_overdue):
"""Second-line text per type. Implementation-phase choice
per spec open question 1."""
# Age in human terms — hours if < 24h, days otherwise
if rec.create_date:
age = self.now - rec.create_date
hours = int(age.total_seconds() / 3600)
age_str = (f'{hours}h' if hours < 24
else f'{hours // 24}d')
else:
age_str = ''
if is_overdue:
return f'{age_str} overdue' if age_str else 'overdue'
return age_str
class FpQualityDashboardController(http.Controller):
@http.route('/fp/quality/dashboard/snapshot',
type='jsonrpc', auth='user', methods=['POST'])
def snapshot(self):
"""Return the full dashboard snapshot. See spec §Data model."""
return FpQualityDashboardSnapshot(request.env).build()