diff --git a/.architecture-rules/module.yaml b/.architecture-rules/module.yaml index f3f61261..266efcf2 100644 --- a/.architecture-rules/module.yaml +++ b/.architecture-rules/module.yaml @@ -419,3 +419,133 @@ module_rules: required_files: - "__init__.py" - "versions/__init__.py" + + # ========================================================================= + # Legacy Location Rules (Auto-Discovery Enforcement) + # ========================================================================= + + - id: "MOD-016" + name: "Routes must be in modules, not app/api/v1/" + severity: "error" + description: | + All API routes must be defined in module directories, not in legacy + app/api/v1/vendor/ or app/api/v1/admin/ locations. + + WRONG (legacy location): + app/api/v1/vendor/orders.py + app/api/v1/admin/orders.py + + RIGHT (module location): + app/modules/orders/routes/api/vendor.py + app/modules/orders/routes/api/admin.py + + Routes in modules are auto-discovered and registered. Legacy routes + require manual registration and don't follow module patterns. + + EXCEPTIONS (allowed in legacy): + - __init__.py (router aggregation) + - auth.py (core authentication - will move to tenancy) + - Files with # noqa: mod-016 comment + + WHY THIS MATTERS: + - Auto-discovery: Module routes are automatically registered + - Encapsulation: Routes belong with their domain logic + - Consistency: All modules follow the same pattern + - Maintainability: Easier to understand module boundaries + pattern: + prohibited_locations: + - "app/api/v1/vendor/*.py" + - "app/api/v1/admin/*.py" + exceptions: + - "__init__.py" + - "auth.py" + + - id: "MOD-017" + name: "Services must be in modules, not app/services/" + severity: "error" + description: | + All business logic services must be defined in module directories, + not in the legacy app/services/ location. + + WRONG (legacy location): + app/services/order_service.py + + RIGHT (module location): + app/modules/orders/services/order_service.py + + EXCEPTIONS (allowed in legacy): + - __init__.py (re-exports for backwards compatibility) + - Files that are pure re-exports from modules + - Files with # noqa: mod-017 comment + + WHY THIS MATTERS: + - Encapsulation: Services belong with their domain + - Clear boundaries: Know which module owns which service + - Testability: Can test modules in isolation + - Refactoring: Easier to move/rename modules + pattern: + prohibited_locations: + - "app/services/*.py" + exceptions: + - "__init__.py" + + - id: "MOD-018" + name: "Tasks must be in modules, not app/tasks/" + severity: "error" + description: | + All Celery background tasks must be defined in module directories, + not in the legacy app/tasks/ location. + + WRONG (legacy location): + app/tasks/subscription_tasks.py + + RIGHT (module location): + app/modules/billing/tasks/subscription.py + + The module tasks/ directory must have __init__.py for Celery + autodiscovery to work. + + EXCEPTIONS (allowed in legacy): + - __init__.py (Celery app configuration) + - dispatcher.py (task routing infrastructure) + - Files with # noqa: mod-018 comment + + WHY THIS MATTERS: + - Auto-discovery: Celery finds tasks from module directories + - Encapsulation: Tasks belong with their domain logic + - Consistency: All async operations in one place per module + pattern: + prohibited_locations: + - "app/tasks/*.py" + exceptions: + - "__init__.py" + - "dispatcher.py" + + - id: "MOD-019" + name: "Schemas must be in modules, not models/schema/" + severity: "error" + description: | + All Pydantic schemas must be defined in module directories, + not in the legacy models/schema/ location. + + WRONG (legacy location): + models/schema/order.py + + RIGHT (module location): + app/modules/orders/schemas/order.py + + EXCEPTIONS (allowed in legacy): + - __init__.py (re-exports for backwards compatibility) + - auth.py (core authentication schemas) + - Files with # noqa: mod-019 comment + + WHY THIS MATTERS: + - Encapsulation: Schemas belong with their domain + - Co-location: Request/response schemas near route handlers + - Clear ownership: Know which module owns which schema + pattern: + prohibited_locations: + - "models/schema/*.py" + exceptions: + - "__init__.py" + - "auth.py" diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py index e2c9426a..6d9c381a 100644 --- a/app/api/v1/admin/__init__.py +++ b/app/api/v1/admin/__init__.py @@ -47,7 +47,6 @@ from . import ( companies, dashboard, email_templates, - features, images, logs, media, @@ -60,7 +59,6 @@ from . import ( platform_health, platforms, settings, - subscriptions, # Legacy - will be replaced by billing module router tests, users, vendor_domains, @@ -179,9 +177,6 @@ router.include_router( # Include test runner endpoints router.include_router(tests.router, prefix="/tests", tags=["admin-tests"]) -# Include feature management endpoints -router.include_router(features.router, tags=["admin-features"]) - # ============================================================================ # Auto-discovered Module Routes diff --git a/app/api/v1/admin/order_item_exceptions.py b/app/api/v1/admin/order_item_exceptions.py deleted file mode 100644 index 28ab643d..00000000 --- a/app/api/v1/admin/order_item_exceptions.py +++ /dev/null @@ -1,252 +0,0 @@ -# app/api/v1/admin/order_item_exceptions.py -""" -Admin API endpoints for order item exception management. - -Provides admin-level management of: -- Listing exceptions across all vendors -- Resolving exceptions by assigning products -- Bulk resolution by GTIN -- Exception statistics -""" - -import logging - -from fastapi import APIRouter, Depends, Path, Query -from sqlalchemy.orm import Session - -from app.api.deps import get_current_admin_api -from app.core.database import get_db -from app.services.order_item_exception_service import order_item_exception_service -from models.schema.auth import UserContext -from app.modules.orders.schemas import ( - BulkResolveRequest, - BulkResolveResponse, - IgnoreExceptionRequest, - OrderItemExceptionListResponse, - OrderItemExceptionResponse, - OrderItemExceptionStats, - ResolveExceptionRequest, -) - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/order-exceptions", tags=["Order Item Exceptions"]) - - -# ============================================================================ -# Exception Listing and Stats -# ============================================================================ - - -@router.get("", response_model=OrderItemExceptionListResponse) -def list_exceptions( - vendor_id: int | None = Query(None, description="Filter by vendor"), - status: str | None = Query( - None, - pattern="^(pending|resolved|ignored)$", - description="Filter by status" - ), - search: str | None = Query( - None, - description="Search in GTIN, product name, SKU, or order number" - ), - skip: int = Query(0, ge=0), - limit: int = Query(50, ge=1, le=200), - db: Session = Depends(get_db), - current_admin: UserContext = Depends(get_current_admin_api), -): - """ - List order item exceptions with filtering and pagination. - - Returns exceptions for unmatched products during marketplace order imports. - """ - exceptions, total = order_item_exception_service.get_pending_exceptions( - db=db, - vendor_id=vendor_id, - status=status, - search=search, - skip=skip, - limit=limit, - ) - - # Enrich with order and vendor info - response_items = [] - for exc in exceptions: - item = OrderItemExceptionResponse.model_validate(exc) - if exc.order_item and exc.order_item.order: - order = exc.order_item.order - item.order_number = order.order_number - item.order_id = order.id - item.order_date = order.order_date - item.order_status = order.status - # Add vendor name for cross-vendor view - if order.vendor: - item.vendor_name = order.vendor.name - response_items.append(item) - - return OrderItemExceptionListResponse( - exceptions=response_items, - total=total, - skip=skip, - limit=limit, - ) - - -@router.get("/stats", response_model=OrderItemExceptionStats) -def get_exception_stats( - vendor_id: int | None = Query(None, description="Filter by vendor"), - db: Session = Depends(get_db), - current_admin: UserContext = Depends(get_current_admin_api), -): - """ - Get exception statistics. - - Returns counts of pending, resolved, and ignored exceptions. - """ - stats = order_item_exception_service.get_exception_stats(db, vendor_id) - return OrderItemExceptionStats(**stats) - - -# ============================================================================ -# Exception Details -# ============================================================================ - - -@router.get("/{exception_id}", response_model=OrderItemExceptionResponse) -def get_exception( - exception_id: int = Path(..., description="Exception ID"), - db: Session = Depends(get_db), - current_admin: UserContext = Depends(get_current_admin_api), -): - """ - Get details of a single exception. - """ - exception = order_item_exception_service.get_exception_by_id(db, exception_id) - - response = OrderItemExceptionResponse.model_validate(exception) - if exception.order_item and exception.order_item.order: - order = exception.order_item.order - response.order_number = order.order_number - response.order_id = order.id - response.order_date = order.order_date - response.order_status = order.status - - return response - - -# ============================================================================ -# Exception Resolution -# ============================================================================ - - -@router.post("/{exception_id}/resolve", response_model=OrderItemExceptionResponse) -def resolve_exception( - exception_id: int = Path(..., description="Exception ID"), - request: ResolveExceptionRequest = ..., - db: Session = Depends(get_db), - current_admin: UserContext = Depends(get_current_admin_api), -): - """ - Resolve an exception by assigning a product. - - This updates the order item's product_id and marks the exception as resolved. - """ - exception = order_item_exception_service.resolve_exception( - db=db, - exception_id=exception_id, - product_id=request.product_id, - resolved_by=current_admin.id, - notes=request.notes, - ) - db.commit() - - response = OrderItemExceptionResponse.model_validate(exception) - if exception.order_item and exception.order_item.order: - order = exception.order_item.order - response.order_number = order.order_number - response.order_id = order.id - response.order_date = order.order_date - response.order_status = order.status - - logger.info( - f"Admin {current_admin.id} resolved exception {exception_id} " - f"with product {request.product_id}" - ) - - return response - - -@router.post("/{exception_id}/ignore", response_model=OrderItemExceptionResponse) -def ignore_exception( - exception_id: int = Path(..., description="Exception ID"), - request: IgnoreExceptionRequest = ..., - db: Session = Depends(get_db), - current_admin: UserContext = Depends(get_current_admin_api), -): - """ - Mark an exception as ignored. - - Note: Ignored exceptions still block order confirmation. - Use this when a product will never be matched (e.g., discontinued). - """ - exception = order_item_exception_service.ignore_exception( - db=db, - exception_id=exception_id, - resolved_by=current_admin.id, - notes=request.notes, - ) - db.commit() - - response = OrderItemExceptionResponse.model_validate(exception) - if exception.order_item and exception.order_item.order: - order = exception.order_item.order - response.order_number = order.order_number - response.order_id = order.id - response.order_date = order.order_date - response.order_status = order.status - - logger.info( - f"Admin {current_admin.id} ignored exception {exception_id}: {request.notes}" - ) - - return response - - -# ============================================================================ -# Bulk Operations -# ============================================================================ - - -@router.post("/bulk-resolve", response_model=BulkResolveResponse) -def bulk_resolve_by_gtin( - request: BulkResolveRequest, - vendor_id: int = Query(..., description="Vendor ID"), - db: Session = Depends(get_db), - current_admin: UserContext = Depends(get_current_admin_api), -): - """ - Bulk resolve all pending exceptions for a GTIN. - - Useful when a new product is imported and multiple orders have - items with the same unmatched GTIN. - """ - resolved_count = order_item_exception_service.bulk_resolve_by_gtin( - db=db, - vendor_id=vendor_id, - gtin=request.gtin, - product_id=request.product_id, - resolved_by=current_admin.id, - notes=request.notes, - ) - db.commit() - - logger.info( - f"Admin {current_admin.id} bulk resolved {resolved_count} exceptions " - f"for GTIN {request.gtin} with product {request.product_id}" - ) - - return BulkResolveResponse( - resolved_count=resolved_count, - gtin=request.gtin, - product_id=request.product_id, - ) diff --git a/app/api/v1/admin/orders.py b/app/api/v1/admin/orders.py deleted file mode 100644 index 63ecc930..00000000 --- a/app/api/v1/admin/orders.py +++ /dev/null @@ -1,193 +0,0 @@ -# app/api/v1/admin/orders.py -""" -Admin order management endpoints. - -Provides order management capabilities for administrators: -- View orders across all vendors -- View vendor-specific orders -- Update order status on behalf of vendors -- Order statistics and reporting - -Admin Context: Uses admin JWT authentication. -Vendor selection is passed as a request parameter. -""" - -import logging - -from fastapi import APIRouter, Depends, Query -from sqlalchemy.orm import Session - -from app.api.deps import get_current_admin_api -from app.core.database import get_db -from app.services.order_service import order_service -from models.schema.auth import UserContext -from app.modules.orders.schemas import ( - AdminOrderItem, - AdminOrderListResponse, - AdminOrderStats, - AdminOrderStatusUpdate, - AdminVendorsWithOrdersResponse, - MarkAsShippedRequest, - OrderDetailResponse, - ShippingLabelInfo, -) - -router = APIRouter(prefix="/orders") -logger = logging.getLogger(__name__) - - -# ============================================================================ -# List & Statistics Endpoints -# ============================================================================ - - -@router.get("", response_model=AdminOrderListResponse) -def get_all_orders( - skip: int = Query(0, ge=0), - limit: int = Query(50, ge=1, le=500), - vendor_id: int | None = Query(None, description="Filter by vendor"), - status: str | None = Query(None, description="Filter by status"), - channel: str | None = Query(None, description="Filter by channel"), - search: str | None = Query(None, description="Search by order number or customer"), - db: Session = Depends(get_db), - current_admin: UserContext = Depends(get_current_admin_api), -): - """ - Get orders across all vendors with filtering. - - Allows admins to view and filter orders across the platform. - """ - orders, total = order_service.get_all_orders_admin( - db=db, - skip=skip, - limit=limit, - vendor_id=vendor_id, - status=status, - channel=channel, - search=search, - ) - - return AdminOrderListResponse( - orders=[AdminOrderItem(**order) for order in orders], - total=total, - skip=skip, - limit=limit, - ) - - -@router.get("/stats", response_model=AdminOrderStats) -def get_order_stats( - db: Session = Depends(get_db), - current_admin: UserContext = Depends(get_current_admin_api), -): - """Get platform-wide order statistics.""" - return order_service.get_order_stats_admin(db) - - -@router.get("/vendors", response_model=AdminVendorsWithOrdersResponse) -def get_vendors_with_orders( - db: Session = Depends(get_db), - current_admin: UserContext = Depends(get_current_admin_api), -): - """Get list of vendors that have orders.""" - vendors = order_service.get_vendors_with_orders_admin(db) - return AdminVendorsWithOrdersResponse(vendors=vendors) - - -# ============================================================================ -# Order Detail & Update Endpoints -# ============================================================================ - - -@router.get("/{order_id}", response_model=OrderDetailResponse) -def get_order_detail( - order_id: int, - db: Session = Depends(get_db), - current_admin: UserContext = Depends(get_current_admin_api), -): - """Get order details including items and addresses.""" - order = order_service.get_order_by_id_admin(db, order_id) - - # Enrich with vendor info - response = OrderDetailResponse.model_validate(order) - if order.vendor: - response.vendor_name = order.vendor.name - response.vendor_code = order.vendor.vendor_code - - return response - - -@router.patch("/{order_id}/status", response_model=OrderDetailResponse) -def update_order_status( - order_id: int, - status_update: AdminOrderStatusUpdate, - db: Session = Depends(get_db), - current_admin: UserContext = Depends(get_current_admin_api), -): - """ - Update order status. - - Admin can update status and add tracking number. - Status changes are logged with optional reason. - """ - order = order_service.update_order_status_admin( - db=db, - order_id=order_id, - status=status_update.status, - tracking_number=status_update.tracking_number, - reason=status_update.reason, - ) - - logger.info( - f"Admin {current_admin.email} updated order {order.order_number} " - f"status to {status_update.status}" - ) - - db.commit() - return order - - -@router.post("/{order_id}/ship", response_model=OrderDetailResponse) -def mark_order_as_shipped( - order_id: int, - ship_request: MarkAsShippedRequest, - db: Session = Depends(get_db), - current_admin: UserContext = Depends(get_current_admin_api), -): - """ - Mark an order as shipped with optional tracking information. - - This endpoint: - - Sets order status to 'shipped' - - Sets shipped_at timestamp - - Optionally stores tracking number, URL, and carrier - """ - order = order_service.mark_as_shipped_admin( - db=db, - order_id=order_id, - tracking_number=ship_request.tracking_number, - tracking_url=ship_request.tracking_url, - shipping_carrier=ship_request.shipping_carrier, - ) - - logger.info( - f"Admin {current_admin.email} marked order {order.order_number} as shipped" - ) - - db.commit() - return order - - -@router.get("/{order_id}/shipping-label", response_model=ShippingLabelInfo) -def get_shipping_label_info( - order_id: int, - db: Session = Depends(get_db), - current_admin: UserContext = Depends(get_current_admin_api), -): - """ - Get shipping label information for an order. - - Returns the shipment number, carrier, and generated label URL - based on carrier settings. - """ - return order_service.get_shipping_label_info_admin(db, order_id) diff --git a/app/api/v1/admin/subscriptions.py b/app/api/v1/admin/subscriptions.py deleted file mode 100644 index f4743b90..00000000 --- a/app/api/v1/admin/subscriptions.py +++ /dev/null @@ -1,331 +0,0 @@ -# app/api/v1/admin/subscriptions.py -""" -Admin Subscription Management API. - -Provides endpoints for platform administrators to manage: -- Subscription tiers (CRUD) -- Vendor subscriptions (view, update, override limits) -- Billing history across all vendors -- Subscription analytics -""" - -import logging - -from fastapi import APIRouter, Depends, Path, Query -from sqlalchemy.orm import Session - -from app.api.deps import get_current_admin_api -from app.core.database import get_db -from app.services.admin_subscription_service import admin_subscription_service -from app.services.subscription_service import subscription_service -from models.schema.auth import UserContext -from app.modules.billing.schemas import ( - BillingHistoryListResponse, - BillingHistoryWithVendor, - SubscriptionStatsResponse, - SubscriptionTierCreate, - SubscriptionTierListResponse, - SubscriptionTierResponse, - SubscriptionTierUpdate, - VendorSubscriptionCreate, - VendorSubscriptionListResponse, - VendorSubscriptionResponse, - VendorSubscriptionUpdate, - VendorSubscriptionWithVendor, -) - -router = APIRouter(prefix="/subscriptions") -logger = logging.getLogger(__name__) - - -# ============================================================================ -# Subscription Tier Endpoints -# ============================================================================ - - -@router.get("/tiers", response_model=SubscriptionTierListResponse) -def list_subscription_tiers( - include_inactive: bool = Query(False, description="Include inactive tiers"), - current_user: UserContext = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """ - List all subscription tiers. - - Returns all tiers with their limits, features, and Stripe configuration. - """ - tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive) - - return SubscriptionTierListResponse( - tiers=[SubscriptionTierResponse.model_validate(t) for t in tiers], - total=len(tiers), - ) - - -@router.get("/tiers/{tier_code}", response_model=SubscriptionTierResponse) -def get_subscription_tier( - tier_code: str = Path(..., description="Tier code"), - current_user: UserContext = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """Get a specific subscription tier by code.""" - tier = admin_subscription_service.get_tier_by_code(db, tier_code) - return SubscriptionTierResponse.model_validate(tier) - - -@router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201) -def create_subscription_tier( - tier_data: SubscriptionTierCreate, - current_user: UserContext = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """Create a new subscription tier.""" - tier = admin_subscription_service.create_tier(db, tier_data.model_dump()) - db.commit() - db.refresh(tier) - return SubscriptionTierResponse.model_validate(tier) - - -@router.patch("/tiers/{tier_code}", response_model=SubscriptionTierResponse) -def update_subscription_tier( - tier_data: SubscriptionTierUpdate, - tier_code: str = Path(..., description="Tier code"), - current_user: UserContext = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """Update a subscription tier.""" - update_data = tier_data.model_dump(exclude_unset=True) - tier = admin_subscription_service.update_tier(db, tier_code, update_data) - db.commit() - db.refresh(tier) - return SubscriptionTierResponse.model_validate(tier) - - -@router.delete("/tiers/{tier_code}", status_code=204) -def delete_subscription_tier( - tier_code: str = Path(..., description="Tier code"), - current_user: UserContext = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """ - Soft-delete a subscription tier. - - Sets is_active=False rather than deleting to preserve history. - """ - admin_subscription_service.deactivate_tier(db, tier_code) - db.commit() - - -# ============================================================================ -# Vendor Subscription Endpoints -# ============================================================================ - - -@router.get("", response_model=VendorSubscriptionListResponse) -def list_vendor_subscriptions( - page: int = Query(1, ge=1), - per_page: int = Query(20, ge=1, le=100), - status: str | None = Query(None, description="Filter by status"), - tier: str | None = Query(None, description="Filter by tier"), - search: str | None = Query(None, description="Search vendor name"), - current_user: UserContext = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """ - List all vendor subscriptions with filtering. - - Includes vendor information for each subscription. - """ - data = admin_subscription_service.list_subscriptions( - db, page=page, per_page=per_page, status=status, tier=tier, search=search - ) - - subscriptions = [] - for sub, vendor in data["results"]: - sub_dict = { - **VendorSubscriptionResponse.model_validate(sub).model_dump(), - "vendor_name": vendor.name, - "vendor_code": vendor.subdomain, - } - subscriptions.append(VendorSubscriptionWithVendor(**sub_dict)) - - return VendorSubscriptionListResponse( - subscriptions=subscriptions, - total=data["total"], - page=data["page"], - per_page=data["per_page"], - pages=data["pages"], - ) - - -# ============================================================================ -# Statistics Endpoints -# ============================================================================ - - -@router.get("/stats", response_model=SubscriptionStatsResponse) -def get_subscription_stats( - current_user: UserContext = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """Get subscription statistics for admin dashboard.""" - stats = admin_subscription_service.get_stats(db) - return SubscriptionStatsResponse(**stats) - - -# ============================================================================ -# Billing History Endpoints -# ============================================================================ - - -@router.get("/billing/history", response_model=BillingHistoryListResponse) -def list_billing_history( - page: int = Query(1, ge=1), - per_page: int = Query(20, ge=1, le=100), - vendor_id: int | None = Query(None, description="Filter by vendor"), - status: str | None = Query(None, description="Filter by status"), - current_user: UserContext = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """List billing history (invoices) across all vendors.""" - data = admin_subscription_service.list_billing_history( - db, page=page, per_page=per_page, vendor_id=vendor_id, status=status - ) - - invoices = [] - for invoice, vendor in data["results"]: - invoice_dict = { - "id": invoice.id, - "vendor_id": invoice.vendor_id, - "stripe_invoice_id": invoice.stripe_invoice_id, - "invoice_number": invoice.invoice_number, - "invoice_date": invoice.invoice_date, - "due_date": invoice.due_date, - "subtotal_cents": invoice.subtotal_cents, - "tax_cents": invoice.tax_cents, - "total_cents": invoice.total_cents, - "amount_paid_cents": invoice.amount_paid_cents, - "currency": invoice.currency, - "status": invoice.status, - "invoice_pdf_url": invoice.invoice_pdf_url, - "hosted_invoice_url": invoice.hosted_invoice_url, - "description": invoice.description, - "created_at": invoice.created_at, - "vendor_name": vendor.name, - "vendor_code": vendor.subdomain, - } - invoices.append(BillingHistoryWithVendor(**invoice_dict)) - - return BillingHistoryListResponse( - invoices=invoices, - total=data["total"], - page=data["page"], - per_page=data["per_page"], - pages=data["pages"], - ) - - -# ============================================================================ -# Vendor Subscription Detail Endpoints -# ============================================================================ - - -@router.post("/{vendor_id}", response_model=VendorSubscriptionWithVendor, status_code=201) -def create_vendor_subscription( - create_data: VendorSubscriptionCreate, - vendor_id: int = Path(..., description="Vendor ID"), - current_user: UserContext = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """ - Create a subscription for a vendor. - - Creates a new subscription with the specified tier and status. - Defaults to Essential tier with trial status. - """ - # Verify vendor exists - vendor = admin_subscription_service.get_vendor(db, vendor_id) - - # Create subscription using the subscription service - sub = subscription_service.get_or_create_subscription( - db, - vendor_id=vendor_id, - tier=create_data.tier, - trial_days=create_data.trial_days, - ) - - # Update status if not trial - if create_data.status != "trial": - sub.status = create_data.status - - sub.is_annual = create_data.is_annual - - db.commit() - db.refresh(sub) - - # Get usage counts - usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id) - - logger.info(f"Admin created subscription for vendor {vendor_id}: tier={create_data.tier}") - - return VendorSubscriptionWithVendor( - **VendorSubscriptionResponse.model_validate(sub).model_dump(), - vendor_name=vendor.name, - vendor_code=vendor.subdomain, - products_count=usage["products_count"], - team_count=usage["team_count"], - ) - - -@router.get("/{vendor_id}", response_model=VendorSubscriptionWithVendor) -def get_vendor_subscription( - vendor_id: int = Path(..., description="Vendor ID"), - current_user: UserContext = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """Get subscription details for a specific vendor.""" - sub, vendor = admin_subscription_service.get_subscription(db, vendor_id) - - # Get usage counts - usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id) - - return VendorSubscriptionWithVendor( - **VendorSubscriptionResponse.model_validate(sub).model_dump(), - vendor_name=vendor.name, - vendor_code=vendor.subdomain, - products_count=usage["products_count"], - team_count=usage["team_count"], - ) - - -@router.patch("/{vendor_id}", response_model=VendorSubscriptionWithVendor) -def update_vendor_subscription( - update_data: VendorSubscriptionUpdate, - vendor_id: int = Path(..., description="Vendor ID"), - current_user: UserContext = Depends(get_current_admin_api), - db: Session = Depends(get_db), -): - """ - Update a vendor's subscription. - - Allows admins to: - - Change tier - - Update status - - Set custom limit overrides - - Extend trial period - """ - data = update_data.model_dump(exclude_unset=True) - sub, vendor = admin_subscription_service.update_subscription(db, vendor_id, data) - db.commit() - db.refresh(sub) - - # Get usage counts - usage = admin_subscription_service.get_vendor_usage_counts(db, vendor_id) - - return VendorSubscriptionWithVendor( - **VendorSubscriptionResponse.model_validate(sub).model_dump(), - vendor_name=vendor.name, - vendor_code=vendor.subdomain, - products_count=usage["products_count"], - team_count=usage["team_count"], - ) diff --git a/app/api/v1/admin/vendors.py b/app/api/v1/admin/vendors.py index 3ae3ec26..2d229977 100644 --- a/app/api/v1/admin/vendors.py +++ b/app/api/v1/admin/vendors.py @@ -345,7 +345,7 @@ def export_vendor_products_letzshop( """ from fastapi.responses import Response - from app.services.letzshop_export_service import letzshop_export_service + from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier) @@ -396,7 +396,7 @@ def export_vendor_products_letzshop_to_folder( from pathlib import Path as FilePath from app.core.config import settings - from app.services.letzshop_export_service import letzshop_export_service + from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier) include_inactive = request.include_inactive if request else False diff --git a/app/api/v1/platform/letzshop_vendors.py b/app/api/v1/platform/letzshop_vendors.py index 721b0c22..4f2252b0 100644 --- a/app/api/v1/platform/letzshop_vendors.py +++ b/app/api/v1/platform/letzshop_vendors.py @@ -19,7 +19,7 @@ from pydantic import BaseModel from sqlalchemy.orm import Session from app.core.database import get_db -from app.services.letzshop.vendor_sync_service import LetzshopVendorSyncService +from app.modules.marketplace.services.letzshop import LetzshopVendorSyncService from app.services.platform_signup_service import platform_signup_service from app.modules.marketplace.models import LetzshopVendorCache diff --git a/app/api/v1/vendor/__init__.py b/app/api/v1/vendor/__init__.py index a3063f8a..a6435ec7 100644 --- a/app/api/v1/vendor/__init__.py +++ b/app/api/v1/vendor/__init__.py @@ -15,10 +15,11 @@ For multi-tenant apps, module enablement is checked at request time based on platform context (not at route registration time). Self-contained modules (auto-discovered from app/modules/{module}/routes/api/vendor.py): -- billing: Subscription tiers, vendor billing, checkout, add-ons, features +- analytics: Vendor analytics and reporting +- billing: Subscription tiers, vendor billing, checkout, add-ons, features, usage - inventory: Stock management, inventory tracking - orders: Order management, fulfillment, exceptions, invoices -- marketplace: Letzshop integration, product sync +- marketplace: Letzshop integration, product sync, onboarding - catalog: Vendor product catalog management - cms: Content pages management - customers: Customer management @@ -29,7 +30,6 @@ from fastapi import APIRouter # Import all sub-routers (legacy routes that haven't been migrated to modules) from . import ( - analytics, auth, dashboard, email_settings, @@ -38,11 +38,9 @@ from . import ( media, messages, notifications, - onboarding, profile, settings, team, - usage, ) # Create vendor router @@ -66,7 +64,6 @@ router.include_router(profile.router, tags=["vendor-profile"]) router.include_router(settings.router, tags=["vendor-settings"]) router.include_router(email_templates.router, tags=["vendor-email-templates"]) router.include_router(email_settings.router, tags=["vendor-email-settings"]) -router.include_router(onboarding.router, tags=["vendor-onboarding"]) # Business operations (with prefixes: /team/*) router.include_router(team.router, tags=["vendor-team"]) @@ -75,8 +72,6 @@ router.include_router(team.router, tags=["vendor-team"]) router.include_router(media.router, tags=["vendor-media"]) router.include_router(notifications.router, tags=["vendor-notifications"]) router.include_router(messages.router, tags=["vendor-messages"]) -router.include_router(analytics.router, tags=["vendor-analytics"]) -router.include_router(usage.router, tags=["vendor-usage"]) # ============================================================================ diff --git a/app/api/v1/vendor/analytics.py b/app/api/v1/vendor/analytics.py deleted file mode 100644 index 659e19e9..00000000 --- a/app/api/v1/vendor/analytics.py +++ /dev/null @@ -1,55 +0,0 @@ -# app/api/v1/vendor/analytics.py -""" -Vendor analytics and reporting endpoints. - -Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern). -The get_current_vendor_api dependency guarantees token_vendor_id is present. - -Feature Requirements: -- basic_reports: Basic analytics (Essential tier) -- analytics_dashboard: Advanced analytics (Business tier) -""" - -import logging - -from fastapi import APIRouter, Depends, Query -from sqlalchemy.orm import Session - -from app.api.deps import get_current_vendor_api -from app.core.database import get_db -from app.core.feature_gate import RequireFeature -from app.services.stats_service import stats_service -from app.modules.billing.models import FeatureCode -from models.schema.auth import UserContext -from app.modules.analytics.schemas import ( - VendorAnalyticsCatalog, - VendorAnalyticsImports, - VendorAnalyticsInventory, - VendorAnalyticsResponse, -) - -router = APIRouter(prefix="/analytics") -logger = logging.getLogger(__name__) - - -@router.get("", response_model=VendorAnalyticsResponse) -def get_vendor_analytics( - period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"), - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), - _: None = Depends(RequireFeature(FeatureCode.BASIC_REPORTS, FeatureCode.ANALYTICS_DASHBOARD)), -): - """Get vendor analytics data for specified time period.""" - data = stats_service.get_vendor_analytics(db, current_user.token_vendor_id, period) - - return VendorAnalyticsResponse( - period=data["period"], - start_date=data["start_date"], - imports=VendorAnalyticsImports(count=data["imports"]["count"]), - catalog=VendorAnalyticsCatalog( - products_added=data["catalog"]["products_added"] - ), - inventory=VendorAnalyticsInventory( - total_locations=data["inventory"]["total_locations"] - ), - ) diff --git a/app/api/v1/vendor/order_item_exceptions.py b/app/api/v1/vendor/order_item_exceptions.py deleted file mode 100644 index c8512444..00000000 --- a/app/api/v1/vendor/order_item_exceptions.py +++ /dev/null @@ -1,261 +0,0 @@ -# app/api/v1/vendor/order_item_exceptions.py -""" -Vendor API endpoints for order item exception management. - -Provides vendor-level management of: -- Listing vendor's own exceptions -- Resolving exceptions by assigning products -- Exception statistics for vendor dashboard -""" - -import logging - -from fastapi import APIRouter, Depends, Path, Query -from sqlalchemy.orm import Session - -from app.api.deps import get_current_vendor_api -from app.core.database import get_db -from app.services.order_item_exception_service import order_item_exception_service -from models.schema.auth import UserContext -from app.modules.orders.schemas import ( - BulkResolveRequest, - BulkResolveResponse, - IgnoreExceptionRequest, - OrderItemExceptionListResponse, - OrderItemExceptionResponse, - OrderItemExceptionStats, - ResolveExceptionRequest, -) - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/order-exceptions", tags=["Vendor Order Item Exceptions"]) - - -# ============================================================================ -# Exception Listing and Stats -# ============================================================================ - - -@router.get("", response_model=OrderItemExceptionListResponse) -def list_vendor_exceptions( - status: str | None = Query( - None, - pattern="^(pending|resolved|ignored)$", - description="Filter by status" - ), - search: str | None = Query( - None, - description="Search in GTIN, product name, SKU, or order number" - ), - skip: int = Query(0, ge=0), - limit: int = Query(50, ge=1, le=200), - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """ - List order item exceptions for the authenticated vendor. - - Returns exceptions for unmatched products during marketplace order imports. - """ - vendor_id = current_user.token_vendor_id - - exceptions, total = order_item_exception_service.get_pending_exceptions( - db=db, - vendor_id=vendor_id, - status=status, - search=search, - skip=skip, - limit=limit, - ) - - # Enrich with order info - response_items = [] - for exc in exceptions: - item = OrderItemExceptionResponse.model_validate(exc) - if exc.order_item and exc.order_item.order: - order = exc.order_item.order - item.order_number = order.order_number - item.order_id = order.id - item.order_date = order.order_date - item.order_status = order.status - response_items.append(item) - - return OrderItemExceptionListResponse( - exceptions=response_items, - total=total, - skip=skip, - limit=limit, - ) - - -@router.get("/stats", response_model=OrderItemExceptionStats) -def get_vendor_exception_stats( - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """ - Get exception statistics for the authenticated vendor. - - Returns counts of pending, resolved, and ignored exceptions. - """ - vendor_id = current_user.token_vendor_id - stats = order_item_exception_service.get_exception_stats(db, vendor_id) - return OrderItemExceptionStats(**stats) - - -# ============================================================================ -# Exception Details -# ============================================================================ - - -@router.get("/{exception_id}", response_model=OrderItemExceptionResponse) -def get_vendor_exception( - exception_id: int = Path(..., description="Exception ID"), - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """ - Get details of a single exception (vendor-scoped). - """ - vendor_id = current_user.token_vendor_id - - # Pass vendor_id for scoped access - exception = order_item_exception_service.get_exception_by_id( - db, exception_id, vendor_id - ) - - response = OrderItemExceptionResponse.model_validate(exception) - if exception.order_item and exception.order_item.order: - order = exception.order_item.order - response.order_number = order.order_number - response.order_id = order.id - response.order_date = order.order_date - response.order_status = order.status - - return response - - -# ============================================================================ -# Exception Resolution -# ============================================================================ - - -@router.post("/{exception_id}/resolve", response_model=OrderItemExceptionResponse) -def resolve_vendor_exception( - exception_id: int = Path(..., description="Exception ID"), - request: ResolveExceptionRequest = ..., - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """ - Resolve an exception by assigning a product (vendor-scoped). - - This updates the order item's product_id and marks the exception as resolved. - """ - vendor_id = current_user.token_vendor_id - - exception = order_item_exception_service.resolve_exception( - db=db, - exception_id=exception_id, - product_id=request.product_id, - resolved_by=current_user.id, - notes=request.notes, - vendor_id=vendor_id, # Vendor-scoped access - ) - db.commit() - - response = OrderItemExceptionResponse.model_validate(exception) - if exception.order_item and exception.order_item.order: - order = exception.order_item.order - response.order_number = order.order_number - response.order_id = order.id - response.order_date = order.order_date - response.order_status = order.status - - logger.info( - f"Vendor user {current_user.id} resolved exception {exception_id} " - f"with product {request.product_id}" - ) - - return response - - -@router.post("/{exception_id}/ignore", response_model=OrderItemExceptionResponse) -def ignore_vendor_exception( - exception_id: int = Path(..., description="Exception ID"), - request: IgnoreExceptionRequest = ..., - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """ - Mark an exception as ignored (vendor-scoped). - - Note: Ignored exceptions still block order confirmation. - Use this when a product will never be matched (e.g., discontinued). - """ - vendor_id = current_user.token_vendor_id - - exception = order_item_exception_service.ignore_exception( - db=db, - exception_id=exception_id, - resolved_by=current_user.id, - notes=request.notes, - vendor_id=vendor_id, # Vendor-scoped access - ) - db.commit() - - response = OrderItemExceptionResponse.model_validate(exception) - if exception.order_item and exception.order_item.order: - order = exception.order_item.order - response.order_number = order.order_number - response.order_id = order.id - response.order_date = order.order_date - response.order_status = order.status - - logger.info( - f"Vendor user {current_user.id} ignored exception {exception_id}: {request.notes}" - ) - - return response - - -# ============================================================================ -# Bulk Operations -# ============================================================================ - - -@router.post("/bulk-resolve", response_model=BulkResolveResponse) -def bulk_resolve_vendor_exceptions( - request: BulkResolveRequest, - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """ - Bulk resolve all pending exceptions for a GTIN (vendor-scoped). - - Useful when a new product is imported and multiple orders have - items with the same unmatched GTIN. - """ - vendor_id = current_user.token_vendor_id - - resolved_count = order_item_exception_service.bulk_resolve_by_gtin( - db=db, - vendor_id=vendor_id, - gtin=request.gtin, - product_id=request.product_id, - resolved_by=current_user.id, - notes=request.notes, - ) - db.commit() - - logger.info( - f"Vendor user {current_user.id} bulk resolved {resolved_count} exceptions " - f"for GTIN {request.gtin} with product {request.product_id}" - ) - - return BulkResolveResponse( - resolved_count=resolved_count, - gtin=request.gtin, - product_id=request.product_id, - ) diff --git a/app/api/v1/vendor/orders.py b/app/api/v1/vendor/orders.py deleted file mode 100644 index 26256ac3..00000000 --- a/app/api/v1/vendor/orders.py +++ /dev/null @@ -1,269 +0,0 @@ -# app/api/v1/vendor/orders.py -""" -Vendor order management endpoints. - -Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern). -The get_current_vendor_api dependency guarantees token_vendor_id is present. -""" - -import logging - -from fastapi import APIRouter, Depends, Query -from pydantic import BaseModel, Field -from sqlalchemy.orm import Session - -from app.api.deps import get_current_vendor_api -from app.core.database import get_db -from app.services.order_inventory_service import order_inventory_service -from app.services.order_service import order_service -from models.schema.auth import UserContext -from app.modules.orders.schemas import ( - OrderDetailResponse, - OrderListResponse, - OrderResponse, - OrderUpdate, -) - -router = APIRouter(prefix="/orders") -logger = logging.getLogger(__name__) - - -@router.get("", response_model=OrderListResponse) -def get_vendor_orders( - skip: int = Query(0, ge=0), - limit: int = Query(100, ge=1, le=1000), - status: str | None = Query(None, description="Filter by order status"), - customer_id: int | None = Query(None, description="Filter by customer"), - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """ - Get all orders for vendor. - - Supports filtering by: - - status: Order status (pending, processing, shipped, delivered, cancelled) - - customer_id: Filter orders from specific customer - - Vendor is determined from JWT token (vendor_id claim). - Requires Authorization header (API endpoint). - """ - orders, total = order_service.get_vendor_orders( - db=db, - vendor_id=current_user.token_vendor_id, - skip=skip, - limit=limit, - status=status, - customer_id=customer_id, - ) - - return OrderListResponse( - orders=[OrderResponse.model_validate(o) for o in orders], - total=total, - skip=skip, - limit=limit, - ) - - -@router.get("/{order_id}", response_model=OrderDetailResponse) -def get_order_details( - order_id: int, - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """ - Get detailed order information including items and addresses. - - Requires Authorization header (API endpoint). - """ - order = order_service.get_order( - db=db, vendor_id=current_user.token_vendor_id, order_id=order_id - ) - - return OrderDetailResponse.model_validate(order) - - -@router.put("/{order_id}/status", response_model=OrderResponse) -def update_order_status( - order_id: int, - order_update: OrderUpdate, - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """ - Update order status and tracking information. - - Valid statuses: - - pending: Order placed, awaiting processing - - processing: Order being prepared - - shipped: Order shipped to customer - - delivered: Order delivered - - cancelled: Order cancelled - - refunded: Order refunded - - Requires Authorization header (API endpoint). - """ - order = order_service.update_order_status( - db=db, - vendor_id=current_user.token_vendor_id, - order_id=order_id, - order_update=order_update, - ) - db.commit() - - logger.info( - f"Order {order.order_number} status updated to {order.status} " - f"by user {current_user.username}" - ) - - return OrderResponse.model_validate(order) - - -# ============================================================================ -# Partial Shipment Endpoints -# ============================================================================ - - -class ShipItemRequest(BaseModel): - """Request to ship specific quantity of an order item.""" - - quantity: int | None = Field( - None, ge=1, description="Quantity to ship (default: remaining quantity)" - ) - - -class ShipItemResponse(BaseModel): - """Response from shipping an item.""" - - order_id: int - item_id: int - fulfilled_quantity: int - shipped_quantity: int | None = None - remaining_quantity: int | None = None - is_fully_shipped: bool | None = None - message: str | None = None - - -class ShipmentStatusItemResponse(BaseModel): - """Item-level shipment status.""" - - item_id: int - product_id: int - product_name: str - quantity: int - shipped_quantity: int - remaining_quantity: int - is_fully_shipped: bool - is_partially_shipped: bool - - -class ShipmentStatusResponse(BaseModel): - """Order shipment status response.""" - - order_id: int - order_number: str - order_status: str - is_fully_shipped: bool - is_partially_shipped: bool - shipped_item_count: int - total_item_count: int - total_shipped_units: int - total_ordered_units: int - items: list[ShipmentStatusItemResponse] - - -@router.get("/{order_id}/shipment-status", response_model=ShipmentStatusResponse) -def get_shipment_status( - order_id: int, - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """ - Get detailed shipment status for an order. - - Returns item-level shipment status showing what has been shipped - and what remains. Useful for partial shipment tracking. - - Requires Authorization header (API endpoint). - """ - result = order_inventory_service.get_shipment_status( - db=db, - vendor_id=current_user.token_vendor_id, - order_id=order_id, - ) - - return ShipmentStatusResponse( - order_id=result["order_id"], - order_number=result["order_number"], - order_status=result["order_status"], - is_fully_shipped=result["is_fully_shipped"], - is_partially_shipped=result["is_partially_shipped"], - shipped_item_count=result["shipped_item_count"], - total_item_count=result["total_item_count"], - total_shipped_units=result["total_shipped_units"], - total_ordered_units=result["total_ordered_units"], - items=[ShipmentStatusItemResponse(**item) for item in result["items"]], - ) - - -@router.post("/{order_id}/items/{item_id}/ship", response_model=ShipItemResponse) -def ship_order_item( - order_id: int, - item_id: int, - request: ShipItemRequest | None = None, - current_user: UserContext = Depends(get_current_vendor_api), - db: Session = Depends(get_db), -): - """ - Ship a specific order item (supports partial shipment). - - Fulfills inventory and updates the item's shipped quantity. - If quantity is not specified, ships the remaining quantity. - - Example use cases: - - Ship all of an item: POST /orders/{id}/items/{item_id}/ship - - Ship partial: POST /orders/{id}/items/{item_id}/ship with {"quantity": 2} - - Requires Authorization header (API endpoint). - """ - quantity = request.quantity if request else None - - result = order_inventory_service.fulfill_item( - db=db, - vendor_id=current_user.token_vendor_id, - order_id=order_id, - item_id=item_id, - quantity=quantity, - skip_missing=True, - ) - - # Update order status based on shipment state - order = order_service.get_order(db, current_user.token_vendor_id, order_id) - - if order.is_fully_shipped and order.status != "shipped": - order_service.update_order_status( - db=db, - vendor_id=current_user.token_vendor_id, - order_id=order_id, - order_update=OrderUpdate(status="shipped"), - ) - logger.info(f"Order {order.order_number} fully shipped") - elif order.is_partially_shipped and order.status not in ( - "partially_shipped", - "shipped", - ): - order_service.update_order_status( - db=db, - vendor_id=current_user.token_vendor_id, - order_id=order_id, - order_update=OrderUpdate(status="partially_shipped"), - ) - logger.info(f"Order {order.order_number} partially shipped") - - db.commit() - - logger.info( - f"Shipped item {item_id} of order {order_id}: " - f"{result.get('fulfilled_quantity', 0)} units" - ) - - return ShipItemResponse(**result) diff --git a/app/modules/billing/routes/api/__init__.py b/app/modules/billing/routes/api/__init__.py index 1e4ed4a0..12f875e9 100644 --- a/app/modules/billing/routes/api/__init__.py +++ b/app/modules/billing/routes/api/__init__.py @@ -3,8 +3,10 @@ Billing module API routes. Provides REST API endpoints for subscription and billing management: -- Admin API: Subscription tier management, vendor subscriptions, billing history -- Vendor API: Subscription status, tier comparison, invoices +- Admin API: Subscription tier management, vendor subscriptions, billing history, features +- Vendor API: Subscription status, tier comparison, invoices, features + +Each main router (admin.py, vendor.py) aggregates its related sub-routers internally. """ from app.modules.billing.routes.api.admin import admin_router diff --git a/app/modules/billing/routes/api/admin.py b/app/modules/billing/routes/api/admin.py index 0ef8b0b2..a842f7ee 100644 --- a/app/modules/billing/routes/api/admin.py +++ b/app/modules/billing/routes/api/admin.py @@ -334,3 +334,12 @@ def update_vendor_subscription( products_count=usage["products_count"], team_count=usage["team_count"], ) + + +# ============================================================================ +# Aggregate Feature Management Routes +# ============================================================================ +# Include the features router to aggregate all billing-related admin routes +from app.modules.billing.routes.api.admin_features import admin_features_router + +admin_router.include_router(admin_features_router, tags=["admin-features"]) diff --git a/app/api/v1/admin/features.py b/app/modules/billing/routes/api/admin_features.py similarity index 90% rename from app/api/v1/admin/features.py rename to app/modules/billing/routes/api/admin_features.py index 37d938a7..cf92a101 100644 --- a/app/api/v1/admin/features.py +++ b/app/modules/billing/routes/api/admin_features.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/features.py +# app/modules/billing/routes/api/admin_features.py """ Admin feature management endpoints. @@ -7,6 +7,8 @@ Provides endpoints for: - Updating tier feature assignments - Managing feature metadata - Viewing feature usage statistics + +All routes require module access control for the 'billing' module. """ import logging @@ -15,12 +17,15 @@ from fastapi import APIRouter, Depends, Query from pydantic import BaseModel from sqlalchemy.orm import Session -from app.api.deps import get_current_admin_api +from app.api.deps import get_current_admin_api, require_module_access from app.core.database import get_db from app.services.feature_service import feature_service from models.schema.auth import UserContext -router = APIRouter(prefix="/features") +admin_features_router = APIRouter( + prefix="/features", + dependencies=[Depends(require_module_access("billing"))], +) logger = logging.getLogger(__name__) @@ -141,7 +146,7 @@ def _feature_to_response(feature) -> FeatureResponse: # ============================================================================ -@router.get("", response_model=FeatureListResponse) +@admin_features_router.get("", response_model=FeatureListResponse) def list_features( category: str | None = Query(None, description="Filter by category"), active_only: bool = Query(False, description="Only active features"), @@ -159,7 +164,7 @@ def list_features( ) -@router.get("/categories", response_model=CategoryListResponse) +@admin_features_router.get("/categories", response_model=CategoryListResponse) def list_categories( current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), @@ -169,7 +174,7 @@ def list_categories( return CategoryListResponse(categories=categories) -@router.get("/tiers", response_model=TierListWithFeaturesResponse) +@admin_features_router.get("/tiers", response_model=TierListWithFeaturesResponse) def list_tiers_with_features( current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), @@ -192,7 +197,7 @@ def list_tiers_with_features( ) -@router.get("/{feature_code}", response_model=FeatureResponse) +@admin_features_router.get("/{feature_code}", response_model=FeatureResponse) def get_feature( feature_code: str, current_user: UserContext = Depends(get_current_admin_api), @@ -213,7 +218,7 @@ def get_feature( return _feature_to_response(feature) -@router.put("/{feature_code}", response_model=FeatureResponse) +@admin_features_router.put("/{feature_code}", response_model=FeatureResponse) def update_feature( feature_code: str, request: UpdateFeatureRequest, @@ -249,7 +254,7 @@ def update_feature( return _feature_to_response(feature) -@router.put("/tiers/{tier_code}/features", response_model=TierFeaturesResponse) +@admin_features_router.put("/tiers/{tier_code}/features", response_model=TierFeaturesResponse) def update_tier_features( tier_code: str, request: UpdateTierFeaturesRequest, @@ -279,7 +284,7 @@ def update_tier_features( ) -@router.get("/tiers/{tier_code}/features", response_model=TierFeatureDetailResponse) +@admin_features_router.get("/tiers/{tier_code}/features", response_model=TierFeatureDetailResponse) def get_tier_features( tier_code: str, current_user: UserContext = Depends(get_current_admin_api), diff --git a/app/modules/billing/routes/api/vendor.py b/app/modules/billing/routes/api/vendor.py index 629a1c4f..9af3a65b 100644 --- a/app/modules/billing/routes/api/vendor.py +++ b/app/modules/billing/routes/api/vendor.py @@ -223,7 +223,9 @@ def get_invoices( from app.modules.billing.routes.api.vendor_features import vendor_features_router from app.modules.billing.routes.api.vendor_checkout import vendor_checkout_router from app.modules.billing.routes.api.vendor_addons import vendor_addons_router +from app.modules.billing.routes.api.vendor_usage import vendor_usage_router vendor_router.include_router(vendor_features_router, tags=["vendor-features"]) vendor_router.include_router(vendor_checkout_router, tags=["vendor-billing"]) vendor_router.include_router(vendor_addons_router, tags=["vendor-billing-addons"]) +vendor_router.include_router(vendor_usage_router, tags=["vendor-usage"]) diff --git a/app/api/v1/vendor/features.py b/app/modules/billing/routes/api/vendor_features.py similarity index 92% rename from app/api/v1/vendor/features.py rename to app/modules/billing/routes/api/vendor_features.py index 7270b152..5a0e7e43 100644 --- a/app/api/v1/vendor/features.py +++ b/app/modules/billing/routes/api/vendor_features.py @@ -1,4 +1,4 @@ -# app/api/v1/vendor/features.py +# app/modules/billing/routes/api/vendor_features.py """ Vendor features API endpoints. @@ -12,6 +12,8 @@ Endpoints: - GET /features - Full feature list with availability and metadata - GET /features/{code} - Single feature details with upgrade info - GET /features/categories - List feature categories + +All routes require module access control for the 'billing' module. """ import logging @@ -20,13 +22,16 @@ from fastapi import APIRouter, Depends, Query from pydantic import BaseModel from sqlalchemy.orm import Session -from app.api.deps import get_current_vendor_api +from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db from app.exceptions import FeatureNotFoundError from app.services.feature_service import feature_service from models.schema.auth import UserContext -router = APIRouter(prefix="/features") +vendor_features_router = APIRouter( + prefix="/features", + dependencies=[Depends(require_module_access("billing"))], +) logger = logging.getLogger(__name__) @@ -112,7 +117,7 @@ class FeatureCheckResponse(BaseModel): # ============================================================================ -@router.get("/available", response_model=FeatureCodeListResponse) +@vendor_features_router.get("/available", response_model=FeatureCodeListResponse) def get_available_features( current_user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db), @@ -144,7 +149,7 @@ def get_available_features( ) -@router.get("", response_model=FeatureListResponse) +@vendor_features_router.get("", response_model=FeatureListResponse) def get_features( category: str | None = Query(None, description="Filter by category"), include_unavailable: bool = Query(True, description="Include features not available to vendor"), @@ -209,7 +214,7 @@ def get_features( ) -@router.get("/categories", response_model=CategoryListResponse) +@vendor_features_router.get("/categories", response_model=CategoryListResponse) def get_feature_categories( current_user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db), @@ -224,7 +229,7 @@ def get_feature_categories( return CategoryListResponse(categories=categories) -@router.get("/grouped", response_model=FeatureGroupedResponse) +@vendor_features_router.get("/grouped", response_model=FeatureGroupedResponse) def get_features_grouped( current_user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db), @@ -270,7 +275,7 @@ def get_features_grouped( ) -@router.get("/{feature_code}", response_model=FeatureDetailResponse) +@vendor_features_router.get("/{feature_code}", response_model=FeatureDetailResponse) def get_feature_detail( feature_code: str, current_user: UserContext = Depends(get_current_vendor_api), @@ -325,7 +330,7 @@ def get_feature_detail( ) -@router.get("/check/{feature_code}", response_model=FeatureCheckResponse) +@vendor_features_router.get("/check/{feature_code}", response_model=FeatureCheckResponse) def check_feature( feature_code: str, current_user: UserContext = Depends(get_current_vendor_api), diff --git a/app/api/v1/vendor/usage.py b/app/modules/billing/routes/api/vendor_usage.py similarity index 91% rename from app/api/v1/vendor/usage.py rename to app/modules/billing/routes/api/vendor_usage.py index 800d5c32..7d6664f3 100644 --- a/app/api/v1/vendor/usage.py +++ b/app/modules/billing/routes/api/vendor_usage.py @@ -1,4 +1,4 @@ -# app/api/v1/vendor/usage.py +# app/modules/billing/routes/api/vendor_usage.py """ Vendor usage and limits API endpoints. @@ -6,6 +6,8 @@ Provides endpoints for: - Current usage vs limits - Upgrade recommendations - Approaching limit warnings + +Migrated from app/api/v1/vendor/usage.py to billing module. """ import logging @@ -14,12 +16,15 @@ from fastapi import APIRouter, Depends from pydantic import BaseModel from sqlalchemy.orm import Session -from app.api.deps import get_current_vendor_api +from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db from app.services.usage_service import usage_service from models.schema.auth import UserContext -router = APIRouter(prefix="/usage") +vendor_usage_router = APIRouter( + prefix="/usage", + dependencies=[Depends(require_module_access("billing"))], +) logger = logging.getLogger(__name__) @@ -89,7 +94,7 @@ class LimitCheckResponse(BaseModel): # ============================================================================ -@router.get("", response_model=UsageResponse) +@vendor_usage_router.get("", response_model=UsageResponse) def get_usage( current_user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db), @@ -143,7 +148,7 @@ def get_usage( ) -@router.get("/check/{limit_type}", response_model=LimitCheckResponse) +@vendor_usage_router.get("/check/{limit_type}", response_model=LimitCheckResponse) def check_limit( limit_type: str, current_user: UserContext = Depends(get_current_vendor_api), diff --git a/app/modules/inventory/routes/__init__.py b/app/modules/inventory/routes/__init__.py index f1ecd121..c7d4a5c4 100644 --- a/app/modules/inventory/routes/__init__.py +++ b/app/modules/inventory/routes/__init__.py @@ -6,23 +6,20 @@ This module provides functions to register inventory routes with module-based access control. NOTE: Routers are NOT auto-imported to avoid circular dependencies. -Import directly from admin.py or vendor.py as needed: - from app.modules.inventory.routes.admin import admin_router - from app.modules.inventory.routes.vendor import vendor_router +Import directly from api submodule as needed: + from app.modules.inventory.routes.api import admin_router + from app.modules.inventory.routes.api import vendor_router """ -# Routers are imported on-demand to avoid circular dependencies -# Do NOT add auto-imports here - __all__ = ["admin_router", "vendor_router"] def __getattr__(name: str): """Lazy import routers to avoid circular dependencies.""" if name == "admin_router": - from app.modules.inventory.routes.admin import admin_router + from app.modules.inventory.routes.api import admin_router return admin_router elif name == "vendor_router": - from app.modules.inventory.routes.vendor import vendor_router + from app.modules.inventory.routes.api import vendor_router return vendor_router raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/app/modules/inventory/routes/admin.py b/app/modules/inventory/routes/admin.py deleted file mode 100644 index 999b9050..00000000 --- a/app/modules/inventory/routes/admin.py +++ /dev/null @@ -1,26 +0,0 @@ -# app/modules/inventory/routes/admin.py -""" -Inventory module admin routes. - -This module wraps the existing admin inventory routes and adds -module-based access control. Routes are re-exported from the -original location with the module access dependency. -""" - -from fastapi import APIRouter, Depends - -from app.api.deps import require_module_access - -# Import original router (direct import to avoid circular dependency) -from app.api.v1.admin.inventory import router as original_router - -# Create module-aware router -admin_router = APIRouter( - prefix="/inventory", - dependencies=[Depends(require_module_access("inventory"))], -) - -# Re-export all routes from the original module with module access control -# The routes are copied to maintain the same API structure -for route in original_router.routes: - admin_router.routes.append(route) diff --git a/app/modules/inventory/routes/api/__init__.py b/app/modules/inventory/routes/api/__init__.py index bec0d558..39f6ac4f 100644 --- a/app/modules/inventory/routes/api/__init__.py +++ b/app/modules/inventory/routes/api/__init__.py @@ -1,4 +1,21 @@ -# Routes will be migrated here from legacy locations -# TODO: Move actual route implementations from app/api/v1/ +# app/modules/inventory/routes/api/__init__.py +""" +Inventory module API routes. -__all__ = [] +Provides REST API endpoints for inventory management: +- Admin API: Platform-wide inventory management +- Vendor API: Vendor-specific inventory operations +""" + +__all__ = ["admin_router", "vendor_router"] + + +def __getattr__(name: str): + """Lazy import routers to avoid circular dependencies.""" + if name == "admin_router": + from app.modules.inventory.routes.api.admin import admin_router + return admin_router + elif name == "vendor_router": + from app.modules.inventory.routes.api.vendor import vendor_router + return vendor_router + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/app/api/v1/admin/inventory.py b/app/modules/inventory/routes/api/admin.py similarity index 91% rename from app/api/v1/admin/inventory.py rename to app/modules/inventory/routes/api/admin.py index ceaef0bb..edbe9fc3 100644 --- a/app/api/v1/admin/inventory.py +++ b/app/modules/inventory/routes/api/admin.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/inventory.py +# app/modules/inventory/routes/api/admin.py """ Admin inventory management endpoints. @@ -18,7 +18,7 @@ from fastapi import APIRouter, Depends, File, Form, Query, UploadFile from pydantic import BaseModel from sqlalchemy.orm import Session -from app.api.deps import get_current_admin_api +from app.api.deps import get_current_admin_api, require_module_access from app.core.database import get_db from app.services.inventory_import_service import inventory_import_service from app.services.inventory_service import inventory_service @@ -43,7 +43,10 @@ from app.modules.inventory.schemas import ( ProductInventorySummary, ) -router = APIRouter(prefix="/inventory") +admin_router = APIRouter( + prefix="/inventory", + dependencies=[Depends(require_module_access("inventory"))], +) logger = logging.getLogger(__name__) @@ -52,7 +55,7 @@ logger = logging.getLogger(__name__) # ============================================================================ -@router.get("", response_model=AdminInventoryListResponse) +@admin_router.get("", response_model=AdminInventoryListResponse) def get_all_inventory( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=500), @@ -79,7 +82,7 @@ def get_all_inventory( ) -@router.get("/stats", response_model=AdminInventoryStats) +@admin_router.get("/stats", response_model=AdminInventoryStats) def get_inventory_stats( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -88,7 +91,7 @@ def get_inventory_stats( return inventory_service.get_inventory_stats_admin(db) -@router.get("/low-stock", response_model=list[AdminLowStockItem]) +@admin_router.get("/low-stock", response_model=list[AdminLowStockItem]) def get_low_stock_items( threshold: int = Query(10, ge=0, description="Stock threshold"), vendor_id: int | None = Query(None, description="Filter by vendor"), @@ -105,7 +108,7 @@ def get_low_stock_items( ) -@router.get("/vendors", response_model=AdminVendorsWithInventoryResponse) +@admin_router.get("/vendors", response_model=AdminVendorsWithInventoryResponse) def get_vendors_with_inventory( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -114,7 +117,7 @@ def get_vendors_with_inventory( return inventory_service.get_vendors_with_inventory_admin(db) -@router.get("/locations", response_model=AdminInventoryLocationsResponse) +@admin_router.get("/locations", response_model=AdminInventoryLocationsResponse) def get_inventory_locations( vendor_id: int | None = Query(None, description="Filter by vendor"), db: Session = Depends(get_db), @@ -129,7 +132,7 @@ def get_inventory_locations( # ============================================================================ -@router.get("/vendors/{vendor_id}", response_model=AdminInventoryListResponse) +@admin_router.get("/vendors/{vendor_id}", response_model=AdminInventoryListResponse) def get_vendor_inventory( vendor_id: int, skip: int = Query(0, ge=0), @@ -150,7 +153,7 @@ def get_vendor_inventory( ) -@router.get("/products/{product_id}", response_model=ProductInventorySummary) +@admin_router.get("/products/{product_id}", response_model=ProductInventorySummary) def get_product_inventory( product_id: int, db: Session = Depends(get_db), @@ -165,7 +168,7 @@ def get_product_inventory( # ============================================================================ -@router.post("/set", response_model=InventoryResponse) +@admin_router.post("/set", response_model=InventoryResponse) def set_inventory( inventory_data: AdminInventoryCreate, db: Session = Depends(get_db), @@ -201,7 +204,7 @@ def set_inventory( return result -@router.post("/adjust", response_model=InventoryResponse) +@admin_router.post("/adjust", response_model=InventoryResponse) def adjust_inventory( adjustment: AdminInventoryAdjust, db: Session = Depends(get_db), @@ -240,7 +243,7 @@ def adjust_inventory( return result -@router.put("/{inventory_id}", response_model=InventoryResponse) +@admin_router.put("/{inventory_id}", response_model=InventoryResponse) def update_inventory( inventory_id: int, inventory_update: InventoryUpdate, @@ -264,7 +267,7 @@ def update_inventory( return result -@router.delete("/{inventory_id}", response_model=InventoryMessageResponse) +@admin_router.delete("/{inventory_id}", response_model=InventoryMessageResponse) def delete_inventory( inventory_id: int, db: Session = Depends(get_db), @@ -317,7 +320,7 @@ class InventoryImportResponse(BaseModel): errors: list[str] -@router.post("/import", response_model=InventoryImportResponse) +@admin_router.post("/import", response_model=InventoryImportResponse) async def import_inventory( file: UploadFile = File(..., description="TSV/CSV file with BIN, EAN, PRODUCT, QUANTITY columns"), vendor_id: int = Form(..., description="Vendor ID"), @@ -389,7 +392,7 @@ async def import_inventory( # ============================================================================ -@router.get("/transactions", response_model=AdminInventoryTransactionListResponse) +@admin_router.get("/transactions", response_model=AdminInventoryTransactionListResponse) def get_all_transactions( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), @@ -423,7 +426,7 @@ def get_all_transactions( ) -@router.get("/transactions/stats", response_model=AdminTransactionStatsResponse) +@admin_router.get("/transactions/stats", response_model=AdminTransactionStatsResponse) def get_transaction_stats( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), diff --git a/app/api/v1/vendor/inventory.py b/app/modules/inventory/routes/api/vendor.py similarity index 87% rename from app/api/v1/vendor/inventory.py rename to app/modules/inventory/routes/api/vendor.py index 91d7f835..cb043c81 100644 --- a/app/api/v1/vendor/inventory.py +++ b/app/modules/inventory/routes/api/vendor.py @@ -1,4 +1,4 @@ -# app/api/v1/vendor/inventory.py +# app/modules/inventory/routes/api/vendor.py """ Vendor inventory management endpoints. @@ -11,7 +11,7 @@ import logging from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session -from app.api.deps import get_current_vendor_api +from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db from app.services.inventory_service import inventory_service from app.services.inventory_transaction_service import inventory_transaction_service @@ -31,11 +31,14 @@ from app.modules.inventory.schemas import ( ProductTransactionHistoryResponse, ) -router = APIRouter() +vendor_router = APIRouter( + prefix="/inventory", + dependencies=[Depends(require_module_access("inventory"))], +) logger = logging.getLogger(__name__) -@router.post("/inventory/set", response_model=InventoryResponse) +@vendor_router.post("/set", response_model=InventoryResponse) def set_inventory( inventory: InventoryCreate, current_user: UserContext = Depends(get_current_vendor_api), @@ -49,7 +52,7 @@ def set_inventory( return result -@router.post("/inventory/adjust", response_model=InventoryResponse) +@vendor_router.post("/adjust", response_model=InventoryResponse) def adjust_inventory( adjustment: InventoryAdjust, current_user: UserContext = Depends(get_current_vendor_api), @@ -63,7 +66,7 @@ def adjust_inventory( return result -@router.post("/inventory/reserve", response_model=InventoryResponse) +@vendor_router.post("/reserve", response_model=InventoryResponse) def reserve_inventory( reservation: InventoryReserve, current_user: UserContext = Depends(get_current_vendor_api), @@ -77,7 +80,7 @@ def reserve_inventory( return result -@router.post("/inventory/release", response_model=InventoryResponse) +@vendor_router.post("/release", response_model=InventoryResponse) def release_reservation( reservation: InventoryReserve, current_user: UserContext = Depends(get_current_vendor_api), @@ -91,7 +94,7 @@ def release_reservation( return result -@router.post("/inventory/fulfill", response_model=InventoryResponse) +@vendor_router.post("/fulfill", response_model=InventoryResponse) def fulfill_reservation( reservation: InventoryReserve, current_user: UserContext = Depends(get_current_vendor_api), @@ -105,7 +108,7 @@ def fulfill_reservation( return result -@router.get("/inventory/product/{product_id}", response_model=ProductInventorySummary) +@vendor_router.get("/product/{product_id}", response_model=ProductInventorySummary) def get_product_inventory( product_id: int, current_user: UserContext = Depends(get_current_vendor_api), @@ -117,7 +120,7 @@ def get_product_inventory( ) -@router.get("/inventory", response_model=InventoryListResponse) +@vendor_router.get("", response_model=InventoryListResponse) def get_vendor_inventory( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=1000), @@ -139,7 +142,7 @@ def get_vendor_inventory( ) -@router.put("/inventory/{inventory_id}", response_model=InventoryResponse) +@vendor_router.put("/{inventory_id}", response_model=InventoryResponse) def update_inventory( inventory_id: int, inventory_update: InventoryUpdate, @@ -154,7 +157,7 @@ def update_inventory( return result -@router.delete("/inventory/{inventory_id}", response_model=InventoryMessageResponse) +@vendor_router.delete("/{inventory_id}", response_model=InventoryMessageResponse) def delete_inventory( inventory_id: int, current_user: UserContext = Depends(get_current_vendor_api), @@ -171,7 +174,7 @@ def delete_inventory( # ============================================================================ -@router.get("/inventory/transactions", response_model=InventoryTransactionListResponse) +@vendor_router.get("/transactions", response_model=InventoryTransactionListResponse) def get_inventory_transactions( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), @@ -203,8 +206,8 @@ def get_inventory_transactions( ) -@router.get( - "/inventory/transactions/product/{product_id}", +@vendor_router.get( + "/transactions/product/{product_id}", response_model=ProductTransactionHistoryResponse, ) def get_product_transaction_history( @@ -228,8 +231,8 @@ def get_product_transaction_history( return ProductTransactionHistoryResponse(**result) -@router.get( - "/inventory/transactions/order/{order_id}", +@vendor_router.get( + "/transactions/order/{order_id}", response_model=OrderTransactionHistoryResponse, ) def get_order_transaction_history( diff --git a/app/modules/inventory/routes/vendor.py b/app/modules/inventory/routes/vendor.py deleted file mode 100644 index 3c9d19b7..00000000 --- a/app/modules/inventory/routes/vendor.py +++ /dev/null @@ -1,25 +0,0 @@ -# app/modules/inventory/routes/vendor.py -""" -Inventory module vendor routes. - -This module wraps the existing vendor inventory routes and adds -module-based access control. Routes are re-exported from the -original location with the module access dependency. -""" - -from fastapi import APIRouter, Depends - -from app.api.deps import require_module_access - -# Import original router (direct import to avoid circular dependency) -from app.api.v1.vendor.inventory import router as original_router - -# Create module-aware router -vendor_router = APIRouter( - prefix="/inventory", - dependencies=[Depends(require_module_access("inventory"))], -) - -# Re-export all routes from the original module with module access control -for route in original_router.routes: - vendor_router.routes.append(route) diff --git a/app/modules/marketplace/routes/__init__.py b/app/modules/marketplace/routes/__init__.py index 6dcc78d3..ad15c33d 100644 --- a/app/modules/marketplace/routes/__init__.py +++ b/app/modules/marketplace/routes/__init__.py @@ -2,36 +2,11 @@ """ Marketplace module route registration. -This module provides marketplace routes with module-based access control. - Structure: - routes/api/ - REST API endpoints - routes/pages/ - HTML page rendering (templates) -NOTE: Routers are not eagerly imported here to avoid circular imports. -Import directly from routes/api/admin.py or routes/api/vendor.py instead. +Import routers directly from their respective files: +- from app.modules.marketplace.routes.api.admin import admin_router, admin_letzshop_router +- from app.modules.marketplace.routes.api.vendor import vendor_router, vendor_letzshop_router """ - - -def __getattr__(name: str): - """Lazy import of routers to avoid circular imports.""" - if name == "admin_router": - from app.modules.marketplace.routes.api.admin import admin_router - - return admin_router - elif name == "admin_letzshop_router": - from app.modules.marketplace.routes.api.admin import admin_letzshop_router - - return admin_letzshop_router - elif name == "vendor_router": - from app.modules.marketplace.routes.api.vendor import vendor_router - - return vendor_router - elif name == "vendor_letzshop_router": - from app.modules.marketplace.routes.api.vendor import vendor_letzshop_router - - return vendor_letzshop_router - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - - -__all__ = ["admin_router", "admin_letzshop_router", "vendor_router", "vendor_letzshop_router"] diff --git a/app/modules/marketplace/routes/api/__init__.py b/app/modules/marketplace/routes/api/__init__.py index 9c5abc3d..553e8a8f 100644 --- a/app/modules/marketplace/routes/api/__init__.py +++ b/app/modules/marketplace/routes/api/__init__.py @@ -2,34 +2,7 @@ """ Marketplace module API routes. -Provides REST API endpoints for marketplace integration: -- Admin API: Import jobs, vendor directory, marketplace products -- Vendor API: Letzshop sync, product imports, exports - -NOTE: Routers are not eagerly imported here to avoid circular imports. -Import directly from admin.py or vendor.py instead. +Import routers directly from their respective files: +- from app.modules.marketplace.routes.api.admin import admin_router, admin_letzshop_router +- from app.modules.marketplace.routes.api.vendor import vendor_router, vendor_letzshop_router """ - - -def __getattr__(name: str): - """Lazy import of routers to avoid circular imports.""" - if name == "admin_router": - from app.modules.marketplace.routes.api.admin import admin_router - - return admin_router - elif name == "admin_letzshop_router": - from app.modules.marketplace.routes.api.admin import admin_letzshop_router - - return admin_letzshop_router - elif name == "vendor_router": - from app.modules.marketplace.routes.api.vendor import vendor_router - - return vendor_router - elif name == "vendor_letzshop_router": - from app.modules.marketplace.routes.api.vendor import vendor_letzshop_router - - return vendor_letzshop_router - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - - -__all__ = ["admin_router", "admin_letzshop_router", "vendor_router", "vendor_letzshop_router"] diff --git a/app/api/v1/admin/letzshop.py b/app/modules/marketplace/routes/api/admin_letzshop.py similarity index 97% rename from app/api/v1/admin/letzshop.py rename to app/modules/marketplace/routes/api/admin_letzshop.py index bf70114a..5fabbf1f 100644 --- a/app/api/v1/admin/letzshop.py +++ b/app/modules/marketplace/routes/api/admin_letzshop.py @@ -1,4 +1,4 @@ -# app/api/v1/admin/letzshop.py +# app/modules/marketplace/routes/api/admin_letzshop.py """ Admin API endpoints for Letzshop marketplace integration. @@ -7,6 +7,8 @@ Provides admin-level management of: - Connection testing - Sync triggers and status - Order overview + +All routes require module access control for the 'marketplace' module. """ import logging @@ -14,7 +16,7 @@ import logging from fastapi import APIRouter, BackgroundTasks, Depends, Path, Query from sqlalchemy.orm import Session -from app.api.deps import get_current_admin_api +from app.api.deps import get_current_admin_api, require_module_access from app.core.database import get_db from app.exceptions import ( OrderHasUnresolvedExceptionsException, @@ -22,7 +24,7 @@ from app.exceptions import ( ValidationException, ) from app.services.order_item_exception_service import order_item_exception_service -from app.services.letzshop import ( +from app.modules.marketplace.services.letzshop import ( CredentialsNotFoundError, LetzshopClientError, LetzshopCredentialsService, @@ -64,7 +66,10 @@ from app.modules.marketplace.schemas import ( LetzshopVendorOverview, ) -router = APIRouter(prefix="/letzshop") +admin_letzshop_router = APIRouter( + prefix="/letzshop", + dependencies=[Depends(require_module_access("marketplace"))], +) logger = logging.getLogger(__name__) @@ -88,7 +93,7 @@ def get_credentials_service(db: Session) -> LetzshopCredentialsService: # ============================================================================ -@router.get("/vendors", response_model=LetzshopVendorListResponse) +@admin_letzshop_router.get("/vendors", response_model=LetzshopVendorListResponse) def list_vendors_letzshop_status( skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=1000), @@ -123,7 +128,7 @@ def list_vendors_letzshop_status( # ============================================================================ -@router.get( +@admin_letzshop_router.get( "/vendors/{vendor_id}/credentials", response_model=LetzshopCredentialsResponse, ) @@ -165,7 +170,7 @@ def get_vendor_credentials( ) -@router.post( +@admin_letzshop_router.post( "/vendors/{vendor_id}/credentials", response_model=LetzshopCredentialsResponse, ) @@ -212,7 +217,7 @@ def create_or_update_vendor_credentials( ) -@router.patch( +@admin_letzshop_router.patch( "/vendors/{vendor_id}/credentials", response_model=LetzshopCredentialsResponse, ) @@ -262,7 +267,7 @@ def update_vendor_credentials( ) -@router.delete( +@admin_letzshop_router.delete( "/vendors/{vendor_id}/credentials", response_model=LetzshopSuccessResponse, ) @@ -301,7 +306,7 @@ def delete_vendor_credentials( # ============================================================================ -@router.post( +@admin_letzshop_router.post( "/vendors/{vendor_id}/test", response_model=LetzshopConnectionTestResponse, ) @@ -329,7 +334,7 @@ def test_vendor_connection( ) -@router.post("/test", response_model=LetzshopConnectionTestResponse) +@admin_letzshop_router.post("/test", response_model=LetzshopConnectionTestResponse) def test_api_key( test_request: LetzshopConnectionTestRequest, db: Session = Depends(get_db), @@ -356,7 +361,7 @@ def test_api_key( # ============================================================================ -@router.get( +@admin_letzshop_router.get( "/orders", response_model=LetzshopOrderListResponse, ) @@ -446,7 +451,7 @@ def list_all_letzshop_orders( ) -@router.get( +@admin_letzshop_router.get( "/vendors/{vendor_id}/orders", response_model=LetzshopOrderListResponse, ) @@ -536,7 +541,7 @@ def list_vendor_letzshop_orders( ) -@router.get( +@admin_letzshop_router.get( "/orders/{order_id}", response_model=LetzshopOrderDetailResponse, ) @@ -615,7 +620,7 @@ def get_letzshop_order_detail( ) -@router.post( +@admin_letzshop_router.post( "/vendors/{vendor_id}/sync", response_model=LetzshopSyncTriggerResponse, ) @@ -711,7 +716,7 @@ def trigger_vendor_sync( # ============================================================================ -@router.get( +@admin_letzshop_router.get( "/jobs", response_model=LetzshopJobsListResponse, ) @@ -742,7 +747,7 @@ def list_all_letzshop_jobs( return LetzshopJobsListResponse(jobs=jobs, total=total) -@router.get( +@admin_letzshop_router.get( "/vendors/{vendor_id}/jobs", response_model=LetzshopJobsListResponse, ) @@ -786,7 +791,7 @@ def list_vendor_letzshop_jobs( # ============================================================================ -@router.post( +@admin_letzshop_router.post( "/vendors/{vendor_id}/import-history", response_model=LetzshopHistoricalImportStartResponse, ) @@ -853,7 +858,7 @@ def start_historical_import( ) -@router.get( +@admin_letzshop_router.get( "/vendors/{vendor_id}/import-history/{job_id}/status", response_model=LetzshopHistoricalImportJobResponse, ) @@ -878,7 +883,7 @@ def get_historical_import_status( return LetzshopHistoricalImportJobResponse.model_validate(job) -@router.get( +@admin_letzshop_router.get( "/vendors/{vendor_id}/import-summary", ) def get_import_summary( @@ -911,7 +916,7 @@ def get_import_summary( # ============================================================================ -@router.post( +@admin_letzshop_router.post( "/vendors/{vendor_id}/orders/{order_id}/confirm", response_model=FulfillmentOperationResponse, ) @@ -996,7 +1001,7 @@ def confirm_order( return FulfillmentOperationResponse(success=False, message=str(e)) -@router.post( +@admin_letzshop_router.post( "/vendors/{vendor_id}/orders/{order_id}/reject", response_model=FulfillmentOperationResponse, ) @@ -1070,7 +1075,7 @@ def reject_order( return FulfillmentOperationResponse(success=False, message=str(e)) -@router.post( +@admin_letzshop_router.post( "/vendors/{vendor_id}/orders/{order_id}/items/{item_id}/confirm", response_model=FulfillmentOperationResponse, ) @@ -1135,7 +1140,7 @@ def confirm_single_item( return FulfillmentOperationResponse(success=False, message=str(e)) -@router.post( +@admin_letzshop_router.post( "/vendors/{vendor_id}/orders/{order_id}/items/{item_id}/decline", response_model=FulfillmentOperationResponse, ) @@ -1192,7 +1197,7 @@ def decline_single_item( # ============================================================================ -@router.post( +@admin_letzshop_router.post( "/vendors/{vendor_id}/sync-tracking", response_model=LetzshopSyncTriggerResponse, ) @@ -1293,7 +1298,7 @@ def get_vendor_sync_service(db: Session) -> LetzshopVendorSyncService: return LetzshopVendorSyncService(db) -@router.post("/vendor-directory/sync") +@admin_letzshop_router.post("/vendor-directory/sync") def trigger_vendor_directory_sync( background_tasks: BackgroundTasks, db: Session = Depends(get_db), @@ -1345,7 +1350,7 @@ def trigger_vendor_directory_sync( } -@router.get( +@admin_letzshop_router.get( "/vendor-directory/stats", response_model=LetzshopVendorDirectoryStatsResponse, ) @@ -1365,7 +1370,7 @@ def get_vendor_directory_stats( ) -@router.get( +@admin_letzshop_router.get( "/vendor-directory/vendors", response_model=LetzshopCachedVendorListResponse, ) @@ -1422,7 +1427,7 @@ def list_cached_vendors( ) -@router.get( +@admin_letzshop_router.get( "/vendor-directory/vendors/{slug}", response_model=LetzshopCachedVendorDetailResponse, ) @@ -1479,7 +1484,7 @@ def get_cached_vendor_detail( ) -@router.post( +@admin_letzshop_router.post( "/vendor-directory/vendors/{slug}/create-vendor", response_model=LetzshopCreateVendorFromCacheResponse, ) diff --git a/app/api/v1/admin/marketplace.py b/app/modules/marketplace/routes/api/admin_marketplace.py similarity index 85% rename from app/api/v1/admin/marketplace.py rename to app/modules/marketplace/routes/api/admin_marketplace.py index 450e9a8e..dbcd8baa 100644 --- a/app/api/v1/admin/marketplace.py +++ b/app/modules/marketplace/routes/api/admin_marketplace.py @@ -1,6 +1,8 @@ -# app/api/v1/admin/marketplace.py +# app/modules/marketplace/routes/api/admin_marketplace.py """ Marketplace import job monitoring endpoints for admin. + +All routes require module access control for the 'marketplace' module. """ import logging @@ -8,12 +10,11 @@ import logging from fastapi import APIRouter, BackgroundTasks, Depends, Query from sqlalchemy.orm import Session -from app.api.deps import get_current_admin_api +from app.api.deps import get_current_admin_api, require_module_access from app.core.database import get_db -from app.services.marketplace_import_job_service import marketplace_import_job_service +from app.modules.marketplace.services.marketplace_import_job_service import marketplace_import_job_service from app.services.stats_service import stats_service from app.services.vendor_service import vendor_service -from app.tasks.background_tasks import process_marketplace_import from models.schema.auth import UserContext from app.modules.marketplace.schemas import ( AdminMarketplaceImportJobListResponse, @@ -26,11 +27,14 @@ from app.modules.marketplace.schemas import ( ) from app.modules.analytics.schemas import ImportStatsResponse -router = APIRouter(prefix="/marketplace-import-jobs") +admin_marketplace_router = APIRouter( + prefix="/marketplace-import-jobs", + dependencies=[Depends(require_module_access("marketplace"))], +) logger = logging.getLogger(__name__) -@router.get("", response_model=AdminMarketplaceImportJobListResponse) +@admin_marketplace_router.get("", response_model=AdminMarketplaceImportJobListResponse) def get_all_marketplace_import_jobs( marketplace: str | None = Query(None), status: str | None = Query(None), @@ -59,7 +63,7 @@ def get_all_marketplace_import_jobs( ) -@router.post("", response_model=MarketplaceImportJobResponse) +@admin_marketplace_router.post("", response_model=MarketplaceImportJobResponse) async def create_marketplace_import_job( request: AdminMarketplaceImportJobRequest, background_tasks: BackgroundTasks, @@ -119,7 +123,7 @@ async def create_marketplace_import_job( # NOTE: /stats must be defined BEFORE /{job_id} to avoid route conflicts -@router.get("/stats", response_model=ImportStatsResponse) +@admin_marketplace_router.get("/stats", response_model=ImportStatsResponse) def get_import_statistics( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -129,7 +133,7 @@ def get_import_statistics( return ImportStatsResponse(**stats) -@router.get("/{job_id}", response_model=AdminMarketplaceImportJobResponse) +@admin_marketplace_router.get("/{job_id}", response_model=AdminMarketplaceImportJobResponse) def get_marketplace_import_job( job_id: int, db: Session = Depends(get_db), @@ -140,7 +144,7 @@ def get_marketplace_import_job( return marketplace_import_job_service.convert_to_admin_response_model(job) -@router.get("/{job_id}/errors", response_model=MarketplaceImportErrorListResponse) +@admin_marketplace_router.get("/{job_id}/errors", response_model=MarketplaceImportErrorListResponse) def get_import_job_errors( job_id: int, page: int = Query(1, ge=1), diff --git a/app/modules/marketplace/routes/api/vendor.py b/app/modules/marketplace/routes/api/vendor.py index fc5e528d..ebf10114 100644 --- a/app/modules/marketplace/routes/api/vendor.py +++ b/app/modules/marketplace/routes/api/vendor.py @@ -2,43 +2,32 @@ """ Marketplace module vendor routes. -This module wraps the existing vendor marketplace routes and adds -module-based access control. Routes are re-exported from the -original location with the module access dependency. +This module aggregates all marketplace vendor routers into a single router +for auto-discovery. Routes are defined in dedicated files with module-based +access control. Includes: -- /marketplace/* - Marketplace settings +- /marketplace/* - Marketplace import management - /letzshop/* - Letzshop integration """ -import importlib +from fastapi import APIRouter -from fastapi import APIRouter, Depends +from .vendor_marketplace import vendor_marketplace_router +from .vendor_letzshop import vendor_letzshop_router +from .vendor_onboarding import vendor_onboarding_router -from app.api.deps import require_module_access +# Create aggregate router for auto-discovery +# The router is named 'vendor_router' for auto-discovery compatibility +vendor_router = APIRouter() -# Import original routers using importlib to avoid circular imports -# (direct import triggers app.api.v1.vendor.__init__.py which imports us) -_marketplace_module = importlib.import_module("app.api.v1.vendor.marketplace") -_letzshop_module = importlib.import_module("app.api.v1.vendor.letzshop") -marketplace_original_router = _marketplace_module.router -letzshop_original_router = _letzshop_module.router +# Include marketplace import routes +vendor_router.include_router(vendor_marketplace_router) -# Create module-aware router for marketplace -vendor_router = APIRouter( - prefix="/marketplace", - dependencies=[Depends(require_module_access("marketplace"))], -) +# Include letzshop routes +vendor_router.include_router(vendor_letzshop_router) -# Re-export all routes from the original marketplace module -for route in marketplace_original_router.routes: - vendor_router.routes.append(route) +# Include onboarding routes +vendor_router.include_router(vendor_onboarding_router) -# Create separate router for letzshop integration -vendor_letzshop_router = APIRouter( - prefix="/letzshop", - dependencies=[Depends(require_module_access("marketplace"))], -) - -for route in letzshop_original_router.routes: - vendor_letzshop_router.routes.append(route) +__all__ = ["vendor_router"] diff --git a/app/api/v1/vendor/letzshop.py b/app/modules/marketplace/routes/api/vendor_letzshop.py similarity index 93% rename from app/api/v1/vendor/letzshop.py rename to app/modules/marketplace/routes/api/vendor_letzshop.py index e37ec577..217063e0 100644 --- a/app/api/v1/vendor/letzshop.py +++ b/app/modules/marketplace/routes/api/vendor_letzshop.py @@ -1,4 +1,4 @@ -# app/api/v1/vendor/letzshop.py +# app/modules/marketplace/routes/api/vendor_letzshop.py """ Vendor API endpoints for Letzshop marketplace integration. @@ -9,6 +9,8 @@ Provides vendor-level management of: - Fulfillment operations (confirm, reject, tracking) Vendor Context: Uses token_vendor_id from JWT token. + +All routes require module access control for the 'marketplace' module. """ import logging @@ -16,7 +18,7 @@ import logging from fastapi import APIRouter, Depends, Path, Query from sqlalchemy.orm import Session -from app.api.deps import get_current_vendor_api +from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db from app.exceptions import ( OrderHasUnresolvedExceptionsException, @@ -24,7 +26,7 @@ from app.exceptions import ( ValidationException, ) from app.services.order_item_exception_service import order_item_exception_service -from app.services.letzshop import ( +from app.modules.marketplace.services.letzshop import ( CredentialsNotFoundError, LetzshopClientError, LetzshopCredentialsService, @@ -55,7 +57,10 @@ from app.modules.marketplace.schemas import ( LetzshopSyncTriggerResponse, ) -router = APIRouter(prefix="/letzshop") +vendor_letzshop_router = APIRouter( + prefix="/letzshop", + dependencies=[Depends(require_module_access("marketplace"))], +) logger = logging.getLogger(__name__) @@ -79,7 +84,7 @@ def get_credentials_service(db: Session) -> LetzshopCredentialsService: # ============================================================================ -@router.get("/status", response_model=LetzshopCredentialsStatus) +@vendor_letzshop_router.get("/status", response_model=LetzshopCredentialsStatus) def get_letzshop_status( current_user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db), @@ -90,7 +95,7 @@ def get_letzshop_status( return LetzshopCredentialsStatus(**status) -@router.get("/credentials", response_model=LetzshopCredentialsResponse) +@vendor_letzshop_router.get("/credentials", response_model=LetzshopCredentialsResponse) def get_credentials( current_user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db), @@ -119,7 +124,7 @@ def get_credentials( ) -@router.post("/credentials", response_model=LetzshopCredentialsResponse) +@vendor_letzshop_router.post("/credentials", response_model=LetzshopCredentialsResponse) def save_credentials( credentials_data: LetzshopCredentialsCreate, current_user: UserContext = Depends(get_current_vendor_api), @@ -155,7 +160,7 @@ def save_credentials( ) -@router.patch("/credentials", response_model=LetzshopCredentialsResponse) +@vendor_letzshop_router.patch("/credentials", response_model=LetzshopCredentialsResponse) def update_credentials( credentials_data: LetzshopCredentialsUpdate, current_user: UserContext = Depends(get_current_vendor_api), @@ -192,7 +197,7 @@ def update_credentials( ) -@router.delete("/credentials", response_model=LetzshopSuccessResponse) +@vendor_letzshop_router.delete("/credentials", response_model=LetzshopSuccessResponse) def delete_credentials( current_user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db), @@ -216,7 +221,7 @@ def delete_credentials( # ============================================================================ -@router.post("/test", response_model=LetzshopConnectionTestResponse) +@vendor_letzshop_router.post("/test", response_model=LetzshopConnectionTestResponse) def test_connection( current_user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db), @@ -236,7 +241,7 @@ def test_connection( ) -@router.post("/test-key", response_model=LetzshopConnectionTestResponse) +@vendor_letzshop_router.post("/test-key", response_model=LetzshopConnectionTestResponse) def test_api_key( test_request: LetzshopConnectionTestRequest, current_user: UserContext = Depends(get_current_vendor_api), @@ -263,7 +268,7 @@ def test_api_key( # ============================================================================ -@router.get("/orders", response_model=LetzshopOrderListResponse) +@vendor_letzshop_router.get("/orders", response_model=LetzshopOrderListResponse) def list_orders( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), @@ -316,7 +321,7 @@ def list_orders( ) -@router.get("/orders/{order_id}", response_model=LetzshopOrderDetailResponse) +@vendor_letzshop_router.get("/orders/{order_id}", response_model=LetzshopOrderDetailResponse) def get_order( order_id: int = Path(..., description="Order ID"), current_user: UserContext = Depends(get_current_vendor_api), @@ -378,7 +383,7 @@ def get_order( ) -@router.post("/orders/import", response_model=LetzshopSyncTriggerResponse) +@vendor_letzshop_router.post("/orders/import", response_model=LetzshopSyncTriggerResponse) def import_orders( sync_request: LetzshopSyncTriggerRequest = LetzshopSyncTriggerRequest(), current_user: UserContext = Depends(get_current_vendor_api), @@ -451,7 +456,7 @@ def import_orders( # ============================================================================ -@router.post("/orders/{order_id}/confirm", response_model=FulfillmentOperationResponse) +@vendor_letzshop_router.post("/orders/{order_id}/confirm", response_model=FulfillmentOperationResponse) def confirm_order( order_id: int = Path(..., description="Order ID"), confirm_request: FulfillmentConfirmRequest | None = None, @@ -521,7 +526,7 @@ def confirm_order( return FulfillmentOperationResponse(success=False, message=str(e)) -@router.post("/orders/{order_id}/reject", response_model=FulfillmentOperationResponse) +@vendor_letzshop_router.post("/orders/{order_id}/reject", response_model=FulfillmentOperationResponse) def reject_order( order_id: int = Path(..., description="Order ID"), reject_request: FulfillmentRejectRequest | None = None, @@ -576,7 +581,7 @@ def reject_order( return FulfillmentOperationResponse(success=False, message=str(e)) -@router.post("/orders/{order_id}/tracking", response_model=FulfillmentOperationResponse) +@vendor_letzshop_router.post("/orders/{order_id}/tracking", response_model=FulfillmentOperationResponse) def set_order_tracking( order_id: int = Path(..., description="Order ID"), tracking_request: FulfillmentTrackingRequest = ..., @@ -638,7 +643,7 @@ def set_order_tracking( # ============================================================================ -@router.get("/logs", response_model=LetzshopSyncLogListResponse) +@vendor_letzshop_router.get("/logs", response_model=LetzshopSyncLogListResponse) def list_sync_logs( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), @@ -686,7 +691,7 @@ def list_sync_logs( # ============================================================================ -@router.get("/queue", response_model=FulfillmentQueueListResponse) +@vendor_letzshop_router.get("/queue", response_model=FulfillmentQueueListResponse) def list_fulfillment_queue( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), @@ -737,7 +742,7 @@ def list_fulfillment_queue( # ============================================================================ -@router.get("/export") +@vendor_letzshop_router.get("/export") def export_products_letzshop( language: str = Query( "en", description="Language for title/description (en, fr, de)" @@ -764,7 +769,7 @@ def export_products_letzshop( """ from fastapi.responses import Response - from app.services.letzshop_export_service import letzshop_export_service + from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service from app.services.vendor_service import vendor_service vendor_id = current_user.token_vendor_id diff --git a/app/api/v1/vendor/marketplace.py b/app/modules/marketplace/routes/api/vendor_marketplace.py similarity index 85% rename from app/api/v1/vendor/marketplace.py rename to app/modules/marketplace/routes/api/vendor_marketplace.py index adc1f44c..fb18ac79 100644 --- a/app/api/v1/vendor/marketplace.py +++ b/app/modules/marketplace/routes/api/vendor_marketplace.py @@ -1,9 +1,11 @@ -# app/api/v1/vendor/marketplace.py +# app/modules/marketplace/routes/api/vendor_marketplace.py """ Marketplace import endpoints for vendors. Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern). The get_current_vendor_api dependency guarantees token_vendor_id is present. + +All routes require module access control for the 'marketplace' module. """ import logging @@ -11,11 +13,10 @@ import logging from fastapi import APIRouter, BackgroundTasks, Depends, Query from sqlalchemy.orm import Session -from app.api.deps import get_current_vendor_api +from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db -from app.services.marketplace_import_job_service import marketplace_import_job_service +from app.modules.marketplace.services.marketplace_import_job_service import marketplace_import_job_service from app.services.vendor_service import vendor_service -from app.tasks.background_tasks import process_marketplace_import from middleware.decorators import rate_limit from models.schema.auth import UserContext from app.modules.marketplace.schemas import ( @@ -23,11 +24,14 @@ from app.modules.marketplace.schemas import ( MarketplaceImportJobResponse, ) -router = APIRouter(prefix="/marketplace") +vendor_marketplace_router = APIRouter( + prefix="/marketplace", + dependencies=[Depends(require_module_access("marketplace"))], +) logger = logging.getLogger(__name__) -@router.post("/import", response_model=MarketplaceImportJobResponse) +@vendor_marketplace_router.post("/import", response_model=MarketplaceImportJobResponse) @rate_limit(max_requests=10, window_seconds=3600) async def import_products_from_marketplace( request: MarketplaceImportJobRequest, @@ -93,7 +97,7 @@ async def import_products_from_marketplace( ) -@router.get("/imports/{job_id}", response_model=MarketplaceImportJobResponse) +@vendor_marketplace_router.get("/imports/{job_id}", response_model=MarketplaceImportJobResponse) def get_marketplace_import_status( job_id: int, current_user: UserContext = Depends(get_current_vendor_api), @@ -108,7 +112,7 @@ def get_marketplace_import_status( return marketplace_import_job_service.convert_to_response_model(job) -@router.get("/imports", response_model=list[MarketplaceImportJobResponse]) +@vendor_marketplace_router.get("/imports", response_model=list[MarketplaceImportJobResponse]) def get_marketplace_import_jobs( marketplace: str | None = Query(None, description="Filter by marketplace"), skip: int = Query(0, ge=0), diff --git a/app/api/v1/vendor/onboarding.py b/app/modules/marketplace/routes/api/vendor_onboarding.py similarity index 87% rename from app/api/v1/vendor/onboarding.py rename to app/modules/marketplace/routes/api/vendor_onboarding.py index 6fd73cf0..9bacbf49 100644 --- a/app/api/v1/vendor/onboarding.py +++ b/app/modules/marketplace/routes/api/vendor_onboarding.py @@ -1,4 +1,4 @@ -# app/api/v1/vendor/onboarding.py +# app/modules/marketplace/routes/api/vendor_onboarding.py """ Vendor onboarding API endpoints. @@ -8,6 +8,8 @@ Provides endpoints for the 4-step mandatory onboarding wizard: 3. Product & Order Import Configuration 4. Order Sync (historical import) +Migrated from app/api/v1/vendor/onboarding.py to marketplace module. + Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern). """ @@ -16,10 +18,9 @@ import logging from fastapi import APIRouter, BackgroundTasks, Depends from sqlalchemy.orm import Session -from app.api.deps import get_current_vendor_api +from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db from app.services.onboarding_service import OnboardingService -from app.tasks.letzshop_tasks import process_historical_import from models.schema.auth import UserContext from app.modules.marketplace.schemas import ( CompanyProfileRequest, @@ -38,7 +39,10 @@ from app.modules.marketplace.schemas import ( ProductImportConfigResponse, ) -router = APIRouter(prefix="/onboarding") +vendor_onboarding_router = APIRouter( + prefix="/onboarding", + dependencies=[Depends(require_module_access("marketplace"))], +) logger = logging.getLogger(__name__) @@ -47,7 +51,7 @@ logger = logging.getLogger(__name__) # ============================================================================= -@router.get("/status", response_model=OnboardingStatusResponse) +@vendor_onboarding_router.get("/status", response_model=OnboardingStatusResponse) def get_onboarding_status( current_user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db), @@ -67,7 +71,7 @@ def get_onboarding_status( # ============================================================================= -@router.get("/step/company-profile") +@vendor_onboarding_router.get("/step/company-profile") def get_company_profile( current_user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db), @@ -81,7 +85,7 @@ def get_company_profile( return service.get_company_profile_data(current_user.token_vendor_id) -@router.post("/step/company-profile", response_model=CompanyProfileResponse) +@vendor_onboarding_router.post("/step/company-profile", response_model=CompanyProfileResponse) def save_company_profile( request: CompanyProfileRequest, current_user: UserContext = Depends(get_current_vendor_api), @@ -115,7 +119,7 @@ def save_company_profile( # ============================================================================= -@router.post("/step/letzshop-api/test", response_model=LetzshopApiTestResponse) +@vendor_onboarding_router.post("/step/letzshop-api/test", response_model=LetzshopApiTestResponse) def test_letzshop_api( request: LetzshopApiTestRequest, current_user: UserContext = Depends(get_current_vendor_api), @@ -133,7 +137,7 @@ def test_letzshop_api( ) -@router.post("/step/letzshop-api", response_model=LetzshopApiConfigResponse) +@vendor_onboarding_router.post("/step/letzshop-api", response_model=LetzshopApiConfigResponse) def save_letzshop_api( request: LetzshopApiConfigRequest, current_user: UserContext = Depends(get_current_vendor_api), @@ -160,7 +164,7 @@ def save_letzshop_api( # ============================================================================= -@router.get("/step/product-import") +@vendor_onboarding_router.get("/step/product-import") def get_product_import_config( current_user: UserContext = Depends(get_current_vendor_api), db: Session = Depends(get_db), @@ -174,7 +178,7 @@ def get_product_import_config( return service.get_product_import_config(current_user.token_vendor_id) -@router.post("/step/product-import", response_model=ProductImportConfigResponse) +@vendor_onboarding_router.post("/step/product-import", response_model=ProductImportConfigResponse) def save_product_import_config( request: ProductImportConfigRequest, current_user: UserContext = Depends(get_current_vendor_api), @@ -204,7 +208,7 @@ def save_product_import_config( # ============================================================================= -@router.post("/step/order-sync/trigger", response_model=OrderSyncTriggerResponse) +@vendor_onboarding_router.post("/step/order-sync/trigger", response_model=OrderSyncTriggerResponse) def trigger_order_sync( request: OrderSyncTriggerRequest, background_tasks: BackgroundTasks, @@ -237,7 +241,7 @@ def trigger_order_sync( # Store Celery task ID if using Celery if celery_task_id: - from app.services.letzshop import LetzshopOrderService + from app.modules.marketplace.services.letzshop import LetzshopOrderService order_service = LetzshopOrderService(db) order_service.update_job_celery_task_id(result["job_id"], celery_task_id) @@ -247,7 +251,7 @@ def trigger_order_sync( return result -@router.get( +@vendor_onboarding_router.get( "/step/order-sync/progress/{job_id}", response_model=OrderSyncProgressResponse, ) @@ -268,7 +272,7 @@ def get_order_sync_progress( ) -@router.post("/step/order-sync/complete", response_model=OrderSyncCompleteResponse) +@vendor_onboarding_router.post("/step/order-sync/complete", response_model=OrderSyncCompleteResponse) def complete_order_sync( request: OrderSyncCompleteRequest, current_user: UserContext = Depends(get_current_vendor_api), diff --git a/app/modules/marketplace/services/letzshop/vendor_sync_service.py b/app/modules/marketplace/services/letzshop/vendor_sync_service.py index beb1a6ea..5b4fc0b6 100644 --- a/app/modules/marketplace/services/letzshop/vendor_sync_service.py +++ b/app/modules/marketplace/services/letzshop/vendor_sync_service.py @@ -14,7 +14,7 @@ from sqlalchemy import func from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session -from app.services.letzshop.client_service import LetzshopClient +from .client_service import LetzshopClient from app.modules.marketplace.models import LetzshopVendorCache logger = logging.getLogger(__name__) diff --git a/app/modules/marketplace/tasks/export_tasks.py b/app/modules/marketplace/tasks/export_tasks.py index 5fd45a88..2291758d 100644 --- a/app/modules/marketplace/tasks/export_tasks.py +++ b/app/modules/marketplace/tasks/export_tasks.py @@ -40,7 +40,7 @@ def export_vendor_products_to_folder( Returns: dict: Export results per language with file paths """ - from app.services.letzshop_export_service import letzshop_export_service + from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service languages = ["en", "fr", "de"] results = {} @@ -149,7 +149,7 @@ def export_marketplace_products( Returns: dict: Export result with file path """ - from app.services.letzshop_export_service import letzshop_export_service + from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service with self.get_db() as db: started_at = datetime.now(UTC) diff --git a/app/modules/marketplace/tasks/import_tasks.py b/app/modules/marketplace/tasks/import_tasks.py index ddbff461..e9d9e1e6 100644 --- a/app/modules/marketplace/tasks/import_tasks.py +++ b/app/modules/marketplace/tasks/import_tasks.py @@ -15,9 +15,11 @@ from typing import Callable from app.core.celery_config import celery_app from app.modules.marketplace.models import MarketplaceImportJob, LetzshopHistoricalImportJob from app.services.admin_notification_service import admin_notification_service -from app.services.letzshop import LetzshopClientError -from app.services.letzshop.credentials_service import LetzshopCredentialsService -from app.services.letzshop.order_service import LetzshopOrderService +from app.modules.marketplace.services.letzshop import ( + LetzshopClientError, + LetzshopCredentialsService, + LetzshopOrderService, +) from app.modules.task_base import ModuleTask from app.utils.csv_processor import CSVProcessor from models.database.vendor import Vendor diff --git a/app/modules/marketplace/tasks/sync_tasks.py b/app/modules/marketplace/tasks/sync_tasks.py index 4d3754a8..02c28d3b 100644 --- a/app/modules/marketplace/tasks/sync_tasks.py +++ b/app/modules/marketplace/tasks/sync_tasks.py @@ -11,7 +11,7 @@ from typing import Any from app.core.celery_config import celery_app from app.modules.task_base import ModuleTask from app.services.admin_notification_service import admin_notification_service -from app.services.letzshop.vendor_sync_service import LetzshopVendorSyncService +from app.modules.marketplace.services.letzshop import LetzshopVendorSyncService logger = logging.getLogger(__name__) diff --git a/app/modules/orders/routes/__init__.py b/app/modules/orders/routes/__init__.py index c8e48617..a1f74755 100644 --- a/app/modules/orders/routes/__init__.py +++ b/app/modules/orders/routes/__init__.py @@ -6,23 +6,31 @@ This module provides functions to register orders routes with module-based access control. NOTE: Routers are NOT auto-imported to avoid circular dependencies. -Import directly from admin.py or vendor.py as needed: - from app.modules.orders.routes.admin import admin_router - from app.modules.orders.routes.vendor import vendor_router +Import directly from api submodule as needed: + from app.modules.orders.routes.api import admin_router + from app.modules.orders.routes.api import vendor_router """ -# Routers are imported on-demand to avoid circular dependencies -# Do NOT add auto-imports here - -__all__ = ["admin_router", "vendor_router"] +__all__ = [ + "admin_router", + "admin_exceptions_router", + "vendor_router", + "vendor_exceptions_router", +] def __getattr__(name: str): """Lazy import routers to avoid circular dependencies.""" if name == "admin_router": - from app.modules.orders.routes.admin import admin_router + from app.modules.orders.routes.api import admin_router return admin_router + elif name == "admin_exceptions_router": + from app.modules.orders.routes.api import admin_exceptions_router + return admin_exceptions_router elif name == "vendor_router": - from app.modules.orders.routes.vendor import vendor_router + from app.modules.orders.routes.api import vendor_router return vendor_router + elif name == "vendor_exceptions_router": + from app.modules.orders.routes.api import vendor_exceptions_router + return vendor_exceptions_router raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/app/modules/orders/routes/admin.py b/app/modules/orders/routes/admin.py deleted file mode 100644 index b05618da..00000000 --- a/app/modules/orders/routes/admin.py +++ /dev/null @@ -1,40 +0,0 @@ -# app/modules/orders/routes/admin.py -""" -Orders module admin routes. - -This module wraps the existing admin orders routes and adds -module-based access control. Routes are re-exported from the -original location with the module access dependency. - -Includes: -- /orders/* - Order management -- /order-item-exceptions/* - Exception handling -""" - -from fastapi import APIRouter, Depends - -from app.api.deps import require_module_access - -# Import original routers (direct import to avoid circular dependency) -from app.api.v1.admin.orders import router as orders_original_router -from app.api.v1.admin.order_item_exceptions import router as exceptions_original_router - -# Create module-aware router for orders -admin_router = APIRouter( - prefix="/orders", - dependencies=[Depends(require_module_access("orders"))], -) - -# Re-export all routes from the original orders module -for route in orders_original_router.routes: - admin_router.routes.append(route) - -# Create separate router for order item exceptions -# This is included separately in the admin __init__.py -admin_exceptions_router = APIRouter( - prefix="/order-item-exceptions", - dependencies=[Depends(require_module_access("orders"))], -) - -for route in exceptions_original_router.routes: - admin_exceptions_router.routes.append(route) diff --git a/app/modules/orders/routes/vendor.py b/app/modules/orders/routes/vendor.py deleted file mode 100644 index 1d2aed89..00000000 --- a/app/modules/orders/routes/vendor.py +++ /dev/null @@ -1,39 +0,0 @@ -# app/modules/orders/routes/vendor.py -""" -Orders module vendor routes. - -This module wraps the existing vendor orders routes and adds -module-based access control. Routes are re-exported from the -original location with the module access dependency. - -Includes: -- /orders/* - Order management -- /order-item-exceptions/* - Exception handling -""" - -from fastapi import APIRouter, Depends - -from app.api.deps import require_module_access - -# Import original routers (direct import to avoid circular dependency) -from app.api.v1.vendor.orders import router as orders_original_router -from app.api.v1.vendor.order_item_exceptions import router as exceptions_original_router - -# Create module-aware router for orders -vendor_router = APIRouter( - prefix="/orders", - dependencies=[Depends(require_module_access("orders"))], -) - -# Re-export all routes from the original orders module -for route in orders_original_router.routes: - vendor_router.routes.append(route) - -# Create separate router for order item exceptions -vendor_exceptions_router = APIRouter( - prefix="/order-item-exceptions", - dependencies=[Depends(require_module_access("orders"))], -) - -for route in exceptions_original_router.routes: - vendor_exceptions_router.routes.append(route) diff --git a/app/services/letzshop/__init__.py b/app/services/letzshop/__init__.py deleted file mode 100644 index 8d9416e5..00000000 --- a/app/services/letzshop/__init__.py +++ /dev/null @@ -1,53 +0,0 @@ -# app/services/letzshop/__init__.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/marketplace/services/letzshop/ - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.marketplace.services.letzshop import LetzshopClient -""" - -from app.modules.marketplace.services.letzshop import ( - # Client - LetzshopClient, - LetzshopClientError, - LetzshopAuthError, - LetzshopAPIError, - LetzshopConnectionError, - # Credentials - LetzshopCredentialsService, - CredentialsError, - CredentialsNotFoundError, - # Order Service - LetzshopOrderService, - OrderNotFoundError, - VendorNotFoundError, - # Vendor Sync Service - LetzshopVendorSyncService, - get_vendor_sync_service, -) - -__all__ = [ - # Client - "LetzshopClient", - "LetzshopClientError", - "LetzshopAuthError", - "LetzshopAPIError", - "LetzshopConnectionError", - # Credentials - "LetzshopCredentialsService", - "CredentialsError", - "CredentialsNotFoundError", - # Order Service - "LetzshopOrderService", - "OrderNotFoundError", - "VendorNotFoundError", - # Vendor Sync Service - "LetzshopVendorSyncService", - "get_vendor_sync_service", -] diff --git a/app/services/letzshop/client_service.py b/app/services/letzshop/client_service.py deleted file mode 100644 index d58957aa..00000000 --- a/app/services/letzshop/client_service.py +++ /dev/null @@ -1,1015 +0,0 @@ -# app/services/letzshop/client_service.py -""" -GraphQL client for Letzshop marketplace API. - -Handles authentication, request formatting, and error handling -for all Letzshop API operations. -""" - -import logging -import time -from typing import Any, Callable - -import requests - -logger = logging.getLogger(__name__) - -# Default API endpoint -DEFAULT_ENDPOINT = "https://letzshop.lu/graphql" - - -class LetzshopClientError(Exception): - """Base exception for Letzshop client errors.""" - - def __init__(self, message: str, response_data: dict | None = None): - super().__init__(message) - self.message = message - self.response_data = response_data - - -class LetzshopAuthError(LetzshopClientError): - """Raised when authentication fails.""" - - -class LetzshopAPIError(LetzshopClientError): - """Raised when the API returns an error response.""" - - -class LetzshopConnectionError(LetzshopClientError): - """Raised when connection to the API fails.""" - - -# ============================================================================ -# GraphQL Queries -# ============================================================================ - -QUERY_SHIPMENTS_UNCONFIRMED = """ -query { - shipments(state: unconfirmed) { - nodes { - id - number - state - order { - id - number - email - total - completedAt - locale - shipAddress { - firstName - lastName - company - streetName - streetNumber - city - zipCode - phone - country { - name { en fr de } - iso - } - } - billAddress { - firstName - lastName - company - streetName - streetNumber - city - zipCode - phone - country { - name { en fr de } - iso - } - } - } - inventoryUnits { - id - state - variant { - id - sku - mpn - price - tradeId { - number - parser - } - product { - name { - en - fr - de - } - _brand { - ... on Brand { - name - } - } - } - } - } - tracking { - code - provider - } - data { - __typename - } - } - } -} -""" - -QUERY_SHIPMENTS_CONFIRMED = """ -query { - shipments(state: confirmed) { - nodes { - id - number - state - order { - id - number - email - total - completedAt - locale - shipAddress { - firstName - lastName - company - streetName - streetNumber - city - zipCode - phone - country { - name { en fr de } - iso - } - } - billAddress { - firstName - lastName - company - streetName - streetNumber - city - zipCode - phone - country { - name { en fr de } - iso - } - } - } - inventoryUnits { - id - state - variant { - id - sku - mpn - price - tradeId { - number - parser - } - product { - name { - en - fr - de - } - _brand { - ... on Brand { - name - } - } - } - } - } - tracking { - code - provider - } - data { - __typename - } - } - } -} -""" - -QUERY_SHIPMENT_BY_ID = """ -query GetShipment($id: ID!) { - node(id: $id) { - ... on Shipment { - id - number - state - order { - id - number - email - total - completedAt - locale - shipAddress { - firstName - lastName - company - streetName - streetNumber - city - zipCode - phone - country { - name { en fr de } - iso - } - } - billAddress { - firstName - lastName - company - streetName - streetNumber - city - zipCode - phone - country { - name { en fr de } - iso - } - } - } - inventoryUnits { - id - state - variant { - id - sku - mpn - price - tradeId { - number - parser - } - product { - name { - en - fr - de - } - _brand { - ... on Brand { - name - } - } - } - } - } - tracking { - code - provider - } - data { - __typename - } - } - } -} -""" - -# ============================================================================ -# Paginated Queries (for historical import) -# ============================================================================ - -# Note: Using string formatting for state since Letzshop has issues with enum variables -# Note: tracking field removed - causes 'demodulize' server error on some shipments -QUERY_SHIPMENTS_PAGINATED_TEMPLATE = """ -query GetShipmentsPaginated($first: Int!, $after: String) {{ - shipments(state: {state}, first: $first, after: $after) {{ - pageInfo {{ - hasNextPage - endCursor - }} - nodes {{ - id - number - state - order {{ - id - number - email - total - completedAt - locale - shipAddress {{ - firstName - lastName - company - streetName - streetNumber - city - zipCode - phone - country {{ - iso - }} - }} - billAddress {{ - firstName - lastName - company - streetName - streetNumber - city - zipCode - phone - country {{ - iso - }} - }} - }} - inventoryUnits {{ - id - state - variant {{ - id - sku - mpn - price - tradeId {{ - number - parser - }} - product {{ - name {{ - en - fr - de - }} - }} - }} - }} - data {{ - __typename - }} - }} - }} -}} -""" - -# ============================================================================ -# GraphQL Queries - Vendor Directory (Public) -# ============================================================================ - -QUERY_VENDORS_PAGINATED = """ -query GetVendorsPaginated($first: Int!, $after: String) { - vendors(first: $first, after: $after) { - pageInfo { - hasNextPage - endCursor - } - totalCount - nodes { - id - slug - name - active - companyName - legalName - email - phone - fax - homepage - description { en fr de } - location { - street - number - city - zipcode - country { iso } - } - lat - lng - vendorCategories { name { en fr de } } - backgroundImage { url } - socialMediaLinks { url } - openingHours { en fr de } - representative - representativeTitle - } - } -} -""" - -QUERY_VENDOR_BY_SLUG = """ -query GetVendorBySlug($slug: String!) { - vendor(slug: $slug) { - id - slug - name - active - companyName - legalName - email - phone - fax - homepage - description { en fr de } - location { - street - number - city - zipcode - country { iso } - } - lat - lng - vendorCategories { name { en fr de } } - backgroundImage { url } - socialMediaLinks { url } - openingHours { en fr de } - representative - representativeTitle - } -} -""" - -# ============================================================================ -# GraphQL Mutations -# ============================================================================ - -MUTATION_CONFIRM_INVENTORY_UNITS = """ -mutation ConfirmInventoryUnits($input: ConfirmInventoryUnitsInput!) { - confirmInventoryUnits(input: $input) { - inventoryUnits { - id - state - } - errors { - id - code - message - } - } -} -""" - -MUTATION_REJECT_INVENTORY_UNITS = """ -mutation RejectInventoryUnits($input: RejectInventoryUnitsInput!) { - returnInventoryUnits(input: $input) { - inventoryUnits { - id - state - } - errors { - id - code - message - } - } -} -""" - -MUTATION_SET_SHIPMENT_TRACKING = """ -mutation SetShipmentTracking($input: SetShipmentTrackingInput!) { - setShipmentTracking(input: $input) { - shipment { - id - tracking { - code - provider - } - } - errors { - code - message - } - } -} -""" - - -class LetzshopClient: - """ - GraphQL client for Letzshop marketplace API. - - Usage: - client = LetzshopClient(api_key="your-api-key") - shipments = client.get_shipments(state="unconfirmed") - """ - - def __init__( - self, - api_key: str, - endpoint: str = DEFAULT_ENDPOINT, - timeout: int = 30, - ): - """ - Initialize the Letzshop client. - - Args: - api_key: The Letzshop API key (Bearer token). - endpoint: The GraphQL endpoint URL. - timeout: Request timeout in seconds. - """ - self.api_key = api_key - self.endpoint = endpoint - self.timeout = timeout - self._session: requests.Session | None = None - - @property - def session(self) -> requests.Session: - """Get or create a requests session.""" - if self._session is None: - self._session = requests.Session() - self._session.headers.update( - { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - } - ) - return self._session - - def close(self) -> None: - """Close the HTTP session.""" - if self._session is not None: - self._session.close() - self._session = None - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.close() - return False - - def _execute_public( - self, - query: str, - variables: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """ - Execute a GraphQL query without authentication (for public queries). - - Args: - query: The GraphQL query string. - variables: Optional variables for the query. - - Returns: - The response data from the API. - - Raises: - LetzshopAPIError: If the API returns an error. - LetzshopConnectionError: If the request fails. - """ - payload = {"query": query} - if variables: - payload["variables"] = variables - - logger.debug(f"Executing public GraphQL request to {self.endpoint}") - - try: - # Use a simple request without Authorization header - response = requests.post( - self.endpoint, - json=payload, - headers={"Content-Type": "application/json"}, - timeout=self.timeout, - ) - except requests.exceptions.Timeout as e: - raise LetzshopConnectionError(f"Request timed out: {e}") from e - except requests.exceptions.ConnectionError as e: - raise LetzshopConnectionError(f"Connection failed: {e}") from e - except requests.exceptions.RequestException as e: - raise LetzshopConnectionError(f"Request failed: {e}") from e - - # Handle HTTP-level errors - if response.status_code >= 500: - raise LetzshopAPIError( - f"Letzshop server error (HTTP {response.status_code})", - response_data={"status_code": response.status_code}, - ) - - # Parse JSON response - try: - data = response.json() - except ValueError as e: - raise LetzshopAPIError( - f"Invalid JSON response: {response.text[:200]}" - ) from e - - logger.debug(f"GraphQL response: {data}") - - # Handle GraphQL errors - if "errors" in data: - errors = data["errors"] - error_messages = [e.get("message", str(e)) for e in errors] - raise LetzshopAPIError( - f"GraphQL errors: {'; '.join(error_messages)}", - response_data=data, - ) - - return data.get("data", {}) - - def _execute( - self, - query: str, - variables: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """ - Execute a GraphQL query or mutation. - - Args: - query: The GraphQL query or mutation string. - variables: Optional variables for the query. - - Returns: - The response data from the API. - - Raises: - LetzshopAuthError: If authentication fails. - LetzshopAPIError: If the API returns an error. - LetzshopConnectionError: If the request fails. - """ - payload = {"query": query} - if variables: - payload["variables"] = variables - - logger.debug(f"Executing GraphQL request to {self.endpoint}") - - try: - response = self.session.post( - self.endpoint, - json=payload, - timeout=self.timeout, - ) - except requests.exceptions.Timeout as e: - raise LetzshopConnectionError(f"Request timed out: {e}") from e - except requests.exceptions.ConnectionError as e: - raise LetzshopConnectionError(f"Connection failed: {e}") from e - except requests.exceptions.RequestException as e: - raise LetzshopConnectionError(f"Request failed: {e}") from e - - # Handle HTTP-level errors - if response.status_code == 401: - raise LetzshopAuthError( - "Authentication failed. Please check your API key.", - response_data={"status_code": 401}, - ) - if response.status_code == 403: - raise LetzshopAuthError( - "Access forbidden. Your API key may not have the required permissions.", - response_data={"status_code": 403}, - ) - if response.status_code >= 500: - raise LetzshopAPIError( - f"Letzshop server error (HTTP {response.status_code})", - response_data={"status_code": response.status_code}, - ) - - # Parse JSON response - try: - data = response.json() - except ValueError as e: - raise LetzshopAPIError( - f"Invalid JSON response: {response.text[:200]}" - ) from e - - logger.debug(f"GraphQL response: {data}") - - # Check for GraphQL errors - if "errors" in data and data["errors"]: - error_messages = [ - err.get("message", "Unknown error") for err in data["errors"] - ] - logger.warning(f"GraphQL errors received: {data['errors']}") - raise LetzshopAPIError( - f"GraphQL errors: {'; '.join(error_messages)}", - response_data=data, - ) - - return data.get("data", {}) - - # ======================================================================== - # Connection Testing - # ======================================================================== - - def test_connection(self) -> tuple[bool, float, str | None]: - """ - Test the connection to Letzshop API. - - Returns: - Tuple of (success, response_time_ms, error_message). - """ - test_query = """ - query TestConnection { - __typename - } - """ - - start_time = time.time() - - try: - self._execute(test_query) - elapsed_ms = (time.time() - start_time) * 1000 - return True, elapsed_ms, None - except LetzshopClientError as e: - elapsed_ms = (time.time() - start_time) * 1000 - return False, elapsed_ms, str(e) - - # ======================================================================== - # Shipment Queries - # ======================================================================== - - def get_shipments( - self, - state: str = "unconfirmed", - ) -> list[dict[str, Any]]: - """ - Get shipments from Letzshop. - - Args: - state: State filter ("unconfirmed" or "confirmed"). - - Returns: - List of shipment data dictionaries. - """ - # Use pre-built queries with inline state values - # (Letzshop's GraphQL has issues with enum variables) - if state == "confirmed": - query = QUERY_SHIPMENTS_CONFIRMED - else: - query = QUERY_SHIPMENTS_UNCONFIRMED - - logger.debug(f"Fetching shipments with state: {state}") - data = self._execute(query) - logger.debug(f"Shipments response data keys: {data.keys() if data else 'None'}") - shipments_data = data.get("shipments", {}) - nodes = shipments_data.get("nodes", []) - logger.info(f"Got {len(nodes)} {state} shipments from Letzshop API") - return nodes - - def get_unconfirmed_shipments(self) -> list[dict[str, Any]]: - """Get all unconfirmed shipments.""" - return self.get_shipments(state="unconfirmed") - - def get_shipment_by_id(self, shipment_id: str) -> dict[str, Any] | None: - """ - Get a single shipment by its ID. - - Args: - shipment_id: The Letzshop shipment ID. - - Returns: - Shipment data or None if not found. - """ - data = self._execute(QUERY_SHIPMENT_BY_ID, {"id": shipment_id}) - return data.get("node") - - def get_all_shipments_paginated( - self, - state: str = "confirmed", - page_size: int = 50, - max_pages: int | None = None, - progress_callback: Callable[[int, int], None] | None = None, - ) -> list[dict[str, Any]]: - """ - Fetch all shipments with pagination support. - - Args: - state: State filter ("unconfirmed" or "confirmed"). - page_size: Number of shipments per page (default 50). - max_pages: Maximum number of pages to fetch (None = all). - progress_callback: Optional callback(page, total_fetched) for progress updates. - - Returns: - List of all shipment data dictionaries. - """ - query = QUERY_SHIPMENTS_PAGINATED_TEMPLATE.format(state=state) - all_shipments = [] - cursor = None - page = 0 - - while True: - page += 1 - variables = {"first": page_size} - if cursor: - variables["after"] = cursor - - logger.info(f"Fetching {state} shipments page {page} (cursor: {cursor})") - - try: - data = self._execute(query, variables) - except LetzshopAPIError as e: - # Log error but return what we have so far - logger.error(f"Error fetching page {page}: {e}") - break - - shipments_data = data.get("shipments", {}) - nodes = shipments_data.get("nodes", []) - page_info = shipments_data.get("pageInfo", {}) - - all_shipments.extend(nodes) - - if progress_callback: - progress_callback(page, len(all_shipments)) - - logger.info(f"Page {page}: fetched {len(nodes)} shipments, total: {len(all_shipments)}") - - # Check if there are more pages - if not page_info.get("hasNextPage"): - logger.info(f"Reached last page. Total shipments: {len(all_shipments)}") - break - - cursor = page_info.get("endCursor") - - # Check max pages limit - if max_pages and page >= max_pages: - logger.info(f"Reached max pages limit ({max_pages}). Total shipments: {len(all_shipments)}") - break - - return all_shipments - - # ======================================================================== - # Fulfillment Mutations - # ======================================================================== - - def confirm_inventory_units( - self, - inventory_unit_ids: list[str], - ) -> dict[str, Any]: - """ - Confirm inventory units for fulfillment. - - Args: - inventory_unit_ids: List of inventory unit IDs to confirm. - - Returns: - Response data including confirmed units and any errors. - """ - variables = { - "input": { - "inventoryUnitIds": inventory_unit_ids, - } - } - - data = self._execute(MUTATION_CONFIRM_INVENTORY_UNITS, variables) - return data.get("confirmInventoryUnits", {}) - - def reject_inventory_units( - self, - inventory_unit_ids: list[str], - ) -> dict[str, Any]: - """ - Reject/return inventory units. - - Args: - inventory_unit_ids: List of inventory unit IDs to reject. - - Returns: - Response data including rejected units and any errors. - """ - variables = { - "input": { - "inventoryUnitIds": inventory_unit_ids, - } - } - - data = self._execute(MUTATION_REJECT_INVENTORY_UNITS, variables) - return data.get("returnInventoryUnits", {}) - - def set_shipment_tracking( - self, - shipment_id: str, - tracking_code: str, - tracking_provider: str, - ) -> dict[str, Any]: - """ - Set tracking information for a shipment. - - Args: - shipment_id: The Letzshop shipment ID. - tracking_code: The tracking number. - tracking_provider: The carrier code (e.g., "dhl", "ups"). - - Returns: - Response data including updated shipment and any errors. - """ - variables = { - "input": { - "shipmentId": shipment_id, - "tracking": { - "code": tracking_code, - "provider": tracking_provider, - }, - } - } - - data = self._execute(MUTATION_SET_SHIPMENT_TRACKING, variables) - return data.get("setShipmentTracking", {}) - - # ======================================================================== - # Vendor Directory Queries (Public - No Auth Required) - # ======================================================================== - - def get_all_vendors_paginated( - self, - page_size: int = 50, - max_pages: int | None = None, - progress_callback: Callable[[int, int, int], None] | None = None, - ) -> list[dict[str, Any]]: - """ - Fetch all vendors from Letzshop marketplace directory. - - This uses the public GraphQL API (no authentication required). - - Args: - page_size: Number of vendors per page (default 50). - max_pages: Maximum number of pages to fetch (None = all). - progress_callback: Optional callback(page, total_fetched, total_count) - for progress updates. - - Returns: - List of all vendor data dictionaries. - """ - all_vendors = [] - cursor = None - page = 0 - total_count = None - - while True: - page += 1 - variables = {"first": page_size} - if cursor: - variables["after"] = cursor - - logger.info(f"Fetching vendors page {page} (cursor: {cursor})") - - try: - # Use public endpoint (no authentication required) - data = self._execute_public(QUERY_VENDORS_PAGINATED, variables) - except LetzshopAPIError as e: - logger.error(f"Error fetching vendors page {page}: {e}") - break - - vendors_data = data.get("vendors", {}) - nodes = vendors_data.get("nodes", []) - page_info = vendors_data.get("pageInfo", {}) - - if total_count is None: - total_count = vendors_data.get("totalCount", 0) - logger.info(f"Total vendors in Letzshop: {total_count}") - - all_vendors.extend(nodes) - - if progress_callback: - progress_callback(page, len(all_vendors), total_count) - - logger.info( - f"Page {page}: fetched {len(nodes)} vendors, " - f"total: {len(all_vendors)}/{total_count}" - ) - - # Check if there are more pages - if not page_info.get("hasNextPage"): - logger.info(f"Reached last page. Total vendors: {len(all_vendors)}") - break - - cursor = page_info.get("endCursor") - - # Check max pages limit - if max_pages and page >= max_pages: - logger.info( - f"Reached max pages limit ({max_pages}). " - f"Total vendors: {len(all_vendors)}" - ) - break - - return all_vendors - - def get_vendor_by_slug(self, slug: str) -> dict[str, Any] | None: - """ - Get a single vendor by their URL slug. - - Args: - slug: The vendor's URL slug (e.g., "nicks-diecast-corner"). - - Returns: - Vendor data dictionary or None if not found. - """ - try: - # Use public endpoint (no authentication required) - data = self._execute_public(QUERY_VENDOR_BY_SLUG, {"slug": slug}) - return data.get("vendor") - except LetzshopAPIError as e: - logger.warning(f"Vendor not found with slug '{slug}': {e}") - return None diff --git a/app/services/letzshop/credentials_service.py b/app/services/letzshop/credentials_service.py deleted file mode 100644 index 11858ef6..00000000 --- a/app/services/letzshop/credentials_service.py +++ /dev/null @@ -1,400 +0,0 @@ -# app/services/letzshop/credentials_service.py -""" -Letzshop credentials management service. - -Handles secure storage and retrieval of per-vendor Letzshop API credentials. -""" - -import logging -from datetime import UTC, datetime - -from sqlalchemy.orm import Session - -from app.utils.encryption import decrypt_value, encrypt_value, mask_api_key -from app.modules.marketplace.models import VendorLetzshopCredentials - -from .client_service import LetzshopClient - -logger = logging.getLogger(__name__) - -# Default Letzshop GraphQL endpoint -DEFAULT_ENDPOINT = "https://letzshop.lu/graphql" - - -class CredentialsError(Exception): - """Base exception for credentials errors.""" - - -class CredentialsNotFoundError(CredentialsError): - """Raised when credentials are not found for a vendor.""" - - -class LetzshopCredentialsService: - """ - Service for managing Letzshop API credentials. - - Provides secure storage and retrieval of encrypted API keys, - connection testing, and sync status updates. - """ - - def __init__(self, db: Session): - """ - Initialize the credentials service. - - Args: - db: SQLAlchemy database session. - """ - self.db = db - - # ======================================================================== - # CRUD Operations - # ======================================================================== - - def get_credentials(self, vendor_id: int) -> VendorLetzshopCredentials | None: - """ - Get Letzshop credentials for a vendor. - - Args: - vendor_id: The vendor ID. - - Returns: - VendorLetzshopCredentials or None if not found. - """ - return ( - self.db.query(VendorLetzshopCredentials) - .filter(VendorLetzshopCredentials.vendor_id == vendor_id) - .first() - ) - - def get_credentials_or_raise(self, vendor_id: int) -> VendorLetzshopCredentials: - """ - Get Letzshop credentials for a vendor or raise an exception. - - Args: - vendor_id: The vendor ID. - - Returns: - VendorLetzshopCredentials. - - Raises: - CredentialsNotFoundError: If credentials are not found. - """ - credentials = self.get_credentials(vendor_id) - if credentials is None: - raise CredentialsNotFoundError( - f"Letzshop credentials not found for vendor {vendor_id}" - ) - return credentials - - def create_credentials( - self, - vendor_id: int, - api_key: str, - api_endpoint: str | None = None, - auto_sync_enabled: bool = False, - sync_interval_minutes: int = 15, - ) -> VendorLetzshopCredentials: - """ - Create Letzshop credentials for a vendor. - - Args: - vendor_id: The vendor ID. - api_key: The Letzshop API key (will be encrypted). - api_endpoint: Custom API endpoint (optional). - auto_sync_enabled: Whether to enable automatic sync. - sync_interval_minutes: Sync interval in minutes. - - Returns: - Created VendorLetzshopCredentials. - """ - # Encrypt the API key - encrypted_key = encrypt_value(api_key) - - credentials = VendorLetzshopCredentials( - vendor_id=vendor_id, - api_key_encrypted=encrypted_key, - api_endpoint=api_endpoint or DEFAULT_ENDPOINT, - auto_sync_enabled=auto_sync_enabled, - sync_interval_minutes=sync_interval_minutes, - ) - - self.db.add(credentials) - self.db.flush() - - logger.info(f"Created Letzshop credentials for vendor {vendor_id}") - return credentials - - def update_credentials( - self, - vendor_id: int, - api_key: str | None = None, - api_endpoint: str | None = None, - auto_sync_enabled: bool | None = None, - sync_interval_minutes: int | None = None, - ) -> VendorLetzshopCredentials: - """ - Update Letzshop credentials for a vendor. - - Args: - vendor_id: The vendor ID. - api_key: New API key (optional, will be encrypted if provided). - api_endpoint: New API endpoint (optional). - auto_sync_enabled: New auto-sync setting (optional). - sync_interval_minutes: New sync interval (optional). - - Returns: - Updated VendorLetzshopCredentials. - - Raises: - CredentialsNotFoundError: If credentials are not found. - """ - credentials = self.get_credentials_or_raise(vendor_id) - - if api_key is not None: - credentials.api_key_encrypted = encrypt_value(api_key) - if api_endpoint is not None: - credentials.api_endpoint = api_endpoint - if auto_sync_enabled is not None: - credentials.auto_sync_enabled = auto_sync_enabled - if sync_interval_minutes is not None: - credentials.sync_interval_minutes = sync_interval_minutes - - self.db.flush() - - logger.info(f"Updated Letzshop credentials for vendor {vendor_id}") - return credentials - - def delete_credentials(self, vendor_id: int) -> bool: - """ - Delete Letzshop credentials for a vendor. - - Args: - vendor_id: The vendor ID. - - Returns: - True if deleted, False if not found. - """ - credentials = self.get_credentials(vendor_id) - if credentials is None: - return False - - self.db.delete(credentials) - self.db.flush() - - logger.info(f"Deleted Letzshop credentials for vendor {vendor_id}") - return True - - def upsert_credentials( - self, - vendor_id: int, - api_key: str, - api_endpoint: str | None = None, - auto_sync_enabled: bool = False, - sync_interval_minutes: int = 15, - ) -> VendorLetzshopCredentials: - """ - Create or update Letzshop credentials for a vendor. - - Args: - vendor_id: The vendor ID. - api_key: The Letzshop API key (will be encrypted). - api_endpoint: Custom API endpoint (optional). - auto_sync_enabled: Whether to enable automatic sync. - sync_interval_minutes: Sync interval in minutes. - - Returns: - Created or updated VendorLetzshopCredentials. - """ - existing = self.get_credentials(vendor_id) - - if existing: - return self.update_credentials( - vendor_id=vendor_id, - api_key=api_key, - api_endpoint=api_endpoint, - auto_sync_enabled=auto_sync_enabled, - sync_interval_minutes=sync_interval_minutes, - ) - - return self.create_credentials( - vendor_id=vendor_id, - api_key=api_key, - api_endpoint=api_endpoint, - auto_sync_enabled=auto_sync_enabled, - sync_interval_minutes=sync_interval_minutes, - ) - - # ======================================================================== - # Key Decryption and Client Creation - # ======================================================================== - - def get_decrypted_api_key(self, vendor_id: int) -> str: - """ - Get the decrypted API key for a vendor. - - Args: - vendor_id: The vendor ID. - - Returns: - Decrypted API key. - - Raises: - CredentialsNotFoundError: If credentials are not found. - """ - credentials = self.get_credentials_or_raise(vendor_id) - return decrypt_value(credentials.api_key_encrypted) - - def get_masked_api_key(self, vendor_id: int) -> str: - """ - Get a masked version of the API key for display. - - Args: - vendor_id: The vendor ID. - - Returns: - Masked API key (e.g., "sk-a***************"). - - Raises: - CredentialsNotFoundError: If credentials are not found. - """ - api_key = self.get_decrypted_api_key(vendor_id) - return mask_api_key(api_key) - - def create_client(self, vendor_id: int) -> LetzshopClient: - """ - Create a Letzshop client for a vendor. - - Args: - vendor_id: The vendor ID. - - Returns: - Configured LetzshopClient. - - Raises: - CredentialsNotFoundError: If credentials are not found. - """ - credentials = self.get_credentials_or_raise(vendor_id) - api_key = decrypt_value(credentials.api_key_encrypted) - - return LetzshopClient( - api_key=api_key, - endpoint=credentials.api_endpoint, - ) - - # ======================================================================== - # Connection Testing - # ======================================================================== - - def test_connection(self, vendor_id: int) -> tuple[bool, float | None, str | None]: - """ - Test the connection for a vendor's credentials. - - Args: - vendor_id: The vendor ID. - - Returns: - Tuple of (success, response_time_ms, error_message). - """ - try: - with self.create_client(vendor_id) as client: - return client.test_connection() - except CredentialsNotFoundError: - return False, None, "Letzshop credentials not configured" - except Exception as e: - logger.error(f"Connection test failed for vendor {vendor_id}: {e}") - return False, None, str(e) - - def test_api_key( - self, - api_key: str, - api_endpoint: str | None = None, - ) -> tuple[bool, float | None, str | None]: - """ - Test an API key without saving it. - - Args: - api_key: The API key to test. - api_endpoint: Optional custom endpoint. - - Returns: - Tuple of (success, response_time_ms, error_message). - """ - try: - with LetzshopClient( - api_key=api_key, - endpoint=api_endpoint or DEFAULT_ENDPOINT, - ) as client: - return client.test_connection() - except Exception as e: - logger.error(f"API key test failed: {e}") - return False, None, str(e) - - # ======================================================================== - # Sync Status Updates - # ======================================================================== - - def update_sync_status( - self, - vendor_id: int, - status: str, - error: str | None = None, - ) -> VendorLetzshopCredentials | None: - """ - Update the last sync status for a vendor. - - Args: - vendor_id: The vendor ID. - status: Sync status (success, failed, partial). - error: Error message if sync failed. - - Returns: - Updated credentials or None if not found. - """ - credentials = self.get_credentials(vendor_id) - if credentials is None: - return None - - credentials.last_sync_at = datetime.now(UTC) - credentials.last_sync_status = status - credentials.last_sync_error = error - - self.db.flush() - - return credentials - - # ======================================================================== - # Status Helpers - # ======================================================================== - - def is_configured(self, vendor_id: int) -> bool: - """Check if Letzshop is configured for a vendor.""" - return self.get_credentials(vendor_id) is not None - - def get_status(self, vendor_id: int) -> dict: - """ - Get the Letzshop integration status for a vendor. - - Args: - vendor_id: The vendor ID. - - Returns: - Status dictionary with configuration and sync info. - """ - credentials = self.get_credentials(vendor_id) - - if credentials is None: - return { - "is_configured": False, - "is_connected": False, - "last_sync_at": None, - "last_sync_status": None, - "auto_sync_enabled": False, - } - - return { - "is_configured": True, - "is_connected": credentials.last_sync_status == "success", - "last_sync_at": credentials.last_sync_at, - "last_sync_status": credentials.last_sync_status, - "auto_sync_enabled": credentials.auto_sync_enabled, - } diff --git a/app/services/letzshop/order_service.py b/app/services/letzshop/order_service.py deleted file mode 100644 index dd88973d..00000000 --- a/app/services/letzshop/order_service.py +++ /dev/null @@ -1,1136 +0,0 @@ -# app/services/letzshop/order_service.py -""" -Letzshop order service for handling order-related database operations. - -This service handles Letzshop-specific order operations while using the -unified Order model. All Letzshop orders are stored in the `orders` table -with `channel='letzshop'`. -""" - -import logging -from datetime import UTC, datetime -from typing import Any, Callable - -from sqlalchemy import String, and_, func, or_ -from sqlalchemy.orm import Session - -from app.services.order_service import order_service as unified_order_service -from app.services.subscription_service import subscription_service -from app.modules.marketplace.models import ( - LetzshopFulfillmentQueue, - LetzshopHistoricalImportJob, - LetzshopSyncLog, - MarketplaceImportJob, - VendorLetzshopCredentials, -) -from app.modules.orders.models import Order, OrderItem -from app.modules.catalog.models import Product -from models.database.vendor import Vendor - -logger = logging.getLogger(__name__) - - -class VendorNotFoundError(Exception): - """Raised when a vendor is not found.""" - - -class OrderNotFoundError(Exception): - """Raised when an order is not found.""" - - -class LetzshopOrderService: - """Service for Letzshop order database operations using unified Order model.""" - - def __init__(self, db: Session): - self.db = db - - # ========================================================================= - # Vendor Operations - # ========================================================================= - - def get_vendor(self, vendor_id: int) -> Vendor | None: - """Get vendor by ID.""" - return self.db.query(Vendor).filter(Vendor.id == vendor_id).first() - - def get_vendor_or_raise(self, vendor_id: int) -> Vendor: - """Get vendor by ID or raise VendorNotFoundError.""" - vendor = self.get_vendor(vendor_id) - if vendor is None: - raise VendorNotFoundError(f"Vendor with ID {vendor_id} not found") - return vendor - - def list_vendors_with_letzshop_status( - self, - skip: int = 0, - limit: int = 100, - configured_only: bool = False, - ) -> tuple[list[dict[str, Any]], int]: - """ - List vendors with their Letzshop integration status. - - Returns a tuple of (vendor_overviews, total_count). - """ - query = self.db.query(Vendor).filter(Vendor.is_active == True) # noqa: E712 - - if configured_only: - query = query.join( - VendorLetzshopCredentials, - Vendor.id == VendorLetzshopCredentials.vendor_id, - ) - - total = query.count() - vendors = query.order_by(Vendor.name).offset(skip).limit(limit).all() - - vendor_overviews = [] - for vendor in vendors: - credentials = ( - self.db.query(VendorLetzshopCredentials) - .filter(VendorLetzshopCredentials.vendor_id == vendor.id) - .first() - ) - - # Count Letzshop orders from unified orders table - pending_orders = 0 - total_orders = 0 - if credentials: - pending_orders = ( - self.db.query(func.count(Order.id)) - .filter( - Order.vendor_id == vendor.id, - Order.channel == "letzshop", - Order.status == "pending", - ) - .scalar() - or 0 - ) - total_orders = ( - self.db.query(func.count(Order.id)) - .filter( - Order.vendor_id == vendor.id, - Order.channel == "letzshop", - ) - .scalar() - or 0 - ) - - vendor_overviews.append( - { - "vendor_id": vendor.id, - "vendor_name": vendor.name, - "vendor_code": vendor.vendor_code, - "is_configured": credentials is not None, - "auto_sync_enabled": credentials.auto_sync_enabled - if credentials - else False, - "last_sync_at": credentials.last_sync_at if credentials else None, - "last_sync_status": credentials.last_sync_status - if credentials - else None, - "pending_orders": pending_orders, - "total_orders": total_orders, - } - ) - - return vendor_overviews, total - - # ========================================================================= - # Order Operations (using unified Order model) - # ========================================================================= - - def get_order(self, vendor_id: int, order_id: int) -> Order | None: - """Get a Letzshop order by ID for a specific vendor.""" - return ( - self.db.query(Order) - .filter( - Order.id == order_id, - Order.vendor_id == vendor_id, - Order.channel == "letzshop", - ) - .first() - ) - - def get_order_or_raise(self, vendor_id: int, order_id: int) -> Order: - """Get a Letzshop order or raise OrderNotFoundError.""" - order = self.get_order(vendor_id, order_id) - if order is None: - raise OrderNotFoundError(f"Order {order_id} not found") - return order - - def get_order_by_shipment_id( - self, vendor_id: int, shipment_id: str - ) -> Order | None: - """Get a Letzshop order by external shipment ID.""" - return ( - self.db.query(Order) - .filter( - Order.vendor_id == vendor_id, - Order.channel == "letzshop", - Order.external_shipment_id == shipment_id, - ) - .first() - ) - - def get_order_by_id(self, order_id: int) -> Order | None: - """Get a Letzshop order by its database ID.""" - return ( - self.db.query(Order) - .filter( - Order.id == order_id, - Order.channel == "letzshop", - ) - .first() - ) - - def list_orders( - self, - vendor_id: int | None = None, - skip: int = 0, - limit: int = 50, - status: str | None = None, - has_declined_items: bool | None = None, - search: str | None = None, - ) -> tuple[list[Order], int]: - """ - List Letzshop orders for a vendor (or all vendors). - - Args: - vendor_id: Vendor ID to filter by. If None, returns all vendors. - skip: Number of records to skip. - limit: Maximum number of records to return. - status: Filter by order status (pending, processing, shipped, etc.) - has_declined_items: If True, only return orders with declined items. - search: Search by order number, customer name, or email. - - Returns a tuple of (orders, total_count). - """ - query = self.db.query(Order).filter( - Order.channel == "letzshop", - ) - - # Filter by vendor if specified - if vendor_id is not None: - query = query.filter(Order.vendor_id == vendor_id) - - if status: - query = query.filter(Order.status == status) - - if search: - search_term = f"%{search}%" - query = query.filter( - or_( - Order.order_number.ilike(search_term), - Order.external_order_number.ilike(search_term), - Order.customer_email.ilike(search_term), - Order.customer_first_name.ilike(search_term), - Order.customer_last_name.ilike(search_term), - ) - ) - - # Filter for orders with declined items - if has_declined_items is True: - # Subquery to find orders with declined items - declined_order_ids = ( - self.db.query(OrderItem.order_id) - .filter(OrderItem.item_state == "confirmed_unavailable") - .subquery() - ) - query = query.filter(Order.id.in_(declined_order_ids)) - - total = query.count() - orders = ( - query.order_by(Order.order_date.desc()) - .offset(skip) - .limit(limit) - .all() - ) - - return orders, total - - def get_order_stats(self, vendor_id: int | None = None) -> dict[str, int]: - """ - Get order counts by status for Letzshop orders. - - Args: - vendor_id: Vendor ID to filter by. If None, returns stats for all vendors. - - Returns: - Dict with counts for each status. - """ - query = self.db.query( - Order.status, - func.count(Order.id).label("count"), - ).filter(Order.channel == "letzshop") - - if vendor_id is not None: - query = query.filter(Order.vendor_id == vendor_id) - - status_counts = query.group_by(Order.status).all() - - stats = { - "pending": 0, - "processing": 0, - "shipped": 0, - "delivered": 0, - "cancelled": 0, - "refunded": 0, - "total": 0, - } - for status, count in status_counts: - if status in stats: - stats[status] = count - stats["total"] += count - - # Count orders with declined items - declined_query = ( - self.db.query(func.count(func.distinct(OrderItem.order_id))) - .join(Order, OrderItem.order_id == Order.id) - .filter( - Order.channel == "letzshop", - OrderItem.item_state == "confirmed_unavailable", - ) - ) - if vendor_id is not None: - declined_query = declined_query.filter(Order.vendor_id == vendor_id) - - stats["has_declined_items"] = declined_query.scalar() or 0 - - return stats - - def create_order( - self, - vendor_id: int, - shipment_data: dict[str, Any], - ) -> Order: - """ - Create a new Letzshop order from shipment data. - - Uses the unified order service to create the order. - """ - return unified_order_service.create_letzshop_order( - db=self.db, - vendor_id=vendor_id, - shipment_data=shipment_data, - ) - - def update_order_from_shipment( - self, - order: Order, - shipment_data: dict[str, Any], - ) -> Order: - """Update an existing order from shipment data.""" - order_data = shipment_data.get("order", {}) - - # Map Letzshop state to status - letzshop_state = shipment_data.get("state", "unconfirmed") - state_mapping = { - "unconfirmed": "pending", - "confirmed": "processing", - "declined": "cancelled", - } - new_status = state_mapping.get(letzshop_state, "processing") - - # Update status if changed - if order.status != new_status: - order.status = new_status - now = datetime.now(UTC) - if new_status == "processing": - order.confirmed_at = now - elif new_status == "cancelled": - order.cancelled_at = now - - # Update external data - order.external_data = shipment_data - - # Update locale if not set - if not order.customer_locale and order_data.get("locale"): - order.customer_locale = order_data.get("locale") - - # Update order_date if not set - if not order.order_date: - completed_at_str = order_data.get("completedAt") - if completed_at_str: - try: - if completed_at_str.endswith("Z"): - completed_at_str = completed_at_str[:-1] + "+00:00" - order.order_date = datetime.fromisoformat(completed_at_str) - except (ValueError, TypeError): - pass - - # Update inventory unit states in order items - inventory_units_data = shipment_data.get("inventoryUnits", []) - if isinstance(inventory_units_data, dict): - inventory_units_data = inventory_units_data.get("nodes", []) - - for unit in inventory_units_data: - unit_id = unit.get("id") - unit_state = unit.get("state") - if unit_id and unit_state: - # Find and update the corresponding order item - item = ( - self.db.query(OrderItem) - .filter( - OrderItem.order_id == order.id, - OrderItem.external_item_id == unit_id, - ) - .first() - ) - if item: - item.item_state = unit_state - - order.updated_at = datetime.now(UTC) - return order - - def mark_order_confirmed(self, order: Order) -> Order: - """Mark an order as confirmed (processing).""" - order.confirmed_at = datetime.now(UTC) - order.status = "processing" - order.updated_at = datetime.now(UTC) - return order - - def mark_order_rejected(self, order: Order) -> Order: - """Mark an order as rejected (cancelled).""" - order.cancelled_at = datetime.now(UTC) - order.status = "cancelled" - order.updated_at = datetime.now(UTC) - return order - - def update_inventory_unit_state( - self, order: Order, item_id: str, state: str - ) -> Order: - """ - Update the state of a single order item. - - Args: - order: The order containing the item. - item_id: The external item ID (Letzshop inventory unit ID). - state: The new state (confirmed_available, confirmed_unavailable). - - Returns: - The updated order. - """ - # Find and update the item - item = ( - self.db.query(OrderItem) - .filter( - OrderItem.order_id == order.id, - OrderItem.external_item_id == item_id, - ) - .first() - ) - - if item: - item.item_state = state - item.updated_at = datetime.now(UTC) - - # Check if all items are now processed - all_items = ( - self.db.query(OrderItem) - .filter(OrderItem.order_id == order.id) - .all() - ) - - all_confirmed = all( - i.item_state in ("confirmed_available", "confirmed_unavailable", "returned") - for i in all_items - ) - - if all_confirmed: - has_available = any( - i.item_state == "confirmed_available" for i in all_items - ) - all_unavailable = all( - i.item_state == "confirmed_unavailable" for i in all_items - ) - - now = datetime.now(UTC) - if all_unavailable: - order.status = "cancelled" - order.cancelled_at = now - elif has_available: - order.status = "processing" - order.confirmed_at = now - - order.updated_at = now - - return order - - def set_order_tracking( - self, - order: Order, - tracking_number: str, - tracking_provider: str, - ) -> Order: - """Set tracking information for an order.""" - order.tracking_number = tracking_number - order.tracking_provider = tracking_provider - order.shipped_at = datetime.now(UTC) - order.status = "shipped" - order.updated_at = datetime.now(UTC) - return order - - def get_orders_without_tracking( - self, - vendor_id: int, - limit: int = 100, - ) -> list[Order]: - """Get orders that have been confirmed but don't have tracking info.""" - return ( - self.db.query(Order) - .filter( - Order.vendor_id == vendor_id, - Order.channel == "letzshop", - Order.status == "processing", # Confirmed orders - Order.tracking_number.is_(None), - Order.external_shipment_id.isnot(None), # Has shipment ID - ) - .limit(limit) - .all() - ) - - def update_tracking_from_shipment_data( - self, - order: Order, - shipment_data: dict[str, Any], - ) -> bool: - """ - Update order tracking from Letzshop shipment data. - - Args: - order: The order to update. - shipment_data: Raw shipment data from Letzshop API. - - Returns: - True if tracking was updated, False otherwise. - """ - tracking_data = shipment_data.get("tracking") or {} - tracking_number = tracking_data.get("code") or tracking_data.get("number") - - if not tracking_number: - return False - - tracking_provider = tracking_data.get("provider") - # Handle carrier object format: tracking { carrier { name code } } - if not tracking_provider and tracking_data.get("carrier"): - carrier = tracking_data.get("carrier", {}) - tracking_provider = carrier.get("code") or carrier.get("name") - - order.tracking_number = tracking_number - order.tracking_provider = tracking_provider - order.updated_at = datetime.now(UTC) - - logger.info( - f"Updated tracking for order {order.order_number}: " - f"{tracking_provider} {tracking_number}" - ) - return True - - def get_order_items(self, order: Order) -> list[OrderItem]: - """Get all items for an order.""" - return ( - self.db.query(OrderItem) - .filter(OrderItem.order_id == order.id) - .all() - ) - - # ========================================================================= - # Sync Log Operations - # ========================================================================= - - def list_sync_logs( - self, - vendor_id: int, - skip: int = 0, - limit: int = 50, - ) -> tuple[list[LetzshopSyncLog], int]: - """List sync logs for a vendor.""" - query = self.db.query(LetzshopSyncLog).filter( - LetzshopSyncLog.vendor_id == vendor_id - ) - total = query.count() - logs = ( - query.order_by(LetzshopSyncLog.started_at.desc()) - .offset(skip) - .limit(limit) - .all() - ) - return logs, total - - # ========================================================================= - # Fulfillment Queue Operations - # ========================================================================= - - def list_fulfillment_queue( - self, - vendor_id: int, - skip: int = 0, - limit: int = 50, - status: str | None = None, - ) -> tuple[list[LetzshopFulfillmentQueue], int]: - """List fulfillment queue items for a vendor.""" - query = self.db.query(LetzshopFulfillmentQueue).filter( - LetzshopFulfillmentQueue.vendor_id == vendor_id - ) - - if status: - query = query.filter(LetzshopFulfillmentQueue.status == status) - - total = query.count() - items = ( - query.order_by(LetzshopFulfillmentQueue.created_at.desc()) - .offset(skip) - .limit(limit) - .all() - ) - return items, total - - def add_to_fulfillment_queue( - self, - vendor_id: int, - order_id: int, - operation: str, - payload: dict[str, Any], - ) -> LetzshopFulfillmentQueue: - """Add an operation to the fulfillment queue.""" - queue_item = LetzshopFulfillmentQueue( - vendor_id=vendor_id, - order_id=order_id, - operation=operation, - payload=payload, - status="pending", - ) - self.db.add(queue_item) - return queue_item - - # ========================================================================= - # Unified Jobs Operations - # ========================================================================= - - def list_letzshop_jobs( - self, - vendor_id: int | None = None, - job_type: str | None = None, - status: str | None = None, - skip: int = 0, - limit: int = 20, - ) -> tuple[list[dict[str, Any]], int]: - """ - List unified Letzshop-related jobs for a vendor or all vendors. - - Combines product imports, historical order imports, and order syncs. - If vendor_id is None, returns jobs across all vendors. - """ - jobs = [] - - # Fetch vendor info - for single vendor or build lookup for all vendors - if vendor_id: - vendor = self.get_vendor(vendor_id) - vendor_lookup = {vendor_id: (vendor.name if vendor else None, vendor.vendor_code if vendor else None)} - else: - # Build lookup for all vendors when showing all jobs - from models.database.vendor import Vendor - vendors = self.db.query(Vendor.id, Vendor.name, Vendor.vendor_code).all() - vendor_lookup = {v.id: (v.name, v.vendor_code) for v in vendors} - - # Historical order imports from letzshop_historical_import_jobs - if job_type in (None, "historical_import"): - hist_query = self.db.query(LetzshopHistoricalImportJob) - if vendor_id: - hist_query = hist_query.filter( - LetzshopHistoricalImportJob.vendor_id == vendor_id, - ) - if status: - hist_query = hist_query.filter( - LetzshopHistoricalImportJob.status == status - ) - - hist_jobs = hist_query.order_by( - LetzshopHistoricalImportJob.created_at.desc() - ).all() - - for job in hist_jobs: - v_name, v_code = vendor_lookup.get(job.vendor_id, (None, None)) - jobs.append( - { - "id": job.id, - "type": "historical_import", - "status": job.status, - "created_at": job.created_at, - "started_at": job.started_at, - "completed_at": job.completed_at, - "records_processed": job.orders_processed or 0, - "records_succeeded": (job.orders_imported or 0) - + (job.orders_updated or 0), - "records_failed": job.orders_skipped or 0, - "vendor_id": job.vendor_id, - "vendor_name": v_name, - "vendor_code": v_code, - "current_phase": job.current_phase, - "error_message": job.error_message, - } - ) - - # Product imports from marketplace_import_jobs - if job_type in (None, "import"): - import_query = self.db.query(MarketplaceImportJob).filter( - MarketplaceImportJob.marketplace == "Letzshop", - ) - if vendor_id: - import_query = import_query.filter( - MarketplaceImportJob.vendor_id == vendor_id, - ) - if status: - import_query = import_query.filter( - MarketplaceImportJob.status == status - ) - - import_jobs = import_query.order_by( - MarketplaceImportJob.created_at.desc() - ).all() - - for job in import_jobs: - v_name, v_code = vendor_lookup.get(job.vendor_id, (None, None)) - jobs.append( - { - "id": job.id, - "type": "import", - "status": job.status, - "created_at": job.created_at, - "started_at": job.started_at, - "completed_at": job.completed_at, - "records_processed": job.total_processed or 0, - "records_succeeded": (job.imported_count or 0) - + (job.updated_count or 0), - "records_failed": job.error_count or 0, - "vendor_id": job.vendor_id, - "vendor_name": v_name, - "vendor_code": v_code, - } - ) - - # Order syncs from letzshop_sync_logs - if job_type in (None, "order_sync"): - sync_query = self.db.query(LetzshopSyncLog).filter( - LetzshopSyncLog.operation_type == "order_import", - ) - if vendor_id: - sync_query = sync_query.filter(LetzshopSyncLog.vendor_id == vendor_id) - if status: - sync_query = sync_query.filter(LetzshopSyncLog.status == status) - - sync_logs = sync_query.order_by(LetzshopSyncLog.created_at.desc()).all() - - for log in sync_logs: - v_name, v_code = vendor_lookup.get(log.vendor_id, (None, None)) - jobs.append( - { - "id": log.id, - "type": "order_sync", - "status": log.status, - "created_at": log.created_at, - "started_at": log.started_at, - "completed_at": log.completed_at, - "records_processed": log.records_processed or 0, - "records_succeeded": log.records_succeeded or 0, - "records_failed": log.records_failed or 0, - "vendor_id": log.vendor_id, - "vendor_name": v_name, - "vendor_code": v_code, - "error_details": log.error_details, - } - ) - - # Product exports from letzshop_sync_logs - if job_type in (None, "export"): - export_query = self.db.query(LetzshopSyncLog).filter( - LetzshopSyncLog.operation_type == "product_export", - ) - if vendor_id: - export_query = export_query.filter(LetzshopSyncLog.vendor_id == vendor_id) - if status: - export_query = export_query.filter(LetzshopSyncLog.status == status) - - export_logs = export_query.order_by( - LetzshopSyncLog.created_at.desc() - ).all() - - for log in export_logs: - v_name, v_code = vendor_lookup.get(log.vendor_id, (None, None)) - jobs.append( - { - "id": log.id, - "type": "export", - "status": log.status, - "created_at": log.created_at, - "started_at": log.started_at, - "completed_at": log.completed_at, - "records_processed": log.records_processed or 0, - "records_succeeded": log.records_succeeded or 0, - "records_failed": log.records_failed or 0, - "vendor_id": log.vendor_id, - "vendor_name": v_name, - "vendor_code": v_code, - "error_details": log.error_details, - } - ) - - # Sort all jobs by created_at descending - jobs.sort(key=lambda x: x["created_at"], reverse=True) - - total = len(jobs) - jobs = jobs[skip : skip + limit] - - return jobs, total - - # ========================================================================= - # Historical Import Operations - # ========================================================================= - - def import_historical_shipments( - self, - vendor_id: int, - shipments: list[dict[str, Any]], - match_products: bool = True, - progress_callback: Callable[[int, int, int, int], None] | None = None, - ) -> dict[str, Any]: - """ - Import historical shipments into the unified orders table. - - Args: - vendor_id: Vendor ID to import for. - shipments: List of shipment data from Letzshop API. - match_products: Whether to match GTIN to local products. - progress_callback: Optional callback(processed, imported, updated, skipped) - - Returns: - Dict with import statistics. - """ - stats = { - "total": len(shipments), - "imported": 0, - "updated": 0, - "skipped": 0, - "errors": 0, - "limit_exceeded": 0, - "products_matched": 0, - "products_not_found": 0, - "eans_processed": set(), - "eans_matched": set(), - "eans_not_found": set(), - "error_messages": [], - } - - # Get subscription usage upfront for batch efficiency - usage = subscription_service.get_usage(self.db, vendor_id) - orders_remaining = usage.orders_remaining # None = unlimited - - for i, shipment in enumerate(shipments): - shipment_id = shipment.get("id") - if not shipment_id: - continue - - # Check if order already exists - existing_order = self.get_order_by_shipment_id(vendor_id, shipment_id) - - if existing_order: - # Check if we need to update - letzshop_state = shipment.get("state") - state_mapping = { - "unconfirmed": "pending", - "confirmed": "processing", - "declined": "cancelled", - } - expected_status = state_mapping.get(letzshop_state, "processing") - - needs_update = False - if existing_order.status != expected_status: - self.update_order_from_shipment(existing_order, shipment) - needs_update = True - - # Update order_date if missing - if not existing_order.order_date: - order_data = shipment.get("order", {}) - completed_at_str = order_data.get("completedAt") - if completed_at_str: - try: - if completed_at_str.endswith("Z"): - completed_at_str = completed_at_str[:-1] + "+00:00" - existing_order.order_date = datetime.fromisoformat( - completed_at_str - ) - needs_update = True - except (ValueError, TypeError): - pass - - if needs_update: - self.db.commit() # noqa: SVC-006 - background task needs incremental commits - stats["updated"] += 1 - else: - stats["skipped"] += 1 - else: - # Check tier limit before creating order - if orders_remaining is not None and orders_remaining <= 0: - stats["limit_exceeded"] += 1 - stats["error_messages"].append( - f"Shipment {shipment_id}: Order limit reached" - ) - continue - - # Create new order using unified service - try: - self.create_order(vendor_id, shipment) - self.db.commit() # noqa: SVC-006 - background task needs incremental commits - stats["imported"] += 1 - - # Decrement remaining count for batch efficiency - if orders_remaining is not None: - orders_remaining -= 1 - - except Exception as e: - self.db.rollback() # Rollback failed order - stats["errors"] += 1 - stats["error_messages"].append( - f"Shipment {shipment_id}: {str(e)}" - ) - logger.error(f"Error importing shipment {shipment_id}: {e}") - - # Process GTINs for matching - if match_products: - inventory_units = shipment.get("inventoryUnits", []) - for unit in inventory_units: - variant = unit.get("variant", {}) or {} - trade_id = variant.get("tradeId") or {} - gtin = trade_id.get("number") - - if gtin: - stats["eans_processed"].add(gtin) - - # Report progress - if progress_callback and ((i + 1) % 10 == 0 or i == len(shipments) - 1): - progress_callback( - i + 1, - stats["imported"], - stats["updated"], - stats["skipped"], - ) - - # Match GTINs to local products - if match_products and stats["eans_processed"]: - matched, not_found = self._match_gtins_to_products( - vendor_id, list(stats["eans_processed"]) - ) - stats["eans_matched"] = matched - stats["eans_not_found"] = not_found - stats["products_matched"] = len(matched) - stats["products_not_found"] = len(not_found) - - # Convert sets to lists for JSON serialization - stats["eans_processed"] = list(stats["eans_processed"]) - stats["eans_matched"] = list(stats["eans_matched"]) - stats["eans_not_found"] = list(stats["eans_not_found"]) - - return stats - - def _match_gtins_to_products( - self, - vendor_id: int, - gtins: list[str], - ) -> tuple[set[str], set[str]]: - """Match GTIN codes to local products.""" - if not gtins: - return set(), set() - - products = ( - self.db.query(Product) - .filter( - Product.vendor_id == vendor_id, - Product.gtin.in_(gtins), - ) - .all() - ) - - matched_gtins = {p.gtin for p in products if p.gtin} - not_found_gtins = set(gtins) - matched_gtins - - logger.info( - f"GTIN matching: {len(matched_gtins)} matched, " - f"{len(not_found_gtins)} not found" - ) - return matched_gtins, not_found_gtins - - def get_products_by_gtins( - self, - vendor_id: int, - gtins: list[str], - ) -> dict[str, Product]: - """Get products by their GTIN codes.""" - if not gtins: - return {} - - products = ( - self.db.query(Product) - .filter( - Product.vendor_id == vendor_id, - Product.gtin.in_(gtins), - ) - .all() - ) - - return {p.gtin: p for p in products if p.gtin} - - def get_historical_import_summary( - self, - vendor_id: int, - ) -> dict[str, Any]: - """Get summary of Letzshop orders for a vendor.""" - # Count orders by status - status_counts = ( - self.db.query( - Order.status, - func.count(Order.id).label("count"), - ) - .filter( - Order.vendor_id == vendor_id, - Order.channel == "letzshop", - ) - .group_by(Order.status) - .all() - ) - - # Count orders by locale - locale_counts = ( - self.db.query( - Order.customer_locale, - func.count(Order.id).label("count"), - ) - .filter( - Order.vendor_id == vendor_id, - Order.channel == "letzshop", - ) - .group_by(Order.customer_locale) - .all() - ) - - # Count orders by country - country_counts = ( - self.db.query( - Order.ship_country_iso, - func.count(Order.id).label("count"), - ) - .filter( - Order.vendor_id == vendor_id, - Order.channel == "letzshop", - ) - .group_by(Order.ship_country_iso) - .all() - ) - - # Total orders - total_orders = ( - self.db.query(func.count(Order.id)) - .filter( - Order.vendor_id == vendor_id, - Order.channel == "letzshop", - ) - .scalar() - or 0 - ) - - # Unique customers - unique_customers = ( - self.db.query(func.count(func.distinct(Order.customer_email))) - .filter( - Order.vendor_id == vendor_id, - Order.channel == "letzshop", - ) - .scalar() - or 0 - ) - - return { - "total_orders": total_orders, - "unique_customers": unique_customers, - "orders_by_status": {status: count for status, count in status_counts}, - "orders_by_locale": { - locale or "unknown": count for locale, count in locale_counts - }, - "orders_by_country": { - country or "unknown": count for country, count in country_counts - }, - } - - # ========================================================================= - # Historical Import Job Operations - # ========================================================================= - - def get_running_historical_import_job( - self, - vendor_id: int, - ) -> LetzshopHistoricalImportJob | None: - """Get any running historical import job for a vendor.""" - return ( - self.db.query(LetzshopHistoricalImportJob) - .filter( - LetzshopHistoricalImportJob.vendor_id == vendor_id, - LetzshopHistoricalImportJob.status.in_( - ["pending", "fetching", "processing"] - ), - ) - .first() - ) - - def create_historical_import_job( - self, - vendor_id: int, - user_id: int, - ) -> LetzshopHistoricalImportJob: - """Create a new historical import job.""" - job = LetzshopHistoricalImportJob( - vendor_id=vendor_id, - user_id=user_id, - status="pending", - ) - self.db.add(job) - self.db.commit() # noqa: SVC-006 - job must be visible immediately before background task starts - self.db.refresh(job) - return job - - def get_historical_import_job_by_id( - self, - vendor_id: int, - job_id: int, - ) -> LetzshopHistoricalImportJob | None: - """Get a historical import job by ID.""" - return ( - self.db.query(LetzshopHistoricalImportJob) - .filter( - LetzshopHistoricalImportJob.id == job_id, - LetzshopHistoricalImportJob.vendor_id == vendor_id, - ) - .first() - ) - - def update_job_celery_task_id( - self, - job_id: int, - celery_task_id: str, - ) -> bool: - """ - Update the Celery task ID for a historical import job. - - Args: - job_id: The job ID to update. - celery_task_id: The Celery task ID to set. - - Returns: - True if updated successfully, False if job not found. - """ - job = ( - self.db.query(LetzshopHistoricalImportJob) - .filter(LetzshopHistoricalImportJob.id == job_id) - .first() - ) - if job: - job.celery_task_id = celery_task_id - self.db.commit() # noqa: SVC-006 - Called from API endpoint - return True - return False diff --git a/app/services/letzshop/vendor_sync_service.py b/app/services/letzshop/vendor_sync_service.py deleted file mode 100644 index beb1a6ea..00000000 --- a/app/services/letzshop/vendor_sync_service.py +++ /dev/null @@ -1,521 +0,0 @@ -# app/services/letzshop/vendor_sync_service.py -""" -Service for syncing Letzshop vendor directory to local cache. - -Fetches vendor data from Letzshop's public GraphQL API and stores it -in the letzshop_vendor_cache table for fast lookups during signup. -""" - -import logging -from datetime import UTC, datetime -from typing import Any, Callable - -from sqlalchemy import func -from sqlalchemy.dialects.postgresql import insert as pg_insert -from sqlalchemy.orm import Session - -from app.services.letzshop.client_service import LetzshopClient -from app.modules.marketplace.models import LetzshopVendorCache - -logger = logging.getLogger(__name__) - - -class LetzshopVendorSyncService: - """ - Service for syncing Letzshop vendor directory. - - Usage: - service = LetzshopVendorSyncService(db) - stats = service.sync_all_vendors() - """ - - def __init__(self, db: Session): - """Initialize the sync service.""" - self.db = db - - def sync_all_vendors( - self, - progress_callback: Callable[[int, int, int], None] | None = None, - max_pages: int | None = None, - ) -> dict[str, Any]: - """ - Sync all vendors from Letzshop to local cache. - - Args: - progress_callback: Optional callback(page, fetched, total) for progress. - - Returns: - Dictionary with sync statistics. - """ - stats = { - "started_at": datetime.now(UTC), - "total_fetched": 0, - "created": 0, - "updated": 0, - "errors": 0, - "error_details": [], - } - - logger.info("Starting Letzshop vendor directory sync...") - - # Create client (no API key needed for public vendor data) - client = LetzshopClient(api_key="") - - try: - # Fetch all vendors - vendors = client.get_all_vendors_paginated( - page_size=50, - max_pages=max_pages, - progress_callback=progress_callback, - ) - - stats["total_fetched"] = len(vendors) - logger.info(f"Fetched {len(vendors)} vendors from Letzshop") - - # Process each vendor - for vendor_data in vendors: - try: - result = self._upsert_vendor(vendor_data) - if result == "created": - stats["created"] += 1 - elif result == "updated": - stats["updated"] += 1 - except Exception as e: - stats["errors"] += 1 - error_info = { - "vendor_id": vendor_data.get("id"), - "slug": vendor_data.get("slug"), - "error": str(e), - } - stats["error_details"].append(error_info) - logger.error(f"Error processing vendor {vendor_data.get('slug')}: {e}") - - # Commit all changes - self.db.commit() - logger.info( - f"Sync complete: {stats['created']} created, " - f"{stats['updated']} updated, {stats['errors']} errors" - ) - - except Exception as e: - self.db.rollback() - logger.error(f"Vendor sync failed: {e}") - stats["error"] = str(e) - raise - - finally: - client.close() - - stats["completed_at"] = datetime.now(UTC) - stats["duration_seconds"] = ( - stats["completed_at"] - stats["started_at"] - ).total_seconds() - - return stats - - def _upsert_vendor(self, vendor_data: dict[str, Any]) -> str: - """ - Insert or update a vendor in the cache. - - Args: - vendor_data: Raw vendor data from Letzshop API. - - Returns: - "created" or "updated" indicating the operation performed. - """ - letzshop_id = vendor_data.get("id") - slug = vendor_data.get("slug") - - if not letzshop_id or not slug: - raise ValueError("Vendor missing required id or slug") - - # Parse the vendor data - parsed = self._parse_vendor_data(vendor_data) - - # Check if exists - existing = ( - self.db.query(LetzshopVendorCache) - .filter(LetzshopVendorCache.letzshop_id == letzshop_id) - .first() - ) - - if existing: - # Update existing record (preserve claimed status) - for key, value in parsed.items(): - if key not in ("claimed_by_vendor_id", "claimed_at"): - setattr(existing, key, value) - existing.last_synced_at = datetime.now(UTC) - return "updated" - else: - # Create new record - cache_entry = LetzshopVendorCache( - **parsed, - last_synced_at=datetime.now(UTC), - ) - self.db.add(cache_entry) - return "created" - - def _parse_vendor_data(self, data: dict[str, Any]) -> dict[str, Any]: - """ - Parse raw Letzshop vendor data into cache model fields. - - Args: - data: Raw vendor data from Letzshop API. - - Returns: - Dictionary of parsed fields for LetzshopVendorCache. - """ - # Extract location - location = data.get("location") or {} - country = location.get("country") or {} - - # Extract descriptions - description = data.get("description") or {} - - # Extract opening hours - opening_hours = data.get("openingHours") or {} - - # Extract categories (list of translated name objects) - categories = [] - for cat in data.get("vendorCategories") or []: - cat_name = cat.get("name") or {} - # Prefer English, fallback to French or German - name = cat_name.get("en") or cat_name.get("fr") or cat_name.get("de") - if name: - categories.append(name) - - # Extract social media URLs - social_links = [] - for link in data.get("socialMediaLinks") or []: - url = link.get("url") - if url: - social_links.append(url) - - # Extract background image - bg_image = data.get("backgroundImage") or {} - - return { - "letzshop_id": data.get("id"), - "slug": data.get("slug"), - "name": data.get("name"), - "company_name": data.get("companyName") or data.get("legalName"), - "is_active": data.get("active", True), - # Descriptions - "description_en": description.get("en"), - "description_fr": description.get("fr"), - "description_de": description.get("de"), - # Contact - "email": data.get("email"), - "phone": data.get("phone"), - "fax": data.get("fax"), - "website": data.get("homepage"), - # Location - "street": location.get("street"), - "street_number": location.get("number"), - "city": location.get("city"), - "zipcode": location.get("zipcode"), - "country_iso": country.get("iso", "LU"), - "latitude": str(data.get("lat")) if data.get("lat") else None, - "longitude": str(data.get("lng")) if data.get("lng") else None, - # Categories and media - "categories": categories, - "background_image_url": bg_image.get("url"), - "social_media_links": social_links, - # Opening hours - "opening_hours_en": opening_hours.get("en"), - "opening_hours_fr": opening_hours.get("fr"), - "opening_hours_de": opening_hours.get("de"), - # Representative - "representative_name": data.get("representative"), - "representative_title": data.get("representativeTitle"), - # Raw data for reference - "raw_data": data, - } - - def sync_single_vendor(self, slug: str) -> LetzshopVendorCache | None: - """ - Sync a single vendor by slug. - - Useful for on-demand refresh when a user looks up a vendor. - - Args: - slug: The vendor's URL slug. - - Returns: - The updated/created cache entry, or None if not found. - """ - client = LetzshopClient(api_key="") - - try: - vendor_data = client.get_vendor_by_slug(slug) - - if not vendor_data: - logger.warning(f"Vendor not found on Letzshop: {slug}") - return None - - result = self._upsert_vendor(vendor_data) - self.db.commit() - - logger.info(f"Single vendor sync: {slug} ({result})") - - return ( - self.db.query(LetzshopVendorCache) - .filter(LetzshopVendorCache.slug == slug) - .first() - ) - - finally: - client.close() - - def get_cached_vendor(self, slug: str) -> LetzshopVendorCache | None: - """ - Get a vendor from cache by slug. - - Args: - slug: The vendor's URL slug. - - Returns: - Cache entry or None if not found. - """ - return ( - self.db.query(LetzshopVendorCache) - .filter(LetzshopVendorCache.slug == slug.lower()) - .first() - ) - - def search_cached_vendors( - self, - search: str | None = None, - city: str | None = None, - category: str | None = None, - only_unclaimed: bool = False, - page: int = 1, - limit: int = 20, - ) -> tuple[list[LetzshopVendorCache], int]: - """ - Search cached vendors with filters. - - Args: - search: Search term for name. - city: Filter by city. - category: Filter by category. - only_unclaimed: Only return vendors not yet claimed. - page: Page number (1-indexed). - limit: Items per page. - - Returns: - Tuple of (vendors list, total count). - """ - query = self.db.query(LetzshopVendorCache).filter( - LetzshopVendorCache.is_active == True # noqa: E712 - ) - - if search: - search_term = f"%{search.lower()}%" - query = query.filter( - func.lower(LetzshopVendorCache.name).like(search_term) - ) - - if city: - query = query.filter( - func.lower(LetzshopVendorCache.city) == city.lower() - ) - - if category: - # Search in JSON array - query = query.filter( - LetzshopVendorCache.categories.contains([category]) - ) - - if only_unclaimed: - query = query.filter( - LetzshopVendorCache.claimed_by_vendor_id.is_(None) - ) - - # Get total count - total = query.count() - - # Apply pagination - offset = (page - 1) * limit - vendors = ( - query.order_by(LetzshopVendorCache.name) - .offset(offset) - .limit(limit) - .all() - ) - - return vendors, total - - def get_sync_stats(self) -> dict[str, Any]: - """ - Get statistics about the vendor cache. - - Returns: - Dictionary with cache statistics. - """ - total = self.db.query(LetzshopVendorCache).count() - active = ( - self.db.query(LetzshopVendorCache) - .filter(LetzshopVendorCache.is_active == True) # noqa: E712 - .count() - ) - claimed = ( - self.db.query(LetzshopVendorCache) - .filter(LetzshopVendorCache.claimed_by_vendor_id.isnot(None)) - .count() - ) - - # Get last sync time - last_synced = ( - self.db.query(func.max(LetzshopVendorCache.last_synced_at)).scalar() - ) - - # Get unique cities - cities = ( - self.db.query(LetzshopVendorCache.city) - .filter(LetzshopVendorCache.city.isnot(None)) - .distinct() - .count() - ) - - return { - "total_vendors": total, - "active_vendors": active, - "claimed_vendors": claimed, - "unclaimed_vendors": active - claimed, - "unique_cities": cities, - "last_synced_at": last_synced.isoformat() if last_synced else None, - } - - def mark_vendor_claimed( - self, - letzshop_slug: str, - vendor_id: int, - ) -> bool: - """ - Mark a Letzshop vendor as claimed by a platform vendor. - - Args: - letzshop_slug: The Letzshop vendor slug. - vendor_id: The platform vendor ID that claimed it. - - Returns: - True if successful, False if vendor not found. - """ - cache_entry = self.get_cached_vendor(letzshop_slug) - - if not cache_entry: - return False - - cache_entry.claimed_by_vendor_id = vendor_id - cache_entry.claimed_at = datetime.now(UTC) - self.db.commit() - - logger.info(f"Vendor {letzshop_slug} claimed by vendor_id={vendor_id}") - return True - - def create_vendor_from_cache( - self, - letzshop_slug: str, - company_id: int, - ) -> dict[str, Any]: - """ - Create a platform vendor from a cached Letzshop vendor. - - Args: - letzshop_slug: The Letzshop vendor slug. - company_id: The company ID to create the vendor under. - - Returns: - Dictionary with created vendor info. - - Raises: - ValueError: If vendor not found, already claimed, or company not found. - """ - import random - - from sqlalchemy import func - - from app.services.admin_service import admin_service - from models.database.company import Company - from models.database.vendor import Vendor - from models.schema.vendor import VendorCreate - - # Get cache entry - cache_entry = self.get_cached_vendor(letzshop_slug) - if not cache_entry: - raise ValueError(f"Letzshop vendor '{letzshop_slug}' not found in cache") - - if cache_entry.is_claimed: - raise ValueError( - f"Letzshop vendor '{cache_entry.name}' is already claimed " - f"by vendor ID {cache_entry.claimed_by_vendor_id}" - ) - - # Verify company exists - company = self.db.query(Company).filter(Company.id == company_id).first() - if not company: - raise ValueError(f"Company with ID {company_id} not found") - - # Generate vendor code from slug - vendor_code = letzshop_slug.upper().replace("-", "_")[:20] - - # Check if vendor code already exists - existing = ( - self.db.query(Vendor) - .filter(func.upper(Vendor.vendor_code) == vendor_code) - .first() - ) - if existing: - vendor_code = f"{vendor_code[:16]}_{random.randint(100, 999)}" - - # Generate subdomain from slug - subdomain = letzshop_slug.lower().replace("_", "-")[:30] - existing_subdomain = ( - self.db.query(Vendor) - .filter(func.lower(Vendor.subdomain) == subdomain) - .first() - ) - if existing_subdomain: - subdomain = f"{subdomain[:26]}-{random.randint(100, 999)}" - - # Create vendor data from cache - address = f"{cache_entry.street or ''} {cache_entry.street_number or ''}".strip() - vendor_data = VendorCreate( - name=cache_entry.name, - vendor_code=vendor_code, - subdomain=subdomain, - company_id=company_id, - email=cache_entry.email or company.email, - phone=cache_entry.phone, - description=cache_entry.description_en or cache_entry.description_fr or "", - city=cache_entry.city, - country=cache_entry.country_iso or "LU", - website=cache_entry.website, - address_line_1=address or None, - postal_code=cache_entry.zipcode, - ) - - # Create vendor - vendor = admin_service.create_vendor(self.db, vendor_data) - - # Mark the Letzshop vendor as claimed (commits internally) # noqa: SVC-006 - self.mark_vendor_claimed(letzshop_slug, vendor.id) - - logger.info( - f"Created vendor {vendor.vendor_code} from Letzshop vendor {letzshop_slug}" - ) - - return { - "id": vendor.id, - "vendor_code": vendor.vendor_code, - "name": vendor.name, - "subdomain": vendor.subdomain, - "company_id": vendor.company_id, - } - - -# Singleton-style function for easy access -def get_vendor_sync_service(db: Session) -> LetzshopVendorSyncService: - """Get a vendor sync service instance.""" - return LetzshopVendorSyncService(db) diff --git a/app/services/letzshop_export_service.py b/app/services/letzshop_export_service.py deleted file mode 100644 index 16718d47..00000000 --- a/app/services/letzshop_export_service.py +++ /dev/null @@ -1,25 +0,0 @@ -# app/services/letzshop_export_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/marketplace/services/letzshop_export_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.marketplace.services import letzshop_export_service -""" - -from app.modules.marketplace.services.letzshop_export_service import ( - LetzshopExportService, - letzshop_export_service, - LETZSHOP_CSV_COLUMNS, -) - -__all__ = [ - "LetzshopExportService", - "letzshop_export_service", - "LETZSHOP_CSV_COLUMNS", -] diff --git a/app/services/marketplace_import_job_service.py b/app/services/marketplace_import_job_service.py deleted file mode 100644 index 79c1d7fc..00000000 --- a/app/services/marketplace_import_job_service.py +++ /dev/null @@ -1,23 +0,0 @@ -# app/services/marketplace_import_job_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/marketplace/services/marketplace_import_job_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.marketplace.services import marketplace_import_job_service -""" - -from app.modules.marketplace.services.marketplace_import_job_service import ( - MarketplaceImportJobService, - marketplace_import_job_service, -) - -__all__ = [ - "MarketplaceImportJobService", - "marketplace_import_job_service", -] diff --git a/app/services/marketplace_product_service.py b/app/services/marketplace_product_service.py deleted file mode 100644 index 901ce9d7..00000000 --- a/app/services/marketplace_product_service.py +++ /dev/null @@ -1,23 +0,0 @@ -# app/services/marketplace_product_service.py -""" -LEGACY LOCATION - Re-exports from module for backwards compatibility. - -The canonical implementation is now in: - app/modules/marketplace/services/marketplace_product_service.py - -This file exists to maintain backwards compatibility with code that -imports from the old location. All new code should import directly -from the module: - - from app.modules.marketplace.services import marketplace_product_service -""" - -from app.modules.marketplace.services.marketplace_product_service import ( - MarketplaceProductService, - marketplace_product_service, -) - -__all__ = [ - "MarketplaceProductService", - "marketplace_product_service", -] diff --git a/app/services/marketplace_service.py b/app/services/marketplace_service.py deleted file mode 100644 index 00533012..00000000 --- a/app/services/marketplace_service.py +++ /dev/null @@ -1 +0,0 @@ -# Marketplace import services (MarketplaceProduct diff --git a/app/services/onboarding_service.py b/app/services/onboarding_service.py index 0a59871d..aacb546c 100644 --- a/app/services/onboarding_service.py +++ b/app/services/onboarding_service.py @@ -22,8 +22,10 @@ from app.exceptions import ( OnboardingSyncNotCompleteException, VendorNotFoundException, ) -from app.services.letzshop.credentials_service import LetzshopCredentialsService -from app.services.letzshop.order_service import LetzshopOrderService +from app.modules.marketplace.services.letzshop import ( + LetzshopCredentialsService, + LetzshopOrderService, +) from app.modules.marketplace.models import ( OnboardingStatus, OnboardingStep, diff --git a/app/tasks/letzshop_tasks.py b/app/tasks/letzshop_tasks.py index 1a508666..d97627cc 100644 --- a/app/tasks/letzshop_tasks.py +++ b/app/tasks/letzshop_tasks.py @@ -7,10 +7,12 @@ from typing import Any, Callable from app.core.database import SessionLocal from app.services.admin_notification_service import admin_notification_service -from app.services.letzshop import LetzshopClientError -from app.services.letzshop.credentials_service import LetzshopCredentialsService -from app.services.letzshop.order_service import LetzshopOrderService -from app.services.letzshop.vendor_sync_service import LetzshopVendorSyncService +from app.modules.marketplace.services.letzshop import ( + LetzshopClientError, + LetzshopCredentialsService, + LetzshopOrderService, + LetzshopVendorSyncService, +) from app.modules.marketplace.models import LetzshopHistoricalImportJob logger = logging.getLogger(__name__) diff --git a/docs/architecture/module-system.md b/docs/architecture/module-system.md index a118fc6a..c1a78142 100644 --- a/docs/architecture/module-system.md +++ b/docs/architecture/module-system.md @@ -499,6 +499,381 @@ Currently, all migrations reside in central `alembic/versions/`. The module-spec - **New modules**: Should create migrations in their own `migrations/versions/` - **Future reorganization**: Existing migrations will be moved to modules pre-production +## Entity Auto-Discovery Reference + +This section details the auto-discovery requirements for each entity type. **All entities must be in modules** - legacy locations are deprecated and will trigger architecture validation errors. + +### Routes + +Routes define API and page endpoints. They are auto-discovered from module directories. + +| Type | Location | Discovery | Router Name | +|------|----------|-----------|-------------| +| Vendor API | `routes/api/vendor.py` | `app/modules/routes.py` | `vendor_router` | +| Admin API | `routes/api/admin.py` | `app/modules/routes.py` | `admin_router` | +| Shop API | `routes/api/shop.py` | `app/modules/routes.py` | `shop_router` | +| Vendor Pages | `routes/pages/vendor.py` | `app/modules/routes.py` | `vendor_router` | +| Admin Pages | `routes/pages/admin.py` | `app/modules/routes.py` | `admin_router` | + +**Structure:** +``` +app/modules/{module}/routes/ +├── __init__.py +├── api/ +│ ├── __init__.py +│ ├── vendor.py # Must export vendor_router +│ ├── admin.py # Must export admin_router +│ └── vendor_{feature}.py # Sub-routers aggregated in vendor.py +└── pages/ + ├── __init__.py + └── vendor.py # Must export vendor_router +``` + +**Example - Aggregating Sub-Routers:** +```python +# app/modules/billing/routes/api/vendor.py +from fastapi import APIRouter, Depends +from app.api.deps import require_module_access + +vendor_router = APIRouter( + prefix="/billing", + dependencies=[Depends(require_module_access("billing"))], +) + +# Aggregate sub-routers +from .vendor_checkout import vendor_checkout_router +from .vendor_usage import vendor_usage_router + +vendor_router.include_router(vendor_checkout_router) +vendor_router.include_router(vendor_usage_router) +``` + +**Legacy Locations (DEPRECATED - will cause errors):** +- `app/api/v1/vendor/*.py` - Move to module `routes/api/vendor.py` +- `app/api/v1/admin/*.py` - Move to module `routes/api/admin.py` + +--- + +### Services + +Services contain business logic. They are not auto-discovered but should be in modules for organization. + +| Location | Import Pattern | +|----------|----------------| +| `services/*.py` | `from app.modules.{module}.services import service_name` | +| `services/__init__.py` | Re-exports all public services | + +**Structure:** +``` +app/modules/{module}/services/ +├── __init__.py # Re-exports: from .order_service import order_service +├── order_service.py # OrderService class + order_service singleton +└── fulfillment_service.py # Related services +``` + +**Example:** +```python +# app/modules/orders/services/order_service.py +from sqlalchemy.orm import Session +from app.modules.orders.models import Order + +class OrderService: + def get_order(self, db: Session, order_id: int) -> Order: + return db.query(Order).filter(Order.id == order_id).first() + +order_service = OrderService() + +# app/modules/orders/services/__init__.py +from .order_service import order_service, OrderService + +__all__ = ["order_service", "OrderService"] +``` + +**Legacy Locations (DEPRECATED - will cause errors):** +- `app/services/*.py` - Move to module `services/` +- `app/services/{module}/` - Move to `app/modules/{module}/services/` + +--- + +### Models + +Database models (SQLAlchemy). Currently in `models/database/`, migrating to modules. + +| Location | Base Class | Discovery | +|----------|------------|-----------| +| `models/*.py` | `Base` from `models.base` | Alembic autogenerate | + +**Structure:** +``` +app/modules/{module}/models/ +├── __init__.py # Re-exports: from .order import Order, OrderItem +├── order.py # Order model +└── order_item.py # Related models +``` + +**Example:** +```python +# app/modules/orders/models/order.py +from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy.orm import relationship +from models.base import Base, TimestampMixin + +class Order(Base, TimestampMixin): + __tablename__ = "orders" + + id = Column(Integer, primary_key=True) + vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False) + status = Column(String(50), default="pending") + items = relationship("OrderItem", back_populates="order") +``` + +**Legacy Locations (being migrated):** +- `models/database/*.py` - Core models remain here, domain models move to modules + +--- + +### Schemas + +Pydantic schemas for request/response validation. + +| Location | Base Class | Usage | +|----------|------------|-------| +| `schemas/*.py` | `BaseModel` from Pydantic | API routes, validation | + +**Structure:** +``` +app/modules/{module}/schemas/ +├── __init__.py # Re-exports all schemas +├── order.py # Order request/response schemas +└── order_item.py # Related schemas +``` + +**Example:** +```python +# app/modules/orders/schemas/order.py +from pydantic import BaseModel +from datetime import datetime + +class OrderResponse(BaseModel): + id: int + vendor_id: int + status: str + created_at: datetime + + class Config: + from_attributes = True + +class OrderCreateRequest(BaseModel): + customer_id: int + items: list[OrderItemRequest] +``` + +**Legacy Locations (DEPRECATED - will cause errors):** +- `models/schema/*.py` - Move to module `schemas/` + +--- + +### Tasks (Celery) + +Background tasks are auto-discovered by Celery from module `tasks/` directories. + +| Location | Discovery | Registration | +|----------|-----------|--------------| +| `tasks/*.py` | `app/modules/tasks.py` | Celery autodiscover | + +**Structure:** +``` +app/modules/{module}/tasks/ +├── __init__.py # REQUIRED - imports task functions +├── import_tasks.py # Task definitions +└── export_tasks.py # Related tasks +``` + +**Example:** +```python +# app/modules/marketplace/tasks/import_tasks.py +from celery import shared_task +from app.core.database import SessionLocal + +@shared_task(bind=True) +def process_import(self, job_id: int, vendor_id: int): + db = SessionLocal() + try: + # Process import + pass + finally: + db.close() + +# app/modules/marketplace/tasks/__init__.py +from .import_tasks import process_import +from .export_tasks import export_products + +__all__ = ["process_import", "export_products"] +``` + +**Legacy Locations (DEPRECATED - will cause errors):** +- `app/tasks/*.py` - Move to module `tasks/` + +--- + +### Exceptions + +Module-specific exceptions inherit from `WizamartException`. + +| Location | Base Class | Usage | +|----------|------------|-------| +| `exceptions.py` | `WizamartException` | Domain errors | + +**Structure:** +``` +app/modules/{module}/ +└── exceptions.py # All module exceptions +``` + +**Example:** +```python +# app/modules/orders/exceptions.py +from app.exceptions import WizamartException + +class OrderException(WizamartException): + """Base exception for orders module.""" + pass + +class OrderNotFoundError(OrderException): + """Order not found.""" + def __init__(self, order_id: int): + super().__init__(f"Order {order_id} not found") + self.order_id = order_id + +class OrderAlreadyFulfilledError(OrderException): + """Order has already been fulfilled.""" + pass +``` + +--- + +### Templates + +Jinja2 templates are auto-discovered from module `templates/` directories. + +| Location | URL Pattern | Discovery | +|----------|-------------|-----------| +| `templates/{module}/vendor/*.html` | `/vendor/{vendor}/...` | Jinja2 loader | +| `templates/{module}/admin/*.html` | `/admin/...` | Jinja2 loader | + +**Structure:** +``` +app/modules/{module}/templates/ +└── {module}/ + ├── vendor/ + │ ├── index.html + │ └── detail.html + └── admin/ + └── list.html +``` + +**Template Reference:** +```python +# In route +return templates.TemplateResponse( + request=request, + name="{module}/vendor/index.html", + context={"items": items} +) +``` + +--- + +### Static Files + +JavaScript, CSS, and images are auto-mounted from module `static/` directories. + +| Location | URL | Discovery | +|----------|-----|-----------| +| `static/vendor/js/*.js` | `/static/modules/{module}/vendor/js/*.js` | `main.py` | +| `static/admin/js/*.js` | `/static/modules/{module}/admin/js/*.js` | `main.py` | + +**Structure:** +``` +app/modules/{module}/static/ +├── vendor/js/ +│ └── {module}.js +├── admin/js/ +│ └── {module}.js +└── shared/js/ + └── common.js +``` + +**Template Reference:** +```html + +``` + +--- + +### Locales (i18n) + +Translation files are auto-discovered from module `locales/` directories. + +| Location | Format | Discovery | +|----------|--------|-----------| +| `locales/*.json` | JSON key-value | `app/utils/i18n.py` | + +**Structure:** +``` +app/modules/{module}/locales/ +├── en.json +├── de.json +├── fr.json +└── lb.json +``` + +**Example:** +```json +{ + "orders.title": "Orders", + "orders.status.pending": "Pending", + "orders.status.fulfilled": "Fulfilled" +} +``` + +**Usage:** +```python +from app.utils.i18n import t + +message = t("orders.title", locale="en") # "Orders" +``` + +--- + +### Configuration + +Module-specific environment configuration. + +| Location | Base Class | Discovery | +|----------|------------|-----------| +| `config.py` | `BaseSettings` | `app/modules/config.py` | + +**Example:** +```python +# app/modules/marketplace/config.py +from pydantic_settings import BaseSettings + +class MarketplaceConfig(BaseSettings): + api_timeout: int = 30 + batch_size: int = 100 + + model_config = {"env_prefix": "MARKETPLACE_"} + +config = MarketplaceConfig() +``` + +**Environment Variables:** +```bash +MARKETPLACE_API_TIMEOUT=60 +MARKETPLACE_BATCH_SIZE=500 +``` + ## Architecture Validation Rules The architecture validator (`scripts/validate_architecture.py`) enforces module structure: @@ -520,6 +895,10 @@ The architecture validator (`scripts/validate_architecture.py`) enforces module | MOD-013 | INFO | config.py should export `config` or `config_class` | | MOD-014 | WARNING | Migrations must follow naming convention | | MOD-015 | WARNING | Migrations directory must have `__init__.py` files | +| MOD-016 | ERROR | Routes must be in modules, not `app/api/v1/` | +| MOD-017 | ERROR | Services must be in modules, not `app/services/` | +| MOD-018 | ERROR | Tasks must be in modules, not `app/tasks/` | +| MOD-019 | ERROR | Schemas must be in modules, not `models/schema/` | Run validation: ```bash diff --git a/docs/development/migration/module-autodiscovery-migration.md b/docs/development/migration/module-autodiscovery-migration.md new file mode 100644 index 00000000..de968d08 --- /dev/null +++ b/docs/development/migration/module-autodiscovery-migration.md @@ -0,0 +1,170 @@ +# Module Auto-Discovery Migration History + +This document tracks the migration of legacy code to the self-contained module architecture with auto-discovery. + +## Overview + +The Wizamart platform has been migrating from a monolithic structure with code in centralized locations (`app/api/v1/`, `app/services/`, `models/`) to a fully modular architecture where each module owns all its entities (routes, services, models, schemas, tasks). + +## Migration Goals + +1. **Self-Contained Modules**: Each module in `app/modules/{module}/` owns all its code +2. **Auto-Discovery**: All entities are automatically discovered - no manual registration +3. **No Legacy Dependencies**: Modules should not import from legacy locations +4. **Zero Framework Changes**: Adding/removing modules requires no changes to core framework + +## Migration Timeline + +### Phase 1: Foundation (2026-01-28) + +#### Commit: `1ef5089` - Migrate schemas to canonical module locations +- Moved Pydantic schemas from `models/schema/` to `app/modules/{module}/schemas/` +- Established schema auto-discovery pattern + +#### Commit: `b9f08b8` - Clean up legacy models and migrate remaining schemas +- Removed duplicate schema definitions +- Consolidated schema exports in module `__init__.py` + +#### Commit: `0f9b80c` - Migrate Feature to billing module and split ProductMedia to catalog +- Moved `Feature` model to `app/modules/billing/models/` +- Moved `ProductMedia` to `app/modules/catalog/models/` + +#### Commit: `d7de723` - Remove legacy feature.py re-export +- Removed `app/services/feature.py` re-export file +- Direct imports from module now required + +### Phase 2: API Dependencies (2026-01-29) + +#### Commit: `cad862f` - Introduce UserContext schema for API dependency injection +- Created `models/schema/auth.py` with `UserContext` schema +- Standardized vendor/admin API authentication pattern +- Enables consistent `token_vendor_id` access across routes + +### Phase 3: Module Structure Enforcement (2026-01-29) + +#### Commit: `434db15` - Add module exceptions, locales, and fix architecture warnings +- Added `exceptions.py` to all self-contained modules +- Created locale files for i18n support +- Fixed architecture validation warnings + +#### Commit: `0b4291d` - Migrate JavaScript files to module directories +- Moved JS files from `static/vendor/js/` to `app/modules/{module}/static/vendor/js/` +- Module static files now auto-mounted at `/static/modules/{module}/` + +### Phase 4: Customer Module (2026-01-30) + +#### Commit: `e0b69f5` - Migrate customers routes to module with auto-discovery +- Created `app/modules/customers/routes/api/vendor.py` +- Moved customer management routes from legacy location + +#### Commit: `0a82c84` - Remove legacy route files, fully self-contained +- Deleted `app/api/v1/vendor/customers.py` +- Customers module now fully self-contained + +### Phase 5: Full Route Auto-Discovery (2026-01-31) + +#### Commit: `db56b34` - Switch to full auto-discovery for module API routes +- Updated `app/modules/routes.py` with route auto-discovery +- Modules with `is_self_contained=True` have routes auto-registered +- No manual `include_router()` calls needed + +#### Commit: `6f27813` - Migrate products and vendor_products to module auto-discovery +- Moved product routes to `app/modules/catalog/routes/api/` +- Moved vendor product routes to catalog module +- Deleted legacy `app/api/v1/vendor/products.py` + +#### Commit: `e2cecff` - Migrate vendor billing, invoices, payments to module auto-discovery +- Created `app/modules/billing/routes/api/vendor_checkout.py` +- Created `app/modules/billing/routes/api/vendor_addons.py` +- Deleted legacy billing routes from `app/api/v1/vendor/` + +### Phase 6: Remaining Vendor Routes (2026-01-31) + +#### Current Changes - Migrate analytics, usage, onboarding +- **Deleted**: `app/api/v1/vendor/analytics.py` (duplicate - analytics module already auto-discovered) +- **Created**: `app/modules/billing/routes/api/vendor_usage.py` (usage limits/upgrades) +- **Created**: `app/modules/marketplace/routes/api/vendor_onboarding.py` (onboarding wizard) +- **Deleted**: `app/api/v1/vendor/usage.py` (migrated to billing) +- **Deleted**: `app/api/v1/vendor/onboarding.py` (migrated to marketplace) + +## Current State + +### Migrated to Modules (Auto-Discovered) + +| Module | Routes | Services | Models | Schemas | Tasks | +|--------|--------|----------|--------|---------|-------| +| analytics | API | Stats | Report | Stats | - | +| billing | API | Billing, Subscription | Tier, Subscription, Invoice | Billing | Subscription | +| catalog | API | Product | Product, Category | Product | - | +| cart | API | Cart | Cart, CartItem | Cart | Cleanup | +| checkout | API | Checkout | - | Checkout | - | +| cms | API, Pages | ContentPage | ContentPage, Section | CMS | - | +| customers | API | Customer | Customer | Customer | - | +| inventory | API | Inventory | Stock, Location | Inventory | - | +| marketplace | API | Import, Export, Sync | ImportJob | Marketplace | Import, Export | +| messaging | API | Message | Message | Message | - | +| orders | API | Order | Order, OrderItem | Order | - | +| payments | API | Payment, Stripe | Payment | Payment | - | + +### Still in Legacy Locations (Need Migration) + +#### Vendor Routes (`app/api/v1/vendor/`) +- `auth.py` - Authentication (belongs in core/tenancy) +- `dashboard.py` - Dashboard (belongs in core) +- `email_settings.py` - Email settings (belongs in messaging) +- `email_templates.py` - Email templates (belongs in messaging) +- `info.py` - Vendor info (belongs in tenancy) +- `media.py` - Media library (belongs in cms) +- `messages.py` - Messages (belongs in messaging) +- `notifications.py` - Notifications (belongs in messaging) +- `profile.py` - Profile (belongs in core/tenancy) +- `settings.py` - Settings (belongs in core) +- `team.py` - Team management (belongs in tenancy) + +#### Admin Routes (`app/api/v1/admin/`) +- Most files still in legacy location +- Target: Move to respective modules or tenancy module + +#### Services (`app/services/`) +- 61 files still in legacy location +- Many are re-exports from modules +- Target: Move actual code to modules, delete re-exports + +#### Tasks (`app/tasks/`) +- `letzshop_tasks.py` - Belongs in marketplace module +- `subscription_tasks.py` - Belongs in billing module +- Others need evaluation + +## Architecture Rules + +The following rules enforce the module-first architecture: + +| Rule | Severity | Description | +|------|----------|-------------| +| MOD-016 | ERROR | Routes must be in modules, not `app/api/v1/{vendor,admin}/` | +| MOD-017 | ERROR | Services must be in modules, not `app/services/` | +| MOD-018 | ERROR | Tasks must be in modules, not `app/tasks/` | +| MOD-019 | WARNING | Schemas should be in modules, not `models/schema/` | + +## Next Steps + +1. **Migrate remaining vendor routes** to appropriate modules +2. **Migrate admin routes** to modules +3. **Move services** from `app/services/` to module `services/` +4. **Move tasks** from `app/tasks/` to module `tasks/` +5. **Clean up re-exports** once all code is in modules + +## Verification + +Run architecture validation to check compliance: + +```bash +python scripts/validate_architecture.py +``` + +Check for legacy location violations: + +```bash +python scripts/validate_architecture.py -d app/api/v1/vendor +python scripts/validate_architecture.py -d app/services +``` diff --git a/scripts/validate_architecture.py b/scripts/validate_architecture.py index e4542e24..0ffb613a 100755 --- a/scripts/validate_architecture.py +++ b/scripts/validate_architecture.py @@ -224,6 +224,9 @@ class ArchitectureValidator: # Validate module structure self._validate_modules(target) + # Validate legacy locations (must be in modules) + self._validate_legacy_locations(target) + return self.result def validate_file(self, file_path: Path, quiet: bool = False) -> ValidationResult: @@ -4348,6 +4351,193 @@ class ArchitectureValidator: suggestion="Create 'exceptions.py' or 'exceptions/__init__.py'", ) + def _validate_legacy_locations(self, target_path: Path): + """ + Validate that code is not in legacy locations (MOD-016 to MOD-019). + + All routes, services, tasks, and schemas should be in module directories, + not in the legacy centralized locations. + """ + print("🚫 Checking legacy locations...") + + # MOD-016: Routes must be in modules, not app/api/v1/ + self._check_legacy_routes(target_path) + + # MOD-017: Services must be in modules, not app/services/ + self._check_legacy_services(target_path) + + # MOD-018: Tasks must be in modules, not app/tasks/ + self._check_legacy_tasks(target_path) + + # MOD-019: Schemas must be in modules, not models/schema/ + self._check_legacy_schemas(target_path) + + def _check_legacy_routes(self, target_path: Path): + """MOD-016: Check for routes in legacy app/api/v1/ locations.""" + # Check vendor routes + vendor_api_path = target_path / "app" / "api" / "v1" / "vendor" + if vendor_api_path.exists(): + for py_file in vendor_api_path.glob("*.py"): + if py_file.name == "__init__.py": + continue + # Allow auth.py for now (core authentication) + if py_file.name == "auth.py": + continue + + # Check for noqa comment + content = py_file.read_text() + if "noqa: mod-016" in content.lower(): + continue + + self._add_violation( + rule_id="MOD-016", + rule_name="Routes must be in modules, not app/api/v1/", + severity=Severity.ERROR, + file_path=py_file, + line_number=1, + message=f"Route file '{py_file.name}' in legacy location - should be in module", + context="app/api/v1/vendor/", + suggestion="Move to app/modules/{module}/routes/api/vendor.py", + ) + + # Check admin routes + admin_api_path = target_path / "app" / "api" / "v1" / "admin" + if admin_api_path.exists(): + for py_file in admin_api_path.glob("*.py"): + if py_file.name == "__init__.py": + continue + # Allow auth.py for now (core authentication) + if py_file.name == "auth.py": + continue + + # Check for noqa comment + content = py_file.read_text() + if "noqa: mod-016" in content.lower(): + continue + + self._add_violation( + rule_id="MOD-016", + rule_name="Routes must be in modules, not app/api/v1/", + severity=Severity.ERROR, + file_path=py_file, + line_number=1, + message=f"Route file '{py_file.name}' in legacy location - should be in module", + context="app/api/v1/admin/", + suggestion="Move to app/modules/{module}/routes/api/admin.py", + ) + + def _check_legacy_services(self, target_path: Path): + """MOD-017: Check for services in legacy app/services/ location.""" + services_path = target_path / "app" / "services" + if not services_path.exists(): + return + + for py_file in services_path.glob("*.py"): + if py_file.name == "__init__.py": + continue + + # Check for noqa comment + content = py_file.read_text() + if "noqa: mod-017" in content.lower(): + continue + + # Check if file is a pure re-export (only imports, no class/def) + lines = content.split("\n") + has_definitions = any( + re.match(r"^(class|def|async def)\s+\w+", line) + for line in lines + ) + + # If it's a re-export only file, it's a warning not error + if not has_definitions: + # Check if it imports from modules + imports_from_module = "from app.modules." in content + if imports_from_module: + # Re-export from module - this is acceptable during migration + continue + + self._add_violation( + rule_id="MOD-017", + rule_name="Services must be in modules, not app/services/", + severity=Severity.ERROR, + file_path=py_file, + line_number=1, + message=f"Service file '{py_file.name}' in legacy location - should be in module", + context="app/services/", + suggestion="Move to app/modules/{module}/services/", + ) + + def _check_legacy_tasks(self, target_path: Path): + """MOD-018: Check for tasks in legacy app/tasks/ location.""" + tasks_path = target_path / "app" / "tasks" + if not tasks_path.exists(): + return + + for py_file in tasks_path.glob("*.py"): + if py_file.name == "__init__.py": + continue + # Allow dispatcher.py (infrastructure) + if py_file.name == "dispatcher.py": + continue + + # Check for noqa comment + content = py_file.read_text() + if "noqa: mod-018" in content.lower(): + continue + + self._add_violation( + rule_id="MOD-018", + rule_name="Tasks must be in modules, not app/tasks/", + severity=Severity.ERROR, + file_path=py_file, + line_number=1, + message=f"Task file '{py_file.name}' in legacy location - should be in module", + context="app/tasks/", + suggestion="Move to app/modules/{module}/tasks/", + ) + + def _check_legacy_schemas(self, target_path: Path): + """MOD-019: Check for schemas in legacy models/schema/ location.""" + schemas_path = target_path / "models" / "schema" + if not schemas_path.exists(): + return + + for py_file in schemas_path.glob("*.py"): + if py_file.name == "__init__.py": + continue + # Allow auth.py (core authentication schemas) + if py_file.name == "auth.py": + continue + + # Check for noqa comment + content = py_file.read_text() + if "noqa: mod-019" in content.lower(): + continue + + # Check if file is a pure re-export + lines = content.split("\n") + has_definitions = any( + re.match(r"^class\s+\w+", line) + for line in lines + ) + + # If it's a re-export only file, allow it during migration + if not has_definitions: + imports_from_module = "from app.modules." in content + if imports_from_module: + continue + + self._add_violation( + rule_id="MOD-019", + rule_name="Schemas must be in modules, not models/schema/", + severity=Severity.ERROR, + file_path=py_file, + line_number=1, + message=f"Schema file '{py_file.name}' in legacy location - should be in module", + context="models/schema/", + suggestion="Move to app/modules/{module}/schemas/", + ) + def _get_rule(self, rule_id: str) -> dict[str, Any]: """Get rule configuration by ID""" # Look in different rule categories