Captures everything the next Claude session needs to pick up cold:
- Live module versions on entech (portal 19.0.3.7.0, jobs/reports
versions, all 5 tests green)
- What shipped this session (24+ commits, summarised by area)
- Sub-A (IA + sidebar) brainstorm decisions locked, spec written,
plan ready to execute (11 tasks, 4 phases)
- What's deferred (sub-B multi-user, sub-C search, drafts, real
statements, RMA portal, top-recurring-parts) and WHY — so next
session doesn't re-litigate
- Gotchas hit + fixed this session that aren't obvious from code
- Deploy recipe (file copy + module upgrade + cache bust) used 20+
times this session
CLAUDE.md's Recent Session Handoff section now points to the new
handoff doc; the previous handoff is kept as 'superseded but kept
for context' below it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
59 KiB
Customer Portal IA + Sidebar — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Wrap every /my/* portal page in a sticky 240px left sidebar (grouped Dashboard → Activity → Documents → Account), replace 3 thin custom pages with Odoo defaults or new equivalents, and ship a new /my/account_summary page with 3 tabs (Invoices · Credit Memos · Statements) + Open Balance pill.
Architecture: New fp_portal_shell template inherits portal.portal_layout and injects the sidebar around every existing portal body — zero per-template edits for the chrome change. Sidebar data structure lives in one Python helper and feeds the template via _prepare_portal_layout_values(). New Account Summary page is a single controller + template, modeled on the existing Odoo /my/invoices portal pattern.
Tech Stack: Odoo 19 (Python + QWeb XML + SCSS), vanilla JS for mobile hamburger toggle, no JS framework. Deployment via SSH to entech LXC 111 (native Odoo, db admin).
Spec: docs/superpowers/specs/2026-05-17-portal-ia-sidebar-design.md
File Inventory
NEW files:
fusion_plating_portal/views/fp_portal_shell.xml—portal.portal_layoutinherit + sidebar markupfusion_plating_portal/views/fp_portal_account_summary.xml—portal_my_account_summarytemplatefusion_plating_portal/static/src/scss/fp_portal_sidebar.scss— sidebar layout, sticky, active state, sections, mobile drawerfusion_plating_portal/static/src/js/fp_portal_sidebar.js— hamburger toggle (vanilla JS, ~20 lines)
MODIFY files:
fusion_plating_portal/controllers/portal.py— addportal_account_summaryroute, 3 redirect routes,_fp_sidebar_items()helper, extend_prepare_portal_layout_values()fusion_plating_portal/views/fp_portal_templates.xml— delete theportal_my_fp_invoicestemplate body (route is redirected)fusion_plating_portal/tests/test_portal_dashboard.py— add Account Summary tests (Open Balance, tab partitioning, filter, search)fusion_plating_portal/__manifest__.py— version bump 19.0.3.7.0 → 19.0.4.0.0 (sidebar is a significant chrome change, minor bump), register new XML + SCSS + JS files
Decisions baked in:
- Statements tab in V1 is a placeholder ("Monthly statements coming soon — contact your sales rep for a copy"). Real statement generation (account.followup integration OR cron-precomputed PDFs) is its own follow-up — spec §Open Items §2/§5.
- Sidebar item active-state matched by
page_name(FP routes) OR URL-prefix (Odoo defaults), one helper.
PHASE 1 — Sidebar Shell
Goal: every /my/* page renders inside the new sidebar wrap. No page bodies change. Empty Account Summary placeholder; redirects + tabbed view come in Phases 2 and 3.
Task 1: Create fp_portal_sidebar.scss
Files:
-
Create:
fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss -
Step 1: Write the sidebar SCSS
Create the file with this content:
// ============================================================================
// Fusion Plating — Portal · Sidebar shell
// Sticky 240px left rail wrapping every /my/* page. Grouped sections
// (Dashboard / ACTIVITY / DOCUMENTS / ACCOUNT). Active page = mint
// gradient fill + brand teal left bar. Below 768px collapses to a
// hamburger drawer with backdrop.
// ============================================================================
.o_fp_portal_shell {
display: grid;
grid-template-columns: 240px 1fr;
gap: $fp-space-5;
align-items: start;
background: $fp-page-bg;
min-height: calc(100vh - 80px);
padding: $fp-space-4;
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: 0;
padding: $fp-space-3;
}
}
.o_fp_portal_sidebar {
position: sticky;
top: $fp-space-4;
background: $fp-card-bg;
border: 1px solid $fp-card-border;
border-radius: $fp-radius-card;
padding: .85rem .5rem;
box-shadow: $fp-shadow-card;
font-family: $fp-font;
align-self: start;
.o_fp_sidebar_header {
padding: .45rem .9rem .7rem;
font-size: .62rem;
color: $fp-muted;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
border-bottom: 1px solid $fp-section-bg;
}
.o_fp_sidebar_section_label {
padding: .85rem .9rem .25rem;
font-size: .62rem;
color: $fp-muted-light;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
}
.o_fp_sidebar_item {
display: flex;
align-items: center;
gap: .55rem;
padding: .5rem .9rem;
margin: .05rem .15rem;
color: $fp-text-body;
font-size: .85rem;
text-decoration: none;
border-radius: 6px;
border-left: 3px solid transparent;
transition: background .12s ease, color .12s ease;
&:hover {
background: $fp-section-bg;
color: $fp-teal-dark;
text-decoration: none;
}
&.o_fp_sidebar_active {
background: linear-gradient(90deg, $fp-mint 0%, $fp-mint-pastel 100%);
color: $fp-teal-dark;
font-weight: 600;
border-left: 3px solid $fp-teal;
}
.o_fp_sidebar_icon {
width: 1.15rem;
text-align: center;
flex-shrink: 0;
}
}
.o_fp_sidebar_footer {
border-top: 1px solid $fp-section-bg;
margin: .7rem .15rem 0;
padding-top: .5rem;
}
// Mobile: slide-in drawer
@media (max-width: 768px) {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 280px;
z-index: 1040;
transform: translateX(-100%);
transition: transform .2s ease;
border-radius: 0;
border-top: none;
border-bottom: none;
border-left: none;
margin: 0;
&.o_fp_open {
transform: translateX(0);
}
}
}
// Mobile hamburger button (above main content, hidden on desktop)
.o_fp_portal_hamburger {
display: none;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
background: $fp-card-bg;
border: 1px solid $fp-card-border;
border-radius: $fp-radius-button;
color: $fp-teal;
margin-bottom: $fp-space-3;
cursor: pointer;
transition: background .12s ease;
&:hover { background: $fp-section-bg; }
@media (max-width: 768px) {
display: inline-flex;
}
}
// Backdrop behind the open mobile drawer
.o_fp_portal_backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(15, 30, 30, .35);
z-index: 1030;
&.o_fp_open {
display: block;
}
}
- Step 2: Verify file written
Run: ls -la K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss
Expected: file exists, non-zero size.
- Step 3: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss && \
git commit -m "feat(portal): sidebar shell SCSS — sticky 240px rail + mobile drawer
Grouped sections via .o_fp_sidebar_section_label, active item gets
mint gradient fill + brand-teal left bar. Below 768px the sidebar
collapses to a fixed slide-in drawer (.o_fp_open class), with
.o_fp_portal_hamburger button + .o_fp_portal_backdrop as siblings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 2: Create fp_portal_sidebar.js
Files:
-
Create:
fusion_plating_portal/static/src/js/fp_portal_sidebar.js -
Step 1: Write the hamburger JS
/**
* Fusion Plating — Portal sidebar hamburger toggle.
* Vanilla JS — no OWL / no jQuery. Loaded on every /my/* page.
* Below 768px the sidebar is translateX(-100%); toggling
* .o_fp_open on both sidebar + backdrop shows/hides it.
*/
(function () {
"use strict";
function init() {
var sidebar = document.querySelector(".o_fp_portal_sidebar");
var hamburger = document.querySelector(".o_fp_portal_hamburger");
var backdrop = document.querySelector(".o_fp_portal_backdrop");
if (!sidebar || !hamburger || !backdrop) {
return; // sidebar not on this page (logged-out, error pages, etc.)
}
function toggleOpen(force) {
var willOpen = (typeof force === "boolean")
? force
: !sidebar.classList.contains("o_fp_open");
sidebar.classList.toggle("o_fp_open", willOpen);
backdrop.classList.toggle("o_fp_open", willOpen);
}
hamburger.addEventListener("click", function (e) {
e.preventDefault();
toggleOpen();
});
backdrop.addEventListener("click", function () {
toggleOpen(false);
});
// Close when navigating to a sidebar link on mobile
sidebar.querySelectorAll("a.o_fp_sidebar_item").forEach(function (a) {
a.addEventListener("click", function () {
if (window.innerWidth < 769) {
toggleOpen(false);
}
});
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
- Step 2: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/static/src/js/fp_portal_sidebar.js && \
git commit -m "feat(portal): mobile sidebar hamburger toggle (vanilla JS)
20 lines, no framework. Toggles .o_fp_open on sidebar + backdrop.
Backdrop click closes drawer; navigating a sidebar link on mobile
auto-closes. No-ops gracefully when sidebar isn't on the page
(logged-out, 500 pages, etc.).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 3: Create fp_portal_shell.xml
Files:
-
Create:
fusion_plating_portal/views/fp_portal_shell.xml -
Step 1: Inspect Odoo's portal.portal_layout template
Run on entech to see the exact wrapper structure:
ssh pve-worker5 "pct exec 111 -- bash -c 'grep -A 40 \"id=.portal_layout.\" /usr/lib/python3/dist-packages/odoo/addons/portal/views/portal_templates.xml | head -50'"
You're looking for the element that wraps page content — typically <div id="wrap" class="o_portal_wrap"> or similar. Note the exact xpath anchor.
- Step 2: Write the shell template
Create fusion_plating_portal/views/fp_portal_shell.xml:
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Wraps every /my/* page (FP custom + Odoo default) in the new
sidebar shell. Inherit on portal.portal_layout so we don't have
to edit every individual page template.
-->
<odoo>
<!-- ================================================================== -->
<!-- Inherit portal.portal_layout to wrap content in sidebar shell -->
<!-- ================================================================== -->
<template id="fp_portal_shell"
name="FP Portal Shell — Sidebar Wrap"
inherit_id="portal.portal_layout"
priority="50">
<!-- Wrap the existing portal body in a grid (sidebar | content).
#wrap is Odoo's canonical content wrapper inside portal_layout.
We wrap its CHILDREN, not the wrap itself, so we keep Odoo's
outer styling intact. -->
<xpath expr="//div[@id='wrap']" position="inside">
<t t-if="False">
<!-- placeholder kept to make the xpath unique even if Odoo's
wrap renders nothing for the current page -->
</t>
</xpath>
<xpath expr="//div[@id='wrap']/*" position="before">
<div class="o_fp_portal_shell">
<!-- Mobile hamburger (shown only below 768px via SCSS) -->
<button type="button" class="o_fp_portal_hamburger d-md-none"
aria-label="Open navigation">
<i class="fa fa-bars"/>
</button>
<!-- Backdrop for mobile drawer (hidden by default) -->
<div class="o_fp_portal_backdrop"/>
<!-- Sidebar -->
<t t-call="fusion_plating_portal.fp_portal_sidebar"/>
<!-- Main content slot — Odoo's existing portal page body
renders into this main element below via the natural
inherit order. We open the div here; the closing tag
is injected by the after-anchor below. -->
<main class="o_fp_portal_main">
</div>
</xpath>
<xpath expr="//div[@id='wrap']/*[last()]" position="after">
</main>
<!-- close .o_fp_portal_shell (opened above) -->
</xpath>
</template>
<!-- ================================================================== -->
<!-- Sidebar template — rendered by fp_portal_shell -->
<!-- ================================================================== -->
<template id="fp_portal_sidebar" name="FP Portal Sidebar">
<aside class="o_fp_portal_sidebar">
<!-- Partner display name header -->
<div class="o_fp_sidebar_header">
<t t-out="fp_partner_display_name or 'My Account'"/>
</div>
<!-- Items, walked from the Python-side data structure -->
<t t-foreach="fp_sidebar_items or []" t-as="entry">
<!-- Section labels render as headers -->
<t t-if="entry.get('type') == 'section_label'">
<div class="o_fp_sidebar_section_label" t-out="entry['label']"/>
</t>
<!-- Items render as anchor links -->
<t t-elif="entry.get('type') == 'item'">
<a t-att-href="entry['url']"
t-attf-class="o_fp_sidebar_item #{'o_fp_sidebar_active' if entry.get('active') else ''}">
<span class="o_fp_sidebar_icon" t-out="entry.get('icon') or '•'"/>
<span t-out="entry['label']"/>
</a>
</t>
</t>
<!-- Footer: sign out -->
<div class="o_fp_sidebar_footer">
<a href="/web/session/logout?redirect=/" class="o_fp_sidebar_item">
<span class="o_fp_sidebar_icon">↪</span>
<span>Sign Out</span>
</a>
</div>
</aside>
</template>
</odoo>
Note: The xpath approach in Step 2 above (wrapping #wrap's children) is the cleanest in theory but Odoo's portal.portal_layout actual structure may vary by version. The Step 1 inspection result might force a different anchor. If #wrap isn't present, the fallback is to inherit at portal.frontend_layout or website.layout level and use a t-call to the existing portal body. Update this task inline with the actual anchor before committing.
- Step 3: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/views/fp_portal_shell.xml && \
git commit -m "feat(portal): sidebar shell template + portal.portal_layout inherit
fp_portal_shell wraps every /my/* page (FP custom + Odoo default)
in a sticky-sidebar shell with no per-template edits. Sidebar markup
is a separate fp_portal_sidebar template that reads fp_sidebar_items
+ fp_partner_display_name from the page context.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 4: Sidebar data helper + layout values inject
Files:
-
Modify:
fusion_plating_portal/controllers/portal.py -
Step 1: Add
_fp_sidebar_items()helper toFpCustomerPortal
Find the _fp_get_partner_domain helper in fusion_plating_portal/controllers/portal.py (around line 111). Immediately after it, add:
# ==========================================================================
# Sidebar — items + active-state resolution
# ==========================================================================
# Sidebar item structure: list of dicts with `type` = 'item' | 'section_label'.
# Items have label / url / icon / key. Key matches either a page_name set by
# an FP route OR a URL prefix for Odoo default pages.
_FP_SIDEBAR_LAYOUT = [
{'type': 'item', 'key': 'fp_dashboard', 'label': 'Dashboard', 'icon': '🏠', 'url': '/my/home'},
{'type': 'section_label', 'label': 'Activity'},
{'type': 'item', 'key': 'fp_quote_requests','label': 'Quote Requests', 'icon': '📄', 'url': '/my/quote_requests'},
{'type': 'item', 'key': 'fp_configurator', 'label': 'Get a Quote', 'icon': '+', 'url': '/my/configurator'},
{'type': 'item', 'key': 'odoo_orders', 'label': 'Purchase Orders', 'icon': '🛒', 'url': '/my/orders'},
{'type': 'item', 'key': 'fp_jobs', 'label': 'Work Orders', 'icon': '⚙️', 'url': '/my/jobs'},
{'type': 'section_label', 'label': 'Documents'},
{'type': 'item', 'key': 'fp_certifications','label': 'Certifications', 'icon': '📑', 'url': '/my/certifications'},
{'type': 'item', 'key': 'fp_deliveries', 'label': 'Packing Slips', 'icon': '📦', 'url': '/my/deliveries'},
{'type': 'item', 'key': 'fp_account_summary','label': 'Account Summary', 'icon': '💰', 'url': '/my/account_summary'},
{'type': 'section_label', 'label': 'Account'},
{'type': 'item', 'key': 'odoo_account', 'label': 'Profile', 'icon': '👤', 'url': '/my/account'},
]
# Map either a page_name (set by FP routes) OR a URL prefix
# (for Odoo defaults that don't set page_name) to a sidebar item key.
_FP_PAGE_NAME_TO_SIDEBAR_KEY = {
'fp_dashboard': 'fp_dashboard',
'fp_quote_requests': 'fp_quote_requests',
'fp_quote_request': 'fp_quote_requests',
'fp_configurator': 'fp_configurator',
'fp_jobs': 'fp_jobs',
'fp_portal_job': 'fp_jobs',
'fp_certifications': 'fp_certifications',
'fp_deliveries': 'fp_deliveries',
'fp_account_summary': 'fp_account_summary',
}
_FP_URL_PREFIX_TO_SIDEBAR_KEY = [
# Order matters — first match wins, so list longer prefixes first.
('/my/orders', 'odoo_orders'),
('/my/quotes', 'odoo_orders'), # /my/quotes is also sale_portal
('/my/invoices', 'fp_account_summary'),
('/my/account_summary', 'fp_account_summary'),
('/my/account', 'odoo_account'),
('/my/security', 'odoo_account'),
('/my/home', 'fp_dashboard'),
('/my', 'fp_dashboard'), # /my (no trailing) -> dashboard
]
def _fp_resolve_active_sidebar_key(self, url, page_name):
"""Resolve which sidebar item should be marked active for this request."""
if page_name and page_name in self._FP_PAGE_NAME_TO_SIDEBAR_KEY:
return self._FP_PAGE_NAME_TO_SIDEBAR_KEY[page_name]
if url:
for prefix, key in self._FP_URL_PREFIX_TO_SIDEBAR_KEY:
if url.startswith(prefix):
return key
return None
def _fp_sidebar_items(self, url, page_name):
"""Return the sidebar item list with the right item marked active."""
active_key = self._fp_resolve_active_sidebar_key(url, page_name)
out = []
for entry in self._FP_SIDEBAR_LAYOUT:
if entry.get('type') == 'item':
copy = dict(entry)
copy['active'] = (active_key == entry['key'])
out.append(copy)
else:
out.append(entry)
return out
- Step 2: Extend
_prepare_portal_layout_valuesto inject sidebar data
In the same file, find _prepare_portal_layout_values (around line 29). Replace its body with:
def _prepare_portal_layout_values(self):
values = super()._prepare_portal_layout_values()
# Resolve current URL + page_name for sidebar active-state
url = request.httprequest.path if request else ''
page_name = values.get('page_name')
values['fp_sidebar_items'] = self._fp_sidebar_items(url, page_name)
# Partner display name for the sidebar header
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
values['fp_partner_display_name'] = commercial.name or partner.name
return values
IMPORTANT: this overrides the same-name method we previously edited (counters). Make sure NOT to lose the counter-injection — the existing override builds fp_quote_request_count, fp_portal_job_count, etc. The new code should ADD to the values dict, not replace what's there. Verify the existing override still increments the counters; if not, restore that logic alongside the new sidebar data.
- Step 3: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/controllers/portal.py && \
git commit -m "feat(portal): _fp_sidebar_items helper + layout-values inject
Drives the sidebar from a single Python data structure
(_FP_SIDEBAR_LAYOUT). Active state resolved by page_name lookup OR
URL-prefix match (so Odoo default pages like /my/orders and
/my/account light up correctly). _prepare_portal_layout_values
extends super() so existing counter injection (fp_quote_request_count
etc.) keeps firing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 5: Register Phase 1 files + version bump
Files:
-
Modify:
fusion_plating_portal/__manifest__.py -
Step 1: Bump version + register new files
Change 'version': '19.0.3.7.0' → 'version': '19.0.4.0.0'.
In the 'data' list, add 'views/fp_portal_shell.xml' near the TOP (right after macros, before any template that might call sidebar context vars):
'data': [
'security/fp_portal_security.xml',
'security/ir.model.access.csv',
'data/fp_sequence_data.xml',
'views/fp_portal_macros.xml',
'views/fp_portal_shell.xml', # NEW — must load early
'views/fp_quote_request_views.xml',
'views/fp_portal_dashboard.xml',
'views/fp_portal_templates.xml',
'views/fp_portal_configurator_templates.xml',
'views/fp_portal_breadcrumbs.xml',
'views/fp_sale_order_portal.xml',
'views/fp_menu.xml',
],
In the 'assets' block, add fp_portal_sidebar.scss (after fp_portal_dashboard.scss, before the legacy catch-all) and fp_portal_sidebar.js (with the JS files at the end):
'assets': {
'web.assets_frontend': [
'fusion_plating_portal/static/src/scss/_fp_portal_tokens.scss',
'fusion_plating_portal/static/src/scss/fp_portal_buttons.scss',
'fusion_plating_portal/static/src/scss/fp_portal_badges.scss',
'fusion_plating_portal/static/src/scss/fp_portal_cards.scss',
'fusion_plating_portal/static/src/scss/fp_portal_stepper.scss',
'fusion_plating_portal/static/src/scss/fp_portal_dashboard.scss',
'fusion_plating_portal/static/src/scss/fp_portal_timeline.scss',
'fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss', # NEW
'fusion_plating_portal/static/src/scss/fusion_plating_portal.scss',
'fusion_plating_portal/static/src/js/fp_rfq_form.js',
'fusion_plating_portal/static/src/js/fp_portal_sidebar.js', # NEW
],
},
- Step 2: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/__manifest__.py && \
git commit -m "chore(portal): bump 19.0.4.0.0 + register sidebar shell + JS
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 6: Deploy Phase 1 to entech + visual verify
Files: (deployment, no edits)
- Step 1: Copy 6 changed/new files to entech
for f in \
static/src/scss/fp_portal_sidebar.scss \
static/src/js/fp_portal_sidebar.js \
views/fp_portal_shell.xml \
controllers/portal.py \
__manifest__.py; do
cat "K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/$f" | \
ssh pve-worker5 "pct exec 111 -- bash -c 'mkdir -p \$(dirname /mnt/extra-addons/custom/fusion_plating_portal/$f) && cat > /mnt/extra-addons/custom/fusion_plating_portal/$f'"
done
- Step 2: Upgrade module + restart
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_portal --test-tags=fp_portal --stop-after-init 2>&1 | tail -15\" && systemctl start odoo'"
Expected: registry loaded clean, all existing tests still pass.
- Step 3: Bust asset cache
ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\""
- Step 4: Visual smoke test
Open in a logged-in browser, walk each URL and confirm sidebar appears with correct active item:
| URL | Expected active sidebar item |
|---|---|
| https://enplating.com/my/home | Dashboard |
| https://enplating.com/my/quote_requests | Quote Requests |
| https://enplating.com/my/configurator | Get a Quote |
| https://enplating.com/my/orders (Odoo) | Purchase Orders |
| https://enplating.com/my/jobs | Work Orders |
| https://enplating.com/my/certifications | Certifications |
| https://enplating.com/my/deliveries | Packing Slips |
| https://enplating.com/my/account (Odoo) | Profile |
Also test mobile: shrink browser below 768px, sidebar should disappear; click hamburger button (top-left of main content), drawer slides in; click backdrop or a sidebar link, drawer closes.
If any URL doesn't show the sidebar, the portal.portal_layout inherit xpath in Task 3 didn't catch — revisit Step 1 of Task 3 (inspect Odoo's wrap structure) and adjust the xpath anchor.
PHASE 2 — Page Audit Redirects
Goal: 3 legacy URLs redirect cleanly to their new homes. No content changes, just routes.
Task 7: Add 3 redirects + delete thin templates
Files:
-
Modify:
fusion_plating_portal/controllers/portal.py -
Modify:
fusion_plating_portal/views/fp_portal_templates.xml -
Step 1: Replace
portal_my_fp_invoicesbody with redirect
Find the portal_my_fp_invoices route handler in fusion_plating_portal/controllers/portal.py (grep for /my/fp_invoices). Replace the entire method body with:
@http.route(
['/my/fp_invoices', '/my/fp_invoices/page/<int:page>'],
type='http', auth='user', website=True,
)
def portal_my_fp_invoices(self, **kw):
"""Legacy URL — redirected to /my/account_summary (Sub-A IA)."""
return request.redirect('/my/account_summary')
- Step 2: Replace
portal_my_purchase_ordersbody with redirect
Find portal_my_purchase_orders and replace its method body:
@http.route(
['/my/purchase_orders', '/my/purchase_orders/page/<int:page>'],
type='http', auth='user', website=True,
)
def portal_my_purchase_orders(self, **kw):
"""Legacy URL — redirected to Odoo default /my/orders (Sub-A IA)."""
return request.redirect('/my/orders')
- Step 3: Replace
portal_my_quote_request_new(or equivalent) with redirect
Find the GET handler for /my/quote_requests/new. Replace its body so a GET redirects to the configurator — but the POST handler (the actual form submit) MUST be preserved untouched because the existing RFQ form still submits there. If they share one method, split them:
@http.route(
['/my/quote_requests/new'],
type='http', auth='user', website=True,
methods=['GET'],
)
def portal_my_quote_request_new_get(self, **kw):
"""GET — legacy entry point, redirected to the configurator wizard."""
return request.redirect('/my/configurator/new')
# POST kept for back-compat with the existing RFQ form button.
# If the form is fully retired in a later phase, drop this method.
@http.route(
['/my/quote_requests/new'],
type='http', auth='user', website=True,
methods=['POST'], csrf=True,
)
def portal_my_quote_request_new_post(self, **kw):
# ... existing POST body, unchanged
...
If you find a single method handling both GET + POST, factor the redirect out:
def portal_my_quote_request_new(self, **kw):
if request.httprequest.method == 'GET':
return request.redirect('/my/configurator/new')
# ... existing POST body
- Step 4: Delete the thin invoice + PO template bodies
In fusion_plating_portal/views/fp_portal_templates.xml, find <template id="portal_my_fp_invoices" and <template id="portal_my_purchase_orders" — delete each whole template block (it's never rendered now). Sample diff:
<!-- DELETE this whole block -->
<template id="portal_my_fp_invoices" name="My Invoices">
... (existing thin invoice list body)
</template>
- Step 5: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/controllers/portal.py fusion_plating_portal/views/fp_portal_templates.xml && \
git commit -m "feat(portal): redirect 3 legacy URLs to consolidated homes (Sub-A IA)
- /my/fp_invoices -> /my/account_summary
- /my/purchase_orders -> /my/orders (Odoo default)
- /my/quote_requests/new (GET) -> /my/configurator/new
(POST handler preserved for back-compat with the existing RFQ form
button; will be removed after the form is fully retired)
Thin templates deleted: portal_my_fp_invoices, portal_my_purchase_orders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 8: Deploy Phase 2 + verify redirects
Files: (deployment)
- Step 1: Copy 2 changed files
for f in \
controllers/portal.py \
views/fp_portal_templates.xml; do
cat "K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/$f" | \
ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_portal/$f'"
done
- Step 2: Restart odoo (no schema change, just controller + template reload)
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_portal --stop-after-init 2>&1 | tail -10\" && systemctl start odoo'"
- Step 3: Verify redirects with curl
curl -s -o /dev/null -w "/my/fp_invoices -> %{redirect_url} (%{http_code})\n" https://enplating.com/my/fp_invoices
curl -s -o /dev/null -w "/my/purchase_orders -> %{redirect_url} (%{http_code})\n" https://enplating.com/my/purchase_orders
curl -sL -o /dev/null -w "/my/quote_requests/new -> %{url_effective} (%{http_code})\n" https://enplating.com/my/quote_requests/new
Unauthenticated curl hits the login redirect first (303 → /web/login), so the chain ends at login. To confirm the intended redirect specifically, you'd need to test with a logged-in session or check the route registration in ir.http. For now, 303 status is enough to confirm the route is wired and not 500'ing.
PHASE 3 — Account Summary
Goal: /my/account_summary page exists with 3 tabs (Invoices · Credit Memos · Statements), Open Balance pill, search + filter pills + sort + pagination.
Task 9: Add portal_account_summary controller route + tests
Files:
-
Modify:
fusion_plating_portal/controllers/portal.py -
Modify:
fusion_plating_portal/tests/test_portal_dashboard.py -
Step 1: Write the failing test FIRST
Append to fusion_plating_portal/tests/test_portal_dashboard.py:
def test_account_summary_partitions_invoices_and_credits(self):
"""Account Summary helper splits posted moves by move_type."""
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
Move = self.env['account.move']
inv = Move.create({
'partner_id': self.partner.id,
'move_type': 'out_invoice',
'invoice_date': '2026-05-01',
'invoice_line_ids': [(0, 0, {
'name': 'Test plating',
'quantity': 1,
'price_unit': 250.00,
})],
})
inv.action_post()
cm = Move.create({
'partner_id': self.partner.id,
'move_type': 'out_refund',
'invoice_date': '2026-05-02',
'invoice_line_ids': [(0, 0, {
'name': 'Test credit',
'quantity': 1,
'price_unit': 50.00,
})],
})
cm.action_post()
controller = FpCustomerPortal()
data = controller._fp_account_summary_data(
self.partner.commercial_partner_id,
tab='invoices',
filter_state='all',
search='',
sort='date_desc',
page=1,
)
# Tab=invoices -> only out_invoice
names = data['records'].mapped('name')
self.assertIn(inv.name, names)
self.assertNotIn(cm.name, names)
data = controller._fp_account_summary_data(
self.partner.commercial_partner_id,
tab='credit_memos',
filter_state='all',
search='',
sort='date_desc',
page=1,
)
names = data['records'].mapped('name')
self.assertIn(cm.name, names)
self.assertNotIn(inv.name, names)
def test_account_summary_open_balance_sums_residuals(self):
"""Open Balance pill = sum of amount_residual across open invoices."""
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
Move = self.env['account.move']
inv = Move.create({
'partner_id': self.partner.id,
'move_type': 'out_invoice',
'invoice_date': '2026-05-01',
'invoice_line_ids': [(0, 0, {
'name': 'Open inv',
'quantity': 1,
'price_unit': 750.00,
})],
})
inv.action_post()
controller = FpCustomerPortal()
open_balance = controller._fp_account_summary_open_balance(
self.partner.commercial_partner_id,
)
# The 750 invoice has amount_residual = 750 until paid
self.assertEqual(open_balance, 750.00)
def test_account_summary_search_matches_name_and_ref(self):
"""Search box filters by invoice number OR customer PO (ref)."""
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
Move = self.env['account.move']
inv = Move.create({
'partner_id': self.partner.id,
'move_type': 'out_invoice',
'invoice_date': '2026-05-01',
'ref': 'PO-CUSTOMER-99999',
'invoice_line_ids': [(0, 0, {
'name': 'Sale',
'quantity': 1,
'price_unit': 100.0,
})],
})
inv.action_post()
controller = FpCustomerPortal()
# Search by ref (customer PO)
data = controller._fp_account_summary_data(
self.partner.commercial_partner_id,
tab='invoices', filter_state='all',
search='99999', sort='date_desc', page=1,
)
self.assertIn(inv, data['records'])
# Search that matches nothing
data = controller._fp_account_summary_data(
self.partner.commercial_partner_id,
tab='invoices', filter_state='all',
search='zzznotfoundzzz', sort='date_desc', page=1,
)
self.assertNotIn(inv, data['records'])
- Step 2: Implement the helpers + route
In fusion_plating_portal/controllers/portal.py, add this BEFORE the existing # DASHBOARD section (near line 116):
# ==========================================================================
# Account Summary (Sub-A IA) — invoices + credits + statements
# ==========================================================================
_FP_ACCOUNT_SUMMARY_TABS = [
('invoices', 'Invoices', 'out_invoice'),
('credit_memos', 'Credit Memos', 'out_refund'),
('statements', 'Statements', None), # placeholder in V1
]
_FP_ACCOUNT_SUMMARY_FILTERS = ['open', 'closed', 'all']
_FP_ACCOUNT_SUMMARY_SORTS = {
'date_desc': 'invoice_date desc, id desc',
'date_asc': 'invoice_date asc, id asc',
'amount_desc': 'amount_total desc, id desc',
'amount_asc': 'amount_total asc, id asc',
}
_FP_ACCOUNT_SUMMARY_PER_PAGE = 10
def _fp_account_summary_open_balance(self, commercial_partner):
"""Sum of amount_residual across this partner's open invoices."""
moves = self.env['account.move'].sudo().search([
('partner_id', 'child_of', commercial_partner.id),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('amount_residual', '>', 0),
])
return sum(moves.mapped('amount_residual'))
def _fp_account_summary_data(self, commercial_partner, tab, filter_state,
search, sort, page):
"""Return {records, total, pager_offset} for one tab+filter combination.
tab — 'invoices' | 'credit_memos' | 'statements'
filter_state — 'open' | 'closed' | 'all'
search — substring matched against name OR ref (case-insensitive)
sort — key from _FP_ACCOUNT_SUMMARY_SORTS
page — 1-indexed
"""
if tab == 'statements':
# V1 placeholder — Statements is a 'coming soon' tab.
return {'records': self.env['account.move'].browse(), 'total': 0,
'offset': 0}
# Resolve move_type from tab key
move_type = next(
(mt for k, _l, mt in self._FP_ACCOUNT_SUMMARY_TABS if k == tab),
'out_invoice',
)
domain = [
('partner_id', 'child_of', commercial_partner.id),
('move_type', '=', move_type),
('state', '=', 'posted'),
]
if filter_state == 'open':
domain.append(('amount_residual', '>', 0))
elif filter_state == 'closed':
domain.append(('amount_residual', '=', 0))
if search:
domain.append('|')
domain.append(('name', 'ilike', search))
domain.append(('ref', 'ilike', search))
Move = self.env['account.move'].sudo()
order = self._FP_ACCOUNT_SUMMARY_SORTS.get(sort, 'invoice_date desc')
total = Move.search_count(domain)
offset = max(0, (page - 1) * self._FP_ACCOUNT_SUMMARY_PER_PAGE)
records = Move.search(domain, order=order, limit=self._FP_ACCOUNT_SUMMARY_PER_PAGE, offset=offset)
return {'records': records, 'total': total, 'offset': offset}
@http.route(
['/my/account_summary', '/my/account_summary/page/<int:page>'],
type='http', auth='user', website=True,
)
def portal_account_summary(self, page=1, tab='invoices',
filter_state='open', search='', sort='date_desc',
**kw):
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
# Sanitize inputs
if tab not in [k for k, _l, _t in self._FP_ACCOUNT_SUMMARY_TABS]:
tab = 'invoices'
if filter_state not in self._FP_ACCOUNT_SUMMARY_FILTERS:
filter_state = 'open'
if sort not in self._FP_ACCOUNT_SUMMARY_SORTS:
sort = 'date_desc'
data = self._fp_account_summary_data(
commercial, tab, filter_state, search, sort, page,
)
open_balance = self._fp_account_summary_open_balance(commercial)
pager = portal_pager(
url='/my/account_summary',
url_args={'tab': tab, 'filter_state': filter_state,
'search': search, 'sort': sort},
total=data['total'],
page=page,
step=self._FP_ACCOUNT_SUMMARY_PER_PAGE,
)
values = {
'page_name': 'fp_account_summary',
'records': data['records'],
'tabs': self._FP_ACCOUNT_SUMMARY_TABS,
'active_tab': tab,
'filter_state': filter_state,
'search': search,
'sort': sort,
'open_balance': open_balance,
'currency': commercial.property_account_receivable_id.currency_id
if commercial.property_account_receivable_id else request.env.company.currency_id,
'pager': pager,
'total': data['total'],
}
return request.render('fusion_plating_portal.portal_my_account_summary', values)
- Step 3: Run the 3 new tests
Deploy + run from entech (see Task 14 for the full deploy step — for now use this quick form):
cat K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/controllers/portal.py | \
ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_portal/controllers/portal.py'"
cat K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/tests/test_portal_dashboard.py | \
ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_portal/tests/test_portal_dashboard.py'"
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin --test-tags=fp_portal --stop-after-init 2>&1 | tail -25\" && systemctl start odoo'"
Expected: 3 new tests pass (test_account_summary_partitions_invoices_and_credits, test_account_summary_open_balance_sums_residuals, test_account_summary_search_matches_name_and_ref), all prior tests still green.
Iterate on the helper code until tests pass. The template won't be wired yet so /my/account_summary will 404 if you try the URL — that's expected; comes in next task.
- Step 4: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/controllers/portal.py fusion_plating_portal/tests/test_portal_dashboard.py && \
git commit -m "feat(portal): account_summary controller + 3 unit tests
New /my/account_summary route. Splits posted account.move into
Invoices (out_invoice) / Credit Memos (out_refund) / Statements
(V1 placeholder). Open Balance helper sums amount_residual across
open invoices for the partner's commercial tree.
Search filters name OR ref (customer PO). Sort options: date desc/asc,
amount desc/asc. Filter pills: open / closed / all.
Tests cover the tab partitioning, the open-balance sum, and the
search behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 10: Create fp_portal_account_summary.xml template
Files:
-
Create:
fusion_plating_portal/views/fp_portal_account_summary.xml -
Step 1: Write the template
Create fusion_plating_portal/views/fp_portal_account_summary.xml:
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
-->
<odoo>
<template id="portal_my_account_summary" name="Account Summary">
<t t-call="portal.portal_layout">
<div class="o_fp_account_summary">
<!-- Page header: title + Open Balance pill -->
<div class="d-flex justify-content-between align-items-baseline mb-3">
<h3 class="mb-0" style="color: var(--fp-text, #111827)">Account Summary</h3>
<div class="o_fp_badge o_fp_badge_paid" t-if="open_balance">
<span class="o_fp_badge_dot"/>
Open Balance:
<span t-field="open_balance"
t-options='{"widget": "monetary", "display_currency": currency}'/>
</div>
<span class="o_fp_badge" t-else=""
style="background:#f3f7f6;color:#374151">
Open Balance: $0.00
</span>
</div>
<!-- Tab strip -->
<ul class="nav nav-tabs mb-3" role="tablist">
<t t-foreach="tabs" t-as="tab_entry">
<li class="nav-item">
<a t-attf-href="/my/account_summary?tab=#{tab_entry[0]}"
t-attf-class="nav-link #{'active' if active_tab == tab_entry[0] else ''}"
t-out="tab_entry[1]"/>
</li>
</t>
</ul>
<!-- Filter pills + search + sort -->
<t t-if="active_tab != 'statements'">
<div class="d-flex flex-wrap align-items-center gap-3 mb-3">
<div class="d-flex align-items-center gap-2">
<span class="text-muted small">Showing:</span>
<t t-foreach="['open', 'closed', 'all']" t-as="fk">
<a t-attf-href="/my/account_summary?tab=#{active_tab}&filter_state=#{fk}&sort=#{sort}&search=#{search}"
t-attf-class="o_fp_filter_pill #{'o_fp_filter_pill_active' if filter_state == fk else ''}"
t-out="fk.capitalize()"/>
</t>
</div>
<form method="GET" action="/my/account_summary" class="d-flex gap-1 ms-auto m-0">
<input type="hidden" name="tab" t-att-value="active_tab"/>
<input type="hidden" name="filter_state" t-att-value="filter_state"/>
<input type="hidden" name="sort" t-att-value="sort"/>
<input type="text" name="search" t-att-value="search"
placeholder="Search invoice # or PO #"
class="form-control form-control-sm"
style="max-width: 260px"/>
<button type="submit" class="o_fp_btn_secondary o_fp_btn_sm">Search</button>
</form>
<select onchange="window.location.href = this.value"
class="form-select form-select-sm" style="max-width: 200px">
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&filter_state=' + filter_state + '&sort=date_desc&search=' + search"
t-att-selected="sort == 'date_desc'">Newest first</option>
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&filter_state=' + filter_state + '&sort=date_asc&search=' + search"
t-att-selected="sort == 'date_asc'">Oldest first</option>
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&filter_state=' + filter_state + '&sort=amount_desc&search=' + search"
t-att-selected="sort == 'amount_desc'">Largest amount</option>
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&filter_state=' + filter_state + '&sort=amount_asc&search=' + search"
t-att-selected="sort == 'amount_asc'">Smallest amount</option>
</select>
</div>
</t>
<!-- Table -->
<t t-if="active_tab == 'statements'">
<div class="o_fp_card text-center text-muted" style="padding: 2rem">
<p>Monthly statements coming soon.</p>
<p class="small">
For a copy in the meantime, contact your sales rep at EN Plating.
</p>
</div>
</t>
<t t-elif="not records">
<div class="o_fp_card text-center text-muted" style="padding: 1.5rem">
<t t-if="search">No results for "<t t-out="search"/>".</t>
<t t-else="">No records in this tab.</t>
</div>
</t>
<t t-else="">
<div class="o_fp_card" style="padding: 0; overflow: hidden">
<table class="table mb-0">
<thead>
<tr>
<th>#</th>
<th>Status</th>
<th>Posted On</th>
<th>PO #</th>
<th>Due Date</th>
<th class="text-end">Balance</th>
<th class="text-end">View PDF</th>
</tr>
</thead>
<tbody>
<tr t-foreach="records" t-as="move">
<td t-out="move.name"/>
<td>
<t t-if="move.amount_residual == 0">
<span class="o_fp_badge o_fp_badge_paid"><span class="o_fp_badge_dot"/>Closed</span>
</t>
<t t-else="">
<span class="o_fp_badge o_fp_badge_in_progress"><span class="o_fp_badge_dot"/>Open</span>
</t>
</td>
<td>
<span t-if="move.invoice_date"
t-field="move.invoice_date"
t-options='{"widget": "date"}'/>
</td>
<td t-out="move.ref or ''"/>
<td>
<span t-if="move.invoice_date_due"
t-field="move.invoice_date_due"
t-options='{"widget": "date"}'/>
</td>
<td class="text-end">
<span t-field="move.amount_residual"
t-options='{"widget": "monetary", "display_currency": move.currency_id}'/>
</td>
<td class="text-end">
<a t-attf-href="/my/invoices/#{move.id}?report_type=pdf&download=true"
class="o_fp_btn_ghost o_fp_btn_sm">View PDF</a>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pager -->
<div class="d-flex justify-content-between align-items-center mt-3"
t-if="pager and pager.get('page_count', 0) > 1">
<div class="text-muted small">
Showing
<t t-out="pager['offset'] + 1"/>–<t t-out="min(pager['offset'] + 10, total)"/>
of <t t-out="total"/>
</div>
<ul class="pagination mb-0">
<t t-foreach="pager.get('pages', [])" t-as="p">
<li t-attf-class="page-item #{'active' if p['num'] == pager['page']['num'] else ''}">
<a class="page-link" t-att-href="p['url']" t-out="p['num']"/>
</li>
</t>
</ul>
</div>
</t>
</div>
</t>
</template>
</odoo>
- Step 2: Add small SCSS for the filter pills (append to
fp_portal_dashboard.scssnear the bottom)
// Filter pills used by Account Summary (also reusable elsewhere)
.o_fp_filter_pill {
display: inline-block;
padding: .25rem .75rem;
border-radius: $fp-radius-pill;
background: $fp-section-bg;
color: $fp-muted;
font-size: .8rem;
text-decoration: none;
transition: background .12s ease, color .12s ease;
&:hover { background: $fp-mint; color: $fp-teal-dark; text-decoration: none; }
&.o_fp_filter_pill_active {
background: $fp-gradient-primary;
color: #fff;
font-weight: 600;
}
}
- Step 3: Register the new template in manifest data list
Open fusion_plating_portal/__manifest__.py. In the 'data' list, add 'views/fp_portal_account_summary.xml' near the other portal view files (order after fp_portal_templates.xml is fine since the template doesn't depend on anything in templates).
'data': [
'security/fp_portal_security.xml',
'security/ir.model.access.csv',
'data/fp_sequence_data.xml',
'views/fp_portal_macros.xml',
'views/fp_portal_shell.xml',
'views/fp_quote_request_views.xml',
'views/fp_portal_dashboard.xml',
'views/fp_portal_templates.xml',
'views/fp_portal_account_summary.xml', # NEW
'views/fp_portal_configurator_templates.xml',
'views/fp_portal_breadcrumbs.xml',
'views/fp_sale_order_portal.xml',
'views/fp_menu.xml',
],
- Step 4: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/views/fp_portal_account_summary.xml fusion_plating_portal/static/src/scss/fp_portal_dashboard.scss fusion_plating_portal/__manifest__.py && \
git commit -m "feat(portal): Account Summary template (3 tabs, filter, search, sort, pager)
Tabs: Invoices / Credit Memos / Statements (V1 placeholder).
Page header carries the Open Balance pill. Per-tab filter pills
(Open/Closed/All), search box (name OR ref), sort dropdown
(newest/oldest/largest/smallest), 10-per-page pager.
Empty states: 'No results for X' for failed searches, 'No records
in this tab' for empty result sets, and the dedicated Statements
'coming soon' card. Statements tab hides the filter/search/sort
strip — nothing to filter yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
PHASE 4 — Deploy + Smoke Test
Task 11: Deploy Phase 3 + run tests + visual sweep
Files: (deployment)
- Step 1: Copy all Phase 3 files to entech
for f in \
controllers/portal.py \
views/fp_portal_account_summary.xml \
static/src/scss/fp_portal_dashboard.scss \
tests/test_portal_dashboard.py \
__manifest__.py; do
cat "K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/$f" | \
ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_portal/$f'"
done
- Step 2: Upgrade module + run tests
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_portal --test-tags=fp_portal --stop-after-init 2>&1 | tail -25\" && systemctl start odoo'"
Expected: all existing portal tests still pass + 3 new Account Summary tests pass.
- Step 3: Bust asset cache
ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\""
- Step 4: Visual smoke sweep — every URL
Logged in as admin (or a portal user), visit and confirm:
| URL | Expected |
|---|---|
| /my/home | Sidebar visible, "Dashboard" active, dashboard renders normally |
| /my/jobs | "Work Orders" active |
| /my/jobs/ | "Work Orders" active (parent highlighted on detail) |
| /my/quote_requests | "Quote Requests" active |
| /my/configurator | "Get a Quote" active |
| /my/orders | "Purchase Orders" active (Odoo default page) |
| /my/certifications | "Certifications" active |
| /my/deliveries | "Packing Slips" active |
| /my/account_summary | "Account Summary" active, page renders with 3 tabs |
| /my/account_summary?tab=credit_memos | Credit Memos tab active |
| /my/account_summary?tab=statements | Statements tab shows "coming soon" card |
| /my/account_summary?filter_state=closed | Closed filter pill active, only closed invoices shown |
| /my/account_summary?search=PO123 | Filtered to matching invoices |
| /my/account (Odoo) | "Profile" active |
| /my/fp_invoices (legacy) | Redirects to /my/account_summary |
| /my/purchase_orders (legacy) | Redirects to /my/orders |
| /my/quote_requests/new (GET, legacy) | Redirects to /my/configurator/new |
Mobile: shrink browser below 768px on /my/home, sidebar hides, hamburger appears, clicking hamburger reveals drawer, clicking a link closes it.
- Step 5: Tag the phase done
cd K:/Github/Odoo-Modules/fusion_plating && git tag portal-sub-a-shipped
Done
After Task 11 the sidebar + page audit + Account Summary are all live. Sub-projects B (multi-user) and C (search) are ready to consume the sidebar slots defined here.
Self-Review
1. Spec coverage:
- ✅ Sidebar shell (Tasks 1-6): every
/my/*page wrapped (template inherit onportal.portal_layout) - ✅ Sidebar items list (Task 4): matches spec exactly (Dashboard, Activity/Quote Requests/Get a Quote/Purchase Orders/Work Orders, Documents/Certifications/Packing Slips/Account Summary, Account/Profile)
- ✅ Active state via page_name + URL prefix (Task 4
_fp_resolve_active_sidebar_key) - ✅ Mobile collapse + hamburger (Task 2 JS + Task 1 SCSS @media)
- ✅ 3 legacy redirects (Task 7): /my/fp_invoices, /my/purchase_orders, /my/quote_requests/new
- ✅ Account Summary URL + tabs + Open Balance + filters + search + sort + pager (Tasks 9, 10)
- ✅ Statements V1 placeholder explicitly handled (Task 10 template, Task 9 controller short-circuit)
- ✅ Unit tests for the 3 main account_summary behaviours (Task 9 Step 1)
- ✅ Page audit deletions (Task 7 Step 4)
2. Placeholder scan:
- One soft area in Task 3: the xpath anchor in
fp_portal_shell.xmlmay need adjustment if Odoo'sportal.portal_layoutdoesn't have<div id="wrap">in our version. Task 3 Step 1 calls this out and tells the engineer to inspect first. Acceptable. - No "TODO" / "TBD" / "fill in details" in any task body.
3. Type consistency:
_FP_SIDEBAR_LAYOUT(Task 4) entries use keystype / key / label / icon / urlconsistently. Template (Task 3) reads exactly these. ✓_FP_ACCOUNT_SUMMARY_TABSis a list of (key, label, move_type) tuples — Task 9 iterates with this shape, Task 10 template iterates with the same shape. ✓_fp_account_summary_datareturn shape{records, total, offset}consistent between Task 9 implementation and Task 9 tests. ✓- Helper names match between tasks:
_fp_account_summary_open_balance,_fp_account_summary_data,_fp_resolve_active_sidebar_key,_fp_sidebar_items— all defined in Task 4 / Task 9, all referenced exactly once with the same name. ✓
Sub-projects B (multi-user) and C (portal search) will add items to the SIDEBAR_LAYOUT data structure and possibly a search input above the Dashboard entry — both extensions, no restructure needed.