Source code for fsh_lib.saved_views

"""Per-user saved views: mixin + payload schemas + hydration.

A *saved view* is a named filter+sort state stored on behalf of a
single user.  Mirrors the :class:`fsh_lib.files.FileMixin` idiom:
the consumer subclasses :class:`SavedViewMixin` on their own
``DeclarativeBase`` and defines a normal kiln resource pointing
at it.

A single mixed-in model serves every opted-in resource;
``resource_type`` discriminates rows so the codegen-generated
CRUD scopes reads and writes per resource.

Stored payloads keep raw filter values, including raw ids on
``ref`` values.  Read paths run those ids through
:func:`hydrate_view`, which dispatches by slug through the
project-wide :meth:`fsh_lib.resource_registry.ResourceRegistry.hydrate_refs`
to produce hydrated ``items``.  Stale or invisible refs are
silently skipped.
"""

from __future__ import annotations

# Pydantic resolves these annotations at runtime to build JSON
# schemas, so the imports must stay at module scope.  Without
# the ``noqa`` ruff would hoist them under TYPE_CHECKING and
# ``model_rebuild()`` would raise ``class-not-fully-defined``.
import datetime  # noqa: TC003
import uuid  # noqa: TC003
from typing import TYPE_CHECKING, Any, Literal

from pydantic import BaseModel, Field
from sqlalchemy import Integer, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column

if TYPE_CHECKING:
    from sqlalchemy.ext.asyncio import AsyncSession


[docs] class SavedViewMixin: """SQLAlchemy mixin supplying the columns of a saved-view row. Subclass on a ``DeclarativeBase``-derived class: .. code-block:: python from fsh_lib.saved_views import SavedViewMixin class SavedView(Base, SavedViewMixin): __tablename__ = "saved_views" Then point each opted-in resource at the model: .. code-block:: jsonnet { model: "myapp.models.Product", saved_views: { model: "myapp.models.SavedView" }, representations: [ { name: "default", fields: [ { name: "id", type: "uuid" }, { name: "name", type: "str" }, ], }, ], default_representation: "default", // ... } The mixin owns no primary key, ``created_at``, or ``updated_at`` — pgcraft's ``UUIDV4PKPlugin`` and ``TimestampPlugin`` inject those columns at factory run time when the consumer attaches them via ``__plugins__``. Indexes on ``resource_type`` and ``owner_id`` are recommended; both columns drive every read filter. """ if TYPE_CHECKING: # Plugin-owned columns: ``UUIDV4PKPlugin`` injects ``id``, # ``TimestampPlugin`` injects ``created_at`` / ``updated_at`` # at factory time. Declaring them as runtime mapped columns # would collide with the plugin's column at table-build time, # so they live only in the type-checker's view -- enough for # :func:`hydrate_view` to read them without a cast. id: Mapped[uuid.UUID] created_at: Mapped[datetime.datetime] updated_at: Mapped[datetime.datetime] resource_type: Mapped[str] = mapped_column(String(64), index=True) """Slug of the parent resource (lowercase model class name). Drives the ``WHERE resource_type = ...`` clause every saved-view read / write inserts so views never bleed across resources.""" owner_id: Mapped[str] = mapped_column(String(64), index=True) """Stringified user id. Saved views are per-user; the generated routes filter by ``owner_id == str(session.<attr>)`` where ``<attr>`` is :attr:`~be.config.schema.AuthConfig.user_id_attr`.""" name: Mapped[str] = mapped_column(String(255)) """Caller-supplied display name.""" order_index: Mapped[int] = mapped_column( Integer, nullable=False, server_default="0", default=0, ) """Per-(owner, resource_type) display order. Lower values sort earlier; ties broken by ``created_at`` so newly-created views still land at a deterministic spot when the FE hasn't yet stamped an explicit index. The generated list route's ``order`` modifier sorts by this column ascending, and the update route exposes it as a writable field so the FE persists drag-reorders by PATCHing each affected row's new index.""" payload: Mapped[dict[str, Any]] = mapped_column( JSONB, default=dict, ) """Raw filter+sort spec, stored as PostgreSQL ``JSONB``. Ref values store ids only; hydration happens at read time via :func:`hydrate_view`."""
[docs] class SavedViewCreate(BaseModel): """Request body for ``POST /views``. ``payload`` is the raw filter+sort spec — same shape as :class:`SavedViewUpdate`'s, but ``name`` is required. """ name: str payload: dict[str, Any] = Field(default_factory=dict)
[docs] class SavedViewUpdate(BaseModel): """Request body for ``PATCH /views/{id}``. Both fields optional; missing fields leave the stored value untouched. """ name: str | None = None payload: dict[str, Any] | None = None
[docs] class SavedViewRefValue(BaseModel): """Hydrated ``ref`` / ``self`` filter value. Stored as ``{kind, type, ids}`` on write; :func:`hydrate_view` populates ``items`` with the labelled rows from the per-resource serializer so the FE can render a chip without a follow-up fetch. ``items`` payload-shape is per-resource and stays open (``dict[str, Any]``); the FE narrows it via the slug-keyed serializer registry on its end. """ kind: Literal["ref", "self"] type: str ids: list[str] items: list[dict[str, Any]] = Field(default_factory=list)
SavedViewFilterValue = ( str | int | float | bool | list[str] | SavedViewRefValue | None ) """Union of legal filter values after hydration. Mirrors the FE's ``SavedViewFilterValue`` -- primitives / multi-select arrays / hydrated ref values pass through, ``None`` means "filter not active"."""
[docs] class SavedViewFilterEntry(BaseModel): """One filter committed to the saved-view payload.""" id: str value: SavedViewFilterValue = None
[docs] class SavedViewSort(BaseModel): """Stable serialised sort descriptor.""" column: str direction: Literal["ascending", "descending"]
[docs] class SavedViewPayloadResponse(BaseModel): """Hydrated payload returned by :func:`hydrate_view`.""" search: str | None = None filters: list[SavedViewFilterEntry] = Field(default_factory=list) sort: SavedViewSort | None = None
[docs] class SavedViewResponse(BaseModel): """Typed response shape for saved-view read / write routes. Mirrors the dict :func:`hydrate_view` returns. Wired as the operation's :attr:`~be.config.schema.OperationConfig.response_model` so the generated FastAPI route declares it (and openapi-ts produces a typed FE return) instead of falling back to ``dict[str, Any]``. ``id`` is :class:`~uuid.UUID` (not ``str``) so the ORM column flows in raw -- Pydantic / FastAPI serialise it to the canonical hex form on the wire and openapi-ts surfaces a ``string`` typed as a uuid format. ``created_at`` / ``updated_at`` are :class:`~datetime.datetime` for the same reason: ISO-format serialisation happens at the JSON boundary, not in the hydration call site. ``order_index`` carries the row's current drag-reorder position so the FE knows the persisted tab order without a separate query; the generated list route sorts by it ascending. """ id: uuid.UUID name: str resource_type: str owner_id: str payload: SavedViewPayloadResponse order_index: int = 0 created_at: datetime.datetime | None = None updated_at: datetime.datetime | None = None
HydrateRefs = ( "Callable[[str, list[Any], AsyncSession, Any]," " Awaitable[list[dict[str, Any]]]]" ) """Type alias for the slug-keyed ref hydrator. ``await fn(resource_slug, ids, db, session)`` returns hydrated link payloads (``model_dump()``-ed) for the rows matching *ids*. Returns ``[]`` for unknown slugs or empty *ids*. :meth:`fsh_lib.resource_registry.ResourceRegistry.hydrate_refs` satisfies this shape. Kept as a string to avoid forcing SQLAlchemy / typing imports at module load."""
[docs] async def hydrate_view( view: SavedViewMixin, hydrate_refs: Any, db: AsyncSession, session: Any, ) -> SavedViewResponse: """Return the typed response payload for one saved view. Walks each entry in ``view.payload["filters"]``; for entries with ``value.kind in {"ref", "self"}``, calls *hydrate_refs* with the slug + ids and replaces ``ids`` with the returned ``items``. Stale or invisible rows are silently skipped so the dump never throws because of dangling refs. Pydantic validation happens at construction so a stored payload that drifts from the schema (legacy data, manual SQL edits) raises here rather than silently shipping the bad shape to clients. Always called against a persisted row (the route hands us the row it just inserted / fetched), so ``view.id`` is set -- typed as ``Mapped[uuid.UUID]`` non-optional on the mixin. """ raw_payload = dict(view.payload or {}) raw_filters = list(raw_payload.get("filters") or []) hydrated_filters = [ await _hydrate_entry(entry, hydrate_refs, db, session) for entry in raw_filters ] payload = SavedViewPayloadResponse.model_validate( {**raw_payload, "filters": hydrated_filters}, ) return SavedViewResponse( id=view.id, name=view.name, resource_type=view.resource_type, owner_id=view.owner_id, payload=payload, order_index=view.order_index, created_at=view.created_at, updated_at=view.updated_at, )
async def _hydrate_entry( entry: dict[str, Any], hydrate_refs: Any, db: AsyncSession, session: Any, ) -> dict[str, Any]: """Hydrate one filter entry, leaving non-link values untouched. Both ``ref`` (FK to another resource) and ``self`` (PK of this resource) values dump as link schemas, so they share the same hydration path: dispatch on ``value["type"]`` through *hydrate_refs*. Returns a plain dict (rather than a typed entry) because the caller hands the result to :meth:`SavedViewPayloadResponse.model_validate` for the actual validation step -- producing one object per call would just serialize again on the way out. """ value = entry.get("value") if not isinstance(value, dict): return entry if value.get("kind") not in {"ref", "self"}: return entry ref_type = value.get("type") ids: list[Any] = list(value.get("ids") or []) if not isinstance(ref_type, str) or not ids: return { **entry, "value": {**value, "items": []}, } items = await hydrate_refs(ref_type, ids, db, session) return { **entry, "value": {**value, "items": items}, }