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:
2026-02-04 19:25:00 +01:00
parent 37942ae02b
commit 0583dd2cc4
29 changed files with 3643 additions and 650 deletions

View File

@@ -224,8 +224,8 @@ Configure Letzshop API access:
|----------|--------|-------------|
| `/admin/products` | GET | List marketplace products with filters |
| `/admin/products/stats` | GET | Get product statistics |
| `/admin/vendors/{id}/export/letzshop` | GET | Download CSV export |
| `/admin/vendors/{id}/export/letzshop` | POST | Export to pickup folder |
| `/admin/letzshop/vendors/{id}/export` | GET | Download CSV export |
| `/admin/letzshop/vendors/{id}/export` | POST | Export to pickup folder |
### Jobs

View File

@@ -0,0 +1,253 @@
# Session Note: Module Dependency Redesign
**Date:** 2026-02-03
**Status:** To be continued
**Priority:** High - Architecture blocker
---
## Summary
We discovered that while billing, payments, and messaging have been correctly reclassified as core modules, **the app would still crash if optional modules are removed** due to hard imports from core → optional modules.
The fundamental issue is **flawed architecture**: core modules should never depend on optional modules. The dependency direction is backwards.
---
## Work Completed Today
### 1. Module Reclassification
Made billing, payments, and messaging core modules:
- `app/modules/billing/definition.py` - `is_core=True`
- `app/modules/payments/definition.py` - `is_core=True`
- `app/modules/messaging/definition.py` - `is_core=True`
### 2. Letzshop Export Routes Moved (T5b)
Moved export routes from tenancy to marketplace where they belong:
- **Old:** `GET/POST /api/v1/admin/vendors/{id}/export/letzshop`
- **New:** `GET/POST /api/v1/admin/letzshop/vendors/{id}/export`
Files changed:
- `app/modules/marketplace/routes/api/admin_letzshop.py`
- `app/modules/marketplace/schemas/letzshop.py`
- `app/modules/tenancy/routes/api/admin_vendors.py`
- `app/modules/tenancy/schemas/vendor.py`
- Tests and documentation updated
### 3. Documentation Updated
- `docs/architecture/module-system.md`
- `docs/proposals/decouple-modules.md`
- `docs/testing/admin-frontend-features.md`
- `docs/guides/letzshop-admin-management.md`
---
## Critical Issue: Core → Optional Dependencies
### Current State (BROKEN)
The app crashes on startup with `ImportError` if optional modules are removed.
#### tenancy (core) → analytics (optional)
```python
# tenancy/routes/api/admin_vendors.py (lines 20, 23)
from app.modules.analytics.services.stats_service import stats_service # TOP-LEVEL
from app.modules.analytics.schemas import VendorStatsResponse # TOP-LEVEL
```
#### tenancy (core) → marketplace (optional)
```python
# tenancy/models/__init__.py (line 22)
from app.modules.marketplace.models.marketplace_import_job import MarketplaceImportJob # TOP-LEVEL
# tenancy/services/vendor_service.py (lines 19, 26)
from app.modules.marketplace.exceptions import MarketplaceProductNotFoundException
from app.modules.marketplace.models import MarketplaceProduct
```
#### tenancy (core) → catalog (optional)
```python
# tenancy/services/vendor_service.py (lines 18, 27, 30)
from app.modules.catalog.exceptions import ProductAlreadyExistsException
from app.modules.catalog.models import Product
from app.modules.catalog.schemas import ProductCreate
```
#### billing (core) → catalog (optional)
```python
# billing/services/subscription_service.py (line 48)
from app.modules.catalog.models import Product
# billing/services/admin_subscription_service.py (line 30)
from app.modules.catalog.models import Product
# billing/services/capacity_forecast_service.py (line 19)
from app.modules.catalog.models import Product
```
---
## The Design Flaw
### Current (Wrong)
```
Core Modules ──imports──> Optional Modules
CRASH if optional removed
```
### Correct Design
```
Optional Modules ──extends/provides to──> Core Modules
Graceful degradation if optional removed
```
---
## Root Cause Analysis
| Violation | Why It's Wrong | What Should Happen |
|-----------|----------------|-------------------|
| tenancy imports `stats_service` | Core shouldn't know analytics exists | Analytics registers a MetricsProvider; core discovers it |
| tenancy imports `MarketplaceImportJob` | Core shouldn't know marketplace exists | Marketplace owns its relationships entirely |
| tenancy's vendor_service creates products | Product creation is catalog's domain | Move this code to catalog module |
| billing imports `Product` to count | Billing shouldn't query catalog tables | Catalog provides count via ProductCountProvider protocol |
---
## Proposed Solution: Provider Pattern
### Architecture
```
┌─────────────────────────────────────────────────────────┐
│ CORE MODULES │
│ (Define protocols/hooks, never import from optional) │
│ │
│ contracts: Define protocols (MetricsProvider, etc.) │
│ core: Discover and aggregate providers │
│ tenancy: Vendor management (no product knowledge) │
│ billing: Tier limits (ask "count?" via protocol) │
└─────────────────────────────────────────────────────────┘
│ implements/provides
┌─────────────────────────────────────────────────────────┐
│ OPTIONAL MODULES │
│ (Extend core by implementing protocols/hooks) │
│ │
│ catalog: Implements ProductCountProvider │
│ analytics: Implements MetricsProvider │
│ marketplace: Owns import jobs, provides to tenancy │
└─────────────────────────────────────────────────────────┘
```
### Example: Billing Needs Product Count
**Current (wrong):**
```python
# billing/services/subscription_service.py
from app.modules.catalog.models import Product # CRASH if catalog removed
def get_product_count(vendor_id):
return db.query(Product).filter(Product.vendor_id == vendor_id).count()
```
**Proposed (correct):**
```python
# contracts/capacity.py
class ProductCountProvider(Protocol):
def get_product_count(self, db: Session, vendor_id: int) -> int: ...
# catalog/services/product_count_provider.py
class CatalogProductCountProvider:
def get_product_count(self, db, vendor_id):
return db.query(Product).filter(...).count()
# Register in catalog/definition.py
catalog_module = ModuleDefinition(
product_count_provider=_get_product_count_provider,
...
)
# billing/services/subscription_service.py
def get_product_count(db, vendor_id, platform_id):
provider = get_product_count_provider(db, platform_id) # Discovers from enabled modules
if provider:
return provider.get_product_count(db, vendor_id)
return 0 # Graceful fallback
```
---
## Questions to Resolve Tomorrow
1. **What belongs where?**
- Does product creation in `tenancy/vendor_service.py` belong in catalog?
- Should `MarketplaceImportJob` relationships stay on User/Vendor or move entirely to marketplace?
2. **Provider patterns needed:**
- `MetricsProvider` (already proposed) - for dashboard stats
- `ProductCountProvider` - for billing tier limits
- `ImportJobProvider` - for marketplace import jobs?
3. **Migration strategy:**
- Move code first, or create protocols first?
- How to handle the User/Vendor ↔ MarketplaceImportJob relationship?
4. **Testing:**
- How to verify the app runs with optional modules removed?
- Integration test that imports only core modules?
---
## Module Classification Reference
### Core Modules (8)
| Module | Purpose |
|--------|---------|
| contracts | Protocol definitions |
| core | Dashboard, settings |
| tenancy | Platform, company, vendor, user management |
| cms | Content pages, media, themes |
| customers | Customer database |
| billing | Subscriptions, tier limits |
| payments | Payment gateways |
| messaging | Email, notifications |
### Optional Modules (8)
| Module | Purpose |
|--------|---------|
| analytics | Reports, dashboards |
| cart | Shopping cart |
| catalog | Product catalog |
| checkout | Order placement |
| inventory | Stock management |
| loyalty | Loyalty programs |
| marketplace | Letzshop integration |
| orders | Order management |
### Internal Modules (2)
| Module | Purpose |
|--------|---------|
| dev-tools | Component library |
| monitoring | Logs, tasks |
---
## Files to Review Tomorrow
1. `app/modules/tenancy/services/vendor_service.py` - Product creation code
2. `app/modules/tenancy/models/__init__.py` - MarketplaceImportJob import
3. `app/modules/billing/services/subscription_service.py` - Product count queries
4. `app/modules/contracts/` - Existing protocols to extend
---
## Next Steps
1. Design the provider protocols needed
2. Decide what code moves where
3. Implement the provider pattern for one case (e.g., ProductCountProvider)
4. Test that core modules can load without optional modules
5. Apply pattern to remaining violations

View File

@@ -0,0 +1,552 @@
# Implementation Plan: Decouple Core Modules from Optional Modules
## Executive Summary
This plan addresses the remaining architecture violations where core modules have hard dependencies on optional modules. The goal is to ensure the app can run even if optional modules are removed.
**Current Status:**
- ✅ Dashboard statistics (core → analytics) - FIXED via MetricsProvider pattern
- ❌ Tenancy → marketplace, catalog, billing, analytics - **6 violations**
- ❌ CMS → catalog, billing, messaging - **5 violations (1 misplaced service)**
- ❌ Customers → orders - **2 violations**
---
## Part 1: Tenancy Module Violations
### Violation T1: MarketplaceImportJob in models/__init__.py
**Current Code:**
```python
# tenancy/models/__init__.py:22
from app.modules.marketplace.models.marketplace_import_job import MarketplaceImportJob # noqa: F401
```
**Purpose:** SQLAlchemy relationship resolution for `User.marketplace_import_jobs` and `Vendor.marketplace_import_jobs`
**Solution: Remove relationships from core models**
The relationships `User.marketplace_import_jobs` and `Vendor.marketplace_import_jobs` should be defined ONLY in the MarketplaceImportJob model using `backref`, not on the User/Vendor models. This is a one-way relationship that optional modules add to core models.
**Implementation:**
1. Remove the import from `tenancy/models/__init__.py`
2. Remove `marketplace_import_jobs` relationship from User model
3. Remove `marketplace_import_jobs` relationship from Vendor model
4. Ensure MarketplaceImportJob already has the relationship defined with `backref`
5. Access pattern changes: `user.marketplace_import_jobs` → query `MarketplaceImportJob.filter(user_id=user.id)`
**Impact:** Low - This is internal data access, not a public API
---
### Violation T2: MarketplaceImportJob in admin_service.py
**Current Code:**
```python
# tenancy/services/admin_service.py:36,40
from app.modules.marketplace.models import MarketplaceImportJob
from app.modules.marketplace.schemas import MarketplaceImportJobResponse
```
**Used In:**
- `get_marketplace_import_jobs()` - Admin endpoint for listing import jobs
- `get_recent_import_jobs()` - Dashboard recent imports widget
- `_convert_job_to_response()` - Response conversion
**Solution: Move functionality to marketplace module**
These methods belong in the marketplace module, not tenancy. The admin dashboard should call marketplace service methods.
**Implementation:**
1. Create `app/modules/marketplace/services/import_job_service.py` (if not exists)
2. Move `get_marketplace_import_jobs()` logic to marketplace module
3. Move `get_recent_import_jobs()` logic to marketplace module
4. Update admin_service to use lazy imports with try/except:
```python
def get_recent_import_jobs(self, db: Session, limit: int = 10) -> list:
try:
from app.modules.marketplace.services import import_job_service
return import_job_service.get_recent_jobs(db, limit)
except ImportError:
return [] # Marketplace module not installed
```
5. Update admin dashboard route to handle empty list gracefully
**Impact:** Medium - Admin UI shows "No imports" if marketplace disabled
---
### Violation T3: Catalog/Marketplace in vendor_service.py
**Current Code:**
```python
# tenancy/services/vendor_service.py:18-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
```
**Used In:**
- `add_product_to_catalog()` - Adds marketplace product to vendor catalog
**Solution: Move product management to catalog module**
Product management is catalog functionality, not tenancy functionality. The `add_product_to_catalog()` method should live in the catalog module.
**Implementation:**
1. Create `app/modules/catalog/services/product_catalog_service.py`
2. Move `add_product_to_catalog()` to catalog module
3. Move helper methods `_get_product_by_id_or_raise()` and `_product_in_catalog()`
4. vendor_service delegates to catalog service with lazy import:
```python
def add_product_to_catalog(self, db: Session, vendor_id: int, product_data: dict):
try:
from app.modules.catalog.services import product_catalog_service
return product_catalog_service.add_product(db, vendor_id, product_data)
except ImportError:
raise ModuleNotEnabledException("catalog")
```
**Impact:** Medium - Feature requires both catalog and marketplace modules
---
### Violation T4: TierLimitExceededException in vendor_team_service.py
**Current Code:**
```python
# tenancy/services/vendor_team_service.py:34
from app.modules.billing.exceptions import TierLimitExceededException
# Line 78 (lazy import inside method)
from app.modules.billing.services import subscription_service
subscription_service.check_team_limit(db, vendor.id)
```
**Used In:**
- `invite_team_member()` - Validates team size against subscription tier
**Solution: Protocol-based limit checking**
Define a `TierLimitChecker` protocol in contracts module. Billing implements it, tenancy uses it optionally.
**Implementation:**
1. Add to `app/modules/contracts/billing.py`:
```python
@runtime_checkable
class TierLimitCheckerProtocol(Protocol):
def check_team_limit(self, db: Session, vendor_id: int) -> None:
"""Raises TierLimitExceededException if limit exceeded."""
...
```
2. Add generic exception to tenancy module:
```python
# tenancy/exceptions.py
class TeamSizeLimitExceededException(WizamartException):
"""Team size limit exceeded (billing module provides specific limits)."""
```
3. Update vendor_team_service.py:
```python
def invite_team_member(self, ...):
# Check tier limits if billing module available
try:
from app.modules.billing.services import subscription_service
subscription_service.check_team_limit(db, vendor.id)
except ImportError:
pass # No billing module - no tier limits
except Exception as e:
# Convert billing exception to tenancy exception
if "limit" in str(e).lower():
raise TeamSizeLimitExceededException(str(e))
raise
```
**Impact:** Low - Without billing, team size is unlimited
---
### Violation T5: Analytics/Marketplace in admin_vendors.py
**Current Code:**
```python
# tenancy/routes/api/admin_vendors.py:20,23
from app.modules.analytics.services.stats_service import stats_service
from app.modules.analytics.schemas import VendorStatsResponse
# Lines 348, 399 (lazy imports)
from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service
```
**Used In:**
- `get_vendor_statistics()` - Returns vendor counts
- `export_vendor_products_letzshop()` - CSV export
- `export_vendor_products_letzshop_to_folder()` - Batch export
**Solution A (Analytics):** Use MetricsProvider pattern (already implemented!)
The stats endpoint should use our new `StatsAggregatorService`:
```python
@router.get("/vendors/stats")
def get_vendor_statistics(db: Session = Depends(get_db), ...):
from app.modules.core.services.stats_aggregator import stats_aggregator
metrics = stats_aggregator.get_admin_dashboard_stats(db, platform_id)
# Extract tenancy metrics
tenancy_metrics = metrics.get("tenancy", [])
return _build_vendor_stats_response(tenancy_metrics)
```
**Solution B (Marketplace Export):** Already uses lazy imports - wrap in try/except
```python
@router.get("/vendors/{vendor_id}/export/letzshop")
def export_vendor_products_letzshop(...):
try:
from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service
return letzshop_export_service.export_vendor_products(...)
except ImportError:
raise ModuleNotEnabledException("marketplace")
```
**Impact:** Low - Features gracefully degrade
---
### Violation T6: Analytics in admin_platform_users.py
**Current Code:**
```python
# tenancy/routes/api/admin_platform_users.py:18
from app.modules.analytics.services.stats_service import stats_service
```
**Used In:**
- `get_user_statistics()` - Returns user counts
**Solution:** Same as T5 - use StatsAggregator
```python
@router.get("/users/stats")
def get_user_statistics(db: Session = Depends(get_db), ...):
from app.modules.core.services.stats_aggregator import stats_aggregator
metrics = stats_aggregator.get_admin_dashboard_stats(db, platform_id)
tenancy_metrics = metrics.get("tenancy", [])
return _build_user_stats_response(tenancy_metrics)
```
**Impact:** None - Already have user metrics in tenancy_metrics
---
## Part 2: CMS Module Violations
### Violation C1: ProductMedia in media.py and media_service.py
**Current Code:**
```python
# cms/models/media.py:77-80
product_associations = relationship("ProductMedia", back_populates="media", ...)
# cms/services/media_service.py:31
from app.modules.catalog.models import ProductMedia
```
**Used In:**
- `attach_to_product()` - Creates product-media association
- `detach_from_product()` - Removes product-media association
- `get_media_usage()` - Lists where media is used
**Solution: Move product-media logic to catalog module**
The CMS media service shouldn't know about products. Product-media associations are catalog concerns.
**Implementation:**
1. Create `app/modules/catalog/services/product_media_service.py`
2. Move `attach_to_product()` and `detach_from_product()` to catalog
3. CMS media_service uses lazy delegation:
```python
def attach_to_product(self, db: Session, media_id: int, product_id: int):
try:
from app.modules.catalog.services import product_media_service
return product_media_service.attach_media(db, media_id, product_id)
except ImportError:
raise ModuleNotEnabledException("catalog")
```
4. For `get_media_usage()`, optionally include product associations:
```python
def get_media_usage(self, db: Session, media_id: int) -> dict:
usage = {"pages": [...], "products": []}
try:
from app.modules.catalog.services import product_media_service
usage["products"] = product_media_service.get_media_products(db, media_id)
except ImportError:
pass # No catalog module
return usage
```
**Impact:** Medium - Product-media features require catalog module
---
### Violation C2: TIER_LIMITS in platform.py (Homepage pricing)
**Current Code:**
```python
# cms/routes/pages/platform.py:17
from app.modules.billing.models import TIER_LIMITS, TierCode
```
**Used In:**
- `_get_tiers_data()` - Builds pricing tier display for homepage
- Called by `homepage()` and `content_page()` handlers
**Solution: Use Context Provider pattern**
Billing module should provide tier data via context provider (already supported in module architecture).
**Implementation:**
1. Add context provider to billing module definition:
```python
# billing/definition.py
def _get_platform_context(request, db, platform) -> dict:
from app.modules.billing.models import TIER_LIMITS, TierCode
tiers = [...] # Build tiers data
return {"tiers": tiers, "has_billing": True}
billing_module = ModuleDefinition(
context_providers={
FrontendType.PLATFORM: _get_platform_context,
},
)
```
2. Remove `_get_tiers_data()` from platform.py
3. Template checks `{% if tiers %}` before showing pricing section
4. Homepage gracefully shows "Contact us for pricing" if billing disabled
**Impact:** Low - Pricing section hidden if billing disabled
---
### Violation C3 & C4: MISPLACED SERVICE - vendor_email_settings_service.py
**Current Code:**
```python
# cms/services/vendor_email_settings_service.py:28-33
from app.modules.messaging.models import VendorEmailSettings, EmailProvider, PREMIUM_EMAIL_PROVIDERS
from app.modules.billing.models import VendorSubscription, TierCode
```
**Critical Finding:** This service is in the WRONG MODULE!
The `vendor_email_settings_service.py` is a **messaging** service that manages email provider configuration. It belongs in the messaging module, not CMS.
**Solution: Move service to messaging module**
**Implementation:**
1. Move `cms/services/vendor_email_settings_service.py` → `messaging/services/vendor_email_settings_service.py`
2. Update all imports that reference it (search codebase)
3. For billing tier checks, use lazy import with graceful fallback:
```python
# messaging/services/vendor_email_settings_service.py
def _check_premium_tier(self, db: Session, vendor_id: int, provider: str) -> bool:
if provider not in PREMIUM_EMAIL_PROVIDERS:
return True # Non-premium providers always allowed
try:
from app.modules.billing.services import subscription_service
tier = subscription_service.get_vendor_tier(db, vendor_id)
return tier in {TierCode.BUSINESS, TierCode.ENTERPRISE}
except ImportError:
return True # No billing module - all providers allowed
```
4. Update `cms/services/__init__.py` to remove the export
5. Add backwards-compatibility alias if needed (deprecation warning)
**Impact:** Medium - Requires import path updates
---
### Violation C5: Dead Code - Vendor import
**Current Code:**
```python
# cms/services/vendor_email_settings_service.py:27
from app.modules.tenancy.models import Vendor # UNUSED
```
**Solution:** Remove the unused import when moving the service.
---
## Part 3: Customers Module Violations
### Violation CU1 & CU2: Order imports in customer_service.py
**Current Code:**
```python
# customers/services/customer_service.py:332-350 (lazy import)
from app.modules.orders.models import Order # in get_customer_orders()
# customers/services/customer_service.py:365-396 (lazy import)
from app.modules.orders.models import Order # in get_customer_statistics()
```
**Used In:**
- `get_customer_orders()` - Lists orders for a customer
- `get_customer_statistics()` - Calculates customer LTV metrics
**Solution: Protocol-based customer order service**
Define a `CustomerOrdersProtocol` in contracts. Orders module implements it.
**Implementation:**
1. Add to `app/modules/contracts/orders.py`:
```python
@runtime_checkable
class CustomerOrdersProtocol(Protocol):
def get_customer_orders(
self, db: Session, vendor_id: int, customer_id: int, skip: int, limit: int
) -> tuple[list, int]:
...
def get_customer_statistics(
self, db: Session, vendor_id: int, customer_id: int
) -> dict:
...
```
2. Create `app/modules/orders/services/customer_orders_service.py`:
```python
class CustomerOrdersService:
def get_customer_orders(self, db, vendor_id, customer_id, skip, limit):
from app.modules.orders.models import Order
# ... existing logic
def get_customer_statistics(self, db, vendor_id, customer_id):
from app.modules.orders.models import Order
# ... existing logic
customer_orders_service = CustomerOrdersService()
```
3. Update customer_service.py to use lazy service discovery:
```python
def get_customer_orders(self, db, vendor_id, customer_id, skip=0, limit=50):
try:
from app.modules.orders.services import customer_orders_service
return customer_orders_service.get_customer_orders(db, vendor_id, customer_id, skip, limit)
except ImportError:
return [], 0 # No orders module
def get_customer_statistics(self, db, vendor_id, customer_id):
customer = self.get_customer(db, vendor_id, customer_id)
stats = {
"customer_id": customer_id,
"member_since": customer.created_at,
"is_active": customer.is_active,
"total_orders": 0,
"total_spent": 0.0,
"average_order_value": 0.0,
"last_order_date": None,
}
try:
from app.modules.orders.services import customer_orders_service
order_stats = customer_orders_service.get_customer_statistics(db, vendor_id, customer_id)
stats.update(order_stats)
except ImportError:
pass # No orders module - return base stats
return stats
```
**Impact:** Low - Customer details show "No orders" if orders disabled
---
## Implementation Phases
### Phase 1: Quick Wins (Low Risk)
1. T6: Update admin_platform_users.py to use StatsAggregator
2. T5a: Update admin_vendors.py stats endpoint to use StatsAggregator
3. C5: Remove dead Vendor import
4. T4: Add try/except for billing tier check
### Phase 2: Service Relocation (Medium Risk)
1. C3/C4: Move vendor_email_settings_service to messaging module
2. T2: Move import job methods to marketplace module
3. C1: Move product-media logic to catalog module
### Phase 3: Model Relationship Cleanup (Medium Risk)
1. T1: Remove MarketplaceImportJob relationship from User/Vendor models
2. T3: Move product catalog methods to catalog module
### Phase 4: Protocol-Based Decoupling (Low Risk)
1. CU1/CU2: Create customer orders service in orders module
2. C2: Add billing context provider for pricing tiers
### Phase 5: Testing & Verification
1. Run full test suite with all modules enabled
2. Test with each optional module disabled individually
3. Update architecture validation script
4. Update documentation
---
## Summary Table
| ID | Module | Violation | Solution | Risk | Phase |
|----|--------|-----------|----------|------|-------|
| T1 | tenancy | MarketplaceImportJob in models | Remove relationship from core | Medium | 3 |
| T2 | tenancy | ImportJob in admin_service | Move to marketplace | Medium | 2 |
| T3 | tenancy | Products in vendor_service | Move to catalog | Medium | 3 |
| T4 | tenancy | TierLimit in team_service | Try/except wrapper | Low | 1 |
| T5a | tenancy | Stats in admin_vendors | Use StatsAggregator | Low | 1 |
| T5b | tenancy | Export in admin_vendors | Already lazy - add try/except | Low | 1 |
| T6 | tenancy | Stats in admin_users | Use StatsAggregator | Low | 1 |
| C1 | cms | ProductMedia in media | Move to catalog | Medium | 2 |
| C2 | cms | TIER_LIMITS in platform | Context provider | Low | 4 |
| C3/C4 | cms | MISPLACED email service | Move to messaging | Medium | 2 |
| C5 | cms | Dead Vendor import | Remove | None | 1 |
| CU1/CU2 | customers | Order in customer_service | Protocol + lazy | Low | 4 |
---
## Verification Commands
After implementation, run these to verify:
```bash
# Check no core→optional imports remain
grep -r "from app.modules.analytics" app/modules/core/ app/modules/tenancy/ app/modules/cms/ app/modules/customers/ 2>/dev/null | grep -v "# optional" || echo "Clean!"
grep -r "from app.modules.marketplace" app/modules/core/ app/modules/tenancy/ app/modules/cms/ app/modules/customers/ 2>/dev/null | grep -v "# optional" || echo "Clean!"
grep -r "from app.modules.billing" app/modules/core/ app/modules/tenancy/ app/modules/cms/ app/modules/customers/ 2>/dev/null | grep -v "# optional" || echo "Clean!"
grep -r "from app.modules.catalog" app/modules/core/ app/modules/tenancy/ app/modules/cms/ app/modules/customers/ 2>/dev/null | grep -v "# optional" || echo "Clean!"
grep -r "from app.modules.orders" app/modules/core/ app/modules/tenancy/ app/modules/cms/ app/modules/customers/ 2>/dev/null | grep -v "# optional" || echo "Clean!"
# Run tests with each optional module "disabled" (move temporarily)
# This would be a more advanced test
# Run architecture validator
python scripts/validate_architecture.py
```
---
## Expected Outcome
After all phases complete:
- ✅ App starts successfully even if optional modules are removed
- ✅ Core modules have NO hard imports from optional modules
- ✅ Features gracefully degrade when optional modules disabled
- ✅ All cross-module communication via protocols or lazy imports with try/except

View 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)

View File

@@ -112,8 +112,8 @@
| Verify vendor | `/api/v1/admin/vendors/{vendor_identifier}/verification` | PUT | Working |
| Toggle vendor status | `/api/v1/admin/vendors/{vendor_identifier}/status` | PUT | Working |
| Delete vendor (confirm) | `/api/v1/admin/vendors/{vendor_identifier}?confirm=true` | DELETE | Working |
| Export Letzshop CSV (download) | `/api/v1/admin/vendors/{vendor_identifier}/export/letzshop` | GET | Working |
| Export Letzshop CSV (to folder) | `/api/v1/admin/vendors/{vendor_identifier}/export/letzshop` | POST | Working |
| Export Letzshop CSV (download) | `/api/v1/admin/letzshop/vendors/{vendor_id}/export` | GET | Working |
| Export Letzshop CSV (to folder) | `/api/v1/admin/letzshop/vendors/{vendor_id}/export` | POST | Working |
**Create Vendor Form Fields:**
- [ ] name (required)