Skip to content

pyvelm.render

render

HTMX + Jinja renderer.

A small, framework-shipped UI layer that interprets ir.ui.view.arch into HTML. Developers don't write Jinja — they declare view arch in their manifests, and the framework's templates dispatch through a widget registry to produce field HTML per cell.

The widget registry is keyed by (field_class, hint): - An explicit widget attribute on a field-spec dict picks a named variant (e.g. Boolean + "toggle" → render as a styled toggle). - Without a hint, the registry falls back to the bare field class (Boolean → checkmark, Many2one → display value, etc.). - Subclass lookup via MRO so Text falls through to Char.

Widgets are tiny functions: (value, field_spec, field) -> Markup. They MUST return a markupsafe.Markup to opt out of Jinja auto-escape; bare strings are escaped. This is the safety contract.

widget

widget(field_class: type, hint: str | None = None, mode: str = 'display')

Register a renderer for (field_class, hint) under mode. Decorator.

Source code in pyvelm/render.py
def widget(field_class: type, hint: str | None = None, mode: str = "display"):
    """Register a renderer for (field_class, hint) under `mode`.
    Decorator."""
    table = _registry if mode == "display" else _edit_registry

    def decorator(fn: WidgetRenderer) -> WidgetRenderer:
        table[(field_class, hint)] = fn
        return fn

    return decorator

find_renderer

find_renderer(field: Field, hint: str | None, mode: str = 'display') -> WidgetRenderer

Walk the field's MRO looking for an explicit hint match; fall back to a no-hint match at the same level; finally return the default renderer for the requested mode.

Source code in pyvelm/render.py
def find_renderer(
    field: Field, hint: str | None, mode: str = "display"
) -> WidgetRenderer:
    """Walk the field's MRO looking for an explicit hint match; fall
    back to a no-hint match at the same level; finally return the
    default renderer for the requested mode."""
    table = _registry if mode == "display" else _edit_registry
    for cls in type(field).__mro__:
        if not isinstance(cls, type) or not issubclass(cls, Field):
            continue
        if hint is not None:
            r = table.get((cls, hint))
            if r is not None:
                return r
        r = table.get((cls, None))
        if r is not None:
            return r
    return _default_renderer if mode == "display" else _default_edit

register_shell_globals

register_shell_globals(jinja_env) -> None

Register shared Jinja globals for framework and module template envs.

Source code in pyvelm/render.py
def register_shell_globals(jinja_env) -> None:
    """Register shared Jinja globals for framework and module template envs."""
    from pyvelm.branding import default_brand_globals

    _register_icon_globals(jinja_env)
    for key, val in default_brand_globals().items():
        jinja_env.globals.setdefault(key, val)
    jinja_env.filters.setdefault("human_size", _human_size)

merge_template_context

merge_template_context(env, current_path: str | None = None, **extra) -> dict

Merge layout shell + company theme for any Jinja template render.

Source code in pyvelm/render.py
def merge_template_context(
    env, current_path: str | None = None, **extra
) -> dict:
    """Merge layout shell + company theme for any Jinja template render."""
    ctx = layout_context(env, current_path) if env is not None else {}
    if env is not None:
        ctx.update(extra)
        return ctx
    from pyvelm.branding import branding_context

    ctx = branding_context(None)
    ctx.update(extra)
    return ctx

render_list_row

render_list_row(view, record, env, *, mode: str = 'display') -> str

Render a single <tr> fragment for a record.

Used by the click-to-edit flow: GET (display), GET .../edit (edit), POST .../row/{id} (returns display after save), POST .../new (returns display after create). One template per mode.

Source code in pyvelm/render.py
def render_list_row(view, record, env, *, mode: str = "display") -> str:
    """Render a single `<tr>` fragment for a record.

    Used by the click-to-edit flow: GET (display), GET .../edit (edit),
    POST .../row/{id} (returns display after save), POST .../new (returns
    display after create). One template per mode.
    """
    from .views import resolve_arch

    arch = resolve_arch(view)
    fields_spec = arch.get("fields", [])
    cls = env.registry[view.model]
    # Enrich for both modes so the display-mode "open record" link
    # under each Many2one gets its URL.
    fields_spec = _enrich_specs_for_edit(env, cls, fields_spec)
    cells = _render_cells(record, fields_spec, mode=mode)
    template_name = "list_row_edit.html" if mode == "edit" else "list_row.html"
    template = _env.get_template(template_name)
    return template.render(
        view=view,
        row={"id": record.id, "cells": cells},
        access=template_access(env, view.model),
    )

render_new_row

render_new_row(view, env) -> str

Render an empty edit <tr> for inline creation.

Source code in pyvelm/render.py
def render_new_row(view, env) -> str:
    """Render an empty edit `<tr>` for inline creation."""
    from .views import resolve_arch

    arch = resolve_arch(view)
    fields_spec = arch.get("fields", [])
    cls = env.registry[view.model]
    fields_spec = _enrich_specs_for_edit(env, cls, fields_spec)
    cells = _render_cells_empty(env, cls, fields_spec, mode="edit")
    template = _env.get_template("list_row_edit.html")
    return template.render(view=view, row={"id": None, "cells": cells})

harvest_o2m_commands

harvest_o2m_commands(model_cls, form_data, env) -> tuple[dict, dict]

Pull inline-O2m commands out of a flat form-data MultiDict.

Returns (commands_by_field, errors):

  • commands_by_field maps each O2m parent field name to an ordered list of dicts like {"op": "create"|"update"|"delete", "id": int|None, "vals": {...}}.
  • errors maps the namespaced sub-key ("rate_ids[0][rate]") to a human-readable message, suitable for surfacing in a form-level banner.

Keys not matching the nested pattern are ignored — the top-level parse_form_vals keeps doing its own thing for scalar fields.

Source code in pyvelm/render.py
def harvest_o2m_commands(model_cls, form_data, env) -> tuple[dict, dict]:
    """Pull inline-O2m commands out of a flat form-data MultiDict.

    Returns ``(commands_by_field, errors)``:

    * ``commands_by_field`` maps each O2m parent field name to an
      ordered list of dicts like
      ``{"op": "create"|"update"|"delete", "id": int|None,
         "vals": {...}}``.
    * ``errors`` maps the namespaced sub-key
      (``"rate_ids[0][rate]"``) to a human-readable message, suitable
      for surfacing in a form-level banner.

    Keys not matching the nested pattern are ignored — the top-level
    ``parse_form_vals`` keeps doing its own thing for scalar fields."""
    by_field: dict[str, dict[int, dict]] = {}
    keys = list(form_data.keys()) if hasattr(form_data, "keys") else list(form_data)
    o2m_names: set[str] = set()
    for key in keys:
        m = _O2M_NESTED_KEY.match(key)
        if not m:
            continue
        oname = m.group(1)
        if isinstance(model_cls._fields.get(oname), One2many):
            o2m_names.add(oname)
    for oname in o2m_names:
        ofield = model_cls._fields[oname]
        co_cls = env.registry[ofield.comodel_name]
        by_field[oname] = _o2m_bucket_form(form_data, oname, co_cls)

    commands_by_field: dict[str, list] = {}
    errors: dict[str, str] = {}
    for oname, by_idx in by_field.items():
        ofield = model_cls._fields[oname]
        co_cls = env.registry[ofield.comodel_name]
        cmds: list[dict] = []
        for idx in sorted(by_idx):
            raw = dict(by_idx[idx])
            op = raw.pop("_op", "update")
            rid_raw = raw.pop("id", None)
            try:
                rid = int(rid_raw) if rid_raw not in (None, "") else None
            except (TypeError, ValueError):
                rid = None
            if op == "delete":
                if rid is None:
                    continue
                cmds.append({"op": "delete", "id": rid, "vals": {}})
                continue
            child_vals: dict = {}
            had_error = False
            for sub_name in list(raw.keys()):
                if sub_name.endswith("_date") or sub_name.endswith("_time"):
                    base = sub_name.rsplit("_", 1)[0]
                    if isinstance(co_cls._fields.get(base), Datetime):
                        continue
                sub_raw = raw[sub_name]
                sub_field = co_cls._fields.get(sub_name)
                if sub_field is None or not sub_field.is_stored:
                    continue
                if isinstance(sub_field, Datetime) and (
                    f"{sub_name}_date" in raw or f"{sub_name}_time" in raw
                ):
                    mini = {
                        f"{sub_name}_date": raw.get(f"{sub_name}_date", ""),
                        f"{sub_name}_time": raw.get(f"{sub_name}_time", ""),
                    }
                    value, err = _parse_datetime_field(
                        mini, sub_name, sub_field, env=env
                    )
                else:
                    value, err = _parse_scalar(sub_field, sub_raw, env)
                if err:
                    errors[f"{oname}[{idx}][{sub_name}]"] = err
                    had_error = True
                    continue
                child_vals[sub_name] = value
            if had_error:
                continue
            if op == "create":
                cmds.append({"op": "create", "id": None, "vals": child_vals})
            else:
                if rid is None:
                    continue
                cmds.append({"op": "update", "id": rid, "vals": child_vals})
        if cmds:
            commands_by_field[oname] = cmds
    return commands_by_field, errors

apply_o2m_commands

apply_o2m_commands(parent_record, commands_by_field)

Persist O2m commands harvested by :func:harvest_o2m_commands.

Runs inside the caller's transaction. For create commands the inverse FK is set to the parent record so the new child stays linked even when the form didn't surface that field explicitly.

Source code in pyvelm/render.py
def apply_o2m_commands(parent_record, commands_by_field):
    """Persist O2m commands harvested by :func:`harvest_o2m_commands`.

    Runs inside the caller's transaction. For ``create`` commands the
    inverse FK is set to the parent record so the new child stays
    linked even when the form didn't surface that field explicitly.
    """
    env = parent_record.env
    parent_cls = type(parent_record)
    for oname, cmds in commands_by_field.items():
        ofield = parent_cls._fields[oname]
        Child = env[ofield.comodel_name]
        inverse = ofield.inverse_name
        for cmd in cmds:
            op = cmd["op"]
            if op == "create":
                vals = dict(cmd["vals"])
                vals.setdefault(inverse, parent_record.id)
                Child.create(vals)
            elif op == "update":
                Child.browse(cmd["id"]).write(cmd["vals"])
            elif op == "delete":
                Child.browse(cmd["id"]).unlink()

parse_form_vals

parse_form_vals(model_cls, form_data, env=None) -> tuple[dict, dict]

Convert a form-data MultiDict back into (vals, errors).

vals is the ORM-ready dict suitable for create() / write(). errors maps field_name -> human-readable message for any field that couldn't be coerced or that was left blank when declared required=True. Empty errors means it's safe to persist; non-empty means the caller should re-render the edit form with messages stamped on the offending cells.

Boolean checkboxes use a hidden-input-then-checkbox pair so that "unchecked" produces an empty string and "checked" produces "on" (we take the last value via getlist). Many2one selects emit an empty string for the null option, which becomes None.

Unknown form keys are ignored (the form may legitimately include framework-private fields). Empty Char inputs become None rather than empty strings.

Source code in pyvelm/render.py
def parse_form_vals(model_cls, form_data, env=None) -> tuple[dict, dict]:
    """Convert a form-data MultiDict back into `(vals, errors)`.

    `vals` is the ORM-ready dict suitable for `create()` / `write()`.
    `errors` maps `field_name -> human-readable message` for any
    field that couldn't be coerced or that was left blank when
    declared `required=True`. Empty `errors` means it's safe to
    persist; non-empty means the caller should re-render the edit
    form with messages stamped on the offending cells.

    Boolean checkboxes use a hidden-input-then-checkbox pair so that
    "unchecked" produces an empty string and "checked" produces "on"
    (we take the last value via `getlist`). Many2one selects emit an
    empty string for the null option, which becomes `None`.

    Unknown form keys are ignored (the form may legitimately include
    framework-private fields). Empty Char inputs become `None` rather
    than empty strings.
    """
    vals: dict = {}
    errors: dict[str, str] = {}
    for fname, field in model_cls._fields.items():
        # Many2many: handled before the is_stored gate. M2m has no
        # column on the owning table (is_stored=False) but the chip
        # editor still posts ids that BaseModel.write turns into
        # junction-table rows.
        if isinstance(field, Many2many):
            if fname not in form_data:
                continue
            seq = (
                form_data.getlist(fname)
                if hasattr(form_data, "getlist")
                else [form_data[fname]]
            )
            try:
                ids = [int(v) for v in seq if v not in ("", None)]
            except (TypeError, ValueError):
                errors[fname] = "Invalid record reference."
                continue
            vals[fname] = ids
            continue
        if not field.is_stored:
            continue
        try:
            from pyvelm.timestamps import is_system_timestamp_field
        except ImportError:
            is_system_timestamp_field = lambda _c, _f: False  # noqa: E731
        if is_system_timestamp_field(model_cls, fname):
            continue
        if isinstance(field, Datetime) and (
            fname in form_data
            or f"{fname}_date" in form_data
            or f"{fname}_time" in form_data
        ):
            parsed, err = _parse_datetime_field(form_data, fname, field, env=env)
            if err:
                errors[fname] = err
            else:
                vals[fname] = parsed
            continue
        if fname not in form_data:
            continue
        if isinstance(field, Boolean):
            seq = (
                form_data.getlist(fname)
                if hasattr(form_data, "getlist")
                else [form_data[fname]]
            )
            last = seq[-1] if seq else ""
            vals[fname] = bool(last)
            continue
        raw = form_data[fname]
        empty = raw in ("", None)

        if empty:
            if getattr(field, "required", False):
                errors[fname] = "This field is required."
            else:
                vals[fname] = None
            continue

        try:
            if isinstance(field, Integer):
                vals[fname] = int(raw)
            elif isinstance(field, Float):
                vals[fname] = float(raw)
            elif isinstance(field, Many2one):
                vals[fname] = int(raw)
            elif isinstance(field, Date):
                vals[fname] = field.to_sql_param(raw)
            elif isinstance(field, Time):
                vals[fname] = field.to_sql_param(raw)
            else:
                vals[fname] = raw
        except (TypeError, ValueError):
            if isinstance(field, Integer):
                errors[fname] = "Must be a whole number."
            elif isinstance(field, Float):
                errors[fname] = "Must be a number."
            elif isinstance(field, Many2one):
                errors[fname] = "Invalid record reference."
            elif isinstance(field, Date):
                errors[fname] = "Must be a date (YYYY-MM-DD)."
            elif isinstance(field, Datetime):
                errors[fname] = "Must be a datetime."
            elif isinstance(field, Time):
                errors[fname] = "Must be a time (HH:MM)."
            else:
                errors[fname] = "Invalid value."
    from .mass_assignment import filter_mass_assignment

    vals = filter_mass_assignment(model_cls, vals)
    return vals, errors

parse_bc_param

parse_bc_param(bc: str | None) -> list[tuple[str, str]]

Parse bc=mod/view,mod2/view2 into an ordered ancestor stack.

Source code in pyvelm/render.py
def parse_bc_param(bc: str | None) -> list[tuple[str, str]]:
    """Parse ``bc=mod/view,mod2/view2`` into an ordered ancestor stack."""
    if not bc or not str(bc).strip():
        return []
    out: list[tuple[str, str]] = []
    for part in str(bc).split(","):
        part = part.strip()
        if "/" not in part:
            continue
        module, name = part.split("/", 1)
        if module and name:
            out.append((module, name))
    return out

format_bc_param

format_bc_param(stack: list[tuple[str, str]]) -> str

Encode a breadcrumb ancestor stack for URL query params.

Source code in pyvelm/render.py
def format_bc_param(stack: list[tuple[str, str]]) -> str:
    """Encode a breadcrumb ancestor stack for URL query params."""
    return ",".join(f"{m}/{n}" for m, n in stack)

encode_view_nav_query

encode_view_nav_query(ref_module: str | None, ref_name: str | None, *, search: str = '', order: str = '', filters: str = '', group_by: str = '', page: int | None = None, page_size: int | None = None, bc_stack: list[tuple[str, str]] | None = None) -> str

Query string carrying the parent view and nav history onto form URLs.

  • ref — immediate parent view (module/viewname, any view type).
  • bc — comma-separated ancestor chain (oldest first), Odoo-style.
  • list — legacy alias of ref when the parent is a list view.
Source code in pyvelm/render.py
def encode_view_nav_query(
    ref_module: str | None,
    ref_name: str | None,
    *,
    search: str = "",
    order: str = "",
    filters: str = "",
    group_by: str = "",
    page: int | None = None,
    page_size: int | None = None,
    bc_stack: list[tuple[str, str]] | None = None,
) -> str:
    """Query string carrying the parent view and nav history onto form URLs.

    * ``ref`` — immediate parent view (``module/viewname``, any view type).
    * ``bc`` — comma-separated ancestor chain (oldest first), Odoo-style.
    * ``list`` — legacy alias of ``ref`` when the parent is a list view.
    """
    params: dict[str, str] = {}
    if ref_module and ref_name:
        ref = f"{ref_module}/{ref_name}"
        params["ref"] = ref
    bc = format_bc_param(bc_stack or [])
    if bc:
        params["bc"] = bc
    if search:
        params["search"] = search
    if order:
        params["order"] = order
    if filters:
        params["filters"] = filters
    if group_by:
        params["group_by"] = group_by
    if page is not None and page > 0:
        params["page"] = str(page)
    if page_size is not None:
        params["page_size"] = str(page_size)
    if ref_module and ref_name:
        # Legacy alias — form nav parser still accepts ``list=``.
        params["list"] = params["ref"]
    return urlencode(params)

encode_list_nav_query

encode_list_nav_query(list_module: str | None, list_name: str | None, *, search: str = '', order: str = '', filters: str = '', group_by: str = '', page: int | None = None, page_size: int | None = None, bc_stack: list[tuple[str, str]] | None = None) -> str

Query string carrying list/kanban context onto form URLs.

Source code in pyvelm/render.py
def encode_list_nav_query(
    list_module: str | None,
    list_name: str | None,
    *,
    search: str = "",
    order: str = "",
    filters: str = "",
    group_by: str = "",
    page: int | None = None,
    page_size: int | None = None,
    bc_stack: list[tuple[str, str]] | None = None,
) -> str:
    """Query string carrying list/kanban context onto form URLs."""
    return encode_view_nav_query(
        list_module,
        list_name,
        search=search,
        order=order,
        filters=filters,
        group_by=group_by,
        page=page,
        page_size=page_size,
        bc_stack=bc_stack,
    )

render_form_page

render_form_page(view, record_or_none, env, *, mode: str, body_only: bool = False, current_path: str | None = None, list_module: str | None = None, list_name: str | None = None, list_search: str = '', list_order: str = '', list_filters: str = '', group_by: str = '', page: int | None = None, page_size: int | None = None, bc_stack: list[tuple[str, str]] | None = None, errors: dict | None = None, submitted: dict | None = None, form_error: str | None = None, prefill: dict | None = None, form_playback=None, in_dialog: bool = False) -> str

Render the form HTML.

mode is "display", "edit", or "new". For "new" the record is None and field values come from defaults. body_only returns just the swappable inner-HTML fragment (used by HTMX swap targets); the default returns a complete page.

errors / submitted carry forward state from a failed save: errors stamps per-field messages, submitted resurrects the typed values so the user doesn't lose work. form_error is a top-level message for whole-form failures (e.g. ORM raised on write — unique constraint, database error).

Source code in pyvelm/render.py
def render_form_page(
    view,
    record_or_none,
    env,
    *,
    mode: str,
    body_only: bool = False,
    current_path: str | None = None,
    list_module: str | None = None,
    list_name: str | None = None,
    list_search: str = "",
    list_order: str = "",
    list_filters: str = "",
    group_by: str = "",
    page: int | None = None,
    page_size: int | None = None,
    bc_stack: list[tuple[str, str]] | None = None,
    errors: dict | None = None,
    submitted: dict | None = None,
    form_error: str | None = None,
    prefill: dict | None = None,
    form_playback=None,
    in_dialog: bool = False,
) -> str:
    """Render the form HTML.

    `mode` is "display", "edit", or "new". For "new" the record is None
    and field values come from defaults. `body_only` returns just the
    swappable inner-HTML fragment (used by HTMX swap targets); the
    default returns a complete page.

    `errors` / `submitted` carry forward state from a failed save:
    `errors` stamps per-field messages, `submitted` resurrects the
    typed values so the user doesn't lose work. `form_error` is a
    top-level message for whole-form failures (e.g. ORM raised on
    write — unique constraint, database error).
    """
    sections = _form_sections(
        view,
        record_or_none,
        env,
        mode,
        errors=errors,
        submitted=submitted,
        prefill=prefill,
        form_playback=form_playback,
    )
    title = _record_title(record_or_none, view.model, mode)
    template_name = "form_body.html" if body_only else "form.html"
    template = _env.get_template(template_name)
    if body_only:
        ctx = {}
    else:
        from pyvelm.menu import build_menu_tree

        prelim_menu = build_menu_tree(env, current_path)
        record_href: str | None = None
        if mode == "edit" and record_or_none is not None and record_or_none._ids:
            nav = encode_view_nav_query(
                list_module,
                list_name,
                search=list_search,
                order=list_order,
                filters=list_filters,
                group_by=group_by,
                page=page,
                page_size=page_size,
                bc_stack=bc_stack,
            )
            qs = f"?{nav}" if nav else ""
            record_href = (
                f"/web/views/{view.module}/{view.name}/record/"
                f"{record_or_none.id}{qs}"
            )
        form_crumbs = build_form_breadcrumbs(
            prelim_menu,
            env,
            ref_module=list_module,
            ref_name=list_name,
            bc_stack=bc_stack,
            search=list_search,
            order=list_order,
            filters=list_filters,
            group_by=group_by,
            page=page,
            page_size=page_size,
            leaf_label=title if mode != "new" else "New",
            mode=mode,
            record_href=record_href,
        )
        ctx = layout_context(env, current_path, breadcrumbs=form_crumbs)
        ctx["subtitle"] = f"{view.model} · {mode}"
    # Resolve header actions: substitute {id} with the current record's
    # id (display-mode only; new/edit records can't take row-level
    # actions). Anything without an id falls through with an empty list.
    from .views import resolve_arch

    arch = resolve_arch(view)
    is_detail = view.view_type == "detail"
    header_actions: list[dict] = []
    edit_form_href: str | None = None
    if mode == "display" and record_or_none is not None and record_or_none._ids:
        header_actions = _resolve_header_actions(
            arch.get("header_actions", []),
            env,
            model=view.model,
            module=view.module,
            name=view.name,
            record_id=record_or_none.id,
            record=record_or_none,
        )
        if is_detail:
            form_name = arch.get("form_view") or _find_form_view(view, env)
            rec_access = record_form_access(env, record_or_none, view=view)
            if form_name and rec_access["can_write"]:
                nav = encode_view_nav_query(
                    list_module,
                    list_name,
                    search=list_search,
                    order=list_order,
                    filters=list_filters,
                    group_by=group_by,
                    page=page,
                    page_size=page_size,
                    bc_stack=bc_stack,
                )
                qs = f"?{nav}" if nav else ""
                edit_form_href = (
                    f"/web/views/{view.module}/{form_name}/record/"
                    f"{record_or_none.id}/edit{qs}"
                )
    workflow_ctx = None
    if (
        mode == "display"
        and record_or_none is not None
        and record_or_none._ids
        and "workflow.instance" in env.registry
    ):
        from pyvelm.workflow.service import form_context as workflow_form_context

        workflow_ctx = workflow_form_context(env, view.model, record_or_none.id)

    chatter_ctx = None
    if (
        mode == "display"
        and record_or_none is not None
        and record_or_none._ids
    ):
        from pyvelm.mail_chatter import form_chatter_context

        chatter_ctx = form_chatter_context(
            env,
            view.model,
            record_or_none.id,
            enabled=_model_has_mail_thread(env, view.model),
        )

    record_pager = None
    if mode in ("display", "edit") and record_or_none is not None and record_or_none._ids:
        record_pager = _record_pager(
            env,
            model=view.model,
            record_id=record_or_none.id,
            form_module=view.module,
            form_name=view.name,
            mode=mode,
            list_module=list_module,
            list_name=list_name,
            list_search=list_search,
            list_order=list_order,
            list_filters=list_filters,
            group_by=group_by,
            bc_stack=bc_stack,
        )
    return template.render(
        view=view,
        record=record_or_none,
        record_id=(record_or_none.id if record_or_none else None),
        title=title,
        mode=mode,
        is_detail=is_detail,
        edit_form_href=edit_form_href,
        body_only=body_only,
        in_dialog=in_dialog,
        sections=sections,
        form_error=form_error,
        header_actions=header_actions,
        workflow_context=workflow_ctx,
        chatter_context=chatter_ctx,
        record_pager=record_pager,
        list_nav_query=encode_view_nav_query(
            list_module,
            list_name,
            search=list_search,
            order=list_order,
            filters=list_filters,
            group_by=group_by,
            page=page,
            page_size=page_size,
            bc_stack=bc_stack,
        ),
        access=_form_template_access(env, view.model, record_or_none, view=view),
        record_access=(
            record_form_access(env, record_or_none, view=view)
            if record_or_none is not None and record_or_none._ids
            else None
        ),
        **ctx,
    )

render_chatter_panel

render_chatter_panel(env, res_model: str, res_id: int, *, filter_key: str = 'all', composer_mode: str = 'note', error: str | None = None) -> str

HTMX fragment: chatter log + composer for one record.

Source code in pyvelm/render.py
def render_chatter_panel(
    env,
    res_model: str,
    res_id: int,
    *,
    filter_key: str = "all",
    composer_mode: str = "note",
    error: str | None = None,
) -> str:
    """HTMX fragment: chatter log + composer for one record."""
    from pyvelm.mail_chatter import form_chatter_context

    ctx = form_chatter_context(
        env,
        res_model,
        res_id,
        enabled=_model_has_mail_thread(env, res_model),
        filter_key=filter_key,
        composer_mode=composer_mode,
    )
    if ctx is None:
        return ""
    if error:
        ctx = {**ctx, "error": error}
    return _env.get_template("_chatter_panel_inner.html").render(chatter_context=ctx)

render_kanban_page

render_kanban_page(view, env, *, page: int = 0, page_size: int = 10, search: str = '', order: str = '', filters: str = '', group_by: str = '', bc_stack: list[tuple[str, str]] | None = None, current_path: str | None = None) -> str

Render a kanban view: cards optionally grouped into columns.

When the arch omits group_by, the board uses the same search, filter, URL group-by, and pagination controls as a list view. A fixed arch group_by keeps the classic column board (all records).

Source code in pyvelm/render.py
def render_kanban_page(
    view,
    env,
    *,
    page: int = 0,
    page_size: int = 10,
    search: str = "",
    order: str = "",
    filters: str = "",
    group_by: str = "",
    bc_stack: list[tuple[str, str]] | None = None,
    current_path: str | None = None,
) -> str:
    """Render a kanban view: cards optionally grouped into columns.

    When the arch omits ``group_by``, the board uses the same search,
    filter, URL group-by, and pagination controls as a list view. A
    fixed arch ``group_by`` keeps the classic column board (all records).
    """
    from .views import resolve_arch

    arch = resolve_arch(view)
    ctx = _render_kanban_content(
        view,
        arch,
        env,
        page=page,
        page_size=page_size,
        search=search,
        order=order,
        filters=filters,
        url_group_by=group_by,
        bc_stack=bc_stack,
    )
    template = _env.get_template("kanban.html")
    return template.render(
        **ctx,
        view_switcher=_other_views_for_model(
            env,
            view,
            bc_stack=bc_stack,
            search=search,
            order=order,
            filters=filters,
            group_by=group_by,
            page=page,
            page_size=page_size,
        ),
        bc_param=format_bc_param(bc_stack or []),
        **layout_context(env, current_path, leaf_label=ctx["page_title"]),
    )

render_kanban_rows

render_kanban_rows(view, env, *, page: int = 0, page_size: int = 10, search: str = '', order: str = '', filters: str = '', group_by: str = '', bc_stack: list[tuple[str, str]] | None = None) -> str

Kanban card grid / columns fragment for HTMX toolbar swaps.

Source code in pyvelm/render.py
def render_kanban_rows(
    view,
    env,
    *,
    page: int = 0,
    page_size: int = 10,
    search: str = "",
    order: str = "",
    filters: str = "",
    group_by: str = "",
    bc_stack: list[tuple[str, str]] | None = None,
) -> str:
    """Kanban card grid / columns fragment for HTMX toolbar swaps."""
    from .views import resolve_arch

    arch = resolve_arch(view)
    ctx = _render_kanban_content(
        view,
        arch,
        env,
        page=page,
        page_size=page_size,
        search=search,
        order=order,
        filters=filters,
        url_group_by=group_by,
        bc_stack=bc_stack,
    )
    template = _env.get_template("kanban_cards.html")
    return template.render(**ctx)

render_graph_page

render_graph_page(view, env, *, search: str = '', filters: str = '', current_path: str | None = None) -> str

Render a graph view: one chart aggregating one measure by one groupby field, rendered by ApexCharts on the client.

Arch shape::

{"groupby": "stage",
 "measure": "expected_revenue:sum",
 "chart":   "bar" | "line" | "pie",
 "title":   "...",      # optional
 "stacked": False,      # optional, bar only
 "horizontal": False,   # optional, bar only
 "domain":  [...]}      # optional, ANDed with URL filters
Source code in pyvelm/render.py
def render_graph_page(
    view,
    env,
    *,
    search: str = "",
    filters: str = "",
    current_path: str | None = None,
) -> str:
    """Render a graph view: one chart aggregating one measure by one
    groupby field, rendered by ApexCharts on the client.

    Arch shape::

        {"groupby": "stage",
         "measure": "expected_revenue:sum",
         "chart":   "bar" | "line" | "pie",
         "title":   "...",      # optional
         "stacked": False,      # optional, bar only
         "horizontal": False,   # optional, bar only
         "domain":  [...]}      # optional, ANDed with URL filters
    """
    from .views import resolve_arch

    arch = resolve_arch(view)
    groupby_spec = arch["groupby"]
    measure_spec = arch.get("measure") or "__count"
    chart_type = arch.get("chart", "bar")
    static_domain = list(arch.get("domain") or [])

    chart_data = _graph_chart_data(
        env,
        model=view.model,
        groupby=groupby_spec,
        measure=measure_spec,
        chart=chart_type,
        domain=static_domain,
        search=search,
        filters=filters,
        stacked=bool(arch.get("stacked")),
        horizontal=bool(arch.get("horizontal")),
    )
    n_groups = len(chart_data["labels"])

    page_title = _view_title(view, arch)

    # Build field lists for toolbar dropdowns.
    from .fields import Boolean, Date, Datetime, Float, Integer, Many2one

    model_cls = env.registry[view.model]
    groupable_fields: list[dict] = []
    measurable_fields: list[dict] = [{"value": "__count", "label": "Count"}]
    for fname, field in model_cls._fields.items():
        if not field.is_stored or field.private:
            continue
        label = field.string or fname
        ft = type(field).__name__
        if isinstance(field, (Many2one, Date, Datetime)):
            groupable_fields.append({"value": fname, "label": label, "type": ft})
            if isinstance(field, (Date, Datetime)):
                for trunc in ("day", "week", "month", "quarter", "year"):
                    groupable_fields.append({
                        "value": f"{fname}:{trunc}",
                        "label": f"{label} ({trunc})",
                        "type": ft,
                    })
        elif not isinstance(field, (Float, Boolean)):
            groupable_fields.append({"value": fname, "label": label, "type": ft})
        if isinstance(field, (Integer, Float)):
            measurable_fields.append({
                "value": f"{fname}:sum",
                "label": f"{label} (sum)",
                "type": ft,
            })
            measurable_fields.append({
                "value": f"{fname}:avg",
                "label": f"{label} (avg)",
                "type": ft,
            })

    template = _env.get_template("graph.html")
    return template.render(
        view=view,
        chart_data=chart_data,
        search=search,
        filters=filters,
        page_title=page_title,
        subtitle=f"{n_groups} group{'s' if n_groups != 1 else ''}",
        view_switcher=_other_views_for_model(env, view),
        groupable_fields=groupable_fields,
        measurable_fields=measurable_fields,
        **layout_context(env, current_path),
    )

render_pivot_page

render_pivot_page(view, env, *, search: str = '', filters: str = '', current_path: str | None = None) -> str

Render a pivot view: a cross-tab table aggregating one or more measures over the cartesian product of row_groupby × col_groupby.

Single read_group call covers the whole table: we ask for all rows and cols together, then pivot the flat result into a nested HTML matrix in Python. Row totals (per row) and a grand-total column / row are computed cell-by-cell — Postgres' ROLLUP could do it server-side but for the cardinalities a pivot is useful at (a few rows × a few cols × few measures), client-side aggregation is plenty fast and avoids burning round-trips.

Source code in pyvelm/render.py
def render_pivot_page(
    view,
    env,
    *,
    search: str = "",
    filters: str = "",
    current_path: str | None = None,
) -> str:
    """Render a pivot view: a cross-tab table aggregating one or more
    measures over the cartesian product of ``row_groupby`` × ``col_groupby``.

    Single ``read_group`` call covers the whole table: we ask for all
    rows and cols together, then pivot the flat result into a nested
    HTML matrix in Python. Row totals (per row) and a grand-total
    column / row are computed cell-by-cell — Postgres' ROLLUP could
    do it server-side but for the cardinalities a pivot is useful at
    (a few rows × a few cols × few measures), client-side aggregation
    is plenty fast and avoids burning round-trips.
    """
    from .views import resolve_arch

    arch = resolve_arch(view)
    row_specs = list(arch.get("row_groupby") or [])
    col_specs = list(arch.get("col_groupby") or [])
    measure_specs = list(arch.get("measures") or ["__count"])
    static_domain = list(arch.get("domain") or [])

    model_cls = env.registry[view.model]
    Model = env[view.model]

    pseudo_fields_spec = [
        {"name": n} for n, f in model_cls._fields.items() if f.is_stored
    ]
    domain = list(static_domain)
    if search:
        domain.extend(_build_search_domain(model_cls, pseudo_fields_spec, search))
    if filters:
        domain.extend(_parse_filters(model_cls, pseudo_fields_spec, filters))

    flat_rows = Model.read_group(
        domain,
        groupby=row_specs + col_specs,
        measures=measure_specs,
    )

    # Order is "first seen in the read_group result" per axis spec —
    # consistent because read_group's default ORDER BY mirrors the
    # group-key order we requested.
    row_axes = _pivot_axis_labels(flat_rows, row_specs, model_cls)
    col_axes = _pivot_axis_labels(flat_rows, col_specs, model_cls)

    # Index flat_rows by (row_key_tuple, col_key_tuple) for O(1) cell
    # lookup. Each cell holds the per-measure dict.
    cell_index: dict[tuple, dict] = {}
    for r in flat_rows:
        row_key = tuple(r.get(s) for s in row_specs)
        col_key = tuple(r.get(s) for s in col_specs)
        cell_index[(row_key, col_key)] = {
            m: r.get(m) for m in measure_specs
        }

    # Materialize the cartesian product of row / col axes. With more
    # than ~5k cells the page gets unwieldy; we trust the view author
    # to keep cardinalities sane (Odoo applies the same convention).
    def _product(axes):
        from itertools import product as _ip
        if not axes:
            return [()]
        return list(_ip(*[[entry["value"] for entry in a] for a in axes]))

    row_combos = _product(row_axes)
    col_combos = _product(col_axes)

    # Headers: one row per col_groupby level, repeating each parent
    # label across its children for the right colspan. The first
    # column carries the row-axis label; the trailing column is
    # the "Total" grand-sum.
    header_levels: list[list[dict]] = []
    if col_axes:
        for level_idx, level in enumerate(col_axes):
            # The colspan at this level is the product of sizes of
            # the levels *below* it.
            below = col_axes[level_idx + 1:]
            span_per_label = 1
            for b in below:
                span_per_label *= max(1, len(b))
            # Repeat each label as many times as the parent product
            # above (group-by-group). The label here repeats once per
            # combination of levels *above*, but visually we collapse
            # consecutive duplicates into a single th with colspan.
            cells = []
            for entry in level:
                cells.append({
                    "label": entry["label"],
                    "colspan": span_per_label * len(measure_specs),
                })
            header_levels.append(cells)
    # Final header row: measure labels, one per leaf-col entry plus
    # the grand-total column.
    measure_label_row: list[dict] = []
    for _combo in col_combos:
        for m in measure_specs:
            measure_label_row.append({
                "label": _measure_label(m, model_cls),
                "colspan": 1,
            })
    # Grand total column header — one cell spanning len(measure_specs)
    # at the right side. We emit it on each level, and the measure
    # row gets one entry per measure under it.
    grand_header = {
        "label": "Total",
        "colspan": len(measure_specs),
    }

    # Body rows: nested by row_groupby. For first iteration we render
    # a flat list (no row indentation between levels — that's a polish
    # task) but still surface row totals.
    body_rows: list[dict] = []
    for row_combo in row_combos:
        row_labels: list[str] = []
        for level_idx, key_val in enumerate(row_combo):
            entries = row_axes[level_idx]
            label = next(
                (e["label"] for e in entries if e["value"] == key_val),
                str(key_val),
            )
            row_labels.append(label)
        cells: list[dict] = []
        # Per-measure row totals (summed across columns).
        row_totals: dict[str, float | int] = {m: 0 for m in measure_specs}
        for col_combo in col_combos:
            measures_at_cell = cell_index.get((row_combo, col_combo))
            for m in measure_specs:
                value = measures_at_cell.get(m) if measures_at_cell else None
                cells.append({
                    "value": value,
                    "display": _format_pivot_cell(value, m),
                })
                if value is not None:
                    try:
                        row_totals[m] += float(value)
                    except (TypeError, ValueError):
                        pass
        # Grand-total cells (rightmost) — one per measure.
        for m in measure_specs:
            total = row_totals[m]
            cells.append({
                "value": total,
                "display": _format_pivot_cell(total, m),
                "is_total": True,
            })
        body_rows.append({"labels": row_labels, "cells": cells})

    # Column-grand-total row at the bottom.
    col_totals: list[dict] = []
    grand_grand: dict[str, float | int] = {m: 0 for m in measure_specs}
    for col_combo in col_combos:
        for m in measure_specs:
            running: float | int = 0
            for row_combo in row_combos:
                measures_at_cell = cell_index.get((row_combo, col_combo))
                if measures_at_cell is None:
                    continue
                v = measures_at_cell.get(m)
                if v is None:
                    continue
                try:
                    running += float(v)
                except (TypeError, ValueError):
                    pass
            col_totals.append({
                "value": running,
                "display": _format_pivot_cell(running, m),
                "is_total": True,
            })
            grand_grand[m] += running
    for m in measure_specs:
        col_totals.append({
            "value": grand_grand[m],
            "display": _format_pivot_cell(grand_grand[m], m),
            "is_total": True,
        })

    # Row-axis header column titles ("Stage" / "Salesperson"…).
    row_axis_titles: list[str] = []
    for spec in row_specs:
        fname = spec.split(":", 1)[0]
        f = model_cls._fields.get(fname)
        label = (f.string if f and f.string else fname) if f else fname
        if ":" in spec:
            label += f" ({spec.split(':', 1)[1]})"
        row_axis_titles.append(label)

    page_title = _view_title(view, arch)

    # Build field lists for toolbar dropdowns (same logic as render_graph_page).
    from .fields import Boolean, Date, Datetime, Float, Integer, Many2one

    groupable_fields: list[dict] = []
    measurable_fields: list[dict] = [{"value": "__count", "label": "Count"}]
    for fname, field in model_cls._fields.items():
        if not field.is_stored or field.private:
            continue
        label = field.string or fname
        ft = type(field).__name__
        if isinstance(field, (Many2one, Date, Datetime)):
            groupable_fields.append({"value": fname, "label": label, "type": ft})
            if isinstance(field, (Date, Datetime)):
                for trunc in ("day", "week", "month", "quarter", "year"):
                    groupable_fields.append({
                        "value": f"{fname}:{trunc}",
                        "label": f"{label} ({trunc})",
                        "type": ft,
                    })
        elif not isinstance(field, (Float, Boolean)):
            groupable_fields.append({"value": fname, "label": label, "type": ft})
        if isinstance(field, (Integer, Float)):
            measurable_fields.append({
                "value": f"{fname}:sum",
                "label": f"{label} (sum)",
                "type": ft,
            })
            measurable_fields.append({
                "value": f"{fname}:avg",
                "label": f"{label} (avg)",
                "type": ft,
            })

    template = _env.get_template("pivot.html")
    return template.render(
        view=view,
        row_axis_titles=row_axis_titles,
        header_levels=header_levels,
        measure_label_row=measure_label_row,
        grand_header=grand_header,
        body_rows=body_rows,
        col_totals=col_totals,
        measure_count=len(measure_specs),
        col_combos_count=len(col_combos),
        search=search,
        filters=filters,
        page_title=page_title,
        subtitle=(
            f"{len(row_combos)} row{'s' if len(row_combos) != 1 else ''}"
            f" × {max(1, len(col_combos))} column{'s' if len(col_combos) != 1 else ''}"
        ),
        view_switcher=_other_views_for_model(env, view),
        groupable_fields=groupable_fields,
        measurable_fields=measurable_fields,
        init_row_groupby=",".join(row_specs),
        init_col_groupby=",".join(col_specs),
        init_measures=",".join(measure_specs),
        **layout_context(env, current_path),
    )

render_list_page

render_list_page(view, env, *, page: int, page_size: int, search: str = '', order: str = '', filters: str = '', group_by: str = '', bc_stack: list[tuple[str, str]] | None = None, current_path: str | None = None) -> str

Full HTML page for a list view: heading, toolbar, sortable DataTable with server-side search / ordering / pagination.

Source code in pyvelm/render.py
def render_list_page(
    view,
    env,
    *,
    page: int,
    page_size: int,
    search: str = "",
    order: str = "",
    filters: str = "",
    group_by: str = "",
    bc_stack: list[tuple[str, str]] | None = None,
    current_path: str | None = None,
) -> str:
    """Full HTML page for a list view: heading, toolbar, sortable DataTable
    with server-side search / ordering / pagination."""
    from .views import resolve_arch

    arch = resolve_arch(view)
    fields_spec = arch.get("fields", [])
    form_view_name = arch.get("form_view") or _find_form_view(view, env)
    detail_view_name = arch.get("detail_view") or _find_detail_view(view, env)
    record_href = arch.get("record_href")
    create_href = arch.get("create_href")
    access = template_access(env, view.model)
    bulk_actions = _resolve_bulk_actions(arch, env, model=view.model)
    bulk_enabled = bool(bulk_actions) and not arch.get("sequence")

    model_cls = env.registry[view.model]
    Model = env[view.model]

    domain = _list_page_domain(model_cls, arch, fields_spec, search, filters)

    # Drag-reorder: `arch["sequence"]` names the Integer field that
    # backs the row ordering. When present we force-sort by that
    # field, ignoring any user-supplied order (drag-reorder semantics
    # only make sense in the canonical order). Pagination is also
    # disabled so the user always sees the full list — reordering a
    # paginated subset would be confusing.
    sequence_field = arch.get("sequence")
    if sequence_field and sequence_field in model_cls._fields:
        safe_ord = f'"{sequence_field}" ASC, "id" ASC'
    else:
        sequence_field = None
        safe_ord = _safe_order(fields_spec, order)

    fields_spec = _enrich_specs_for_edit(env, model_cls, fields_spec)
    headers = _field_headers(model_cls, fields_spec)
    # Validate group_by against the headers' group_kind metadata. An
    # unknown column or one whose type doesn't group cleanly is silently
    # dropped — the value is user-controlled URL input.
    safe_group_by = (
        group_by
        if any(h["name"] == group_by and h["group_kind"] != "none" for h in headers)
        else ""
    )

    total = Model.search_count(domain)
    if safe_group_by:
        # When grouping is active, fetch all matching records (capped)
        # and skip pagination — Odoo's behavior. The cap keeps memory
        # bounded if someone groups a huge table without filtering.
        _GROUP_CAP = 500
        recs = Model.search(domain, limit=_GROUP_CAP, order=safe_ord)
        groups = _group_rows(
            recs, view, fields_spec, safe_group_by, env, model_cls, arch
        )
        rows = []
        total_pages = 1
    elif sequence_field:
        # No pagination when drag-reorder is active.
        recs = Model.search(domain, order=safe_ord)
        groups = None
        rows = _build_list_rows(view, recs, fields_spec, env, arch)
        total_pages = 1
    else:
        offset = page * page_size
        recs = Model.search(domain, limit=page_size, offset=offset, order=safe_ord)
        groups = None
        rows = _build_list_rows(view, recs, fields_spec, env, arch)
        total_pages = max(1, (total + page_size - 1) // page_size)

    page_title = _view_title(view, arch)
    list_nav_query = encode_view_nav_query(
        view.module,
        view.name,
        search=search,
        order=order,
        filters=filters,
        group_by=safe_group_by,
        page=page,
        page_size=page_size,
        bc_stack=bc_stack,
    )
    page_actions = _merge_list_page_actions(
        _resolve_header_actions(
            arch.get("page_actions", []),
            env,
            model=view.model,
            module=view.module,
            name=view.name,
            record_id=0,
            record=None,
            slot="page",
        ),
        _default_list_io_actions(view, env, list_nav_query=list_nav_query)
        if arch.get("import_export", True)
        else [],
    )
    template = _env.get_template("list.html")
    return template.render(
        view=view,
        headers=headers,
        rows=rows,
        groups=groups,
        page=page,
        page_size=page_size,
        total=total,
        total_pages=total_pages,
        search=search,
        order=order,
        filters=filters,
        group_by=safe_group_by,
        sequence_field=sequence_field,
        form_view_name=form_view_name,
        detail_view_name=detail_view_name,
        record_href=record_href,
        create_href=create_href,
        page_actions=page_actions,
        bulk_actions=bulk_actions,
        bulk_enabled=bulk_enabled,
        list_nav_query=list_nav_query,
        page_title=page_title,
        # No record-count subtitle on list views — the pager footer
        # already shows the total. Pass an empty string so existing
        # ``{% if subtitle %}`` checks just skip.
        subtitle="",
        view_switcher=_other_views_for_model(
            env,
            view,
            bc_stack=bc_stack,
            search=search,
            order=order,
            filters=filters,
            group_by=safe_group_by,
            page=page,
            page_size=page_size,
        ),
        bc_param=format_bc_param(bc_stack or []),
        access=access,
        **layout_context(env, current_path, leaf_label=page_title),
    )

render_list_import_page

render_list_import_page(view, env, *, step: str = 'upload', fields: list[dict] | None = None, headers: list[str] | None = None, rows: list[list] | None = None, mapping: dict[int, str] | None = None, payload: str = '', result: dict | None = None, error: str | None = None, update_by_id: bool = False, filename: str = '', test_message: str | None = None, test_errors: list[dict] | None = None, selected_fields: list[str] | None = None, import_rolled_back: bool = False, import_had_success: bool = False, auto_download_failed: bool = False, list_search: str = '', list_order: str = '', list_filters: str = '', csrf_token: str = '', fragment: bool = False) -> str

Import wizard fragment for PvDialog (upload → preview → result).

Source code in pyvelm/render.py
def render_list_import_page(
    view,
    env,
    *,
    step: str = "upload",
    fields: list[dict] | None = None,
    headers: list[str] | None = None,
    rows: list[list] | None = None,
    mapping: dict[int, str] | None = None,
    payload: str = "",
    result: dict | None = None,
    error: str | None = None,
    update_by_id: bool = False,
    filename: str = "",
    test_message: str | None = None,
    test_errors: list[dict] | None = None,
    selected_fields: list[str] | None = None,
    import_rolled_back: bool = False,
    import_had_success: bool = False,
    auto_download_failed: bool = False,
    list_search: str = "",
    list_order: str = "",
    list_filters: str = "",
    csrf_token: str = "",
    fragment: bool = False,
) -> str:
    """Import wizard fragment for PvDialog (upload → preview → result)."""
    from .importer import (
        filter_import_fields,
        import_errors_by_line,
        list_importable_fields,
    )

    importable = fields or list_importable_fields(env, view.model)
    all_importable = list_importable_fields(env, view.model)
    selected_set = {
        f["name"] for f in filter_import_fields(all_importable, selected_fields)
    } if selected_fields else {f["name"] for f in all_importable}
    template = _env.get_template(
        "list_import_inner.html" if fragment else "list_import.html"
    )
    tested = test_errors is not None
    preview_rows = (rows or []) if tested else (rows or [])[:5]
    row_errors = import_errors_by_line(test_errors) if tested else {}
    return template.render(
        view=view,
        step=step,
        fields=importable,
        template_field_choices=all_importable,
        selected_field_names=selected_set,
        headers=headers or [],
        rows=rows or [],
        preview_rows=preview_rows,
        mapping=mapping or {},
        payload=payload,
        result=result,
        error=error,
        update_by_id=update_by_id,
        filename=filename,
        total_rows=len(rows or []),
        test_message=test_message,
        test_has_errors=bool(test_errors),
        show_row_errors=tested,
        row_errors=row_errors,
        csrf_token=csrf_token,
        import_rolled_back=import_rolled_back,
        import_had_success=import_had_success,
        auto_download_failed=auto_download_failed,
        template_base_url=f"/web/views/{view.module}/{view.name}/import",
        failed_download_url=(
            f"/web/views/{view.module}/{view.name}/import/failed.xlsx"
        ),
        template_filename=f"{view.name}_import_template.xlsx",
        list_search=list_search,
        list_order=list_order,
        list_filters=list_filters,
    )

render_list_rows

render_list_rows(view, env, *, page: int, page_size: int, search: str = '', order: str = '', filters: str = '', group_by: str = '', bc_stack: list[tuple[str, str]] | None = None) -> str

Table body fragment + oob pagination — used by HTMX control swaps.

Source code in pyvelm/render.py
def render_list_rows(
    view,
    env,
    *,
    page: int,
    page_size: int,
    search: str = "",
    order: str = "",
    filters: str = "",
    group_by: str = "",
    bc_stack: list[tuple[str, str]] | None = None,
) -> str:
    """Table body fragment + oob pagination — used by HTMX control swaps."""
    from .views import resolve_arch

    arch = resolve_arch(view)
    fields_spec = arch.get("fields", [])
    form_view_name = arch.get("form_view") or _find_form_view(view, env)
    detail_view_name = arch.get("detail_view") or _find_detail_view(view, env)
    record_href = arch.get("record_href")
    create_href = arch.get("create_href")
    access = template_access(env, view.model)
    bulk_actions = _resolve_bulk_actions(arch, env, model=view.model)
    bulk_enabled = bool(bulk_actions) and not arch.get("sequence")

    model_cls = env.registry[view.model]
    Model = env[view.model]

    domain = _list_page_domain(model_cls, arch, fields_spec, search, filters)

    sequence_field = arch.get("sequence")
    if sequence_field and sequence_field in model_cls._fields:
        safe_ord = f'"{sequence_field}" ASC, "id" ASC'
    else:
        sequence_field = None
        safe_ord = _safe_order(fields_spec, order)

    fields_spec = _enrich_specs_for_edit(env, model_cls, fields_spec)
    headers = _field_headers(model_cls, fields_spec)
    safe_group_by = (
        group_by
        if any(h["name"] == group_by and h["group_kind"] != "none" for h in headers)
        else ""
    )

    total = Model.search_count(domain)
    if safe_group_by:
        _GROUP_CAP = 500
        recs = Model.search(domain, limit=_GROUP_CAP, order=safe_ord)
        groups = _group_rows(
            recs, view, fields_spec, safe_group_by, env, model_cls, arch
        )
        rows = []
        total_pages = 1
    elif sequence_field:
        recs = Model.search(domain, order=safe_ord)
        groups = None
        rows = _build_list_rows(view, recs, fields_spec, env, arch)
        total_pages = 1
    else:
        offset = page * page_size
        recs = Model.search(domain, limit=page_size, offset=offset, order=safe_ord)
        groups = None
        rows = _build_list_rows(view, recs, fields_spec, env, arch)
        total_pages = max(1, (total + page_size - 1) // page_size)

    list_nav_query = encode_view_nav_query(
        view.module,
        view.name,
        search=search,
        order=order,
        filters=filters,
        group_by=safe_group_by,
        page=page,
        page_size=page_size,
        bc_stack=bc_stack,
    )
    template = _env.get_template("list_rows.html")
    return template.render(
        view=view,
        headers=headers,
        rows=rows,
        groups=groups,
        page=page,
        page_size=page_size,
        total=total,
        total_pages=total_pages,
        search=search,
        order=order,
        filters=filters,
        group_by=safe_group_by,
        sequence_field=sequence_field,
        form_view_name=form_view_name,
        detail_view_name=detail_view_name,
        record_href=record_href,
        create_href=create_href,
        bulk_actions=bulk_actions,
        bulk_enabled=bulk_enabled,
        list_nav_query=list_nav_query,
        access=access,
    )

render_account_profile_page

render_account_profile_page(env, *, current_path: str | None = None, error: str = '', success: bool = False, form_overrides: dict | None = None) -> str

Self-service profile editor (name + avatar) for the signed-in user.

Source code in pyvelm/render.py
def render_account_profile_page(
    env,
    *,
    current_path: str | None = None,
    error: str = "",
    success: bool = False,
    form_overrides: dict | None = None,
) -> str:
    """Self-service profile editor (name + avatar) for the signed-in user."""
    user = _load_account_user(env)
    if user is None:
        raise ValueError("No signed-in user")
    ctx = _account_profile_context(user)
    if form_overrides:
        for key in ("name",):
            if key in form_overrides:
                ctx[key] = form_overrides[key]
        if "avatar_url" in form_overrides:
            ctx["avatar_widget"] = _render_image_widget(
                form_overrides["avatar_url"] or "",
                {"name": "avatar_url"},
                readonly=False,
            )
    template = _env.get_template("account_profile.html")
    return template.render(
        error=error,
        success=success,
        **ctx,
        **_account_layout_context(env, current_path, leaf_label="My profile"),
    )

render_password_page

render_password_page(env, *, current_path: str | None = None, error: str = '', success: bool = False) -> str

Self-service password-change form. The form lives outside the res.users edit flow because the bcrypt verification of the current password belongs to the user themselves; admins can still set passwords on other accounts via the regular res.users form.

Source code in pyvelm/render.py
def render_password_page(
    env, *, current_path: str | None = None, error: str = "", success: bool = False
) -> str:
    """Self-service password-change form. The form lives outside the
    res.users edit flow because the bcrypt verification of the
    current password belongs to the user themselves; admins can still
    set passwords on other accounts via the regular res.users form."""
    template = _env.get_template("password.html")
    return template.render(
        error=error,
        success=success,
        **_account_layout_context(env, current_path, leaf_label="Change password"),
    )

render_admin_password_reset_page

render_admin_password_reset_page(env, user, *, current_path: str | None = None, csrf_token: str = '', error: str = '', success: bool = False) -> str

Admin-driven password reset for another user.

No current-password check — by construction this page is reached via the res.users form's "Reset password" action, which the ORM already gated on perm_write for the model. Self-service (the user changing their own password with their current one) still lives at /web/account/password.

Source code in pyvelm/render.py
def render_admin_password_reset_page(
    env,
    user,
    *,
    current_path: str | None = None,
    csrf_token: str = "",
    error: str = "",
    success: bool = False,
) -> str:
    """Admin-driven password reset for *another* user.

    No current-password check — by construction this page is reached
    via the ``res.users`` form's "Reset password" action, which the
    ORM already gated on ``perm_write`` for the model. Self-service
    (the user changing their own password with their current one)
    still lives at ``/web/account/password``.
    """
    template = _env.get_template("admin_password_reset.html")
    return template.render(
        user=user,
        error=error,
        success=success,
        csrf_token=csrf_token,
        **layout_context(env, current_path),
    )

render_dashboard_page

render_dashboard_page(view, env, *, current_path: str | None = None) -> str

Render a declarative dashboard from view_type="dashboard" arch.

Source code in pyvelm/render.py
def render_dashboard_page(
    view,
    env,
    *,
    current_path: str | None = None,
) -> str:
    """Render a declarative dashboard from ``view_type="dashboard"`` arch."""
    from .views import resolve_arch

    arch = resolve_arch(view)
    page_title = arch.get("title") or _view_title(view, arch)
    subtitle = arch.get("subtitle") or ""
    grid_columns = max(1, min(6, int(arch.get("columns") or 2)))
    widgets = _materialize_dashboard_widgets(
        env, list(arch.get("widgets") or []), view.module
    )
    for w in widgets:
        w["colspan"] = _resolve_dashboard_colspan(w.get("colspan"), grid_columns)
    chart_widgets = [
        w for w in widgets
        if w.get("type") == "chart" and w.get("chart_data")
    ]
    template = _env.get_template("dashboard.html")
    ctx = layout_context(env, current_path, leaf_label=page_title) if env else {}
    return template.render(
        page_title=page_title,
        subtitle=subtitle,
        grid_columns=grid_columns,
        widgets=widgets,
        chart_widgets=chart_widgets,
        **ctx,
    )

render_landing_page

render_landing_page(env=None, *, current_path: str | None = None) -> str

Public marketing-style entry page at / (no app sidebar).

Source code in pyvelm/render.py
def render_landing_page(env=None, *, current_path: str | None = None) -> str:
    """Public marketing-style entry page at ``/`` (no app sidebar)."""
    from .branding import branding_context
    from .home import home_url, login_url

    ctx = branding_context(env)
    app = (ctx.get("brand") or {}).get("app_name") or "pyvelm"
    template = _env.get_template("landing.html")
    tagline = ((ctx.get("brand") or {}).get("app_tagline") or "").strip() or (
        "Sign in to manage your data, workflows, and team — or explore the demo modules."
    )
    return template.render(
        **ctx,
        headline=f"Welcome to {app}",
        tagline=tagline,
        get_started_href=login_url(),
        sign_in_href=login_url(),
        home_url=home_url(),
        current_path=current_path or "/",
        dev_db_display=development_db_display(env),
    )

render_home_page

render_home_page(env, *, current_path: str | None = None) -> str

Render PYVELM_HOME_URL when it points at a built-in view path.

Source code in pyvelm/render.py
def render_home_page(env, *, current_path: str | None = None) -> str:
    """Render ``PYVELM_HOME_URL`` when it points at a built-in view path."""
    from .home import home_url

    path = home_url()
    current_path = current_path or path
    if path in ("/web/admin", "/web/admin/"):
        return render_admin_page(env, current_path=current_path)
    if path.startswith("/web/views/"):
        parts = path.rstrip("/").split("/")
        if len(parts) >= 5 and parts[1] == "web" and parts[2] == "views":
            module, name = parts[3], parts[4]
            view = _load_ui_view(env.sudo(), module, name)
            if view is None:
                raise ValueError(f"Home view {module}/{name!r} not found")
            if view.view_type == "dashboard":
                return render_dashboard_page(view, env, current_path=current_path)
            if view.view_type == "list":
                return render_list_page(view, env, current_path=current_path)
            if view.view_type == "kanban":
                return render_kanban_page(view, env, current_path=current_path)
            if view.view_type == "graph":
                return render_graph_page(view, env, current_path=current_path)
            raise ValueError(
                f"Home view {module}/{name!r} has type {view.view_type!r}; "
                "use dashboard, list, kanban, or graph"
            )
    raise ValueError(
        f"PYVELM_HOME_URL={path!r} cannot be rendered inline — use a /web/views/… "
        "path or leave the default /web/admin"
    )

access_denied_use_sidebar

access_denied_use_sidebar(current_path: str | None) -> bool

Return whether the access-denied page should include the app sidebar.

Source code in pyvelm/render.py
def access_denied_use_sidebar(current_path: str | None) -> bool:
    """Return whether the access-denied page should include the app sidebar."""
    if not current_path:
        return True
    path = current_path.split("?", 1)[0]
    return not any(path.startswith(prefix) for prefix in _MINIMAL_ACCESS_DENIED_PREFIXES)

render_error_page

render_error_page(env, *, status_code: int, title: str | None = None, message: str | None = None, detail: str | None = None, current_path: str | None = None, use_sidebar: bool | None = None, retry_after: int | None = None) -> str

Full-page styled error screen for any 4xx/5xx status.

Title / message default per status code (see _ERROR_ICON_DETAILS); pass title / message to override. detail carries a small diagnostic line (e.g. the raw exception message). retry_after renders a live countdown — used by 429 responses.

The sidebar / top-nav choice mirrors :func:render_access_denied_page so error pages on account / feedback URLs stay minimal.

Source code in pyvelm/render.py
def render_error_page(
    env,
    *,
    status_code: int,
    title: str | None = None,
    message: str | None = None,
    detail: str | None = None,
    current_path: str | None = None,
    use_sidebar: bool | None = None,
    retry_after: int | None = None,
) -> str:
    """Full-page styled error screen for any 4xx/5xx status.

    Title / message default per status code (see ``_ERROR_ICON_DETAILS``);
    pass ``title`` / ``message`` to override. ``detail`` carries a small
    diagnostic line (e.g. the raw exception message). ``retry_after``
    renders a live countdown — used by 429 responses.

    The sidebar / top-nav choice mirrors :func:`render_access_denied_page`
    so error pages on account / feedback URLs stay minimal.
    """
    template = _env.get_template("error.html")
    defaults = _error_defaults(int(status_code))
    if title is not None:
        defaults["title"] = title
    if message is not None:
        defaults["message"] = message
    if use_sidebar is None:
        use_sidebar = access_denied_use_sidebar(current_path)
    ctx = merge_template_context(env, current_path)
    ctx.update(defaults)
    ctx["status_code"] = int(status_code)
    ctx["detail"] = detail
    ctx["retry_after"] = int(retry_after) if retry_after else None
    if not use_sidebar:
        ctx["use_sidebar"] = False
        ctx["breadcrumbs"] = []
    return template.render(**ctx)

render_access_denied_page

render_access_denied_page(env, *, detail: str | None = None, current_path: str | None = None, use_sidebar: bool | None = None) -> str

Full-page "Access denied" screen inside the app shell.

Served by the HTTP layer when an authenticated user hits a page or action they lack the grant for — read access opens pages, so this is the fallback for the genuinely-forbidden case (e.g. a deep-linked edit/create URL). detail carries the raw PermissionError message for the small diagnostic line; pass None to omit it.

When use_sidebar is false (auto for feedback capture / account URLs), the page uses the same top-nav-only layout as those flows.

Source code in pyvelm/render.py
def render_access_denied_page(
    env,
    *,
    detail: str | None = None,
    current_path: str | None = None,
    use_sidebar: bool | None = None,
) -> str:
    """Full-page "Access denied" screen inside the app shell.

    Served by the HTTP layer when an authenticated user hits a page or
    action they lack the grant for — read access opens pages, so this is
    the fallback for the genuinely-forbidden case (e.g. a deep-linked
    edit/create URL). `detail` carries the raw ``PermissionError``
    message for the small diagnostic line; pass ``None`` to omit it.

    When ``use_sidebar`` is false (auto for feedback capture / account URLs),
    the page uses the same top-nav-only layout as those flows.
    """
    template = _env.get_template("access_denied.html")
    if use_sidebar is None:
        use_sidebar = access_denied_use_sidebar(current_path)
    ctx = merge_template_context(env, current_path, detail=detail)
    if not use_sidebar:
        ctx["use_sidebar"] = False
        ctx["breadcrumbs"] = []
    return template.render(**ctx)

install_module_action

install_module_action(env, module_roots: list, target_name: str) -> dict

Install target_name (and any uninstalled prerequisites) into the live environment + registry.

Returns a dict describing the action: {ok, message, installed: [names]}. Raises ValueError on any unrecoverable problem (unknown module, dependency cycle, install hook error — the env transaction rolls back so partial state is impossible).

Source code in pyvelm/render.py
def install_module_action(env, module_roots: list, target_name: str) -> dict:
    """Install `target_name` (and any uninstalled prerequisites) into
    the live environment + registry.

    Returns a dict describing the action: `{ok, message, installed:
    [names]}`. Raises ValueError on any unrecoverable problem (unknown
    module, dependency cycle, install hook error — the env transaction
    rolls back so partial state is impossible).
    """
    from . import loader as _loader

    specs = _loader.discover(module_roots)
    if target_name not in specs:
        raise ValueError(f"Unknown module {target_name!r}")

    # Figure out the topo-ordered list of things to install: the
    # target plus any of its (transitive) deps that aren't installed
    # yet. resolve_order topo-sorts the whole graph; we trim to the
    # frontier.
    ordered = _loader.resolve_order(specs)
    installed = set(
        r[0]
        for r in env.conn.execute(
            f'SELECT "name" FROM "{_loader.IR_MODULE_TABLE}"'
        ).fetchall()
    )

    def needed(name: str, acc: set):
        if name in installed or name in acc:
            return
        spec = specs[name]
        for d in spec.depends:
            needed(d, acc)
        acc.add(name)

    needed_set: set = set()
    needed(target_name, needed_set)
    to_install = [s for s in ordered if s.name in needed_set]

    # Import each module's Python so its models register into the
    # live registry, then run install (schema, hooks, views, menus).
    for spec in to_install:
        if not spec.loaded:
            _loader._load_models(spec, env.registry)
    _loader.install(to_install, env)

    return {
        "ok": True,
        "installed": [s.name for s in to_install],
        "message": (
            f"Installed {target_name}"
            + (f" + {len(to_install) - 1} dependencies" if len(to_install) > 1 else "")
        ),
    }

upgrade_module_action

upgrade_module_action(env, module_roots: list, target_name: str) -> dict

Apply version-gap migration scripts and bump ir_module.version.

When the installed version already matches the manifest, this is a no-op (use Sync for schema diff and view/menu reload). Only migration files strictly between the recorded version and the manifest target are executed — see loader._run_migrations.

Source code in pyvelm/render.py
def upgrade_module_action(env, module_roots: list, target_name: str) -> dict:
    """Apply version-gap migration scripts and bump ``ir_module.version``.

    When the installed version already matches the manifest, this is a
    no-op (use **Sync** for schema diff and view/menu reload). Only
    migration files strictly between the recorded version and the
    manifest target are executed — see ``loader._run_migrations``.
    """
    from . import loader as _loader

    specs = _loader.discover(module_roots)
    if target_name not in specs:
        raise ValueError(f"Unknown module {target_name!r}")
    spec = specs[target_name]
    current = _loader._installed_version(env, target_name)
    if current is None:
        raise ValueError(
            f"Module {target_name!r} is not installed — use Install first."
        )
    if current >= spec.version:
        return {
            "ok": True,
            "upgraded": [],
            "message": (
                f"{target_name} is at {spec.version_str}; "
                "no pending migrations. Use Sync for schema and views."
            ),
            "detail": {},
        }
    _loader.reload_installed_models(env, specs)
    outcomes = _loader.install([spec], env)
    detail = outcomes[0] if outcomes else {}
    parts = [
        f"Upgraded {target_name} ({spec.version_str})",
        detail.get("schema", ""),
        detail.get("views", ""),
        detail.get("menus", ""),
    ]
    message = " | ".join(p for p in parts if p)
    return {
        "ok": True,
        "upgraded": [target_name],
        "message": message,
        "detail": detail,
    }

sync_module_action

sync_module_action(env, module_roots: list, target_name: str) -> dict

Re-sync views/menus and apply additive schema without requiring a version bump.

Source code in pyvelm/render.py
def sync_module_action(env, module_roots: list, target_name: str) -> dict:
    """Re-sync views/menus and apply additive schema without requiring a version bump."""
    from . import loader as _loader

    specs = _loader.discover(module_roots)
    if target_name not in specs:
        raise ValueError(f"Unknown module {target_name!r}")
    spec = specs[target_name]
    current = _loader._installed_version(env, target_name)
    if current is None:
        raise ValueError(
            f"Module {target_name!r} is not installed — use Install first."
        )
    _loader.reload_installed_models(env, specs)
    outcomes = _loader.install([spec], env)
    detail = outcomes[0] if outcomes else {}
    parts = [
        f"Synced {target_name} ({spec.version_str})",
        detail.get("schema", ""),
        detail.get("views", ""),
        detail.get("menus", ""),
    ]
    message = " | ".join(p for p in parts if p)
    return {
        "ok": True,
        "synced": [target_name],
        "message": message,
        "detail": detail,
    }

uninstall_preview

uninstall_preview(env, module_roots: list, target_name: str) -> dict

Compute what would happen if target_name were uninstalled.

Returns a dict shaped for both the JSON endpoint and the confirm modal. blockers is the list of reasons the uninstall would be refused; an empty list means it's safe to proceed.

Counts cover the user-visible side effects
  • tables: tables that would be DROP CASCADE'd
  • views / menus / access / rules: row counts in ir_* tables
Source code in pyvelm/render.py
def uninstall_preview(env, module_roots: list, target_name: str) -> dict:
    """Compute what would happen if `target_name` were uninstalled.

    Returns a dict shaped for both the JSON endpoint and the confirm
    modal. `blockers` is the list of reasons the uninstall would be
    refused; an empty list means it's safe to proceed.

    Counts cover the user-visible side effects:
      - tables: tables that would be DROP CASCADE'd
      - views / menus / access / rules: row counts in ir_* tables
    """
    from . import loader as _loader

    if target_name == "base":
        return {
            "target": target_name,
            "blockers": [
                "`base` is a bundled bootstrap module and cannot be uninstalled."
            ],
            "tables": [],
            "views": 0,
            "menus": 0,
            "access": 0,
            "rules": 0,
            "reverse_deps": [],
        }

    blockers, reverse_deps = _uninstall_blockers(env, module_roots, target_name)

    # Tables owned by this module.
    registry = env.registry
    owned_tables: list[str] = []
    for model_name, owner in registry._model_module.items():
        if owner == target_name:
            cls = registry._models.get(model_name)
            if cls is not None:
                owned_tables.append(cls._table)

    # Counts of data rows we'd clean up. Each module's identity in
    # ir.ui.view / ir.ui.menu is `module = <target_name>`.
    def _count(table: str, where: str = '"module" = %s') -> int:
        try:
            row = env.conn.execute(
                f'SELECT COUNT(*) FROM "{table}" WHERE {where}',
                [target_name],
            ).fetchone()
            return int(row[0]) if row else 0
        except Exception:  # noqa: BLE001
            return 0

    return {
        "target": target_name,
        "blockers": blockers,
        "tables": sorted(owned_tables),
        "views": _count("ir_ui_view"),
        "menus": _count("ir_ui_menu"),
        # ir.model.access / ir.rule entries are seeded by install hooks
        # using "<group>/<model>"-style names; we don't track which
        # module owns each one, so report 0 and let them linger. Same
        # constraint Odoo lives with for very old data files.
        "access": 0,
        "rules": 0,
        "reverse_deps": sorted(reverse_deps),
    }

uninstall_module_action

uninstall_module_action(env, module_roots: list, target_name: str) -> dict

Drop tables, delete view/menu records, and remove the ir_module row for target_name. Refuses to proceed if uninstall_preview returns blockers — that's the single safety gate.

Side effects are all wrapped in one transaction so a mid-flight failure rolls everything back.

Source code in pyvelm/render.py
def uninstall_module_action(env, module_roots: list, target_name: str) -> dict:
    """Drop tables, delete view/menu records, and remove the ir_module
    row for `target_name`. Refuses to proceed if `uninstall_preview`
    returns blockers — that's the single safety gate.

    Side effects are all wrapped in one transaction so a mid-flight
    failure rolls everything back.
    """
    preview = uninstall_preview(env, module_roots, target_name)
    if preview["blockers"]:
        raise ValueError("; ".join(preview["blockers"]))

    with env.transaction():
        for table in preview["tables"]:
            env.conn.execute(f'DROP TABLE IF EXISTS "{table}" CASCADE')
        env.conn.execute('DELETE FROM "ir_ui_view" WHERE "module" = %s', [target_name])
        env.conn.execute('DELETE FROM "ir_ui_menu" WHERE "module" = %s', [target_name])
        env.conn.execute('DELETE FROM "ir_module" WHERE "name" = %s', [target_name])
        # Forget the module's models from the live registry so future
        # /web/apps catalog passes show it as Not installed.
        registry = env.registry
        forgotten = [
            mn
            for mn, owner in list(registry._model_module.items())
            if owner == target_name
        ]
        for mn in forgotten:
            registry._model_module.pop(mn, None)
            registry._models.pop(mn, None)

    return {
        "ok": True,
        "uninstalled": target_name,
        "message": f"Uninstalled {target_name}",
    }

render_apps_page

render_apps_page(env, module_roots: list, current_path: str | None = None) -> str

Apps catalog at /web/apps — grid of module cards.

Source code in pyvelm/render.py
def render_apps_page(env, module_roots: list, current_path: str | None = None) -> str:
    """Apps catalog at `/web/apps` — grid of module cards."""
    env.check_can("res.users", "view_any", perm="read")
    catalog = _apps_catalog(env, module_roots)
    summary = {
        "total": len(catalog),
        "installed": sum(1 for c in catalog if c["state"] == "installed"),
        "to_upgrade": sum(1 for c in catalog if c["state"] == "to_upgrade"),
        "to_sync": sum(1 for c in catalog if c["state"] == "to_sync"),
        "uninstalled": sum(1 for c in catalog if c["state"] == "uninstalled"),
    }
    categories = sorted({c["category"] for c in catalog})
    template = _env.get_template("apps.html")
    return template.render(
        catalog=catalog,
        categories=categories,
        summary=summary,
        page_title="Apps",
        subtitle=(
            f"{summary['total']} modules · "
            f"{summary['installed']} installed · "
            f"{summary['to_upgrade']} to upgrade · "
            f"{summary['to_sync']} to sync · "
            f"{summary['uninstalled']} uninstalled"
        ),
        **layout_context(env, current_path),
    )

render_apps_detail_page

render_apps_detail_page(env, module_roots: list, name: str, current_path: str | None = None) -> str | None

Per-module detail at /web/apps/<name>. Returns None if unknown.

Source code in pyvelm/render.py
def render_apps_detail_page(
    env, module_roots: list, name: str, current_path: str | None = None
) -> str | None:
    """Per-module detail at `/web/apps/<name>`. Returns None if unknown."""
    env.check_can("res.users", "view_any", perm="read")
    app = _apps_catalog_entry(env, module_roots, name)
    if app is None:
        return None
    ctx = layout_context(env, current_path, leaf_label=app["display_name"])
    ctx["breadcrumbs"] = [
        _home_breadcrumb(),
        {"label": "Apps", "href": "/web/apps"},
        {"label": app["display_name"], "href": None},
    ]
    template = _env.get_template("apps_detail.html")
    return template.render(
        app=app,
        page_title=app["display_name"],
        subtitle=app["name"],
        **ctx,
    )

render_report_run_page

render_report_run_page(report_rec, env, current_path: str | None = None) -> str

Interactive report run page with parameter form and live preview.

Source code in pyvelm/render.py
def render_report_run_page(report_rec, env, current_path: str | None = None) -> str:
    """Interactive report run page with parameter form and live preview."""
    from .reports.service import can_run_report, definition_dict

    if not can_run_report(env, report_rec):
        raise PermissionError("Not allowed to run this report")
    defn = definition_dict(report_rec)
    parameters = defn.get("parameters") or []
    initial_params = {p["name"]: "" for p in parameters}
    ctx = layout_context(env, current_path, leaf_label=report_rec.name)
    ctx["breadcrumbs"] = [
        _home_breadcrumb(),
        {"label": "Reports", "href": "/web/records/reports/report.list"},
        {"label": report_rec.name, "href": None},
    ]
    template = _env.get_template("report_run.html")
    return template.render(
        report={"id": report_rec.id, "name": report_rec.name, "description": report_rec.description or "", "root_model": report_rec.root_model},
        parameters=parameters,
        query_suffix="",
        alpine_config={"reportId": report_rec.id, "initialParams": initial_params},
        page_title=report_rec.name,
        subtitle=f"Model: {report_rec.root_model}",
        **ctx,
    )

render_report_builder_page

render_report_builder_page(env, report_rec=None, current_path: str | None = None) -> str

Visual report builder — create or edit.

Source code in pyvelm/render.py
def render_report_builder_page(
    env, report_rec=None, current_path: str | None = None,
) -> str:
    """Visual report builder — create or edit."""
    import json as _json
    from .reports.compile import parse_definition

    page_title = "New report"
    alpine_cfg: dict = {
        "reportId": None,
        "reportMode": "detail",
        "meta": {
            "name": "",
            "description": "",
            "root_model": "",
            "row_limit": 10000,
            "schedule_active": False,
            "output_format": "xlsx",
        },
        "definition": {
            "version": 1,
            "root": "",
            "columns": [],
            "filters": [],
            "parameters": [],
            "parameter_filters": [],
            "order": [],
        },
        "columnSort": {},
        "orderRules": [],
    }
    if report_rec is not None:
        report_rec.ensure_one()
        page_title = report_rec.name
        defn = parse_definition(report_rec.definition)
        filters_ui = []
        for leaf in defn.get("filters") or []:
            if isinstance(leaf, (list, tuple)) and len(leaf) >= 3:
                v = leaf[2]
                if isinstance(v, list):
                    v = ", ".join(str(x) for x in v)
                filters_ui.append({"field": leaf[0], "op": leaf[1], "value": str(v)})
        param_links: dict[str, dict] = {}
        for leaf in defn.get("parameter_filters") or []:
            if (
                isinstance(leaf, (list, tuple))
                and len(leaf) >= 3
                and isinstance(leaf[2], dict)
                and "param" in leaf[2]
            ):
                param_links[leaf[2]["param"]] = {
                    "filter_field": leaf[0],
                    "filter_op": leaf[1],
                }
        parameters_ui = []
        for p in defn.get("parameters") or []:
            link = param_links.get(p.get("name"), {})
            parameters_ui.append({
                **p,
                "filter_field": link.get("filter_field", ""),
                "filter_op": link.get("filter_op", "ilike"),
            })
        report_mode = (
            "summary"
            if defn.get("groupby") and defn.get("measures")
            else "detail"
        )
        column_sort: dict[str, str] = {}
        order_rules: list[dict] = []
        for item in defn.get("order") or []:
            parts = str(item).strip().rsplit(None, 1)
            if len(parts) != 2:
                continue
            field, direction = parts[0], parts[1].lower()
            if direction not in ("asc", "desc"):
                continue
            column_sort[field] = direction
            order_rules.append({
                "field": field,
                "direction": direction,
                "label": field,
            })
        alpine_cfg = {
            "reportId": report_rec.id,
            "reportMode": report_mode,
            "meta": {
                "name": report_rec.name,
                "description": report_rec.description or "",
                "root_model": report_rec.root_model,
                "row_limit": report_rec.row_limit or 10000,
                "schedule_active": bool(report_rec.schedule_active),
                "output_format": report_rec.output_format or "xlsx",
            },
            "definition": {
                **defn,
                "filters": filters_ui,
                "parameters": parameters_ui,
            },
            "columnSort": column_sort,
            "orderRules": order_rules,
        }
    ctx = layout_context(env, current_path, leaf_label=page_title)
    ctx["breadcrumbs"] = [
        _home_breadcrumb(),
        {"label": "Reports", "href": "/web/records/reports/report.list"},
        {"label": page_title, "href": None},
    ]
    template = _env.get_template("report_builder.html")
    return template.render(
        page_title=page_title,
        alpine_config=alpine_cfg,
        **ctx,
    )

render_workflow_transition_form

render_workflow_transition_form(env, instance_id: int, transition_key: str, *, errors: dict | None = None, form_error: str | None = None, values: dict | None = None) -> str | None

HTMX fragment for a workflow transition stage form (PvDialog body).

Source code in pyvelm/render.py
def render_workflow_transition_form(
    env,
    instance_id: int,
    transition_key: str,
    *,
    errors: dict | None = None,
    form_error: str | None = None,
    values: dict | None = None,
) -> str | None:
    """HTMX fragment for a workflow transition stage form (PvDialog body)."""
    from pyvelm.workflow.engine import WorkflowEngine

    if "workflow.instance" not in env.registry:
        return None
    Instance = env["workflow.instance"]
    inst = Instance.search([("id", "=", instance_id)], limit=1)
    if not inst:
        return None
    inst.ensure_one()
    tr_ui = next(
        (
            t
            for t in WorkflowEngine.available_transitions(env, inst)
            if t.get("key") == transition_key
        ),
        None,
    )
    if tr_ui is None:
        return None
    post_url = f"/web/workflow/instances/{instance_id}/transition/{transition_key}"
    template = _env.get_template("workflow_transition_form.html")
    return template.render(
        transition_label=tr_ui["label"],
        transition_key=transition_key,
        instance_id=instance_id,
        form_fields=tr_ui.get("form_fields") or [],
        post_url=post_url,
        values=values or {},
        errors=errors or {},
        form_error=form_error,
    )

render_workflow_builder_page

render_workflow_builder_page(env, workflow_rec=None, current_path: str | None = None) -> str

Visual workflow designer — create or edit.

Source code in pyvelm/render.py
def render_workflow_builder_page(
    env, workflow_rec=None, current_path: str | None = None,
) -> str:
    """Visual workflow designer — create or edit."""
    import json as _json

    from pyvelm.reports.fields_api import list_readable_models
    from pyvelm.workflow.engine import parse_definition
    from pyvelm.workflow.service import list_groups, list_model_fields, list_users

    page_title = "New workflow"
    alpine_cfg: dict = {
        "workflowId": None,
        "meta": {"name": "", "description": "", "model": "", "active": True},
        "definition": {
            "version": 1,
            "model": "",
            "states": [
                {"key": "draft", "label": "Draft", "initial": True, "_uid": "w1"},
                {"key": "done", "label": "Done", "final": True, "_uid": "w2"},
            ],
            "transitions": [],
        },
        "models": list_readable_models(env),
        "groups": list_groups(env),
        "users": list_users(env),
        "recordFields": [],
    }
    if workflow_rec is not None:
        workflow_rec.ensure_one()
        page_title = workflow_rec.name
        defn = parse_definition(workflow_rec.definition)
        alpine_cfg["workflowId"] = workflow_rec.id
        alpine_cfg["meta"] = {
            "name": workflow_rec.name,
            "description": workflow_rec.description or "",
            "model": workflow_rec.model,
            "active": bool(workflow_rec.active),
        }
        alpine_cfg["definition"] = defn
        if defn.get("auto_start"):
            alpine_cfg["definition"]["auto_start"] = True
        if workflow_rec.model:
            alpine_cfg["recordFields"] = list_model_fields(env, workflow_rec.model)

    ctx = layout_context(env, current_path, leaf_label=page_title)
    ctx["breadcrumbs"] = [
        _home_breadcrumb(),
        {"label": "Workflows", "href": "/web/views/workflow/workflow_definition.list"},
        {"label": page_title, "href": None},
    ]
    template = _env.get_template("workflow_builder.html")
    return template.render(
        page_title=page_title,
        alpine_config=alpine_cfg,
        **ctx,
    )

build_form_breadcrumbs

build_form_breadcrumbs(menu_tree: list, env, *, ref_module: str | None = None, ref_name: str | None = None, bc_stack: list[tuple[str, str]] | None = None, search: str = '', order: str = '', filters: str = '', group_by: str = '', page: int | None = None, page_size: int | None = None, leaf_label: str | None = None, mode: str | None = None, record_href: str | None = None) -> list[dict]

Odoo-style trail: Home → …ancestors… → list/kanban → record → Edit.

Source code in pyvelm/render.py
def build_form_breadcrumbs(
    menu_tree: list,
    env,
    *,
    ref_module: str | None = None,
    ref_name: str | None = None,
    bc_stack: list[tuple[str, str]] | None = None,
    search: str = "",
    order: str = "",
    filters: str = "",
    group_by: str = "",
    page: int | None = None,
    page_size: int | None = None,
    leaf_label: str | None = None,
    mode: str | None = None,
    record_href: str | None = None,
) -> list[dict]:
    """Odoo-style trail: Home → …ancestors… → list/kanban → record → Edit."""
    from pyvelm.home import home_url

    crumbs: list[dict] = [{"label": "Home", "href": home_url()}]
    for mod, view_name in bc_stack or []:
        entry = _view_breadcrumb(
            env, mod, view_name, menu_tree, link_query=False
        )
        if entry:
            crumbs.append(entry)
    if ref_module and ref_name:
        parent = _view_breadcrumb(
            env,
            ref_module,
            ref_name,
            menu_tree,
            search=search,
            order=order,
            filters=filters,
            group_by=group_by,
            page=page,
            page_size=page_size,
            bc_stack=bc_stack,
        )
        if parent:
            crumbs.append(parent)
    if mode == "new":
        crumbs.append({"label": leaf_label or "New", "href": None})
    elif mode == "edit" and leaf_label:
        if record_href:
            crumbs.append({"label": leaf_label, "href": record_href})
        else:
            crumbs.append({"label": leaf_label, "href": None})
        crumbs.append({"label": "Edit", "href": None})
    elif leaf_label:
        crumbs.append({"label": leaf_label, "href": None})
    return crumbs

build_breadcrumbs

build_breadcrumbs(menu_tree: list, current_path: str | None, leaf_label: str | None = None, *, parent_href: str | None = None, parent_label: str | None = None) -> list[dict]

Build navigation crumbs: Home → list → record/detail.

  • Home links to :func:~pyvelm.home.home_url (PYVELM_HOME_URL).
  • List / kanban / graph pages get a single leaf (the view), linked from Home only on the leaf when it is not the current page.
  • Form / new / edit pages pass parent_href + parent_label (the model's list view) so the middle crumb links back to the list. The record title stays in the page heading only (no third crumb unless leaf_label is passed explicitly).
Source code in pyvelm/render.py
def build_breadcrumbs(
    menu_tree: list,
    current_path: str | None,
    leaf_label: str | None = None,
    *,
    parent_href: str | None = None,
    parent_label: str | None = None,
) -> list[dict]:
    """Build navigation crumbs: Home → list → record/detail.

    * Home links to :func:`~pyvelm.home.home_url` (``PYVELM_HOME_URL``).
    * List / kanban / graph pages get a single leaf (the view), linked
      from Home only on the leaf when it is not the current page.
    * Form / new / edit pages pass ``parent_href`` + ``parent_label``
      (the model's list view) so the middle crumb links back to the
      list. The record title stays in the page heading only (no third
      crumb unless ``leaf_label`` is passed explicitly).
    """
    from pyvelm.home import home_url

    crumbs: list[dict] = [{"label": "Home", "href": home_url()}]
    if parent_href and parent_label:
        crumbs.append({"label": parent_label, "href": parent_href})
        if leaf_label:
            crumbs.append({"label": leaf_label, "href": None})
        return crumbs

    _parent, leaf = (
        _menu_entry_for_href(menu_tree, current_path)
        if current_path
        else (None, None)
    )
    if leaf is not None:
        crumbs.append({"label": leaf_label or leaf.get("label") or "Page", "href": None})
    elif leaf_label:
        crumbs.append({"label": leaf_label, "href": None})
    return crumbs

development_db_display

development_db_display(env=None) -> str | None

Redacted active database line for the dev-mode footer, or None in production.

Source code in pyvelm/render.py
def development_db_display(env=None) -> str | None:
    """Redacted active database line for the dev-mode footer, or ``None`` in production."""
    from pyvelm.runtime import is_development

    if not is_development():
        return None

    import os

    from pyvelm.database import app_dsn_from_env, capabilities_from_dsn, dsn_display

    cap = None
    if env is not None:
        cap = getattr(getattr(env, "conn", None), "capabilities", None)

    dsn = app_dsn_from_env()
    if dsn and cap is None:
        try:
            cap = capabilities_from_dsn(dsn)
        except Exception:
            pass

    backend = cap.name if cap else "database"
    if not dsn:
        return f"{backend} (PYVELM_DSN not set)"
    return f"{backend} · {dsn_display(dsn)}"

layout_context

layout_context(env, current_path: str | None = None, leaf_label: str | None = None, *, breadcrumbs: list | None = None) -> dict

Return the shell context every page renderer passes to the layouts/main.html base template.

Source code in pyvelm/render.py
def layout_context(
    env,
    current_path: str | None = None,
    leaf_label: str | None = None,
    *,
    breadcrumbs: list | None = None,
) -> dict:
    """Return the shell context every page renderer passes to the
    `layouts/main.html` base template."""
    name = ""
    login = ""
    initial = "?"
    avatar_url = ""
    if env.uid is not None and "res.users" in env.registry:
        env.prime_current_user_cache()
        user = env["res.users"].browse(env.uid)
        if env["res.users"].search([("id", "=", env.uid)], limit=1):
            name = user.name or user.login or f"user#{user.id}"
            login = user.login or ""
            initial = (name[:1] or "?").upper()
            if "avatar_url" in env["res.users"]._fields:
                avatar_url = user.avatar_url or ""

    companies: list[dict] = []
    current_company_name = ""
    if "res.company" in env.registry:
        for c in env.with_company(None).sudo()["res.company"].search([]):
            companies.append({"id": c.id, "name": c.name})
            if env.company_id == c.id:
                current_company_name = c.name

    from pyvelm.branding import branding_context

    from pyvelm.home import home_url

    from pyvelm.menu import (
        build_menu_tree,
        menu_active_path_from_breadcrumbs,
        menu_layout_context,
    )

    home_href = home_url()
    if breadcrumbs is None:
        prelim_menu = build_menu_tree(env, current_path)
        breadcrumbs = build_breadcrumbs(prelim_menu, current_path, leaf_label)
    menu_path = menu_active_path_from_breadcrumbs(
        breadcrumbs,
        current_path=current_path,
        home_href=home_href,
    )
    menu_tree = build_menu_tree(env, menu_path)
    return {
        **menu_layout_context(menu_tree, menu_path),
        "home_href": home_href,
        "current_user_name": name,
        "current_user_login": login,
        "current_user_initial": initial,
        "current_user_avatar": avatar_url,
        "companies": companies,
        "current_company_id": env.company_id,
        "current_company_name": current_company_name,
        **branding_context(env),
        # Default crumbs derived from the menu. Pages that want a
        # different leaf label (e.g. the record name on a form view)
        # pass `leaf_label`; renderers can override `breadcrumbs`
        # directly when the page lives outside the menu altogether.
        "breadcrumbs": breadcrumbs,
        "dev_db_display": development_db_display(env),
    }