diff --git a/app/main.py b/app/main.py index 9a874bd..3f37bf2 100644 --- a/app/main.py +++ b/app/main.py @@ -30,6 +30,7 @@ from app.routes import ( auth, contact_folders, contacts, + dashboard, entity_history, groups, health, @@ -293,6 +294,7 @@ def create_app() -> FastAPI: app.include_router(notifications.router) app.include_router(contacts.router) app.include_router(contact_folders.router) + app.include_router(dashboard.router) app.include_router(entity_history.router) app.include_router(import_export.router) app.include_router(plugins.router) diff --git a/app/routes/__init__.py b/app/routes/__init__.py index e04f6ca..d97b8ac 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -6,6 +6,7 @@ from app.routes import ( audit, # noqa: F401 auth, # noqa: F401 contacts, # noqa: F401 + dashboard, # noqa: F401 entity_history, # noqa: F401 currencies, # noqa: F401 taxes, # noqa: F401 diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py new file mode 100644 index 0000000..4d11a83 --- /dev/null +++ b/app/routes/dashboard.py @@ -0,0 +1,38 @@ +"""Dashboard routes — list available widgets from active plugins (Task 5.25).""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.db import get_db +from app.deps import require_permission +from app.plugins.registry import get_registry + +router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"]) + + +@router.get("/widgets") +async def list_dashboard_widgets( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("dashboard:read")), +): + """List all available dashboard widgets from active plugins. + + Returns a flat list of widget definitions contributed by active plugins, + sorted by their order field. Each widget includes the contributing plugin name. + """ + registry = get_registry() + manifests = await registry.get_active_manifests(db) + + widgets: list[dict] = [] + for manifest in manifests: + for widget in manifest.get("dashboard_widgets", []): + widget_copy = dict(widget) + widget_copy["plugin_name"] = manifest["name"] + widgets.append(widget_copy) + + # Sort by order field + widgets.sort(key=lambda w: w.get("order", 100)) + + return {"items": widgets, "total": len(widgets)} diff --git a/frontend/src/__tests__/Dashboard.test.tsx b/frontend/src/__tests__/Dashboard.test.tsx new file mode 100644 index 0000000..1a784f7 --- /dev/null +++ b/frontend/src/__tests__/Dashboard.test.tsx @@ -0,0 +1,74 @@ +/** + * Dashboard tests — Task 5.25. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { DashboardGrid } from '@/components/dashboard/DashboardGrid'; +import type { DashboardWidgetDef } from '@/api/dashboard'; + +// Mock i18n +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +// Mock DashboardWidgetLoader to avoid lazy loading in tests +vi.mock('@/components/dashboard/DashboardWidgetLoader', () => ({ + DashboardWidgetLoader: ({ widget }: { widget: DashboardWidgetDef }) => ( +
{t('dashboard.widgetError')}
; + } + + const entries: CalendarEntry[] = data ?? []; + const now = new Date(); + const upcoming = entries + .filter((e: CalendarEntry) => new Date(e.start_at || e.due_date || '') >= now) + .slice(0, 3); + + if (upcoming.length === 0) { + return{t('dashboard.noUpcomingEvents')}
; + } + + return ( ++ {t('dashboard.noWidgets')} +
+ ); + } + + return ( ++ {t('dashboard.widgetNotAvailable', { name: widget.label || widget.id })} +
+{t('dashboard.widgetError')}
; + } + + const contacts = data?.items ?? []; + + if (contacts.length === 0) { + return{t('dashboard.noContacts')}
; + } + + return ( +{t('dashboard.widgetError')}
; + } + + const tasks = data?.items ?? []; + const openTasks = tasks.filter((task) => task.status !== 'done'); + const overdueTasks = tasks.filter((task) => { + if (!task.due_date || task.status === 'done') return false; + return new Date(task.due_date) < new Date(); + }); + const highPriority = openTasks.filter((task) => task.priority === 'high' || task.priority === 'urgent'); + + return ( +{t('dashboard.noOpenTasks')}
+ )} ++ {t('dashboard.widgetsUnavailable')} +
+ ) : widgets.length > 0 ? ( +{t('dashboard.activityUnavailable')} diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..d6393e8 --- /dev/null +++ b/tests/test_dashboard.py @@ -0,0 +1,46 @@ +"""Dashboard widget API tests — Task 5.25.""" + +from __future__ import annotations + +import pytest +from httpx import AsyncClient + +from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users + + +@pytest.mark.asyncio +class TestDashboardWidgets: + """GET /api/v1/dashboard/widgets — list available widgets.""" + + async def test_list_widgets_returns_200(self, client: AsyncClient, db_session): + """Dashboard widgets endpoint returns 200 with items list.""" + await seed_tenant_and_users(db_session) + await login_client(client, "admin@tenanta.com") + + resp = await client.get("/api/v1/dashboard/widgets", headers=ORIGIN_HEADER) + assert resp.status_code == 200 + data = resp.json() + assert "items" in data + assert "total" in data + assert isinstance(data["items"], list) + + async def test_list_widgets_requires_auth(self, client: AsyncClient, db_session): + """Dashboard widgets endpoint requires authentication.""" + await seed_tenant_and_users(db_session) + + resp = await client.get("/api/v1/dashboard/widgets", headers=ORIGIN_HEADER) + assert resp.status_code == 401 + + async def test_list_widgets_has_plugin_name(self, client: AsyncClient, db_session): + """Each widget should include the contributing plugin name.""" + await seed_tenant_and_users(db_session) + await login_client(client, "admin@tenanta.com") + + resp = await client.get("/api/v1/dashboard/widgets", headers=ORIGIN_HEADER) + assert resp.status_code == 200 + data = resp.json() + for widget in data["items"]: + assert "plugin_name" in widget + assert "id" in widget + assert "component" in widget + assert "label_key" in widget