Skip to content

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.

The portal system consists of:

  1. PortalAction - Configuration records that define which models are accessible in the portal
  2. Portal Views - PortalList and PortalDetail view types for displaying documents
  3. WebBlocks - Jinja templates for rendering portal pages
  4. Theme Integration - Inherits styling from website theme when installed

To expose a model in the customer portal, create a PortalAction record:

views/portal.yaml
- 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: true
FieldTypeDescription
identifierstringUnique identifier (used in URL: /portal/{identifier})
namestringDisplay name in navigation sidebar
modelstringTarget model name
modulestringModule that owns this portal action
iconstringBootstrap icon class (e.g., bi-receipt)
sequenceintegerOrder in navigation (lower = first)
contact_fieldstringField linking records to portal user’s contact
global_filterstringQ expression to filter records (e.g., only show certain types)
list_viewstringIdentifier of PortalList view
detail_viewstringIdentifier of PortalDetail view
create_urlstringAddress of your “raise a new one” page. Set it to offer a button; blank means read-only
create_labelstringCaption on that button (defaults to “New”)
activebooleanWhether this portal action is enabled

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 Ticket

The 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 action from current_template.create_url rather 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 use the same UiView system as backend views but with types PortalList and PortalDetail.

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: 600

The following field names are automatically sortable: name, date, state, total, number, created_date.

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: messages
ElementDescription
rowHorizontal container (12-column grid)
columnVertical container with span (1-12)
cardCard container with title and optional icon
dividerVisual separator

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: state
ElementDescription
fieldData field with widget
actionButtonButton for an action (method), a report download (report), or a link (url)
messagesCollaboration/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.)

PropertyDescription
display_mode: info_panelShow field in sidebar info panel
noLabel: trueHide field label
visible: Q(...)Conditional visibility using Q expressions
fw: 600Font weight (400, 500, 600, 700)
size: xlText size (sm, md, lg, xl)

Portal views support these widgets:

WidgetDescription
TextPlain text display
DateFormatted date
BadgeStatus badge with colors
MonetaryCurrency-formatted amount
NumberNumeric value
ProgressBarPercentage as a filled bar + label (honors suffix, precision)
HtmlHTML content
ListEmbedded list (for lines)
- type: field
name: state
properties:
widget: Badge
colors:
Draft: gray
Posted: green
Paid: teal
Cancelled: red

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: 600

A 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).

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: Monetary

Monetary fields display prominently. Other field types display as label/value pairs.

Action buttons appear in the sidebar and can trigger:

- type: actionButton
properties:
label: Download PDF
icon: bi-download
report: sale_order_print
variant: secondary
- 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.

- type: actionButton
properties:
label: Accept & Sign
icon: bi-pen
method: action_confirm
modal: signature
variant: primary
visible: Q(state__eq='Draft')
VariantDescription
primaryProminent action (dark background)
secondaryStandard action (outlined)
successPositive action (green)

Use Q expressions for conditional visibility on fields, cards, and actions:

# Show only when state is Draft
visible: Q(state__eq='Draft')
# Show only when field has value
visible: Q(terms_conditions__isnull=False) & Q(terms_conditions__neq='')
# Show for Posted invoices
visible: Q(state__eq='Posted')

Add a messaging/collaboration section to allow portal users to communicate:

- type: messages

This renders the document’s message history and allows adding new messages.

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: true

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

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);
}

Here’s a complete portal configuration for a Sales Order:

views/portal.yaml
- 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: true
views/portal_views.yaml
- 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: 600
- 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: messages
- 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: Amount

Portal URLs follow this pattern:

URLDescription
/portalPortal 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

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's

Resending 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.

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 None

It 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.

Portal access is controlled by:

  1. Authentication - Users must be logged in with portal access
  2. Contact Filtering - Records are filtered by contact_field to show only the user’s documents
  3. Global Filter - Additional Q expression filtering (e.g., only show customer invoices, not vendor bills)
  4. Model Collaboration - The model should have _collaborate = True (and typically _follow_fields listing the customer so they follow their own records)