refactor: move letzshop endpoints to marketplace module and add vendor service tests
Move letzshop-related functionality from tenancy to marketplace module: - Move admin letzshop routes to marketplace/routes/api/admin_letzshop.py - Move letzshop schemas to marketplace/schemas/letzshop.py - Remove letzshop code from tenancy module (admin_vendors, vendor_service) - Update model exports and imports Add comprehensive unit tests for vendor services: - test_company_service.py: Company management operations - test_platform_service.py: Platform management operations - test_vendor_domain_service.py: Vendor domain operations - test_vendor_team_service.py: Vendor team management Update module definitions: - billing, messaging, payments: Minor definition updates Add architecture proposals documentation: - Module dependency redesign session notes - Decouple modules implementation plan - Module decoupling proposal Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
917
docs/proposals/decouple-modules.md
Normal file
917
docs/proposals/decouple-modules.md
Normal file
@@ -0,0 +1,917 @@
|
||||
Analytics Dependency Status: ❌ NOT FIXED ─
|
||||
|
||||
The MetricsProvider pattern exists in contracts/metrics.py, but admin_vendors.py still has hard imports:
|
||||
|
||||
File: app/modules/tenancy/routes/api/admin_vendors.py
|
||||
┌──────┬────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────┐
|
||||
│ Line │ Import │ Used In │
|
||||
├──────┼────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────┤
|
||||
│ 20 │ from app.modules.analytics.services.stats_service import stats_service │ get_vendor_statistics_endpoint() (line 110) │
|
||||
├──────┼────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────┤
|
||||
│ 23 │ from app.modules.analytics.schemas import VendorStatsResponse │ Return type (line 104) │
|
||||
└──────┴────────────────────────────────────────────────────────────────────────┴─────────────────────────────────────────────┘
|
||||
---
|
||||
All Core → Optional Dependencies
|
||||
|
||||
1. tenancy → marketplace
|
||||
|
||||
File: app/modules/tenancy/models/__init__.py:22
|
||||
from app.modules.marketplace.models.marketplace_import_job import MarketplaceImportJob
|
||||
- Purpose: SQLAlchemy needs this to resolve User.marketplace_import_jobs and Vendor.marketplace_import_jobs relationships
|
||||
|
||||
File: app/modules/tenancy/services/vendor_service.py
|
||||
┌──────┬─────────────────────────────────────┬───────────────────────────────────────────────────────────────────────┐
|
||||
│ Line │ Import │ Functions Using It │
|
||||
├──────┼─────────────────────────────────────┼───────────────────────────────────────────────────────────────────────┤
|
||||
│ 19 │ MarketplaceProductNotFoundException │ add_product_to_catalog() :463, _get_product_by_id_or_raise() :571 │
|
||||
├──────┼─────────────────────────────────────┼───────────────────────────────────────────────────────────────────────┤
|
||||
│ 26 │ MarketplaceProduct model │ _get_product_by_id_or_raise() :565-566, add_product_to_catalog() :466 │
|
||||
└──────┴─────────────────────────────────────┴───────────────────────────────────────────────────────────────────────┘
|
||||
---
|
||||
2. tenancy → catalog
|
||||
|
||||
File: app/modules/tenancy/services/vendor_service.py
|
||||
┌──────┬───────────────────────────────┬────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Line │ Import │ Functions Using It │
|
||||
├──────┼───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ 18 │ ProductAlreadyExistsException │ add_product_to_catalog() :472 │
|
||||
├──────┼───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ 27 │ Product model │ add_product_to_catalog() :477, get_products() :533, _product_in_catalog() :579 │
|
||||
├──────┼───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ 30 │ ProductCreate schema │ add_product_to_catalog() parameter type :447 │
|
||||
└──────┴───────────────────────────────┴────────────────────────────────────────────────────────────────────────────────┘
|
||||
---
|
||||
3. billing → catalog
|
||||
|
||||
File: app/modules/billing/services/subscription_service.py:48
|
||||
┌───────────────────────┬─────────┬─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Function │ Lines │ What It Does │
|
||||
├───────────────────────┼─────────┼─────────────────────────────────────────────────────────────────────────┤
|
||||
│ get_usage() │ 371-376 │ db.query(func.count(Product.id)).filter(Product.vendor_id == vendor_id) │
|
||||
├───────────────────────┼─────────┼─────────────────────────────────────────────────────────────────────────┤
|
||||
│ get_usage_summary() │ 420-425 │ Same product count query │
|
||||
├───────────────────────┼─────────┼─────────────────────────────────────────────────────────────────────────┤
|
||||
│ can_add_product() │ 514-519 │ Same product count query │
|
||||
├───────────────────────┼─────────┼─────────────────────────────────────────────────────────────────────────┤
|
||||
│ check_product_limit() │ 532-537 │ Same product count query │
|
||||
└───────────────────────┴─────────┴─────────────────────────────────────────────────────────────────────────┘
|
||||
File: app/modules/billing/services/admin_subscription_service.py:30
|
||||
┌───────────────────────────┬─────────┬─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Function │ Lines │ What It Does │
|
||||
├───────────────────────────┼─────────┼─────────────────────────────────────────────────────────────────────────┤
|
||||
│ get_vendor_usage_counts() │ 212-217 │ db.query(func.count(Product.id)).filter(Product.vendor_id == vendor_id) │
|
||||
└───────────────────────────┴─────────┴─────────────────────────────────────────────────────────────────────────┘
|
||||
File: app/modules/billing/services/capacity_forecast_service.py:19
|
||||
┌──────────────────────────┬──────┬───────────────────────────────────────────┐
|
||||
│ Function │ Line │ What It Does │
|
||||
├──────────────────────────┼──────┼───────────────────────────────────────────┤
|
||||
│ capture_daily_snapshot() │ 91 │ db.query(func.count(Product.id)).scalar() │
|
||||
└──────────────────────────┴──────┴───────────────────────────────────────────┘
|
||||
---
|
||||
Summary: What to Move Where?
|
||||
┌──────────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Dependency │ Recommendation │
|
||||
├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ admin_vendors.py → analytics │ Use the existing MetricsProvider pattern via stats_aggregator │
|
||||
├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ vendor_service.py → marketplace/catalog │ Move add_product_to_catalog() and get_products() to catalog module - these are product operations │
|
||||
├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ tenancy/models/__init__.py → marketplace │ Remove MarketplaceImportJob import, move relationships to marketplace module │
|
||||
├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ billing/* → catalog (Product count) │ Create ProductCountProvider protocol in contracts │
|
||||
└──────────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
Would you like me to start working on any of these?
|
||||
|
||||
|
||||
--------------------
|
||||
Modular Architecture Analysis
|
||||
|
||||
Executive Summary
|
||||
|
||||
This analysis answers two questions:
|
||||
1. Are all modules self-contained and respecting architecture rules?
|
||||
2. Would the app still run if non-core modules are removed?
|
||||
|
||||
Key Findings:
|
||||
- ✅ Route registration is fully auto-discovered (no hardcoded module routes)
|
||||
- ✅ Middleware has no module-specific dependencies
|
||||
- ✅ API routers use dynamic discovery pattern
|
||||
- ❌ CRITICAL: Core modules have hard dependencies on optional modules - the app would CRASH if optional modules are removed
|
||||
|
||||
---
|
||||
Part 1: Architecture Rule Compliance
|
||||
|
||||
What's Working Well
|
||||
┌─────────────────────────┬────────┬────────────────────────────────────────────────┐
|
||||
│ Aspect │ Status │ Details │
|
||||
├─────────────────────────┼────────┼────────────────────────────────────────────────┤
|
||||
│ Module auto-discovery │ ✅ │ All modules discovered via definition.py │
|
||||
├─────────────────────────┼────────┼────────────────────────────────────────────────┤
|
||||
│ Route auto-registration │ ✅ │ Admin, vendor, storefront routes fully dynamic │
|
||||
├─────────────────────────┼────────┼────────────────────────────────────────────────┤
|
||||
│ Middleware stack │ ✅ │ No hardcoded module dependencies │
|
||||
├─────────────────────────┼────────┼────────────────────────────────────────────────┤
|
||||
│ Static file mounting │ ✅ │ Dynamic scanning of module directories │
|
||||
├─────────────────────────┼────────┼────────────────────────────────────────────────┤
|
||||
│ Module enablement │ ✅ │ Controlled via PlatformModule.is_enabled │
|
||||
└─────────────────────────┴────────┴────────────────────────────────────────────────┘
|
||||
Previous Issues Fixed (This Session)
|
||||
┌───────────────────────────────────┬──────────┬─────────────────────────────────────────────────────────────┐
|
||||
│ Issue │ Status │ Fix Applied │
|
||||
├───────────────────────────────────┼──────────┼─────────────────────────────────────────────────────────────┤
|
||||
│ Module definition variable names │ ✅ Fixed │ catalog, checkout, cart renamed to *_module │
|
||||
├───────────────────────────────────┼──────────┼─────────────────────────────────────────────────────────────┤
|
||||
│ Router attachment functions │ ✅ Fixed │ Added get_*_module_with_routers() functions │
|
||||
├───────────────────────────────────┼──────────┼─────────────────────────────────────────────────────────────┤
|
||||
│ Billing exceptions location │ ✅ Fixed │ Moved to exceptions.py with backwards-compat aliases │
|
||||
├───────────────────────────────────┼──────────┼─────────────────────────────────────────────────────────────┤
|
||||
│ VendorEmailSettingsService DI │ ✅ Fixed │ Changed to db-as-parameter pattern │
|
||||
├───────────────────────────────────┼──────────┼─────────────────────────────────────────────────────────────┤
|
||||
│ VendorDomainService exception bug │ ✅ Fixed │ Added proper re-raise for DomainVerificationFailedException │
|
||||
├───────────────────────────────────┼──────────┼─────────────────────────────────────────────────────────────┤
|
||||
│ VendorTeamService exception bug │ ✅ Fixed │ Fixed CannotRemoveOwnerException arguments │
|
||||
└───────────────────────────────────┴──────────┴─────────────────────────────────────────────────────────────┘
|
||||
---
|
||||
Part 2: Critical Architecture Violations
|
||||
|
||||
❌ Core → Optional Module Dependencies
|
||||
|
||||
The app would NOT run if optional modules are removed. Core modules have hard imports from optional modules:
|
||||
|
||||
tenancy (core) → optional modules
|
||||
|
||||
tenancy/models/__init__.py:22
|
||||
→ from app.modules.marketplace.models import MarketplaceImportJob
|
||||
|
||||
tenancy/services/admin_service.py:36,40
|
||||
→ from app.modules.marketplace.models import MarketplaceImportJob
|
||||
→ from app.modules.marketplace.schemas import MarketplaceImportJobResponse
|
||||
|
||||
tenancy/services/vendor_service.py:18,19,26,27,30
|
||||
→ from app.modules.catalog.exceptions import ProductAlreadyExistsException
|
||||
→ from app.modules.marketplace.exceptions import MarketplaceProductNotFoundException
|
||||
→ from app.modules.marketplace.models import MarketplaceProduct
|
||||
→ from app.modules.catalog.models import Product
|
||||
→ from app.modules.catalog.schemas import ProductCreate
|
||||
|
||||
tenancy/services/vendor_team_service.py:34
|
||||
→ from app.modules.billing.exceptions import TierLimitExceededException
|
||||
|
||||
tenancy/routes/api/admin_vendors.py:20,23,348,399
|
||||
→ from app.modules.analytics.services.stats_service import stats_service
|
||||
→ from app.modules.analytics.schemas import VendorStatsResponse
|
||||
→ from app.modules.marketplace.services import letzshop_export_service
|
||||
|
||||
tenancy/routes/api/admin_platform_users.py:18
|
||||
→ from app.modules.analytics.services.stats_service import stats_service
|
||||
|
||||
core (core) → analytics (optional)
|
||||
|
||||
core/routes/api/admin_dashboard.py:14,16
|
||||
→ from app.modules.analytics.services.stats_service import stats_service
|
||||
→ from app.modules.analytics.schemas import VendorStatsResponse, ...
|
||||
|
||||
core/routes/api/vendor_dashboard.py:17,20
|
||||
→ from app.modules.analytics.services.stats_service import stats_service
|
||||
→ from app.modules.analytics.schemas import VendorStatsResponse, ...
|
||||
|
||||
cms (core) → optional modules
|
||||
|
||||
cms/models/__init__.py:14, cms/models/media.py:9, cms/services/media_service.py:31
|
||||
→ from app.modules.catalog.models import ProductMedia
|
||||
|
||||
cms/routes/pages/platform.py:17
|
||||
→ from app.modules.billing.models import TIER_LIMITS, TierCode
|
||||
|
||||
cms/services/vendor_email_settings_service.py:33
|
||||
→ from app.modules.billing.models import VendorSubscription, TierCode
|
||||
|
||||
customers (core) → orders (optional)
|
||||
|
||||
customers/services/customer_service.py:332,368
|
||||
→ from app.modules.orders.models import Order # (lazy import but still hard dep)
|
||||
|
||||
---
|
||||
Part 3: Module Categorization (UPDATED 2026-02-03)
|
||||
|
||||
Core Modules (8) - Always enabled
|
||||
┌───────────┬───────────────────────────────────────────────────┬──────────────────────────────┐
|
||||
│ Module │ Purpose │ Depends on Optional? │
|
||||
├───────────┼───────────────────────────────────────────────────┼──────────────────────────────┤
|
||||
│ contracts │ Cross-module Protocol interfaces │ No │
|
||||
├───────────┼───────────────────────────────────────────────────┼──────────────────────────────┤
|
||||
│ core │ Dashboard, settings, profile │ ❌ YES (analytics) │
|
||||
├───────────┼───────────────────────────────────────────────────┼──────────────────────────────┤
|
||||
│ cms │ Content pages, media, themes │ ❌ YES (catalog) │
|
||||
├───────────┼───────────────────────────────────────────────────┼──────────────────────────────┤
|
||||
│ customers │ Customer database, profiles │ ❌ YES (orders) │
|
||||
├───────────┼───────────────────────────────────────────────────┼──────────────────────────────┤
|
||||
│ tenancy │ Platforms, companies, vendors, users │ ❌ YES (marketplace,catalog, │
|
||||
│ │ │ analytics) │
|
||||
├───────────┼───────────────────────────────────────────────────┼──────────────────────────────┤
|
||||
│ billing │ Subscriptions, tier limits, invoices │ No (depends on payments, │
|
||||
│ │ │ which is also core) │
|
||||
├───────────┼───────────────────────────────────────────────────┼──────────────────────────────┤
|
||||
│ payments │ Payment gateways (Stripe, PayPal) │ No │
|
||||
├───────────┼───────────────────────────────────────────────────┼──────────────────────────────┤
|
||||
│ messaging │ Email, notifications, templates │ No │
|
||||
└───────────┴───────────────────────────────────────────────────┴──────────────────────────────┘
|
||||
|
||||
Why billing, payments, messaging are core (decided 2026-02-03):
|
||||
- billing: Tier limits affect team size, product limits, email providers - pervasive
|
||||
- payments: Required by billing for subscription payment processing
|
||||
- messaging: Email required for registration, password reset, team invitations
|
||||
|
||||
Optional Modules (8) - Can be disabled per platform
|
||||
┌─────────────┬────────────────────────────┬───────────────────────────────────┐
|
||||
│ Module │ Purpose │ Dependencies │
|
||||
├─────────────┼────────────────────────────┼───────────────────────────────────┤
|
||||
│ analytics │ Reports, dashboards │ (standalone) │
|
||||
├─────────────┼────────────────────────────┼───────────────────────────────────┤
|
||||
│ cart │ Shopping cart │ inventory │
|
||||
├─────────────┼────────────────────────────┼───────────────────────────────────┤
|
||||
│ catalog │ Product catalog │ inventory │
|
||||
├─────────────┼────────────────────────────┼───────────────────────────────────┤
|
||||
│ checkout │ Order placement │ cart, orders, customers │
|
||||
├─────────────┼────────────────────────────┼───────────────────────────────────┤
|
||||
│ inventory │ Stock management │ (standalone) │
|
||||
├─────────────┼────────────────────────────┼───────────────────────────────────┤
|
||||
│ loyalty │ Loyalty programs │ customers │
|
||||
├─────────────┼────────────────────────────┼───────────────────────────────────┤
|
||||
│ marketplace │ Letzshop integration │ inventory │
|
||||
├─────────────┼────────────────────────────┼───────────────────────────────────┤
|
||||
│ orders │ Order management │ (standalone) │
|
||||
└─────────────┴────────────────────────────┴───────────────────────────────────┘
|
||||
|
||||
Internal Modules (2) - Admin-only
|
||||
┌────────────┬─────────────────────────────────┐
|
||||
│ Module │ Purpose │
|
||||
├────────────┼─────────────────────────────────┤
|
||||
│ dev-tools │ Component library, icon browser │
|
||||
├────────────┼─────────────────────────────────┤
|
||||
│ monitoring │ Logs, tasks, health checks │
|
||||
└────────────┴─────────────────────────────────┘
|
||||
---
|
||||
Part 4: Answer to User Questions
|
||||
|
||||
Q1: Are all modules self-contained and respecting architecture rules?
|
||||
|
||||
NO. While the route registration and middleware are properly decoupled, the core modules have hard import dependencies on optional modules. This violates the documented rule that "core
|
||||
modules cannot depend on optional modules."
|
||||
|
||||
Specific violations:
|
||||
- tenancy depends on: marketplace, billing, catalog, analytics
|
||||
- core depends on: analytics
|
||||
- cms depends on: catalog, billing
|
||||
- customers depends on: orders
|
||||
|
||||
Q2: Would the app still run if non-core modules are removed?
|
||||
|
||||
NO. The app would crash immediately on import with ImportError or ModuleNotFoundError because:
|
||||
|
||||
1. tenancy/models/__init__.py imports MarketplaceImportJob at module load time
|
||||
2. core/routes/api/admin_dashboard.py imports stats_service at module load time
|
||||
3. cms/models/__init__.py imports ProductMedia at module load time
|
||||
|
||||
Even if using lazy imports (like in customers/services/customer_service.py), the code paths that use these imports would still fail at runtime.
|
||||
|
||||
---
|
||||
Part 5: Contracts Pattern Deep Dive (Option A)
|
||||
|
||||
How the Existing Contracts Module Works
|
||||
|
||||
The app/modules/contracts/ module already implements Protocol-based interfaces:
|
||||
|
||||
app/modules/contracts/
|
||||
├── __init__.py # Exports all protocols
|
||||
├── base.py # ServiceProtocol, CRUDServiceProtocol
|
||||
├── cms.py # ContentServiceProtocol, MediaServiceProtocol
|
||||
└── definition.py # Module definition (is_core=True)
|
||||
|
||||
Key Pattern:
|
||||
# 1. Protocol definition in contracts (no implementation)
|
||||
@runtime_checkable
|
||||
class ContentServiceProtocol(Protocol):
|
||||
def get_page_for_vendor(self, db: Session, ...) -> object | None: ...
|
||||
|
||||
# 2. Implementation in the module itself
|
||||
class ContentPageService: # Implements the protocol implicitly (duck typing)
|
||||
def get_page_for_vendor(self, db: Session, ...) -> ContentPage | None:
|
||||
# actual implementation
|
||||
|
||||
# 3. Usage in other modules (depends on protocol, not implementation)
|
||||
class OrderService:
|
||||
def __init__(self, content: ContentServiceProtocol | None = None):
|
||||
self._content = content
|
||||
|
||||
@property
|
||||
def content(self) -> ContentServiceProtocol:
|
||||
if self._content is None:
|
||||
from app.modules.cms.services import content_page_service # Lazy load
|
||||
self._content = content_page_service
|
||||
return self._content
|
||||
|
||||
---
|
||||
Part 6: Metrics Provider Pattern (Proposed)
|
||||
|
||||
Design Goal
|
||||
|
||||
Each module provides its own metrics/stats. The analytics module aggregates them but doesn't implement them.
|
||||
|
||||
Step 1: Define MetricsProvider Protocol
|
||||
|
||||
File: app/modules/contracts/metrics.py
|
||||
from typing import Protocol, runtime_checkable, TYPE_CHECKING
|
||||
from dataclasses import dataclass
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@dataclass
|
||||
class MetricValue:
|
||||
"""Standard metric value with metadata."""
|
||||
key: str # e.g., "orders.total_count"
|
||||
value: int | float | str
|
||||
label: str # Human-readable label
|
||||
category: str # Grouping category
|
||||
icon: str | None = None # Optional UI icon
|
||||
|
||||
@runtime_checkable
|
||||
class MetricsProviderProtocol(Protocol):
|
||||
"""
|
||||
Protocol for modules that provide metrics/statistics.
|
||||
|
||||
Each module implements this to expose its own metrics.
|
||||
The analytics module discovers and aggregates all providers.
|
||||
"""
|
||||
|
||||
@property
|
||||
def metrics_category(self) -> str:
|
||||
"""Category name for this provider's metrics (e.g., 'orders', 'inventory')."""
|
||||
...
|
||||
|
||||
def get_vendor_metrics(
|
||||
self,
|
||||
db: "Session",
|
||||
vendor_id: int,
|
||||
date_from: "datetime | None" = None,
|
||||
date_to: "datetime | None" = None,
|
||||
) -> list[MetricValue]:
|
||||
"""Get metrics for a specific vendor."""
|
||||
...
|
||||
|
||||
def get_platform_metrics(
|
||||
self,
|
||||
db: "Session",
|
||||
platform_id: int,
|
||||
date_from: "datetime | None" = None,
|
||||
date_to: "datetime | None" = None,
|
||||
) -> list[MetricValue]:
|
||||
"""Get metrics aggregated for a platform."""
|
||||
...
|
||||
|
||||
Step 2: Each Module Implements the Protocol
|
||||
|
||||
File: app/modules/orders/services/order_metrics.py
|
||||
from app.modules.contracts.metrics import MetricsProviderProtocol, MetricValue
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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, date_from=None, date_to=None
|
||||
) -> list[MetricValue]:
|
||||
from app.modules.orders.models import Order
|
||||
|
||||
query = db.query(Order).filter(Order.vendor_id == vendor_id)
|
||||
if date_from:
|
||||
query = query.filter(Order.created_at >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(Order.created_at <= date_to)
|
||||
|
||||
total_orders = query.count()
|
||||
total_revenue = query.with_entities(func.sum(Order.total)).scalar() or 0
|
||||
|
||||
return [
|
||||
MetricValue(
|
||||
key="orders.total_count",
|
||||
value=total_orders,
|
||||
label="Total Orders",
|
||||
category="orders",
|
||||
icon="shopping-cart"
|
||||
),
|
||||
MetricValue(
|
||||
key="orders.total_revenue",
|
||||
value=float(total_revenue),
|
||||
label="Total Revenue",
|
||||
category="orders",
|
||||
icon="currency-euro"
|
||||
),
|
||||
]
|
||||
|
||||
def get_platform_metrics(self, db: Session, platform_id: int, **kwargs):
|
||||
# Aggregate across all vendors in platform
|
||||
...
|
||||
|
||||
# Singleton instance
|
||||
order_metrics_provider = OrderMetricsProvider()
|
||||
|
||||
Step 3: Register Provider in Module Definition
|
||||
|
||||
File: app/modules/orders/definition.py
|
||||
from app.modules.base import ModuleDefinition
|
||||
|
||||
def _get_metrics_provider():
|
||||
"""Lazy load metrics provider 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",
|
||||
# ... existing config ...
|
||||
metrics_provider=_get_metrics_provider, # NEW: Optional callable
|
||||
)
|
||||
|
||||
Step 4: Analytics Module Discovers All Providers
|
||||
|
||||
File: app/modules/analytics/services/stats_aggregator.py
|
||||
from app.modules.contracts.metrics import MetricsProviderProtocol, MetricValue
|
||||
from app.modules.registry import MODULES
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
class StatsAggregatorService:
|
||||
"""Aggregates metrics from all module providers."""
|
||||
|
||||
def _get_enabled_providers(self, db: Session, platform_id: int) -> list[MetricsProviderProtocol]:
|
||||
"""Get metrics providers from enabled modules."""
|
||||
from app.modules.service import module_service
|
||||
|
||||
providers = []
|
||||
for module in MODULES.values():
|
||||
# Check if module is enabled for this platform
|
||||
if not module_service.is_module_enabled(db, platform_id, module.code):
|
||||
continue
|
||||
|
||||
# Check if module has a metrics provider
|
||||
if hasattr(module, 'metrics_provider') and module.metrics_provider:
|
||||
provider = module.metrics_provider() # Call the lazy getter
|
||||
if provider:
|
||||
providers.append(provider)
|
||||
|
||||
return providers
|
||||
|
||||
def get_vendor_stats(
|
||||
self, db: Session, vendor_id: int, platform_id: int, **kwargs
|
||||
) -> dict[str, list[MetricValue]]:
|
||||
"""Get all metrics for a vendor, grouped by category."""
|
||||
providers = self._get_enabled_providers(db, platform_id)
|
||||
|
||||
result = {}
|
||||
for provider in providers:
|
||||
metrics = provider.get_vendor_metrics(db, vendor_id, **kwargs)
|
||||
result[provider.metrics_category] = metrics
|
||||
|
||||
return result
|
||||
|
||||
stats_aggregator = StatsAggregatorService()
|
||||
|
||||
Step 5: Dashboard Uses Protocol, Not Implementation
|
||||
|
||||
File: app/modules/core/routes/api/vendor_dashboard.py
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
router = APIRouter(prefix="/dashboard")
|
||||
|
||||
@router.get("/stats")
|
||||
def get_dashboard_stats(
|
||||
current_user: UserContext = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get aggregated stats for vendor dashboard."""
|
||||
# Lazy import - only fails if analytics module removed AND this endpoint called
|
||||
from app.modules.analytics.services.stats_aggregator import stats_aggregator
|
||||
|
||||
return stats_aggregator.get_vendor_stats(
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
platform_id=current_user.platform_id,
|
||||
)
|
||||
|
||||
---
|
||||
Part 7: Creating a New Module with Metrics
|
||||
|
||||
Checklist for New Module with Metrics Support
|
||||
|
||||
1. Create module structure:
|
||||
app/modules/my_module/
|
||||
├── __init__.py
|
||||
├── definition.py # Include metrics_provider
|
||||
├── models/
|
||||
├── services/
|
||||
│ ├── __init__.py
|
||||
│ └── my_metrics.py # Implement MetricsProviderProtocol
|
||||
├── routes/
|
||||
└── schemas/
|
||||
2. Implement metrics provider:
|
||||
# app/modules/my_module/services/my_metrics.py
|
||||
from app.modules.contracts.metrics import MetricsProviderProtocol, MetricValue
|
||||
|
||||
class MyModuleMetricsProvider:
|
||||
@property
|
||||
def metrics_category(self) -> str:
|
||||
return "my_module"
|
||||
|
||||
def get_vendor_metrics(self, db, vendor_id, **kwargs):
|
||||
return [
|
||||
MetricValue(key="my_module.count", value=42, label="My Count", category="my_module")
|
||||
]
|
||||
|
||||
def get_platform_metrics(self, db, platform_id, **kwargs):
|
||||
return []
|
||||
|
||||
my_module_metrics = MyModuleMetricsProvider()
|
||||
3. Register in definition:
|
||||
# app/modules/my_module/definition.py
|
||||
def _get_metrics_provider():
|
||||
from app.modules.my_module.services.my_metrics import my_module_metrics
|
||||
return my_module_metrics
|
||||
|
||||
my_module = ModuleDefinition(
|
||||
code="my_module",
|
||||
metrics_provider=_get_metrics_provider,
|
||||
# ...
|
||||
)
|
||||
4. Metrics appear automatically in dashboards when module is enabled
|
||||
|
||||
---
|
||||
Part 8: Benefits of This Architecture
|
||||
┌───────────────────────────┬───────────────────────┬──────────────────────────────────┐
|
||||
│ Aspect │ Before │ After │
|
||||
├───────────────────────────┼───────────────────────┼──────────────────────────────────┤
|
||||
│ Core depends on analytics │ ❌ Hard import │ ✅ Optional lazy import │
|
||||
├───────────────────────────┼───────────────────────┼──────────────────────────────────┤
|
||||
│ Adding new metrics │ Edit analytics module │ Just add provider to your module │
|
||||
├───────────────────────────┼───────────────────────┼──────────────────────────────────┤
|
||||
│ Module isolation │ ❌ Coupled │ ✅ Truly independent │
|
||||
├───────────────────────────┼───────────────────────┼──────────────────────────────────┤
|
||||
│ Testing │ Hard (need analytics) │ Easy (mock protocol) │
|
||||
├───────────────────────────┼───────────────────────┼──────────────────────────────────┤
|
||||
│ Disable analytics module │ ❌ App crashes │ ✅ Dashboard shows "no stats" │
|
||||
└───────────────────────────┴───────────────────────┴──────────────────────────────────┘
|
||||
---
|
||||
Part 9: Core Module Metrics & Dashboard Architecture
|
||||
|
||||
Key Questions Answered
|
||||
|
||||
Q1: How do core modules implement MetricsProviderProtocol?
|
||||
|
||||
Core modules implement MetricsProviderProtocol the same way as optional modules. The difference is they're always enabled.
|
||||
|
||||
tenancy module metrics:
|
||||
# app/modules/tenancy/services/tenancy_metrics.py
|
||||
class TenancyMetricsProvider:
|
||||
@property
|
||||
def metrics_category(self) -> str:
|
||||
return "tenancy"
|
||||
|
||||
def get_vendor_metrics(self, db, vendor_id, **kwargs):
|
||||
# Vendor-specific: team members count, domains count
|
||||
from app.modules.tenancy.models import VendorUser, VendorDomain
|
||||
|
||||
team_count = db.query(VendorUser).filter(
|
||||
VendorUser.vendor_id == vendor_id, VendorUser.is_active == True
|
||||
).count()
|
||||
|
||||
domains_count = db.query(VendorDomain).filter(
|
||||
VendorDomain.vendor_id == vendor_id
|
||||
).count()
|
||||
|
||||
return [
|
||||
MetricValue(key="tenancy.team_members", value=team_count,
|
||||
label="Team Members", category="tenancy", icon="users"),
|
||||
MetricValue(key="tenancy.domains", value=domains_count,
|
||||
label="Custom Domains", category="tenancy", icon="globe"),
|
||||
]
|
||||
|
||||
def get_platform_metrics(self, db, platform_id, **kwargs):
|
||||
# Platform-wide: total vendors, total users, active vendors
|
||||
from app.modules.tenancy.models import Vendor, User
|
||||
|
||||
total_vendors = db.query(Vendor).filter(
|
||||
Vendor.platform_id == platform_id
|
||||
).count()
|
||||
|
||||
active_vendors = db.query(Vendor).filter(
|
||||
Vendor.platform_id == platform_id, Vendor.is_active == True
|
||||
).count()
|
||||
|
||||
return [
|
||||
MetricValue(key="tenancy.total_vendors", value=total_vendors,
|
||||
label="Total Vendors", category="tenancy", icon="store"),
|
||||
MetricValue(key="tenancy.active_vendors", value=active_vendors,
|
||||
label="Active Vendors", category="tenancy", icon="check-circle"),
|
||||
]
|
||||
|
||||
customers module metrics:
|
||||
# app/modules/customers/services/customer_metrics.py
|
||||
class CustomerMetricsProvider:
|
||||
@property
|
||||
def metrics_category(self) -> str:
|
||||
return "customers"
|
||||
|
||||
def get_vendor_metrics(self, db, vendor_id, date_from=None, date_to=None):
|
||||
from app.modules.customers.models import Customer
|
||||
|
||||
query = db.query(Customer).filter(Customer.vendor_id == vendor_id)
|
||||
total = query.count()
|
||||
|
||||
# New customers in period
|
||||
if date_from:
|
||||
new_query = query.filter(Customer.created_at >= date_from)
|
||||
if date_to:
|
||||
new_query = new_query.filter(Customer.created_at <= date_to)
|
||||
new_count = new_query.count()
|
||||
else:
|
||||
new_count = 0
|
||||
|
||||
return [
|
||||
MetricValue(key="customers.total", value=total,
|
||||
label="Total Customers", category="customers", icon="users"),
|
||||
MetricValue(key="customers.new", value=new_count,
|
||||
label="New Customers", category="customers", icon="user-plus"),
|
||||
]
|
||||
|
||||
def get_platform_metrics(self, db, platform_id, **kwargs):
|
||||
# Platform admin sees aggregated customer stats
|
||||
...
|
||||
|
||||
Q2: Should analytics be part of core?
|
||||
|
||||
Recommendation: Split analytics into two parts:
|
||||
┌────────────────────────┬─────────────────────────────┬─────────────────────────────────────────────────────┐
|
||||
│ Component │ Location │ Purpose │
|
||||
├────────────────────────┼─────────────────────────────┼─────────────────────────────────────────────────────┤
|
||||
│ StatsAggregatorService │ core module │ Basic metric discovery & aggregation for dashboards │
|
||||
├────────────────────────┼─────────────────────────────┼─────────────────────────────────────────────────────┤
|
||||
│ Advanced Analytics │ analytics module (optional) │ Reports, charts, trends, exports, historical data │
|
||||
└────────────────────────┴─────────────────────────────┴─────────────────────────────────────────────────────┘
|
||||
Why this split works:
|
||||
- Dashboards always work (aggregator in core)
|
||||
- Advanced analytics features can be disabled per platform
|
||||
- No circular dependencies
|
||||
- Clear separation of concerns
|
||||
|
||||
Implementation:
|
||||
app/modules/core/services/
|
||||
├── stats_aggregator.py # Discovers & aggregates MetricsProviders (CORE)
|
||||
└── ...
|
||||
|
||||
app/modules/analytics/services/
|
||||
├── reports_service.py # Advanced reports (OPTIONAL)
|
||||
├── trends_service.py # Historical trends (OPTIONAL)
|
||||
└── export_service.py # Data exports (OPTIONAL)
|
||||
|
||||
StatsAggregatorService in core:
|
||||
# app/modules/core/services/stats_aggregator.py
|
||||
from app.modules.contracts.metrics import MetricsProviderProtocol, MetricValue
|
||||
from app.modules.registry import MODULES
|
||||
|
||||
class StatsAggregatorService:
|
||||
"""
|
||||
Core service that discovers and aggregates metrics from all modules.
|
||||
Lives in core module so dashboards always work.
|
||||
"""
|
||||
|
||||
def _get_providers(self, db, platform_id) -> list[MetricsProviderProtocol]:
|
||||
"""Get all metrics providers from enabled modules."""
|
||||
from app.modules.service import module_service
|
||||
|
||||
providers = []
|
||||
for module in MODULES.values():
|
||||
# Always include core modules, check others
|
||||
if not module.is_core:
|
||||
if not module_service.is_module_enabled(db, platform_id, module.code):
|
||||
continue
|
||||
|
||||
if hasattr(module, 'metrics_provider') and module.metrics_provider:
|
||||
provider = module.metrics_provider()
|
||||
if provider:
|
||||
providers.append(provider)
|
||||
|
||||
return providers
|
||||
|
||||
def get_vendor_dashboard_stats(self, db, vendor_id, platform_id, **kwargs):
|
||||
"""For vendor dashboard - single vendor metrics."""
|
||||
providers = self._get_providers(db, platform_id)
|
||||
return {p.metrics_category: p.get_vendor_metrics(db, vendor_id, **kwargs)
|
||||
for p in providers}
|
||||
|
||||
def get_admin_dashboard_stats(self, db, platform_id, **kwargs):
|
||||
"""For admin dashboard - platform-wide metrics."""
|
||||
providers = self._get_providers(db, platform_id)
|
||||
return {p.metrics_category: p.get_platform_metrics(db, platform_id, **kwargs)
|
||||
for p in providers}
|
||||
|
||||
stats_aggregator = StatsAggregatorService()
|
||||
|
||||
Q3: Should this be used by both admin and vendor dashboards?
|
||||
|
||||
YES. The protocol has two methods for this exact purpose:
|
||||
┌───────────────────────────────────┬──────────────────┬───────────────────────────────┐
|
||||
│ Method │ Used By │ Data Scope │
|
||||
├───────────────────────────────────┼──────────────────┼───────────────────────────────┤
|
||||
│ get_vendor_metrics(vendor_id) │ Vendor Dashboard │ Single vendor's data │
|
||||
├───────────────────────────────────┼──────────────────┼───────────────────────────────┤
|
||||
│ get_platform_metrics(platform_id) │ Admin Dashboard │ Aggregated across all vendors │
|
||||
└───────────────────────────────────┴──────────────────┴───────────────────────────────┘
|
||||
Vendor Dashboard:
|
||||
# app/modules/core/routes/api/vendor_dashboard.py
|
||||
@router.get("/stats")
|
||||
def get_vendor_stats(
|
||||
current_user: UserContext = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.modules.core.services.stats_aggregator import stats_aggregator
|
||||
|
||||
return stats_aggregator.get_vendor_dashboard_stats(
|
||||
db=db,
|
||||
vendor_id=current_user.token_vendor_id,
|
||||
platform_id=current_user.platform_id,
|
||||
)
|
||||
|
||||
Admin Dashboard:
|
||||
# app/modules/core/routes/api/admin_dashboard.py
|
||||
@router.get("/stats")
|
||||
def get_platform_stats(
|
||||
current_user: UserContext = Depends(get_current_platform_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.modules.core.services.stats_aggregator import stats_aggregator
|
||||
|
||||
return stats_aggregator.get_admin_dashboard_stats(
|
||||
db=db,
|
||||
platform_id=current_user.platform_id,
|
||||
)
|
||||
|
||||
Module Categorization Update (UPDATED 2026-02-03)
|
||||
|
||||
With this architecture, module categorization becomes:
|
||||
┌───────────┬──────────┬─────────────────┬───────────────────────────────────────┐
|
||||
│ Module │ Type │ Has Metrics? │ Metrics Examples │
|
||||
├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤
|
||||
│ core │ Core │ ✅ + Aggregator │ System stats, aggregator service │
|
||||
├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤
|
||||
│ tenancy │ Core │ ✅ │ Vendors, users, team members, domains │
|
||||
├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤
|
||||
│ customers │ Core │ ✅ │ Customer counts, new customers │
|
||||
├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤
|
||||
│ cms │ Core │ ✅ │ Pages, media items │
|
||||
├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤
|
||||
│ contracts │ Core │ ❌ │ Just protocols, no data │
|
||||
├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤
|
||||
│ billing │ Core │ ✅ │ Subscription stats, tier usage │
|
||||
├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤
|
||||
│ payments │ Core │ ✅ │ Transaction stats │
|
||||
├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤
|
||||
│ messaging │ Core │ ✅ │ Email stats, notification counts │
|
||||
├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤
|
||||
│ orders │ Optional │ ✅ │ Order counts, revenue │
|
||||
├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤
|
||||
│ inventory │ Optional │ ✅ │ Stock levels, low stock │
|
||||
├───────────┼──────────┼─────────────────┼───────────────────────────────────────┤
|
||||
│ analytics │ Optional │ ❌ │ Advanced reports (uses aggregator) │
|
||||
└───────────┴──────────┴─────────────────┴───────────────────────────────────────┘
|
||||
---
|
||||
Part 10: Implementation Steps
|
||||
|
||||
Phase 1: Create Metrics Contract (contracts module)
|
||||
|
||||
1. Create app/modules/contracts/metrics.py with:
|
||||
- MetricValue dataclass
|
||||
- MetricsProviderProtocol
|
||||
2. Add metrics_provider: Callable | None field to ModuleDefinition base class
|
||||
3. Export from contracts/__init__.py
|
||||
|
||||
Phase 2: Create Stats Aggregator (core module)
|
||||
|
||||
1. Create app/modules/core/services/stats_aggregator.py
|
||||
- StatsAggregatorService that discovers all metrics providers
|
||||
- get_vendor_dashboard_stats() method
|
||||
- get_admin_dashboard_stats() method
|
||||
2. Register in core module exports
|
||||
|
||||
Phase 3: Add Metrics Providers to Core Modules
|
||||
|
||||
1. tenancy → tenancy_metrics.py
|
||||
- Vendor count, user count, team members, domains
|
||||
2. customers → customer_metrics.py
|
||||
- Customer count, new customers, active customers
|
||||
3. cms → cms_metrics.py
|
||||
- Page count, media count
|
||||
|
||||
Phase 4: Add Metrics Providers to Optional Modules
|
||||
|
||||
Priority order:
|
||||
1. orders → order_metrics.py - order counts, revenue
|
||||
2. inventory → inventory_metrics.py - stock levels, low stock alerts
|
||||
3. billing → billing_metrics.py - subscription stats
|
||||
4. marketplace → marketplace_metrics.py - import stats
|
||||
|
||||
Phase 5: Update Dashboard Routes
|
||||
|
||||
1. Update core/routes/api/vendor_dashboard.py to use aggregator
|
||||
2. Update core/routes/api/admin_dashboard.py to use aggregator
|
||||
3. Remove direct imports from analytics module
|
||||
4. Handle graceful degradation when no metrics available
|
||||
|
||||
Phase 6: Refactor Analytics Module (Optional Features)
|
||||
|
||||
1. Keep advanced features in analytics module:
|
||||
- reports_service.py - detailed reports
|
||||
- trends_service.py - historical trends
|
||||
- export_service.py - data exports
|
||||
2. Analytics module can use stats_aggregator for base data
|
||||
3. Remove duplicated metric calculations from analytics
|
||||
|
||||
Phase 7: Update Tests
|
||||
|
||||
1. Add unit tests for StatsAggregatorService
|
||||
2. Add unit tests for each metrics provider
|
||||
3. Update dashboard API tests
|
||||
4. Test with analytics module disabled
|
||||
|
||||
---
|
||||
Verification Commands
|
||||
|
||||
# Check module discovery
|
||||
python -c "from app.modules.registry import MODULES; print(list(MODULES.keys()))"
|
||||
|
||||
# Test metrics provider discovery
|
||||
python -c "
|
||||
from app.modules.registry import MODULES
|
||||
for m in MODULES.values():
|
||||
if hasattr(m, 'metrics_provider') and m.metrics_provider:
|
||||
print(f'{m.code}: has metrics provider')
|
||||
"
|
||||
|
||||
# Test stats aggregator (after implementation)
|
||||
python -c "
|
||||
from app.modules.core.services.stats_aggregator import stats_aggregator
|
||||
from app.core.database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Test with platform_id=1
|
||||
stats = stats_aggregator.get_admin_dashboard_stats(db, platform_id=1)
|
||||
print('Categories:', list(stats.keys()))
|
||||
finally:
|
||||
db.close()
|
||||
"
|
||||
|
||||
# Run metrics provider tests
|
||||
pytest tests/unit/services/test_*_metrics.py -v
|
||||
|
||||
# Verify no core→optional imports remain
|
||||
grep -r "from app.modules.analytics" app/modules/core/ app/modules/tenancy/ || echo "Clean!"
|
||||
|
||||
---
|
||||
Summary: What Changes
|
||||
|
||||
Before (Current State)
|
||||
|
||||
core/routes/admin_dashboard.py
|
||||
└── imports stats_service from analytics module ❌
|
||||
└── analytics calculates ALL metrics internally
|
||||
|
||||
After (New Architecture)
|
||||
|
||||
core/routes/admin_dashboard.py
|
||||
└── imports stats_aggregator from core module ✅
|
||||
└── discovers MetricsProviders from ALL modules
|
||||
├── tenancy_metrics (core)
|
||||
├── customer_metrics (core)
|
||||
├── order_metrics (optional, if enabled)
|
||||
├── inventory_metrics (optional, if enabled)
|
||||
└── ...
|
||||
|
||||
analytics module (optional)
|
||||
└── Advanced features: reports, trends, exports
|
||||
└── can use stats_aggregator for base data
|
||||
|
||||
Key Benefits
|
||||
|
||||
1. Dashboards always work - aggregator is in core
|
||||
2. Each module owns its metrics - no cross-module coupling
|
||||
3. Optional modules truly optional - can be removed without breaking app
|
||||
4. Easy to add new metrics - just implement protocol in your module
|
||||
5. Both dashboards supported - vendor (per-vendor) and admin (platform-wide)
|
||||
Reference in New Issue
Block a user