feat: add configurable view-aware dashboards
This commit is contained in:
@@ -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"]
|
||||
@@ -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."""
|
||||
+63
@@ -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."""
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user