Two big workflow additions:
1. Visual drag-and-drop flowchart designer (Drawflow) + card-by-card runner
(with show-whole-tree toggle) so admins build per-(category, symptom)
decision trees with embedded photos/videos and CS walks callers through
them on the phone. Resolved-on-call closes the repair; escalated copies
the full transcript into internal_notes so the dispatched tech sees what
was already tried before they arrive at the client.
2. Vendor + draft-PO + factory-tracking on the part-order capture. Tech on
the phone with the factory picks the vendor from contacts, types the OEM
part #, cost, ETA date (calendar widget), factory ticket #, RA #, ticks
under_warranty, and the system auto-creates a draft purchase.order with
the right product (looked up or created from OEM) + activity for the
office on the ETA day + client email with ETA prominently shown and
cost intentionally omitted.
NEW MODELS
fusion.repair.symptom.class - lookup table (category + name + code).
Replaces the flat x_fc_issue_category Char on repair.order. Seeded with
7 stairlift symptoms + lighter coverage for hospital bed / porch lift /
lift chair. Equipment Class added to fusion.repair.product.category
(this carried over from the Bundle 10 plan).
fusion.repair.flowchart + .node + .edge - design-time graph.
- flowchart has name, category, symptom, version, published flag,
canvas_layout (Drawflow JSON), node_ids, edge_ids, computed start_node
- node has node_type (question / suggestion / info / outcome),
content_html, media_ids (M2M ir.attachment for photos + videos),
is_start, outcome_kind (resolved / escalate / order_part),
canvas_x/y for Drawflow round-trip
- edge has source, target, label, sequence - supports N-ary branching
(not just Yes/No)
- designer_load() and designer_save(payload) RPC API the OWL component
consumes; save is atomic-replace + bumps version + soft-validates
fusion.repair.flowchart.run + .step - runtime sessions.
- One run per repair, audited; runtime_start_or_resume() returns the
existing in-progress run or creates a fresh one for the matching chart
- runtime_choose(edge_id, cs_note) records a step + advances current_node
- runtime_complete(outcome) snapshots final node + calls _apply_outcome:
resolved -> auto-close via action_repair_start + action_repair_end,
set x_fc_resolved_on_call, post transcript to chatter
escalated -> prepend transcript to repair.internal_notes so the tech
sees it first when they open the form
order_part -> chatter note; tech opens visit-report wizard next
abandoned -> just save transcript
- Each step snapshots node_name + chosen_label at write time so the
transcript survives later chart edits without breaking.
REPAIR.ORDER EXTENSIONS
- x_fc_symptom_class_id (M2O) - new structured symptom field
- x_fc_resolved_on_call (Boolean, tracked) - true after a resolved outcome
- x_fc_flowchart_run_ids + x_fc_flowchart_run_count
- action_start_troubleshoot() - opens the runner client action, raises a
helpful UserError if no symptom set or no published chart exists
- action_view_flowchart_runs() smart button
- x_fc_issue_category renamed string to "(legacy)" - kept for back-compat
+ AI prompt context; new intakes set the M2O
DRAWFLOW DESIGNER (OWL)
static/src/lib/drawflow/drawflow.min.{js,css} - vendored Drawflow 0.0.59
(MIT). Loaded only in web.assets_backend, ~48KB total.
components/flowchart_designer/flowchart_designer.{js,xml,scss}:
- Client action "fusion_repair_flowchart_designer" with full drag-drop
canvas + zoom + pan
- 4 custom node templates color-banded by type (question blue,
suggestion green, info gray, outcome red/green/amber per outcome_kind)
- Right-panel editor for selected node: title, type, outcome kind,
content (HTML), media uploader (drag-drop or click), set-as-start
toggle, per-outgoing-edge label editor
- Save serializes Drawflow JSON to canvas_layout + atomic-replaces the
structured node/edge rows via the designer_save RPC
CARD RUNNER (OWL)
components/flowchart_runner/flowchart_runner.{js,xml,scss}:
- Client action "fusion_repair_flowchart_runner"
- DEFAULT MODE: card-by-card. One big card per node, embedded photos +
inline <video controls>, answer buttons sized for phone use, CS note
textarea (saved as cs_note on the step), running transcript at the
bottom
- TOGGLE: "Show Whole Tree" loads the same Drawflow lib in read-only
fixed mode, imports the canvas_layout JSON, highlights current node
yellow / visited green via .fr-current / .fr-visited classes
- Outcome buttons drive the right runtime_complete() call; success
notifications + auto-return to the parent repair form
- "Abandon & Escalate" header button at all times - transcript is saved
even on bail-out so the dispatched tech still benefits
PART ORDER + VENDOR PO
repair.part.order new fields:
vendor_partner_id (M2O res.partner, is_company domain), purchase_order_id
(auto-created draft PO), product_id (auto-resolved or created),
unit_cost (Monetary) + currency_id, internal_po_ref, factory_ticket_ref,
factory_ra_number, under_warranty.
action_create_draft_po() - resolves product.product by OEM (default_code)
or creates a new one in a "Spare Parts" product.category, creates a
purchase.order in draft state with one line (product + qty + price_unit
+ date_planned from expected_date or +7d), stamps Westin's internal PO
ref as partner_ref so the factory can find it on return. Office reviews
and confirms via the normal Odoo flow.
_schedule_eta_activity() - schedules a Repair: Assign Technician activity
on the parent repair.order due on expected_date, assigned to
repair.user_id, so the office is reminded to call the client and book
the return visit on the day parts arrive.
VISIT-REPORT WIZARD PARTLINE EXTENSIONS
Same new fields exposed inline on the partline list so the tech captures
everything on the phone with the factory in one form:
vendor_partner_id (vendors-only filter), unit_cost + currency,
expected_date (calendar widget) replacing expected_lead_days as the
preferred input, under_warranty, internal_po_ref, factory_ticket_ref,
factory_ra_number, create_draft_po (default True - auto-builds PO on
submit when vendor + cost are both set).
CLIENT EMAIL TIGHTENED
email_template_parts_ordered:
- Subject now includes ETA "Parts ordered for your stairlift - expected 2026-06-06"
- Hero ETA panel: large blue-bordered card with "Expected Arrival" label
and the date in 24px bold
- Cost INTENTIONALLY OMITTED - "Our office will call you to confirm a
return visit time. If you have any questions about pricing or
scheduling, please reach out to our office directly."
- "There is nothing for you to do right now." callout
UI
- repair.order form header: new "Start Troubleshooting" button (info
style, sitemap icon, visible when state in (draft, confirmed,
under_repair) AND symptom is set)
- repair.order form intake row: x_fc_symptom_class_id picker filtered to
the category, x_fc_resolved_on_call display when true
- repair.part.order form: header button "Create Draft Purchase Order"
+ new Vendor / Cost / Warranty group + System group with the PO link
- Intake wizard equipment line: symptom_class_id picker
- New menus:
Configuration > Symptom Classes
Configuration > Troubleshooting Flowcharts
Fusion Repairs > Troubleshooting Sessions (run history)
SECURITY
18 new ACL rows for the 6 new models, scoped Manager-full / User-read /
FieldTech-read. Flowchart runs and steps get write access for User so CS
can record steps; Manager owns flowchart + node + edge CRUD.
POST-MIGRATION (19.0.2.2.0)
Existing installs: walks all distinct (category, x_fc_issue_category) text
pairs on repair.order, creates a placeholder fusion.repair.symptom.class
per pair (or reuses an existing match by code/name), back-fills the new
x_fc_symptom_class_id M2O. Idempotent + safe to re-run.
DEPENDENCY
Added 'purchase' to depends (action_create_draft_po needs purchase.order).
VERIFIED END-TO-END on local westin-v19 (Margaret persona, 0 bugs):
STEP 0 seed: chart v1 8 nodes / 12 edges / published, 7 stairlift
symptoms, stairlift class=lift_elevating
STEP 1 CS creates RO-202605-60 with symptom Not Moving
STEP 2 Start Troubleshooting -> client action tag returned
STEP 3 walk run: Power on? Yes -> Seatbelt? Yes -> Swivel? Yes ->
outcome 'Still not moving - dispatch technician'
(outcome_kind=escalate)
STEP 4 runtime_complete('escalated') -> internal_notes prepended with
CS troubleshooting summary
STEP 5 visit-report parts_needed with vendor Handicare + cost $425 +
warranty + factory refs -> PART-00008 created + draft
PO 26690 auto-built with line "Handicare 1100 control
board" qty 1 @ $425, partner_ref WH-2026-1042
STEP 6 mark_ordered -> client email queued (NO cost mentioned, ETA
shown prominently) + office activity scheduled for
2026-06-06
STEP 7 fresh resume returns same run; resolved outcome auto-closes the
repair (state=done, x_fc_resolved_on_call=True)
Bumped to 19.0.2.2.0.
Co-authored-by: Cursor <cursoragent@cursor.com>
End-to-end spec for the owner-approval feature on fusion_helpdesk +
fusion_helpdesk_central. Captures data model, engagement flow (single +
bulk), magic-link approval portal, OpenAI summary, reminder cron,
reporting dashboard, edge cases, and test plan. Ready for the
writing-plans skill to turn into an implementation plan.
The OWL dialog used <t t-out="m.body"/> on message bodies, but t-out
escapes plain strings — it only renders raw when the value is a Markup
instance. Bodies arrive over JSON-RPC as plain strings (Markup is a
client-side type, doesn't cross the wire), so the customer was seeing
literal "<p>This has been fixed.</p>" in the thread instead of the
rendered HTML.
Wrap incoming bodies in `markup()` at the boundary (openTicket +
sendReply call sites) so the template renders them as the sanitised
HTML the central chatter already produced. Trust is fine — the body is
sanitised server-side by mail.thread before it ever leaves nexa.
Bumps fusion_helpdesk to 19.0.1.7.1.
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.
The flat write_date-sorted list was hard to scan with 50+ tickets — solved
ones were intermixed with active ones, and there was no signal for
priority. Bucket each ticket server-side into 'critical' (open + priority
High/Urgent), 'solved' (stage marked fold=True on central) or 'open'
(everything else), and render three labelled sections in the dialog with
sticky headers, count badges, and per-group accent colours. Backend keeps
its write_date desc order so latest is always at top within each bucket.
Bucketing uses helpdesk.stage.fold (not the stage name) so renaming
"Solved" to "Done" on the central won't quietly mis-categorise rows.
Adds bucket_ticket() in utils.py with unit tests covering the
folded-wins-over-priority precedence and the missing-priority fallback.
Also surfaces a small Urgent (triangle) / High (arrow) icon on each row
so a critical ticket reads at a glance even after a user scrolls past
the section header.
Bumps fusion_helpdesk to 19.0.1.6.0.
The customer-followup ship left two papercuts that hid 51 historical
tickets from the entech owner:
1. group_reporter_admin had zero members on install — the new XML record
created the group but never granted it. Extend base.group_system's
implied_ids so every system administrator transparently inherits the
admin view of the embedded inbox on install / upgrade. (4, id) tuple
is additive — never replaces base's existing implications.
2. Tickets created before this feature shipped had NULL
x_fc_client_label and NULL partner_email, so the scope filter
excluded them all. The reporter identity was still recoverable from
the description HTML's diag block. Backfill recipe is captured in
CLAUDE.md so future deployments can apply the same one-shot UPDATE
without re-deriving the regex.
Bumps fusion_helpdesk to 19.0.1.5.0. Verified live on entech: all six
base.group_system members now return True for
has_group('fusion_helpdesk.group_reporter_admin').
Adds two Integer fields to res.partner:
- x_fc_default_lead_time_min_days
- x_fc_default_lead_time_max_days
Set once on the customer's Plating Defaults tab (Fulfilment group);
auto-copies onto every new Express Order via the existing
_onchange_partner_id hook. Operator can still override per-order
since the onchange only fills when the wizard field is still blank.
Field declaration lives in fusion_plating_configurator (alongside
the rest of the partner cascade reads). View edit lives in
fusion_plating_invoicing where the Plating Defaults tab already
hosts the other partner-level defaults (invoice strategy, deposit
%, delivery method, deadline-days). Invoicing depends on
configurator, so the fields are registered before the view loads.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three related fixes on the Express Orders totals card:
1. Totals card now breaks out Subtotal / Tax / Tooling Charge /
Grand Total. Previously the "Subtotal" and "Grand Total" rows
both read from total_amount (same value rendered twice) and no
tax was shown at all. Customers on a fiscal position-mapped
tax rate (Ontario HST, etc.) had their taxes silently dropped
from the preview.
2. tooling_charge now feeds the Grand Total. The total_amount
compute previously summed line subtotals only. Added a real
SO line for the tooling charge in action_create_order so the
eventual sale.order.amount_total matches the preview AND the
invoice carries a "Tooling Charge" line item.
3. tax_ids is now visible as an optional column on the lines
list. Operator can see + override the auto-applied tax per
line. Default still comes from FP-SERVICE product mapped
through partner.property_account_position_id (fiscal position).
New compute fields on fp.direct.order.wizard:
- total_subtotal (sum of line.qty * line.unit_price, pre-tax)
- total_tax (sum of line + tooling taxes via compute_all)
- total_amount (subtotal + tax + tooling — was just subtotal)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Durable: nexa/entech upgrade commands, central service-account Contact
Creation prerequisite, backup-outside-addons-path gotcha, smoke-tests-must-
call-the-controller lesson. Plus current deploy status + the one remaining
step (browser confirmation of My Tickets / New on entech).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Squash-merge of feat/helpdesk-customer-followup. The billing and
fusion_login_audit work from that branch is already on main (landed
separately); this lands only the helpdesk feature.
- Identity keystone: submit() forwards partner_email/partner_name/
x_fc_client_label so the central Helpdesk find-or-creates the customer
partner and subscribes them as a follower (enables reply emails + magic link).
- Embedded in-app 'My Tickets' inbox: server-side scoped read/reply RPC
endpoints, per-user seen tracking (fusion.helpdesk.ticket.seen), systray
unread badge. Defense-in-depth scope domain + _norm_email normalisation
(wildcard emails cannot widen scope).
- fusion_helpdesk_central: x_fc_client_label field + list/search views +
branded acknowledgement email template.
- Deployed and smoke-tested live: nexa central 19.0.1.1.0, entech client
19.0.1.4.1 (requires Contact Creation on the central service account).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the login string resolves to an existing user and the password is
wrong, BOTH overrides used to write a failure row:
- _check_credentials wrapper: result=failure, reason=bad_password
- _login wrapper (catching the propagating AccessDenied): result=
failure, reason=unknown_user
Discovered in production smoke on westin-v19 after the deploy: a
single failed login for info@gsafinancialconsulting.com produced two
audit rows (one bad_password, one unknown_user). The unknown_user
label was wrong — the user IS in the system.
Fix: _login now checks whether the login string resolves to any user
BEFORE writing the unknown_user row. If yes, _check_credentials
already logged the attempt and _login skips. If no, the user lookup
in super() failed and _login is the only chance to log.
Regression test test_login_known_user_bad_password_single_row asserts
exactly one row per attempt and that the row carries bad_password
(not unknown_user) when the user exists.
30 tests green locally; production smoke on westin-v19 confirms:
one row per failed login, bad_password, IP 172.18.0.1 captured.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Capture in the plan the Odoo 19 gotchas discovered during execution
that the original plan template missed:
- Test command requires --http-port=0 --gevent-port=0 (running
container holds 8069).
- Declarative models.Constraint / models.Index (T2).
- res.users.groups_id renamed to group_ids (T3, T6).
- ir.rule groups is additive not restrictive (T3).
- mail.template inline-template ctx IS env.context (T11).
- ir.cron has no numbercall field in 19 (T12).
- registry.cursor() in tests is TestCursor; cr.commit() raises;
use savepoints (T13).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Asserts the smart-button and Login Activity tab fields are stripped
from res.users get_view() for non-admin users, and present for
Settings admins. Locks down the contract behind the
groups="base.group_system" XML attributes on the form-inheritance
view (the inherited view record cannot carry groups itself per
CLAUDE.md rule #11; the gate must live on the inner nodes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5-min cron processes up to 100 pending rows per pass: private IPs
short-circuit to state=private_ip; same-IP cache (30 days) avoids
duplicate ip-api.com calls; reverse DNS via socket with 1.5s timeout;
HTTP lookup respects ip-api''s X-Rl rate-limit header. Tests cover
private-IP shortcut, cache hit (no HTTP), and internal-state skip --
no network calls needed.
Per-row isolation uses cr.savepoint() instead of cr.commit() because
Odoo 19 TestCursor raises AssertionError on commit/rollback. Recorded
the gotcha as CLAUDE.md rule #14.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds _fc_retention_gc() that deletes rows older than the configured
horizon (default 365 days; 0 = keep forever). Registered as a daily
ir.cron. Tests verify both the delete path and the "keep forever"
short-circuit.
Also documents the Odoo 19 gotcha that ir.cron dropped the numbercall
field (the legacy "-1 = run forever" pattern now raises ValueError at
install time; just omit the field).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mail template + helpers (_fc_alert_*, _fc_recent_failure_count,
_fc_send_failure_alert) wired into _check_credentials so that crossing
the consecutive-failure threshold within the window queues exactly one
mail.mail per attempted login per 60-minute cooldown. Master switch
x_fc_login_audit_alert_enabled honoured. Recipients are members of
base.group_system with a non-empty email and share=False; the
__system__ superuser is excluded by Odoo''s default user filter.
Tests (3 new, 22 total green):
test_failure_burst_queues_one_email
test_cooldown_suppresses_second_alert
test_alert_disabled_master_switch
setUp ensures base.user_admin has an email (fusion-dev''s admin user
ships without one; the only user with an email is __system__, which
is filtered out of standard res.users searches).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four x_fc_* fields on res.config.settings backed by ir.config_parameter:
retention_days (default 365, 0 = forever), alert_threshold (5),
alert_window_min (15), alert_enabled (True). New "Login Audit" block
on the General Settings page (gated by base.group_system on the block,
NOT on the inherited view record per CLAUDE.md rule #11).
CLAUDE.md gotchas added during this task:
#5 Boolean config_parameter fields don't round-trip "False" as a
string — IrConfigParameter.set_param deletes the row on falsy.
Test with assertFalse, never assertEqual(..., "False").
#6 ir.ui.view uses group_ids (Odoo 19 rename mirrored from res.users).
Setting groups_id on an ir.ui.view record raises ValueError at
install. (The XML attribute groups="..." on inner nodes is
unrelated and still works.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
List, form, and search views for fusion.login.audit, plus a "Login
Events" full-history action and a "Failed Logins (24h)" pre-filtered
action. Both surface under Settings -> Technical -> Login Audit
(menu items gated by base.group_system). Views are no-create / no-edit
/ no-delete to enforce append-only at the UI layer too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds four x_fc_* fields on res.users: login_audit_ids (One2many),
login_audit_count (compute), last_successful_login (compute, stored),
last_login_ip (compute, stored). action_fc_view_login_audit returns
a window action scoped to the current user. View inheritance adds a
smart button to the button box and a "Login Activity" page to the
notebook, both gated by base.group_system on the inner XML nodes
(NOT on the view record — Odoo 19 forbids that; see CLAUDE.md rule #11).
Tests (2 new, 18 total green):
test_computed_last_successful_login — uses registry cursor to commit
the audit row so the stored compute picks it up across the
TransactionCase boundary.
test_action_view_login_audit_returns_window_action — smart-button
action shape + domain scoping.
CLAUDE.md rule #11 added: inherited ir.ui.view records cannot have
groups/group_ids on the record; the gate must be on the inner XML nodes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Overrides res.users._login. When the login string does not resolve to
any user, super() raises AccessDenied; we record a row with user_id=NULL
and failure_reason="unknown_user", then re-raise. Closes the gap where
typo'd or scanned logins would otherwise vanish from the audit trail.
The existing _fc_record_login_event helper writes through an independent
registry.cursor(), so the audit row survives the rollback that follows
the re-raised AccessDenied.
Note: in Odoo 19 _login is a plain instance method (not the classmethod
it was in earlier versions) and takes (credential, user_agent_env). The
original plan was written for the classmethod signature; corrected here
and recorded in CLAUDE.md rule #10 so future-Claude does not waste time
re-discovering it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps res.users._check_credentials. On AccessDenied, records a row with
result=failure and failure_reason='bad_password' (or '2fa_failed' when
credential['type'] == 'totp'), then re-raises. Regression test asserts
the attempted password value never lands in any audit field.
The audit row is written through registry.cursor() (independent cursor) so
it survives the rollback that follows AccessDenied — in production
odoo/service/model.py::retrying resets the transaction and http.py closes
the cursor without committing, in tests assertRaises opens its own
savepoint. Either way an inline write would vanish. Tests
enter registry_test_mode and use manual try/except to keep the audit row
visible across the savepoint hierarchy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Overrides res.users._update_last_login to create a fusion.login.audit
row with result=success after the parent runs. The write goes through
sudo() + mail_create_nolog=True. Any exception in the audit path is
caught and logged but never propagates — a broken audit table must
never block a real user from logging in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single helper builds vals for fusion.login.audit rows from the live
HTTP request, or falls back to ip=''internal'' + geo_lookup_state=''internal''
when there is no request. Parses UA into browser/os/device_type via the
bundled user_agents library. Never reads credential[''password'']. Tests
cover: no-request fallback, UA parsing on a Chrome/Windows UA, and the
regression that no password value leaks into the vals dict.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Record rule grants admins an unrestricted domain on the audit log;
ACL forbids write/create/unlink for every group (audit is append-only;
sudo() inside auth hooks is the only write path). Defence-in-depth
layering: ACL is the actual gate, the rule documents and locks down
admin access path.
Tests (5, all green) cover:
test_admin_can_read_through_acl_and_rule — positive path through both.
test_acl_blocks_read_for_regular_user — base.group_user denied by ACL.
test_acl_blocks_read_for_portal_user — base.group_portal share user
denied (sensitive data leakage
surface closed at ACL layer).
test_acl_blocks_write_for_admin — append-only at the write boundary.
test_acl_blocks_unlink_for_admin — append-only at the unlink boundary.
Drop the redundant `from . import tests` from the root __init__.py —
Odoo's test loader imports `odoo.addons.<mod>.tests` directly; the
extra import was dead weight (and inconsistent with the repo pattern).
CLAUDE.md gotchas added during this task:
#6 res.users.groups_id -> group_ids rename (test setUp pitfall).
#6 ir.rule `groups` is additive, not restrictive — group-scoped
rules only apply to users in that group, they do not restrict
non-members. Default to letting the ACL gate; use rules for
row-level filters ACLs cannot express.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- All 16 columns per spec (user, attempted_login, result, failure_reason,
event_time, ip/geo fields, user_agent triple, device_type, database).
- Check constraint binds failure_reason presence to result value.
- Three composite indexes (user+time, login+time, geo_state+time) supporting
the per-user, failure-burst, and geo cron queries.
- Minimal admin-read ACL added so subsequent tests can verify writes.
- 3 TransactionCase tests passing: model create, failure_reason nullable on
success, geo_lookup_state='internal' accepted.
Odoo 19 deprecation note: this implementation uses the declarative
models.Constraint and models.Index attributes (Odoo 19 silently drops the
legacy `_sql_constraints = [...]` list and `init()`/raw-SQL pattern with
only a warning). Captured in CLAUDE.md rule #9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empty installable module with manifest, package inits, and icon.
Subsequent tasks add the audit model, hooks, views, and tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Durable login audit for Odoo 19 (westin-v19). Captures successful and
failed authentications via _update_last_login / _check_credentials /
_login overrides, surfaces history on res.users as a smart button +
"Login Activity" tab (admins-only), async geo-enriches IPs via ip-api.com
through network_logger, 365-day retention with daily GC cron, and
emails Settings admins on N consecutive failures for the same login
within a configurable window.
Motivation: a spot audit of GSA Accounting (uid 63) showed Odoo's
res_users_log keeps only one row per user (rest is GC'd), /var/log/odoo
is empty (warn-level stdout logging), and the container json log
rotates within days — leaving no durable login trail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
docs(billing): session handoff — core on main, sub-project #2 (NexaCloud) next
Captures resume state for the centralized-billing initiative: core engine done
and on main, the 4-chunk decomposition of sub-project #2 (NexaCloud adapter +
dual-run reconciliation), the pending "where to start" decision, open questions,
and the test/branch workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
Add _match_api_key() class method to fusion.billing.service, with a
TDD test suite (TestServiceApiKey) covering key generation, hash storage,
positive match, and rejection of bad/inactive keys. Also fix
fcb_test_on_trial.sh to use --http-port 8070, as Odoo 19 forces
http_spawn() even under --no-http when --test-enable is set.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Local dev Odoo is Community (can't install the module). Add a guest-exec runner
that syncs the module to the odoo-trial Enterprise sandbox (VM 316, db trial) and
runs --test-enable there; pass = FCB_EXIT=0. Scaffold verified installing on
Odoo 19.0 Enterprise (7 fusion_billing_* tables created).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Centralize billing for all NexaSystems services (NexaCloud, NexaDesk,
NexaMaps, custom apps, memberships) on the Odoo 19 Enterprise instance,
replacing Lago. The module adds only the metering + integration layer;
native sale_subscription / account_accountant / payment_stripe do all the
financial work (invoicing, HST, dunning, portal, credit notes, Stripe).
Includes:
- Design spec (docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md):
6 locked decisions, architecture, data model, usage engine, Lago-shaped
API, webhook control loop, NexaCloud pilot, phased dual-run migration.
- Module scaffold: 7 fusion.billing.* models (service, account.link, metric,
charge, usage, webhook, reconciliation), bearer-auth API controller shell,
security ACLs, README. Compiles on Odoo 19.0; engine/API bodies are stubs
pending the implementation plan.
- CLAUDE.md rule #15: no sale.subscription model in Odoo 19 — a subscription
is a sale.order(is_subscription) + sale.subscription.plan (verified live).
Task 0 verified: a single Stripe account is shared across NexaCloud and all
Lago providers, so no Stripe account/card migration is required.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fp.step.template rows already held 'fa-bathtub' (1), 'fa-flag' (2),
and 'fa-undo' (2) — all plating-relevant and presumably valid in an
earlier version of the Selection list. When step_insert snapshot-
copied these into a fresh fusion.plating.process.node via
_copy_snapshot_fields, the ORM rejected them with
ValueError: Wrong value for fusion.plating.process.node.icon
because they weren't in the curated 39-icon list anymore.
Adding 'fa-bathtub' (bathtub / tank / soak), 'fa-flag' (flag /
milestone / gate), and 'fa-undo' (undo / rework / rerun) to the
process.node Selection. Aligns the two lists (template uses
_get_icon_selection -> node._fields['icon'].selection at runtime).
No data migration needed — existing template rows immediately
re-validate against the wider Selection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FpExpressActionBtns.onOpen called action_open_part which returned an
ir.actions.act_window dict without a 'views' key. Odoo 19's
_preprocessAction in the web client tries to .map over action.views
and throws TypeError: Cannot read properties of undefined (reading 'map').
Fix: include 'views': [[False, 'form']] alongside view_mode='form' on
both copies of action_open_part (wizard line + sale.order.line).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three view edits to surface the new cert toggles + workflow nudges:
1. res.partner — Plating Documents tab gains a "Aerospace / Defence"
separator + group with the three new toggles (Nadcap / MTR /
Customer-Specific). All boolean_toggle widget, default OFF.
2. fp.process.node — Recipe form gains a "Certificate Output" group
visible only when node_type == 'recipe'. Five requires_* toggles
+ a blue info banner explaining the suppress-only precedence.
3. fp.certificate — Certificate PDF tab gains a yellow alert banner
when certificate_type is one of the three orphan types AND no
attachment is set. Tells the operator "this type expects a PDF
you upload from disk".
Sub: docs/superpowers/specs/2026-05-27-recipe-cert-toggles-design.md
Task: T6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>