feat: add configurable view-aware dashboards

This commit is contained in:
2026-07-29 14:32:54 +02:00
parent b28229c003
commit f0732bead2
24 changed files with 3169 additions and 146 deletions
@@ -0,0 +1,3 @@
from govoplan_dashboard.backend.db.models import DashboardLayout
__all__ = ["DashboardLayout"]
@@ -0,0 +1,79 @@
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import Index, Integer, JSON, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class DashboardLayout(Base, TimestampMixin):
__tablename__ = "dashboard_layouts"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"account_id",
"context_key",
name="uq_dashboard_layouts_actor_context",
),
Index(
"ix_dashboard_layouts_actor",
"tenant_id",
"account_id",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=new_uuid,
)
tenant_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
index=True,
)
account_id: Mapped[str] = mapped_column(
String(255),
nullable=False,
index=True,
)
context_key: Mapped[str] = mapped_column(
String(300),
nullable=False,
index=True,
)
view_id: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
layout_version: Mapped[int] = mapped_column(
Integer,
default=1,
nullable=False,
)
revision: Mapped[int] = mapped_column(
Integer,
default=1,
nullable=False,
)
placements: Mapped[list[dict[str, Any]]] = mapped_column(
JSON,
default=list,
nullable=False,
)
known_widget_ids: Mapped[list[str]] = mapped_column(
JSON,
default=list,
nullable=False,
)
__all__ = ["DashboardLayout", "new_uuid"]
+56 -2
View File
@@ -1,8 +1,39 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.modules import DocumentationTopic, FrontendModule, FrontendRoute, ModuleManifest, NavItem
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleManifest,
NavItem,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_dashboard.backend.db.models import DashboardLayout
def _dashboard_router(_context):
from govoplan_dashboard.backend.router import router
return router
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
return {
"dashboard_layouts": (
session.query(DashboardLayout)
.filter(DashboardLayout.tenant_id == tenant_id)
.count()
)
}
manifest = ModuleManifest(
@@ -11,6 +42,28 @@ manifest = ModuleManifest(
version="0.1.8",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"),
route_factory=_dashboard_router,
tenant_summary_providers=(_tenant_summary,),
migration_spec=MigrationSpec(
module_id="dashboard",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
DashboardLayout,
label="Dashboard",
),
retirement_notes=(
"Destructive retirement removes personal Dashboard layouts after "
"the installer captures a database snapshot."
),
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
DashboardLayout,
label="Dashboard",
),
),
nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),),
frontend=FrontendModule(
module_id="dashboard",
@@ -34,7 +87,8 @@ manifest = ModuleManifest(
summary="The dashboard module owns the configurable home surface. Feature modules expose widgets through a narrow dashboard.widgets capability.",
body=(
"Core only provides a minimal fallback home when the dashboard module is absent. "
"Dashboard widgets must be contributed through core contracts, not by importing sibling module components directly."
"Dashboard widgets must be contributed through core contracts, not by importing sibling module components directly. "
"Personal layouts are stored per tenant, account, and active View."
),
layer="configured",
documentation_types=("admin", "user"),
@@ -0,0 +1 @@
"""Dashboard database migrations."""
@@ -0,0 +1,63 @@
"""add personal and View-scoped dashboard layouts
Revision ID: 7b9d2f4a6c8e
Revises: None
Create Date: 2026-07-29 10:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "7b9d2f4a6c8e"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"dashboard_layouts",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("account_id", sa.String(length=255), nullable=False),
sa.Column("context_key", sa.String(length=300), nullable=False),
sa.Column("view_id", sa.String(length=255), nullable=True),
sa.Column("layout_version", sa.Integer(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("placements", sa.JSON(), nullable=False),
sa.Column("known_widget_ids", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_dashboard_layouts")),
sa.UniqueConstraint(
"tenant_id",
"account_id",
"context_key",
name="uq_dashboard_layouts_actor_context",
),
)
for column in (
"tenant_id",
"account_id",
"context_key",
"view_id",
):
op.create_index(
op.f(f"ix_dashboard_layouts_{column}"),
"dashboard_layouts",
[column],
unique=False,
)
op.create_index(
"ix_dashboard_layouts_actor",
"dashboard_layouts",
["tenant_id", "account_id"],
unique=False,
)
def downgrade() -> None:
op.drop_table("dashboard_layouts")
@@ -0,0 +1 @@
"""Dashboard migration revisions."""
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.db.session import get_session
from govoplan_dashboard.backend.db.models import DashboardLayout
from govoplan_dashboard.backend.schemas import (
DashboardLayoutResponse,
DashboardLayoutUpdateRequest,
)
from govoplan_dashboard.backend.service import (
DashboardLayoutConflict,
DashboardLayoutLimitExceeded,
delete_dashboard_layout,
get_dashboard_layout,
normalize_view_id,
save_dashboard_layout,
)
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
def _response(
layout: DashboardLayout | None,
*,
view_id: str | None,
) -> DashboardLayoutResponse:
if layout is None:
return DashboardLayoutResponse(
exists=False,
view_id=normalize_view_id(view_id),
)
return DashboardLayoutResponse(
exists=True,
view_id=layout.view_id,
layout_version=layout.layout_version,
revision=layout.revision,
placements=layout.placements,
known_widget_ids=layout.known_widget_ids,
updated_at=layout.updated_at,
)
@router.get("/layout", response_model=DashboardLayoutResponse)
def api_get_dashboard_layout(
view_id: str | None = Query(default=None, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> DashboardLayoutResponse:
return _response(
get_dashboard_layout(
session,
tenant_id=principal.tenant_id,
account_id=principal.account_id,
view_id=view_id,
),
view_id=view_id,
)
@router.put("/layout", response_model=DashboardLayoutResponse)
def api_save_dashboard_layout(
payload: DashboardLayoutUpdateRequest,
view_id: str | None = Query(default=None, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> DashboardLayoutResponse:
try:
layout = save_dashboard_layout(
session,
tenant_id=principal.tenant_id,
account_id=principal.account_id,
view_id=view_id,
expected_revision=payload.expected_revision,
layout_version=payload.layout_version,
placements=payload.placements,
known_widget_ids=payload.known_widget_ids,
)
session.commit()
except DashboardLayoutConflict as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except DashboardLayoutLimitExceeded as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"The dashboard layout changed in another session. "
"Reload it before saving."
),
) from exc
return _response(layout, view_id=view_id)
@router.delete("/layout", status_code=status.HTTP_204_NO_CONTENT)
def api_delete_dashboard_layout(
view_id: str | None = Query(default=None, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> None:
delete_dashboard_layout(
session,
tenant_id=principal.tenant_id,
account_id=principal.account_id,
view_id=view_id,
)
session.commit()
__all__ = ["router"]
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
import math
from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
WidgetId = Annotated[str, Field(min_length=1, max_length=160)]
ConfigurationValue = str | int | float | bool | None
class DashboardWidgetPlacementPayload(BaseModel):
instance_id: str = Field(min_length=1, max_length=120)
widget_id: WidgetId
size: Literal["small", "medium", "wide", "full"] = "medium"
configuration: dict[str, ConfigurationValue] = Field(
default_factory=dict,
max_length=40,
)
@field_validator("configuration")
@classmethod
def validate_configuration(
cls,
value: dict[str, ConfigurationValue],
) -> dict[str, ConfigurationValue]:
approximate_size = 0
for key, item in value.items():
if not key or len(key) > 120:
raise ValueError(
"Widget configuration keys must contain 1 to 120 characters."
)
approximate_size += len(key)
if isinstance(item, str):
if len(item) > 4_000:
raise ValueError(
"Widget configuration text values may contain at most "
"4,000 characters."
)
approximate_size += len(item)
else:
approximate_size += len(str(item))
if isinstance(item, float) and not math.isfinite(item):
raise ValueError(
"Widget configuration numbers must be finite."
)
if approximate_size > 32_000:
raise ValueError(
"A widget configuration may contain at most 32,000 characters."
)
return value
class DashboardLayoutUpdateRequest(BaseModel):
expected_revision: int | None = Field(default=None, ge=0)
layout_version: Literal[1] = 1
placements: list[DashboardWidgetPlacementPayload] = Field(
default_factory=list,
max_length=100,
)
known_widget_ids: list[WidgetId] = Field(
default_factory=list,
max_length=500,
)
@model_validator(mode="after")
def validate_unique_ids(self) -> "DashboardLayoutUpdateRequest":
instance_ids = [item.instance_id for item in self.placements]
if len(instance_ids) != len(set(instance_ids)):
raise ValueError("Dashboard widget instance ids must be unique.")
return self
class DashboardLayoutResponse(BaseModel):
exists: bool
view_id: str | None = None
layout_version: int = 1
revision: int = 0
placements: list[DashboardWidgetPlacementPayload] = Field(
default_factory=list
)
known_widget_ids: list[str] = Field(default_factory=list)
updated_at: datetime | None = None
__all__ = [
"DashboardLayoutResponse",
"DashboardLayoutUpdateRequest",
"DashboardWidgetPlacementPayload",
]
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
from collections.abc import Sequence
from sqlalchemy.orm import Session
from govoplan_dashboard.backend.db.models import DashboardLayout
from govoplan_dashboard.backend.schemas import DashboardWidgetPlacementPayload
MAX_LAYOUT_CONTEXTS_PER_ACCOUNT = 100
class DashboardLayoutConflict(ValueError):
pass
class DashboardLayoutLimitExceeded(ValueError):
pass
def normalize_view_id(view_id: str | None) -> str | None:
normalized = (view_id or "").strip()
if not normalized:
return None
if len(normalized) > 255:
raise ValueError("Dashboard View ids may contain at most 255 characters.")
return normalized
def layout_context_key(view_id: str | None) -> str:
normalized = normalize_view_id(view_id)
return f"view:{normalized}" if normalized else "full"
def get_dashboard_layout(
session: Session,
*,
tenant_id: str,
account_id: str,
view_id: str | None,
) -> DashboardLayout | None:
return (
session.query(DashboardLayout)
.filter(
DashboardLayout.tenant_id == tenant_id,
DashboardLayout.account_id == account_id,
DashboardLayout.context_key == layout_context_key(view_id),
)
.one_or_none()
)
def save_dashboard_layout(
session: Session,
*,
tenant_id: str,
account_id: str,
view_id: str | None,
expected_revision: int | None,
layout_version: int,
placements: Sequence[DashboardWidgetPlacementPayload],
known_widget_ids: Sequence[str],
) -> DashboardLayout:
normalized_view_id = normalize_view_id(view_id)
layout = get_dashboard_layout(
session,
tenant_id=tenant_id,
account_id=account_id,
view_id=normalized_view_id,
)
current_revision = layout.revision if layout is not None else 0
if (
expected_revision is not None
and expected_revision != current_revision
):
raise DashboardLayoutConflict(
"The dashboard layout changed in another session. Reload it before "
"saving."
)
if layout is None:
layout_count = (
session.query(DashboardLayout)
.filter(
DashboardLayout.tenant_id == tenant_id,
DashboardLayout.account_id == account_id,
)
.count()
)
if layout_count >= MAX_LAYOUT_CONTEXTS_PER_ACCOUNT:
raise DashboardLayoutLimitExceeded(
"At most 100 Dashboard layouts may be stored per account and tenant."
)
layout = DashboardLayout(
tenant_id=tenant_id,
account_id=account_id,
context_key=layout_context_key(normalized_view_id),
view_id=normalized_view_id,
layout_version=layout_version,
revision=1,
placements=[],
known_widget_ids=[],
)
session.add(layout)
else:
layout.layout_version = layout_version
layout.revision += 1
layout.placements = [
placement.model_dump(mode="json")
for placement in placements
]
layout.known_widget_ids = list(dict.fromkeys(known_widget_ids))
session.flush()
return layout
def delete_dashboard_layout(
session: Session,
*,
tenant_id: str,
account_id: str,
view_id: str | None,
) -> bool:
layout = get_dashboard_layout(
session,
tenant_id=tenant_id,
account_id=account_id,
view_id=view_id,
)
if layout is None:
return False
session.delete(layout)
session.flush()
return True
__all__ = [
"DashboardLayoutConflict",
"DashboardLayoutLimitExceeded",
"MAX_LAYOUT_CONTEXTS_PER_ACCOUNT",
"delete_dashboard_layout",
"get_dashboard_layout",
"layout_context_key",
"normalize_view_id",
"save_dashboard_layout",
]