diff --git a/app/plugins/builtins/calendar/plugin.py b/app/plugins/builtins/calendar/plugin.py index 5c66209..2b869a2 100644 --- a/app/plugins/builtins/calendar/plugin.py +++ b/app/plugins/builtins/calendar/plugin.py @@ -4,6 +4,7 @@ from __future__ import annotations from app.plugins.base import BasePlugin from app.plugins.manifest import ( + FrontendDashboardWidget, FrontendDetailTab, FrontendMenuItem, FrontendPageRoute, @@ -40,6 +41,18 @@ class CalendarPlugin(BasePlugin): ], events=[], migrations=["0001_initial.sql", "0002_add_deleted_at.sql"], + dashboard_widgets=[ + FrontendDashboardWidget( + id="calendar_upcoming", + label_key="dashboard.calendarUpcoming", + label="Upcoming Appointments", + component="@/components/dashboard/CalendarUpcomingWidget", + icon="Calendar", + order=30, + col_span=1, + permission="calendar:read", + ), + ], permissions=[ "calendar:read", "calendar:write", diff --git a/app/plugins/builtins/contacts/contracts.py b/app/plugins/builtins/contacts/contracts.py new file mode 100644 index 0000000..a1be526 --- /dev/null +++ b/app/plugins/builtins/contacts/contracts.py @@ -0,0 +1,62 @@ +"""Public contract for the contacts plugin. + +Exposes the symbols that other core modules and plugins need without +importing from internal modules directly (Block C7: dashboard counts). +""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.visibility import apply_visibility_filter +from app.models.contact import Contact +from app.plugins.builtins.contracts import get_contract_registry + + +class ContactsContract: + """Public API surface for the contacts plugin.""" + + contract_name = "contacts" + + @staticmethod + async def get_counts( + db: AsyncSession, + tenant_id: Any, + user_id: Any, + is_system_admin: bool = False, + ) -> dict[str, int]: + """Return visibility-filtered contact/company/person counts.""" + queries = [] + for type_filter in (None, "company", "person"): + query = select(func.count(Contact.id)).where( + Contact.tenant_id == tenant_id, + Contact.deleted_at.is_(None), + ) + if type_filter is not None: + query = query.where(Contact.type == type_filter) + query = await apply_visibility_filter( + db, query, "contact", Contact, user_id, tenant_id, is_system_admin + ) + queries.append(query) + + results = [((await db.execute(q)).scalar() or 0) for q in queries] + return { + "contacts": results[0], + "companies": results[1], + "persons": results[2], + "total": results[0], + } + + @classmethod + def get_function(cls, name: str): + """Return a callable exposed by this contract, or None if absent.""" + return getattr(cls, name, None) + + +# ─── self-registration ─── + +_contract = ContactsContract() +get_contract_registry().register("contacts", _contract) diff --git a/app/plugins/builtins/contacts/plugin.py b/app/plugins/builtins/contacts/plugin.py index 4aec111..c7861c5 100644 --- a/app/plugins/builtins/contacts/plugin.py +++ b/app/plugins/builtins/contacts/plugin.py @@ -9,7 +9,11 @@ from __future__ import annotations import logging from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest, PluginRouteDef +from app.plugins.manifest import ( + FrontendDashboardWidget, + PluginManifest, + PluginRouteDef, +) logger = logging.getLogger(__name__) @@ -54,6 +58,18 @@ class ContactsPlugin(BasePlugin): ], events=[], migrations=[], + dashboard_widgets=[ + FrontendDashboardWidget( + id="recent_contacts", + label_key="dashboard.recentContacts", + label="Recent Contacts", + component="@/components/dashboard/RecentContactsWidget", + icon="Users", + order=10, + col_span=2, + permission="contacts:read", + ), + ], permissions=[ "contacts:read", "contacts:write", diff --git a/app/plugins/builtins/tasks/plugin.py b/app/plugins/builtins/tasks/plugin.py index 773158a..7ffa6ad 100644 --- a/app/plugins/builtins/tasks/plugin.py +++ b/app/plugins/builtins/tasks/plugin.py @@ -5,6 +5,7 @@ from __future__ import annotations from app.plugins.base import BasePlugin from app.plugins.manifest import ( CronJobContribution, + FrontendDashboardWidget, FrontendMenuItem, FrontendPageRoute, PluginManifest, @@ -30,6 +31,18 @@ class TasksPlugin(BasePlugin): ], events=[], migrations=["0001_initial.sql", "0002_unified_task_system.sql"], + dashboard_widgets=[ + FrontendDashboardWidget( + id="tasks_summary", + label_key="dashboard.tasksSummary", + label="Tasks Summary", + component="@/components/dashboard/TasksSummaryWidget", + icon="CheckSquare", + order=20, + col_span=1, + permission="tasks:read", + ), + ], permissions=[ "tasks:read", "tasks:write", diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 8d5d926..368db47 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -5,13 +5,11 @@ from __future__ import annotations import uuid from fastapi import APIRouter, Depends -from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db -from app.core.visibility import apply_visibility_filter from app.deps import require_permission -from app.models.contact import Contact +from app.plugins.builtins.contracts import get_contract from app.plugins.registry import get_registry router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"]) @@ -50,51 +48,16 @@ async def get_dashboard_counts( ): """Get dashboard count statistics filtered by user visibility. - Returns counts for contacts and companies that the current user - is allowed to see based on ownership and sharing permissions. + Counts come from the contacts plugin contract (Block C7) — the core + dashboard route has no direct dependency on the Contact model. """ - tenant_id = uuid.UUID(current_user["tenant_id"]) - user_id = uuid.UUID(current_user["user_id"]) - is_system_admin = current_user.get("is_system_admin", False) + contacts_contract = get_contract("contacts") + if contacts_contract is None or not hasattr(contacts_contract, "get_counts"): + return {"contacts": 0, "companies": 0, "persons": 0, "total": 0} - # Contact count with visibility filter - contact_query = select(func.count(Contact.id)).where( - Contact.tenant_id == tenant_id, - Contact.deleted_at.is_(None), + return await contacts_contract.get_counts( + db=db, + tenant_id=uuid.UUID(current_user["tenant_id"]), + user_id=uuid.UUID(current_user["user_id"]), + is_system_admin=current_user.get("is_system_admin", False), ) - contact_query = await apply_visibility_filter( - db, contact_query, "contact", Contact, user_id, tenant_id, is_system_admin - ) - contact_result = await db.execute(contact_query) - contact_count = contact_result.scalar() or 0 - - # Company count (Contact.type == 'company') with visibility filter - company_query = select(func.count(Contact.id)).where( - Contact.tenant_id == tenant_id, - Contact.deleted_at.is_(None), - Contact.type == "company", - ) - company_query = await apply_visibility_filter( - db, company_query, "contact", Contact, user_id, tenant_id, is_system_admin - ) - company_result = await db.execute(company_query) - company_count = company_result.scalar() or 0 - - # Person count (Contact.type == 'person') with visibility filter - person_query = select(func.count(Contact.id)).where( - Contact.tenant_id == tenant_id, - Contact.deleted_at.is_(None), - Contact.type == "person", - ) - person_query = await apply_visibility_filter( - db, person_query, "contact", Contact, user_id, tenant_id, is_system_admin - ) - person_result = await db.execute(person_query) - person_count = person_result.scalar() or 0 - - return { - "contacts": contact_count, - "companies": company_count, - "persons": person_count, - "total": contact_count, - }