8 distinct bugs caught + fixed while testing the live admin DB on entech
after the migration was approved. Each surfaced a real Odoo 19 gotcha
now codified in CLAUDE.md (rules 13b-13l).
Picker architecture:
- res.users.x_fc_plating_landing_action_id and res.company.x_fc_default_landing_action_id
now Many2one('ir.actions.actions') instead of ('ir.actions.act_window'),
so the picker accepts BOTH window actions (Sale Orders / Quotations /
Process Recipes) AND client actions (Manager Desk / Plant Kanban /
Quality Dashboard). Picker went from 3 entries to 6.
- x_fc_pickable_landing field moved from the two subclasses to the
ir.actions.actions base. Single source of truth.
- _render_resolved on the base dispatches to the correct subclass by
action type.
Non-admin Preferences access:
- Added ACL grant: group_fp_technician (and all higher roles via
implication) get read on ir.actions.actions. Without this, opening
Preferences raised AccessError on the picker domain evaluation.
- Removed the accessible_landing_action_ids Many2many compute (failed
for non-admins because field assignment requires write access on
the comodel relation, even with sudo'd search). Picker now shows all
6 entries to all users; resolver falls through gracefully if the
user picks an action they can't reach.
- res.users SELF_WRITEABLE_FIELDS / SELF_READABLE_FIELDS extended via
@property + super() (NOT class attribute — Odoo 19 changed the
pattern). Non-admin users can now save the Preferences dialog with
plating fields without hitting the standard write ACL.
Migration workflow:
- res.groups.users -> .user_ids (Odoo 19 rename; deprecated alias
removed). Was crashing _fp_notify_owners and _cron_purge_expired.
- user.message_post -> user.partner_id.message_post (res.users uses
_inherits delegation which doesn't expose mail.thread methods).
Was crashing the Owner approval click.
Tablet lock screen:
- /fp/tablet/tiles points at group_fp_technician instead of the old
group_fusion_plating_operator. Post-migration nobody holds the old
group directly (only via implication), so res.groups.user_ids on
the old xmlid returned empty — 'No operators configured' shown
even with PIN set.
- PIN pad dots dark mode: empty dot now dark gray (#424245), filled
dot now pure white. Previous version had both at light shades so
user couldn't see PIN entry progress.
- Lock-screen logo frame dark mode: near-opaque white plate
(rgba 0.95) so company logos designed for light backgrounds
render correctly. Previous 0.08 alpha let the dark page bleed
through.
Pre-deploy collision fix (already committed before deploy but
documented here for completeness):
- pre-migrate.py to rename old configurator's 'Shop Manager' group
display name before new fp_security_v2.xml loads the new
group_fp_shop_manager_v2 with the same display name (avoids
res_groups_name_uniq violation).
Module versions bumped:
fusion_plating: 19.0.21.1.0 -> 19.0.21.1.2
fusion_plating_shopfloor: 19.0.32.0.4 -> 19.0.32.0.6
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
147 lines
5.6 KiB
Python
147 lines
5.6 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.
|
|
"""Fusion Plating role helpers on res.users.
|
|
|
|
The x_fc_plating_role Selection field is a clean UX wrapper around the
|
|
seven plating-role groups. Owner-only Team page reads/writes this field
|
|
via drag-and-drop on a kanban grouped by role.
|
|
"""
|
|
from markupsafe import Markup
|
|
|
|
from odoo import _, api, fields, models
|
|
|
|
|
|
_FP_PLATING_ROLE_TO_GROUP_XMLID = {
|
|
'technician': 'fusion_plating.group_fp_technician',
|
|
'sales_rep': 'fusion_plating.group_fp_sales_rep',
|
|
'shop_manager': 'fusion_plating.group_fp_shop_manager_v2',
|
|
'sales_manager': 'fusion_plating.group_fp_sales_manager',
|
|
'manager': 'fusion_plating.group_fp_manager',
|
|
'quality_manager': 'fusion_plating.group_fp_quality_manager',
|
|
'owner': 'fusion_plating.group_fp_owner',
|
|
}
|
|
|
|
# Highest precedence first — first match wins
|
|
_FP_ROLE_PRECEDENCE = (
|
|
'owner', 'quality_manager', 'manager', 'sales_manager',
|
|
'shop_manager', 'sales_rep', 'technician',
|
|
)
|
|
|
|
|
|
class ResUsers(models.Model):
|
|
_inherit = 'res.users'
|
|
|
|
# Allow non-admin users to write their OWN plating-related fields
|
|
# from the standard User Preferences dialog. SELF_WRITEABLE_FIELDS is
|
|
# a @property in Odoo 19 (not a class attribute) — must override via
|
|
# @property + super(). See CLAUDE.md rule 13k.
|
|
@property
|
|
def SELF_WRITEABLE_FIELDS(self):
|
|
return super().SELF_WRITEABLE_FIELDS + [
|
|
'x_fc_plating_landing_action_id', # personal landing-page override
|
|
'x_fc_signature_image', # "Plating Signature" used on reports
|
|
]
|
|
|
|
@property
|
|
def SELF_READABLE_FIELDS(self):
|
|
return super().SELF_READABLE_FIELDS + [
|
|
'x_fc_plating_landing_action_id',
|
|
'x_fc_signature_image',
|
|
'x_fc_plating_role',
|
|
'x_fc_tablet_pin_set_date',
|
|
]
|
|
|
|
x_fc_plating_role = fields.Selection(
|
|
[
|
|
('no', 'No'),
|
|
('technician', 'Technician'),
|
|
('sales_rep', 'Sales Representative'),
|
|
('shop_manager', 'Shop Manager'),
|
|
('sales_manager', 'Sales Manager'),
|
|
('manager', 'Manager'),
|
|
('quality_manager', 'Quality Manager'),
|
|
('owner', 'Owner'),
|
|
],
|
|
compute='_compute_plating_role',
|
|
inverse='_inverse_plating_role',
|
|
store=True,
|
|
string='Fusion Plating Role',
|
|
help='Highest plating role currently held by this user. Changing this '
|
|
'field reassigns the user to the corresponding res.groups (clears '
|
|
'old plating groups, adds new). Posts an audit chatter message.',
|
|
)
|
|
|
|
@api.depends('group_ids')
|
|
def _compute_plating_role(self):
|
|
# Resolve xmlids once
|
|
role_to_group = {}
|
|
for role, xmlid in _FP_PLATING_ROLE_TO_GROUP_XMLID.items():
|
|
grp = self.env.ref(xmlid, raise_if_not_found=False)
|
|
if grp:
|
|
role_to_group[role] = grp
|
|
for user in self:
|
|
user.x_fc_plating_role = 'no'
|
|
for candidate in _FP_ROLE_PRECEDENCE:
|
|
grp = role_to_group.get(candidate)
|
|
if grp and grp in user.group_ids:
|
|
user.x_fc_plating_role = candidate
|
|
break
|
|
|
|
def _inverse_plating_role(self):
|
|
# Resolve all plating-role group ids
|
|
all_role_ids = []
|
|
role_to_group = {}
|
|
for role, xmlid in _FP_PLATING_ROLE_TO_GROUP_XMLID.items():
|
|
grp = self.env.ref(xmlid, raise_if_not_found=False)
|
|
if grp:
|
|
role_to_group[role] = grp
|
|
all_role_ids.append(grp.id)
|
|
|
|
# I4 fix — capture old roles BEFORE the cache mutates by reading
|
|
# the stored x_fc_plating_role column directly from PostgreSQL.
|
|
# `user._origin.x_fc_plating_role` returns the IN-CACHE new value
|
|
# (the assignment that triggered the inverse), not the prior DB
|
|
# value, so the chatter audit displayed "X -> X" instead of the
|
|
# actual old -> new transition.
|
|
self.env.cr.execute(
|
|
"SELECT id, x_fc_plating_role FROM res_users WHERE id IN %s",
|
|
(tuple(self.ids),) if self.ids else ((0,),),
|
|
)
|
|
old_role_by_id = dict(self.env.cr.fetchall())
|
|
|
|
for user in self:
|
|
old_role = old_role_by_id.get(user.id) or 'unset'
|
|
new_role = user.x_fc_plating_role
|
|
if old_role == new_role:
|
|
# No actual change — skip both the writes and the audit so
|
|
# we don't spam chatter with "X -> X" rows.
|
|
continue
|
|
|
|
# Remove every plating-role group (additive-by-default Odoo
|
|
# m2m write of (3, id) removes single rows)
|
|
user.sudo().write({
|
|
'group_ids': [(3, gid) for gid in all_role_ids]
|
|
})
|
|
|
|
# Add the chosen role (no-op for 'no')
|
|
if new_role and new_role != 'no':
|
|
target = role_to_group.get(new_role)
|
|
if target:
|
|
user.sudo().write({
|
|
'group_ids': [(4, target.id)]
|
|
})
|
|
|
|
# Post audit (Markup so role names render bold, not literal HTML)
|
|
user.partner_id.message_post(
|
|
body=Markup(_(
|
|
'Plating role changed: <b>%(old)s</b> -> <b>%(new)s</b> by %(actor)s'
|
|
)) % {
|
|
'old': old_role,
|
|
'new': new_role or 'unset',
|
|
'actor': self.env.user.name,
|
|
},
|
|
message_type='notification',
|
|
)
|