"""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},
}