feat(c7): dashboard widgets as plugin contributions + contact counts via contacts contract
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
This commit is contained in:
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from app.plugins.base import BasePlugin
|
from app.plugins.base import BasePlugin
|
||||||
from app.plugins.manifest import (
|
from app.plugins.manifest import (
|
||||||
|
FrontendDashboardWidget,
|
||||||
FrontendDetailTab,
|
FrontendDetailTab,
|
||||||
FrontendMenuItem,
|
FrontendMenuItem,
|
||||||
FrontendPageRoute,
|
FrontendPageRoute,
|
||||||
@@ -40,6 +41,18 @@ class CalendarPlugin(BasePlugin):
|
|||||||
],
|
],
|
||||||
events=[],
|
events=[],
|
||||||
migrations=["0001_initial.sql", "0002_add_deleted_at.sql"],
|
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=[
|
permissions=[
|
||||||
"calendar:read",
|
"calendar:read",
|
||||||
"calendar:write",
|
"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)
|
||||||
@@ -9,7 +9,11 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.plugins.base import BasePlugin
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -54,6 +58,18 @@ class ContactsPlugin(BasePlugin):
|
|||||||
],
|
],
|
||||||
events=[],
|
events=[],
|
||||||
migrations=[],
|
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=[
|
permissions=[
|
||||||
"contacts:read",
|
"contacts:read",
|
||||||
"contacts:write",
|
"contacts:write",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from app.plugins.base import BasePlugin
|
from app.plugins.base import BasePlugin
|
||||||
from app.plugins.manifest import (
|
from app.plugins.manifest import (
|
||||||
CronJobContribution,
|
CronJobContribution,
|
||||||
|
FrontendDashboardWidget,
|
||||||
FrontendMenuItem,
|
FrontendMenuItem,
|
||||||
FrontendPageRoute,
|
FrontendPageRoute,
|
||||||
PluginManifest,
|
PluginManifest,
|
||||||
@@ -30,6 +31,18 @@ class TasksPlugin(BasePlugin):
|
|||||||
],
|
],
|
||||||
events=[],
|
events=[],
|
||||||
migrations=["0001_initial.sql", "0002_unified_task_system.sql"],
|
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=[
|
permissions=[
|
||||||
"tasks:read",
|
"tasks:read",
|
||||||
"tasks:write",
|
"tasks:write",
|
||||||
|
|||||||
+11
-48
@@ -5,13 +5,11 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import func, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.visibility import apply_visibility_filter
|
|
||||||
from app.deps import require_permission
|
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
|
from app.plugins.registry import get_registry
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"])
|
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.
|
"""Get dashboard count statistics filtered by user visibility.
|
||||||
|
|
||||||
Returns counts for contacts and companies that the current user
|
Counts come from the contacts plugin contract (Block C7) — the core
|
||||||
is allowed to see based on ownership and sharing permissions.
|
dashboard route has no direct dependency on the Contact model.
|
||||||
"""
|
"""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
contacts_contract = get_contract("contacts")
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
if contacts_contract is None or not hasattr(contacts_contract, "get_counts"):
|
||||||
is_system_admin = current_user.get("is_system_admin", False)
|
return {"contacts": 0, "companies": 0, "persons": 0, "total": 0}
|
||||||
|
|
||||||
# Contact count with visibility filter
|
return await contacts_contract.get_counts(
|
||||||
contact_query = select(func.count(Contact.id)).where(
|
db=db,
|
||||||
Contact.tenant_id == tenant_id,
|
tenant_id=uuid.UUID(current_user["tenant_id"]),
|
||||||
Contact.deleted_at.is_(None),
|
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,
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user