Three coordinated changes on top of the section grouping: 1. **Mark as Critical** — a red chip on the New tab sets priority='3' when submitted. The central post-create hook auto-applies a "Critical" helpdesk.tag (shipped via fusion_helpdesk_central data XML, noupdate=1 so support can recolor without losing it on upgrade), giving support a kanban-groupable signal that doesn't rely on remembering what priority='3' means. Scoped to in-app-channel tickets only, so a support agent manually setting Urgent on their own ticket isn't silently tagged. 2. **KPI cards above the sections** — Total / Open / Closed / Critical in a 4-up grid (auto-collapses to 2x2 under 540px). Each card uses its own saturated gradient so it reads on both light and dark mode — the dialog backdrop is irrelevant because the gradient brings its own background. Counts are computed in JS from state.tickets so they always match what's rendered below. 3. **Colored stage pills** — red Critical, green Solved, dark-yellow New, orange Cancelled, blue for In Progress / Testing / On Hold. Critical priority gets a *separate* red pill alongside the stage pill so you keep stage info even on escalated tickets. Stage matching is substring-based (lowercased) so a renamed "Resolved" or "Done" stage on central still maps to the green pill. Tests cover the new is_critical=True → priority='3' wiring and the default omission so SLA / stage defaults keep working for normal tickets. Bumps fusion_helpdesk to 19.0.1.7.0 and fusion_helpdesk_central to 19.0.1.2.0. End-to-end smoke test verified live: priority=3 + x_fc_client_label triggers the Critical tag.
142 lines
5.7 KiB
Python
142 lines
5.7 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1
|
|
"""Pure helpers for fusion_helpdesk.
|
|
|
|
No Odoo environment, no `request` — just data in, data out. Everything
|
|
here is unit-testable in isolation, which is what lets us validate the
|
|
identity keystone, the server-side scoping boundary, the public-message
|
|
filter and the unread maths without a live central Odoo to talk to.
|
|
"""
|
|
from odoo.tools import email_normalize
|
|
|
|
# Sentinel used so a missing label/email can never widen a domain to
|
|
# "match everything". An empty string in `=`/`=ilike` would match rows
|
|
# whose field is also empty; '__none__' will simply match nothing.
|
|
_NO_MATCH = '__none__'
|
|
|
|
|
|
def escape_like(value):
|
|
"""Escape SQL LIKE/ILIKE wildcards so a user-supplied value can never
|
|
widen an `=ilike` match to other rows.
|
|
|
|
`res.users.email` is self-writeable and unvalidated, so without this a
|
|
user could set their email to ``%`` and have ``partner_email =ilike '%'``
|
|
match EVERY ticket in their deployment (a cross-user IDOR). Escaping the
|
|
backslash first, then ``%`` and ``_``, makes those characters match
|
|
literally. Real emails containing ``_`` (e.g. ``john_doe@x.com``) keep
|
|
working — the underscore is matched as a literal, which is what we want.
|
|
"""
|
|
return (value or '').replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
|
|
|
|
|
|
def _norm_email(*candidates):
|
|
"""Return the first candidate that normalises to a valid email, else ''.
|
|
|
|
Used to derive the inbox scope key from a chain of fallbacks (the
|
|
confirmed reply email -> ``user.email`` -> ``user.login``).
|
|
``email_normalize`` lowercases the address and returns a falsy value for
|
|
anything that is not exactly one valid email — including a self-set
|
|
wildcard like ``%`` — so the value fed into ``build_scope_domain`` can
|
|
never widen the scope. Pairs with :func:`escape_like` as defense in depth
|
|
against the ``partner_email =ilike`` IDOR.
|
|
"""
|
|
for candidate in candidates:
|
|
normalized = email_normalize(candidate or '')
|
|
if normalized:
|
|
return normalized
|
|
return ''
|
|
|
|
|
|
def build_ticket_vals(kind, subject, body_html, team_id, client_label,
|
|
reporter_name, reporter_email, company_name,
|
|
is_critical=False):
|
|
"""Construct the `helpdesk.ticket` create vals for a forwarded report.
|
|
|
|
The identity fields (`partner_email`, `partner_name`,
|
|
`partner_company_name`) drive native helpdesk find-or-create of the
|
|
customer partner + follower subscription on the central Odoo, and
|
|
`x_fc_client_label` tags the deployment for the scoped inbox.
|
|
|
|
When `is_critical` is set, we pass `priority='3'` (Urgent) — the central
|
|
post-create hook then auto-applies the Critical helpdesk.tag so support
|
|
can filter by it. Priority drives the inbox's Critical section too
|
|
(see `bucket_ticket`), so the same toggle is load-bearing on both ends.
|
|
"""
|
|
kind_label = 'Bug Report' if kind == 'bug' else 'Feature Request'
|
|
prefix = ('[%s] ' % client_label) if client_label else ''
|
|
vals = {
|
|
'name': '%s%s: %s' % (prefix, kind_label, subject or '(untitled)'),
|
|
'description': body_html,
|
|
'partner_name': reporter_name or '',
|
|
}
|
|
if team_id:
|
|
vals['team_id'] = team_id
|
|
if reporter_email:
|
|
vals['partner_email'] = reporter_email
|
|
if company_name:
|
|
vals['partner_company_name'] = company_name
|
|
if client_label:
|
|
vals['x_fc_client_label'] = client_label
|
|
if is_critical:
|
|
vals['priority'] = '3'
|
|
return vals
|
|
|
|
|
|
def build_scope_domain(label, email, is_admin):
|
|
"""Server-side ticket scope for the embedded inbox.
|
|
|
|
`x_fc_client_label` is ALWAYS bound (defense in depth) so neither a
|
|
regular user nor a deployment admin can ever read another
|
|
deployment's tickets — even though the shared bot can technically see
|
|
every ticket on the central Odoo. Regular users are additionally
|
|
bound to their own `partner_email`.
|
|
"""
|
|
domain = [('x_fc_client_label', '=', label or _NO_MATCH)]
|
|
if not is_admin:
|
|
safe_email = escape_like(email)
|
|
domain.append(('partner_email', '=ilike', safe_email or _NO_MATCH))
|
|
return domain
|
|
|
|
|
|
def is_public_message(msg):
|
|
"""True when a message is customer-visible (not an internal note).
|
|
|
|
`msg` is a plain dict carrying a `subtype_is_internal` flag resolved
|
|
from the central `mail.message.subtype`. Internal notes must never be
|
|
shown to a client in the embedded inbox.
|
|
"""
|
|
return not msg.get('subtype_is_internal', False)
|
|
|
|
|
|
def bucket_ticket(stage_is_folded, priority):
|
|
"""Bucket key for the "My Tickets" inbox: 'critical' | 'solved' | 'open'.
|
|
|
|
Folded stages (Solved, Cancelled — whatever the central support team has
|
|
marked as kanban-folded) collapse into the 'solved' bucket regardless of
|
|
priority, because a closed ticket is not actionable. For everything still
|
|
open, High and Urgent (priority '2'/'3') promote to 'critical' so the
|
|
reporter can find blockers without scrolling. Anything else is 'open'.
|
|
"""
|
|
if stage_is_folded:
|
|
return 'solved'
|
|
if (priority or '0') in ('2', '3'):
|
|
return 'critical'
|
|
return 'open'
|
|
|
|
|
|
def compute_unread_count(tickets, seen_by_id):
|
|
"""Number of tickets with a support reply the user hasn't seen.
|
|
|
|
`tickets` is a list of dicts each carrying `id` and
|
|
`last_support_msg_id` (id of the latest customer-visible support
|
|
message, 0 if none). `seen_by_id` maps central ticket id -> last
|
|
message id the user has seen (absent => 0 baseline).
|
|
"""
|
|
count = 0
|
|
for ticket in tickets:
|
|
last = ticket.get('last_support_msg_id') or 0
|
|
if last and last > (seen_by_id.get(ticket['id']) or 0):
|
|
count += 1
|
|
return count
|