refactor: complete module-driven architecture migration

This commit completes the migration to a fully module-driven architecture:

## Models Migration
- Moved all domain models from models/database/ to their respective modules:
  - tenancy: User, Admin, Vendor, Company, Platform, VendorDomain, etc.
  - cms: MediaFile, VendorTheme
  - messaging: Email, VendorEmailSettings, VendorEmailTemplate
  - core: AdminMenuConfig
- models/database/ now only contains Base and TimestampMixin (infrastructure)

## Schemas Migration
- Moved all domain schemas from models/schema/ to their respective modules:
  - tenancy: company, vendor, admin, team, vendor_domain
  - cms: media, image, vendor_theme
  - messaging: email
- models/schema/ now only contains base.py and auth.py (infrastructure)

## Routes Migration
- Moved admin routes from app/api/v1/admin/ to modules:
  - menu_config.py -> core module
  - modules.py -> tenancy module
  - module_config.py -> tenancy module
- app/api/v1/admin/ now only aggregates auto-discovered module routes

## Menu System
- Implemented module-driven menu system with MenuDiscoveryService
- Extended FrontendType enum: PLATFORM, ADMIN, VENDOR, STOREFRONT
- Added MenuItemDefinition and MenuSectionDefinition dataclasses
- Each module now defines its own menu items in definition.py
- MenuService integrates with MenuDiscoveryService for template rendering

## Documentation
- Updated docs/architecture/models-structure.md
- Updated docs/architecture/menu-management.md
- Updated architecture validation rules for new exceptions

## Architecture Validation
- Updated MOD-019 rule to allow base.py in models/schema/
- Created core module exceptions.py and schemas/ directory
- All validation errors resolved (only warnings remain)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 21:02:56 +01:00
parent 09d7d282c6
commit d7a0ff8818
307 changed files with 5536 additions and 3826 deletions

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
Add I18n.loadModule() calls to JS files that use I18n.t().
This ensures module translations are loaded before use.
"""
import re
from pathlib import Path
from collections import defaultdict
PROJECT_ROOT = Path(__file__).parent.parent
MODULES_DIR = PROJECT_ROOT / "app" / "modules"
# Pattern to find I18n.t('module.xxx') calls and extract module name
I18N_PATTERN = re.compile(r"I18n\.t\(['\"](\w+)\.")
def find_modules_used(content: str) -> set[str]:
"""Find all modules referenced in I18n.t() calls."""
return set(I18N_PATTERN.findall(content))
def add_module_loading(js_file: Path):
"""Add I18n.loadModule() calls to a JS file."""
content = js_file.read_text(encoding="utf-8")
# Find modules used in this file
modules = find_modules_used(content)
if not modules:
return False
# Check if already has module loading
if "I18n.loadModule(" in content:
return False
# Find the init() method and add loading there
# Look for common patterns:
# 1. init() { ... }
# 2. async init() { ... }
init_patterns = [
# Pattern for "init() {" or "async init() {"
(r"((?:async\s+)?init\s*\(\s*\)\s*\{)", "init"),
# Pattern for "mounted() {" (Vue style)
(r"(mounted\s*\(\s*\)\s*\{)", "mounted"),
]
for pattern, method_name in init_patterns:
match = re.search(pattern, content)
if match:
# Generate loading code
load_calls = "\n".join(f" await I18n.loadModule('{m}');" for m in sorted(modules))
# If init is not async, we need to make it async
full_match = match.group(1)
if "async" not in full_match:
# Make init async
new_init = full_match.replace(f"{method_name}()", f"async {method_name}()")
content = content.replace(full_match, new_init)
full_match = new_init
# Add loading after the opening brace
insert_code = f"\n // Load i18n translations\n{load_calls}\n"
content = content.replace(full_match, full_match + insert_code)
js_file.write_text(content, encoding="utf-8")
return True
# If no init found, add at file level (for simpler scripts)
# This handles files that don't use Alpine components
if "function " in content or "const " in content:
# Find first function definition
func_match = re.search(r"^(function\s+\w+\s*\([^)]*\)\s*\{)", content, re.MULTILINE)
if func_match:
load_calls = "\n".join(f" await I18n.loadModule('{m}');" for m in sorted(modules))
# Make function async if needed
full_match = func_match.group(1)
if "async" not in full_match:
new_func = full_match.replace("function ", "async function ")
content = content.replace(full_match, new_func)
full_match = new_func
insert_code = f"\n // Load i18n translations\n{load_calls}\n"
content = content.replace(full_match, full_match + insert_code)
js_file.write_text(content, encoding="utf-8")
return True
return False
def main():
print("=" * 70)
print("Adding I18n.loadModule() to JS files")
print("=" * 70)
updated = 0
skipped = 0
# Find all JS files with I18n.t() calls
for js_file in MODULES_DIR.rglob("*.js"):
content = js_file.read_text(encoding="utf-8")
if "I18n.t(" not in content:
continue
modules = find_modules_used(content)
if not modules:
continue
rel_path = js_file.relative_to(PROJECT_ROOT)
if add_module_loading(js_file):
print(f" Updated: {rel_path} (modules: {', '.join(sorted(modules))})")
updated += 1
else:
print(f" Skipped: {rel_path} (already has loading or no init method)")
skipped += 1
print()
print(f"Updated: {updated} files")
print(f"Skipped: {skipped} files")
print("=" * 70)
if __name__ == "__main__":
main()

View File

@@ -23,7 +23,9 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from app.core.database import SessionLocal
from app.utils.money import cents_to_euros, euros_to_cents
from models.database import Order, OrderItem, Product, Vendor
from app.modules.orders.models import Order, OrderItem
from app.modules.catalog.models import Product
from app.modules.tenancy.models import Vendor
def generate_order_number():

View File

@@ -18,7 +18,7 @@ from sqlalchemy.orm import Session
from app.core.database import SessionLocal
from app.modules.cms.models import ContentPage
from models.database.vendor import Vendor
from app.modules.tenancy.models import Vendor
def create_landing_page(

View File

@@ -1,163 +0,0 @@
#!/usr/bin/env python3
"""Debug script for historical import issues."""
import sys
sys.path.insert(0, ".")
from app.core.database import SessionLocal
from app.modules.marketplace.services.letzshop.credentials_service import LetzshopCredentialsService
from app.modules.marketplace.models import LetzshopHistoricalImportJob
def get_valid_shipment_states(vendor_id: int = 1):
"""Query the GraphQL schema to find valid ShipmentStateEnum values."""
db = SessionLocal()
try:
creds_service = LetzshopCredentialsService(db)
# Introspection query to get enum values
introspection_query = """
query {
__type(name: "ShipmentStateEnum") {
name
enumValues {
name
description
}
}
}
"""
with creds_service.create_client(vendor_id) as client:
print("Querying ShipmentStateEnum values...")
result = client._execute(introspection_query)
if result and "__type" in result:
type_info = result["__type"]
if type_info:
print(f"\nEnum: {type_info['name']}")
print("Valid values:")
for value in type_info.get("enumValues", []):
print(f" - {value['name']}: {value.get('description', 'No description')}")
else:
print("ShipmentStateEnum type not found")
else:
print(f"Unexpected result: {result}")
finally:
db.close()
def check_import_job_status():
"""Check the status of historical import jobs."""
db = SessionLocal()
try:
jobs = db.query(LetzshopHistoricalImportJob).order_by(
LetzshopHistoricalImportJob.id.desc()
).limit(5).all()
print("\n=== Recent Historical Import Jobs ===")
for job in jobs:
print(f"\nJob {job.id}:")
print(f" Status: {job.status}")
print(f" Phase: {job.current_phase}")
print(f" Page: {job.current_page}")
print(f" Shipments fetched: {job.shipments_fetched}")
print(f" Orders processed: {job.orders_processed}")
print(f" Orders imported: {job.orders_imported}")
print(f" Orders updated: {job.orders_updated}")
print(f" Orders skipped: {job.orders_skipped}")
print(f" Confirmed stats: {job.confirmed_stats}")
print(f" Declined stats: {job.declined_stats}")
print(f" Error: {job.error_message}")
print(f" Started: {job.started_at}")
print(f" Completed: {job.completed_at}")
finally:
db.close()
def test_fetch_states(vendor_id: int = 1):
"""Test fetching shipments with different states."""
db = SessionLocal()
try:
creds_service = LetzshopCredentialsService(db)
# States to test
states_to_test = ["unconfirmed", "confirmed", "declined", "shipped", "rejected"]
with creds_service.create_client(vendor_id) as client:
for state in states_to_test:
print(f"\nTesting state: {state}")
try:
# Just try to fetch first page
shipments = client.get_all_shipments_paginated(
state=state,
page_size=1,
max_pages=1,
)
print(f" ✓ Success: {len(shipments)} shipments")
except Exception as e:
print(f" ✗ Error: {e}")
finally:
db.close()
def check_declined_items(vendor_id: int = 1):
"""Check for orders with declined inventory units."""
db = SessionLocal()
try:
from models.database.letzshop import LetzshopOrder
from collections import Counter
# Get all orders with inventory_units
orders = db.query(LetzshopOrder).filter(
LetzshopOrder.vendor_id == vendor_id,
LetzshopOrder.inventory_units.isnot(None),
).all()
# Count all states
state_counts = Counter()
total_units = 0
for order in orders:
units = order.inventory_units or []
for unit in units:
state = unit.get("state", "unknown")
state_counts[state] += 1
total_units += 1
print(f"\n=== Inventory Unit States (all {len(orders)} orders) ===")
print(f" Total units: {total_units}")
print(f"\n State breakdown:")
for state, count in sorted(state_counts.items(), key=lambda x: -x[1]):
print(f" {state}: {count}")
# Show a sample order with its units
if orders:
sample = orders[0]
print(f"\nSample order #{sample.id} (shipment {sample.letzshop_shipment_id}):")
print(f" Shipment state: {sample.letzshop_state}")
print(f" Sync status: {sample.sync_status}")
if sample.inventory_units:
for i, unit in enumerate(sample.inventory_units[:5]):
print(f" Unit {i+1}: state={unit.get('state')}, id={unit.get('id')}")
finally:
db.close()
if __name__ == "__main__":
print("=== Letzshop Historical Import Debug ===\n")
print("1. Checking valid shipment states...")
get_valid_shipment_states()
print("\n\n2. Testing different state values...")
test_fetch_states()
print("\n\n3. Checking import job status...")
check_import_job_status()
print("\n\n4. Checking declined items in inventory units...")
check_declined_items()

View File

@@ -8,7 +8,7 @@ Run this script to create default logging configuration settings.
# Import all models to avoid SQLAlchemy relationship issues
import models # noqa: F401
from app.core.database import SessionLocal
from models.database.admin import AdminSetting
from app.modules.tenancy.models import AdminSetting
def init_log_settings():

View File

@@ -36,8 +36,8 @@ from app.core.database import SessionLocal
from app.core.environment import is_production
from app.core.permissions import PermissionGroups
from middleware.auth import AuthManager
from models.database.admin import AdminSetting
from models.database.user import User
from app.modules.tenancy.models import AdminSetting
from app.modules.tenancy.models import User
# =============================================================================
# HELPER FUNCTIONS

196
scripts/migrate_js_i18n.py Normal file
View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""
Migrate hardcoded toast messages in JS files to use I18n.t().
Extracts messages, creates translation keys, and updates both JS and locale files.
"""
import json
import re
from pathlib import Path
from collections import defaultdict
PROJECT_ROOT = Path(__file__).parent.parent
MODULES_DIR = PROJECT_ROOT / "app" / "modules"
# Languages to update
LANGUAGES = ["en", "fr", "de", "lb"]
# Pattern to match Utils.showToast('message', 'type') or Utils.showToast("message", "type")
TOAST_PATTERN = re.compile(r"Utils\.showToast\(['\"]([^'\"]+)['\"],\s*['\"](\w+)['\"]\)")
# Map module names to their message namespace
MODULE_MESSAGE_NS = {
"catalog": "catalog",
"orders": "orders",
"customers": "customers",
"inventory": "inventory",
"marketplace": "marketplace",
"tenancy": "tenancy",
"core": "core",
"messaging": "messaging",
"billing": "billing",
"cms": "cms",
"checkout": "checkout",
"cart": "cart",
"dev_tools": "dev_tools",
"monitoring": "monitoring",
"analytics": "analytics",
}
def message_to_key(message: str) -> str:
"""Convert a message string to a translation key."""
# Remove special characters and convert to snake_case
key = message.lower()
key = re.sub(r'[^\w\s]', '', key)
key = re.sub(r'\s+', '_', key)
# Truncate if too long
if len(key) > 40:
key = key[:40].rstrip('_')
return key
def find_js_files_with_toasts() -> dict[str, list[Path]]:
"""Find all JS files with toast messages, grouped by module."""
files_by_module = defaultdict(list)
for module_dir in sorted(MODULES_DIR.iterdir()):
if not module_dir.is_dir():
continue
module_name = module_dir.name
# Find all JS files in this module
for js_file in module_dir.rglob("*.js"):
content = js_file.read_text(encoding="utf-8")
if "Utils.showToast(" in content:
files_by_module[module_name].append(js_file)
return dict(files_by_module)
def extract_messages_from_file(js_file: Path) -> list[tuple[str, str]]:
"""Extract all toast messages from a JS file."""
content = js_file.read_text(encoding="utf-8")
return TOAST_PATTERN.findall(content)
def load_locale_file(module_path: Path, lang: str) -> dict:
"""Load a module's locale file."""
locale_file = module_path / "locales" / f"{lang}.json"
if locale_file.exists():
with open(locale_file, encoding="utf-8") as f:
return json.load(f)
return {}
def save_locale_file(module_path: Path, lang: str, data: dict):
"""Save a module's locale file."""
locale_file = module_path / "locales" / f"{lang}.json"
locale_file.parent.mkdir(parents=True, exist_ok=True)
with open(locale_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
def update_js_file(js_file: Path, module_name: str, message_keys: dict[str, str]):
"""Update a JS file to use I18n.t() for toast messages."""
content = js_file.read_text(encoding="utf-8")
original = content
for message, key in message_keys.items():
# Replace both single and double quoted versions
full_key = f"{module_name}.messages.{key}"
# Pattern to match the exact message in Utils.showToast
for quote in ["'", '"']:
old = f"Utils.showToast({quote}{message}{quote},"
new = f"Utils.showToast(I18n.t('{full_key}'),"
content = content.replace(old, new)
if content != original:
js_file.write_text(content, encoding="utf-8")
return True
return False
def process_module(module_name: str, js_files: list[Path]) -> dict[str, str]:
"""Process all JS files for a module and return message->key mapping."""
all_messages = {}
# Extract all unique messages
for js_file in js_files:
messages = extract_messages_from_file(js_file)
for message, msg_type in messages:
if message not in all_messages:
all_messages[message] = message_to_key(message)
return all_messages
def main():
print("=" * 70)
print("JS i18n Migration Script")
print("=" * 70)
# Find all JS files with toasts
files_by_module = find_js_files_with_toasts()
total_files = sum(len(files) for files in files_by_module.values())
print(f"Found {total_files} JS files with toast messages across {len(files_by_module)} modules")
print()
for module_name, js_files in sorted(files_by_module.items()):
print(f"\n{'='*70}")
print(f"Module: {module_name} ({len(js_files)} files)")
print("=" * 70)
module_path = MODULES_DIR / module_name
# Process all JS files and get message mappings
message_keys = process_module(module_name, js_files)
if not message_keys:
print(" No messages found")
continue
print(f" Found {len(message_keys)} unique messages:")
for msg, key in sorted(message_keys.items(), key=lambda x: x[1]):
print(f" {key}: {msg[:50]}{'...' if len(msg) > 50 else ''}")
# Update locale files for all languages
print(f"\n Updating locale files...")
for lang in LANGUAGES:
locale_data = load_locale_file(module_path, lang)
# Add messages section if not exists
if "messages" not in locale_data:
locale_data["messages"] = {}
# Add each message (only add if not already present)
for message, key in message_keys.items():
if key not in locale_data["messages"]:
# For English, use the original message
# For other languages, we'll use the English as placeholder
locale_data["messages"][key] = message
save_locale_file(module_path, lang, locale_data)
print(f" Updated: {lang}.json")
# Update JS files
print(f"\n Updating JS files...")
for js_file in js_files:
if update_js_file(js_file, module_name, message_keys):
rel_path = js_file.relative_to(PROJECT_ROOT)
print(f" Updated: {rel_path}")
print("\n" + "=" * 70)
print("Migration complete!")
print("\nNext steps:")
print("1. Review the generated message keys in locale files")
print("2. Translate non-English messages (currently using English as placeholder)")
print("3. Test the application to verify toast messages work")
print("=" * 70)
if __name__ == "__main__":
main()

View File

@@ -52,8 +52,8 @@ from app.core.database import SessionLocal
from app.core.environment import get_environment, is_production
from middleware.auth import AuthManager
from app.modules.cms.models import ContentPage
from models.database.admin import PlatformAlert
from models.database.company import Company
from app.modules.tenancy.models import PlatformAlert
from app.modules.tenancy.models import Company
from app.modules.customers.models.customer import Customer, CustomerAddress
from app.modules.marketplace.models import (
MarketplaceImportJob,
@@ -62,10 +62,10 @@ from app.modules.marketplace.models import (
)
from app.modules.orders.models import Order, OrderItem
from app.modules.catalog.models import Product
from models.database.user import User
from models.database.vendor import Role, Vendor, VendorUser
from models.database.vendor_domain import VendorDomain
from models.database.vendor_theme import VendorTheme
from app.modules.tenancy.models import User
from app.modules.tenancy.models import Role, Vendor, VendorUser
from app.modules.tenancy.models import VendorDomain
from app.modules.cms.models import VendorTheme
SEED_MODE = os.getenv("SEED_MODE", "normal") # normal, minimal, reset
FORCE_RESET = os.getenv("FORCE_RESET", "false").lower() in ("true", "1", "yes")

View File

@@ -13,7 +13,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.core.database import get_db
from models.database.email import EmailCategory, EmailTemplate
from app.modules.messaging.models import EmailCategory, EmailTemplate
# =============================================================================

View File

@@ -62,7 +62,7 @@ def test_logging_endpoints():
print("\n[3] Checking database logs...")
try:
from app.core.database import SessionLocal
from models.database.admin import ApplicationLog
from app.modules.tenancy.models import ApplicationLog
db = SessionLocal()
try:

View File

@@ -4505,7 +4505,10 @@ class ArchitectureValidator:
for py_file in schemas_path.glob("*.py"):
if py_file.name == "__init__.py":
continue
# Allow auth.py (core authentication schemas)
# Allow base.py (base schema classes - infrastructure)
if py_file.name == "base.py":
continue
# Allow auth.py (core authentication schemas - cross-cutting)
if py_file.name == "auth.py":
continue

View File

@@ -146,8 +146,8 @@ def verify_model_structure():
from app.modules.inventory.models import Inventory
from app.modules.marketplace.models import MarketplaceImportJob, MarketplaceProduct
from app.modules.catalog.models import Product
from models.database.user import User
from models.database.vendor import Vendor
from app.modules.tenancy.models import User
from app.modules.tenancy.models import Vendor
print("[OK] All database models imported successfully")