diff --git a/app/modules/analytics/schemas/stats.py b/app/modules/analytics/schemas/stats.py index b0b8f5f6..8498d508 100644 --- a/app/modules/analytics/schemas/stats.py +++ b/app/modules/analytics/schemas/stats.py @@ -2,7 +2,9 @@ """ Analytics module schemas for statistics and reporting. -This is the canonical location for stats schemas. +Base dashboard schemas are defined in core.schemas.dashboard. +This module re-exports them for backward compatibility and adds +analytics-specific schemas (trends, reports, etc.). """ from datetime import datetime @@ -11,206 +13,29 @@ from typing import Any from pydantic import BaseModel, Field - -class StatsResponse(BaseModel): - """Comprehensive platform statistics response schema.""" - - total_products: int - unique_brands: int - unique_categories: int - unique_marketplaces: int = 0 - unique_vendors: int = 0 - total_inventory_entries: int = 0 - total_inventory_quantity: int = 0 - - -class MarketplaceStatsResponse(BaseModel): - """Statistics per marketplace response schema.""" - - marketplace: str - total_products: int - unique_vendors: int - unique_brands: int +# Re-export base dashboard schemas from core for backward compatibility +# These are the canonical definitions in core module +from app.modules.core.schemas.dashboard import ( + AdminDashboardResponse, + ImportStatsResponse, + MarketplaceStatsResponse, + OrderStatsBasicResponse, + PlatformStatsResponse, + ProductStatsResponse, + StatsResponse, + UserStatsResponse, + VendorCustomerStats, + VendorDashboardStatsResponse, + VendorInfo, + VendorOrderStats, + VendorProductStats, + VendorRevenueStats, + VendorStatsResponse, +) # ============================================================================ -# Import Statistics -# ============================================================================ - - -class ImportStatsResponse(BaseModel): - """Import job statistics response schema. - - Used by: GET /api/v1/admin/marketplace-import-jobs/stats - """ - - total: int = Field(..., description="Total number of import jobs") - pending: int = Field(..., description="Jobs waiting to start") - processing: int = Field(..., description="Jobs currently running") - completed: int = Field(..., description="Successfully completed jobs") - failed: int = Field(..., description="Failed jobs") - success_rate: float = Field(..., description="Percentage of successful imports") - - -# ============================================================================ -# User Statistics -# ============================================================================ - - -class UserStatsResponse(BaseModel): - """User statistics response schema. - - Used by: Platform statistics endpoints - """ - - total_users: int = Field(..., description="Total number of users") - active_users: int = Field(..., description="Number of active users") - inactive_users: int = Field(..., description="Number of inactive users") - admin_users: int = Field(..., description="Number of admin users") - activation_rate: float = Field(..., description="Percentage of active users") - - -# ============================================================================ -# Vendor Statistics (Admin) -# ============================================================================ - - -class VendorStatsResponse(BaseModel): - """Vendor statistics response schema for admin dashboard. - - Used by: GET /api/v1/admin/vendors/stats - """ - - total: int = Field(..., description="Total number of vendors") - verified: int = Field(..., description="Number of verified vendors") - pending: int = Field(..., description="Number of pending verification vendors") - inactive: int = Field(..., description="Number of inactive vendors") - - -# ============================================================================ -# Product Statistics -# ============================================================================ - - -class ProductStatsResponse(BaseModel): - """Product statistics response schema. - - Used by: Platform statistics endpoints - """ - - total_products: int = Field(0, description="Total number of products") - active_products: int = Field(0, description="Number of active products") - out_of_stock: int = Field(0, description="Number of out-of-stock products") - - -# ============================================================================ -# Platform Statistics (Combined) -# ============================================================================ - - -class OrderStatsBasicResponse(BaseModel): - """Basic order statistics (stub until Order model is fully implemented). - - Used by: Platform statistics endpoints - """ - - total_orders: int = Field(0, description="Total number of orders") - pending_orders: int = Field(0, description="Number of pending orders") - completed_orders: int = Field(0, description="Number of completed orders") - - -class PlatformStatsResponse(BaseModel): - """Combined platform statistics response schema. - - Used by: GET /api/v1/admin/dashboard/stats/platform - """ - - users: UserStatsResponse - vendors: VendorStatsResponse - products: ProductStatsResponse - orders: OrderStatsBasicResponse - imports: ImportStatsResponse - - -# ============================================================================ -# Admin Dashboard Response -# ============================================================================ - - -class AdminDashboardResponse(BaseModel): - """Admin dashboard response schema. - - Used by: GET /api/v1/admin/dashboard - """ - - platform: dict[str, Any] = Field(..., description="Platform information") - users: UserStatsResponse - vendors: VendorStatsResponse - recent_vendors: list[dict[str, Any]] = Field( - default_factory=list, description="Recent vendors" - ) - recent_imports: list[dict[str, Any]] = Field( - default_factory=list, description="Recent import jobs" - ) - - -# ============================================================================ -# Vendor Dashboard Statistics -# ============================================================================ - - -class VendorProductStats(BaseModel): - """Vendor product statistics.""" - - total: int = Field(0, description="Total products in catalog") - active: int = Field(0, description="Active products") - - -class VendorOrderStats(BaseModel): - """Vendor order statistics.""" - - total: int = Field(0, description="Total orders") - pending: int = Field(0, description="Pending orders") - completed: int = Field(0, description="Completed orders") - - -class VendorCustomerStats(BaseModel): - """Vendor customer statistics.""" - - total: int = Field(0, description="Total customers") - active: int = Field(0, description="Active customers") - - -class VendorRevenueStats(BaseModel): - """Vendor revenue statistics.""" - - total: float = Field(0, description="Total revenue") - this_month: float = Field(0, description="Revenue this month") - - -class VendorInfo(BaseModel): - """Vendor basic info for dashboard.""" - - id: int - name: str - vendor_code: str - - -class VendorDashboardStatsResponse(BaseModel): - """Vendor dashboard statistics response schema. - - Used by: GET /api/v1/vendor/dashboard/stats - """ - - vendor: VendorInfo - products: VendorProductStats - orders: VendorOrderStats - customers: VendorCustomerStats - revenue: VendorRevenueStats - - -# ============================================================================ -# Vendor Analytics +# Vendor Analytics (Analytics-specific, not in core) # ============================================================================ @@ -327,6 +152,7 @@ class OrderStatsResponse(BaseModel): __all__ = [ + # Re-exported from core.schemas.dashboard (for backward compatibility) "StatsResponse", "MarketplaceStatsResponse", "ImportStatsResponse", @@ -342,6 +168,7 @@ __all__ = [ "VendorRevenueStats", "VendorInfo", "VendorDashboardStatsResponse", + # Analytics-specific schemas "VendorAnalyticsImports", "VendorAnalyticsCatalog", "VendorAnalyticsInventory", diff --git a/app/modules/base.py b/app/modules/base.py index 0585ebd0..7b7563cf 100644 --- a/app/modules/base.py +++ b/app/modules/base.py @@ -44,6 +44,8 @@ if TYPE_CHECKING: from fastapi import APIRouter from pydantic import BaseModel + from app.modules.contracts.metrics import MetricsProviderProtocol + from app.modules.enums import FrontendType @@ -391,6 +393,26 @@ class ModuleDefinition: # ) context_providers: dict[FrontendType, Callable[..., dict[str, Any]]] = field(default_factory=dict) + # ========================================================================= + # Metrics Provider (Module-Driven Statistics) + # ========================================================================= + # Callable that returns a MetricsProviderProtocol implementation. + # Use a callable (factory function) to enable lazy loading and avoid + # circular imports. Each module can provide its own metrics for dashboards. + # + # Example: + # def _get_metrics_provider(): + # from app.modules.orders.services.order_metrics import order_metrics_provider + # return order_metrics_provider + # + # orders_module = ModuleDefinition( + # code="orders", + # metrics_provider=_get_metrics_provider, + # ) + # + # The provider will be discovered by core's StatsAggregator service. + metrics_provider: "Callable[[], MetricsProviderProtocol] | None" = None + # ========================================================================= # Menu Item Methods (Legacy - uses menu_items dict of IDs) # ========================================================================= @@ -755,6 +777,28 @@ class ModuleDefinition: """Get list of frontend types this module provides context for.""" return list(self.context_providers.keys()) + # ========================================================================= + # Metrics Provider Methods + # ========================================================================= + + def has_metrics_provider(self) -> bool: + """Check if this module has a metrics provider.""" + return self.metrics_provider is not None + + def get_metrics_provider_instance(self) -> "MetricsProviderProtocol | None": + """ + Get the metrics provider instance for this module. + + Calls the metrics_provider factory function to get the provider. + Returns None if no provider is configured. + + Returns: + MetricsProviderProtocol instance, or None + """ + if self.metrics_provider is None: + return None + return self.metrics_provider() + # ========================================================================= # Magic Methods # ========================================================================= diff --git a/app/modules/catalog/definition.py b/app/modules/catalog/definition.py index 6634e588..67fc8a5e 100644 --- a/app/modules/catalog/definition.py +++ b/app/modules/catalog/definition.py @@ -29,6 +29,13 @@ def _get_vendor_router(): return vendor_router +def _get_metrics_provider(): + """Lazy import of metrics provider to avoid circular imports.""" + from app.modules.catalog.services.catalog_metrics import catalog_metrics_provider + + return catalog_metrics_provider + + # Catalog module definition catalog_module = ModuleDefinition( code="catalog", @@ -105,6 +112,8 @@ catalog_module = ModuleDefinition( ), ], }, + # Metrics provider for dashboard statistics + metrics_provider=_get_metrics_provider, ) diff --git a/app/modules/catalog/services/catalog_metrics.py b/app/modules/catalog/services/catalog_metrics.py new file mode 100644 index 00000000..b5cc05b2 --- /dev/null +++ b/app/modules/catalog/services/catalog_metrics.py @@ -0,0 +1,261 @@ +# app/modules/catalog/services/catalog_metrics.py +""" +Metrics provider for the catalog module. + +Provides metrics for: +- Product counts +- Active/inactive products +- Featured products +""" + +import logging +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.modules.contracts.metrics import ( + MetricValue, + MetricsContext, + MetricsProviderProtocol, +) + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + + +class CatalogMetricsProvider: + """ + Metrics provider for catalog module. + + Provides product-related metrics for vendor and platform dashboards. + """ + + @property + def metrics_category(self) -> str: + return "catalog" + + def get_vendor_metrics( + self, + db: Session, + vendor_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get product metrics for a specific vendor. + + Provides: + - Total products + - Active products + - Featured products + - New products (in period) + """ + from app.modules.catalog.models import Product + + try: + # Total products + total_products = ( + db.query(Product).filter(Product.vendor_id == vendor_id).count() + ) + + # Active products + active_products = ( + db.query(Product) + .filter(Product.vendor_id == vendor_id, Product.is_active == True) + .count() + ) + + # Featured products + featured_products = ( + db.query(Product) + .filter( + Product.vendor_id == vendor_id, + Product.is_featured == True, + Product.is_active == True, + ) + .count() + ) + + # New products (default to last 30 days) + date_from = context.date_from if context else None + if date_from is None: + date_from = datetime.utcnow() - timedelta(days=30) + + new_products_query = db.query(Product).filter( + Product.vendor_id == vendor_id, + Product.created_at >= date_from, + ) + if context and context.date_to: + new_products_query = new_products_query.filter( + Product.created_at <= context.date_to + ) + new_products = new_products_query.count() + + # Products with translations + products_with_translations = ( + db.query(func.count(func.distinct(Product.id))) + .filter(Product.vendor_id == vendor_id) + .join(Product.translations) + .scalar() + or 0 + ) + + return [ + MetricValue( + key="catalog.total_products", + value=total_products, + label="Total Products", + category="catalog", + icon="box", + description="Total products in catalog", + ), + MetricValue( + key="catalog.active_products", + value=active_products, + label="Active Products", + category="catalog", + icon="check-circle", + description="Products that are active and visible", + ), + MetricValue( + key="catalog.featured_products", + value=featured_products, + label="Featured Products", + category="catalog", + icon="star", + description="Products marked as featured", + ), + MetricValue( + key="catalog.new_products", + value=new_products, + label="New Products", + category="catalog", + icon="plus-circle", + description="Products added in the period", + ), + ] + except Exception as e: + logger.warning(f"Failed to get catalog vendor metrics: {e}") + return [] + + def get_platform_metrics( + self, + db: Session, + platform_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get product metrics aggregated for a platform. + + Aggregates catalog data across all vendors. + """ + from app.modules.catalog.models import Product + from app.modules.tenancy.models import VendorPlatform + + try: + # Get all vendor IDs for this platform using VendorPlatform junction table + vendor_ids = ( + db.query(VendorPlatform.vendor_id) + .filter( + VendorPlatform.platform_id == platform_id, + VendorPlatform.is_active == True, + ) + .subquery() + ) + + # Total products + total_products = ( + db.query(Product).filter(Product.vendor_id.in_(vendor_ids)).count() + ) + + # Active products + active_products = ( + db.query(Product) + .filter(Product.vendor_id.in_(vendor_ids), Product.is_active == True) + .count() + ) + + # Featured products + featured_products = ( + db.query(Product) + .filter( + Product.vendor_id.in_(vendor_ids), + Product.is_featured == True, + Product.is_active == True, + ) + .count() + ) + + # Vendors with products + vendors_with_products = ( + db.query(func.count(func.distinct(Product.vendor_id))) + .filter(Product.vendor_id.in_(vendor_ids)) + .scalar() + or 0 + ) + + # Average products per vendor + total_vendors = ( + db.query(VendorPlatform) + .filter( + VendorPlatform.platform_id == platform_id, + VendorPlatform.is_active == True, + ) + .count() + ) + avg_products = round(total_products / total_vendors, 1) if total_vendors > 0 else 0 + + return [ + MetricValue( + key="catalog.total_products", + value=total_products, + label="Total Products", + category="catalog", + icon="box", + description="Total products across all vendors", + ), + MetricValue( + key="catalog.active_products", + value=active_products, + label="Active Products", + category="catalog", + icon="check-circle", + description="Products that are active and visible", + ), + MetricValue( + key="catalog.featured_products", + value=featured_products, + label="Featured Products", + category="catalog", + icon="star", + description="Products marked as featured", + ), + MetricValue( + key="catalog.vendors_with_products", + value=vendors_with_products, + label="Vendors with Products", + category="catalog", + icon="store", + description="Vendors that have created products", + ), + MetricValue( + key="catalog.avg_products_per_vendor", + value=avg_products, + label="Avg Products/Vendor", + category="catalog", + icon="calculator", + description="Average products per vendor", + ), + ] + except Exception as e: + logger.warning(f"Failed to get catalog platform metrics: {e}") + return [] + + +# Singleton instance +catalog_metrics_provider = CatalogMetricsProvider() + +__all__ = ["CatalogMetricsProvider", "catalog_metrics_provider"] diff --git a/app/modules/cms/definition.py b/app/modules/cms/definition.py index 9277bd1d..51ef3974 100644 --- a/app/modules/cms/definition.py +++ b/app/modules/cms/definition.py @@ -121,6 +121,13 @@ def _get_vendor_router(): return vendor_router +def _get_metrics_provider(): + """Lazy import of metrics provider to avoid circular imports.""" + from app.modules.cms.services.cms_metrics import cms_metrics_provider + + return cms_metrics_provider + + # CMS module definition - Self-contained module (pilot) cms_module = ModuleDefinition( code="cms", @@ -244,6 +251,8 @@ cms_module = ModuleDefinition( templates_path="templates", # Module-specific translations (accessible via cms.* keys) locales_path="locales", + # Metrics provider for dashboard statistics + metrics_provider=_get_metrics_provider, ) diff --git a/app/modules/cms/services/cms_metrics.py b/app/modules/cms/services/cms_metrics.py new file mode 100644 index 00000000..257ef234 --- /dev/null +++ b/app/modules/cms/services/cms_metrics.py @@ -0,0 +1,253 @@ +# app/modules/cms/services/cms_metrics.py +""" +Metrics provider for the CMS module. + +Provides metrics for: +- Content pages +- Media files +- Themes +""" + +import logging +from typing import TYPE_CHECKING + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.modules.contracts.metrics import ( + MetricValue, + MetricsContext, + MetricsProviderProtocol, +) + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + + +class CMSMetricsProvider: + """ + Metrics provider for CMS module. + + Provides content management metrics for vendor and platform dashboards. + """ + + @property + def metrics_category(self) -> str: + return "cms" + + def get_vendor_metrics( + self, + db: Session, + vendor_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get CMS metrics for a specific vendor. + + Provides: + - Total content pages + - Published pages + - Media files count + - Theme status + """ + from app.modules.cms.models import ContentPage, MediaFile, VendorTheme + + try: + # Content pages + total_pages = ( + db.query(ContentPage).filter(ContentPage.vendor_id == vendor_id).count() + ) + + published_pages = ( + db.query(ContentPage) + .filter( + ContentPage.vendor_id == vendor_id, + ContentPage.is_published == True, + ) + .count() + ) + + # Media files + media_count = ( + db.query(MediaFile).filter(MediaFile.vendor_id == vendor_id).count() + ) + + # Total media size (in MB) + total_media_size = ( + db.query(func.sum(MediaFile.file_size)) + .filter(MediaFile.vendor_id == vendor_id) + .scalar() + or 0 + ) + total_media_size_mb = round(total_media_size / (1024 * 1024), 2) + + # Theme configured + has_theme = ( + db.query(VendorTheme).filter(VendorTheme.vendor_id == vendor_id).first() + is not None + ) + + return [ + MetricValue( + key="cms.total_pages", + value=total_pages, + label="Total Pages", + category="cms", + icon="file-text", + description="Total content pages created", + ), + MetricValue( + key="cms.published_pages", + value=published_pages, + label="Published Pages", + category="cms", + icon="globe", + description="Content pages that are published", + ), + MetricValue( + key="cms.media_count", + value=media_count, + label="Media Files", + category="cms", + icon="image", + description="Files in media library", + ), + MetricValue( + key="cms.media_size", + value=total_media_size_mb, + label="Media Size", + category="cms", + icon="hard-drive", + unit="MB", + description="Total storage used by media", + ), + MetricValue( + key="cms.has_theme", + value=1 if has_theme else 0, + label="Theme Configured", + category="cms", + icon="palette", + description="Whether a custom theme is configured", + ), + ] + except Exception as e: + logger.warning(f"Failed to get CMS vendor metrics: {e}") + return [] + + def get_platform_metrics( + self, + db: Session, + platform_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get CMS metrics aggregated for a platform. + + Aggregates content management data across all vendors. + """ + from app.modules.cms.models import ContentPage, MediaFile, VendorTheme + from app.modules.tenancy.models import VendorPlatform + + try: + # Get all vendor IDs for this platform using VendorPlatform junction table + vendor_ids = ( + db.query(VendorPlatform.vendor_id) + .filter( + VendorPlatform.platform_id == platform_id, + VendorPlatform.is_active == True, + ) + .subquery() + ) + + # Content pages + total_pages = ( + db.query(ContentPage) + .filter(ContentPage.vendor_id.in_(vendor_ids)) + .count() + ) + + published_pages = ( + db.query(ContentPage) + .filter( + ContentPage.vendor_id.in_(vendor_ids), + ContentPage.is_published == True, + ) + .count() + ) + + # Media files + media_count = ( + db.query(MediaFile).filter(MediaFile.vendor_id.in_(vendor_ids)).count() + ) + + # Total media size (in GB for platform-level) + total_media_size = ( + db.query(func.sum(MediaFile.file_size)) + .filter(MediaFile.vendor_id.in_(vendor_ids)) + .scalar() + or 0 + ) + total_media_size_gb = round(total_media_size / (1024 * 1024 * 1024), 2) + + # Vendors with themes + vendors_with_themes = ( + db.query(func.count(func.distinct(VendorTheme.vendor_id))) + .filter(VendorTheme.vendor_id.in_(vendor_ids)) + .scalar() + or 0 + ) + + return [ + MetricValue( + key="cms.total_pages", + value=total_pages, + label="Total Pages", + category="cms", + icon="file-text", + description="Total content pages across all vendors", + ), + MetricValue( + key="cms.published_pages", + value=published_pages, + label="Published Pages", + category="cms", + icon="globe", + description="Published content pages across all vendors", + ), + MetricValue( + key="cms.media_count", + value=media_count, + label="Media Files", + category="cms", + icon="image", + description="Total media files across all vendors", + ), + MetricValue( + key="cms.media_size", + value=total_media_size_gb, + label="Total Media Size", + category="cms", + icon="hard-drive", + unit="GB", + description="Total storage used by media", + ), + MetricValue( + key="cms.vendors_with_themes", + value=vendors_with_themes, + label="Themed Vendors", + category="cms", + icon="palette", + description="Vendors with custom themes", + ), + ] + except Exception as e: + logger.warning(f"Failed to get CMS platform metrics: {e}") + return [] + + +# Singleton instance +cms_metrics_provider = CMSMetricsProvider() + +__all__ = ["CMSMetricsProvider", "cms_metrics_provider"] diff --git a/app/modules/contracts/__init__.py b/app/modules/contracts/__init__.py index b3d3faf5..c392ca3a 100644 --- a/app/modules/contracts/__init__.py +++ b/app/modules/contracts/__init__.py @@ -21,12 +21,34 @@ Usage: from app.modules.cms.services import content_page_service self._content = content_page_service return self._content + +Metrics Provider Pattern: + from app.modules.contracts.metrics import MetricsProviderProtocol, MetricValue + + class OrderMetricsProvider: + @property + def metrics_category(self) -> str: + return "orders" + + def get_vendor_metrics(self, db, vendor_id, context=None) -> list[MetricValue]: + return [MetricValue(key="orders.total", value=42, label="Total", category="orders")] """ from app.modules.contracts.base import ServiceProtocol from app.modules.contracts.cms import ContentServiceProtocol +from app.modules.contracts.metrics import ( + MetricValue, + MetricsContext, + MetricsProviderProtocol, +) __all__ = [ + # Base protocols "ServiceProtocol", + # CMS protocols "ContentServiceProtocol", + # Metrics protocols + "MetricValue", + "MetricsContext", + "MetricsProviderProtocol", ] diff --git a/app/modules/contracts/metrics.py b/app/modules/contracts/metrics.py new file mode 100644 index 00000000..535a02c6 --- /dev/null +++ b/app/modules/contracts/metrics.py @@ -0,0 +1,215 @@ +# app/modules/contracts/metrics.py +""" +Metrics provider protocol for cross-module statistics aggregation. + +This module defines the protocol that modules implement to expose their own metrics. +The core module's StatsAggregator discovers and aggregates all providers. + +Benefits: +- Each module owns its metrics (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 metrics (just implement protocol in your module) + +Usage: + # 1. Implement the protocol in your module + class OrderMetricsProvider: + @property + def metrics_category(self) -> str: + return "orders" + + def get_vendor_metrics(self, db, vendor_id, **kwargs) -> list[MetricValue]: + return [ + MetricValue(key="orders.total", value=42, label="Total Orders", category="orders") + ] + + # 2. Register in module definition + def _get_metrics_provider(): + from app.modules.orders.services.order_metrics import order_metrics_provider + return order_metrics_provider + + orders_module = ModuleDefinition( + code="orders", + metrics_provider=_get_metrics_provider, + # ... + ) + + # 3. Metrics appear automatically in dashboards when module is enabled +""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + + +@dataclass +class MetricValue: + """ + Standard metric value with metadata. + + This is the unit of data returned by metrics providers. + It contains both the value and metadata for display. + + Attributes: + key: Unique identifier for this metric (e.g., "orders.total_count") + Format: "{category}.{metric_name}" for consistency + value: The actual metric value (int, float, or str) + label: Human-readable label for display (e.g., "Total Orders") + category: Grouping category (should match metrics_category of provider) + icon: Optional Lucide icon name for UI display (e.g., "shopping-cart") + description: Optional longer description of what this metric represents + unit: Optional unit suffix (e.g., "EUR", "%", "items") + trend: Optional trend indicator ("up", "down", "stable") + trend_value: Optional numeric trend value (e.g., percentage change) + + Example: + MetricValue( + key="orders.total_count", + value=1234, + label="Total Orders", + category="orders", + icon="shopping-cart", + unit="orders", + ) + """ + + key: str + value: int | float | str + label: str + category: str + icon: str | None = None + description: str | None = None + unit: str | None = None + trend: str | None = None # "up", "down", "stable" + trend_value: float | None = None + + +@dataclass +class MetricsContext: + """ + Context for metrics collection. + + Provides filtering and scoping options for metrics providers. + + Attributes: + date_from: Start of date range filter + date_to: End of date range filter + include_trends: Whether to calculate trends (may be expensive) + period: Time period for analytics (e.g., "7d", "30d", "90d", "1y") + """ + + date_from: datetime | None = None + date_to: datetime | None = None + include_trends: bool = False + period: str = "30d" + + +@runtime_checkable +class MetricsProviderProtocol(Protocol): + """ + Protocol for modules that provide metrics/statistics. + + Each module implements this to expose its own metrics. + The core module's StatsAggregator discovers and aggregates all providers. + + Implementation Notes: + - Providers should be stateless (all data via db session) + - Return empty list if no metrics available (don't raise) + - Use consistent key format: "{category}.{metric_name}" + - Include icon hints for UI rendering + - Be mindful of query performance (use efficient aggregations) + + Example Implementation: + class OrderMetricsProvider: + @property + def metrics_category(self) -> str: + return "orders" + + def get_vendor_metrics( + self, db: Session, vendor_id: int, context: MetricsContext | None = None + ) -> list[MetricValue]: + from app.modules.orders.models import Order + total = db.query(Order).filter(Order.vendor_id == vendor_id).count() + return [ + MetricValue( + key="orders.total", + value=total, + label="Total Orders", + category="orders", + icon="shopping-cart" + ) + ] + + def get_platform_metrics( + self, db: Session, platform_id: int, context: MetricsContext | None = None + ) -> list[MetricValue]: + # Aggregate across all vendors in platform + ... + """ + + @property + def metrics_category(self) -> str: + """ + Category name for this provider's metrics. + + Should be a short, lowercase identifier matching the module's domain. + Examples: "orders", "inventory", "customers", "billing" + + Returns: + Category string used for grouping metrics + """ + ... + + def get_vendor_metrics( + self, + db: "Session", + vendor_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get metrics for a specific vendor. + + Called by the vendor dashboard to display vendor-scoped statistics. + Should only include data belonging to the specified vendor. + + Args: + db: Database session for queries + vendor_id: ID of the vendor to get metrics for + context: Optional filtering/scoping context + + Returns: + List of MetricValue objects for this vendor + """ + ... + + def get_platform_metrics( + self, + db: "Session", + platform_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get metrics aggregated for a platform. + + Called by the admin dashboard to display platform-wide statistics. + Should aggregate data across all vendors in the platform. + + Args: + db: Database session for queries + platform_id: ID of the platform to get metrics for + context: Optional filtering/scoping context + + Returns: + List of MetricValue objects aggregated for the platform + """ + ... + + +__all__ = [ + "MetricValue", + "MetricsContext", + "MetricsProviderProtocol", +] diff --git a/app/modules/core/routes/api/admin_dashboard.py b/app/modules/core/routes/api/admin_dashboard.py index 1c02091f..5156e32a 100644 --- a/app/modules/core/routes/api/admin_dashboard.py +++ b/app/modules/core/routes/api/admin_dashboard.py @@ -1,19 +1,22 @@ # app/modules/core/routes/api/admin_dashboard.py """ 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. + +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 fastapi import APIRouter, Depends +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.tenancy.services.admin_service import admin_service -from app.modules.analytics.services.stats_service import stats_service -from models.schema.auth import UserContext -from app.modules.analytics.schemas import ( +from app.modules.core.schemas.dashboard import ( AdminDashboardResponse, ImportStatsResponse, MarketplaceStatsResponse, @@ -24,35 +27,106 @@ from app.modules.analytics.schemas import ( UserStatsResponse, VendorStatsResponse, ) +from app.modules.core.services.stats_aggregator import stats_aggregator +from app.modules.tenancy.services.admin_service import admin_service +from models.schema.auth import UserContext admin_dashboard_router = APIRouter(prefix="/dashboard") logger = logging.getLogger(__name__) +def _get_platform_id(request: Request, current_admin: UserContext) -> int: + """ + Get platform_id from available sources in priority order. + + Priority: + 1. JWT token (token_platform_id) - set when admin selects a platform + 2. Request state (set by PlatformContextMiddleware) - for page routes + 3. First accessible platform (for platform admins) + 4. Fallback to 1 (for super admins with global access) + + Returns: + Platform ID to use for queries + """ + # 1. From JWT token (most authoritative for API routes) + if current_admin.token_platform_id: + return current_admin.token_platform_id + + # 2. From request state (set by PlatformContextMiddleware) + platform = getattr(request.state, "platform", None) + if platform: + return platform.id + + # 3. For platform admins, use their first accessible platform + if current_admin.accessible_platform_ids: + return current_admin.accessible_platform_ids[0] + + # 4. Fallback for super admins (global access) + return 1 + + +def _extract_metric_value( + metrics: dict[str, list], category: str, key: str, default: int | float = 0 +) -> int | float: + """Extract a specific metric value from categorized metrics.""" + if category not in metrics: + return default + for metric in metrics[category]: + if metric.key == key: + return metric.value + return default + + @admin_dashboard_router.get("", response_model=AdminDashboardResponse) def get_admin_dashboard( + request: Request, db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ): """Get admin dashboard with platform statistics (Admin only).""" - user_stats = stats_service.get_user_statistics(db) - vendor_stats = stats_service.get_vendor_statistics(db) + platform_id = _get_platform_id(request, current_admin) + + # Get aggregated metrics from all enabled modules + metrics = stats_aggregator.get_admin_dashboard_stats(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) + inactive_users = _extract_metric_value(metrics, "tenancy", "tenancy.inactive_users", 0) + admin_users = _extract_metric_value(metrics, "tenancy", "tenancy.admin_users", 0) + activation_rate = _extract_metric_value( + metrics, "tenancy", "tenancy.user_activation_rate", 0 + ) + + # Extract vendor stats from tenancy module + total_vendors = _extract_metric_value(metrics, "tenancy", "tenancy.total_vendors", 0) + verified_vendors = _extract_metric_value( + metrics, "tenancy", "tenancy.verified_vendors", 0 + ) + pending_vendors = _extract_metric_value( + metrics, "tenancy", "tenancy.pending_vendors", 0 + ) + inactive_vendors = _extract_metric_value( + metrics, "tenancy", "tenancy.inactive_vendors", 0 + ) return AdminDashboardResponse( platform={ "name": "Multi-Tenant Ecommerce Platform", "version": "1.0.0", }, - users=UserStatsResponse(**user_stats), + users=UserStatsResponse( + total_users=int(total_users), + active_users=int(active_users), + inactive_users=int(inactive_users), + admin_users=int(admin_users), + activation_rate=float(activation_rate), + ), vendors=VendorStatsResponse( - total=vendor_stats.get("total", vendor_stats.get("total_vendors", 0)), - verified=vendor_stats.get( - "verified", vendor_stats.get("verified_vendors", 0) - ), - pending=vendor_stats.get("pending", vendor_stats.get("pending_vendors", 0)), - inactive=vendor_stats.get( - "inactive", vendor_stats.get("inactive_vendors", 0) - ), + total=int(total_vendors), + verified=int(verified_vendors), + 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), @@ -61,67 +135,168 @@ def get_admin_dashboard( @admin_dashboard_router.get("/stats", response_model=StatsResponse) def get_comprehensive_stats( + request: Request, db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ): """Get comprehensive platform statistics (Admin only).""" - stats_data = stats_service.get_comprehensive_stats(db=db) + platform_id = _get_platform_id(request, current_admin) + + # Get aggregated metrics + metrics = stats_aggregator.get_admin_dashboard_stats(db=db, platform_id=platform_id) + + # Extract product stats from catalog module + total_products = _extract_metric_value(metrics, "catalog", "catalog.total_products", 0) + + # Extract marketplace stats + unique_marketplaces = _extract_metric_value( + metrics, "marketplace", "marketplace.unique_marketplaces", 0 + ) + unique_brands = _extract_metric_value( + metrics, "marketplace", "marketplace.unique_brands", 0 + ) + + # Extract vendor stats + unique_vendors = _extract_metric_value(metrics, "tenancy", "tenancy.total_vendors", 0) + + # Extract inventory stats + inventory_entries = _extract_metric_value(metrics, "inventory", "inventory.entries", 0) + inventory_quantity = _extract_metric_value( + metrics, "inventory", "inventory.total_quantity", 0 + ) return StatsResponse( - total_products=stats_data["total_products"], - unique_brands=stats_data["unique_brands"], - unique_categories=stats_data["unique_categories"], - unique_marketplaces=stats_data["unique_marketplaces"], - unique_vendors=stats_data["unique_vendors"], - total_inventory_entries=stats_data["total_inventory_entries"], - total_inventory_quantity=stats_data["total_inventory_quantity"], + total_products=int(total_products), + unique_brands=int(unique_brands), + unique_categories=0, # TODO: Add category tracking + unique_marketplaces=int(unique_marketplaces), + unique_vendors=int(unique_vendors), + total_inventory_entries=int(inventory_entries), + total_inventory_quantity=int(inventory_quantity), ) -@admin_dashboard_router.get("/stats/marketplace", response_model=list[MarketplaceStatsResponse]) +@admin_dashboard_router.get( + "/stats/marketplace", response_model=list[MarketplaceStatsResponse] +) def get_marketplace_stats( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ): """Get statistics broken down by marketplace (Admin only).""" - marketplace_stats = stats_service.get_marketplace_breakdown_stats(db=db) + # For detailed marketplace breakdown, we still use the analytics service + # as the MetricsProvider pattern is for aggregated stats + try: + from app.modules.analytics.services.stats_service import stats_service - return [ - MarketplaceStatsResponse( - marketplace=stat["marketplace"], - total_products=stat["total_products"], - unique_vendors=stat["unique_vendors"], - unique_brands=stat["unique_brands"], - ) - for stat in marketplace_stats - ] + marketplace_stats = stats_service.get_marketplace_breakdown_stats(db=db) + return [ + MarketplaceStatsResponse( + marketplace=stat["marketplace"], + total_products=stat["total_products"], + unique_vendors=stat["unique_vendors"], + unique_brands=stat["unique_brands"], + ) + for stat in marketplace_stats + ] + except ImportError: + # Analytics module not available + logger.warning("Analytics module not available for marketplace breakdown stats") + return [] @admin_dashboard_router.get("/stats/platform", response_model=PlatformStatsResponse) def get_platform_statistics( + request: Request, db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ): """Get comprehensive platform statistics (Admin only).""" - user_stats = stats_service.get_user_statistics(db) - vendor_stats = stats_service.get_vendor_statistics(db) - product_stats = stats_service.get_product_statistics(db) - order_stats = stats_service.get_order_statistics(db) - import_stats = stats_service.get_import_statistics(db) + platform_id = _get_platform_id(request, current_admin) + + # Get aggregated metrics from all enabled modules + metrics = stats_aggregator.get_admin_dashboard_stats(db=db, platform_id=platform_id) + + # User stats from tenancy + total_users = _extract_metric_value(metrics, "tenancy", "tenancy.total_users", 0) + active_users = _extract_metric_value(metrics, "tenancy", "tenancy.active_users", 0) + inactive_users = _extract_metric_value(metrics, "tenancy", "tenancy.inactive_users", 0) + admin_users = _extract_metric_value(metrics, "tenancy", "tenancy.admin_users", 0) + activation_rate = _extract_metric_value( + metrics, "tenancy", "tenancy.user_activation_rate", 0 + ) + + # Vendor stats from tenancy + total_vendors = _extract_metric_value(metrics, "tenancy", "tenancy.total_vendors", 0) + verified_vendors = _extract_metric_value( + metrics, "tenancy", "tenancy.verified_vendors", 0 + ) + pending_vendors = _extract_metric_value( + metrics, "tenancy", "tenancy.pending_vendors", 0 + ) + inactive_vendors = _extract_metric_value( + metrics, "tenancy", "tenancy.inactive_vendors", 0 + ) + + # Product stats from catalog + total_products = _extract_metric_value(metrics, "catalog", "catalog.total_products", 0) + active_products = _extract_metric_value( + metrics, "catalog", "catalog.active_products", 0 + ) + + # Order stats from orders + total_orders = _extract_metric_value(metrics, "orders", "orders.total", 0) + + # Import stats from marketplace + total_imports = _extract_metric_value( + metrics, "marketplace", "marketplace.total_imports", 0 + ) + pending_imports = _extract_metric_value( + metrics, "marketplace", "marketplace.pending_imports", 0 + ) + processing_imports = _extract_metric_value( + metrics, "marketplace", "marketplace.processing_imports", 0 + ) + completed_imports = _extract_metric_value( + metrics, "marketplace", "marketplace.successful_imports", 0 + ) + failed_imports = _extract_metric_value( + metrics, "marketplace", "marketplace.failed_imports", 0 + ) + import_success_rate = _extract_metric_value( + metrics, "marketplace", "marketplace.success_rate", 0 + ) return PlatformStatsResponse( - users=UserStatsResponse(**user_stats), - vendors=VendorStatsResponse( - total=vendor_stats.get("total", vendor_stats.get("total_vendors", 0)), - verified=vendor_stats.get( - "verified", vendor_stats.get("verified_vendors", 0) - ), - pending=vendor_stats.get("pending", vendor_stats.get("pending_vendors", 0)), - inactive=vendor_stats.get( - "inactive", vendor_stats.get("inactive_vendors", 0) - ), + users=UserStatsResponse( + total_users=int(total_users), + active_users=int(active_users), + inactive_users=int(inactive_users), + admin_users=int(admin_users), + activation_rate=float(activation_rate), + ), + vendors=VendorStatsResponse( + total=int(total_vendors), + verified=int(verified_vendors), + pending=int(pending_vendors), + inactive=int(inactive_vendors), + ), + products=ProductStatsResponse( + total_products=int(total_products), + active_products=int(active_products), + out_of_stock=0, # TODO: Implement + ), + orders=OrderStatsBasicResponse( + total_orders=int(total_orders), + pending_orders=0, # TODO: Implement status tracking + completed_orders=0, # TODO: Implement status tracking + ), + imports=ImportStatsResponse( + total=int(total_imports), + pending=int(pending_imports), + processing=int(processing_imports), + completed=int(completed_imports), + failed=int(failed_imports), + success_rate=float(import_success_rate), ), - products=ProductStatsResponse(**product_stats), - orders=OrderStatsBasicResponse(**order_stats), - imports=ImportStatsResponse(**import_stats), ) diff --git a/app/modules/core/routes/api/vendor_dashboard.py b/app/modules/core/routes/api/vendor_dashboard.py index a63f1a72..605f3ee1 100644 --- a/app/modules/core/routes/api/vendor_dashboard.py +++ b/app/modules/core/routes/api/vendor_dashboard.py @@ -4,6 +4,9 @@ Vendor dashboard and statistics endpoints. Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern). The get_current_vendor_api dependency guarantees token_vendor_id is present. + +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. """ import logging @@ -13,11 +16,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db -from app.modules.tenancy.exceptions import VendorNotActiveException -from app.modules.analytics.services.stats_service import stats_service -from app.modules.tenancy.services.vendor_service import vendor_service -from models.schema.auth import UserContext -from app.modules.analytics.schemas import ( +from app.modules.core.schemas.dashboard import ( VendorCustomerStats, VendorDashboardStatsResponse, VendorInfo, @@ -25,11 +24,27 @@ from app.modules.analytics.schemas import ( VendorProductStats, VendorRevenueStats, ) +from app.modules.core.services.stats_aggregator import stats_aggregator +from app.modules.tenancy.exceptions import VendorNotActiveException +from app.modules.tenancy.services.vendor_service import vendor_service +from models.schema.auth import UserContext vendor_dashboard_router = APIRouter(prefix="/dashboard") logger = logging.getLogger(__name__) +def _extract_metric_value( + metrics: dict[str, list], category: str, key: str, default: int | float = 0 +) -> int | float: + """Extract a specific metric value from categorized metrics.""" + if category not in metrics: + return default + for metric in metrics[category]: + if metric.key == key: + return metric.value + return default + + @vendor_dashboard_router.get("/stats", response_model=VendorDashboardStatsResponse) def get_vendor_dashboard_stats( request: Request, @@ -47,6 +62,9 @@ def get_vendor_dashboard_stats( Vendor is determined from the JWT token (vendor_id claim). Requires Authorization header (API endpoint). + + Statistics are aggregated from all enabled modules via the MetricsProvider protocol. + Each module provides its own metrics, which are combined here for the dashboard. """ vendor_id = current_user.token_vendor_id @@ -56,8 +74,33 @@ def get_vendor_dashboard_stats( if not vendor.is_active: raise VendorNotActiveException(vendor.vendor_code) - # Get vendor-scoped statistics - stats_data = stats_service.get_vendor_stats(db=db, vendor_id=vendor_id) + # Get aggregated metrics from all enabled modules + # Get platform_id from request context (set by PlatformContextMiddleware) + platform = getattr(request.state, "platform", None) + platform_id = platform.id if platform else 1 + metrics = stats_aggregator.get_vendor_dashboard_stats( + db=db, + vendor_id=vendor_id, + platform_id=platform_id, + ) + + # Extract metrics from each category + # Product metrics (from catalog module) + total_products = _extract_metric_value(metrics, "catalog", "catalog.total_products", 0) + active_products = _extract_metric_value(metrics, "catalog", "catalog.active_products", 0) + + # Order metrics (from orders module) + total_orders = _extract_metric_value(metrics, "orders", "orders.total", 0) + pending_orders = 0 # TODO: Add when status tracking is implemented + completed_orders = 0 # TODO: Add when status tracking is implemented + + # Customer metrics (from customers module) + total_customers = _extract_metric_value(metrics, "customers", "customers.total", 0) + active_customers = 0 # TODO: Add when activity tracking is implemented + + # Revenue metrics (from orders module) + total_revenue = _extract_metric_value(metrics, "orders", "orders.total_revenue", 0) + revenue_this_month = _extract_metric_value(metrics, "orders", "orders.revenue_period", 0) return VendorDashboardStatsResponse( vendor=VendorInfo( @@ -66,20 +109,20 @@ def get_vendor_dashboard_stats( vendor_code=vendor.vendor_code, ), products=VendorProductStats( - total=stats_data.get("total_products", 0), - active=stats_data.get("active_products", 0), + total=int(total_products), + active=int(active_products), ), orders=VendorOrderStats( - total=stats_data.get("total_orders", 0), - pending=stats_data.get("pending_orders", 0), - completed=stats_data.get("completed_orders", 0), + total=int(total_orders), + pending=int(pending_orders), + completed=int(completed_orders), ), customers=VendorCustomerStats( - total=stats_data.get("total_customers", 0), - active=stats_data.get("active_customers", 0), + total=int(total_customers), + active=int(active_customers), ), revenue=VendorRevenueStats( - total=stats_data.get("total_revenue", 0), - this_month=stats_data.get("revenue_this_month", 0), + total=float(total_revenue), + this_month=float(revenue_this_month), ), ) diff --git a/app/modules/core/schemas/__init__.py b/app/modules/core/schemas/__init__.py index e69de29b..3f3874e7 100644 --- a/app/modules/core/schemas/__init__.py +++ b/app/modules/core/schemas/__init__.py @@ -0,0 +1,43 @@ +# app/modules/core/schemas/__init__.py +""" +Core module schemas. +""" + +from app.modules.core.schemas.dashboard import ( + AdminDashboardResponse, + ImportStatsResponse, + MarketplaceStatsResponse, + OrderStatsBasicResponse, + PlatformStatsResponse, + ProductStatsResponse, + StatsResponse, + UserStatsResponse, + VendorCustomerStats, + VendorDashboardStatsResponse, + VendorInfo, + VendorOrderStats, + VendorProductStats, + VendorRevenueStats, + VendorStatsResponse, +) + +__all__ = [ + # Stats responses + "StatsResponse", + "MarketplaceStatsResponse", + "ImportStatsResponse", + "UserStatsResponse", + "VendorStatsResponse", + "ProductStatsResponse", + "PlatformStatsResponse", + "OrderStatsBasicResponse", + # Admin dashboard + "AdminDashboardResponse", + # Vendor dashboard + "VendorProductStats", + "VendorOrderStats", + "VendorCustomerStats", + "VendorRevenueStats", + "VendorInfo", + "VendorDashboardStatsResponse", +] diff --git a/app/modules/core/schemas/dashboard.py b/app/modules/core/schemas/dashboard.py new file mode 100644 index 00000000..d72c4a58 --- /dev/null +++ b/app/modules/core/schemas/dashboard.py @@ -0,0 +1,244 @@ +# app/modules/core/schemas/dashboard.py +""" +Dashboard schemas for core module. + +These schemas define the response structures for vendor and admin dashboards. +They're located in core because dashboards are core functionality that should +always be available, regardless of which optional modules are enabled. + +The analytics module can extend these with additional functionality (trends, +reports, exports) but the base dashboard schemas live here. +""" + +from typing import Any + +from pydantic import BaseModel, Field + + +# ============================================================================ +# User Statistics +# ============================================================================ + + +class UserStatsResponse(BaseModel): + """User statistics response schema. + + Used by: Platform statistics endpoints + """ + + total_users: int = Field(..., description="Total number of users") + active_users: int = Field(..., description="Number of active users") + inactive_users: int = Field(..., description="Number of inactive users") + admin_users: int = Field(..., description="Number of admin users") + activation_rate: float = Field(..., description="Percentage of active users") + + +# ============================================================================ +# Vendor Statistics (Admin) +# ============================================================================ + + +class VendorStatsResponse(BaseModel): + """Vendor statistics response schema for admin dashboard. + + Used by: GET /api/v1/admin/vendors/stats + """ + + total: int = Field(..., description="Total number of vendors") + verified: int = Field(..., description="Number of verified vendors") + pending: int = Field(..., description="Number of pending verification vendors") + inactive: int = Field(..., description="Number of inactive vendors") + + +# ============================================================================ +# Product Statistics +# ============================================================================ + + +class ProductStatsResponse(BaseModel): + """Product statistics response schema. + + Used by: Platform statistics endpoints + """ + + total_products: int = Field(0, description="Total number of products") + active_products: int = Field(0, description="Number of active products") + out_of_stock: int = Field(0, description="Number of out-of-stock products") + + +# ============================================================================ +# Order Statistics +# ============================================================================ + + +class OrderStatsBasicResponse(BaseModel): + """Basic order statistics (stub until Order model is fully implemented). + + Used by: Platform statistics endpoints + """ + + total_orders: int = Field(0, description="Total number of orders") + pending_orders: int = Field(0, description="Number of pending orders") + completed_orders: int = Field(0, description="Number of completed orders") + + +# ============================================================================ +# Import Statistics +# ============================================================================ + + +class ImportStatsResponse(BaseModel): + """Import job statistics response schema. + + Used by: GET /api/v1/admin/marketplace-import-jobs/stats + """ + + total: int = Field(..., description="Total number of import jobs") + pending: int = Field(..., description="Jobs waiting to start") + processing: int = Field(..., description="Jobs currently running") + completed: int = Field(..., description="Successfully completed jobs") + failed: int = Field(..., description="Failed jobs") + success_rate: float = Field(..., description="Percentage of successful imports") + + +# ============================================================================ +# Comprehensive Stats +# ============================================================================ + + +class StatsResponse(BaseModel): + """Comprehensive platform statistics response schema.""" + + total_products: int + unique_brands: int + unique_categories: int + unique_marketplaces: int = 0 + unique_vendors: int = 0 + total_inventory_entries: int = 0 + total_inventory_quantity: int = 0 + + +class MarketplaceStatsResponse(BaseModel): + """Statistics per marketplace response schema.""" + + marketplace: str + total_products: int + unique_vendors: int + unique_brands: int + + +# ============================================================================ +# Platform Statistics (Combined) +# ============================================================================ + + +class PlatformStatsResponse(BaseModel): + """Combined platform statistics response schema. + + Used by: GET /api/v1/admin/dashboard/stats/platform + """ + + users: UserStatsResponse + vendors: VendorStatsResponse + products: ProductStatsResponse + orders: OrderStatsBasicResponse + imports: ImportStatsResponse + + +# ============================================================================ +# Admin Dashboard Response +# ============================================================================ + + +class AdminDashboardResponse(BaseModel): + """Admin dashboard response schema. + + Used by: GET /api/v1/admin/dashboard + """ + + platform: dict[str, Any] = Field(..., description="Platform information") + users: UserStatsResponse + vendors: VendorStatsResponse + recent_vendors: list[dict[str, Any]] = Field( + default_factory=list, description="Recent vendors" + ) + recent_imports: list[dict[str, Any]] = Field( + default_factory=list, description="Recent import jobs" + ) + + +# ============================================================================ +# Vendor Dashboard Statistics +# ============================================================================ + + +class VendorProductStats(BaseModel): + """Vendor product statistics.""" + + total: int = Field(0, description="Total products in catalog") + active: int = Field(0, description="Active products") + + +class VendorOrderStats(BaseModel): + """Vendor order statistics.""" + + total: int = Field(0, description="Total orders") + pending: int = Field(0, description="Pending orders") + completed: int = Field(0, description="Completed orders") + + +class VendorCustomerStats(BaseModel): + """Vendor customer statistics.""" + + total: int = Field(0, description="Total customers") + active: int = Field(0, description="Active customers") + + +class VendorRevenueStats(BaseModel): + """Vendor revenue statistics.""" + + total: float = Field(0, description="Total revenue") + this_month: float = Field(0, description="Revenue this month") + + +class VendorInfo(BaseModel): + """Vendor basic info for dashboard.""" + + id: int + name: str + vendor_code: str + + +class VendorDashboardStatsResponse(BaseModel): + """Vendor dashboard statistics response schema. + + Used by: GET /api/v1/vendor/dashboard/stats + """ + + vendor: VendorInfo + products: VendorProductStats + orders: VendorOrderStats + customers: VendorCustomerStats + revenue: VendorRevenueStats + + +__all__ = [ + # Stats responses + "StatsResponse", + "MarketplaceStatsResponse", + "ImportStatsResponse", + "UserStatsResponse", + "VendorStatsResponse", + "ProductStatsResponse", + "PlatformStatsResponse", + "OrderStatsBasicResponse", + # Admin dashboard + "AdminDashboardResponse", + # Vendor dashboard + "VendorProductStats", + "VendorOrderStats", + "VendorCustomerStats", + "VendorRevenueStats", + "VendorInfo", + "VendorDashboardStatsResponse", +] diff --git a/app/modules/core/services/__init__.py b/app/modules/core/services/__init__.py index 20cbbfbd..fef9c417 100644 --- a/app/modules/core/services/__init__.py +++ b/app/modules/core/services/__init__.py @@ -28,6 +28,10 @@ from app.modules.core.services.platform_settings_service import ( PlatformSettingsService, platform_settings_service, ) +from app.modules.core.services.stats_aggregator import ( + StatsAggregatorService, + stats_aggregator, +) from app.modules.core.services.storage_service import ( LocalStorageBackend, R2StorageBackend, @@ -64,4 +68,7 @@ __all__ = [ # Platform settings "PlatformSettingsService", "platform_settings_service", + # Stats aggregator + "StatsAggregatorService", + "stats_aggregator", ] diff --git a/app/modules/core/services/stats_aggregator.py b/app/modules/core/services/stats_aggregator.py new file mode 100644 index 00000000..35de02b6 --- /dev/null +++ b/app/modules/core/services/stats_aggregator.py @@ -0,0 +1,253 @@ +# app/modules/core/services/stats_aggregator.py +""" +Stats aggregator service for collecting metrics from all modules. + +This service lives in core because dashboards are core functionality that should +always be available. It discovers and aggregates MetricsProviders from all enabled +modules, providing a unified interface for dashboard statistics. + +Benefits: +- Dashboards always work (aggregator is in core) +- Each module owns its metrics (no cross-module coupling) +- Optional modules are truly optional (can be removed without breaking app) +- Easy to add new metrics (just implement MetricsProviderProtocol in your module) + +Usage: + from app.modules.core.services.stats_aggregator import stats_aggregator + + # Get vendor dashboard stats + stats = stats_aggregator.get_vendor_dashboard_stats( + db=db, vendor_id=123, platform_id=1 + ) + + # Get admin dashboard stats + stats = stats_aggregator.get_admin_dashboard_stats( + db=db, platform_id=1 + ) +""" + +import logging +from typing import TYPE_CHECKING, Any + +from sqlalchemy.orm import Session + +from app.modules.contracts.metrics import ( + MetricValue, + MetricsContext, + MetricsProviderProtocol, +) + +if TYPE_CHECKING: + from app.modules.base import ModuleDefinition + +logger = logging.getLogger(__name__) + + +class StatsAggregatorService: + """ + Aggregates metrics from all module providers. + + This service discovers MetricsProviders from enabled modules and provides + a unified interface for dashboard statistics. It handles graceful degradation + when modules are disabled or providers fail. + """ + + def _get_enabled_providers( + self, db: Session, platform_id: int + ) -> list[tuple["ModuleDefinition", MetricsProviderProtocol]]: + """ + Get metrics 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, MetricsProviderProtocol]] = [] + + for module in MODULES.values(): + # Skip modules without metrics providers + if not module.has_metrics_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_metrics_provider_instance() + if provider is not None: + providers.append((module, provider)) + except Exception as e: + logger.warning( + f"Failed to get metrics provider for module {module.code}: {e}" + ) + + return providers + + def get_vendor_dashboard_stats( + self, + db: Session, + vendor_id: int, + platform_id: int, + context: MetricsContext | None = None, + ) -> dict[str, list[MetricValue]]: + """ + Get all metrics for a vendor, grouped by category. + + Called by the vendor dashboard to display vendor-scoped statistics. + + Args: + db: Database session + vendor_id: ID of the vendor to get metrics for + platform_id: Platform ID (for module enablement check) + context: Optional filtering/scoping context + + Returns: + Dict mapping category name to list of MetricValue objects + """ + providers = self._get_enabled_providers(db, platform_id) + result: dict[str, list[MetricValue]] = {} + + for module, provider in providers: + try: + metrics = provider.get_vendor_metrics(db, vendor_id, context) + if metrics: + result[provider.metrics_category] = metrics + except Exception as e: + logger.warning( + f"Failed to get vendor metrics from module {module.code}: {e}" + ) + # Continue with other providers - graceful degradation + + return result + + def get_admin_dashboard_stats( + self, + db: Session, + platform_id: int, + context: MetricsContext | None = None, + ) -> dict[str, list[MetricValue]]: + """ + Get all metrics for a platform, grouped by category. + + Called by the admin dashboard to display platform-wide statistics. + + Args: + db: Database session + platform_id: ID of the platform to get metrics for + context: Optional filtering/scoping context + + Returns: + Dict mapping category name to list of MetricValue objects + """ + providers = self._get_enabled_providers(db, platform_id) + result: dict[str, list[MetricValue]] = {} + + for module, provider in providers: + try: + metrics = provider.get_platform_metrics(db, platform_id, context) + if metrics: + result[provider.metrics_category] = metrics + except Exception as e: + logger.warning( + f"Failed to get platform metrics from module {module.code}: {e}" + ) + # Continue with other providers - graceful degradation + + return result + + def get_vendor_stats_flat( + self, + db: Session, + vendor_id: int, + platform_id: int, + context: MetricsContext | None = None, + ) -> dict[str, Any]: + """ + Get vendor metrics as a flat dictionary. + + This is a convenience method that flattens the category-grouped metrics + into a single dictionary with metric keys as keys. Useful for backward + compatibility with existing dashboard code. + + Args: + db: Database session + vendor_id: ID of the vendor to get metrics for + platform_id: Platform ID (for module enablement check) + context: Optional filtering/scoping context + + Returns: + Flat dict mapping metric keys to values + """ + categorized = self.get_vendor_dashboard_stats(db, vendor_id, platform_id, context) + return self._flatten_metrics(categorized) + + def get_admin_stats_flat( + self, + db: Session, + platform_id: int, + context: MetricsContext | None = None, + ) -> dict[str, Any]: + """ + Get platform metrics as a flat dictionary. + + This is a convenience method that flattens the category-grouped metrics + into a single dictionary with metric keys as keys. Useful for backward + compatibility with existing dashboard code. + + Args: + db: Database session + platform_id: Platform ID + context: Optional filtering/scoping context + + Returns: + Flat dict mapping metric keys to values + """ + categorized = self.get_admin_dashboard_stats(db, platform_id, context) + return self._flatten_metrics(categorized) + + def _flatten_metrics( + self, categorized: dict[str, list[MetricValue]] + ) -> dict[str, Any]: + """Flatten categorized metrics into a single dictionary.""" + flat: dict[str, Any] = {} + for metrics in categorized.values(): + for metric in metrics: + flat[metric.key] = metric.value + return flat + + def get_available_categories( + self, db: Session, platform_id: int + ) -> list[str]: + """ + Get list of available metric 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.metrics_category for _, provider in providers] + + +# Singleton instance +stats_aggregator = StatsAggregatorService() + +__all__ = ["StatsAggregatorService", "stats_aggregator"] diff --git a/app/modules/customers/definition.py b/app/modules/customers/definition.py index 0e721e94..f02c5d65 100644 --- a/app/modules/customers/definition.py +++ b/app/modules/customers/definition.py @@ -29,6 +29,13 @@ def _get_vendor_router(): return vendor_router +def _get_metrics_provider(): + """Lazy import of metrics provider to avoid circular imports.""" + from app.modules.customers.services.customer_metrics import customer_metrics_provider + + return customer_metrics_provider + + # Customers module definition customers_module = ModuleDefinition( code="customers", @@ -124,6 +131,8 @@ customers_module = ModuleDefinition( models_path="app.modules.customers.models", schemas_path="app.modules.customers.schemas", exceptions_path="app.modules.customers.exceptions", + # Metrics provider for dashboard statistics + metrics_provider=_get_metrics_provider, ) diff --git a/app/modules/customers/services/customer_metrics.py b/app/modules/customers/services/customer_metrics.py new file mode 100644 index 00000000..76dbfea5 --- /dev/null +++ b/app/modules/customers/services/customer_metrics.py @@ -0,0 +1,204 @@ +# app/modules/customers/services/customer_metrics.py +""" +Metrics provider for the customers module. + +Provides metrics for: +- Customer counts +- New customers +- Customer addresses +""" + +import logging +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.modules.contracts.metrics import ( + MetricValue, + MetricsContext, + MetricsProviderProtocol, +) + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + + +class CustomerMetricsProvider: + """ + Metrics provider for customers module. + + Provides customer-related metrics for vendor and platform dashboards. + """ + + @property + def metrics_category(self) -> str: + return "customers" + + def get_vendor_metrics( + self, + db: Session, + vendor_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get customer metrics for a specific vendor. + + Provides: + - Total customers + - New customers (in period) + - Customers with addresses + """ + from app.modules.customers.models import Customer, CustomerAddress + + try: + # Total customers + total_customers = ( + db.query(Customer).filter(Customer.vendor_id == vendor_id).count() + ) + + # New customers (default to last 30 days) + date_from = context.date_from if context else None + if date_from is None: + date_from = datetime.utcnow() - timedelta(days=30) + + new_customers_query = db.query(Customer).filter( + Customer.vendor_id == vendor_id, + Customer.created_at >= date_from, + ) + if context and context.date_to: + new_customers_query = new_customers_query.filter( + Customer.created_at <= context.date_to + ) + new_customers = new_customers_query.count() + + # Customers with addresses + customers_with_addresses = ( + db.query(func.count(func.distinct(CustomerAddress.customer_id))) + .join(Customer, Customer.id == CustomerAddress.customer_id) + .filter(Customer.vendor_id == vendor_id) + .scalar() + or 0 + ) + + return [ + MetricValue( + key="customers.total", + value=total_customers, + label="Total Customers", + category="customers", + icon="users", + description="Total number of customers", + ), + MetricValue( + key="customers.new", + value=new_customers, + label="New Customers", + category="customers", + icon="user-plus", + description="Customers acquired in the period", + ), + MetricValue( + key="customers.with_addresses", + value=customers_with_addresses, + label="With Addresses", + category="customers", + icon="map-pin", + description="Customers who have saved addresses", + ), + ] + except Exception as e: + logger.warning(f"Failed to get customer vendor metrics: {e}") + return [] + + def get_platform_metrics( + self, + db: Session, + platform_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get customer metrics aggregated for a platform. + + For platforms, aggregates customer data across all vendors. + """ + from app.modules.customers.models import Customer + from app.modules.tenancy.models import VendorPlatform + + try: + # Get all vendor IDs for this platform using VendorPlatform junction table + vendor_ids = ( + db.query(VendorPlatform.vendor_id) + .filter( + VendorPlatform.platform_id == platform_id, + VendorPlatform.is_active == True, + ) + .subquery() + ) + + # Total customers across all vendors + total_customers = ( + db.query(Customer).filter(Customer.vendor_id.in_(vendor_ids)).count() + ) + + # Unique customers (by email across platform) + unique_customer_emails = ( + db.query(func.count(func.distinct(Customer.email))) + .filter(Customer.vendor_id.in_(vendor_ids)) + .scalar() + or 0 + ) + + # New customers (default to last 30 days) + date_from = context.date_from if context else None + if date_from is None: + date_from = datetime.utcnow() - timedelta(days=30) + + new_customers_query = db.query(Customer).filter( + Customer.vendor_id.in_(vendor_ids), + Customer.created_at >= date_from, + ) + if context and context.date_to: + new_customers_query = new_customers_query.filter( + Customer.created_at <= context.date_to + ) + new_customers = new_customers_query.count() + + return [ + MetricValue( + key="customers.total", + value=total_customers, + label="Total Customers", + category="customers", + icon="users", + description="Total customer records across all vendors", + ), + MetricValue( + key="customers.unique_emails", + value=unique_customer_emails, + label="Unique Customers", + category="customers", + icon="user", + description="Unique customer emails across platform", + ), + MetricValue( + key="customers.new", + value=new_customers, + label="New Customers", + category="customers", + icon="user-plus", + description="Customers acquired in the period", + ), + ] + except Exception as e: + logger.warning(f"Failed to get customer platform metrics: {e}") + return [] + + +# Singleton instance +customer_metrics_provider = CustomerMetricsProvider() + +__all__ = ["CustomerMetricsProvider", "customer_metrics_provider"] diff --git a/app/modules/inventory/definition.py b/app/modules/inventory/definition.py index 26b45de7..61e85c3d 100644 --- a/app/modules/inventory/definition.py +++ b/app/modules/inventory/definition.py @@ -29,6 +29,13 @@ def _get_vendor_router(): return vendor_router +def _get_metrics_provider(): + """Lazy import of metrics provider to avoid circular imports.""" + from app.modules.inventory.services.inventory_metrics import inventory_metrics_provider + + return inventory_metrics_provider + + # Inventory module definition inventory_module = ModuleDefinition( code="inventory", @@ -131,6 +138,8 @@ inventory_module = ModuleDefinition( models_path="app.modules.inventory.models", schemas_path="app.modules.inventory.schemas", exceptions_path="app.modules.inventory.exceptions", + # Metrics provider for dashboard statistics + metrics_provider=_get_metrics_provider, ) diff --git a/app/modules/inventory/services/inventory_metrics.py b/app/modules/inventory/services/inventory_metrics.py new file mode 100644 index 00000000..7f370897 --- /dev/null +++ b/app/modules/inventory/services/inventory_metrics.py @@ -0,0 +1,318 @@ +# app/modules/inventory/services/inventory_metrics.py +""" +Metrics provider for the inventory module. + +Provides metrics for: +- Inventory quantities +- Stock levels +- Low stock alerts +""" + +import logging +from typing import TYPE_CHECKING + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.modules.contracts.metrics import ( + MetricValue, + MetricsContext, + MetricsProviderProtocol, +) + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + + +class InventoryMetricsProvider: + """ + Metrics provider for inventory module. + + Provides stock and inventory metrics for vendor and platform dashboards. + """ + + @property + def metrics_category(self) -> str: + return "inventory" + + def get_vendor_metrics( + self, + db: Session, + vendor_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get inventory metrics for a specific vendor. + + Provides: + - Total inventory quantity + - Reserved quantity + - Available quantity + - Inventory locations + - Low stock items + """ + from app.modules.inventory.models import Inventory + + try: + # Total inventory + total_quantity = ( + db.query(func.sum(Inventory.quantity)) + .filter(Inventory.vendor_id == vendor_id) + .scalar() + or 0 + ) + + # Reserved inventory + reserved_quantity = ( + db.query(func.sum(Inventory.reserved_quantity)) + .filter(Inventory.vendor_id == vendor_id) + .scalar() + or 0 + ) + + # Available inventory + available_quantity = int(total_quantity) - int(reserved_quantity) + + # Inventory entries (SKU/location combinations) + inventory_entries = ( + db.query(Inventory).filter(Inventory.vendor_id == vendor_id).count() + ) + + # Unique locations + unique_locations = ( + db.query(func.count(func.distinct(Inventory.location))) + .filter(Inventory.vendor_id == vendor_id) + .scalar() + or 0 + ) + + # Low stock items (quantity < 10 and > 0) + low_stock_items = ( + db.query(Inventory) + .filter( + Inventory.vendor_id == vendor_id, + Inventory.quantity > 0, + Inventory.quantity < 10, + ) + .count() + ) + + # Out of stock items (quantity = 0) + out_of_stock_items = ( + db.query(Inventory) + .filter(Inventory.vendor_id == vendor_id, Inventory.quantity == 0) + .count() + ) + + return [ + MetricValue( + key="inventory.total_quantity", + value=int(total_quantity), + label="Total Stock", + category="inventory", + icon="package", + unit="items", + description="Total inventory quantity", + ), + MetricValue( + key="inventory.reserved_quantity", + value=int(reserved_quantity), + label="Reserved", + category="inventory", + icon="lock", + unit="items", + description="Inventory reserved for orders", + ), + MetricValue( + key="inventory.available_quantity", + value=available_quantity, + label="Available", + category="inventory", + icon="check", + unit="items", + description="Inventory available for sale", + ), + MetricValue( + key="inventory.entries", + value=inventory_entries, + label="SKU/Location Entries", + category="inventory", + icon="list", + description="Total inventory entries", + ), + MetricValue( + key="inventory.locations", + value=unique_locations, + label="Locations", + category="inventory", + icon="map-pin", + description="Unique storage locations", + ), + MetricValue( + key="inventory.low_stock", + value=low_stock_items, + label="Low Stock", + category="inventory", + icon="alert-triangle", + description="Items with quantity < 10", + ), + MetricValue( + key="inventory.out_of_stock", + value=out_of_stock_items, + label="Out of Stock", + category="inventory", + icon="x-circle", + description="Items with zero quantity", + ), + ] + except Exception as e: + logger.warning(f"Failed to get inventory vendor metrics: {e}") + return [] + + def get_platform_metrics( + self, + db: Session, + platform_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get inventory metrics aggregated for a platform. + + Aggregates stock data across all vendors. + """ + from app.modules.inventory.models import Inventory + from app.modules.tenancy.models import VendorPlatform + + try: + # Get all vendor IDs for this platform using VendorPlatform junction table + vendor_ids = ( + db.query(VendorPlatform.vendor_id) + .filter( + VendorPlatform.platform_id == platform_id, + VendorPlatform.is_active == True, + ) + .subquery() + ) + + # Total inventory + total_quantity = ( + db.query(func.sum(Inventory.quantity)) + .filter(Inventory.vendor_id.in_(vendor_ids)) + .scalar() + or 0 + ) + + # Reserved inventory + reserved_quantity = ( + db.query(func.sum(Inventory.reserved_quantity)) + .filter(Inventory.vendor_id.in_(vendor_ids)) + .scalar() + or 0 + ) + + # Available inventory + available_quantity = int(total_quantity) - int(reserved_quantity) + + # Total inventory entries + inventory_entries = ( + db.query(Inventory).filter(Inventory.vendor_id.in_(vendor_ids)).count() + ) + + # Vendors with inventory + vendors_with_inventory = ( + db.query(func.count(func.distinct(Inventory.vendor_id))) + .filter(Inventory.vendor_id.in_(vendor_ids)) + .scalar() + or 0 + ) + + # Low stock items across platform + low_stock_items = ( + db.query(Inventory) + .filter( + Inventory.vendor_id.in_(vendor_ids), + Inventory.quantity > 0, + Inventory.quantity < 10, + ) + .count() + ) + + # Out of stock items + out_of_stock_items = ( + db.query(Inventory) + .filter(Inventory.vendor_id.in_(vendor_ids), Inventory.quantity == 0) + .count() + ) + + return [ + MetricValue( + key="inventory.total_quantity", + value=int(total_quantity), + label="Total Stock", + category="inventory", + icon="package", + unit="items", + description="Total inventory across all vendors", + ), + MetricValue( + key="inventory.reserved_quantity", + value=int(reserved_quantity), + label="Reserved", + category="inventory", + icon="lock", + unit="items", + description="Inventory reserved for orders", + ), + MetricValue( + key="inventory.available_quantity", + value=available_quantity, + label="Available", + category="inventory", + icon="check", + unit="items", + description="Inventory available for sale", + ), + MetricValue( + key="inventory.entries", + value=inventory_entries, + label="Total Entries", + category="inventory", + icon="list", + description="Total inventory entries across vendors", + ), + MetricValue( + key="inventory.vendors_with_inventory", + value=vendors_with_inventory, + label="Vendors with Stock", + category="inventory", + icon="store", + description="Vendors managing inventory", + ), + MetricValue( + key="inventory.low_stock", + value=low_stock_items, + label="Low Stock Items", + category="inventory", + icon="alert-triangle", + description="Items with quantity < 10", + ), + MetricValue( + key="inventory.out_of_stock", + value=out_of_stock_items, + label="Out of Stock", + category="inventory", + icon="x-circle", + description="Items with zero quantity", + ), + ] + except Exception as e: + logger.warning(f"Failed to get inventory platform metrics: {e}") + return [] + + +# Singleton instance +inventory_metrics_provider = InventoryMetricsProvider() + +__all__ = ["InventoryMetricsProvider", "inventory_metrics_provider"] diff --git a/app/modules/marketplace/definition.py b/app/modules/marketplace/definition.py index 5e3a5c6c..12487664 100644 --- a/app/modules/marketplace/definition.py +++ b/app/modules/marketplace/definition.py @@ -26,6 +26,13 @@ def _get_vendor_router(): return vendor_router +def _get_metrics_provider(): + """Lazy import of metrics provider to avoid circular imports.""" + from app.modules.marketplace.services.marketplace_metrics import marketplace_metrics_provider + + return marketplace_metrics_provider + + # Marketplace module definition marketplace_module = ModuleDefinition( code="marketplace", @@ -146,6 +153,8 @@ marketplace_module = ModuleDefinition( options={"queue": "scheduled"}, ), ], + # Metrics provider for dashboard statistics + metrics_provider=_get_metrics_provider, ) diff --git a/app/modules/marketplace/services/marketplace_metrics.py b/app/modules/marketplace/services/marketplace_metrics.py new file mode 100644 index 00000000..e853bbe7 --- /dev/null +++ b/app/modules/marketplace/services/marketplace_metrics.py @@ -0,0 +1,381 @@ +# app/modules/marketplace/services/marketplace_metrics.py +""" +Metrics provider for the marketplace module. + +Provides metrics for: +- Imported products (staging area) +- Import jobs +- Marketplace statistics +""" + +import logging +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.modules.contracts.metrics import ( + MetricValue, + MetricsContext, + MetricsProviderProtocol, +) + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + + +class MarketplaceMetricsProvider: + """ + Metrics provider for marketplace module. + + Provides import and staging metrics for vendor and platform dashboards. + """ + + @property + def metrics_category(self) -> str: + return "marketplace" + + def get_vendor_metrics( + self, + db: Session, + vendor_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get marketplace metrics for a specific vendor. + + Provides: + - Imported products (staging) + - Import job statistics + """ + from app.modules.marketplace.models import MarketplaceImportJob, MarketplaceProduct + from app.modules.tenancy.models import Vendor + + try: + # Get vendor name for MarketplaceProduct queries + # (MarketplaceProduct uses vendor_name, not vendor_id) + vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() + vendor_name = vendor.name if vendor else "" + + # Staging products + staging_products = ( + db.query(MarketplaceProduct) + .filter(MarketplaceProduct.vendor_name == vendor_name) + .count() + ) + + # Import jobs + total_imports = ( + db.query(MarketplaceImportJob) + .filter(MarketplaceImportJob.vendor_id == vendor_id) + .count() + ) + + successful_imports = ( + db.query(MarketplaceImportJob) + .filter( + MarketplaceImportJob.vendor_id == vendor_id, + MarketplaceImportJob.status == "completed", + ) + .count() + ) + + failed_imports = ( + db.query(MarketplaceImportJob) + .filter( + MarketplaceImportJob.vendor_id == vendor_id, + MarketplaceImportJob.status == "failed", + ) + .count() + ) + + pending_imports = ( + db.query(MarketplaceImportJob) + .filter( + MarketplaceImportJob.vendor_id == vendor_id, + MarketplaceImportJob.status == "pending", + ) + .count() + ) + + # Import success rate + success_rate = ( + round(successful_imports / total_imports * 100, 1) if total_imports > 0 else 0 + ) + + # Recent imports (last 30 days) + date_from = context.date_from if context else None + if date_from is None: + date_from = datetime.utcnow() - timedelta(days=30) + + recent_imports = ( + db.query(MarketplaceImportJob) + .filter( + MarketplaceImportJob.vendor_id == vendor_id, + MarketplaceImportJob.created_at >= date_from, + ) + .count() + ) + + return [ + MetricValue( + key="marketplace.staging_products", + value=staging_products, + label="Staging Products", + category="marketplace", + icon="inbox", + description="Products in staging area", + ), + MetricValue( + key="marketplace.total_imports", + value=total_imports, + label="Total Imports", + category="marketplace", + icon="download", + description="Total import jobs", + ), + MetricValue( + key="marketplace.successful_imports", + value=successful_imports, + label="Successful Imports", + category="marketplace", + icon="check-circle", + description="Completed import jobs", + ), + MetricValue( + key="marketplace.failed_imports", + value=failed_imports, + label="Failed Imports", + category="marketplace", + icon="x-circle", + description="Failed import jobs", + ), + MetricValue( + key="marketplace.pending_imports", + value=pending_imports, + label="Pending Imports", + category="marketplace", + icon="clock", + description="Import jobs waiting to process", + ), + MetricValue( + key="marketplace.success_rate", + value=success_rate, + label="Success Rate", + category="marketplace", + icon="percent", + unit="%", + description="Import success rate", + ), + MetricValue( + key="marketplace.recent_imports", + value=recent_imports, + label="Recent Imports", + category="marketplace", + icon="activity", + description="Imports in the selected period", + ), + ] + except Exception as e: + logger.warning(f"Failed to get marketplace vendor metrics: {e}") + return [] + + def get_platform_metrics( + self, + db: Session, + platform_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get marketplace metrics aggregated for a platform. + + Aggregates import and staging data across all vendors. + """ + from app.modules.marketplace.models import MarketplaceImportJob, MarketplaceProduct + from app.modules.tenancy.models import VendorPlatform + + try: + # Get all vendor IDs for this platform using VendorPlatform junction table + vendor_ids = ( + db.query(VendorPlatform.vendor_id) + .filter( + VendorPlatform.platform_id == platform_id, + VendorPlatform.is_active == True, + ) + .subquery() + ) + + # Total staging products (across all vendors) + # Note: MarketplaceProduct doesn't have direct platform_id link + total_staging_products = db.query(MarketplaceProduct).count() + + # Unique marketplaces + unique_marketplaces = ( + db.query(func.count(func.distinct(MarketplaceProduct.marketplace))) + .filter(MarketplaceProduct.marketplace.isnot(None)) + .scalar() + or 0 + ) + + # Unique brands + unique_brands = ( + db.query(func.count(func.distinct(MarketplaceProduct.brand))) + .filter( + MarketplaceProduct.brand.isnot(None), + MarketplaceProduct.brand != "", + ) + .scalar() + or 0 + ) + + # Import jobs + total_imports = ( + db.query(MarketplaceImportJob) + .filter(MarketplaceImportJob.vendor_id.in_(vendor_ids)) + .count() + ) + + successful_imports = ( + db.query(MarketplaceImportJob) + .filter( + MarketplaceImportJob.vendor_id.in_(vendor_ids), + MarketplaceImportJob.status.in_(["completed", "completed_with_errors"]), + ) + .count() + ) + + failed_imports = ( + db.query(MarketplaceImportJob) + .filter( + MarketplaceImportJob.vendor_id.in_(vendor_ids), + MarketplaceImportJob.status == "failed", + ) + .count() + ) + + pending_imports = ( + db.query(MarketplaceImportJob) + .filter( + MarketplaceImportJob.vendor_id.in_(vendor_ids), + MarketplaceImportJob.status == "pending", + ) + .count() + ) + + processing_imports = ( + db.query(MarketplaceImportJob) + .filter( + MarketplaceImportJob.vendor_id.in_(vendor_ids), + MarketplaceImportJob.status == "processing", + ) + .count() + ) + + # Success rate + success_rate = ( + round(successful_imports / total_imports * 100, 1) if total_imports > 0 else 0 + ) + + # Vendors with imports + vendors_with_imports = ( + db.query(func.count(func.distinct(MarketplaceImportJob.vendor_id))) + .filter(MarketplaceImportJob.vendor_id.in_(vendor_ids)) + .scalar() + or 0 + ) + + return [ + MetricValue( + key="marketplace.total_staging", + value=total_staging_products, + label="Staging Products", + category="marketplace", + icon="inbox", + description="Total products in staging", + ), + MetricValue( + key="marketplace.unique_marketplaces", + value=unique_marketplaces, + label="Marketplaces", + category="marketplace", + icon="globe", + description="Unique marketplace sources", + ), + MetricValue( + key="marketplace.unique_brands", + value=unique_brands, + label="Brands", + category="marketplace", + icon="tag", + description="Unique product brands", + ), + MetricValue( + key="marketplace.total_imports", + value=total_imports, + label="Total Imports", + category="marketplace", + icon="download", + description="Total import jobs", + ), + MetricValue( + key="marketplace.successful_imports", + value=successful_imports, + label="Successful", + category="marketplace", + icon="check-circle", + description="Completed import jobs", + ), + MetricValue( + key="marketplace.failed_imports", + value=failed_imports, + label="Failed", + category="marketplace", + icon="x-circle", + description="Failed import jobs", + ), + MetricValue( + key="marketplace.pending_imports", + value=pending_imports, + label="Pending", + category="marketplace", + icon="clock", + description="Jobs waiting to process", + ), + MetricValue( + key="marketplace.processing_imports", + value=processing_imports, + label="Processing", + category="marketplace", + icon="loader", + description="Jobs currently processing", + ), + MetricValue( + key="marketplace.success_rate", + value=success_rate, + label="Success Rate", + category="marketplace", + icon="percent", + unit="%", + description="Import success rate", + ), + MetricValue( + key="marketplace.vendors_importing", + value=vendors_with_imports, + label="Vendors Importing", + category="marketplace", + icon="store", + description="Vendors using imports", + ), + ] + except Exception as e: + logger.warning(f"Failed to get marketplace platform metrics: {e}") + return [] + + +# Singleton instance +marketplace_metrics_provider = MarketplaceMetricsProvider() + +__all__ = ["MarketplaceMetricsProvider", "marketplace_metrics_provider"] diff --git a/app/modules/orders/definition.py b/app/modules/orders/definition.py index 34ab658b..c9b38772 100644 --- a/app/modules/orders/definition.py +++ b/app/modules/orders/definition.py @@ -29,6 +29,13 @@ def _get_vendor_router(): return vendor_router +def _get_metrics_provider(): + """Lazy import of metrics provider to avoid circular imports.""" + from app.modules.orders.services.order_metrics import order_metrics_provider + + return order_metrics_provider + + # Orders module definition orders_module = ModuleDefinition( code="orders", @@ -133,6 +140,8 @@ orders_module = ModuleDefinition( models_path="app.modules.orders.models", schemas_path="app.modules.orders.schemas", exceptions_path="app.modules.orders.exceptions", + # Metrics provider for dashboard statistics + metrics_provider=_get_metrics_provider, ) diff --git a/app/modules/orders/services/order_metrics.py b/app/modules/orders/services/order_metrics.py new file mode 100644 index 00000000..a166818c --- /dev/null +++ b/app/modules/orders/services/order_metrics.py @@ -0,0 +1,308 @@ +# app/modules/orders/services/order_metrics.py +""" +Metrics provider for the orders module. + +Provides metrics for: +- Order counts and status +- Revenue metrics +- Invoice statistics +""" + +import logging +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.modules.contracts.metrics import ( + MetricValue, + MetricsContext, + MetricsProviderProtocol, +) + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + + +class OrderMetricsProvider: + """ + Metrics provider for orders module. + + Provides order and revenue metrics for vendor and platform dashboards. + """ + + @property + def metrics_category(self) -> str: + return "orders" + + def get_vendor_metrics( + self, + db: Session, + vendor_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get order metrics for a specific vendor. + + Provides: + - Total orders + - Orders by status + - Revenue metrics + """ + from app.modules.orders.models import Order, OrderItem + + try: + # Total orders + total_orders = ( + db.query(Order).filter(Order.vendor_id == vendor_id).count() + ) + + # Orders in period (default to last 30 days) + date_from = context.date_from if context else None + if date_from is None: + date_from = datetime.utcnow() - timedelta(days=30) + + orders_in_period_query = db.query(Order).filter( + Order.vendor_id == vendor_id, + Order.created_at >= date_from, + ) + if context and context.date_to: + orders_in_period_query = orders_in_period_query.filter( + Order.created_at <= context.date_to + ) + orders_in_period = orders_in_period_query.count() + + # Total order items + total_order_items = ( + db.query(OrderItem) + .join(Order, Order.id == OrderItem.order_id) + .filter(Order.vendor_id == vendor_id) + .count() + ) + + # Revenue (sum of order totals) - if total_amount field exists + try: + total_revenue = ( + db.query(func.sum(Order.total_amount)) + .filter(Order.vendor_id == vendor_id) + .scalar() + or 0 + ) + + revenue_in_period = ( + db.query(func.sum(Order.total_amount)) + .filter( + Order.vendor_id == vendor_id, + Order.created_at >= date_from, + ) + .scalar() + or 0 + ) + except Exception: + # Field may not exist + total_revenue = 0 + revenue_in_period = 0 + + # Average order value + avg_order_value = round(total_revenue / total_orders, 2) if total_orders > 0 else 0 + + return [ + MetricValue( + key="orders.total", + value=total_orders, + label="Total Orders", + category="orders", + icon="shopping-cart", + description="Total orders received", + ), + MetricValue( + key="orders.in_period", + value=orders_in_period, + label="Recent Orders", + category="orders", + icon="clock", + description="Orders in the selected period", + ), + MetricValue( + key="orders.total_items", + value=total_order_items, + label="Total Items Sold", + category="orders", + icon="box", + description="Total order items", + ), + MetricValue( + key="orders.total_revenue", + value=float(total_revenue), + label="Total Revenue", + category="orders", + icon="currency-euro", + unit="EUR", + description="Total revenue from orders", + ), + MetricValue( + key="orders.revenue_period", + value=float(revenue_in_period), + label="Period Revenue", + category="orders", + icon="trending-up", + unit="EUR", + description="Revenue in the selected period", + ), + MetricValue( + key="orders.avg_value", + value=avg_order_value, + label="Avg Order Value", + category="orders", + icon="calculator", + unit="EUR", + description="Average order value", + ), + ] + except Exception as e: + logger.warning(f"Failed to get order vendor metrics: {e}") + return [] + + def get_platform_metrics( + self, + db: Session, + platform_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get order metrics aggregated for a platform. + + Aggregates order data across all vendors. + """ + from app.modules.orders.models import Order + from app.modules.tenancy.models import VendorPlatform + + try: + # Get all vendor IDs for this platform using VendorPlatform junction table + vendor_ids = ( + db.query(VendorPlatform.vendor_id) + .filter( + VendorPlatform.platform_id == platform_id, + VendorPlatform.is_active == True, + ) + .subquery() + ) + + # Total orders + total_orders = ( + db.query(Order).filter(Order.vendor_id.in_(vendor_ids)).count() + ) + + # Orders in period (default to last 30 days) + date_from = context.date_from if context else None + if date_from is None: + date_from = datetime.utcnow() - timedelta(days=30) + + orders_in_period_query = db.query(Order).filter( + Order.vendor_id.in_(vendor_ids), + Order.created_at >= date_from, + ) + if context and context.date_to: + orders_in_period_query = orders_in_period_query.filter( + Order.created_at <= context.date_to + ) + orders_in_period = orders_in_period_query.count() + + # Vendors with orders + vendors_with_orders = ( + db.query(func.count(func.distinct(Order.vendor_id))) + .filter(Order.vendor_id.in_(vendor_ids)) + .scalar() + or 0 + ) + + # Revenue metrics + try: + total_revenue = ( + db.query(func.sum(Order.total_amount)) + .filter(Order.vendor_id.in_(vendor_ids)) + .scalar() + or 0 + ) + + revenue_in_period = ( + db.query(func.sum(Order.total_amount)) + .filter( + Order.vendor_id.in_(vendor_ids), + Order.created_at >= date_from, + ) + .scalar() + or 0 + ) + except Exception: + total_revenue = 0 + revenue_in_period = 0 + + # Average order value + avg_order_value = round(total_revenue / total_orders, 2) if total_orders > 0 else 0 + + return [ + MetricValue( + key="orders.total", + value=total_orders, + label="Total Orders", + category="orders", + icon="shopping-cart", + description="Total orders across all vendors", + ), + MetricValue( + key="orders.in_period", + value=orders_in_period, + label="Recent Orders", + category="orders", + icon="clock", + description="Orders in the selected period", + ), + MetricValue( + key="orders.vendors_with_orders", + value=vendors_with_orders, + label="Vendors with Orders", + category="orders", + icon="store", + description="Vendors that have received orders", + ), + MetricValue( + key="orders.total_revenue", + value=float(total_revenue), + label="Total Revenue", + category="orders", + icon="currency-euro", + unit="EUR", + description="Total revenue across platform", + ), + MetricValue( + key="orders.revenue_period", + value=float(revenue_in_period), + label="Period Revenue", + category="orders", + icon="trending-up", + unit="EUR", + description="Revenue in the selected period", + ), + MetricValue( + key="orders.avg_value", + value=avg_order_value, + label="Avg Order Value", + category="orders", + icon="calculator", + unit="EUR", + description="Average order value", + ), + ] + except Exception as e: + logger.warning(f"Failed to get order platform metrics: {e}") + return [] + + +# Singleton instance +order_metrics_provider = OrderMetricsProvider() + +__all__ = ["OrderMetricsProvider", "order_metrics_provider"] diff --git a/app/modules/tenancy/definition.py b/app/modules/tenancy/definition.py index 75f14a1d..ca67e2a4 100644 --- a/app/modules/tenancy/definition.py +++ b/app/modules/tenancy/definition.py @@ -14,6 +14,14 @@ from app.modules.base import ( ) from app.modules.enums import FrontendType + +def _get_metrics_provider(): + """Lazy import of metrics provider to avoid circular imports.""" + from app.modules.tenancy.services.tenancy_metrics import tenancy_metrics_provider + + return tenancy_metrics_provider + + tenancy_module = ModuleDefinition( code="tenancy", name="Tenancy Management", @@ -151,6 +159,8 @@ tenancy_module = ModuleDefinition( models_path="app.modules.tenancy.models", schemas_path="app.modules.tenancy.schemas", exceptions_path="app.modules.tenancy.exceptions", + # Metrics provider for dashboard statistics + metrics_provider=_get_metrics_provider, ) __all__ = ["tenancy_module"] diff --git a/app/modules/tenancy/services/tenancy_metrics.py b/app/modules/tenancy/services/tenancy_metrics.py new file mode 100644 index 00000000..adf63c2e --- /dev/null +++ b/app/modules/tenancy/services/tenancy_metrics.py @@ -0,0 +1,329 @@ +# app/modules/tenancy/services/tenancy_metrics.py +""" +Metrics provider for the tenancy module. + +Provides metrics for: +- Vendor counts and status +- User counts and activation +- Team members (vendor users) +- Custom domains +""" + +import logging +from typing import TYPE_CHECKING + +from sqlalchemy.orm import Session + +from app.modules.contracts.metrics import ( + MetricValue, + MetricsContext, + MetricsProviderProtocol, +) + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + + +class TenancyMetricsProvider: + """ + Metrics provider for tenancy module. + + Provides vendor, user, and organizational metrics. + """ + + @property + def metrics_category(self) -> str: + return "tenancy" + + def get_vendor_metrics( + self, + db: Session, + vendor_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get metrics for a specific vendor. + + For vendors, this provides: + - Team member count + - Custom domains count + """ + from app.modules.tenancy.models import VendorDomain, VendorUser + + try: + # Team members count + team_count = ( + db.query(VendorUser) + .filter(VendorUser.vendor_id == vendor_id, VendorUser.is_active == True) + .count() + ) + + # Custom domains count + domains_count = ( + db.query(VendorDomain) + .filter(VendorDomain.vendor_id == vendor_id) + .count() + ) + + # Verified domains count + verified_domains_count = ( + db.query(VendorDomain) + .filter( + VendorDomain.vendor_id == vendor_id, + VendorDomain.is_verified == True, + ) + .count() + ) + + return [ + MetricValue( + key="tenancy.team_members", + value=team_count, + label="Team Members", + category="tenancy", + icon="users", + description="Active team members with access to this vendor", + ), + MetricValue( + key="tenancy.domains", + value=domains_count, + label="Custom Domains", + category="tenancy", + icon="globe", + description="Custom domains configured for this vendor", + ), + MetricValue( + key="tenancy.verified_domains", + value=verified_domains_count, + label="Verified Domains", + category="tenancy", + icon="check-circle", + description="Custom domains that have been verified", + ), + ] + except Exception as e: + logger.warning(f"Failed to get tenancy vendor metrics: {e}") + return [] + + def get_platform_metrics( + self, + db: Session, + platform_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get metrics aggregated for a platform. + + For platforms, this provides: + - Total vendors + - Active vendors + - Verified vendors + - Total users + - Active users + """ + from app.modules.tenancy.models import AdminPlatform, User, Vendor, VendorPlatform + + try: + # Vendor metrics - using VendorPlatform junction table + # Get vendor IDs that are on this platform + platform_vendor_ids = ( + db.query(VendorPlatform.vendor_id) + .filter(VendorPlatform.platform_id == platform_id) + .subquery() + ) + + total_vendors = ( + db.query(Vendor) + .filter(Vendor.id.in_(platform_vendor_ids)) + .count() + ) + + # Active vendors on this platform (vendor active AND membership active) + active_vendor_ids = ( + db.query(VendorPlatform.vendor_id) + .filter( + VendorPlatform.platform_id == platform_id, + VendorPlatform.is_active == True, + ) + .subquery() + ) + active_vendors = ( + db.query(Vendor) + .filter( + Vendor.id.in_(active_vendor_ids), + Vendor.is_active == True, + ) + .count() + ) + + verified_vendors = ( + db.query(Vendor) + .filter( + Vendor.id.in_(platform_vendor_ids), + Vendor.is_verified == True, + ) + .count() + ) + + pending_vendors = ( + db.query(Vendor) + .filter( + Vendor.id.in_(active_vendor_ids), + Vendor.is_active == True, + Vendor.is_verified == False, + ) + .count() + ) + + inactive_vendors = total_vendors - active_vendors + + # User metrics - using AdminPlatform junction table + # Get user IDs that have access to this platform + platform_user_ids = ( + db.query(AdminPlatform.user_id) + .filter( + AdminPlatform.platform_id == platform_id, + AdminPlatform.is_active == True, + ) + .subquery() + ) + + total_users = ( + db.query(User) + .filter(User.id.in_(platform_user_ids)) + .count() + ) + + active_users = ( + db.query(User) + .filter( + User.id.in_(platform_user_ids), + User.is_active == True, + ) + .count() + ) + + admin_users = ( + db.query(User) + .filter( + User.id.in_(platform_user_ids), + User.role == "admin", + ) + .count() + ) + + inactive_users = total_users - active_users + + # Calculate rates + verification_rate = ( + (verified_vendors / total_vendors * 100) if total_vendors > 0 else 0 + ) + user_activation_rate = ( + (active_users / total_users * 100) if total_users > 0 else 0 + ) + + return [ + # Vendor metrics + MetricValue( + key="tenancy.total_vendors", + value=total_vendors, + label="Total Vendors", + category="tenancy", + icon="store", + description="Total number of vendors on this platform", + ), + MetricValue( + key="tenancy.active_vendors", + value=active_vendors, + label="Active Vendors", + category="tenancy", + icon="check-circle", + description="Vendors that are currently active", + ), + MetricValue( + key="tenancy.verified_vendors", + value=verified_vendors, + label="Verified Vendors", + category="tenancy", + icon="badge-check", + description="Vendors that have been verified", + ), + MetricValue( + key="tenancy.pending_vendors", + value=pending_vendors, + label="Pending Vendors", + category="tenancy", + icon="clock", + description="Active vendors pending verification", + ), + MetricValue( + key="tenancy.inactive_vendors", + value=inactive_vendors, + label="Inactive Vendors", + category="tenancy", + icon="pause-circle", + description="Vendors that are not currently active", + ), + MetricValue( + key="tenancy.vendor_verification_rate", + value=round(verification_rate, 1), + label="Verification Rate", + category="tenancy", + icon="percent", + unit="%", + description="Percentage of vendors that are verified", + ), + # User metrics + MetricValue( + key="tenancy.total_users", + value=total_users, + label="Total Users", + category="tenancy", + icon="users", + description="Total number of users on this platform", + ), + MetricValue( + key="tenancy.active_users", + value=active_users, + label="Active Users", + category="tenancy", + icon="user-check", + description="Users that are currently active", + ), + MetricValue( + key="tenancy.admin_users", + value=admin_users, + label="Admin Users", + category="tenancy", + icon="shield", + description="Users with admin role", + ), + MetricValue( + key="tenancy.inactive_users", + value=inactive_users, + label="Inactive Users", + category="tenancy", + icon="user-x", + description="Users that are not currently active", + ), + MetricValue( + key="tenancy.user_activation_rate", + value=round(user_activation_rate, 1), + label="User Activation Rate", + category="tenancy", + icon="percent", + unit="%", + description="Percentage of users that are active", + ), + ] + except Exception as e: + logger.warning(f"Failed to get tenancy platform metrics: {e}") + return [] + + +# Singleton instance +tenancy_metrics_provider = TenancyMetricsProvider() + +__all__ = ["TenancyMetricsProvider", "tenancy_metrics_provider"] diff --git a/app/modules/tenancy/services/vendor_domain_service.py b/app/modules/tenancy/services/vendor_domain_service.py index bdca32d1..6f472906 100644 --- a/app/modules/tenancy/services/vendor_domain_service.py +++ b/app/modules/tenancy/services/vendor_domain_service.py @@ -325,6 +325,8 @@ class VendorDomainService: raise DomainVerificationFailedException( domain.domain, "No TXT records found for verification" ) + except DomainVerificationFailedException: + raise except Exception as dns_error: raise DNSVerificationException(domain.domain, str(dns_error)) diff --git a/app/modules/tenancy/services/vendor_team_service.py b/app/modules/tenancy/services/vendor_team_service.py index af7622dd..366ac42c 100644 --- a/app/modules/tenancy/services/vendor_team_service.py +++ b/app/modules/tenancy/services/vendor_team_service.py @@ -304,7 +304,7 @@ class VendorTeamService: # Cannot remove owner if vendor_user.is_owner: - raise CannotRemoveOwnerException(vendor.vendor_code) + raise CannotRemoveOwnerException(user_id, vendor.id) # Soft delete - just deactivate vendor_user.is_active = False @@ -354,7 +354,7 @@ class VendorTeamService: # Cannot change owner's role if vendor_user.is_owner: - raise CannotRemoveOwnerException(vendor.vendor_code) + raise CannotRemoveOwnerException(user_id, vendor.id) # Get or create new role new_role = self._get_or_create_role( diff --git a/docs/architecture/metrics-provider-pattern.md b/docs/architecture/metrics-provider-pattern.md new file mode 100644 index 00000000..8744cee9 --- /dev/null +++ b/docs/architecture/metrics-provider-pattern.md @@ -0,0 +1,481 @@ +# Metrics Provider Pattern + +The metrics provider pattern enables modules to provide their own statistics for dashboards without creating cross-module dependencies. This is a key architectural pattern that ensures the platform remains modular and extensible. + +## Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Dashboard Request │ +│ (Admin Dashboard or Vendor Dashboard) │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ StatsAggregatorService │ +│ (app/modules/core/services/stats_aggregator.py) │ +│ │ +│ • Discovers MetricsProviders from all enabled modules │ +│ • Calls get_vendor_metrics() or get_platform_metrics() │ +│ • Returns categorized metrics dict │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────────────┼───────────────────────┐ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │tenancy_metrics│ │ order_metrics │ │catalog_metrics│ + │ (enabled) │ │ (enabled) │ │ (disabled) │ + └───────┬───────┘ └───────┬───────┘ └───────────────┘ + │ │ │ + ▼ ▼ × (skipped) + ┌───────────────┐ ┌───────────────┐ + │ vendor_count │ │ total_orders │ + │ user_count │ │ total_revenue │ + └───────────────┘ └───────────────┘ + │ │ + └───────────┬───────────┘ + ▼ + ┌─────────────────────────────────┐ + │ Categorized Metrics │ + │ {"tenancy": [...], "orders": [...]} │ + └─────────────────────────────────┘ +``` + +## Problem Solved + +Before this pattern, dashboard routes had **hard imports** from optional modules: + +```python +# BAD: Core module with hard dependency on optional module +from app.modules.analytics.services import stats_service # What if disabled? +from app.modules.marketplace.models import MarketplaceImportJob # What if disabled? + +stats = stats_service.get_vendor_stats(db, vendor_id) # App crashes! +``` + +This violated the architecture rule: **Core modules cannot depend on optional modules.** + +## Solution: Protocol-Based Metrics + +Each module implements the `MetricsProviderProtocol` and registers it in its `definition.py`. The `StatsAggregatorService` in core discovers and aggregates metrics from all enabled modules. + +## Key Components + +### 1. MetricValue Dataclass + +Standard structure for metric values: + +```python +# app/modules/contracts/metrics.py +from dataclasses import dataclass + +@dataclass +class MetricValue: + key: str # Unique identifier (e.g., "orders.total") + value: int | float | str # The metric value + label: str # Human-readable label + category: str # Grouping category (module name) + icon: str | None = None # Optional UI icon + description: str | None = None # Tooltip description + unit: str | None = None # Unit (%, EUR, items) + trend: str | None = None # "up", "down", "stable" + trend_value: float | None = None # Percentage change +``` + +### 2. MetricsContext Dataclass + +Context for metric queries (date ranges, options): + +```python +@dataclass +class MetricsContext: + date_from: datetime | None = None + date_to: datetime | None = None + include_trends: bool = False + period: str = "30d" # Default period for calculations +``` + +### 3. MetricsProviderProtocol + +Protocol that modules implement: + +```python +from typing import Protocol, runtime_checkable + +@runtime_checkable +class MetricsProviderProtocol(Protocol): + @property + def metrics_category(self) -> str: + """Category name for this provider's metrics (e.g., 'orders').""" + ... + + def get_vendor_metrics( + self, + db: Session, + vendor_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """Get metrics for a specific vendor (vendor dashboard).""" + ... + + def get_platform_metrics( + self, + db: Session, + platform_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """Get metrics aggregated for a platform (admin dashboard).""" + ... +``` + +### 4. StatsAggregatorService + +Central service in core that discovers and aggregates metrics: + +```python +# app/modules/core/services/stats_aggregator.py +class StatsAggregatorService: + def get_vendor_dashboard_stats( + self, + db: Session, + vendor_id: int, + platform_id: int, + context: MetricsContext | None = None, + ) -> dict[str, list[MetricValue]]: + """Get all metrics for a vendor, grouped by category.""" + providers = self._get_enabled_providers(db, platform_id) + return { + p.metrics_category: p.get_vendor_metrics(db, vendor_id, context) + for p in providers + } + + def get_admin_dashboard_stats( + self, + db: Session, + platform_id: int, + context: MetricsContext | None = None, + ) -> dict[str, list[MetricValue]]: + """Get all metrics for admin dashboard, grouped by category.""" + providers = self._get_enabled_providers(db, platform_id) + return { + p.metrics_category: p.get_platform_metrics(db, platform_id, context) + for p in providers + } +``` + +## Implementing a Metrics Provider + +### Step 1: Create the Metrics Provider Class + +```python +# app/modules/orders/services/order_metrics.py +import logging +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.modules.contracts.metrics import ( + MetricValue, + MetricsContext, + MetricsProviderProtocol, +) + +logger = logging.getLogger(__name__) + + +class OrderMetricsProvider: + """Metrics provider for orders module.""" + + @property + def metrics_category(self) -> str: + return "orders" + + def get_vendor_metrics( + self, + db: Session, + vendor_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """Get order metrics for a specific vendor.""" + from app.modules.orders.models import Order + + try: + total_orders = ( + db.query(Order) + .filter(Order.vendor_id == vendor_id) + .count() + ) + + total_revenue = ( + db.query(func.sum(Order.total_amount)) + .filter(Order.vendor_id == vendor_id) + .scalar() or 0 + ) + + return [ + MetricValue( + key="orders.total", + value=total_orders, + label="Total Orders", + category="orders", + icon="shopping-cart", + description="Total orders received", + ), + MetricValue( + key="orders.total_revenue", + value=float(total_revenue), + label="Total Revenue", + category="orders", + icon="currency-euro", + unit="EUR", + description="Total revenue from orders", + ), + ] + except Exception as e: + logger.warning(f"Failed to get order vendor metrics: {e}") + return [] + + def get_platform_metrics( + self, + db: Session, + platform_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """Get order metrics aggregated for a platform.""" + from app.modules.orders.models import Order + from app.modules.tenancy.models import VendorPlatform + + try: + # IMPORTANT: Use VendorPlatform junction table for multi-platform support + vendor_ids = ( + db.query(VendorPlatform.vendor_id) + .filter( + VendorPlatform.platform_id == platform_id, + VendorPlatform.is_active == True, + ) + .subquery() + ) + + total_orders = ( + db.query(Order) + .filter(Order.vendor_id.in_(vendor_ids)) + .count() + ) + + return [ + MetricValue( + key="orders.total", + value=total_orders, + label="Total Orders", + category="orders", + icon="shopping-cart", + description="Total orders across all vendors", + ), + ] + except Exception as e: + logger.warning(f"Failed to get order platform metrics: {e}") + return [] + + +# Singleton instance +order_metrics_provider = OrderMetricsProvider() +``` + +### Step 2: Register in Module Definition + +```python +# app/modules/orders/definition.py +from app.modules.base import ModuleDefinition + + +def _get_metrics_provider(): + """Lazy import to avoid circular imports.""" + from app.modules.orders.services.order_metrics import order_metrics_provider + return order_metrics_provider + + +orders_module = ModuleDefinition( + code="orders", + name="Order Management", + # ... other config ... + + # Register the metrics provider + metrics_provider=_get_metrics_provider, +) +``` + +### Step 3: Metrics Appear Automatically + +When the module is enabled, its metrics automatically appear in dashboards. + +## Multi-Platform Architecture + +### VendorPlatform Junction Table + +Vendors can belong to multiple platforms. When querying platform-level metrics, **always use the VendorPlatform junction table**: + +```python +# CORRECT: Using VendorPlatform junction table +from app.modules.tenancy.models import VendorPlatform + +vendor_ids = ( + db.query(VendorPlatform.vendor_id) + .filter( + VendorPlatform.platform_id == platform_id, + VendorPlatform.is_active == True, + ) + .subquery() +) + +total_orders = ( + db.query(Order) + .filter(Order.vendor_id.in_(vendor_ids)) + .count() +) + +# WRONG: Vendor.platform_id does not exist! +# vendor_ids = db.query(Vendor.id).filter(Vendor.platform_id == platform_id) +``` + +### Platform Context Flow + +Platform context flows through middleware and JWT tokens: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Request Flow │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ PlatformContextMiddleware │ +│ Sets: request.state.platform (Platform object) │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ VendorContextMiddleware │ +│ Sets: request.state.vendor (Vendor object) │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Route Handler (Dashboard) │ +│ │ +│ # Get platform_id from middleware or JWT token │ +│ platform = getattr(request.state, "platform", None) │ +│ platform_id = platform.id if platform else 1 │ +│ │ +│ # Or from JWT for API routes │ +│ platform_id = current_user.token_platform_id or 1 │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Available Metrics Providers + +| Module | Category | Metrics Provided | +|--------|----------|------------------| +| **tenancy** | `tenancy` | vendor counts, user counts, team members, domains | +| **customers** | `customers` | customer counts, new customers | +| **cms** | `cms` | pages, media files, themes | +| **catalog** | `catalog` | products, active products, featured | +| **inventory** | `inventory` | stock levels, low stock, out of stock | +| **orders** | `orders` | order counts, revenue, average order value | +| **marketplace** | `marketplace` | import jobs, staging products, success rate | + +## Dashboard Routes + +### Vendor Dashboard + +```python +# app/modules/core/routes/api/vendor_dashboard.py +@router.get("/stats", response_model=VendorDashboardStatsResponse) +def get_vendor_dashboard_stats( + request: Request, + current_user: UserContext = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + vendor_id = current_user.token_vendor_id + + # Get platform from middleware + platform = getattr(request.state, "platform", None) + platform_id = platform.id if platform else 1 + + # Get aggregated metrics from all enabled modules + metrics = stats_aggregator.get_vendor_dashboard_stats( + db=db, + vendor_id=vendor_id, + platform_id=platform_id, + ) + + # Extract and return formatted response + ... +``` + +### Admin Dashboard + +```python +# app/modules/core/routes/api/admin_dashboard.py +@router.get("/stats", response_model=StatsResponse) +def get_comprehensive_stats( + request: Request, + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + # Get platform_id with fallback logic + platform_id = _get_platform_id(request, current_admin) + + # Get aggregated metrics from all enabled modules + metrics = stats_aggregator.get_admin_dashboard_stats( + db=db, + platform_id=platform_id, + ) + + # Extract and return formatted response + ... +``` + +## Benefits + +| Aspect | Before | After | +|--------|--------|-------| +| Core depends on optional | Hard import (crashes) | Protocol-based (graceful) | +| Adding new metrics | Edit analytics module | Just add provider to your module | +| Module isolation | Coupled | Truly independent | +| Testing | Hard (need all modules) | Easy (mock protocol) | +| Disable module | App crashes | Dashboard shows partial data | + +## Error Handling + +Metrics providers are wrapped in try/except to prevent one failing module from breaking the entire dashboard: + +```python +try: + metrics = provider.get_vendor_metrics(db, vendor_id, context) +except Exception as e: + logger.warning(f"Failed to get {provider.metrics_category} metrics: {e}") + metrics = [] # Continue with empty metrics for this module +``` + +## Best Practices + +### Do + +- Use lazy imports inside metric 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 + +### 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 metric providers +- Create hard dependencies between core and optional modules + +## Related Documentation + +- [Module System Architecture](module-system.md) - Module structure and auto-discovery +- [Multi-Tenant Architecture](multi-tenant.md) - Platform/vendor/company hierarchy +- [Middleware](middleware.md) - Request context flow +- [User Context Pattern](user-context-pattern.md) - JWT token context diff --git a/docs/architecture/module-system.md b/docs/architecture/module-system.md index 822d21af..661d79de 100644 --- a/docs/architecture/module-system.md +++ b/docs/architecture/module-system.md @@ -242,6 +242,7 @@ analytics_module = ModuleDefinition( | `is_core` | `bool` | Cannot be disabled if True | | `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)) | ## Route Auto-Discovery @@ -1260,6 +1261,7 @@ python scripts/validate_architecture.py ## Related Documentation - [Creating Modules](../development/creating-modules.md) - Step-by-step guide +- [Metrics Provider Pattern](metrics-provider-pattern.md) - Dashboard statistics architecture - [Menu Management](menu-management.md) - Sidebar configuration - [Observability](observability.md) - Health checks integration - [Feature Gating](../implementation/feature-gating-system.md) - Tier-based access