Portal Views
Use list-mode web forms with sidebar navigation to show logged-in users their own records in a portal.
On this page
Authenticated list-mode web forms that present a filtered table of records to logged-in users, combined with sidebar navigation for a portal experience.
Purpose
Portal Views expose a "my documents" list to logged-in users (e.g., guardians viewing their players' applications). They use the same Web Form infrastructure but in list mode, applying per-user document permissions so each user sees only their own records.
File Structure
mymodule/web_form/my_portal_view/
├── my_portal_view.json # Web form definition (show_list: 1, login_required: 1)
├── my_portal_view.py # Server-side get_context() for list filtering, sidebar
└── my_portal_view.js # Client-side enhancements (optional)JSON Configuration
Key settings that distinguish a portal view from a normal web form:
{
"doctype": "Web Form",
"name": "my-portal-view",
"doc_type": "Player Application",
"route": "portal/applications",
"module": "My Module",
"is_standard": 1,
"published": 1,
"login_required": 1,
"show_list": 1,
"allow_edit": 0,
"allow_multiple": 0,
"apply_document_permissions": 1,
"list_columns": [
{"fieldname": "player_full", "fieldtype": "Data", "label": "Player"},
{"fieldname": "season", "fieldtype": "Link", "label": "Season", "options": "Season"},
{"fieldname": "status", "fieldtype": "Select", "label": "Status"},
{"fieldname": "paid", "fieldtype": "Check", "label": "Paid"}
],
"breadcrumbs": "[{\"label\":\"Home\",\"route\":\"/\"},{\"label\":\"Portal\",\"route\":\"portal\"}]"
}| Property | Purpose |
|---|---|
login_required: 1 | Redirect unauthenticated users to login |
show_list: 1 | Display records as a list instead of a single form |
apply_document_permissions: 1 | Respect doctype-level has_permission |
list_columns | Columns to show in the list table |
Server-Side Context (get_context)
The server hook filters the list to only records the current user should see:
import frappe
from frappe.query_builder import DocType
def get_context(context):
# 1. Require login
if frappe.session.user == "Guest":
frappe.throw("Please log in to view this page.", frappe.PermissionError)
# 2. Website sidebar for portal navigation
context.show_sidebar = True
context.sidebar_items = frappe.get_all(
"Website Sidebar Item",
filters={"parent": "Parent Portal"},
fields=["title", "route", "group"],
order_by="idx",
)
# 3. Filter list to current user's records
if context.get("is_list"):
context.list_filters = get_user_filters()
def get_user_filters():
"""Return filters that restrict the record list to the current user's family."""
Contact = DocType("Contact")
Guardian = DocType("Family Guardian")
Player = DocType("Player")
# Find players linked to the current user
player_names = (
frappe.qb.from_(Contact)
.join(Guardian).on(Guardian.guardian == Contact.name)
.join(Player).on(Player.family == Guardian.parent)
.select(Player.name)
.where(Contact.email_id == frappe.session.user)
.run(as_list=True)
)
flat = [p[0] for p in player_names]
if flat:
return {"player": ["in", flat]}
else:
# Return impossible filter to show empty list
return {"player": ["=", "__none__"]}How List Filtering Works
Frappe applies context.list_filters as additional WHERE clauses on the list query. This works in conjunction with apply_document_permissions: 1 for defense-in-depth:
apply_document_permissions– Frappe checkshas_permission()on each recordlist_filters– Pre-filters the query so only relevant records are fetched
Use both together. list_filters improves performance by narrowing the query, while apply_document_permissions ensures no unauthorized access slips through.
Website Sidebar
Portal navigation is defined in a Website Sidebar document. Each item links to a portal route:
context.show_sidebar = True
context.sidebar_items = frappe.get_all(
"Website Sidebar Item",
filters={"parent": "Parent Portal"},
fields=["title", "route", "group"],
order_by="idx",
)This renders a sidebar with links like:
My Players →
/portal/playersApplications →
/portal/applicationsPayments →
/portal/payments
The sidebar is shared across multiple portal views by referencing the same Website Sidebar parent.
List Columns
Define which columns appear in the list table:
"list_columns": [
{"fieldname": "player_full", "fieldtype": "Data", "label": "Player"},
{"fieldname": "season", "fieldtype": "Link", "label": "Season", "options": "Season"},
{"fieldname": "status", "fieldtype": "Select", "label": "Status"},
{"fieldname": "paid", "fieldtype": "Check", "label": "Paid"}
]Columns should match fields that are on the underlying doctype. Link fields render as plain text in the list view.
Client-Side Enhancements
Minimal client-side code for list-mode views, but frappe.ready() still runs:
frappe.ready(function () {
// Customize list page title
const heading = document.querySelector(".web-form-title h1");
if (heading) {
heading.textContent = "My Applications";
}
});Nested Portal Routes
Portal views can be nested under a common prefix to group related views:
play/portal/applications → Player Application list
play/portal/players → Player list
play/portal/payments → Payment historyThe breadcrumbs configuration maintains navigation context:
"breadcrumbs": "[{\"label\":\"Home\",\"route\":\"/\"},{\"label\":\"Portal\",\"route\":\"portal\"}]"Integration with Web Forms
A portal view and a web form can target the same doctype:
| Concern | Configuration |
|---|---|
| Public entry form | login_required: 0, show_list: 0 |
| Authenticated portal | login_required: 1, show_list: 1, apply_document_permissions: 1 |
This allows a two-pronged approach: a public form for new submissions, and a portal for existing users to review their records.
Checklist
Set
login_required: 1for portal viewsSet
show_list: 1andapply_document_permissions: 1Implement
list_filtersinget_context()for per-user filteringConfigure
list_columnsto show relevant fieldsCreate a Website Sidebar document for portal navigation
Set
context.show_sidebar = Trueinget_context()Add
breadcrumbsfor navigation contextTest as both the target user and another user to verify filtering
This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.