019 — Developer Quick Start: Adding a Feature
Audience: developer joining the team who must ship a feature this week.
Goal: know which files to touch, in which order, following which pattern — for any feature
type across both repos.
Read 006, 010
and 012 for the why. This page is the how.
1 · Pick Your Recipe
A full-stack feature is usually A → B → C, plus D if it must update live.
flowchart LR
A["A · Model<br/>fields + api_* methods"] --> B["B · Controller<br/>thin route"]
B --> C["C · Portal<br/>types → service → store → UI"]
A -.-> D["D · Bus event<br/>_sendone()"]
D -.-> C
2 · Code Map
Odoo — addons_lp_ecommerce/
| Path |
Put here |
ecommerce/models/<name>.py |
New model, or _inherit extension of an Odoo model |
ecommerce/models/__init__.py |
Import of every model file |
ecommerce/controllers/<name>_api.py |
Customer Portal routes — /ecommerce/api/* |
ecommerce/controllers_admin/<name>_api.py |
AMP routes — /ecommerce/api/admin/* |
ecommerce/controllers*/__init__.py |
Import of every controller file |
ecommerce/controllers/base_controller.py |
Shared sudo() model accessors (brand_model, …) |
ecommerce/api_errors.py |
Error-code class + messages in API_ERROR_MESSAGES |
ecommerce/api_access.py |
Company-scoped record resolution helpers |
ecommerce/security/ir.model.access.csv |
Model ACLs |
ecommerce/views/<name>_views.xml |
ERP Back Office list / form / search / action |
ecommerce/views/ecommerce_menus.xml |
ERP Back Office menu entries |
ecommerce/data/*.xml |
Crons, sequences, mail templates |
ecommerce/__manifest__.py |
Register every new XML/CSV file in data |
ecommerce/migrations/<version>/ |
Pre/post-migration scripts for data changes |
ecommerce_access_data/data/*.xml |
AMM screens, permission codes, role assignments |
lp_base/controllers/base_controller.py |
Envelope, CORS, @http_auth_validated — do not edit per feature |
Next.js — next_ecommerce/src/
| Path |
Put here |
infrastructure/api/<domain>/<domain>.types.ts |
Request / response interfaces |
infrastructure/api/<domain>/<domain>.endpoints.ts |
URL constants and URL builders |
infrastructure/api/<domain>/<domain>.service.ts |
Typed calls through apiClient |
infrastructure/api/<domain>/<domain>.websocket.ts |
start<Domain>WebSocket() wrapper (real-time only) |
stores/<domain>.store.ts · stores/admin/… |
Zustand store: state, actions, errors |
components/features/<area>/<domain>/ |
Feature components (AMP → features/admin/<domain>/) |
app/<route>/page.tsx |
Customer Portal page (wrap in AuthGuard) |
app/admin/<route>/page.tsx |
AMP page (guarded by the admin layout) |
components/features/admin/layout/admin-navigation.ts |
AMP sidebar entry |
shared/access/access-codes.ts |
AMP_CODES / CLIENT_CODES permission codes |
shared/config/app.config.ts |
Any new NEXT_PUBLIC_* variable |
Reference implementations — copy these
| Pattern |
Backend |
Frontend |
| Admin CRUD (list/detail/create/update/delete) |
models/brand.py · controllers_admin/brand_api.py |
infrastructure/api/brand/* · stores/brand.store.ts · features/admin/brands/ · app/admin/(product-management)/brands/ |
| Global live feed |
models/notification.py (create → bus) |
api/notifications/notifications.websocket.ts · notifications.store.ts |
| Per-record live thread |
models/mail_message.py (_broadcast_chatter_event) |
api/chatter/chatter.websocket.ts · chatter.store.ts |
| Company-scoped resolution |
api_access.py (resolve_id_for_user) |
— |
A · Odoo Model / Business Logic
Steps
| # |
Do |
File |
| 1 |
Create the model (_name = 'ecommerce.<name>') or extend one (_inherit = 'sale.order') |
models/<name>.py |
| 2 |
Import it |
models/__init__.py |
| 3 |
Add ACL rows: base.group_system, ecommerce_group_account_manager, ecommerce_group_read_only (+ base.group_portal if portal users read it as themselves) |
security/ir.model.access.csv |
| 4 |
Add list / form / search views and an action |
views/<name>_views.xml |
| 5 |
Add the menu item |
views/ecommerce_menus.xml |
| 6 |
Register XML files in load order (security → data → views → menus) |
__manifest__.py |
| 7 |
Upgrade the module: -u ecommerce |
— |
Model layout pattern
Group code into regions — the whole codebase follows this:
| Region |
Contains |
| Fields |
Field definitions; tracking=True on business fields |
| Compute / onchange |
@api.depends methods |
| ORM overrides |
create (@api.model_create_multi), write, unlink — always call super() |
| API methods |
api_list_*, api_get_*, api_create_*, api_update_*, api_delete_* |
| Serialisers |
_prepare_<name>_dict(record) — one shape reused by list/detail/update |
| Crons |
_cron_* methods, triggered from data/*_cron.xml |
Which base to inherit
| Need |
Add to _inherit |
| Chatter + field tracking in ERP Back Office |
mail.thread, mail.activity.mixin |
| IDs sent to portals must be obfuscated |
ecommerce.base.secure_model |
| ERP Back Office lists refresh live on change |
ecommerce.base_auto_refresh_model |
Where business logic goes
| Logic type |
Place |
| Rule that must run however the record is saved |
create / write override |
| Rule triggered by a portal action |
The api_* method |
| Rule triggered by a Back Office button |
action_* method + button in the form view |
| Time-based rule |
_cron_* method + ir.cron record in data/ |
| Order state change |
api_update_order path in sale_order.py — never write portal_state directly for quotation_submitted / in_progress (see 016 AD-2) |
Removed models ecommerce.tier and ecommerce.product.merchant must not be reused.
Client segmentation uses res.partner.grade.
B · REST API Endpoint
Request flow
flowchart LR
R["@http.route<br/>auth='public' · csrf=False<br/>methods + OPTIONS"] --> G["@http_auth_validated"]
G --> C["controller<br/>parse params / JSON"]
C --> M["model.api_*()<br/>scope · validate · act"]
M --> S["_prepare_*_dict()"]
S --> E["api_response(**payload)"]
C -. exception .-> H["handle_api_error()"]
Steps
| # |
Do |
File |
| 1 |
Add an error-code class <Name>ApiErrors(ApiErrorCodes) in the next free code block |
api_errors.py |
| 2 |
Add every new code's English message |
api_errors.py → API_ERROR_MESSAGES |
| 3 |
Write api_* method(s) returning a dict with response_code + payload |
models/<name>.py |
| 4 |
Create the controller class subclassing BaseController |
controllers/ or controllers_admin/ |
| 5 |
Import the controller file |
controllers*/__init__.py |
| 6 |
Test with Postman / curl using a logged-in session cookie |
— |
Controller skeleton
from odoo import http
from odoo.http import request
from ..controllers.base_controller import BaseController, http_auth_validated
class WidgetApiController(BaseController):
@http.route('/ecommerce/api/admin/widgets', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False)
@http_auth_validated
def list_widgets_admin(self, **kwargs):
try:
payload = request.env['ecommerce.widget'].sudo().api_list_widgets(dict(request.params))
return self.api_response(**payload)
except Exception as ex:
return self.handle_api_error(ex)
Model method skeleton
@api.model
def api_get_widget(self, widget_id):
widget, error = resolve_id_for_user(self, widget_id, WidgetApiErrors.WIDGET_NOT_FOUND)
if error:
return {'response_code': error}
return {
'response_code': WidgetApiErrors.SUCCESS,
'data': {'widget': self._prepare_widget_dict(widget.sudo())},
}
Rules
| Rule |
Detail |
| Route prefix |
Customer Portal /ecommerce/api/<resource> · AMP /ecommerce/api/admin/<resource> (not the legacy /api/admin/*) |
| URL verbs |
GET list/detail · POST for …/create, …/update, …/delete |
| Unique method name |
Across all controllers — suffix AMP methods with _admin. A duplicate name silently drops a route (looks like a CORS error, is a 404) |
| Thin controller |
Parse → one model call → api_response. No business logic |
| Current user |
request.env.user — never trust a user_id from the request for authorisation |
| Tenant scope (mandatory) |
Portal reads filter by user.portal_company_partner_id; AMP reads filter by companies where account_manager_user_id = user. Use api_access.py helpers for ID lookups |
| Business errors |
HTTP 200 + non-zero response_code. Only session expiry returns 401 |
| Pagination |
page (default 1) + limit (default 20) → return pagination {page, limit, total, pages} |
| Messages |
English only; no new localised message keys |
C · Next.js Feature
Layers — build bottom-up
flowchart BT
T["1 · types.ts"] --> EP["2 · endpoints.ts"]
EP --> SV["3 · service.ts<br/>apiClient + response_code check"]
SV --> ST["4 · store.ts<br/>Zustand: state · actions · error"]
ST --> CO["5 · components/features/…"]
CO --> PG["6 · app/…/page.tsx"]
PG --> NV["7 · navigation + permissions"]
Layer rule: components never call apiClient; services never import React. Only stores
connect both.
Steps
| # |
Do |
File |
| 1 |
Define request / response interfaces mirroring the API payload |
infrastructure/api/<domain>/<domain>.types.ts |
| 2 |
Add URL constants (list, details, create, update(id), delete(id)) |
…/<domain>.endpoints.ts |
| 3 |
Write the service: call apiClient.get(url, { params }) / apiClient.post(url, { body }); if response_code !== '0' throw Error(response_message); log with logger |
…/<domain>.service.ts |
| 4 |
Create the store with DEFAULT_STATE, isLoading, error, filters, pagination, and one action per service call; wrap with subscribeWithSelector(devtools(…)) |
stores/<domain>.store.ts (AMP: stores/admin/) |
| 5 |
Build table / form / modal components reading the store |
components/features/<area>/<domain>/ |
| 6 |
Add the page |
see portal table below |
| 7 |
Add the navigation entry and permission codes |
see portal table below |
Customer Portal vs AMP
| Concern |
Customer Portal |
AMP (Account Management Portal) |
| Page location |
app/<route>/page.tsx |
app/admin/<route>/page.tsx (route groups like (product-management) add no URL segment) |
| Guard |
Wrap content in <AuthGuard> |
Automatic via app/admin/layout.tsx (AdminGuard) |
| Screen gate |
— |
useScreenGuard(CODES.<x>.screen); render null while loading or denied |
| Navigation |
Header / company-profile Navbar.tsx (profile tabs use ?page=) |
components/features/admin/layout/admin-navigation.ts with screen: CODES.<x>.screen |
| Permission codes |
CLIENT_CODES — client.<screen>.<action> |
AMP_CODES (alias CODES) — amp.<screen>.<action> |
| Button gating |
<Can code={CLIENT_CODES.<x>.create}> |
<Can code={CODES.<x>.create}> |
| Backend route |
/ecommerce/api/* |
/ecommerce/api/admin/* |
Registering a new screen with AMM
The screen catalogue lives in two places joined by the screen reference:
| # |
Record |
File |
| 1 |
Frontend codes { screen, view, create, edit, delete } |
shared/access/access-codes.ts |
| 2 |
access.screen with the same reference and path |
ecommerce_access_data/data/access_screen_catalogue_data.xml |
| 3 |
One access.permission per action code |
ecommerce_access_data/data/access_permission_data.xml |
| 4 |
Add the permission refs to each role that should have them |
ecommerce_access_data/data/access_role_data.xml |
When AMM is not installed the permission store runs in OPEN mode and everything renders — so
a missing record will only show up on an environment with AMM. Test there.
Frontend rules
| Rule |
Detail |
| No raw URLs in components |
Endpoint strings live only in *.endpoints.ts |
No process.env reads |
Use config from shared/config/app.config.ts |
Branch on response_code |
Never on HTTP status — business errors are HTTP 200 |
| One store per domain |
No store imports another store; compose in components |
| Theme with CSS variables |
var(--bg-card), var(--text-primary)… — never hard-coded colours |
| Session expiry |
Already handled by the apiClient interceptor ('401' → /login) |
| UI-facing change |
Add a highlights entry to the release file (_docs/releases/<version>.json) |
D · Real-Time Feature
Flow
sequenceDiagram
participant M as Odoo model
participant B as bus.bus
participant W as odooWebSocketClient<br/>(one shared socket)
participant S as Zustand store
participant UI as Component
UI->>S: connectRealtime() on mount
S->>W: start<Domain>WebSocket() → subscribeChannels([channel])
Note over W: subscribe is always the first frame
M->>B: _sendone(channel, type, payload) on create/write/action
B-->>W: frame {type, payload}
W-->>S: handler(type) → dedupe by id → set()
S-->>UI: re-render
UI->>S: disconnectRealtime() on unmount
Backend steps
| # |
Do |
File |
| 1 |
Choose a literal string channel and a <area>/<event> message type |
— |
| 2 |
Call self.env['bus.bus']._sendone(channel, type, payload) after the change |
models/<name>.py (in create / write / api_*) |
| 3 |
Keep the payload small and JSON-safe: ids, labels, scope keys (portal_company_partner_id, …) |
same |
Frontend steps
| # |
Do |
File |
| 1 |
Write start<Domain>WebSocket(options): subscribeChannels([channel]) + subscribeToType(type, h) + optional onConnectionChange; return one function that calls every unsubscribe |
infrastructure/api/<domain>/<domain>.websocket.ts |
| 2 |
Normalise the raw payload into a typed object inside that file |
same |
| 3 |
Add connectRealtime / disconnectRealtime store actions; keep a session counter so late callbacks after disconnect are ignored |
stores/<domain>.store.ts |
| 4 |
Merge events idempotently — skip if the id is already in state |
same |
| 5 |
Filter events that are not for the current user (company, owner, account manager) |
same |
| 6 |
Call connectRealtime in a component useEffect, disconnectRealtime in its cleanup |
components/features/… |
Existing channels
| Channel |
Message type(s) |
Emitted by |
ecommerce/notification |
ecommerce/notification |
ecommerce.notification.create |
ecommerce/presence |
ecommerce/presence |
res.users presence change |
discuss.channel_<uuid> |
discuss.channel/new_message |
discuss.channel.message_post mirror |
chatter_<res_model>_<res_id> |
chatter/new_message · update_message · attachment_upload · attachment_delete |
mail.message._broadcast_chatter_event |
auto_refresh |
auto_refresh |
ecommerce.base_auto_refresh_model (Back Office only) |
Real-time rules
| Rule |
Why |
| Use string channels, never record channels |
The Odoo.sh gateway relays only literal string channels to the portals |
| Never open a second socket |
Always go through odooWebSocketClient; channels are ref-counted |
Never send a frame before subscribe |
Odoo.sh closes with 4001 → endless reconnect |
| Treat every payload as public to logged-in users |
Odoo does not authorise client-chosen string channels — any session can subscribe to any name. Send ids and scope keys; let the client refetch details through a scoped REST endpoint |
| Deduplicate by id |
On-premises delivers some messages twice (record channel + mirror) |
| Test on Odoo.sh staging |
Gateway behaviour cannot be reproduced locally (016 AD-9) |
3 · Full-Stack Checklist
Use as the PR description checklist.
| Area |
Check |
| Model |
Imported in __init__.py · ACL rows added · XML files in manifest · module upgrades cleanly |
| Security |
Every read scoped by company / account manager · IDs resolved through api_access.py |
| API |
Unique method names · OPTIONS in methods · error codes + messages in api_errors.py |
| API |
Postman collection updated |
| Portal |
types → endpoints → service → store → components → page, in that order |
| Portal |
Nav entry + access-codes.ts + AMM screen/permission/role records in sync |
| Real-time |
String channel · payload holds no sensitive data · dedupe + scope filter on the client |
| Release |
Backend deployed before the portal (016 AD-13/14) · release highlights written for UI changes |
| Docs |
New endpoint in 006 · new model in 005 · new feature in 009 |
4 · Common Pitfalls
| Symptom |
Cause |
Fix |
| CORS error in the browser on a new route |
Route not registered — duplicate method name or missing import |
Rename method; add the file to controllers*/__init__.py |
| HTML login page returned instead of JSON |
Route uses auth='user' |
Use auth='public' + @http_auth_validated |
| Preflight fails |
OPTIONS missing from methods |
Add 'OPTIONS' |
| Customer sees another company's data |
Missing company scope in the domain |
Filter by portal_company_partner_id; use api_access.py |
AccessError for portal users only |
No ACL row for base.group_portal when resolving as the user |
Add the ACL row, or serialise under sudo() after resolution |
| Menu / screen hidden on staging but fine locally |
AMM installed there; screen or permission record missing |
Add records in ecommerce_access_data |
| New field missing after deploy |
Module not upgraded on the target database |
Upgrade ecommerce — see 017 |
| Live update works locally, not on Odoo.sh |
Record channel used, or a frame sent before subscribe |
Switch to a string channel; use odooWebSocketClient only |
| Duplicate items appear live |
Event merged without id check |
Dedupe by id in the store |