feat(c7): dashboard widgets as plugin contributions + contact counts via contacts contract
Check Cross-Plugin Imports / check (push) Has been cancelled

This commit is contained in:
Agent Zero
2026-08-23 23:36:36 +02:00
parent dff97f5589
commit cad7d084e8
5 changed files with 116 additions and 49 deletions
+13
View File
@@ -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",
@@ -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)
+17 -1
View File
@@ -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",
+13
View File
@@ -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",
+11 -48
View File
@@ -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,
}