Portal Module
The Portal module provides a customer/vendor self-service interface where external users can view their documents (invoices, orders, etc.) without needing backend access.
Overview
Section titled “Overview”The portal system consists of:
- PortalAction - Configuration records that define which models are accessible in the portal
- Portal Views -
PortalListandPortalDetailview types for displaying documents - WebBlocks - Jinja templates for rendering portal pages
- Theme Integration - Inherits styling from website theme when installed
Making a Model Available in Portal
Section titled “Making a Model Available in Portal”To expose a model in the customer portal, create a PortalAction record:
- data_type: PortalAction identifier: portal_invoices name: Invoices model: FinancialDocument module: invoicing icon: bi-receipt sequence: 20 contact_field: contact global_filter: "Q(type__in=['Customer Invoice', 'Credit Note'])" list_view: invoice_portal_list detail_view: invoice_portal_detail active: truePortalAction Fields
Section titled “PortalAction Fields”| Field | Type | Description |
|---|---|---|
identifier | string | Unique identifier (used in URL: /portal/{identifier}) |
name | string | Display name in navigation sidebar |
model | string | Target model name |
module | string | Module that owns this portal action |
icon | string | Bootstrap icon class (e.g., bi-receipt) |
sequence | integer | Order in navigation (lower = first) |
contact_field | string | Field linking records to portal user’s contact |
global_filter | string | Q expression to filter records (e.g., only show certain types) |
list_view | string | Identifier of PortalList view |
detail_view | string | Identifier of PortalDetail view |
create_url | string | Address of your “raise a new one” page. Set it to offer a button; blank means read-only |
create_label | string | Caption on that button (defaults to “New”) |
active | boolean | Whether this portal action is enabled |
Letting Customers Raise a Document
Section titled “Letting Customers Raise a Document”Most portal document types are read-only — an invoice or a delivery is raised by the
business, and the customer only ever reads it. A few are the other way round: a support
request is something the customer starts. Point create_url at your own page and the
portal puts a button on the list page and on its empty state:
- data_type: PortalAction identifier: portal_tickets name: Support model: HelpdeskTicket create_url: /portal/tickets/new create_label: New TicketThe portal does not build the form for you — it owns the button and the navigation, and your module owns the page behind it, because only your module knows which fields a customer may set and which are yours to stamp.
Two behaviours come with declaring create_url:
- The type stays in the portal navigation even with nothing in it. Types with no records are normally dropped from the sidebar and home cards, but the button lives on the list page — so a type the customer can raise stays reachable at zero records, which is exactly the state a first-time customer is in.
- The button and your form agree on one address. Render your form’s
actionfromcurrent_template.create_urlrather than repeating the path.
Write the page itself as a route on a controller that extends the portal:
class MyPortalController(Controller): _name = "my_portal" # "web", NOT "portal" — the portal is itself an extension of the one base controller, # so its helpers reach you through the shared composition. Naming "portal" is refused # at composition time (see Custom Routes); it used to drop every route silently. __inherit__ = "web"
@route("/portal/things/new", methods=["GET"], auth="user", response_class=HTMLResponse) async def thing_new(self, request: Request): ... return await self._render_portal_page(request, "my_portal_thing_new", context)_render_portal_page renders a WebBlock of yours inside the portal’s own layout, styles
and translations, so the page is the portal rather than a differently-styled form beside
it. Pass current_template (the document type’s config), portal_documents (for the
sidebar) and whatever your template reads.
Two rules worth keeping when you write the handler:
- Take the customer’s identity from their account, never from the form. An email typed into a box is a claim; honouring it lets a signed-in customer file a document against someone else’s contact.
- Validate every choice against what the install offers, and stamp the rest server-side. A portal group should not need create/write permission on the model — the route creates on fully server-controlled values, so the form stays the only door.
Portal Views
Section titled “Portal Views”Portal views use the same UiView system as backend views but with types PortalList and PortalDetail.
PortalList View
Section titled “PortalList View”Displays a table of records with search, sorting, and pagination.
- data_type: UiView name: invoice_portal_list identifier: invoice_portal_list type: PortalList model: FinancialDocument arch: - content: - type: field name: number properties: widget: Text label: Number fw: 600 - type: field name: date properties: widget: Date label: Date - type: field name: state properties: widget: Badge label: Status colors: Draft: gray Posted: green Cancelled: red - type: field name: total_amount properties: widget: Monetary label: Total fw: 600Sortable Columns
Section titled “Sortable Columns”The following field names are automatically sortable: name, date, state, total, number, created_date.
PortalDetail View
Section titled “PortalDetail View”Displays a single document with structured layout. The architecture supports:
- Info Panel Fields - Displayed in the left sidebar
- Action Buttons - Displayed in the left sidebar
- Document Content - Rows, columns, cards, and fields
- data_type: UiView name: invoice_portal_detail identifier: invoice_portal_detail type: PortalDetail model: FinancialDocument arch: # Info panel fields (sidebar) - type: field name: total_amount properties: display_mode: info_panel label: Total Amount widget: Monetary
# Action buttons (sidebar) - type: actionButton properties: label: Download PDF icon: bi-download report: financial_document_print variant: secondary
# Link button — navigates to a URL. `url` may embed {field} tokens resolved off # the record (including relation paths), e.g. link into the document's web page: - type: actionButton properties: label: Continue icon: bi-play-circle url: "/courses/{course__slug}" variant: primary
# Document header - type: row content: - type: column span: 8 content: - type: field name: number properties: widget: Text size: xl fw: 700 noLabel: true - type: column span: 4 properties: style: "text-align: right;" content: - type: field name: state properties: widget: Badge noLabel: true
# Content cards - type: card title: Invoice Details icon: bi-info-circle content: - type: row content: - type: column span: 3 content: - type: field name: date properties: widget: Date label: Invoice Date
# Lines with totals - type: field name: lines properties: widget: List view: invoice_line_portal_list title: Invoice Lines icon: bi-receipt totals: - name: subtotal label: Subtotal widget: Monetary - name: total_amount label: Total widget: Monetary fw: 600
# Messages section - type: messagesArchitecture Elements
Section titled “Architecture Elements”Layout Elements
Section titled “Layout Elements”| Element | Description |
|---|---|
row | Horizontal container (12-column grid) |
column | Vertical container with span (1-12) |
card | Card container with title and optional icon |
divider | Visual separator |
Column Properties
Section titled “Column Properties”Columns support generic style and class properties for flexibility:
- type: column span: 4 properties: style: "text-align: right;" class: "custom-class" content: - type: field name: stateField Elements
Section titled “Field Elements”| Element | Description |
|---|---|
field | Data field with widget |
actionButton | Button for an action (method), a report download (report), or a link (url) |
messages | Collaboration/messaging section |
A field name may be a relation path (e.g. course__subtitle) to display a value from a
related record read-only — the portal resolves it through the relation chain. (This is a portal-view
convenience; backend app views bind a field to a single model field.)
Special Field Properties
Section titled “Special Field Properties”| Property | Description |
|---|---|
display_mode: info_panel | Show field in sidebar info panel |
noLabel: true | Hide field label |
visible: Q(...) | Conditional visibility using Q expressions |
fw: 600 | Font weight (400, 500, 600, 700) |
size: xl | Text size (sm, md, lg, xl) |
Widgets
Section titled “Widgets”Portal views support these widgets:
| Widget | Description |
|---|---|
Text | Plain text display |
Date | Formatted date |
Badge | Status badge with colors |
Monetary | Currency-formatted amount |
Number | Numeric value |
ProgressBar | Percentage as a filled bar + label (honors suffix, precision) |
Html | HTML content |
List | Embedded list (for lines) |
Badge Widget
Section titled “Badge Widget”- type: field name: state properties: widget: Badge colors: Draft: gray Posted: green Paid: teal Cancelled: redList Widget with Totals
Section titled “List Widget with Totals”For order/invoice lines, use the List widget with embedded totals:
- type: field name: lines properties: widget: List view: sale_order_line_portal_list title: Order Lines icon: bi-box-seam totals: - name: subtotal label: Subtotal widget: Monetary - name: tax_breakdown breakdown: true widget: Monetary - name: total label: Total widget: Monetary fw: 600A total entry with breakdown: true reads a JSON list field (each item
{name, amount}) and renders one row per item instead of a single value —
use it for per-tax lines such as split CGST/SGST. The pointed-at field is a
computed JSON field on the model (no label is needed; each row is labelled by
the item’s name).
Info Panel (Sidebar)
Section titled “Info Panel (Sidebar)”Fields marked with display_mode: info_panel appear in the left sidebar:
- type: field name: total_amount properties: display_mode: info_panel label: Total Amount widget: MonetaryMonetary fields display prominently. Other field types display as label/value pairs.
Action Buttons
Section titled “Action Buttons”Action buttons appear in the sidebar and can trigger:
Report Download
Section titled “Report Download”- type: actionButton properties: label: Download PDF icon: bi-download report: sale_order_print variant: secondaryMethod Call
Section titled “Method Call”- type: actionButton properties: label: Confirm Order icon: bi-check method: action_confirm variant: primary confirm: "Are you sure you want to confirm this order?"confirm is the same prompt grammar as a backend button (see Confirmation
prompts) — the string shorthand or the
{title, message, label, variant} form. On a portal page the browser’s own dialog carries
the message, so only message is shown there.
Signature Modal
Section titled “Signature Modal”- type: actionButton properties: label: Accept & Sign icon: bi-pen method: action_confirm modal: signature variant: primary visible: Q(state__eq='Draft')Button Variants
Section titled “Button Variants”| Variant | Description |
|---|---|
primary | Prominent action (dark background) |
secondary | Standard action (outlined) |
success | Positive action (green) |
Conditional Visibility
Section titled “Conditional Visibility”Use Q expressions for conditional visibility on fields, cards, and actions:
# Show only when state is Draftvisible: Q(state__eq='Draft')
# Show only when field has valuevisible: Q(terms_conditions__isnull=False) & Q(terms_conditions__neq='')
# Show for Posted invoicesvisible: Q(state__eq='Posted')Messages Section
Section titled “Messages Section”Add a messaging/collaboration section to allow portal users to communicate:
- type: messagesThis renders the document’s message history and allows adding new messages.
Conditional Cards
Section titled “Conditional Cards”Cards can have visibility conditions:
- type: card title: Terms & Conditions icon: bi-file-text properties: visible: Q(terms_conditions__isnull=False) & Q(terms_conditions__neq='') content: - type: field name: terms_conditions properties: widget: Html noLabel: trueTheme Integration
Section titled “Theme Integration”The portal automatically integrates with the website theme when installed:
- Uses theme CSS variables (
--theme-primary,--theme-background, etc.) - Inherits navigation header and footer
- Falls back to standalone portal header when website module is not installed
CSS Variables
Section titled “CSS Variables”Portal styles use these CSS variables with theme fallbacks:
:root { --portal-accent: var(--theme-primary, #1a1a1a); --portal-background: var(--theme-background, #fafafa); --portal-surface: var(--theme-surface, #ffffff); --portal-border: var(--theme-border, #dee2e6); --portal-text: var(--theme-text, #212529); --portal-text-muted: var(--theme-text_muted, #6c757d);}Complete Example
Section titled “Complete Example”Here’s a complete portal configuration for a Sales Order:
Portal Action
Section titled “Portal Action”- depends: portal_views data_type: PortalAction identifier: portal_orders name: Orders model: SaleOrder module: sales icon: bi-cart sequence: 10 contact_field: contact list_view: sale_order_portal_list detail_view: sale_order_portal_detail active: trueList View
Section titled “List View”- data_type: UiView name: sale_order_portal_list identifier: sale_order_portal_list type: PortalList model: SaleOrder arch: - content: - type: field name: name properties: widget: Text label: Number fw: 600 - type: field name: date properties: widget: Date label: Date - type: field name: state properties: widget: Badge label: Status colors: Draft: gray Confirmed: green Done: teal Cancelled: red - type: field name: total properties: widget: Monetary label: Total fw: 600Detail View
Section titled “Detail View”- data_type: UiView name: sale_order_portal_detail identifier: sale_order_portal_detail type: PortalDetail model: SaleOrder arch: # Sidebar: Info panel - type: field name: user properties: display_mode: info_panel label: Your Contact - type: field name: total properties: display_mode: info_panel label: Total Amount widget: Monetary
# Sidebar: Actions - type: actionButton properties: label: Download PDF icon: bi-download report: sale_order_print variant: secondary - type: actionButton properties: label: Accept & Sign icon: bi-pen method: action_confirm modal: signature variant: primary visible: Q(state__eq='Draft')
# Header row - type: row content: - type: column span: 8 content: - type: field name: name properties: widget: Text size: xl fw: 700 noLabel: true - type: column span: 4 properties: style: "text-align: right;" content: - type: field name: state properties: widget: Badge noLabel: true colors: Draft: gray Confirmed: green Done: teal Cancelled: red
# Order details card - type: card title: Order Details icon: bi-info-circle content: - type: row content: - type: column span: 3 content: - type: field name: date properties: widget: Date label: Order Date - type: column span: 3 content: - type: field name: validity_date properties: widget: Date label: Valid Until visible: Q(state__eq='Draft') - type: column span: 3 content: - type: field name: commitment_date properties: widget: Date label: Delivery Date - type: column span: 3 content: - type: field name: payment_terms properties: widget: Text label: Payment Terms
# Order lines with totals - type: field name: lines properties: widget: List view: sale_order_line_portal_list title: Order Lines icon: bi-box-seam totals: - name: subtotal label: Subtotal widget: Monetary - name: total_tax label: Tax widget: Monetary - name: total label: Total widget: Monetary fw: 600
# Terms & Conditions (conditional) - type: card title: Terms & Conditions icon: bi-file-text properties: visible: Q(terms_conditions__isnull=False) & Q(terms_conditions__neq='') content: - type: field name: terms_conditions properties: widget: Html noLabel: true
# Messages - type: messagesLines View
Section titled “Lines View”- data_type: UiView name: sale_order_line_portal_list identifier: sale_order_line_portal_list type: PortalList model: SaleOrderLine arch: - content: - type: field name: description properties: widget: Text label: Item - type: field name: quantity properties: widget: Number label: Qty - type: field name: unit_price properties: widget: Monetary label: Unit Price - type: field name: subtotal properties: widget: Monetary label: AmountURL Structure
Section titled “URL Structure”Portal URLs follow this pattern:
| URL | Description |
|---|---|
/portal | Portal home page |
/portal/{identifier} | Document list |
/portal/{identifier}/{id} | Document detail |
/portal/{identifier}/{id}/report/{report_id} | PDF report download |
/portal/{identifier}/{id}/action/{method} | Execute action |
/portal/access/{token} | Magic link — one document, no login |
Magic Links on Outbound Email
Section titled “Magic Links on Outbound Email”Publishing a model to the portal also puts it in the customer’s email. When an email is sent
about a record whose model has a PortalAction, the delivery appends a “View Online” button
linking to /portal/access/{token} — the customer opens the live document without an account.
There is nothing to configure. Shipping the PortalAction above is the whole opt-in: a send
raised from a form is anchored to its record automatically, and the link resolves from there.
(A send raised in code passes model/document to queue_email — see
Email.)
The one precondition is that the install knows its own public URL, since the link has to be absolute to survive an inbox. That is auto-detected per database and overridable under Settings → General — see The install’s public URL. Where it isn’t known, no button is emitted at all rather than a relative link that goes nowhere.
The token is a PortalAccess row, issued one per document and recipient contact:
access = await get_model("PortalAccess").get_or_create_token("SaleOrder", order.id, contact)access.get_portal_url() # "/portal/access/<token>"await access.revoke() # kills this customer's link, nobody else'sResending returns the same token and pushes its expiry out (90 days by default), so the link
in the customer’s oldest email about that document stays the live one. A revoked or expired token
is never revived — a fresh one is issued alongside it, and the dead row remains as the audit trail
(access_count, last_accessed).
contact_field decides who the token is bound to, the same field that scopes the customer’s
document list — so a record with no contact simply gets no link.
Linking to a document from your own code
Section titled “Linking to a document from your own code”To hand a customer the same link from somewhere else — a printed QR, an SMS, a webhook payload — ask for the document’s URL rather than assembling one:
url = await get_model("PortalAccess").document_url("SaleOrder", order.id)# "https://erp.example.com/portal/access/<token>", or NoneIt resolves the model’s PortalAction, reads contact_field to find the customer, and issues (or
reuses) their token — so it returns the same URL the “View Online” email button carries.
That matters: minting a second token for one document would mean revoking the customer’s access
closes one route in and silently leaves the other live.
None is the ordinary answer, not an error, and every caller must handle it — the model isn’t
published to the portal, the record has no contact to bind a token to, or the install has no
public URL. Emit nothing in that case rather than a link that goes nowhere.
Note that issuing a token is a write, so this belongs in an action or a report’s
data_method — never in a calculated field, where every read of the record would trigger it.
Security
Section titled “Security”Portal access is controlled by:
- Authentication - Users must be logged in with portal access
- Contact Filtering - Records are filtered by
contact_fieldto show only the user’s documents - Global Filter - Additional Q expression filtering (e.g., only show customer invoices, not vendor bills)
- Model Collaboration - The model should have
_collaborate = True(and typically_follow_fieldslisting the customer so they follow their own records)