Declaring a resource¶
A resource is one screen's worth of declaration. Everything else is derived.
Resource(
"deals",
provider="db.main#deals",
fields=[...],
views=[...], # optional; sensible ones are generated
actions=[...], # optional
policy=..., # optional; defaults to "any signed-in user"
)
provider is a connection#target reference, an already-built provider
object, or a Union(...) of several — see
Several databases.
Fields¶
Every field answers four questions: how a submitted string becomes a Python value, what that value is validated against, how it renders, and which filter operators make sense for it.
TextField(
"name",
label="Deal name", # defaults to a humanised field name
required=True,
help="Shown under the input",
placeholder="Acme renewal",
# where it appears
in_list=True, in_form=True, in_detail=True, in_filter=False,
sortable=True, searchable=True, inline_editable=True,
# who may see or change it
read_roles=["finance"], write_roles=["manager"],
)
Types¶
| Group | Types |
|---|---|
| Text | text textarea richtext email url phone |
| Numeric | integer decimal currency percent duration |
| Boolean | boolean |
| Temporal | date datetime time |
| Choice | select status multiselect tags |
| Structured | json color file image rows |
| Relational | relation backref |
| Derived | computed case |
computed is a value produced from the record rather than read from it. Its
callable is given the record, and — if it declares a second parameter — the
request context with it, which is how a computed label says an instant in the
reader's own hours rather than in UTC:
TextField("when", compute=lambda record, ctx: to_zone(
record["starts_at"], ctx.identity.timezone).strftime("%d %b %H:%M"))
The signature is the request: a one-argument compute keeps working untouched, and a compute that asks for the context but is called without one (from the CLI, or a background job) falls back to UTC rather than refusing to render.
case is a column that does not exist, expressed in terms of ones that do:
ordered branches, first match wins, compiled into the query as a CASE. Use it
for the value a screen is really organised by when no column holds it — a job's
lifecycle, a customer's tier. Because it is in the query rather than in Python,
a list can sort, group and paginate by it. Declare order= when the order the
branches must be matched in differs from the order they should be read in.
A ListView(group_by=...) sorts by that field first and draws a header row each
time its value changes, so a grouped list stays one query and still pages.
rows is a grid: several records entered at once, each cell the ordinary input
for its column, submitted as parallel arrays (rows.email repeated once per
row). Use it as an action's prompt_field when the subject of the action is a
set of new things. Its input offers a "fill from CSV" picker, but the file is
read in the browser and never uploaded — what is submitted is always the grid,
so every cell goes through the same validation as a single-record form.
status renders as a coloured pill and can group a board. relation renders
as a link with a typeahead input. backref is the many side — not stored,
resolved by querying the other resource, shown as an embedded list.
RelationField("company_id", resource="companies", display="name")
BackrefField("contacts", resource="contacts", via="company_id",
columns=("name", "email"))
BackrefField("bookings", resource="bookings", via="venue_id",
order=("-starts_at",))
actions=True offers the target's own row actions on each embedded row, so
somebody can be taken off a shift from the shift they are on rather than from
the assignment's page; running one returns to the screen the button was pressed
on. It is off by default, because an embedded list is usually context and a
screen should not grow buttons because another resource gained an action.
tree= names a tree view on the target: the embedded rows are drawn with that
view's expanders and open through the target's own /children fragment, so a
person's jobs can carry their shifts underneath without this screen knowing
anything about shifts.
order= is the order the embedded rows read in; without it they take the
target's own default. Which one is right depends on the record you arrived
from — a booking list is a booking list, a venue's bookings are read latest
first — and only the backref knows that.
Because a relation is resolved by a query rather than a join, the two resources may live in entirely different backends.
Dynamic choices¶
Resolved per request, never cached across them.
Views¶
Omit views= and reasonable list, form and detail views are generated from the
field flags. Declare them to take control.
ListView(
columns=[Column("name", link=True, width="30%"), "amount", "stage"],
default_sort=["-amount"],
row_actions=["mark_won"],
bulk_actions=["mark_won", "delete"],
inline_edit=True,
)
FormView([
Section("Deal", ["name", "company_id"], columns=2),
Section("Notes", ["notes"], columns=1),
], submit_label="Save deal")
BoardView(group_by="stage", card=Card(title="name", badges=["amount"]),
sum_field="amount")
# A day, week, fortnight or month grid. The scale is a request parameter
# (`?scale=week&at=2026-09-03`), so `default_scale` only says where readers
# land -- two people can read the same calendar at different widths.
# `end_field` and `all_day=False` make the block a span with its clock times
# printed in the reader's zone; `color_field` tints it -- by the field's own
# choice colours where it has them, and by a stable hash of the value where it
# does not, which is how "the same job is the same colour" works for a field
# with as many values as there are jobs.
CalendarView(start_field="due_on", title_field="subject", default_scale="month")
CalendarView(start_field="start_time", end_field="end_time", all_day=False,
title_field="job_id", color_field="job_id", default_scale="week")
ChartView(group_by="stage", measure=Measure(Agg.SUM, "amount"), chart="column")
# A series rather than a ranking. `sort=` orders the groups by their own value
# and turns off ordering by the measure, because the two cannot both win: a
# chart read left to right has to be chronological.
ChartView(group_by="month", measure=Measure(Agg.SUM, "amount"),
chart="line", sort=("month",))
PivotView(rows=("owner",), columns=("stage",),
measures=(Measure(Agg.SUM, "amount"),))
# Rows that expand. `parent_field` names this resource's own parent column,
# so the tree recurses; naming `child_resource` instead hangs a different
# resource under each row, one level deep.
TreeView(columns=[Column("name", link=True), "city"], parent_field="parent_id")
TreeView(parent_field="company_id", child_resource="contacts")
# Bars along a time axis. Both dates are required: a bar needs two, and
# inventing the second would draw a schedule the data does not claim.
GanttView(start_field="created_at", end_field="expected_close",
group_by="stage", progress_field="probability", default_scale="month")
# Pins on a map, from a latitude/longitude pair.
MapView(lat_field="latitude", lon_field="longitude", title_field="name")
# Outstanding work, crossed by who owns it and what kind it is. States are
# computed against today, so nothing has to be swept overnight to stay true.
ActivityView(row_field="owner", activity_field="kind", due_field="due_on",
done_filter=Condition("done", Op.EQ, False))
# Several views on one page. Each panel is fetched separately, from the same
# handler that serves it as a full page.
DashboardView(panels=[
Panel(view="chart", span=1),
Panel(view="activity", resource="activities", span=2),
# A panel may be a *filtered* view -- one work queue rather than the whole
# table. Anything the list route reads works, because it is the list route:
# `quick`, an `f.<field>` filter, a sort.
Panel(view="list", title="Unassigned",
params={"f.owner": "", "sort": "-created_at"}),
], columns=2)
Views are switchable from the toolbar; the list route picks one from ?view=.
?view=kanban reaches a board and ?view=hierarchy a tree, because those are
the names people look for.
How a view is put together¶
Every kind is the same three pieces, which is what makes adding another one small:
| Piece | Where |
|---|---|
| The spec | a View subclass in app/resources/views.py, exposing field_names |
| The query | a case in the match spec.kind in app/web/routes/resource.py |
| The markup | app/templates/views/<kind>.html, overridable per resource |
A tree also has a fragment route, /r/<resource>/<pk>/children, which returns
one level at a time: a row's toggle asks for its children at depth + 1, and
those rows carry toggles of their own. The server never holds more than one
level, so depth costs nothing until someone opens it. A dashboard panel is the
ordinary view with ?panel=1, which swaps the page shell for a bare layout --
the handler, the query, the permission check and the template are unchanged.
Maps are the one view that reaches outside: tiles come from
CRM_MAP_TILE_URL, which defaults to public OpenStreetMap. Empty it and the
map becomes a list of located records, which is what an air-gapped install
wants.
Actions¶
@action("mark_won", "Mark as won", style="primary",
confirm="Mark this deal as won?",
available=lambda record, identity: record["stage"] != "won")
async def mark_won(records, ctx, resource):
for r in records:
await resource.provider.update(r.pk, {"stage": "won"}, ctx)
return ActionResult(message=f"Marked {len(records)} as won.")
The same handler serves a row button, a detail-page button and a bulk
operation — records is a list either way. resource.registry reaches other
resources, so an action can write through a different provider entirely.
Asking for values first¶
Most actions need something the record does not carry: why a deal was lost, how
long a ban should run, what to put in the note. prompt_fields collects them in
a dialog and hands them to the handler as params.
@action("mark_lost", "Mark as lost", style="danger",
prompt_fields=[
SelectField("lost_reason", label="Reason", choices=LOST_REASONS,
required=True),
TextAreaField("lost_note", label="Note", rows=3),
])
async def mark_lost(records, ctx, resource, *, params):
reason = params["lost_reason"]
...
Fields are declared inline, as above, or named as strings when the value is a
column on the resource (prompt_fields=["owner"]). They are coerced and
validated by the field itself, so a bad date comes back in the dialog with the
error next to it rather than reaching the handler.
Only a handler that declares params is given them — an action without prompts
keeps the three-argument signature, and existing handlers need no change.
Reporting a batch honestly¶
A bulk action over forty records is forty separate attempts, usually against
something remote. RowOutcome says what became of each, and from_outcomes
turns that into a message with the right level:
outcomes = [RowOutcome(r.pk, ok, message) for r in records]
return ActionResult.from_outcomes(outcomes, done="Cancelled")
All succeeded reads as a success; a mixed batch is a warning that names the first few failures and their reasons; everything failing is an error that does not refresh the list.
When the answer is too big or too important to fade, set template and the
result renders in the modal instead of as a toast — a per-row report, a file to
take away — staying on screen until it is dismissed:
result = ActionResult.from_outcomes(outcomes, done="Imported")
result.template = "views/_action_report.html"
result.data = {"headings": ["Email", "Result"], "rows": rows}
return result
``` The alternative — one "Done." over a batch where
three rows were refused — is the kind of thing people discover a week later.
## Policies
```python
Policy() # any signed-in user, all operations
PublicPolicy() # readable without signing in
ReadOnlyPolicy() # no writes at all
RolePolicy(read=["staff"], write=["manager"])
OwnerPolicy("owner", identity_attr="email", bypass_roles=("admin", "manager"))
OwnerPolicy is the row-scoping case. It returns a filter, which is folded
into every query the resource serves — lists, detail reads, exports and the
aggregates behind charts alike.
Write your own by subclassing:
class RegionPolicy(Policy):
def scope(self, identity):
region = identity.claims.get("region")
return Condition("region", Op.EQ, region) if region else DENY_ALL
Return a filter, not a boolean, and it applies everywhere automatically.
Search and filters¶
search=SearchSpec(
fields=("name", "notes"), # what free-text search looks in
filters=("stage", "owner", "source"), # what the filter panel offers
group_by=("stage", "owner"),
value_aliases={"my_region": lambda identity: identity.claims["region"]},
)
Filters travel in the URL — ?f.stage=won&f.amount__gte=1000&q=acme — so a
filtered list is a shareable link and the back button works. A parameter naming
an unknown field, an operator the field does not support, or a column the
caller may not read is dropped rather than passed through.
Operators¶
Each field type declares the operators that make sense for it, and only those are accepted:
| Operator | Reads as | Typical field |
|---|---|---|
eq, ne |
is, is not | any |
lt, lte, gt, gte |
is before, is at most, is after, is at least | numbers, dates |
between |
is between | numbers, dates — ?f.amount__between=1000,5000 |
in, not_in |
is any of, is none of | choices — ?f.stage__in=won,lost |
icontains, startswith, endswith |
contains, starts with, ends with | text |
is_null, not_null |
is empty, is not empty | any — the value is ignored |
A bare ?f.stage=won means eq, which is the common case.
The filter builder¶
The Filters panel above a list builds these URLs. Each row is a column, an operator and a value, and all three menus depend on each other — "is before" belongs to a date and not to a checkbox — so the panel is assembled in the browser from a schema the page ships with it.
It submits indexed triples (fc.0.field, fc.0.op, fc.0.value), because an
HTML <select> cannot rewrite the name of the input beside it. The server
rewrites those into the readable form above and redirects, so the encoding
never reaches the address bar, the back button or a shared link. Without
JavaScript the panel falls back to one box per column.
Value aliases¶
A filter value written @name is resolved per request, against the caller and
their timezone:
| Alias | Means |
|---|---|
@me |
the caller's email, else their subject |
@today, @yesterday, @tomorrow |
a calendar date in the caller's zone |
@week_start, @week_end |
Monday and Sunday of this week |
@month_start, @month_end, @year_start |
period boundaries |
@now |
the current instant, UTC |
So ?f.owner=@me&f.closed_on__between=@month_start,@month_end is one link that
means "my deals closing this month" — for whoever opens it, whenever they do.
That is what makes a saved filter worth saving.
Aliases resolve to Python values, not strings, and the field does the final
coercion; @today therefore works on a date column and a datetime one alike.
An alias nobody defines drops its condition rather than filtering on the
literal text "@yesteday", which would look like a working filter over an
empty result. Write @@ for a literal leading at-sign.
value_aliases adds resource-specific ones, and shadows a built-in of the same
name — useful when a resource's owner column holds a login name rather than the
email @me resolves to by default.
Menu placement¶
Group ordering comes from the module manifest: