From 3e38db79aa85dc7267fed8c6d41f6db0d5598320 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Wed, 4 Feb 2026 19:01:23 +0100 Subject: [PATCH] feat: implement DashboardWidgetProvider pattern for modular dashboard widgets Add protocol-based widget system following the MetricsProvider pattern: - Create DashboardWidgetProviderProtocol in contracts/widgets.py - Add WidgetAggregatorService in core to discover and aggregate widgets - Implement MarketplaceWidgetProvider for recent_imports widget - Implement TenancyWidgetProvider for recent_vendors widget - Update admin dashboard to use widget_aggregator - Add widget_provider field to ModuleDefinition Architecture documentation: - Add widget-provider-pattern.md with implementation guide - Add cross-module-import-rules.md enforcing core/optional separation - Update module-system.md with widget_provider and import rules This enables modules to provide rich dashboard widgets without core modules importing from optional modules, maintaining true module independence. Co-Authored-By: Claude Opus 4.5 --- app/modules/base.py | 43 ++ app/modules/contracts/__init__.py | 28 ++ app/modules/contracts/widgets.py | 348 ++++++++++++++ .../core/routes/api/admin_dashboard.py | 45 +- app/modules/core/services/__init__.py | 7 + .../core/services/widget_aggregator.py | 256 ++++++++++ app/modules/marketplace/definition.py | 9 + .../services/marketplace_widgets.py | 211 ++++++++ app/modules/tenancy/definition.py | 9 + .../tenancy/services/tenancy_widgets.py | 156 ++++++ .../architecture/cross-module-import-rules.md | 310 ++++++++++++ docs/architecture/module-system.md | 60 ++- docs/architecture/widget-provider-pattern.md | 449 ++++++++++++++++++ 13 files changed, 1906 insertions(+), 25 deletions(-) create mode 100644 app/modules/contracts/widgets.py create mode 100644 app/modules/core/services/widget_aggregator.py create mode 100644 app/modules/marketplace/services/marketplace_widgets.py create mode 100644 app/modules/tenancy/services/tenancy_widgets.py create mode 100644 docs/architecture/cross-module-import-rules.md create mode 100644 docs/architecture/widget-provider-pattern.md diff --git a/app/modules/base.py b/app/modules/base.py index 7b7563cf..a25289b0 100644 --- a/app/modules/base.py +++ b/app/modules/base.py @@ -45,6 +45,7 @@ if TYPE_CHECKING: from pydantic import BaseModel from app.modules.contracts.metrics import MetricsProviderProtocol + from app.modules.contracts.widgets import DashboardWidgetProviderProtocol from app.modules.enums import FrontendType @@ -413,6 +414,26 @@ class ModuleDefinition: # The provider will be discovered by core's StatsAggregator service. metrics_provider: "Callable[[], MetricsProviderProtocol] | None" = None + # ========================================================================= + # Widget Provider (Module-Driven Dashboard Widgets) + # ========================================================================= + # Callable that returns a DashboardWidgetProviderProtocol implementation. + # Use a callable (factory function) to enable lazy loading and avoid + # circular imports. Each module can provide its own widgets for dashboards. + # + # Example: + # def _get_widget_provider(): + # from app.modules.orders.services.order_widgets import order_widget_provider + # return order_widget_provider + # + # orders_module = ModuleDefinition( + # code="orders", + # widget_provider=_get_widget_provider, + # ) + # + # The provider will be discovered by core's WidgetAggregator service. + widget_provider: "Callable[[], DashboardWidgetProviderProtocol] | None" = None + # ========================================================================= # Menu Item Methods (Legacy - uses menu_items dict of IDs) # ========================================================================= @@ -799,6 +820,28 @@ class ModuleDefinition: return None return self.metrics_provider() + # ========================================================================= + # Widget Provider Methods + # ========================================================================= + + def has_widget_provider(self) -> bool: + """Check if this module has a widget provider.""" + return self.widget_provider is not None + + def get_widget_provider_instance(self) -> "DashboardWidgetProviderProtocol | None": + """ + Get the widget provider instance for this module. + + Calls the widget_provider factory function to get the provider. + Returns None if no provider is configured. + + Returns: + DashboardWidgetProviderProtocol instance, or None + """ + if self.widget_provider is None: + return None + return self.widget_provider() + # ========================================================================= # Magic Methods # ========================================================================= diff --git a/app/modules/contracts/__init__.py b/app/modules/contracts/__init__.py index c392ca3a..d4c59cb1 100644 --- a/app/modules/contracts/__init__.py +++ b/app/modules/contracts/__init__.py @@ -32,6 +32,17 @@ Metrics Provider Pattern: def get_vendor_metrics(self, db, vendor_id, context=None) -> list[MetricValue]: return [MetricValue(key="orders.total", value=42, label="Total", category="orders")] + +Widget Provider Pattern: + from app.modules.contracts.widgets import DashboardWidgetProviderProtocol, DashboardWidget + + class OrderWidgetProvider: + @property + def widgets_category(self) -> str: + return "orders" + + def get_vendor_widgets(self, db, vendor_id, context=None) -> list[DashboardWidget]: + return [DashboardWidget(key="orders.recent", widget_type="list", ...)] """ from app.modules.contracts.base import ServiceProtocol @@ -41,6 +52,15 @@ from app.modules.contracts.metrics import ( MetricsContext, MetricsProviderProtocol, ) +from app.modules.contracts.widgets import ( + BreakdownWidget, + DashboardWidget, + DashboardWidgetProviderProtocol, + ListWidget, + WidgetBreakdownItem, + WidgetContext, + WidgetListItem, +) __all__ = [ # Base protocols @@ -51,4 +71,12 @@ __all__ = [ "MetricValue", "MetricsContext", "MetricsProviderProtocol", + # Widget protocols + "WidgetContext", + "WidgetListItem", + "WidgetBreakdownItem", + "ListWidget", + "BreakdownWidget", + "DashboardWidget", + "DashboardWidgetProviderProtocol", ] diff --git a/app/modules/contracts/widgets.py b/app/modules/contracts/widgets.py new file mode 100644 index 00000000..2920f188 --- /dev/null +++ b/app/modules/contracts/widgets.py @@ -0,0 +1,348 @@ +# app/modules/contracts/widgets.py +""" +Dashboard widget provider protocol for cross-module widget aggregation. + +This module defines the protocol that modules implement to expose dashboard widgets. +The core module's WidgetAggregator discovers and aggregates all providers. + +Benefits: +- Each module owns its widgets (no cross-module coupling) +- Dashboards always work (aggregator is in core) +- Optional modules are truly optional (can be removed without breaking app) +- Easy to add new widgets (just implement protocol in your module) + +Widget Types: +- ListWidget: Displays a list of items (recent imports, recent vendors, etc.) +- BreakdownWidget: Displays grouped statistics (by category, by status, etc.) + +Usage: + # 1. Implement the protocol in your module + class OrderWidgetProvider: + @property + def widgets_category(self) -> str: + return "orders" + + def get_vendor_widgets(self, db, vendor_id, context=None) -> list[DashboardWidget]: + return [ + DashboardWidget( + key="orders.recent", + widget_type="list", + title="Recent Orders", + category="orders", + data=ListWidget(items=[...]), + ) + ] + + # 2. Register in module definition + def _get_widget_provider(): + from app.modules.orders.services.order_widgets import order_widget_provider + return order_widget_provider + + orders_module = ModuleDefinition( + code="orders", + widget_provider=_get_widget_provider, + # ... + ) + + # 3. Widgets appear automatically in dashboards when module is enabled +""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + + +# ============================================================================= +# Context +# ============================================================================= + + +@dataclass +class WidgetContext: + """ + Context for widget data collection. + + Provides filtering and scoping options for widget providers. + + Attributes: + date_from: Start of date range filter + date_to: End of date range filter + limit: Maximum number of items for list widgets + include_details: Whether to include extra details (may be expensive) + """ + + date_from: datetime | None = None + date_to: datetime | None = None + limit: int = 5 + include_details: bool = False + + +# ============================================================================= +# Widget Item Types +# ============================================================================= + + +@dataclass +class WidgetListItem: + """ + Single item in a list widget (recent vendors, orders, imports). + + Attributes: + id: Unique identifier for the item + title: Primary display text + subtitle: Secondary display text (optional) + status: Status indicator ("success", "warning", "error", "neutral") + timestamp: When the item was created/updated + url: Link to item details (optional) + metadata: Additional data for rendering + """ + + id: int | str + title: str + subtitle: str | None = None + status: str | None = None # "success", "warning", "error", "neutral" + timestamp: datetime | None = None + url: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class WidgetBreakdownItem: + """ + Single row in a breakdown widget (stats by marketplace, category). + + Attributes: + label: Display label for the row + value: Primary numeric value + secondary_value: Optional secondary value (e.g., comparison) + percentage: Percentage representation + icon: Lucide icon name (optional) + """ + + label: str + value: int | float + secondary_value: int | float | None = None + percentage: float | None = None + icon: str | None = None + + +# ============================================================================= +# Widget Containers +# ============================================================================= + + +@dataclass +class ListWidget: + """ + Widget containing a list of items. + + Used for: recent imports, recent vendors, recent orders, etc. + + Attributes: + items: List of WidgetListItem objects + total_count: Total number of items (for "X more" display) + view_all_url: Link to view all items + """ + + items: list[WidgetListItem] + total_count: int | None = None + view_all_url: str | None = None + + +@dataclass +class BreakdownWidget: + """ + Widget containing grouped statistics. + + Used for: status breakdown, category distribution, etc. + + Attributes: + items: List of WidgetBreakdownItem objects + total: Sum of all values + """ + + items: list[WidgetBreakdownItem] + total: int | float | None = None + + +# Type alias for widget data +WidgetData = ListWidget | BreakdownWidget + + +# ============================================================================= +# Main Widget Envelope +# ============================================================================= + + +@dataclass +class DashboardWidget: + """ + Standard widget envelope with metadata. + + This is the main unit of data returned by widget providers. + It contains both the widget data and metadata for display. + + Attributes: + key: Unique identifier for this widget (e.g., "marketplace.recent_imports") + Format: "{category}.{widget_name}" for consistency + widget_type: Type of widget ("list" or "breakdown") + title: Human-readable title for display + category: Grouping category (should match widgets_category of provider) + data: The actual widget data (ListWidget or BreakdownWidget) + icon: Optional Lucide icon name for UI display + description: Optional longer description + order: Display order (lower = higher priority) + + Example: + DashboardWidget( + key="marketplace.recent_imports", + widget_type="list", + title="Recent Imports", + category="marketplace", + data=ListWidget( + items=[ + WidgetListItem(id=1, title="Import #1", status="success") + ], + ), + icon="download", + order=10, + ) + """ + + key: str + widget_type: str # "list" or "breakdown" + title: str + category: str + data: WidgetData + icon: str | None = None + description: str | None = None + order: int = 100 + + +# ============================================================================= +# Protocol Interface +# ============================================================================= + + +@runtime_checkable +class DashboardWidgetProviderProtocol(Protocol): + """ + Protocol for modules that provide dashboard widgets. + + Each module implements this to expose its own widgets. + The core module's WidgetAggregator discovers and aggregates all providers. + + Implementation Notes: + - Providers should be stateless (all data via db session) + - Return empty list if no widgets available (don't raise) + - Use consistent key format: "{category}.{widget_name}" + - Include icon hints for UI rendering + - Be mindful of query performance (use efficient queries) + - Translations are handled by the module itself + + Example Implementation: + class MarketplaceWidgetProvider: + @property + def widgets_category(self) -> str: + return "marketplace" + + def get_vendor_widgets( + self, db: Session, vendor_id: int, context: WidgetContext | None = None + ) -> list[DashboardWidget]: + from app.modules.marketplace.models import MarketplaceImportJob + limit = context.limit if context else 5 + jobs = db.query(MarketplaceImportJob).filter(...).limit(limit).all() + return [ + DashboardWidget( + key="marketplace.recent_imports", + widget_type="list", + title="Recent Imports", + category="marketplace", + data=ListWidget(items=[...]), + icon="download" + ) + ] + + def get_platform_widgets( + self, db: Session, platform_id: int, context: WidgetContext | None = None + ) -> list[DashboardWidget]: + # Aggregate across all vendors in platform + ... + """ + + @property + def widgets_category(self) -> str: + """ + Category name for this provider's widgets. + + Should be a short, lowercase identifier matching the module's domain. + Examples: "orders", "inventory", "customers", "marketplace" + + Returns: + Category string used for grouping widgets + """ + ... + + def get_vendor_widgets( + self, + db: "Session", + vendor_id: int, + context: WidgetContext | None = None, + ) -> list[DashboardWidget]: + """ + Get widgets for a specific vendor dashboard. + + Called by the vendor dashboard to display vendor-scoped widgets. + Should only include data belonging to the specified vendor. + + Args: + db: Database session for queries + vendor_id: ID of the vendor to get widgets for + context: Optional filtering/scoping context + + Returns: + List of DashboardWidget objects for this vendor + """ + ... + + def get_platform_widgets( + self, + db: "Session", + platform_id: int, + context: WidgetContext | None = None, + ) -> list[DashboardWidget]: + """ + Get widgets aggregated for a platform. + + Called by the admin dashboard to display platform-wide widgets. + Should aggregate data across all vendors in the platform. + + Args: + db: Database session for queries + platform_id: ID of the platform to get widgets for + context: Optional filtering/scoping context + + Returns: + List of DashboardWidget objects aggregated for the platform + """ + ... + + +__all__ = [ + # Context + "WidgetContext", + # Item types + "WidgetListItem", + "WidgetBreakdownItem", + # Containers + "ListWidget", + "BreakdownWidget", + "WidgetData", + # Main envelope + "DashboardWidget", + # Protocol + "DashboardWidgetProviderProtocol", +] diff --git a/app/modules/core/routes/api/admin_dashboard.py b/app/modules/core/routes/api/admin_dashboard.py index 5156e32a..f8b92ef5 100644 --- a/app/modules/core/routes/api/admin_dashboard.py +++ b/app/modules/core/routes/api/admin_dashboard.py @@ -5,17 +5,22 @@ Admin dashboard and statistics endpoints. This module uses the StatsAggregator service from core to collect metrics from all enabled modules. Each module provides its own metrics via the MetricsProvider protocol. +Dashboard widgets are collected via the WidgetAggregator service, which discovers +DashboardWidgetProvider implementations from all enabled modules. + For backward compatibility, this also falls back to the analytics stats_service for comprehensive statistics that haven't been migrated to the provider pattern yet. """ import logging +from typing import Any from fastapi import APIRouter, Depends, Request from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db +from app.modules.contracts.widgets import ListWidget from app.modules.core.schemas.dashboard import ( AdminDashboardResponse, ImportStatsResponse, @@ -28,7 +33,7 @@ from app.modules.core.schemas.dashboard import ( VendorStatsResponse, ) from app.modules.core.services.stats_aggregator import stats_aggregator -from app.modules.tenancy.services.admin_service import admin_service +from app.modules.core.services.widget_aggregator import widget_aggregator from models.schema.auth import UserContext admin_dashboard_router = APIRouter(prefix="/dashboard") @@ -77,6 +82,31 @@ def _extract_metric_value( return default +def _widget_list_item_to_dict(item) -> dict[str, Any]: + """Convert a WidgetListItem to a dictionary for API response.""" + return { + "id": item.id, + "title": item.title, + "subtitle": item.subtitle, + "status": item.status, + "timestamp": item.timestamp, + "url": item.url, + **item.metadata, + } + + +def _extract_widget_items( + widgets: dict[str, list], category: str, key: str +) -> list[dict[str, Any]]: + """Extract items from a list widget for backward compatibility.""" + if category not in widgets: + return [] + for widget in widgets[category]: + if widget.key == key and isinstance(widget.data, ListWidget): + return [_widget_list_item_to_dict(item) for item in widget.data.items] + return [] + + @admin_dashboard_router.get("", response_model=AdminDashboardResponse) def get_admin_dashboard( request: Request, @@ -89,6 +119,9 @@ def get_admin_dashboard( # Get aggregated metrics from all enabled modules metrics = stats_aggregator.get_admin_dashboard_stats(db=db, platform_id=platform_id) + # Get aggregated widgets from all enabled modules + widgets = widget_aggregator.get_admin_dashboard_widgets(db=db, platform_id=platform_id) + # Extract user stats from tenancy module total_users = _extract_metric_value(metrics, "tenancy", "tenancy.total_users", 0) active_users = _extract_metric_value(metrics, "tenancy", "tenancy.active_users", 0) @@ -110,6 +143,12 @@ def get_admin_dashboard( metrics, "tenancy", "tenancy.inactive_vendors", 0 ) + # Extract recent_vendors from tenancy widget (backward compatibility) + recent_vendors = _extract_widget_items(widgets, "tenancy", "tenancy.recent_vendors") + + # Extract recent_imports from marketplace widget (backward compatibility) + recent_imports = _extract_widget_items(widgets, "marketplace", "marketplace.recent_imports") + return AdminDashboardResponse( platform={ "name": "Multi-Tenant Ecommerce Platform", @@ -128,8 +167,8 @@ def get_admin_dashboard( pending=int(pending_vendors), inactive=int(inactive_vendors), ), - recent_vendors=admin_service.get_recent_vendors(db, limit=5), - recent_imports=admin_service.get_recent_import_jobs(db, limit=10), + recent_vendors=recent_vendors, + recent_imports=recent_imports, ) diff --git a/app/modules/core/services/__init__.py b/app/modules/core/services/__init__.py index fef9c417..1a099207 100644 --- a/app/modules/core/services/__init__.py +++ b/app/modules/core/services/__init__.py @@ -39,6 +39,10 @@ from app.modules.core.services.storage_service import ( get_storage_backend, reset_storage_backend, ) +from app.modules.core.services.widget_aggregator import ( + WidgetAggregatorService, + widget_aggregator, +) __all__ = [ # Auth @@ -71,4 +75,7 @@ __all__ = [ # Stats aggregator "StatsAggregatorService", "stats_aggregator", + # Widget aggregator + "WidgetAggregatorService", + "widget_aggregator", ] diff --git a/app/modules/core/services/widget_aggregator.py b/app/modules/core/services/widget_aggregator.py new file mode 100644 index 00000000..442bca95 --- /dev/null +++ b/app/modules/core/services/widget_aggregator.py @@ -0,0 +1,256 @@ +# app/modules/core/services/widget_aggregator.py +""" +Widget aggregator service for collecting dashboard widgets from all modules. + +This service lives in core because dashboards are core functionality that should +always be available. It discovers and aggregates DashboardWidgetProviders from all +enabled modules, providing a unified interface for dashboard widgets. + +Benefits: +- Dashboards always work (aggregator is in core) +- Each module owns its widgets (no cross-module coupling) +- Optional modules are truly optional (can be removed without breaking app) +- Easy to add new widgets (just implement DashboardWidgetProviderProtocol in your module) + +Usage: + from app.modules.core.services.widget_aggregator import widget_aggregator + + # Get vendor dashboard widgets + widgets = widget_aggregator.get_vendor_dashboard_widgets( + db=db, vendor_id=123, platform_id=1 + ) + + # Get admin dashboard widgets + widgets = widget_aggregator.get_admin_dashboard_widgets( + db=db, platform_id=1 + ) +""" + +import logging +from typing import TYPE_CHECKING + +from sqlalchemy.orm import Session + +from app.modules.contracts.widgets import ( + DashboardWidget, + DashboardWidgetProviderProtocol, + WidgetContext, +) + +if TYPE_CHECKING: + from app.modules.base import ModuleDefinition + +logger = logging.getLogger(__name__) + + +class WidgetAggregatorService: + """ + Aggregates widgets from all module providers. + + This service discovers DashboardWidgetProviders from enabled modules and provides + a unified interface for dashboard widgets. It handles graceful degradation + when modules are disabled or providers fail. + """ + + def _get_enabled_providers( + self, db: Session, platform_id: int + ) -> list[tuple["ModuleDefinition", DashboardWidgetProviderProtocol]]: + """ + Get widget providers from enabled modules. + + Args: + db: Database session + platform_id: Platform ID to check module enablement + + Returns: + List of (module, provider) tuples for enabled modules with providers + """ + from app.modules.registry import MODULES + from app.modules.service import module_service + + providers: list[tuple[ModuleDefinition, DashboardWidgetProviderProtocol]] = [] + + for module in MODULES.values(): + # Skip modules without widget providers + if not module.has_widget_provider(): + continue + + # Core modules are always enabled, check others + if not module.is_core: + try: + if not module_service.is_module_enabled(db, platform_id, module.code): + continue + except Exception as e: + logger.warning( + f"Failed to check if module {module.code} is enabled: {e}" + ) + continue + + # Get the provider instance + try: + provider = module.get_widget_provider_instance() + if provider is not None: + providers.append((module, provider)) + except Exception as e: + logger.warning( + f"Failed to get widget provider for module {module.code}: {e}" + ) + + return providers + + def get_vendor_dashboard_widgets( + self, + db: Session, + vendor_id: int, + platform_id: int, + context: WidgetContext | None = None, + ) -> dict[str, list[DashboardWidget]]: + """ + Get all widgets for a vendor, grouped by category. + + Called by the vendor dashboard to display vendor-scoped widgets. + + Args: + db: Database session + vendor_id: ID of the vendor to get widgets for + platform_id: Platform ID (for module enablement check) + context: Optional filtering/scoping context + + Returns: + Dict mapping category name to list of DashboardWidget objects + """ + providers = self._get_enabled_providers(db, platform_id) + result: dict[str, list[DashboardWidget]] = {} + + for module, provider in providers: + try: + widgets = provider.get_vendor_widgets(db, vendor_id, context) + if widgets: + result[provider.widgets_category] = widgets + except Exception as e: + logger.warning( + f"Failed to get vendor widgets from module {module.code}: {e}" + ) + # Continue with other providers - graceful degradation + + return result + + def get_admin_dashboard_widgets( + self, + db: Session, + platform_id: int, + context: WidgetContext | None = None, + ) -> dict[str, list[DashboardWidget]]: + """ + Get all widgets for a platform, grouped by category. + + Called by the admin dashboard to display platform-wide widgets. + + Args: + db: Database session + platform_id: ID of the platform to get widgets for + context: Optional filtering/scoping context + + Returns: + Dict mapping category name to list of DashboardWidget objects + """ + providers = self._get_enabled_providers(db, platform_id) + result: dict[str, list[DashboardWidget]] = {} + + for module, provider in providers: + try: + widgets = provider.get_platform_widgets(db, platform_id, context) + if widgets: + result[provider.widgets_category] = widgets + except Exception as e: + logger.warning( + f"Failed to get platform widgets from module {module.code}: {e}" + ) + # Continue with other providers - graceful degradation + + return result + + def get_widgets_flat( + self, + db: Session, + platform_id: int, + vendor_id: int | None = None, + context: WidgetContext | None = None, + ) -> list[DashboardWidget]: + """ + Get widgets as a flat list sorted by order. + + Convenience method that returns all widgets in a single list, + sorted by their order field. + + Args: + db: Database session + platform_id: Platform ID + vendor_id: If provided, get vendor widgets; otherwise platform widgets + context: Optional filtering/scoping context + + Returns: + Flat list of DashboardWidget objects sorted by order + """ + if vendor_id is not None: + categorized = self.get_vendor_dashboard_widgets( + db, vendor_id, platform_id, context + ) + else: + categorized = self.get_admin_dashboard_widgets(db, platform_id, context) + + # Flatten and sort by order + all_widgets: list[DashboardWidget] = [] + for widgets in categorized.values(): + all_widgets.extend(widgets) + + return sorted(all_widgets, key=lambda w: w.order) + + def get_widget_by_key( + self, + db: Session, + platform_id: int, + key: str, + vendor_id: int | None = None, + context: WidgetContext | None = None, + ) -> DashboardWidget | None: + """ + Get a specific widget by its key. + + Args: + db: Database session + platform_id: Platform ID + key: Widget key (e.g., "marketplace.recent_imports") + vendor_id: If provided, get vendor widget; otherwise platform widget + context: Optional filtering/scoping context + + Returns: + The DashboardWidget with the specified key, or None if not found + """ + widgets = self.get_widgets_flat(db, platform_id, vendor_id, context) + for widget in widgets: + if widget.key == key: + return widget + return None + + def get_available_categories( + self, db: Session, platform_id: int + ) -> list[str]: + """ + Get list of available widget categories for a platform. + + Args: + db: Database session + platform_id: Platform ID (for module enablement check) + + Returns: + List of category names from enabled providers + """ + providers = self._get_enabled_providers(db, platform_id) + return [provider.widgets_category for _, provider in providers] + + +# Singleton instance +widget_aggregator = WidgetAggregatorService() + +__all__ = ["WidgetAggregatorService", "widget_aggregator"] diff --git a/app/modules/marketplace/definition.py b/app/modules/marketplace/definition.py index 12487664..9468bb19 100644 --- a/app/modules/marketplace/definition.py +++ b/app/modules/marketplace/definition.py @@ -33,6 +33,13 @@ def _get_metrics_provider(): return marketplace_metrics_provider +def _get_widget_provider(): + """Lazy import of widget provider to avoid circular imports.""" + from app.modules.marketplace.services.marketplace_widgets import marketplace_widget_provider + + return marketplace_widget_provider + + # Marketplace module definition marketplace_module = ModuleDefinition( code="marketplace", @@ -155,6 +162,8 @@ marketplace_module = ModuleDefinition( ], # Metrics provider for dashboard statistics metrics_provider=_get_metrics_provider, + # Widget provider for dashboard widgets + widget_provider=_get_widget_provider, ) diff --git a/app/modules/marketplace/services/marketplace_widgets.py b/app/modules/marketplace/services/marketplace_widgets.py new file mode 100644 index 00000000..f9daab18 --- /dev/null +++ b/app/modules/marketplace/services/marketplace_widgets.py @@ -0,0 +1,211 @@ +# app/modules/marketplace/services/marketplace_widgets.py +""" +Marketplace dashboard widget provider. + +Provides widgets for marketplace-related data on vendor and admin dashboards. +Implements the DashboardWidgetProviderProtocol. + +Widgets provided: +- recent_imports: List of recent import jobs with status +""" + +import logging + +from sqlalchemy.orm import Session + +from app.modules.contracts.widgets import ( + DashboardWidget, + DashboardWidgetProviderProtocol, + ListWidget, + WidgetContext, + WidgetListItem, +) + +logger = logging.getLogger(__name__) + + +class MarketplaceWidgetProvider: + """ + Widget provider for marketplace module. + + Provides dashboard widgets for import jobs and other marketplace data. + """ + + @property + def widgets_category(self) -> str: + return "marketplace" + + def _map_status_to_display(self, status: str) -> str: + """Map job status to widget status indicator.""" + status_map = { + "pending": "neutral", + "processing": "warning", + "completed": "success", + "completed_with_errors": "warning", + "failed": "error", + } + return status_map.get(status, "neutral") + + def get_vendor_widgets( + self, + db: Session, + vendor_id: int, + context: WidgetContext | None = None, + ) -> list[DashboardWidget]: + """ + Get marketplace widgets for a vendor dashboard. + + Args: + db: Database session + vendor_id: ID of the vendor + context: Optional filtering/scoping context + + Returns: + List of DashboardWidget objects for the vendor + """ + from app.modules.marketplace.models import MarketplaceImportJob + + limit = context.limit if context else 5 + + # Get recent imports for this vendor + jobs = ( + db.query(MarketplaceImportJob) + .filter(MarketplaceImportJob.vendor_id == vendor_id) + .order_by(MarketplaceImportJob.created_at.desc()) + .limit(limit) + .all() + ) + + items = [ + WidgetListItem( + id=job.id, + title=f"Import #{job.id}", + subtitle=f"{job.marketplace} - {job.language.upper()}", + status=self._map_status_to_display(job.status), + timestamp=job.created_at, + url=f"/vendor/marketplace/imports/{job.id}", + metadata={ + "total_processed": job.total_processed or 0, + "imported_count": job.imported_count or 0, + "error_count": job.error_count or 0, + "status": job.status, + }, + ) + for job in jobs + ] + + # Get total count for "view all" indicator + total_count = ( + db.query(MarketplaceImportJob) + .filter(MarketplaceImportJob.vendor_id == vendor_id) + .count() + ) + + return [ + DashboardWidget( + key="marketplace.recent_imports", + widget_type="list", + title="Recent Imports", + category="marketplace", + data=ListWidget( + items=items, + total_count=total_count, + view_all_url="/vendor/marketplace/imports", + ), + icon="download", + description="Latest product import jobs", + order=20, + ) + ] + + def get_platform_widgets( + self, + db: Session, + platform_id: int, + context: WidgetContext | None = None, + ) -> list[DashboardWidget]: + """ + Get marketplace widgets for the admin/platform dashboard. + + Args: + db: Database session + platform_id: ID of the platform + context: Optional filtering/scoping context + + Returns: + List of DashboardWidget objects for the platform + """ + from sqlalchemy.orm import joinedload + + from app.modules.marketplace.models import MarketplaceImportJob + from app.modules.tenancy.models import Vendor, VendorPlatform + + limit = context.limit if context else 5 + + # Get vendor IDs for this platform + vendor_ids_subquery = ( + db.query(VendorPlatform.vendor_id) + .filter(VendorPlatform.platform_id == platform_id) + .subquery() + ) + + # Get recent imports across all vendors in the platform + jobs = ( + db.query(MarketplaceImportJob) + .options(joinedload(MarketplaceImportJob.vendor)) + .filter(MarketplaceImportJob.vendor_id.in_(vendor_ids_subquery)) + .order_by(MarketplaceImportJob.created_at.desc()) + .limit(limit) + .all() + ) + + items = [ + WidgetListItem( + id=job.id, + title=f"Import #{job.id}", + subtitle=job.vendor.name if job.vendor else "Unknown Vendor", + status=self._map_status_to_display(job.status), + timestamp=job.created_at, + url=f"/admin/marketplace/imports/{job.id}", + metadata={ + "vendor_id": job.vendor_id, + "vendor_code": job.vendor.vendor_code if job.vendor else None, + "marketplace": job.marketplace, + "total_processed": job.total_processed or 0, + "imported_count": job.imported_count or 0, + "error_count": job.error_count or 0, + "status": job.status, + }, + ) + for job in jobs + ] + + # Get total count for platform + total_count = ( + db.query(MarketplaceImportJob) + .filter(MarketplaceImportJob.vendor_id.in_(vendor_ids_subquery)) + .count() + ) + + return [ + DashboardWidget( + key="marketplace.recent_imports", + widget_type="list", + title="Recent Imports", + category="marketplace", + data=ListWidget( + items=items, + total_count=total_count, + view_all_url="/admin/marketplace/letzshop", + ), + icon="download", + description="Latest product import jobs across all vendors", + order=20, + ) + ] + + +# Singleton instance +marketplace_widget_provider = MarketplaceWidgetProvider() + +__all__ = ["MarketplaceWidgetProvider", "marketplace_widget_provider"] diff --git a/app/modules/tenancy/definition.py b/app/modules/tenancy/definition.py index ca67e2a4..792aad36 100644 --- a/app/modules/tenancy/definition.py +++ b/app/modules/tenancy/definition.py @@ -22,6 +22,13 @@ def _get_metrics_provider(): return tenancy_metrics_provider +def _get_widget_provider(): + """Lazy import of widget provider to avoid circular imports.""" + from app.modules.tenancy.services.tenancy_widgets import tenancy_widget_provider + + return tenancy_widget_provider + + tenancy_module = ModuleDefinition( code="tenancy", name="Tenancy Management", @@ -161,6 +168,8 @@ tenancy_module = ModuleDefinition( exceptions_path="app.modules.tenancy.exceptions", # Metrics provider for dashboard statistics metrics_provider=_get_metrics_provider, + # Widget provider for dashboard widgets + widget_provider=_get_widget_provider, ) __all__ = ["tenancy_module"] diff --git a/app/modules/tenancy/services/tenancy_widgets.py b/app/modules/tenancy/services/tenancy_widgets.py new file mode 100644 index 00000000..187b3bdf --- /dev/null +++ b/app/modules/tenancy/services/tenancy_widgets.py @@ -0,0 +1,156 @@ +# app/modules/tenancy/services/tenancy_widgets.py +""" +Tenancy dashboard widget provider. + +Provides widgets for tenancy-related data on admin dashboards. +Implements the DashboardWidgetProviderProtocol. + +Widgets provided: +- recent_vendors: List of recently created vendors with status +""" + +import logging + +from sqlalchemy.orm import Session + +from app.modules.contracts.widgets import ( + DashboardWidget, + DashboardWidgetProviderProtocol, + ListWidget, + WidgetContext, + WidgetListItem, +) + +logger = logging.getLogger(__name__) + + +class TenancyWidgetProvider: + """ + Widget provider for tenancy module. + + Provides dashboard widgets for vendors, users, and other tenancy data. + """ + + @property + def widgets_category(self) -> str: + return "tenancy" + + def _get_vendor_status(self, vendor) -> str: + """Determine widget status indicator for a vendor.""" + if not vendor.is_active: + return "neutral" + if not vendor.is_verified: + return "warning" + return "success" + + def get_vendor_widgets( + self, + db: Session, + vendor_id: int, + context: WidgetContext | None = None, + ) -> list[DashboardWidget]: + """ + Get tenancy widgets for a vendor dashboard. + + Tenancy module doesn't provide vendor-scoped widgets + (vendors don't see other vendors). + + Args: + db: Database session + vendor_id: ID of the vendor + context: Optional filtering/scoping context + + Returns: + Empty list (no vendor-scoped tenancy widgets) + """ + # Tenancy widgets are platform/admin-only + return [] + + def get_platform_widgets( + self, + db: Session, + platform_id: int, + context: WidgetContext | None = None, + ) -> list[DashboardWidget]: + """ + Get tenancy widgets for the admin/platform dashboard. + + Args: + db: Database session + platform_id: ID of the platform + context: Optional filtering/scoping context + + Returns: + List of DashboardWidget objects for the platform + """ + from sqlalchemy.orm import joinedload + + from app.modules.tenancy.models import Vendor, VendorPlatform + + limit = context.limit if context else 5 + + # Get vendor IDs for this platform + vendor_ids_subquery = ( + db.query(VendorPlatform.vendor_id) + .filter(VendorPlatform.platform_id == platform_id) + .subquery() + ) + + # Get recent vendors for this platform + vendors = ( + db.query(Vendor) + .options(joinedload(Vendor.company)) + .filter(Vendor.id.in_(vendor_ids_subquery)) + .order_by(Vendor.created_at.desc()) + .limit(limit) + .all() + ) + + items = [ + WidgetListItem( + id=vendor.id, + title=vendor.name, + subtitle=vendor.vendor_code, + status=self._get_vendor_status(vendor), + timestamp=vendor.created_at, + url=f"/admin/vendors/{vendor.id}", + metadata={ + "vendor_code": vendor.vendor_code, + "subdomain": vendor.subdomain, + "is_active": vendor.is_active, + "is_verified": vendor.is_verified, + "company_name": vendor.company.name if vendor.company else None, + }, + ) + for vendor in vendors + ] + + # Get total vendor count for platform + total_count = ( + db.query(Vendor) + .filter(Vendor.id.in_(vendor_ids_subquery)) + .count() + ) + + return [ + DashboardWidget( + key="tenancy.recent_vendors", + widget_type="list", + title="Recent Vendors", + category="tenancy", + data=ListWidget( + items=items, + total_count=total_count, + view_all_url="/admin/vendors", + ), + icon="shopping-bag", + description="Recently created vendor accounts", + order=10, + ) + ] + + +# Singleton instance +tenancy_widget_provider = TenancyWidgetProvider() + +__all__ = ["TenancyWidgetProvider", "tenancy_widget_provider"] diff --git a/docs/architecture/cross-module-import-rules.md b/docs/architecture/cross-module-import-rules.md new file mode 100644 index 00000000..e612301e --- /dev/null +++ b/docs/architecture/cross-module-import-rules.md @@ -0,0 +1,310 @@ +# Cross-Module Import Rules + +This document defines the strict import rules that ensure the module system remains decoupled, testable, and resilient. These rules are critical for maintaining a truly modular architecture. + +## Core Principle + +**Core modules NEVER import from optional modules.** + +This is the fundamental rule that enables optional modules to be truly optional. When a core module imports from an optional module: +- The app crashes if that module is disabled +- You can't test core functionality in isolation +- You create a hidden dependency that violates the architecture + +## Module Classification + +### Core Modules (Always Enabled) +- `contracts` - Protocols and interfaces +- `core` - Dashboard, settings, profile +- `tenancy` - Platform, company, vendor, admin user management +- `cms` - Content pages, media library +- `customers` - Customer database +- `billing` - Subscriptions, tier limits +- `payments` - Payment gateway integrations +- `messaging` - Email, notifications + +### Optional Modules (Per-Platform) +- `analytics` - Reports, dashboards +- `cart` - Shopping cart +- `catalog` - Product browsing +- `checkout` - Cart-to-order conversion +- `inventory` - Stock management +- `loyalty` - Loyalty programs +- `marketplace` - Letzshop integration +- `orders` - Order management + +## Import Rules Matrix + +| From \ To | Core | Optional | Contracts | +|-----------|------|----------|-----------| +| **Core** | :white_check_mark: | :x: FORBIDDEN | :white_check_mark: | +| **Optional** | :white_check_mark: | :warning: With care | :white_check_mark: | +| **Contracts** | :x: | :x: | :white_check_mark: | + +### Explanation + +1. **Core → Core**: Allowed. Core modules can import from each other (e.g., billing imports from tenancy) + +2. **Core → Optional**: **FORBIDDEN**. This is the most important rule. Core modules must never have direct imports from optional modules. + +3. **Core → Contracts**: Allowed. Contracts define shared protocols and data structures. + +4. **Optional → Core**: Allowed. Optional modules can use core functionality. + +5. **Optional → Optional**: Allowed with care. Check dependencies in `definition.py` to ensure proper ordering. + +6. **Optional → Contracts**: Allowed. This is how optional modules implement protocols. + +7. **Contracts → Anything**: Contracts should only depend on stdlib/typing/Protocol. No module imports. + +## Anti-Patterns (DO NOT DO) + +### Direct Import from Optional Module + +```python +# app/modules/core/routes/api/admin_dashboard.py + +# BAD: Core importing from optional module +from app.modules.marketplace.services import marketplace_service +from app.modules.analytics.services import stats_service + +@router.get("/dashboard") +def get_dashboard(): + # This crashes if marketplace is disabled! + imports = marketplace_service.get_recent_imports() +``` + +### Conditional Import with ismodule_enabled Check + +```python +# BAD: Still creates import-time dependency +from app.modules.service import module_service + +if module_service.is_module_enabled(db, platform_id, "marketplace"): + from app.modules.marketplace.services import marketplace_service # Still loaded! + imports = marketplace_service.get_recent_imports() +``` + +### Type Hints from Optional Modules + +```python +# BAD: Type hint forces import +from app.modules.marketplace.models import MarketplaceImportJob + +def process_import(job: MarketplaceImportJob) -> None: # Crashes if disabled + ... +``` + +## Approved Patterns + +### 1. Provider Protocol Pattern (Metrics & Widgets) + +Use the provider protocol pattern for cross-module data: + +```python +# app/modules/core/services/stats_aggregator.py + +# GOOD: Import only protocols and contracts +from app.modules.contracts.metrics import MetricsProviderProtocol, MetricValue + +class StatsAggregatorService: + def _get_enabled_providers(self, db, platform_id): + # Discover providers from MODULES registry + from app.modules.registry import MODULES + + for module in MODULES.values(): + if module.has_metrics_provider(): + # Provider is called through protocol, not direct import + provider = module.get_metrics_provider_instance() + yield provider +``` + +### 2. Context Provider Pattern + +Modules contribute context without direct imports: + +```python +# app/modules/core/utils.py + +def get_context_for_frontend(frontend_type, request, db, platform): + # GOOD: Discover and call providers through registry + for module in get_enabled_modules(db, platform.id): + if module.has_context_provider(frontend_type): + context.update(module.get_context_contribution(...)) +``` + +### 3. Lazy Factory Functions + +Use factory functions in module definitions: + +```python +# app/modules/marketplace/definition.py + +def _get_widget_provider(): + """Lazy import to avoid circular imports.""" + from app.modules.marketplace.services.marketplace_widgets import marketplace_widget_provider + return marketplace_widget_provider + +marketplace_module = ModuleDefinition( + code="marketplace", + widget_provider=_get_widget_provider, # Called lazily when needed +) +``` + +### 4. Type Checking Only Imports + +Use `TYPE_CHECKING` for type hints without runtime dependency: + +```python +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from app.modules.marketplace.models import MarketplaceImportJob + +def process_import(job: "MarketplaceImportJob") -> None: + # Works at runtime even if marketplace is disabled + ... +``` + +### 5. Registry-Based Discovery + +Discover modules through the registry, not imports: + +```python +# GOOD: Discovery through registry +from app.modules.registry import MODULES + +def get_module_if_enabled(db, platform_id, module_code): + module = MODULES.get(module_code) + if module and module_service.is_module_enabled(db, platform_id, module_code): + return module + return None +``` + +## Architecture Validation + +The architecture validator (`scripts/validate_architecture.py`) includes rules to detect violations: + +| Rule | Severity | Description | +|------|----------|-------------| +| IMPORT-001 | ERROR | Core module imports from optional module | +| IMPORT-002 | WARNING | Optional module imports from unrelated optional module | +| IMPORT-003 | INFO | Consider using protocol pattern instead of direct import | + +Run validation: +```bash +python scripts/validate_architecture.py +``` + +## Provider Pattern Summary + +### When to Use Each Pattern + +| Need | Pattern | Location | +|------|---------|----------| +| Numeric statistics | MetricsProvider | `contracts/metrics.py` | +| Dashboard widgets | WidgetProvider | `contracts/widgets.py` | +| Page template context | Context Provider | `definition.py` | +| Service access | Protocol + DI | `contracts/{module}.py` | + +### Provider Flow + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ CONTRACTS LAYER │ +│ (Shared protocol definitions) │ +│ │ +│ MetricsProviderProtocol DashboardWidgetProviderProtocol │ +│ ContentServiceProtocol ServiceProtocol │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ + │ CORE MODULE │ │ OPTIONAL MODULE 1│ │ OPTIONAL MODULE 2│ + │ │ │ │ │ │ + │ StatsAggregator │ │ OrderMetrics │ │ CatalogMetrics │ + │ WidgetAggregator │ │ (implements) │ │ (implements) │ + │ (discovers) │ │ │ │ │ + └──────────────────┘ └──────────────────┘ └──────────────────┘ + │ │ │ + │ │ │ + ▼ ▼ ▼ + ┌──────────────────────────────────────────────────────────────┐ + │ MODULE REGISTRY │ + │ │ + │ MODULES = { │ + │ "orders": ModuleDefinition(metrics_provider=...), │ + │ "catalog": ModuleDefinition(metrics_provider=...), │ + │ } │ + └──────────────────────────────────────────────────────────────┘ +``` + +## Testing Without Dependencies + +Because core doesn't depend on optional modules, you can test core in isolation: + +```python +# tests/unit/core/test_stats_aggregator.py + +from unittest.mock import MagicMock +from app.modules.contracts.metrics import MetricValue, MetricsProviderProtocol + +class MockMetricsProvider: + """Mock that implements the protocol without needing real modules.""" + @property + def metrics_category(self) -> str: + return "test" + + def get_vendor_metrics(self, db, vendor_id, context=None): + return [MetricValue(key="test.value", value=42, label="Test", category="test")] + +def test_stats_aggregator_with_mock_provider(): + # Test core without any optional modules + mock_provider = MockMetricsProvider() + ... +``` + +## Consequences of Violations + +### If Core Imports Optional Module + +1. **Import Error**: App fails to start if module code has syntax errors +2. **Runtime Crash**: App crashes when disabled module is accessed +3. **Hidden Dependency**: Module appears optional but isn't +4. **Testing Difficulty**: Can't test core without all modules +5. **Deployment Issues**: Can't deploy minimal configurations + +### Detection + +Add these checks to CI: + +```bash +# Check for forbidden imports +grep -r "from app.modules.marketplace" app/modules/core/ && exit 1 +grep -r "from app.modules.analytics" app/modules/core/ && exit 1 +grep -r "from app.modules.orders" app/modules/core/ && exit 1 +``` + +## Summary + +| Rule | Enforcement | +|------|-------------| +| Core → Optional = FORBIDDEN | Architecture validation, CI checks | +| Use Protocol pattern | Code review, documentation | +| Lazy factory functions | Required for definition.py | +| TYPE_CHECKING imports | Required for type hints across modules | +| Registry-based discovery | Required for all cross-module access | + +Following these rules ensures: +- Modules can be truly enabled/disabled per platform +- Testing can be done in isolation +- New modules can be added without modifying core +- The app remains stable when modules fail + +## Related Documentation + +- [Module System Architecture](module-system.md) - Module structure and auto-discovery +- [Metrics Provider Pattern](metrics-provider-pattern.md) - Numeric statistics architecture +- [Widget Provider Pattern](widget-provider-pattern.md) - Dashboard widgets architecture +- [Architecture Violations Status](architecture-violations-status.md) - Current violation tracking diff --git a/docs/architecture/module-system.md b/docs/architecture/module-system.md index 661d79de..e7c6ea28 100644 --- a/docs/architecture/module-system.md +++ b/docs/architecture/module-system.md @@ -25,12 +25,13 @@ The Wizamart platform uses a **plug-and-play modular architecture** where module │ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ CORE MODULES (Always Enabled) │ │ -│ │ core │ tenancy │ cms │ customers │ │ +│ │ contracts │ core │ tenancy │ cms │ customers │ billing │ │ │ +│ │ payments │ messaging │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ OPTIONAL MODULES (Per-Platform) │ │ -│ │ payments │ billing │ inventory │ orders │ marketplace │ ...│ │ +│ │ analytics │ inventory │ catalog │ cart │ checkout │ ... │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────────────┐ │ @@ -72,7 +73,7 @@ touch app/modules/mymodule/exceptions.py ## Three-Tier Classification -### Core Modules (5) +### Core Modules (8) Core modules are **always enabled** and cannot be disabled. They provide fundamental platform functionality. @@ -83,24 +84,29 @@ Core modules are **always enabled** and cannot be disabled. They provide fundame | `cms` | Content pages, media library, themes | Content management | 5 | | `customers` | Customer database, profiles, segmentation | Customer data management | 4 | | `tenancy` | Platform, company, vendor, admin user management | Multi-tenant infrastructure | 4 | +| `billing` | Platform subscriptions, tier limits, vendor invoices | Subscription management, tier-based feature gating | 5 | +| `payments` | Payment gateway integrations (Stripe, PayPal, etc.) | Payment processing, required for billing | 3 | +| `messaging` | Messages, notifications, email templates | Email for registration, password reset, notifications | 3 | -### Optional Modules (11) +**Why these are core:** +- **billing**: Tier limits affect many features (team size, product limits, email providers). Subscription management is fundamental. +- **payments**: Required by billing for subscription payment processing. +- **messaging**: Email is required for user registration, password reset, and team invitations. + +### Optional Modules (8) Optional modules can be **enabled or disabled per platform**. They provide additional functionality that may not be needed by all platforms. | Module | Dependencies | Description | Permissions | |--------|--------------|-------------|-------------| -| `analytics` | - | Reports, dashboards | 3 | -| `billing` | `payments` | Platform subscriptions, vendor invoices | 5 | +| `analytics` | - | Reports, dashboards, advanced statistics | 3 | | `cart` | `inventory` | Shopping cart management, session-based carts | 2 | | `catalog` | `inventory` | Customer-facing product browsing | 6 | -| `checkout` | `cart`, `orders`, `payments`, `customers` | Cart-to-order conversion, checkout flow | 2 | +| `checkout` | `cart`, `orders`, `customers` | Cart-to-order conversion, checkout flow | 2 | | `inventory` | - | Stock management, locations | 3 | | `loyalty` | `customers` | Stamp/points loyalty programs, wallet integration | 4 | -| `marketplace` | `inventory` | Letzshop integration | 3 | -| `messaging` | - | Messages, notifications | 3 | -| `orders` | `payments` | Order management, customer checkout | 4 | -| `payments` | - | Payment gateway integrations (Stripe, PayPal, etc.) | 3 | +| `marketplace` | `inventory` | Letzshop integration, product import/export | 3 | +| `orders` | - | Order management, customer checkout | 4 | ### Internal Modules (2) @@ -243,6 +249,7 @@ analytics_module = ModuleDefinition( | `is_internal` | `bool` | Admin-only if True | | `is_self_contained` | `bool` | Uses self-contained structure | | `metrics_provider` | `Callable` | Factory function returning MetricsProviderProtocol (see [Metrics Provider Pattern](metrics-provider-pattern.md)) | +| `widget_provider` | `Callable` | Factory function returning DashboardWidgetProviderProtocol (see [Widget Provider Pattern](widget-provider-pattern.md)) | ## Route Auto-Discovery @@ -308,22 +315,27 @@ The Framework Layer provides infrastructure that modules depend on. These are ** Modules can depend on other modules. When enabling a module, its dependencies are automatically enabled. ``` - payments - ↙ ↘ - billing orders ←──┐ - ↑ │ - inventory │ │ - ↓ cart │ - marketplace ↘ │ - checkout +CORE MODULES (always enabled): +┌─────────────────────────────────────────────────────────┐ +│ contracts core tenancy cms customers │ +│ billing ← payments messaging │ +└─────────────────────────────────────────────────────────┘ + +OPTIONAL MODULES (dependencies shown): + inventory + ↙ ↓ ↘ + catalog cart marketplace + ↓ + checkout ← orders ``` **Dependency Rules:** -1. Core modules cannot depend on optional modules +1. **Core modules NEVER import from optional modules** (see [Cross-Module Import Rules](cross-module-import-rules.md)) 2. Enabling a module auto-enables its dependencies 3. Disabling a module auto-disables modules that depend on it 4. Circular dependencies are not allowed +5. Use protocol patterns (Metrics/Widget Provider) for cross-module data ## Module Registry @@ -347,7 +359,7 @@ billing = get_module("billing") tier = get_module_tier("billing") # Returns "optional" # Get all core module codes -core_codes = get_core_module_codes() # {"core", "tenancy", "cms", "customers"} +core_codes = get_core_module_codes() # {"contracts", "core", "tenancy", "cms", "customers", "billing", "payments", "messaging"} ``` ## Module Service @@ -1252,16 +1264,20 @@ python scripts/validate_architecture.py ### Don't - Create circular dependencies -- Make core modules depend on optional modules +- **Make core modules import from optional modules** (use provider patterns instead) - Put framework-level code in modules - Skip migration naming conventions - Forget `__init__.py` in tasks directory - Manually register modules in registry.py (use auto-discovery) +- Import optional modules at the top of core module files +- Use direct imports when a protocol pattern exists ## Related Documentation - [Creating Modules](../development/creating-modules.md) - Step-by-step guide +- [Cross-Module Import Rules](cross-module-import-rules.md) - Import constraints and patterns - [Metrics Provider Pattern](metrics-provider-pattern.md) - Dashboard statistics architecture +- [Widget Provider Pattern](widget-provider-pattern.md) - Dashboard widgets architecture - [Menu Management](menu-management.md) - Sidebar configuration - [Observability](observability.md) - Health checks integration - [Feature Gating](../implementation/feature-gating-system.md) - Tier-based access diff --git a/docs/architecture/widget-provider-pattern.md b/docs/architecture/widget-provider-pattern.md new file mode 100644 index 00000000..e99170cf --- /dev/null +++ b/docs/architecture/widget-provider-pattern.md @@ -0,0 +1,449 @@ +# Widget Provider Pattern + +The widget provider pattern enables modules to provide dashboard widgets (lists of recent items, breakdowns) without creating cross-module dependencies. This pattern extends the [Metrics Provider Pattern](metrics-provider-pattern.md) to support richer widget data. + +## Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Dashboard Request │ +│ (Admin Dashboard or Vendor Dashboard) │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ WidgetAggregatorService │ +│ (app/modules/core/services/widget_aggregator.py) │ +│ │ +│ • Discovers WidgetProviders from all enabled modules │ +│ • Calls get_vendor_widgets() or get_platform_widgets() │ +│ • Returns categorized widgets dict │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────────────┼───────────────────────┐ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │tenancy_widgets│ │market_widgets │ │catalog_widgets│ + │ (enabled) │ │ (enabled) │ │ (disabled) │ + └───────┬───────┘ └───────┬───────┘ └───────────────┘ + │ │ │ + ▼ ▼ × (skipped) + ┌───────────────┐ ┌───────────────┐ + │recent_vendors │ │recent_imports │ + │ (ListWidget) │ │ (ListWidget) │ + └───────────────┘ └───────────────┘ + │ │ + └───────────┬───────────┘ + ▼ + ┌─────────────────────────────────┐ + │ Categorized Widgets │ + │{"tenancy": [...], "marketplace": [...]}│ + └─────────────────────────────────┘ +``` + +## Architectural Principle + +**Core defines contracts, modules implement them, core discovers and aggregates.** + +This ensures: +- Core modules **never import** from optional modules +- Optional modules remain truly optional (can be removed without breaking app) +- Dashboard always works with partial data when modules are disabled + +## Key Components + +### 1. WidgetContext Dataclass + +Context for widget queries (date ranges, limits): + +```python +# app/modules/contracts/widgets.py +from dataclasses import dataclass +from datetime import datetime + +@dataclass +class WidgetContext: + date_from: datetime | None = None + date_to: datetime | None = None + limit: int = 5 # Max items for list widgets + include_details: bool = False +``` + +### 2. Widget Item Types + +Items that populate widgets: + +```python +@dataclass +class WidgetListItem: + """Single item in a list widget (recent vendors, orders, imports).""" + id: int | str + title: str + subtitle: str | None = None + status: str | None = None # "success", "warning", "error", "neutral" + timestamp: datetime | None = None + url: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class WidgetBreakdownItem: + """Single row in a breakdown widget (stats by marketplace, category).""" + label: str + value: int | float + secondary_value: int | float | None = None + percentage: float | None = None + icon: str | None = None +``` + +### 3. Widget Containers + +Containers that hold widget items: + +```python +@dataclass +class ListWidget: + """Widget containing a list of items.""" + items: list[WidgetListItem] + total_count: int | None = None + view_all_url: str | None = None + + +@dataclass +class BreakdownWidget: + """Widget containing grouped statistics.""" + items: list[WidgetBreakdownItem] + total: int | float | None = None + +WidgetData = ListWidget | BreakdownWidget +``` + +### 4. DashboardWidget (Main Envelope) + +The standard widget envelope with metadata: + +```python +@dataclass +class DashboardWidget: + key: str # "marketplace.recent_imports" + widget_type: str # "list" or "breakdown" + title: str # "Recent Imports" + category: str # "marketplace" + data: WidgetData + icon: str | None = None # Lucide icon name + description: str | None = None + order: int = 100 # Display order (lower = higher priority) +``` + +### 5. DashboardWidgetProviderProtocol + +Protocol that modules implement: + +```python +@runtime_checkable +class DashboardWidgetProviderProtocol(Protocol): + @property + def widgets_category(self) -> str: + """Category name (e.g., "marketplace", "orders").""" + ... + + def get_vendor_widgets( + self, db: Session, vendor_id: int, context: WidgetContext | None = None + ) -> list[DashboardWidget]: + """Get widgets for vendor dashboard.""" + ... + + def get_platform_widgets( + self, db: Session, platform_id: int, context: WidgetContext | None = None + ) -> list[DashboardWidget]: + """Get widgets for admin/platform dashboard.""" + ... +``` + +### 6. WidgetAggregatorService + +Central service in core that discovers and aggregates widgets: + +```python +# app/modules/core/services/widget_aggregator.py +class WidgetAggregatorService: + def _get_enabled_providers(self, db, platform_id): + # Iterate MODULES registry + # Skip modules without widget_provider + # Check module enablement (except core) + # Return (module, provider) tuples + + def get_vendor_dashboard_widgets( + self, db, vendor_id, platform_id, context=None + ) -> dict[str, list[DashboardWidget]]: + """Returns widgets grouped by category for vendor dashboard.""" + + def get_admin_dashboard_widgets( + self, db, platform_id, context=None + ) -> dict[str, list[DashboardWidget]]: + """Returns widgets grouped by category for admin dashboard.""" + + def get_widgets_flat( + self, db, platform_id, vendor_id=None, context=None + ) -> list[DashboardWidget]: + """Returns flat list sorted by order.""" + +widget_aggregator = WidgetAggregatorService() +``` + +## Implementing a Widget Provider + +### Step 1: Create the Widget Provider Class + +```python +# app/modules/marketplace/services/marketplace_widgets.py +import logging +from sqlalchemy.orm import Session + +from app.modules.contracts.widgets import ( + DashboardWidget, + ListWidget, + WidgetContext, + WidgetListItem, +) + +logger = logging.getLogger(__name__) + + +class MarketplaceWidgetProvider: + """Widget provider for marketplace module.""" + + @property + def widgets_category(self) -> str: + return "marketplace" + + def _map_status_to_display(self, status: str) -> str: + """Map job status to widget status indicator.""" + status_map = { + "pending": "neutral", + "processing": "warning", + "completed": "success", + "completed_with_errors": "warning", + "failed": "error", + } + return status_map.get(status, "neutral") + + def get_vendor_widgets( + self, + db: Session, + vendor_id: int, + context: WidgetContext | None = None, + ) -> list[DashboardWidget]: + """Get marketplace widgets for a vendor dashboard.""" + from app.modules.marketplace.models import MarketplaceImportJob + + limit = context.limit if context else 5 + + try: + jobs = ( + db.query(MarketplaceImportJob) + .filter(MarketplaceImportJob.vendor_id == vendor_id) + .order_by(MarketplaceImportJob.created_at.desc()) + .limit(limit) + .all() + ) + + items = [ + WidgetListItem( + id=job.id, + title=f"Import #{job.id}", + subtitle=f"{job.marketplace} - {job.language.upper()}", + status=self._map_status_to_display(job.status), + timestamp=job.created_at, + url=f"/vendor/marketplace/imports/{job.id}", + metadata={ + "total_processed": job.total_processed or 0, + "imported_count": job.imported_count or 0, + "error_count": job.error_count or 0, + }, + ) + for job in jobs + ] + + return [ + DashboardWidget( + key="marketplace.recent_imports", + widget_type="list", + title="Recent Imports", + category="marketplace", + data=ListWidget(items=items), + icon="download", + order=20, + ) + ] + except Exception as e: + logger.warning(f"Failed to get marketplace vendor widgets: {e}") + return [] + + def get_platform_widgets( + self, + db: Session, + platform_id: int, + context: WidgetContext | None = None, + ) -> list[DashboardWidget]: + """Get marketplace widgets for the admin dashboard.""" + # Similar implementation for platform-wide metrics + # Uses VendorPlatform junction table to filter by platform + ... + + +# Singleton instance +marketplace_widget_provider = MarketplaceWidgetProvider() +``` + +### Step 2: Register in Module Definition + +```python +# app/modules/marketplace/definition.py +from app.modules.base import ModuleDefinition + + +def _get_widget_provider(): + """Lazy import to avoid circular imports.""" + from app.modules.marketplace.services.marketplace_widgets import marketplace_widget_provider + return marketplace_widget_provider + + +marketplace_module = ModuleDefinition( + code="marketplace", + name="Marketplace (Letzshop)", + # ... other config ... + + # Register the widget provider + widget_provider=_get_widget_provider, +) +``` + +### Step 3: Widgets Appear Automatically + +When the module is enabled, its widgets automatically appear in dashboards. + +## Translation Handling + +Translations are handled **by the optional module itself**, not by core: + +```python +from app.core.i18n import gettext as _ + +def get_platform_widgets(self, db, platform_id, context=None): + return [ + DashboardWidget( + key="marketplace.recent_imports", + title=_("Recent Imports"), # Module translates + description=_("Latest import jobs"), # Module translates + # ... + ) + ] +``` + +Core receives already-translated strings and doesn't need translation logic. + +## Available Widget Providers + +| Module | Category | Widgets Provided | +|--------|----------|------------------| +| **tenancy** | `tenancy` | recent_vendors (ListWidget) | +| **marketplace** | `marketplace` | recent_imports (ListWidget) | +| **orders** | `orders` | recent_orders (ListWidget) - future | +| **customers** | `customers` | recent_customers (ListWidget) - future | + +## Comparison with Metrics Provider + +| Aspect | MetricsProvider | DashboardWidgetProvider | +|--------|-----------------|------------------------| +| Protocol location | `contracts/metrics.py` | `contracts/widgets.py` | +| Data unit | `MetricValue` | `DashboardWidget` | +| Context | `MetricsContext` | `WidgetContext` | +| Aggregator | `StatsAggregatorService` | `WidgetAggregatorService` | +| Registration field | `metrics_provider` | `widget_provider` | +| Scope methods | `get_vendor_metrics`, `get_platform_metrics` | `get_vendor_widgets`, `get_platform_widgets` | +| Use case | Numeric statistics | Lists, breakdowns, rich data | + +## Dashboard Usage Example + +```python +# app/modules/core/routes/api/admin_dashboard.py +from app.modules.core.services.widget_aggregator import widget_aggregator + +@admin_dashboard_router.get("") +def get_admin_dashboard(...): + # Get widgets from all enabled modules + widgets = widget_aggregator.get_admin_dashboard_widgets(db, platform_id) + + # Extract for backward compatibility + recent_imports = [] + if "marketplace" in widgets: + for widget in widgets["marketplace"]: + if widget.key == "marketplace.recent_imports": + recent_imports = [item_to_dict(i) for i in widget.data.items] + + return AdminDashboardResponse( + # ... existing fields ... + recent_imports=recent_imports, + ) +``` + +## Multi-Platform Architecture + +Always use `VendorPlatform` junction table for platform-level queries: + +```python +from app.modules.tenancy.models import VendorPlatform + +vendor_ids = ( + db.query(VendorPlatform.vendor_id) + .filter(VendorPlatform.platform_id == platform_id) + .subquery() +) + +jobs = ( + db.query(MarketplaceImportJob) + .filter(MarketplaceImportJob.vendor_id.in_(vendor_ids)) + .order_by(MarketplaceImportJob.created_at.desc()) + .limit(limit) + .all() +) +``` + +## Error Handling + +Widget providers are wrapped in try/except to prevent one failing module from breaking the entire dashboard: + +```python +try: + widgets = provider.get_platform_widgets(db, platform_id, context) +except Exception as e: + logger.warning(f"Failed to get {provider.widgets_category} widgets: {e}") + widgets = [] # Continue with empty widgets for this module +``` + +## Best Practices + +### Do + +- Use lazy imports inside widget methods to avoid circular imports +- Always use `VendorPlatform` junction table for platform-level queries +- Return empty list on error, don't raise exceptions +- Log warnings for debugging but don't crash +- Include helpful descriptions and icons for UI +- Handle translations in the module, not in core + +### Don't + +- Import from optional modules at the top of core module files +- Assume `Vendor.platform_id` exists (it doesn't!) +- Let exceptions propagate from widget providers +- Create hard dependencies between core and optional modules +- Rely on core to translate widget strings + +## Related Documentation + +- [Metrics Provider Pattern](metrics-provider-pattern.md) - Numeric statistics architecture +- [Module System Architecture](module-system.md) - Module structure and auto-discovery +- [Multi-Tenant Architecture](multi-tenant.md) - Platform/vendor/company hierarchy +- [Cross-Module Import Rules](cross-module-import-rules.md) - Import constraints