diff --git a/docs/superpowers/plans/2026-04-15-dashboard-improvements.md b/docs/superpowers/plans/2026-04-15-dashboard-improvements.md new file mode 100644 index 00000000..76bb104b --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-dashboard-improvements.md @@ -0,0 +1,832 @@ +# Dashboard Improvements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the placeholder dashboard with real user/product counts and a system info panel. + +**Architecture:** The dashboard stats endpoint queries the `users_user` and `products_product` tables directly for counts, calls `discover_modules()` for module list, and runs health checks from `app.state.health_registry`. All data is passed as Inertia page props — no separate client-side fetch. The in-memory product event counters are removed. + +**Tech Stack:** FastAPI, SQLAlchemy (async), Inertia.js/React 19, Tailwind CSS 4, lucide-react icons + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `modules/dashboard/dashboard/stats.py` | Create | Stats-fetching logic (DB queries, system info) | +| `modules/dashboard/dashboard/endpoints/api.py` | Modify | Call `fetch_dashboard_stats`, return full stats | +| `modules/dashboard/dashboard/endpoints/views.py` | Modify | Pass stats as Inertia props | +| `modules/dashboard/dashboard/handlers.py` | Delete | Remove in-memory product event counters | +| `modules/dashboard/dashboard/module.py` | Modify | Remove event subscriptions, add Users dependency | +| `modules/dashboard/dashboard/pages/Home.tsx` | Rewrite | Stat cards with real data + system info panel | +| `modules/dashboard/dashboard/locales/en.json` | Modify | Add new i18n keys | +| `modules/dashboard/dashboard/locales/es.json` | Modify | Add new i18n keys | +| `modules/dashboard/pyproject.toml` | Modify | Add `users` dependency | +| `modules/dashboard/tests/test_dashboard.py` | Rewrite | Tests for new stats endpoint and module registration | + +--- + +### Task 1: Add `users` dependency to dashboard module + +**Files:** +- Modify: `modules/dashboard/pyproject.toml` +- Modify: `modules/dashboard/dashboard/module.py` + +- [ ] **Step 1: Add `users` to pyproject.toml dependencies** + +In `modules/dashboard/pyproject.toml`, add `"users"` to both `[project] dependencies` and `[tool.uv.sources]`: + +```toml +[project] +dependencies = [ + "simple-module-core", + "simple-module-db", + "simple-module-hosting", + "products", + "users", +] + +[tool.uv.sources] +simple-module-core = { workspace = true } +simple-module-db = { workspace = true } +simple-module-hosting = { workspace = true } +products = { workspace = true } +users = { workspace = true } +``` + +- [ ] **Step 2: Update module metadata to depend on Users** + +In `modules/dashboard/dashboard/module.py`, change `depends_on`: + +```python +class DashboardModule(ModuleBase): + meta = ModuleMeta( + name="Dashboard", + route_prefix="/api/dashboard", + view_prefix="/dashboard", + depends_on=["Products", "Users"], + ) +``` + +- [ ] **Step 3: Remove event handler registration from module** + +In `modules/dashboard/dashboard/module.py`, remove the `register_event_handlers` method entirely, and remove the imports of `on_product_created`, `on_product_deleted`, `on_product_updated` from `dashboard.handlers`, and the imports of `ProductCreated`, `ProductDeleted`, `ProductUpdated` from `products.contracts.events`, and the import of `EventBus` from `simple_module_core.events`. + +The resulting `module.py`: + +```python +"""Dashboard module definition.""" + +from __future__ import annotations + +import importlib.resources +from pathlib import Path + +from fastapi import APIRouter +from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection +from simple_module_core.module import ModuleBase, ModuleMeta + + +class DashboardModule(ModuleBase): + meta = ModuleMeta( + name="Dashboard", + route_prefix="/api/dashboard", + view_prefix="/dashboard", + depends_on=["Products", "Users"], + ) + + def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: + from dashboard.endpoints.api import router as api + from dashboard.endpoints.views import router as views + + api_router.include_router(api) + view_router.include_router(views) + + def register_menu_items(self, registry: MenuRegistry) -> None: + registry.add( + MenuItem( + label="Dashboard", + url="/dashboard", + icon="home", + order=1, + section=MenuSection.SIDEBAR, + ) + ) + + def locale_dirs(self) -> dict[str, Path]: + return {"dashboard": Path(str(importlib.resources.files(__package__) / "locales"))} +``` + +- [ ] **Step 4: Delete handlers.py** + +Delete the file `modules/dashboard/dashboard/handlers.py` entirely — the in-memory counters are no longer used. + +- [ ] **Step 5: Commit** + +```bash +git add modules/dashboard/pyproject.toml modules/dashboard/dashboard/module.py +git rm modules/dashboard/dashboard/handlers.py +git commit -m "refactor(dashboard): remove in-memory event counters, add Users dependency" +``` + +--- + +### Task 2: Create stats-fetching backend logic + +**Files:** +- Create: `modules/dashboard/dashboard/stats.py` +- Test: `modules/dashboard/tests/test_dashboard.py` + +- [ ] **Step 1: Write the failing test for `fetch_dashboard_stats`** + +Replace the contents of `modules/dashboard/tests/test_dashboard.py` with: + +```python +"""Tests for the Dashboard module: stats endpoint and module registration.""" + +from __future__ import annotations + +import httpx +import pytest +from dashboard.module import DashboardModule + + +# ── Module registration tests ──────────────────────────────────────── + + +class TestDashboardModuleRegistration: + async def test_module_meta(self): + mod = DashboardModule() + assert mod.meta.name == "Dashboard" + assert mod.meta.route_prefix == "/api/dashboard" + assert "Products" in mod.meta.depends_on + assert "Users" in mod.meta.depends_on + + +# ── Stats function unit tests ──────────────────────────────────────── + + +class TestFetchDashboardStats: + async def test_returns_expected_keys(self, app): + from dashboard.stats import fetch_dashboard_stats + + async with app.state.db.session_factory() as db: + stats = await fetch_dashboard_stats(db, app) + + assert "total_users" in stats + assert "active_users_7d" in stats + assert "total_products" in stats + assert "module_count" in stats + assert "system_info" in stats + + async def test_total_users_counts_seeded_users(self, app): + from dashboard.stats import fetch_dashboard_stats + + async with app.state.db.session_factory() as db: + stats = await fetch_dashboard_stats(db, app) + + # The app fixture seeds at least one admin user via authenticated_client deps, + # but fetch_dashboard_stats only counts what's in the DB at call time. + assert isinstance(stats["total_users"], int) + assert stats["total_users"] >= 0 + + async def test_module_count_is_positive(self, app): + from dashboard.stats import fetch_dashboard_stats + + async with app.state.db.session_factory() as db: + stats = await fetch_dashboard_stats(db, app) + + assert stats["module_count"] >= 1 + + async def test_system_info_contains_modules_list(self, app): + from dashboard.stats import fetch_dashboard_stats + + async with app.state.db.session_factory() as db: + stats = await fetch_dashboard_stats(db, app) + + sys_info = stats["system_info"] + assert "modules" in sys_info + assert isinstance(sys_info["modules"], list) + assert len(sys_info["modules"]) >= 1 + assert "name" in sys_info["modules"][0] + assert "status" in sys_info["modules"][0] + + async def test_system_info_contains_python_version(self, app): + from dashboard.stats import fetch_dashboard_stats + + async with app.state.db.session_factory() as db: + stats = await fetch_dashboard_stats(db, app) + + assert "python_version" in stats["system_info"] + assert "." in stats["system_info"]["python_version"] + + async def test_system_info_contains_health_checks(self, app): + from dashboard.stats import fetch_dashboard_stats + + async with app.state.db.session_factory() as db: + stats = await fetch_dashboard_stats(db, app) + + assert "health_checks" in stats["system_info"] + assert isinstance(stats["system_info"]["health_checks"], list) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest modules/dashboard/tests/test_dashboard.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'dashboard.stats'` + +- [ ] **Step 3: Implement `fetch_dashboard_stats`** + +Create `modules/dashboard/dashboard/stats.py`: + +```python +"""Dashboard statistics queries.""" + +from __future__ import annotations + +import sys +from datetime import UTC, datetime, timedelta + +from fastapi import FastAPI +from products.models import Product +from simple_module_core.discovery import discover_modules +from simple_module_core.health import HealthStatus +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from users.models import User + + +async def fetch_dashboard_stats(db: AsyncSession, app: FastAPI) -> dict: + """Gather all dashboard statistics in a single call.""" + total_users = await _count_users(db) + active_users_7d = await _count_active_users(db, days=7) + total_products = await _count_products(db) + module_count, modules_list = _get_module_info() + health_checks = await _run_health_checks(app) + + return { + "total_users": total_users, + "active_users_7d": active_users_7d, + "total_products": total_products, + "module_count": module_count, + "system_info": { + "modules": modules_list, + "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + "health_checks": health_checks, + }, + } + + +async def _count_users(db: AsyncSession) -> int: + result = await db.execute(select(func.count()).select_from(User)) + return result.scalar_one() + + +async def _count_active_users(db: AsyncSession, *, days: int) -> int: + cutoff = datetime.now(UTC) - timedelta(days=days) + result = await db.execute( + select(func.count()).select_from(User).where(User.last_login_at >= cutoff) + ) + return result.scalar_one() + + +async def _count_products(db: AsyncSession) -> int: + result = await db.execute( + select(func.count()).select_from(Product).where(Product.is_active.is_(True)) + ) + return result.scalar_one() + + +def _get_module_info() -> tuple[int, list[dict[str, str]]]: + modules = discover_modules() + modules_list = [{"name": m.meta.name, "status": "loaded"} for m in modules] + return len(modules), modules_list + + +async def _run_health_checks(app: FastAPI) -> list[dict[str, str]]: + registry = app.state.health_registry + results = [] + for check in registry.all_checks: + try: + result = await check.check() + results.append({"name": check.name, "status": result.status.value}) + except Exception: + results.append({"name": check.name, "status": HealthStatus.UNHEALTHY.value}) + return results +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest modules/dashboard/tests/test_dashboard.py -v` +Expected: All `TestFetchDashboardStats` tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add modules/dashboard/dashboard/stats.py modules/dashboard/tests/test_dashboard.py +git commit -m "feat(dashboard): add stats-fetching logic with real DB queries" +``` + +--- + +### Task 3: Update stats API endpoint + +**Files:** +- Modify: `modules/dashboard/dashboard/endpoints/api.py` +- Test: `modules/dashboard/tests/test_dashboard.py` + +- [ ] **Step 1: Add API endpoint tests** + +Append to `modules/dashboard/tests/test_dashboard.py`: + +```python +# ── Stats API endpoint ────────────────────────────────────────────── + + +class TestDashboardStatsEndpoint: + async def test_stats_returns_all_fields(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get("/api/dashboard/stats") + assert resp.status_code == 200 + body = resp.json() + assert "total_users" in body + assert "active_users_7d" in body + assert "total_products" in body + assert "module_count" in body + assert "system_info" in body + + async def test_stats_total_users_includes_seeded_admin( + self, authenticated_client: httpx.AsyncClient + ): + resp = await authenticated_client.get("/api/dashboard/stats") + body = resp.json() + # authenticated_client fixture seeds one admin user + assert body["total_users"] >= 1 + + async def test_stats_system_info_has_modules(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get("/api/dashboard/stats") + body = resp.json() + modules = body["system_info"]["modules"] + assert len(modules) >= 1 + names = [m["name"] for m in modules] + assert "Dashboard" in names + + async def test_stats_requires_authentication(self, client: httpx.AsyncClient): + resp = await client.get("/api/dashboard/stats", follow_redirects=False) + assert resp.status_code in (302, 401, 403) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest modules/dashboard/tests/test_dashboard.py::TestDashboardStatsEndpoint -v` +Expected: FAIL — old endpoint returns `{"product_events": ...}` shape + +- [ ] **Step 3: Update the stats endpoint** + +Replace `modules/dashboard/dashboard/endpoints/api.py` with: + +```python +"""REST API endpoints for the Dashboard module.""" + +from __future__ import annotations + +from fastapi import APIRouter, Request +from simple_module_db.deps import get_db +from sqlalchemy.ext.asyncio import AsyncSession + +from dashboard.stats import fetch_dashboard_stats + +router = APIRouter() + + +@router.get("/stats") +async def dashboard_stats(request: Request, db: AsyncSession = Depends(get_db)) -> dict: + """Return dashboard statistics including user counts and system info.""" + return await fetch_dashboard_stats(db, request.app) +``` + +Note: add `from fastapi import APIRouter, Depends, Request` (include `Depends`). + +Corrected file: + +```python +"""REST API endpoints for the Dashboard module.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from simple_module_db.deps import get_db +from sqlalchemy.ext.asyncio import AsyncSession + +from dashboard.stats import fetch_dashboard_stats + +router = APIRouter() + + +@router.get("/stats") +async def dashboard_stats(request: Request, db: AsyncSession = Depends(get_db)) -> dict: + """Return dashboard statistics including user counts and system info.""" + return await fetch_dashboard_stats(db, request.app) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest modules/dashboard/tests/test_dashboard.py -v` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add modules/dashboard/dashboard/endpoints/api.py modules/dashboard/tests/test_dashboard.py +git commit -m "feat(dashboard): expand /stats endpoint with real counts and system info" +``` + +--- + +### Task 4: Update Inertia view endpoint to pass stats as props + +**Files:** +- Modify: `modules/dashboard/dashboard/endpoints/views.py` + +- [ ] **Step 1: Update the view endpoint** + +Replace `modules/dashboard/dashboard/endpoints/views.py` with: + +```python +"""Inertia view endpoints for the Dashboard. + +Mounted under ``/dashboard`` via :attr:`DashboardModule.meta.view_prefix`. +The public landing page at ``/`` is owned by the host, not this module. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from inertia import InertiaResponse +from simple_module_db.deps import get_db +from simple_module_hosting.i18n_deps import TranslatorDep +from simple_module_hosting.inertia_deps import InertiaDep +from sqlalchemy.ext.asyncio import AsyncSession + +from dashboard.stats import fetch_dashboard_stats + +router = APIRouter() + + +@router.get("/", response_model=None) +async def dashboard( + request: Request, + inertia: InertiaDep, + t: TranslatorDep, + db: AsyncSession = Depends(get_db), +) -> InertiaResponse: + """Authenticated dashboard — requires login (enforced by AuthMiddleware).""" + stats = await fetch_dashboard_stats(db, request.app) + return await inertia.render( + "Dashboard/Home", + { + "welcome": t.t("dashboard.home.welcome_message"), + **stats, + }, + ) +``` + +- [ ] **Step 2: Run full test suite to verify nothing breaks** + +Run: `python -m pytest modules/dashboard/tests/test_dashboard.py -v` +Expected: All tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add modules/dashboard/dashboard/endpoints/views.py +git commit -m "feat(dashboard): pass real stats as Inertia props to Home page" +``` + +--- + +### Task 5: Update i18n locale files + +**Files:** +- Modify: `modules/dashboard/dashboard/locales/en.json` +- Modify: `modules/dashboard/dashboard/locales/es.json` + +- [ ] **Step 1: Update English locale** + +Replace `modules/dashboard/dashboard/locales/en.json` with: + +```json +{ + "home": { + "title": "Dashboard", + "description": "Overview of your application", + "stats": { + "total_users": "Total Users", + "active_users": "Active Users (7d)", + "products": "Products", + "modules": "Modules" + }, + "system_info_title": "System", + "system_info": { + "modules": "Modules", + "python_version": "Python Version", + "health_checks": "Health Checks" + }, + "welcome_card_title": "Welcome", + "welcome_message": "Welcome to SimpleModule", + "description_body": "This is a modular monolith built with FastAPI, Inertia.js, and React. Each module provides its own pages, API endpoints, and database schema." + } +} +``` + +- [ ] **Step 2: Update Spanish locale** + +Replace `modules/dashboard/dashboard/locales/es.json` with: + +```json +{ + "home": { + "title": "Panel", + "description": "Resumen de tu aplicación", + "stats": { + "total_users": "Usuarios Totales", + "active_users": "Usuarios Activos (7d)", + "products": "Productos", + "modules": "Módulos" + }, + "system_info_title": "Sistema", + "system_info": { + "modules": "Módulos", + "python_version": "Versión de Python", + "health_checks": "Verificaciones de Salud" + }, + "welcome_card_title": "Bienvenido", + "welcome_message": "Bienvenido a SimpleModule", + "description_body": "Este es un monolito modular construido con FastAPI, Inertia.js y React. Cada módulo proporciona sus propias páginas, endpoints de API y esquema de base de datos." + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add modules/dashboard/dashboard/locales/en.json modules/dashboard/dashboard/locales/es.json +git commit -m "feat(dashboard): add i18n keys for new stat cards and system info" +``` + +--- + +### Task 6: Rewrite Home.tsx frontend + +**Files:** +- Rewrite: `modules/dashboard/dashboard/pages/Home.tsx` + +- [ ] **Step 1: Rewrite Home.tsx with real stats and system info panel** + +Replace `modules/dashboard/dashboard/pages/Home.tsx` with: + +```tsx +import { usePage } from '@inertiajs/react'; +import { keys, useT } from '@simple-module/i18n'; +import { PageShell } from '@simple-module/ui/components/PageShell'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@simple-module/ui/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableRow, +} from '@simple-module/ui/components/ui/table'; +import { AuthenticatedLayout } from '@simple-module/ui/layouts/AuthenticatedLayout'; +import { Activity, Box, Heart, Package, Server, Users } from 'lucide-react'; + +interface SystemModule { + name: string; + status: string; +} + +interface HealthCheck { + name: string; + status: string; +} + +interface SystemInfo { + modules: SystemModule[]; + python_version: string; + health_checks: HealthCheck[]; +} + +interface Props { + welcome: string; + total_users: number; + active_users_7d: number; + total_products: number; + module_count: number; + system_info: SystemInfo; +} + +function Home() { + const props = usePage<{ props: Props }>().props as unknown as Props; + const { t } = useT(); + + return ( + + {/* Stat Cards */} +
+ } + accent="emerald" + /> + } + accent="amber" + /> + } + accent="primary" + /> + } + accent="violet" + /> +
+ + {/* System Info */} + + + + + {t(keys.dashboard.home.system_info_title)} + + + + {/* Modules */} +
+

+ {t(keys.dashboard.home.system_info.modules)} +

+
+ {props.system_info.modules.map((mod) => ( + + + {mod.name} + + ))} +
+
+ + {/* Python Version + Health Checks */} + + + + + {t(keys.dashboard.home.system_info.python_version)} + + {props.system_info.python_version} + + {props.system_info.health_checks.map((check) => ( + + + + {check.name} + + + + + {check.status} + + + + ))} + +
+
+
+
+ ); +} + +function StatCard({ + title, + value, + icon, + accent, +}: { + title: string; + value: string; + icon: React.ReactNode; + accent: string; +}) { + const styles: Record = { + primary: { + card: 'border-primary-200 bg-gradient-to-br from-primary-50 to-card', + icon: 'text-primary-500 bg-primary-100', + value: 'text-primary-900', + }, + emerald: { + card: 'border-emerald-border bg-gradient-to-br from-emerald-bg to-card', + icon: 'text-emerald-icon-fg bg-emerald-icon-bg', + value: 'text-emerald-value', + }, + violet: { + card: 'border-violet-border bg-gradient-to-br from-violet-bg to-card', + icon: 'text-violet-icon-fg bg-violet-icon-bg', + value: 'text-violet-value', + }, + amber: { + card: 'border-amber-200 bg-gradient-to-br from-amber-50 to-card', + icon: 'text-amber-600 bg-amber-100', + value: 'text-amber-900', + }, + }; + + const s = styles[accent] || styles.primary; + + return ( + + +
+ {title} +
+ {icon} +
+
+

{value}

+
+
+ ); +} + +Home.layout = (page: React.ReactNode) => {page}; +export default Home; +``` + +- [ ] **Step 2: Verify the frontend builds** + +Run: `npm run build --workspace=host` (or the project's build command) +Expected: Build succeeds with no TypeScript errors + +- [ ] **Step 3: Commit** + +```bash +git add modules/dashboard/dashboard/pages/Home.tsx +git commit -m "feat(dashboard): rewrite Home page with real stats and system info panel" +``` + +--- + +### Task 7: Run full test suite and fix any issues + +- [ ] **Step 1: Run all dashboard tests** + +Run: `python -m pytest modules/dashboard/tests/ -v` +Expected: All tests PASS + +- [ ] **Step 2: Run integration tests that touch dashboard** + +Run: `python -m pytest tests/integration/ -v -k "dashboard or product"` +Expected: All tests PASS (product integration tests that checked old `product_events` key will fail — see next step) + +- [ ] **Step 3: Fix integration tests if needed** + +The old integration tests in `modules/dashboard/tests/test_dashboard.py` that referenced `product_events` are already replaced in Task 2. But `tests/integration/test_products_journey.py` may reference the old stats shape. Check and update if needed. + +Run: `python -m pytest tests/integration/test_products_journey.py -v` + +- [ ] **Step 4: Run linter** + +Run: `ruff check modules/dashboard/` +Expected: No errors + +- [ ] **Step 5: Commit any fixes** + +```bash +git add -A +git commit -m "fix(dashboard): resolve test and lint issues from dashboard rewrite" +``` diff --git a/docs/superpowers/specs/2026-04-15-dashboard-improvements-design.md b/docs/superpowers/specs/2026-04-15-dashboard-improvements-design.md new file mode 100644 index 00000000..3199391d --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-dashboard-improvements-design.md @@ -0,0 +1,137 @@ +# Dashboard Improvements: Real Stats + System Info + +**Date:** 2026-04-15 +**Status:** Draft +**Module:** `modules/dashboard` + +## Goal + +Replace the current placeholder dashboard with a data-rich overview showing real user/product counts and system information. Activity logging and audit trails are out of scope — a separate audit log module will handle that. + +## Current State + +The dashboard (`modules/dashboard/dashboard/pages/Home.tsx`) renders: + +- 3 stat cards with hardcoded/placeholder values (Products: "-", Users: "-", Modules: "3") +- A static welcome card +- In-memory product event counters (not persisted, lost on restart) + +The backend view endpoint passes only a `welcome` string to the frontend via Inertia. + +## Design + +### Section 1: Stat Cards (replace placeholders with real data) + +Four stat cards in a responsive grid: + +| Card | Source | Query | +|------|--------|-------| +| **Total Users** | `users_user` table | `SELECT COUNT(*) FROM users_user` | +| **Active Users** (7d) | `users_user.last_login_at` | `WHERE last_login_at >= NOW() - INTERVAL '7 days'` | +| **Products** | `products_product` table | `SELECT COUNT(*) FROM products_product` | +| **Modules** | `discover_modules()` | `len(discover_modules())` | + +The stat card grid changes from 3 to 4 columns: `grid-cols-1 sm:grid-cols-2 md:grid-cols-4`. + +Active Users gets a new accent color (amber) to differentiate from Total Users (emerald stays for total). + +### Section 2: System Info Panel + +A card displayed below the stat cards showing: + +- **Loaded modules** — name and status badge (green dot) for each discovered module +- **Python version** — from `sys.version` +- **Health checks** — results from `app.state.health_registry` with green/yellow/red status indicators + +### Section 3: Dashboard Stats API (updated) + +Expand `GET /api/dashboard/stats` to return all data the frontend needs: + +```python +@router.get("/stats") +async def dashboard_stats(request: Request, db: AsyncSession = Depends(get_db)) -> dict: + return { + "total_users": , + "active_users_7d": = 7 days ago>, + "total_products": , + "module_count": , + "system_info": { + "modules": [ + {"name": "Auth", "status": "loaded"}, + {"name": "Users", "status": "loaded"}, + {"name": "Products", "status": "loaded"}, + {"name": "Dashboard", "status": "loaded"}, + ], + "python_version": "3.12.x", + "health_checks": [ + {"name": "database", "status": "healthy"}, + ], + }, + } +``` + +Cross-module DB queries: The stats endpoint queries `users_user` and `products_product` tables directly (same database). This is a read-only cross-module query, acceptable for an aggregation endpoint. + +### Section 4: Dashboard View Endpoint (updated) + +The Inertia view endpoint (`GET /dashboard`) passes all stats as page props: + +```python +@router.get("/") +async def dashboard(inertia: InertiaDep, t: TranslatorDep, db: ...) -> InertiaResponse: + stats = await fetch_dashboard_stats(db, request) + return await inertia.render("Dashboard/Home", { + "welcome": t.t("dashboard.home.welcome_message"), + **stats, + }) +``` + +### Section 5: Frontend — Home.tsx (rewritten) + +The page receives all stats as Inertia page props and renders: + +1. **Stat Cards Row** — 4-column responsive grid with real values +2. **System Info Panel** — card listing modules, Python version, health checks + +**System Info Panel:** +- Card listing loaded modules with status badges (green dot for loaded) +- Python version display +- Health check results with status indicator (green/yellow/red dot) +- Wrapped in a `Card` with title "System" + +### Section 6: i18n Updates + +Add new translation keys to `modules/dashboard/dashboard/locales/en.json` (and `es.json`): + +- `stats.active_users`: "Active Users (7d)" +- `system_info_title`: "System" +- `system_info.modules`: "Modules" +- `system_info.python_version`: "Python Version" +- `system_info.health_checks`: "Health Checks" + +### Section 7: Cleanup + +Remove the in-memory product event counters from `handlers.py` since they serve no purpose without the activity log. The dashboard module still depends on Products (for the product count query) but no longer subscribes to product events. + +## Files to Modify + +| File | Changes | +|------|---------| +| `modules/dashboard/dashboard/handlers.py` | Remove in-memory counters (file may become empty/deleted) | +| `modules/dashboard/dashboard/module.py` | Remove event handler subscriptions, add `depends_on=["Users"]` | +| `modules/dashboard/dashboard/endpoints/api.py` | Expand `/stats` with real counts + system info | +| `modules/dashboard/dashboard/endpoints/views.py` | Pass full stats as Inertia props | +| `modules/dashboard/dashboard/pages/Home.tsx` | Rewrite with real stat cards + system info panel | +| `modules/dashboard/dashboard/locales/en.json` | Add new translation keys | +| `modules/dashboard/dashboard/locales/es.json` | Add new translation keys | +| `modules/dashboard/pyproject.toml` | Add dependency on `users` | +| `modules/dashboard/tests/test_dashboard.py` | Rewrite tests for new stats endpoint | + +## Out of Scope + +- Activity/audit logging (separate module) +- Event handler persistence +- Recent activity feed +- Activity charts +- Real-time updates +- Dashboard customization diff --git a/framework/hosting/simple_module_hosting/app_builder.py b/framework/hosting/simple_module_hosting/app_builder.py index 746f30ac..3d4124d4 100644 --- a/framework/hosting/simple_module_hosting/app_builder.py +++ b/framework/hosting/simple_module_hosting/app_builder.py @@ -158,6 +158,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: lifespan=lifespan, ) + app.state.modules = modules app.state.menu_registry = menu_registry app.state.perm_registry = perm_registry app.state.ff_registry = ff_registry diff --git a/host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py b/host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py new file mode 100644 index 00000000..24bc54c9 --- /dev/null +++ b/host/migrations/versions/a01185374312_add_indexes_for_dashboard_queries.py @@ -0,0 +1,34 @@ +"""add indexes for dashboard queries + +Revision ID: a01185374312 +Revises: e3ce9754e6dc +Create Date: 2026-04-15 23:53:08.569085 +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a01185374312" +down_revision: str | None = "e3ce9754e6dc" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_index( + op.f("ix_products_product_is_active"), "products_product", ["is_active"], unique=False + ) + op.create_index( + op.f("ix_users_user_last_login_at"), "users_user", ["last_login_at"], unique=False + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_users_user_last_login_at"), table_name="users_user") + op.drop_index(op.f("ix_products_product_is_active"), table_name="products_product") + # ### end Alembic commands ### diff --git a/modules/dashboard/dashboard/endpoints/api.py b/modules/dashboard/dashboard/endpoints/api.py index e387651b..39256c29 100644 --- a/modules/dashboard/dashboard/endpoints/api.py +++ b/modules/dashboard/dashboard/endpoints/api.py @@ -2,16 +2,16 @@ from __future__ import annotations -from fastapi import APIRouter +from fastapi import APIRouter, Depends, Request +from simple_module_db.deps import get_db +from sqlalchemy.ext.asyncio import AsyncSession -from dashboard.handlers import get_product_event_counts +from dashboard.stats import fetch_dashboard_stats router = APIRouter() @router.get("/stats") -async def dashboard_stats() -> dict: - """Return dashboard statistics including product event counts.""" - return { - "product_events": get_product_event_counts(), - } +async def dashboard_stats(request: Request, db: AsyncSession = Depends(get_db)) -> dict: + """Return dashboard statistics including user counts and system info.""" + return await fetch_dashboard_stats(db, request.app) diff --git a/modules/dashboard/dashboard/endpoints/views.py b/modules/dashboard/dashboard/endpoints/views.py index d9b465af..90a7052b 100644 --- a/modules/dashboard/dashboard/endpoints/views.py +++ b/modules/dashboard/dashboard/endpoints/views.py @@ -6,20 +6,31 @@ from __future__ import annotations -from fastapi import APIRouter +from fastapi import APIRouter, Depends, Request from inertia import InertiaResponse +from simple_module_db.deps import get_db from simple_module_hosting.i18n_deps import TranslatorDep from simple_module_hosting.inertia_deps import InertiaDep +from sqlalchemy.ext.asyncio import AsyncSession + +from dashboard.stats import fetch_dashboard_stats router = APIRouter() @router.get("/", response_model=None) -async def dashboard(inertia: InertiaDep, t: TranslatorDep) -> InertiaResponse: +async def dashboard( + request: Request, + inertia: InertiaDep, + t: TranslatorDep, + db: AsyncSession = Depends(get_db), +) -> InertiaResponse: """Authenticated dashboard — requires login (enforced by AuthMiddleware).""" + stats = await fetch_dashboard_stats(db, request.app) return await inertia.render( "Dashboard/Home", { "welcome": t.t("dashboard.home.welcome_message"), + **stats, }, ) diff --git a/modules/dashboard/dashboard/handlers.py b/modules/dashboard/dashboard/handlers.py deleted file mode 100644 index c4611fbd..00000000 --- a/modules/dashboard/dashboard/handlers.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Event handlers for the Dashboard module. - -Subscribes to product domain events to maintain real-time stats -without direct coupling to the Products module's internals. -""" - -from __future__ import annotations - -import logging - -from products.contracts.events import ProductCreated, ProductDeleted, ProductUpdated - -logger = logging.getLogger(__name__) - -_product_event_counts: dict[str, int] = { - "created": 0, - "updated": 0, - "deleted": 0, -} - - -async def on_product_created(event: ProductCreated) -> None: - _product_event_counts["created"] += 1 - logger.info("Dashboard received ProductCreated: %s (id=%d)", event.name, event.product_id) - - -async def on_product_updated(event: ProductUpdated) -> None: - _product_event_counts["updated"] += 1 - logger.info("Dashboard received ProductUpdated: %s (id=%d)", event.name, event.product_id) - - -async def on_product_deleted(event: ProductDeleted) -> None: - _product_event_counts["deleted"] += 1 - logger.info("Dashboard received ProductDeleted: id=%d", event.product_id) - - -def get_product_event_counts() -> dict[str, int]: - """Return a snapshot of product event counts.""" - return dict(_product_event_counts) - - -def reset_product_event_counts() -> None: - """Reset counters — useful for testing.""" - _product_event_counts["created"] = 0 - _product_event_counts["updated"] = 0 - _product_event_counts["deleted"] = 0 diff --git a/modules/dashboard/dashboard/locales/en.json b/modules/dashboard/dashboard/locales/en.json index 27a6168b..5aa421af 100644 --- a/modules/dashboard/dashboard/locales/en.json +++ b/modules/dashboard/dashboard/locales/en.json @@ -3,10 +3,17 @@ "title": "Dashboard", "description": "Overview of your application", "stats": { + "total_users": "Total Users", + "active_users": "Active Users (7d)", "products": "Products", - "users": "Users", "modules": "Modules" }, + "system_info_title": "System", + "system_info": { + "modules": "Modules", + "python_version": "Python Version", + "health_checks": "Health Checks" + }, "welcome_card_title": "Welcome", "welcome_message": "Welcome to SimpleModule", "description_body": "This is a modular monolith built with FastAPI, Inertia.js, and React. Each module provides its own pages, API endpoints, and database schema." diff --git a/modules/dashboard/dashboard/locales/es.json b/modules/dashboard/dashboard/locales/es.json index 2ceade3c..207975f6 100644 --- a/modules/dashboard/dashboard/locales/es.json +++ b/modules/dashboard/dashboard/locales/es.json @@ -3,10 +3,17 @@ "title": "Panel", "description": "Resumen de tu aplicación", "stats": { + "total_users": "Usuarios Totales", + "active_users": "Usuarios Activos (7d)", "products": "Productos", - "users": "Usuarios", "modules": "Módulos" }, + "system_info_title": "Sistema", + "system_info": { + "modules": "Módulos", + "python_version": "Versión de Python", + "health_checks": "Verificaciones de Salud" + }, "welcome_card_title": "Bienvenido", "welcome_message": "Bienvenido a SimpleModule", "description_body": "Este es un monolito modular construido con FastAPI, Inertia.js y React. Cada módulo proporciona sus propias páginas, endpoints de API y esquema de base de datos." diff --git a/modules/dashboard/dashboard/module.py b/modules/dashboard/dashboard/module.py index 36001fac..17fa7070 100644 --- a/modules/dashboard/dashboard/module.py +++ b/modules/dashboard/dashboard/module.py @@ -6,20 +6,16 @@ from pathlib import Path from fastapi import APIRouter -from products.contracts.events import ProductCreated, ProductDeleted, ProductUpdated -from simple_module_core.events import EventBus from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection from simple_module_core.module import ModuleBase, ModuleMeta -from dashboard.handlers import on_product_created, on_product_deleted, on_product_updated - class DashboardModule(ModuleBase): meta = ModuleMeta( name="Dashboard", route_prefix="/api/dashboard", view_prefix="/dashboard", - depends_on=["Products"], + depends_on=["Products", "Users"], ) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: @@ -40,10 +36,5 @@ def register_menu_items(self, registry: MenuRegistry) -> None: ) ) - def register_event_handlers(self, bus: EventBus) -> None: - bus.subscribe(ProductCreated, on_product_created) - bus.subscribe(ProductUpdated, on_product_updated) - bus.subscribe(ProductDeleted, on_product_deleted) - def locale_dirs(self) -> dict[str, Path]: return {"dashboard": Path(str(importlib.resources.files(__package__) / "locales"))} diff --git a/modules/dashboard/dashboard/pages/Home.tsx b/modules/dashboard/dashboard/pages/Home.tsx index c57b3321..acf64e2d 100644 --- a/modules/dashboard/dashboard/pages/Home.tsx +++ b/modules/dashboard/dashboard/pages/Home.tsx @@ -1,23 +1,45 @@ import { usePage } from '@inertiajs/react'; import { keys, useT } from '@simple-module/i18n'; import { PageShell } from '@simple-module/ui/components/PageShell'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@simple-module/ui/components/ui/card'; -import { Separator } from '@simple-module/ui/components/ui/separator'; +import { Card, CardContent, CardHeader, CardTitle } from '@simple-module/ui/components/ui/card'; +import { Table, TableBody, TableCell, TableRow } from '@simple-module/ui/components/ui/table'; import { AuthenticatedLayout } from '@simple-module/ui/layouts/AuthenticatedLayout'; -import { Box, Package, Users } from 'lucide-react'; +import { Activity, Box, Heart, Package, Server, Users } from 'lucide-react'; + +type Accent = 'primary' | 'emerald' | 'violet' | 'amber'; + +const HEALTH_STATUS_COLOR: Record = { + healthy: 'bg-emerald-500', + degraded: 'bg-amber-500', + unhealthy: 'bg-red-500', +}; + +interface SystemModule { + name: string; + status: 'loaded'; +} + +interface HealthCheck { + name: string; + status: 'healthy' | 'degraded' | 'unhealthy'; +} + +interface SystemInfo { + modules: SystemModule[]; + python_version: string; + health_checks: HealthCheck[]; +} interface Props { - welcome: string; + total_users: number; + active_users_7d: number; + total_products: number; + module_count: number; + system_info: SystemInfo; } function Home() { - const { welcome } = usePage<{ props: Props }>().props as unknown as Props; + const props = usePage<{ props: Props }>().props as unknown as Props; const { t } = useT(); return ( @@ -25,22 +47,28 @@ function Home() { title={t(keys.dashboard.home.title)} description={t(keys.dashboard.home.description)} > -
+
+ } + accent="emerald" + /> + } + accent="amber" + /> } accent="primary" /> - } - accent="emerald" - /> } accent="violet" /> @@ -48,14 +76,55 @@ function Home() { - - {t(keys.dashboard.home.welcome_card_title)} + + + {t(keys.dashboard.home.system_info_title)} - {welcome} - - -

{t(keys.dashboard.home.description_body)}

+ +
+

+ {t(keys.dashboard.home.system_info.modules)} +

+
+ {props.system_info.modules.map((mod) => ( + + + {mod.name} + + ))} +
+
+ + + + + + {t(keys.dashboard.home.system_info.python_version)} + + {props.system_info.python_version} + + {props.system_info.health_checks.map((check) => ( + + + + {check.name} + + + + + {check.status} + + + + ))} + +
@@ -71,9 +140,9 @@ function StatCard({ title: string; value: string; icon: React.ReactNode; - accent: string; + accent: Accent; }) { - const styles: Record = { + const styles: Record = { primary: { card: 'border-primary-200 bg-gradient-to-br from-primary-50 to-card', icon: 'text-primary-500 bg-primary-100', @@ -89,9 +158,14 @@ function StatCard({ icon: 'text-violet-icon-fg bg-violet-icon-bg', value: 'text-violet-value', }, + amber: { + card: 'border-amber-200 bg-gradient-to-br from-amber-50 to-card', + icon: 'text-amber-600 bg-amber-100', + value: 'text-amber-900', + }, }; - const s = styles[accent] || styles.primary; + const s = styles[accent]; return ( diff --git a/modules/dashboard/dashboard/stats.py b/modules/dashboard/dashboard/stats.py new file mode 100644 index 00000000..7ca42454 --- /dev/null +++ b/modules/dashboard/dashboard/stats.py @@ -0,0 +1,117 @@ +"""Dashboard statistics queries with TTL-based caching.""" + +from __future__ import annotations + +import asyncio +import sys +import time +from datetime import UTC, datetime, timedelta + +from fastapi import FastAPI +from products.models import Product +from simple_module_core.health import HealthCheck, HealthStatus +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from users.models import User + +_CACHE_TTL_SECONDS = 30 +_cache: dict | None = None +_cache_ts: float = 0.0 +_cache_lock = asyncio.Lock() + + +def _cache_hit() -> dict | None: + if _cache is not None and (time.monotonic() - _cache_ts) < _CACHE_TTL_SECONDS: + return _cache.copy() + return None + + +async def fetch_dashboard_stats(db: AsyncSession, app: FastAPI) -> dict: + """Gather all dashboard statistics, cached for 30 seconds.""" + global _cache, _cache_ts + + hit = _cache_hit() + if hit is not None: + return hit + + async with _cache_lock: + # Re-check after acquiring lock — another coroutine may have refreshed. + hit = _cache_hit() + if hit is not None: + return hit + + total_users = await _count_users(db) + active_users_7d = await _count_active_users(db, days=7) + total_products = await _count_products(db) + modules_list = _get_module_info(app) + health_checks = await _run_health_checks(app) + + result = { + "total_users": total_users, + "active_users_7d": active_users_7d, + "total_products": total_products, + "module_count": len(modules_list), + "system_info": { + "modules": modules_list, + "python_version": ( + f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + ), + "health_checks": health_checks, + }, + } + + _cache = result + _cache_ts = time.monotonic() + return result.copy() + + +def invalidate_stats_cache() -> None: + """Clear the stats cache — useful for testing or after data mutations.""" + global _cache, _cache_ts + _cache = None + _cache_ts = 0.0 + + +async def _count_users(db: AsyncSession) -> int: + result = await db.execute(select(func.count()).select_from(User)) + return result.scalar_one() + + +async def _count_active_users(db: AsyncSession, *, days: int) -> int: + cutoff = datetime.now(UTC) - timedelta(days=days) + result = await db.execute( + select(func.count()).select_from(User).where(User.last_login_at >= cutoff) + ) + return result.scalar_one() + + +async def _count_products(db: AsyncSession) -> int: + result = await db.execute( + select(func.count()).select_from(Product).where(Product.is_active.is_(True)) + ) + return result.scalar_one() + + +def _get_module_info(app: FastAPI) -> list[dict[str, str]]: + # Reads from the module list discovered once at startup, avoiding + # expensive entry-point rescans on every request. + modules = getattr(app.state, "modules", None) + if modules is None: + return [] + return [{"name": m.meta.name, "status": "loaded"} for m in modules] + + +async def _run_health_checks(app: FastAPI) -> list[dict[str, str]]: + registry = app.state.health_registry + checks = registry.all_checks + if not checks: + return [] + + async def _run_one(check: HealthCheck) -> dict[str, str]: + try: + result = await check.check() + return {"name": check.name, "status": result.status.value} + except Exception: + return {"name": check.name, "status": HealthStatus.UNHEALTHY.value} + + return list(await asyncio.gather(*[_run_one(c) for c in checks])) diff --git a/modules/dashboard/pyproject.toml b/modules/dashboard/pyproject.toml index d924568c..a9824cd7 100644 --- a/modules/dashboard/pyproject.toml +++ b/modules/dashboard/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "simple-module-db", "simple-module-hosting", "products", + "users", ] [project.entry-points.simple_module] @@ -30,3 +31,4 @@ simple-module-core = { workspace = true } simple-module-db = { workspace = true } simple-module-hosting = { workspace = true } products = { workspace = true } +users = { workspace = true } diff --git a/modules/dashboard/tests/test_dashboard.py b/modules/dashboard/tests/test_dashboard.py index 45e05f0f..1cf23e55 100644 --- a/modules/dashboard/tests/test_dashboard.py +++ b/modules/dashboard/tests/test_dashboard.py @@ -1,73 +1,19 @@ -"""Tests for the Dashboard module: event handlers, stats endpoint, and -end-to-end event-bus wiring between Products and Dashboard.""" +"""Tests for the Dashboard module: stats endpoint and module registration.""" from __future__ import annotations import httpx import pytest -from dashboard.handlers import ( - get_product_event_counts, - on_product_created, - on_product_deleted, - on_product_updated, - reset_product_event_counts, -) from dashboard.module import DashboardModule -from products.contracts.events import ProductCreated, ProductDeleted, ProductUpdated -from simple_module_core.events import EventBus +from dashboard.stats import invalidate_stats_cache @pytest.fixture(autouse=True) -def _reset_counts(): - """Ensure every test starts with zeroed product event counters.""" - reset_product_event_counts() +def _clear_stats_cache(): + """Ensure each test gets fresh stats, not a cached result.""" + invalidate_stats_cache() yield - reset_product_event_counts() - - -# ── Handler unit tests ─────────────────────────────────────────────── - - -class TestDashboardHandlers: - async def test_on_product_created_increments_counter(self): - await on_product_created(ProductCreated(product_id=1, name="Widget")) - counts = get_product_event_counts() - assert counts["created"] == 1 - assert counts["updated"] == 0 - assert counts["deleted"] == 0 - - async def test_on_product_updated_increments_counter(self): - await on_product_updated(ProductUpdated(product_id=1, name="Widget")) - counts = get_product_event_counts() - assert counts["updated"] == 1 - assert counts["created"] == 0 - - async def test_on_product_deleted_increments_counter(self): - await on_product_deleted(ProductDeleted(product_id=1)) - counts = get_product_event_counts() - assert counts["deleted"] == 1 - - async def test_multiple_events_accumulate(self): - await on_product_created(ProductCreated(product_id=1, name="A")) - await on_product_created(ProductCreated(product_id=2, name="B")) - await on_product_updated(ProductUpdated(product_id=1, name="A2")) - counts = get_product_event_counts() - assert counts["created"] == 2 - assert counts["updated"] == 1 - assert counts["deleted"] == 0 - - async def test_get_product_event_counts_returns_snapshot(self): - """Returned dict should be a copy, not the internal store.""" - counts = get_product_event_counts() - counts["created"] = 999 - assert get_product_event_counts()["created"] == 0 - - async def test_reset_clears_all_counts(self): - await on_product_created(ProductCreated(product_id=1, name="X")) - await on_product_deleted(ProductDeleted(product_id=1)) - reset_product_event_counts() - counts = get_product_event_counts() - assert counts == {"created": 0, "updated": 0, "deleted": 0} + invalidate_stats_cache() # ── Module registration tests ──────────────────────────────────────── @@ -79,127 +25,79 @@ async def test_module_meta(self): assert mod.meta.name == "Dashboard" assert mod.meta.route_prefix == "/api/dashboard" assert "Products" in mod.meta.depends_on + assert "Users" in mod.meta.depends_on - async def test_register_event_handlers_subscribes_to_all_product_events(self): - bus = EventBus() - mod = DashboardModule() - mod.register_event_handlers(bus) - await bus.publish(ProductCreated(product_id=1, name="Widget")) - await bus.publish(ProductUpdated(product_id=1, name="Widget v2")) - await bus.publish(ProductDeleted(product_id=1)) +# ── Stats function unit tests ──────────────────────────────────────── - counts = get_product_event_counts() - assert counts == {"created": 1, "updated": 1, "deleted": 1} +class TestFetchDashboardStats: + @pytest.fixture + async def stats(self, app): + from dashboard.stats import fetch_dashboard_stats -# ── Stats API endpoint ────────────────────────────────────────────── + async with app.state.db.session_factory() as db: + return await fetch_dashboard_stats(db, app) + async def test_returns_expected_keys(self, stats): + assert "total_users" in stats + assert "active_users_7d" in stats + assert "total_products" in stats + assert "module_count" in stats + assert "system_info" in stats -class TestDashboardStatsEndpoint: - async def test_stats_returns_zero_counts_initially( - self, authenticated_client: httpx.AsyncClient - ): - resp = await authenticated_client.get("/api/dashboard/stats") - assert resp.status_code == 200 - body = resp.json() - assert body == {"product_events": {"created": 0, "updated": 0, "deleted": 0}} + async def test_total_users_is_non_negative_int(self, stats): + assert isinstance(stats["total_users"], int) + assert stats["total_users"] >= 0 - async def test_stats_reflects_handler_activity(self, authenticated_client: httpx.AsyncClient): - await on_product_created(ProductCreated(product_id=1, name="X")) - await on_product_updated(ProductUpdated(product_id=1, name="X")) + async def test_module_count_is_positive(self, stats): + assert stats["module_count"] >= 1 - resp = await authenticated_client.get("/api/dashboard/stats") - assert resp.status_code == 200 - body = resp.json() - assert body["product_events"]["created"] == 1 - assert body["product_events"]["updated"] == 1 - assert body["product_events"]["deleted"] == 0 + async def test_system_info_contains_modules_list(self, stats): + sys_info = stats["system_info"] + assert isinstance(sys_info["modules"], list) + assert len(sys_info["modules"]) >= 1 + assert "name" in sys_info["modules"][0] + assert "status" in sys_info["modules"][0] - async def test_stats_requires_authentication(self, client: httpx.AsyncClient): - """Unauthenticated requests should be redirected by AuthMiddleware.""" - resp = await client.get("/api/dashboard/stats", follow_redirects=False) - assert resp.status_code in (302, 401, 403) + async def test_system_info_contains_python_version(self, stats): + assert "." in stats["system_info"]["python_version"] + async def test_system_info_contains_health_checks(self, stats): + assert isinstance(stats["system_info"]["health_checks"], list) -# ── End-to-end: Product API → EventBus → Dashboard handler ────────── +# ── Stats API endpoint ────────────────────────────────────────────── -class TestProductEventIntegration: - async def test_create_product_increments_dashboard_counter( - self, authenticated_client: httpx.AsyncClient - ): - resp = await authenticated_client.post( - "/api/products/", - json={"name": "EventTestWidget", "price": "12.34"}, - ) - assert resp.status_code == 201 +_STATS_URL = "/api/dashboard/stats" - stats = await authenticated_client.get("/api/dashboard/stats") - assert stats.json()["product_events"]["created"] == 1 - async def test_update_product_increments_dashboard_counter( - self, authenticated_client: httpx.AsyncClient - ): - create = await authenticated_client.post( - "/api/products/", - json={"name": "Original", "price": "1.00"}, - ) - product_id = create.json()["id"] - reset_product_event_counts() - - resp = await authenticated_client.put( - f"/api/products/{product_id}", - json={"name": "Updated"}, - ) +class TestDashboardStatsEndpoint: + async def test_stats_returns_all_fields(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get(_STATS_URL) assert resp.status_code == 200 + body = resp.json() + assert "total_users" in body + assert "active_users_7d" in body + assert "total_products" in body + assert "module_count" in body + assert "system_info" in body - stats = await authenticated_client.get("/api/dashboard/stats") - assert stats.json()["product_events"]["updated"] == 1 - - async def test_delete_product_increments_dashboard_counter( + async def test_stats_total_users_includes_seeded_admin( self, authenticated_client: httpx.AsyncClient ): - create = await authenticated_client.post( - "/api/products/", - json={"name": "Doomed", "price": "1.00"}, - ) - product_id = create.json()["id"] - reset_product_event_counts() - - resp = await authenticated_client.delete(f"/api/products/{product_id}") - assert resp.status_code == 204 - - stats = await authenticated_client.get("/api/dashboard/stats") - assert stats.json()["product_events"]["deleted"] == 1 - - async def test_failed_update_does_not_emit_event(self, authenticated_client: httpx.AsyncClient): - """404s should not publish ProductUpdated — handler logic must be after the lookup.""" - resp = await authenticated_client.put( - "/api/products/999999", - json={"name": "ghost"}, - ) - assert resp.status_code == 404 - - stats = await authenticated_client.get("/api/dashboard/stats") - assert stats.json()["product_events"]["updated"] == 0 - - async def test_failed_delete_does_not_emit_event(self, authenticated_client: httpx.AsyncClient): - resp = await authenticated_client.delete("/api/products/999999") - assert resp.status_code == 404 - - stats = await authenticated_client.get("/api/dashboard/stats") - assert stats.json()["product_events"]["deleted"] == 0 - - async def test_full_lifecycle_counters(self, authenticated_client: httpx.AsyncClient): - create = await authenticated_client.post( - "/api/products/", - json={"name": "Lifecycle", "price": "1.00"}, - ) - pid = create.json()["id"] - await authenticated_client.put(f"/api/products/{pid}", json={"name": "L2"}) - await authenticated_client.delete(f"/api/products/{pid}") - - stats = await authenticated_client.get("/api/dashboard/stats") - counts = stats.json()["product_events"] - assert counts == {"created": 1, "updated": 1, "deleted": 1} + resp = await authenticated_client.get(_STATS_URL) + body = resp.json() + assert body["total_users"] >= 1 + + async def test_stats_system_info_has_modules(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get(_STATS_URL) + body = resp.json() + modules = body["system_info"]["modules"] + assert len(modules) >= 1 + names = [m["name"] for m in modules] + assert "Dashboard" in names + + async def test_stats_requires_authentication(self, client: httpx.AsyncClient): + resp = await client.get(_STATS_URL, follow_redirects=False) + assert resp.status_code in (302, 401, 403) diff --git a/modules/products/products/models.py b/modules/products/products/models.py index 68879e5d..a10c434b 100644 --- a/modules/products/products/models.py +++ b/modules/products/products/models.py @@ -24,4 +24,4 @@ class Product(Base, AuditMixin, SoftDeleteMixin): # ty: ignore[unsupported-base name: Mapped[str] = mapped_column(String(200)) description: Mapped[str | None] = mapped_column(String(2000), default=None) price: Mapped[Decimal] = mapped_column(Numeric(10, 2)) - is_active: Mapped[bool] = mapped_column(default=True) + is_active: Mapped[bool] = mapped_column(default=True, index=True) diff --git a/modules/products/tests/test_products.py b/modules/products/tests/test_products.py index b7c2cbda..751494f6 100644 --- a/modules/products/tests/test_products.py +++ b/modules/products/tests/test_products.py @@ -11,6 +11,24 @@ from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession +# ── Model index guards ─────────────────────────────────────────────── + + +def _has_index_on(model, column_name: str) -> bool: + """Check that the model's table has an index covering the given column.""" + table = model.__table__ + return any(column_name in {c.name for c in idx.columns} for idx in table.indexes) + + +class TestProductModelIndexes: + def test_is_active_is_indexed(self): + from products.models import Product + + assert _has_index_on(Product, "is_active"), ( + "Product.is_active must be indexed (used by dashboard product count query)" + ) + + # ── Schema validation ──────────────────────────────────────────────── diff --git a/modules/users/tests/test_models.py b/modules/users/tests/test_models.py index 911a11f4..f38f8597 100644 --- a/modules/users/tests/test_models.py +++ b/modules/users/tests/test_models.py @@ -23,9 +23,7 @@ def column_names(table) -> set[str]: return {c.key for c in inspect(table).mapper.column_attrs} -# --------------------------------------------------------------------------- -# Model structure tests -# --------------------------------------------------------------------------- +# ── Model structure tests ───────────────────────────────────────────── class TestUserTableShape: @@ -34,6 +32,13 @@ def test_tablename(self): assert User.__tablename__ == "users_user" + def test_last_login_at_is_indexed(self): + from users.models import User + + assert any( + "last_login_at" in {c.name for c in i.columns} for i in User.__table__.indexes + ), "User.last_login_at must be indexed" + def test_required_columns(self): from users.models import User diff --git a/modules/users/users/models.py b/modules/users/users/models.py index 20dda909..ea9f917d 100644 --- a/modules/users/users/models.py +++ b/modules/users/users/models.py @@ -33,7 +33,9 @@ class User(SQLAlchemyBaseUserTableUUID, Base, AuditMixin): # ty: ignore[unsuppo full_name: Mapped[str | None] = mapped_column(String(255), default=None) tenant_id: Mapped[str | None] = mapped_column(String(50), index=True, default=None) disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) - last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) + last_login_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), index=True, default=None + ) roles: Mapped[list[Role]] = relationship( secondary="users_user_role", diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 8394ea68..18d4a801 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -7,9 +7,14 @@ export default { 'auth.errors.not_authenticated': '', 'dashboard.home.description': '', 'dashboard.home.description_body': '', + 'dashboard.home.stats.active_users': '', 'dashboard.home.stats.modules': '', 'dashboard.home.stats.products': '', - 'dashboard.home.stats.users': '', + 'dashboard.home.stats.total_users': '', + 'dashboard.home.system_info.health_checks': '', + 'dashboard.home.system_info.modules': '', + 'dashboard.home.system_info.python_version': '', + 'dashboard.home.system_info_title': '', 'dashboard.home.title': '', 'dashboard.home.welcome_card_title': '', 'dashboard.home.welcome_message': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index 225cd8da..d0ee311e 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -13,10 +13,17 @@ export const keys = { description: 'dashboard.home.description', description_body: 'dashboard.home.description_body', stats: { + active_users: 'dashboard.home.stats.active_users', modules: 'dashboard.home.stats.modules', products: 'dashboard.home.stats.products', - users: 'dashboard.home.stats.users', + total_users: 'dashboard.home.stats.total_users', }, + system_info: { + health_checks: 'dashboard.home.system_info.health_checks', + modules: 'dashboard.home.system_info.modules', + python_version: 'dashboard.home.system_info.python_version', + }, + system_info_title: 'dashboard.home.system_info_title', title: 'dashboard.home.title', welcome_card_title: 'dashboard.home.welcome_card_title', welcome_message: 'dashboard.home.welcome_message',