refactor: migrate remaining routes to modules and enforce auto-discovery
MIGRATION: - Delete app/api/v1/vendor/analytics.py (duplicate - analytics module already auto-discovered) - Move usage routes from app/api/v1/vendor/usage.py to billing module - Move onboarding routes from app/api/v1/vendor/onboarding.py to marketplace module - Move features routes to billing module (admin + vendor) - Move inventory routes to inventory module (admin + vendor) - Move marketplace/letzshop routes to marketplace module - Move orders routes to orders module - Delete legacy letzshop service files (moved to marketplace module) DOCUMENTATION: - Add docs/development/migration/module-autodiscovery-migration.md with full migration history - Update docs/architecture/module-system.md with Entity Auto-Discovery Reference section - Add detailed sections for each entity type: routes, services, models, schemas, tasks, exceptions, templates, static files, locales, configuration ARCHITECTURE VALIDATION: - Add MOD-016: Routes must be in modules, not app/api/v1/ - Add MOD-017: Services must be in modules, not app/services/ - Add MOD-018: Tasks must be in modules, not app/tasks/ - Add MOD-019: Schemas must be in modules, not models/schema/ - Update scripts/validate_architecture.py with _validate_legacy_locations method - Update .architecture-rules/module.yaml with legacy location rules These rules enforce that all entities must be in self-contained modules. Legacy locations now trigger ERROR severity violations. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -419,3 +419,133 @@ module_rules:
|
|||||||
required_files:
|
required_files:
|
||||||
- "__init__.py"
|
- "__init__.py"
|
||||||
- "versions/__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"
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ from . import (
|
|||||||
companies,
|
companies,
|
||||||
dashboard,
|
dashboard,
|
||||||
email_templates,
|
email_templates,
|
||||||
features,
|
|
||||||
images,
|
images,
|
||||||
logs,
|
logs,
|
||||||
media,
|
media,
|
||||||
@@ -60,7 +59,6 @@ from . import (
|
|||||||
platform_health,
|
platform_health,
|
||||||
platforms,
|
platforms,
|
||||||
settings,
|
settings,
|
||||||
subscriptions, # Legacy - will be replaced by billing module router
|
|
||||||
tests,
|
tests,
|
||||||
users,
|
users,
|
||||||
vendor_domains,
|
vendor_domains,
|
||||||
@@ -179,9 +177,6 @@ router.include_router(
|
|||||||
# Include test runner endpoints
|
# Include test runner endpoints
|
||||||
router.include_router(tests.router, prefix="/tests", tags=["admin-tests"])
|
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
|
# Auto-discovered Module Routes
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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"],
|
|
||||||
)
|
|
||||||
@@ -345,7 +345,7 @@ def export_vendor_products_letzshop(
|
|||||||
"""
|
"""
|
||||||
from fastapi.responses import Response
|
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)
|
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 pathlib import Path as FilePath
|
||||||
|
|
||||||
from app.core.config import settings
|
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)
|
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
||||||
include_inactive = request.include_inactive if request else False
|
include_inactive = request.include_inactive if request else False
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
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.services.platform_signup_service import platform_signup_service
|
||||||
from app.modules.marketplace.models import LetzshopVendorCache
|
from app.modules.marketplace.models import LetzshopVendorCache
|
||||||
|
|
||||||
|
|||||||
11
app/api/v1/vendor/__init__.py
vendored
11
app/api/v1/vendor/__init__.py
vendored
@@ -15,10 +15,11 @@ For multi-tenant apps, module enablement is checked at request time
|
|||||||
based on platform context (not at route registration time).
|
based on platform context (not at route registration time).
|
||||||
|
|
||||||
Self-contained modules (auto-discovered from app/modules/{module}/routes/api/vendor.py):
|
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
|
- inventory: Stock management, inventory tracking
|
||||||
- orders: Order management, fulfillment, exceptions, invoices
|
- orders: Order management, fulfillment, exceptions, invoices
|
||||||
- marketplace: Letzshop integration, product sync
|
- marketplace: Letzshop integration, product sync, onboarding
|
||||||
- catalog: Vendor product catalog management
|
- catalog: Vendor product catalog management
|
||||||
- cms: Content pages management
|
- cms: Content pages management
|
||||||
- customers: Customer 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)
|
# Import all sub-routers (legacy routes that haven't been migrated to modules)
|
||||||
from . import (
|
from . import (
|
||||||
analytics,
|
|
||||||
auth,
|
auth,
|
||||||
dashboard,
|
dashboard,
|
||||||
email_settings,
|
email_settings,
|
||||||
@@ -38,11 +38,9 @@ from . import (
|
|||||||
media,
|
media,
|
||||||
messages,
|
messages,
|
||||||
notifications,
|
notifications,
|
||||||
onboarding,
|
|
||||||
profile,
|
profile,
|
||||||
settings,
|
settings,
|
||||||
team,
|
team,
|
||||||
usage,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create vendor router
|
# 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(settings.router, tags=["vendor-settings"])
|
||||||
router.include_router(email_templates.router, tags=["vendor-email-templates"])
|
router.include_router(email_templates.router, tags=["vendor-email-templates"])
|
||||||
router.include_router(email_settings.router, tags=["vendor-email-settings"])
|
router.include_router(email_settings.router, tags=["vendor-email-settings"])
|
||||||
router.include_router(onboarding.router, tags=["vendor-onboarding"])
|
|
||||||
|
|
||||||
# Business operations (with prefixes: /team/*)
|
# Business operations (with prefixes: /team/*)
|
||||||
router.include_router(team.router, tags=["vendor-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(media.router, tags=["vendor-media"])
|
||||||
router.include_router(notifications.router, tags=["vendor-notifications"])
|
router.include_router(notifications.router, tags=["vendor-notifications"])
|
||||||
router.include_router(messages.router, tags=["vendor-messages"])
|
router.include_router(messages.router, tags=["vendor-messages"])
|
||||||
router.include_router(analytics.router, tags=["vendor-analytics"])
|
|
||||||
router.include_router(usage.router, tags=["vendor-usage"])
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
55
app/api/v1/vendor/analytics.py
vendored
55
app/api/v1/vendor/analytics.py
vendored
@@ -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"]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
261
app/api/v1/vendor/order_item_exceptions.py
vendored
261
app/api/v1/vendor/order_item_exceptions.py
vendored
@@ -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,
|
|
||||||
)
|
|
||||||
269
app/api/v1/vendor/orders.py
vendored
269
app/api/v1/vendor/orders.py
vendored
@@ -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)
|
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
Billing module API routes.
|
Billing module API routes.
|
||||||
|
|
||||||
Provides REST API endpoints for subscription and billing management:
|
Provides REST API endpoints for subscription and billing management:
|
||||||
- Admin API: Subscription tier management, vendor subscriptions, billing history
|
- Admin API: Subscription tier management, vendor subscriptions, billing history, features
|
||||||
- Vendor API: Subscription status, tier comparison, invoices
|
- 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
|
from app.modules.billing.routes.api.admin import admin_router
|
||||||
|
|||||||
@@ -334,3 +334,12 @@ def update_vendor_subscription(
|
|||||||
products_count=usage["products_count"],
|
products_count=usage["products_count"],
|
||||||
team_count=usage["team_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"])
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# app/api/v1/admin/features.py
|
# app/modules/billing/routes/api/admin_features.py
|
||||||
"""
|
"""
|
||||||
Admin feature management endpoints.
|
Admin feature management endpoints.
|
||||||
|
|
||||||
@@ -7,6 +7,8 @@ Provides endpoints for:
|
|||||||
- Updating tier feature assignments
|
- Updating tier feature assignments
|
||||||
- Managing feature metadata
|
- Managing feature metadata
|
||||||
- Viewing feature usage statistics
|
- Viewing feature usage statistics
|
||||||
|
|
||||||
|
All routes require module access control for the 'billing' module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -15,12 +17,15 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
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.core.database import get_db
|
||||||
from app.services.feature_service import feature_service
|
from app.services.feature_service import feature_service
|
||||||
from models.schema.auth import UserContext
|
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__)
|
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(
|
def list_features(
|
||||||
category: str | None = Query(None, description="Filter by category"),
|
category: str | None = Query(None, description="Filter by category"),
|
||||||
active_only: bool = Query(False, description="Only active features"),
|
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(
|
def list_categories(
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -169,7 +174,7 @@ def list_categories(
|
|||||||
return CategoryListResponse(categories=categories)
|
return CategoryListResponse(categories=categories)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tiers", response_model=TierListWithFeaturesResponse)
|
@admin_features_router.get("/tiers", response_model=TierListWithFeaturesResponse)
|
||||||
def list_tiers_with_features(
|
def list_tiers_with_features(
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
db: Session = Depends(get_db),
|
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(
|
def get_feature(
|
||||||
feature_code: str,
|
feature_code: str,
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
@@ -213,7 +218,7 @@ def get_feature(
|
|||||||
return _feature_to_response(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(
|
def update_feature(
|
||||||
feature_code: str,
|
feature_code: str,
|
||||||
request: UpdateFeatureRequest,
|
request: UpdateFeatureRequest,
|
||||||
@@ -249,7 +254,7 @@ def update_feature(
|
|||||||
return _feature_to_response(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(
|
def update_tier_features(
|
||||||
tier_code: str,
|
tier_code: str,
|
||||||
request: UpdateTierFeaturesRequest,
|
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(
|
def get_tier_features(
|
||||||
tier_code: str,
|
tier_code: str,
|
||||||
current_user: UserContext = Depends(get_current_admin_api),
|
current_user: UserContext = Depends(get_current_admin_api),
|
||||||
@@ -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_features import vendor_features_router
|
||||||
from app.modules.billing.routes.api.vendor_checkout import vendor_checkout_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_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_features_router, tags=["vendor-features"])
|
||||||
vendor_router.include_router(vendor_checkout_router, tags=["vendor-billing"])
|
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_addons_router, tags=["vendor-billing-addons"])
|
||||||
|
vendor_router.include_router(vendor_usage_router, tags=["vendor-usage"])
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# app/api/v1/vendor/features.py
|
# app/modules/billing/routes/api/vendor_features.py
|
||||||
"""
|
"""
|
||||||
Vendor features API endpoints.
|
Vendor features API endpoints.
|
||||||
|
|
||||||
@@ -12,6 +12,8 @@ Endpoints:
|
|||||||
- GET /features - Full feature list with availability and metadata
|
- GET /features - Full feature list with availability and metadata
|
||||||
- GET /features/{code} - Single feature details with upgrade info
|
- GET /features/{code} - Single feature details with upgrade info
|
||||||
- GET /features/categories - List feature categories
|
- GET /features/categories - List feature categories
|
||||||
|
|
||||||
|
All routes require module access control for the 'billing' module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -20,13 +22,16 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
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.core.database import get_db
|
||||||
from app.exceptions import FeatureNotFoundError
|
from app.exceptions import FeatureNotFoundError
|
||||||
from app.services.feature_service import feature_service
|
from app.services.feature_service import feature_service
|
||||||
from models.schema.auth import UserContext
|
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__)
|
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(
|
def get_available_features(
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
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(
|
def get_features(
|
||||||
category: str | None = Query(None, description="Filter by category"),
|
category: str | None = Query(None, description="Filter by category"),
|
||||||
include_unavailable: bool = Query(True, description="Include features not available to vendor"),
|
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(
|
def get_feature_categories(
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -224,7 +229,7 @@ def get_feature_categories(
|
|||||||
return CategoryListResponse(categories=categories)
|
return CategoryListResponse(categories=categories)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/grouped", response_model=FeatureGroupedResponse)
|
@vendor_features_router.get("/grouped", response_model=FeatureGroupedResponse)
|
||||||
def get_features_grouped(
|
def get_features_grouped(
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
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(
|
def get_feature_detail(
|
||||||
feature_code: str,
|
feature_code: str,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
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(
|
def check_feature(
|
||||||
feature_code: str,
|
feature_code: str,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# app/api/v1/vendor/usage.py
|
# app/modules/billing/routes/api/vendor_usage.py
|
||||||
"""
|
"""
|
||||||
Vendor usage and limits API endpoints.
|
Vendor usage and limits API endpoints.
|
||||||
|
|
||||||
@@ -6,6 +6,8 @@ Provides endpoints for:
|
|||||||
- Current usage vs limits
|
- Current usage vs limits
|
||||||
- Upgrade recommendations
|
- Upgrade recommendations
|
||||||
- Approaching limit warnings
|
- Approaching limit warnings
|
||||||
|
|
||||||
|
Migrated from app/api/v1/vendor/usage.py to billing module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -14,12 +16,15 @@ from fastapi import APIRouter, Depends
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
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.core.database import get_db
|
||||||
from app.services.usage_service import usage_service
|
from app.services.usage_service import usage_service
|
||||||
from models.schema.auth import UserContext
|
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__)
|
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(
|
def get_usage(
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
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(
|
def check_limit(
|
||||||
limit_type: str,
|
limit_type: str,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
@@ -6,23 +6,20 @@ This module provides functions to register inventory routes
|
|||||||
with module-based access control.
|
with module-based access control.
|
||||||
|
|
||||||
NOTE: Routers are NOT auto-imported to avoid circular dependencies.
|
NOTE: Routers are NOT auto-imported to avoid circular dependencies.
|
||||||
Import directly from admin.py or vendor.py as needed:
|
Import directly from api submodule as needed:
|
||||||
from app.modules.inventory.routes.admin import admin_router
|
from app.modules.inventory.routes.api import admin_router
|
||||||
from app.modules.inventory.routes.vendor import vendor_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"]
|
__all__ = ["admin_router", "vendor_router"]
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str):
|
def __getattr__(name: str):
|
||||||
"""Lazy import routers to avoid circular dependencies."""
|
"""Lazy import routers to avoid circular dependencies."""
|
||||||
if name == "admin_router":
|
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
|
return admin_router
|
||||||
elif name == "vendor_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
|
return vendor_router
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -1,4 +1,21 @@
|
|||||||
# Routes will be migrated here from legacy locations
|
# app/modules/inventory/routes/api/__init__.py
|
||||||
# TODO: Move actual route implementations from app/api/v1/
|
"""
|
||||||
|
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}")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# app/api/v1/admin/inventory.py
|
# app/modules/inventory/routes/api/admin.py
|
||||||
"""
|
"""
|
||||||
Admin inventory management endpoints.
|
Admin inventory management endpoints.
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
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.core.database import get_db
|
||||||
from app.services.inventory_import_service import inventory_import_service
|
from app.services.inventory_import_service import inventory_import_service
|
||||||
from app.services.inventory_service import inventory_service
|
from app.services.inventory_service import inventory_service
|
||||||
@@ -43,7 +43,10 @@ from app.modules.inventory.schemas import (
|
|||||||
ProductInventorySummary,
|
ProductInventorySummary,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/inventory")
|
admin_router = APIRouter(
|
||||||
|
prefix="/inventory",
|
||||||
|
dependencies=[Depends(require_module_access("inventory"))],
|
||||||
|
)
|
||||||
logger = logging.getLogger(__name__)
|
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(
|
def get_all_inventory(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=500),
|
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(
|
def get_inventory_stats(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: UserContext = Depends(get_current_admin_api),
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
@@ -88,7 +91,7 @@ def get_inventory_stats(
|
|||||||
return inventory_service.get_inventory_stats_admin(db)
|
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(
|
def get_low_stock_items(
|
||||||
threshold: int = Query(10, ge=0, description="Stock threshold"),
|
threshold: int = Query(10, ge=0, description="Stock threshold"),
|
||||||
vendor_id: int | None = Query(None, description="Filter by vendor"),
|
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(
|
def get_vendors_with_inventory(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: UserContext = Depends(get_current_admin_api),
|
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)
|
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(
|
def get_inventory_locations(
|
||||||
vendor_id: int | None = Query(None, description="Filter by vendor"),
|
vendor_id: int | None = Query(None, description="Filter by vendor"),
|
||||||
db: Session = Depends(get_db),
|
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(
|
def get_vendor_inventory(
|
||||||
vendor_id: int,
|
vendor_id: int,
|
||||||
skip: int = Query(0, ge=0),
|
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(
|
def get_product_inventory(
|
||||||
product_id: int,
|
product_id: int,
|
||||||
db: Session = Depends(get_db),
|
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(
|
def set_inventory(
|
||||||
inventory_data: AdminInventoryCreate,
|
inventory_data: AdminInventoryCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -201,7 +204,7 @@ def set_inventory(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/adjust", response_model=InventoryResponse)
|
@admin_router.post("/adjust", response_model=InventoryResponse)
|
||||||
def adjust_inventory(
|
def adjust_inventory(
|
||||||
adjustment: AdminInventoryAdjust,
|
adjustment: AdminInventoryAdjust,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -240,7 +243,7 @@ def adjust_inventory(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{inventory_id}", response_model=InventoryResponse)
|
@admin_router.put("/{inventory_id}", response_model=InventoryResponse)
|
||||||
def update_inventory(
|
def update_inventory(
|
||||||
inventory_id: int,
|
inventory_id: int,
|
||||||
inventory_update: InventoryUpdate,
|
inventory_update: InventoryUpdate,
|
||||||
@@ -264,7 +267,7 @@ def update_inventory(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{inventory_id}", response_model=InventoryMessageResponse)
|
@admin_router.delete("/{inventory_id}", response_model=InventoryMessageResponse)
|
||||||
def delete_inventory(
|
def delete_inventory(
|
||||||
inventory_id: int,
|
inventory_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -317,7 +320,7 @@ class InventoryImportResponse(BaseModel):
|
|||||||
errors: list[str]
|
errors: list[str]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import", response_model=InventoryImportResponse)
|
@admin_router.post("/import", response_model=InventoryImportResponse)
|
||||||
async def import_inventory(
|
async def import_inventory(
|
||||||
file: UploadFile = File(..., description="TSV/CSV file with BIN, EAN, PRODUCT, QUANTITY columns"),
|
file: UploadFile = File(..., description="TSV/CSV file with BIN, EAN, PRODUCT, QUANTITY columns"),
|
||||||
vendor_id: int = Form(..., description="Vendor ID"),
|
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(
|
def get_all_transactions(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=200),
|
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(
|
def get_transaction_stats(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: UserContext = Depends(get_current_admin_api),
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# app/api/v1/vendor/inventory.py
|
# app/modules/inventory/routes/api/vendor.py
|
||||||
"""
|
"""
|
||||||
Vendor inventory management endpoints.
|
Vendor inventory management endpoints.
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ import logging
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.orm import Session
|
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.core.database import get_db
|
||||||
from app.services.inventory_service import inventory_service
|
from app.services.inventory_service import inventory_service
|
||||||
from app.services.inventory_transaction_service import inventory_transaction_service
|
from app.services.inventory_transaction_service import inventory_transaction_service
|
||||||
@@ -31,11 +31,14 @@ from app.modules.inventory.schemas import (
|
|||||||
ProductTransactionHistoryResponse,
|
ProductTransactionHistoryResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
vendor_router = APIRouter(
|
||||||
|
prefix="/inventory",
|
||||||
|
dependencies=[Depends(require_module_access("inventory"))],
|
||||||
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/inventory/set", response_model=InventoryResponse)
|
@vendor_router.post("/set", response_model=InventoryResponse)
|
||||||
def set_inventory(
|
def set_inventory(
|
||||||
inventory: InventoryCreate,
|
inventory: InventoryCreate,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
@@ -49,7 +52,7 @@ def set_inventory(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/inventory/adjust", response_model=InventoryResponse)
|
@vendor_router.post("/adjust", response_model=InventoryResponse)
|
||||||
def adjust_inventory(
|
def adjust_inventory(
|
||||||
adjustment: InventoryAdjust,
|
adjustment: InventoryAdjust,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
@@ -63,7 +66,7 @@ def adjust_inventory(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/inventory/reserve", response_model=InventoryResponse)
|
@vendor_router.post("/reserve", response_model=InventoryResponse)
|
||||||
def reserve_inventory(
|
def reserve_inventory(
|
||||||
reservation: InventoryReserve,
|
reservation: InventoryReserve,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
@@ -77,7 +80,7 @@ def reserve_inventory(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/inventory/release", response_model=InventoryResponse)
|
@vendor_router.post("/release", response_model=InventoryResponse)
|
||||||
def release_reservation(
|
def release_reservation(
|
||||||
reservation: InventoryReserve,
|
reservation: InventoryReserve,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
@@ -91,7 +94,7 @@ def release_reservation(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/inventory/fulfill", response_model=InventoryResponse)
|
@vendor_router.post("/fulfill", response_model=InventoryResponse)
|
||||||
def fulfill_reservation(
|
def fulfill_reservation(
|
||||||
reservation: InventoryReserve,
|
reservation: InventoryReserve,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
@@ -105,7 +108,7 @@ def fulfill_reservation(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/inventory/product/{product_id}", response_model=ProductInventorySummary)
|
@vendor_router.get("/product/{product_id}", response_model=ProductInventorySummary)
|
||||||
def get_product_inventory(
|
def get_product_inventory(
|
||||||
product_id: int,
|
product_id: int,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
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(
|
def get_vendor_inventory(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
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(
|
def update_inventory(
|
||||||
inventory_id: int,
|
inventory_id: int,
|
||||||
inventory_update: InventoryUpdate,
|
inventory_update: InventoryUpdate,
|
||||||
@@ -154,7 +157,7 @@ def update_inventory(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/inventory/{inventory_id}", response_model=InventoryMessageResponse)
|
@vendor_router.delete("/{inventory_id}", response_model=InventoryMessageResponse)
|
||||||
def delete_inventory(
|
def delete_inventory(
|
||||||
inventory_id: int,
|
inventory_id: int,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
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(
|
def get_inventory_transactions(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=200),
|
limit: int = Query(50, ge=1, le=200),
|
||||||
@@ -203,8 +206,8 @@ def get_inventory_transactions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@vendor_router.get(
|
||||||
"/inventory/transactions/product/{product_id}",
|
"/transactions/product/{product_id}",
|
||||||
response_model=ProductTransactionHistoryResponse,
|
response_model=ProductTransactionHistoryResponse,
|
||||||
)
|
)
|
||||||
def get_product_transaction_history(
|
def get_product_transaction_history(
|
||||||
@@ -228,8 +231,8 @@ def get_product_transaction_history(
|
|||||||
return ProductTransactionHistoryResponse(**result)
|
return ProductTransactionHistoryResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@vendor_router.get(
|
||||||
"/inventory/transactions/order/{order_id}",
|
"/transactions/order/{order_id}",
|
||||||
response_model=OrderTransactionHistoryResponse,
|
response_model=OrderTransactionHistoryResponse,
|
||||||
)
|
)
|
||||||
def get_order_transaction_history(
|
def get_order_transaction_history(
|
||||||
@@ -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)
|
|
||||||
@@ -2,36 +2,11 @@
|
|||||||
"""
|
"""
|
||||||
Marketplace module route registration.
|
Marketplace module route registration.
|
||||||
|
|
||||||
This module provides marketplace routes with module-based access control.
|
|
||||||
|
|
||||||
Structure:
|
Structure:
|
||||||
- routes/api/ - REST API endpoints
|
- routes/api/ - REST API endpoints
|
||||||
- routes/pages/ - HTML page rendering (templates)
|
- routes/pages/ - HTML page rendering (templates)
|
||||||
|
|
||||||
NOTE: Routers are not eagerly imported here to avoid circular imports.
|
Import routers directly from their respective files:
|
||||||
Import directly from routes/api/admin.py or routes/api/vendor.py instead.
|
- 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"]
|
|
||||||
|
|||||||
@@ -2,34 +2,7 @@
|
|||||||
"""
|
"""
|
||||||
Marketplace module API routes.
|
Marketplace module API routes.
|
||||||
|
|
||||||
Provides REST API endpoints for marketplace integration:
|
Import routers directly from their respective files:
|
||||||
- Admin API: Import jobs, vendor directory, marketplace products
|
- from app.modules.marketplace.routes.api.admin import admin_router, admin_letzshop_router
|
||||||
- Vendor API: Letzshop sync, product imports, exports
|
- from app.modules.marketplace.routes.api.vendor import vendor_router, vendor_letzshop_router
|
||||||
|
|
||||||
NOTE: Routers are not eagerly imported here to avoid circular imports.
|
|
||||||
Import directly from admin.py or vendor.py instead.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
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"]
|
|
||||||
|
|||||||
@@ -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.
|
Admin API endpoints for Letzshop marketplace integration.
|
||||||
|
|
||||||
@@ -7,6 +7,8 @@ Provides admin-level management of:
|
|||||||
- Connection testing
|
- Connection testing
|
||||||
- Sync triggers and status
|
- Sync triggers and status
|
||||||
- Order overview
|
- Order overview
|
||||||
|
|
||||||
|
All routes require module access control for the 'marketplace' module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -14,7 +16,7 @@ import logging
|
|||||||
from fastapi import APIRouter, BackgroundTasks, Depends, Path, Query
|
from fastapi import APIRouter, BackgroundTasks, Depends, Path, Query
|
||||||
from sqlalchemy.orm import Session
|
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.core.database import get_db
|
||||||
from app.exceptions import (
|
from app.exceptions import (
|
||||||
OrderHasUnresolvedExceptionsException,
|
OrderHasUnresolvedExceptionsException,
|
||||||
@@ -22,7 +24,7 @@ from app.exceptions import (
|
|||||||
ValidationException,
|
ValidationException,
|
||||||
)
|
)
|
||||||
from app.services.order_item_exception_service import order_item_exception_service
|
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,
|
CredentialsNotFoundError,
|
||||||
LetzshopClientError,
|
LetzshopClientError,
|
||||||
LetzshopCredentialsService,
|
LetzshopCredentialsService,
|
||||||
@@ -64,7 +66,10 @@ from app.modules.marketplace.schemas import (
|
|||||||
LetzshopVendorOverview,
|
LetzshopVendorOverview,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/letzshop")
|
admin_letzshop_router = APIRouter(
|
||||||
|
prefix="/letzshop",
|
||||||
|
dependencies=[Depends(require_module_access("marketplace"))],
|
||||||
|
)
|
||||||
logger = logging.getLogger(__name__)
|
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(
|
def list_vendors_letzshop_status(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
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",
|
"/vendors/{vendor_id}/credentials",
|
||||||
response_model=LetzshopCredentialsResponse,
|
response_model=LetzshopCredentialsResponse,
|
||||||
)
|
)
|
||||||
@@ -165,7 +170,7 @@ def get_vendor_credentials(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@admin_letzshop_router.post(
|
||||||
"/vendors/{vendor_id}/credentials",
|
"/vendors/{vendor_id}/credentials",
|
||||||
response_model=LetzshopCredentialsResponse,
|
response_model=LetzshopCredentialsResponse,
|
||||||
)
|
)
|
||||||
@@ -212,7 +217,7 @@ def create_or_update_vendor_credentials(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@admin_letzshop_router.patch(
|
||||||
"/vendors/{vendor_id}/credentials",
|
"/vendors/{vendor_id}/credentials",
|
||||||
response_model=LetzshopCredentialsResponse,
|
response_model=LetzshopCredentialsResponse,
|
||||||
)
|
)
|
||||||
@@ -262,7 +267,7 @@ def update_vendor_credentials(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@admin_letzshop_router.delete(
|
||||||
"/vendors/{vendor_id}/credentials",
|
"/vendors/{vendor_id}/credentials",
|
||||||
response_model=LetzshopSuccessResponse,
|
response_model=LetzshopSuccessResponse,
|
||||||
)
|
)
|
||||||
@@ -301,7 +306,7 @@ def delete_vendor_credentials(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@admin_letzshop_router.post(
|
||||||
"/vendors/{vendor_id}/test",
|
"/vendors/{vendor_id}/test",
|
||||||
response_model=LetzshopConnectionTestResponse,
|
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(
|
def test_api_key(
|
||||||
test_request: LetzshopConnectionTestRequest,
|
test_request: LetzshopConnectionTestRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -356,7 +361,7 @@ def test_api_key(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@admin_letzshop_router.get(
|
||||||
"/orders",
|
"/orders",
|
||||||
response_model=LetzshopOrderListResponse,
|
response_model=LetzshopOrderListResponse,
|
||||||
)
|
)
|
||||||
@@ -446,7 +451,7 @@ def list_all_letzshop_orders(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@admin_letzshop_router.get(
|
||||||
"/vendors/{vendor_id}/orders",
|
"/vendors/{vendor_id}/orders",
|
||||||
response_model=LetzshopOrderListResponse,
|
response_model=LetzshopOrderListResponse,
|
||||||
)
|
)
|
||||||
@@ -536,7 +541,7 @@ def list_vendor_letzshop_orders(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@admin_letzshop_router.get(
|
||||||
"/orders/{order_id}",
|
"/orders/{order_id}",
|
||||||
response_model=LetzshopOrderDetailResponse,
|
response_model=LetzshopOrderDetailResponse,
|
||||||
)
|
)
|
||||||
@@ -615,7 +620,7 @@ def get_letzshop_order_detail(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@admin_letzshop_router.post(
|
||||||
"/vendors/{vendor_id}/sync",
|
"/vendors/{vendor_id}/sync",
|
||||||
response_model=LetzshopSyncTriggerResponse,
|
response_model=LetzshopSyncTriggerResponse,
|
||||||
)
|
)
|
||||||
@@ -711,7 +716,7 @@ def trigger_vendor_sync(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@admin_letzshop_router.get(
|
||||||
"/jobs",
|
"/jobs",
|
||||||
response_model=LetzshopJobsListResponse,
|
response_model=LetzshopJobsListResponse,
|
||||||
)
|
)
|
||||||
@@ -742,7 +747,7 @@ def list_all_letzshop_jobs(
|
|||||||
return LetzshopJobsListResponse(jobs=jobs, total=total)
|
return LetzshopJobsListResponse(jobs=jobs, total=total)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@admin_letzshop_router.get(
|
||||||
"/vendors/{vendor_id}/jobs",
|
"/vendors/{vendor_id}/jobs",
|
||||||
response_model=LetzshopJobsListResponse,
|
response_model=LetzshopJobsListResponse,
|
||||||
)
|
)
|
||||||
@@ -786,7 +791,7 @@ def list_vendor_letzshop_jobs(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@admin_letzshop_router.post(
|
||||||
"/vendors/{vendor_id}/import-history",
|
"/vendors/{vendor_id}/import-history",
|
||||||
response_model=LetzshopHistoricalImportStartResponse,
|
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",
|
"/vendors/{vendor_id}/import-history/{job_id}/status",
|
||||||
response_model=LetzshopHistoricalImportJobResponse,
|
response_model=LetzshopHistoricalImportJobResponse,
|
||||||
)
|
)
|
||||||
@@ -878,7 +883,7 @@ def get_historical_import_status(
|
|||||||
return LetzshopHistoricalImportJobResponse.model_validate(job)
|
return LetzshopHistoricalImportJobResponse.model_validate(job)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@admin_letzshop_router.get(
|
||||||
"/vendors/{vendor_id}/import-summary",
|
"/vendors/{vendor_id}/import-summary",
|
||||||
)
|
)
|
||||||
def get_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",
|
"/vendors/{vendor_id}/orders/{order_id}/confirm",
|
||||||
response_model=FulfillmentOperationResponse,
|
response_model=FulfillmentOperationResponse,
|
||||||
)
|
)
|
||||||
@@ -996,7 +1001,7 @@ def confirm_order(
|
|||||||
return FulfillmentOperationResponse(success=False, message=str(e))
|
return FulfillmentOperationResponse(success=False, message=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@admin_letzshop_router.post(
|
||||||
"/vendors/{vendor_id}/orders/{order_id}/reject",
|
"/vendors/{vendor_id}/orders/{order_id}/reject",
|
||||||
response_model=FulfillmentOperationResponse,
|
response_model=FulfillmentOperationResponse,
|
||||||
)
|
)
|
||||||
@@ -1070,7 +1075,7 @@ def reject_order(
|
|||||||
return FulfillmentOperationResponse(success=False, message=str(e))
|
return FulfillmentOperationResponse(success=False, message=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@admin_letzshop_router.post(
|
||||||
"/vendors/{vendor_id}/orders/{order_id}/items/{item_id}/confirm",
|
"/vendors/{vendor_id}/orders/{order_id}/items/{item_id}/confirm",
|
||||||
response_model=FulfillmentOperationResponse,
|
response_model=FulfillmentOperationResponse,
|
||||||
)
|
)
|
||||||
@@ -1135,7 +1140,7 @@ def confirm_single_item(
|
|||||||
return FulfillmentOperationResponse(success=False, message=str(e))
|
return FulfillmentOperationResponse(success=False, message=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@admin_letzshop_router.post(
|
||||||
"/vendors/{vendor_id}/orders/{order_id}/items/{item_id}/decline",
|
"/vendors/{vendor_id}/orders/{order_id}/items/{item_id}/decline",
|
||||||
response_model=FulfillmentOperationResponse,
|
response_model=FulfillmentOperationResponse,
|
||||||
)
|
)
|
||||||
@@ -1192,7 +1197,7 @@ def decline_single_item(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@admin_letzshop_router.post(
|
||||||
"/vendors/{vendor_id}/sync-tracking",
|
"/vendors/{vendor_id}/sync-tracking",
|
||||||
response_model=LetzshopSyncTriggerResponse,
|
response_model=LetzshopSyncTriggerResponse,
|
||||||
)
|
)
|
||||||
@@ -1293,7 +1298,7 @@ def get_vendor_sync_service(db: Session) -> LetzshopVendorSyncService:
|
|||||||
return LetzshopVendorSyncService(db)
|
return LetzshopVendorSyncService(db)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/vendor-directory/sync")
|
@admin_letzshop_router.post("/vendor-directory/sync")
|
||||||
def trigger_vendor_directory_sync(
|
def trigger_vendor_directory_sync(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -1345,7 +1350,7 @@ def trigger_vendor_directory_sync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@admin_letzshop_router.get(
|
||||||
"/vendor-directory/stats",
|
"/vendor-directory/stats",
|
||||||
response_model=LetzshopVendorDirectoryStatsResponse,
|
response_model=LetzshopVendorDirectoryStatsResponse,
|
||||||
)
|
)
|
||||||
@@ -1365,7 +1370,7 @@ def get_vendor_directory_stats(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@admin_letzshop_router.get(
|
||||||
"/vendor-directory/vendors",
|
"/vendor-directory/vendors",
|
||||||
response_model=LetzshopCachedVendorListResponse,
|
response_model=LetzshopCachedVendorListResponse,
|
||||||
)
|
)
|
||||||
@@ -1422,7 +1427,7 @@ def list_cached_vendors(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@admin_letzshop_router.get(
|
||||||
"/vendor-directory/vendors/{slug}",
|
"/vendor-directory/vendors/{slug}",
|
||||||
response_model=LetzshopCachedVendorDetailResponse,
|
response_model=LetzshopCachedVendorDetailResponse,
|
||||||
)
|
)
|
||||||
@@ -1479,7 +1484,7 @@ def get_cached_vendor_detail(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@admin_letzshop_router.post(
|
||||||
"/vendor-directory/vendors/{slug}/create-vendor",
|
"/vendor-directory/vendors/{slug}/create-vendor",
|
||||||
response_model=LetzshopCreateVendorFromCacheResponse,
|
response_model=LetzshopCreateVendorFromCacheResponse,
|
||||||
)
|
)
|
||||||
@@ -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.
|
Marketplace import job monitoring endpoints for admin.
|
||||||
|
|
||||||
|
All routes require module access control for the 'marketplace' module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -8,12 +10,11 @@ import logging
|
|||||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||||
from sqlalchemy.orm import Session
|
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.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.stats_service import stats_service
|
||||||
from app.services.vendor_service import vendor_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 models.schema.auth import UserContext
|
||||||
from app.modules.marketplace.schemas import (
|
from app.modules.marketplace.schemas import (
|
||||||
AdminMarketplaceImportJobListResponse,
|
AdminMarketplaceImportJobListResponse,
|
||||||
@@ -26,11 +27,14 @@ from app.modules.marketplace.schemas import (
|
|||||||
)
|
)
|
||||||
from app.modules.analytics.schemas import ImportStatsResponse
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=AdminMarketplaceImportJobListResponse)
|
@admin_marketplace_router.get("", response_model=AdminMarketplaceImportJobListResponse)
|
||||||
def get_all_marketplace_import_jobs(
|
def get_all_marketplace_import_jobs(
|
||||||
marketplace: str | None = Query(None),
|
marketplace: str | None = Query(None),
|
||||||
status: 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(
|
async def create_marketplace_import_job(
|
||||||
request: AdminMarketplaceImportJobRequest,
|
request: AdminMarketplaceImportJobRequest,
|
||||||
background_tasks: BackgroundTasks,
|
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
|
# 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(
|
def get_import_statistics(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: UserContext = Depends(get_current_admin_api),
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
@@ -129,7 +133,7 @@ def get_import_statistics(
|
|||||||
return ImportStatsResponse(**stats)
|
return ImportStatsResponse(**stats)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{job_id}", response_model=AdminMarketplaceImportJobResponse)
|
@admin_marketplace_router.get("/{job_id}", response_model=AdminMarketplaceImportJobResponse)
|
||||||
def get_marketplace_import_job(
|
def get_marketplace_import_job(
|
||||||
job_id: int,
|
job_id: int,
|
||||||
db: Session = Depends(get_db),
|
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)
|
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(
|
def get_import_job_errors(
|
||||||
job_id: int,
|
job_id: int,
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
@@ -2,43 +2,32 @@
|
|||||||
"""
|
"""
|
||||||
Marketplace module vendor routes.
|
Marketplace module vendor routes.
|
||||||
|
|
||||||
This module wraps the existing vendor marketplace routes and adds
|
This module aggregates all marketplace vendor routers into a single router
|
||||||
module-based access control. Routes are re-exported from the
|
for auto-discovery. Routes are defined in dedicated files with module-based
|
||||||
original location with the module access dependency.
|
access control.
|
||||||
|
|
||||||
Includes:
|
Includes:
|
||||||
- /marketplace/* - Marketplace settings
|
- /marketplace/* - Marketplace import management
|
||||||
- /letzshop/* - Letzshop integration
|
- /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
|
# Include marketplace import routes
|
||||||
# (direct import triggers app.api.v1.vendor.__init__.py which imports us)
|
vendor_router.include_router(vendor_marketplace_router)
|
||||||
_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
|
|
||||||
|
|
||||||
# Create module-aware router for marketplace
|
# Include letzshop routes
|
||||||
vendor_router = APIRouter(
|
vendor_router.include_router(vendor_letzshop_router)
|
||||||
prefix="/marketplace",
|
|
||||||
dependencies=[Depends(require_module_access("marketplace"))],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Re-export all routes from the original marketplace module
|
# Include onboarding routes
|
||||||
for route in marketplace_original_router.routes:
|
vendor_router.include_router(vendor_onboarding_router)
|
||||||
vendor_router.routes.append(route)
|
|
||||||
|
|
||||||
# Create separate router for letzshop integration
|
__all__ = ["vendor_router"]
|
||||||
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)
|
|
||||||
|
|||||||
@@ -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.
|
Vendor API endpoints for Letzshop marketplace integration.
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@ Provides vendor-level management of:
|
|||||||
- Fulfillment operations (confirm, reject, tracking)
|
- Fulfillment operations (confirm, reject, tracking)
|
||||||
|
|
||||||
Vendor Context: Uses token_vendor_id from JWT token.
|
Vendor Context: Uses token_vendor_id from JWT token.
|
||||||
|
|
||||||
|
All routes require module access control for the 'marketplace' module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -16,7 +18,7 @@ import logging
|
|||||||
from fastapi import APIRouter, Depends, Path, Query
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
from sqlalchemy.orm import Session
|
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.core.database import get_db
|
||||||
from app.exceptions import (
|
from app.exceptions import (
|
||||||
OrderHasUnresolvedExceptionsException,
|
OrderHasUnresolvedExceptionsException,
|
||||||
@@ -24,7 +26,7 @@ from app.exceptions import (
|
|||||||
ValidationException,
|
ValidationException,
|
||||||
)
|
)
|
||||||
from app.services.order_item_exception_service import order_item_exception_service
|
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,
|
CredentialsNotFoundError,
|
||||||
LetzshopClientError,
|
LetzshopClientError,
|
||||||
LetzshopCredentialsService,
|
LetzshopCredentialsService,
|
||||||
@@ -55,7 +57,10 @@ from app.modules.marketplace.schemas import (
|
|||||||
LetzshopSyncTriggerResponse,
|
LetzshopSyncTriggerResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/letzshop")
|
vendor_letzshop_router = APIRouter(
|
||||||
|
prefix="/letzshop",
|
||||||
|
dependencies=[Depends(require_module_access("marketplace"))],
|
||||||
|
)
|
||||||
logger = logging.getLogger(__name__)
|
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(
|
def get_letzshop_status(
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -90,7 +95,7 @@ def get_letzshop_status(
|
|||||||
return LetzshopCredentialsStatus(**status)
|
return LetzshopCredentialsStatus(**status)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/credentials", response_model=LetzshopCredentialsResponse)
|
@vendor_letzshop_router.get("/credentials", response_model=LetzshopCredentialsResponse)
|
||||||
def get_credentials(
|
def get_credentials(
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
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(
|
def save_credentials(
|
||||||
credentials_data: LetzshopCredentialsCreate,
|
credentials_data: LetzshopCredentialsCreate,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
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(
|
def update_credentials(
|
||||||
credentials_data: LetzshopCredentialsUpdate,
|
credentials_data: LetzshopCredentialsUpdate,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
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(
|
def delete_credentials(
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
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(
|
def test_connection(
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
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(
|
def test_api_key(
|
||||||
test_request: LetzshopConnectionTestRequest,
|
test_request: LetzshopConnectionTestRequest,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
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(
|
def list_orders(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=200),
|
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(
|
def get_order(
|
||||||
order_id: int = Path(..., description="Order ID"),
|
order_id: int = Path(..., description="Order ID"),
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
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(
|
def import_orders(
|
||||||
sync_request: LetzshopSyncTriggerRequest = LetzshopSyncTriggerRequest(),
|
sync_request: LetzshopSyncTriggerRequest = LetzshopSyncTriggerRequest(),
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
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(
|
def confirm_order(
|
||||||
order_id: int = Path(..., description="Order ID"),
|
order_id: int = Path(..., description="Order ID"),
|
||||||
confirm_request: FulfillmentConfirmRequest | None = None,
|
confirm_request: FulfillmentConfirmRequest | None = None,
|
||||||
@@ -521,7 +526,7 @@ def confirm_order(
|
|||||||
return FulfillmentOperationResponse(success=False, message=str(e))
|
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(
|
def reject_order(
|
||||||
order_id: int = Path(..., description="Order ID"),
|
order_id: int = Path(..., description="Order ID"),
|
||||||
reject_request: FulfillmentRejectRequest | None = None,
|
reject_request: FulfillmentRejectRequest | None = None,
|
||||||
@@ -576,7 +581,7 @@ def reject_order(
|
|||||||
return FulfillmentOperationResponse(success=False, message=str(e))
|
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(
|
def set_order_tracking(
|
||||||
order_id: int = Path(..., description="Order ID"),
|
order_id: int = Path(..., description="Order ID"),
|
||||||
tracking_request: FulfillmentTrackingRequest = ...,
|
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(
|
def list_sync_logs(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=200),
|
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(
|
def list_fulfillment_queue(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=200),
|
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(
|
def export_products_letzshop(
|
||||||
language: str = Query(
|
language: str = Query(
|
||||||
"en", description="Language for title/description (en, fr, de)"
|
"en", description="Language for title/description (en, fr, de)"
|
||||||
@@ -764,7 +769,7 @@ def export_products_letzshop(
|
|||||||
"""
|
"""
|
||||||
from fastapi.responses import Response
|
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
|
from app.services.vendor_service import vendor_service
|
||||||
|
|
||||||
vendor_id = current_user.token_vendor_id
|
vendor_id = current_user.token_vendor_id
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
# app/api/v1/vendor/marketplace.py
|
# app/modules/marketplace/routes/api/vendor_marketplace.py
|
||||||
"""
|
"""
|
||||||
Marketplace import endpoints for vendors.
|
Marketplace import endpoints for vendors.
|
||||||
|
|
||||||
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
|
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.
|
The get_current_vendor_api dependency guarantees token_vendor_id is present.
|
||||||
|
|
||||||
|
All routes require module access control for the 'marketplace' module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -11,11 +13,10 @@ import logging
|
|||||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||||
from sqlalchemy.orm import Session
|
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.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.services.vendor_service import vendor_service
|
||||||
from app.tasks.background_tasks import process_marketplace_import
|
|
||||||
from middleware.decorators import rate_limit
|
from middleware.decorators import rate_limit
|
||||||
from models.schema.auth import UserContext
|
from models.schema.auth import UserContext
|
||||||
from app.modules.marketplace.schemas import (
|
from app.modules.marketplace.schemas import (
|
||||||
@@ -23,11 +24,14 @@ from app.modules.marketplace.schemas import (
|
|||||||
MarketplaceImportJobResponse,
|
MarketplaceImportJobResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/marketplace")
|
vendor_marketplace_router = APIRouter(
|
||||||
|
prefix="/marketplace",
|
||||||
|
dependencies=[Depends(require_module_access("marketplace"))],
|
||||||
|
)
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
@rate_limit(max_requests=10, window_seconds=3600)
|
||||||
async def import_products_from_marketplace(
|
async def import_products_from_marketplace(
|
||||||
request: MarketplaceImportJobRequest,
|
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(
|
def get_marketplace_import_status(
|
||||||
job_id: int,
|
job_id: int,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
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)
|
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(
|
def get_marketplace_import_jobs(
|
||||||
marketplace: str | None = Query(None, description="Filter by marketplace"),
|
marketplace: str | None = Query(None, description="Filter by marketplace"),
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# app/api/v1/vendor/onboarding.py
|
# app/modules/marketplace/routes/api/vendor_onboarding.py
|
||||||
"""
|
"""
|
||||||
Vendor onboarding API endpoints.
|
Vendor onboarding API endpoints.
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ Provides endpoints for the 4-step mandatory onboarding wizard:
|
|||||||
3. Product & Order Import Configuration
|
3. Product & Order Import Configuration
|
||||||
4. Order Sync (historical import)
|
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).
|
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 fastapi import APIRouter, BackgroundTasks, Depends
|
||||||
from sqlalchemy.orm import Session
|
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.core.database import get_db
|
||||||
from app.services.onboarding_service import OnboardingService
|
from app.services.onboarding_service import OnboardingService
|
||||||
from app.tasks.letzshop_tasks import process_historical_import
|
|
||||||
from models.schema.auth import UserContext
|
from models.schema.auth import UserContext
|
||||||
from app.modules.marketplace.schemas import (
|
from app.modules.marketplace.schemas import (
|
||||||
CompanyProfileRequest,
|
CompanyProfileRequest,
|
||||||
@@ -38,7 +39,10 @@ from app.modules.marketplace.schemas import (
|
|||||||
ProductImportConfigResponse,
|
ProductImportConfigResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/onboarding")
|
vendor_onboarding_router = APIRouter(
|
||||||
|
prefix="/onboarding",
|
||||||
|
dependencies=[Depends(require_module_access("marketplace"))],
|
||||||
|
)
|
||||||
logger = logging.getLogger(__name__)
|
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(
|
def get_onboarding_status(
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
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(
|
def get_company_profile(
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -81,7 +85,7 @@ def get_company_profile(
|
|||||||
return service.get_company_profile_data(current_user.token_vendor_id)
|
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(
|
def save_company_profile(
|
||||||
request: CompanyProfileRequest,
|
request: CompanyProfileRequest,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
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(
|
def test_letzshop_api(
|
||||||
request: LetzshopApiTestRequest,
|
request: LetzshopApiTestRequest,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
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(
|
def save_letzshop_api(
|
||||||
request: LetzshopApiConfigRequest,
|
request: LetzshopApiConfigRequest,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
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(
|
def get_product_import_config(
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
db: Session = Depends(get_db),
|
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)
|
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(
|
def save_product_import_config(
|
||||||
request: ProductImportConfigRequest,
|
request: ProductImportConfigRequest,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
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(
|
def trigger_order_sync(
|
||||||
request: OrderSyncTriggerRequest,
|
request: OrderSyncTriggerRequest,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
@@ -237,7 +241,7 @@ def trigger_order_sync(
|
|||||||
|
|
||||||
# Store Celery task ID if using Celery
|
# Store Celery task ID if using Celery
|
||||||
if celery_task_id:
|
if celery_task_id:
|
||||||
from app.services.letzshop import LetzshopOrderService
|
from app.modules.marketplace.services.letzshop import LetzshopOrderService
|
||||||
|
|
||||||
order_service = LetzshopOrderService(db)
|
order_service = LetzshopOrderService(db)
|
||||||
order_service.update_job_celery_task_id(result["job_id"], celery_task_id)
|
order_service.update_job_celery_task_id(result["job_id"], celery_task_id)
|
||||||
@@ -247,7 +251,7 @@ def trigger_order_sync(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@vendor_onboarding_router.get(
|
||||||
"/step/order-sync/progress/{job_id}",
|
"/step/order-sync/progress/{job_id}",
|
||||||
response_model=OrderSyncProgressResponse,
|
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(
|
def complete_order_sync(
|
||||||
request: OrderSyncCompleteRequest,
|
request: OrderSyncCompleteRequest,
|
||||||
current_user: UserContext = Depends(get_current_vendor_api),
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
@@ -14,7 +14,7 @@ from sqlalchemy import func
|
|||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
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
|
from app.modules.marketplace.models import LetzshopVendorCache
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ def export_vendor_products_to_folder(
|
|||||||
Returns:
|
Returns:
|
||||||
dict: Export results per language with file paths
|
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"]
|
languages = ["en", "fr", "de"]
|
||||||
results = {}
|
results = {}
|
||||||
@@ -149,7 +149,7 @@ def export_marketplace_products(
|
|||||||
Returns:
|
Returns:
|
||||||
dict: Export result with file path
|
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:
|
with self.get_db() as db:
|
||||||
started_at = datetime.now(UTC)
|
started_at = datetime.now(UTC)
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ from typing import Callable
|
|||||||
from app.core.celery_config import celery_app
|
from app.core.celery_config import celery_app
|
||||||
from app.modules.marketplace.models import MarketplaceImportJob, LetzshopHistoricalImportJob
|
from app.modules.marketplace.models import MarketplaceImportJob, LetzshopHistoricalImportJob
|
||||||
from app.services.admin_notification_service import admin_notification_service
|
from app.services.admin_notification_service import admin_notification_service
|
||||||
from app.services.letzshop import LetzshopClientError
|
from app.modules.marketplace.services.letzshop import (
|
||||||
from app.services.letzshop.credentials_service import LetzshopCredentialsService
|
LetzshopClientError,
|
||||||
from app.services.letzshop.order_service import LetzshopOrderService
|
LetzshopCredentialsService,
|
||||||
|
LetzshopOrderService,
|
||||||
|
)
|
||||||
from app.modules.task_base import ModuleTask
|
from app.modules.task_base import ModuleTask
|
||||||
from app.utils.csv_processor import CSVProcessor
|
from app.utils.csv_processor import CSVProcessor
|
||||||
from models.database.vendor import Vendor
|
from models.database.vendor import Vendor
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from typing import Any
|
|||||||
from app.core.celery_config import celery_app
|
from app.core.celery_config import celery_app
|
||||||
from app.modules.task_base import ModuleTask
|
from app.modules.task_base import ModuleTask
|
||||||
from app.services.admin_notification_service import admin_notification_service
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -6,23 +6,31 @@ This module provides functions to register orders routes
|
|||||||
with module-based access control.
|
with module-based access control.
|
||||||
|
|
||||||
NOTE: Routers are NOT auto-imported to avoid circular dependencies.
|
NOTE: Routers are NOT auto-imported to avoid circular dependencies.
|
||||||
Import directly from admin.py or vendor.py as needed:
|
Import directly from api submodule as needed:
|
||||||
from app.modules.orders.routes.admin import admin_router
|
from app.modules.orders.routes.api import admin_router
|
||||||
from app.modules.orders.routes.vendor import vendor_router
|
from app.modules.orders.routes.api import vendor_router
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Routers are imported on-demand to avoid circular dependencies
|
__all__ = [
|
||||||
# Do NOT add auto-imports here
|
"admin_router",
|
||||||
|
"admin_exceptions_router",
|
||||||
__all__ = ["admin_router", "vendor_router"]
|
"vendor_router",
|
||||||
|
"vendor_exceptions_router",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str):
|
def __getattr__(name: str):
|
||||||
"""Lazy import routers to avoid circular dependencies."""
|
"""Lazy import routers to avoid circular dependencies."""
|
||||||
if name == "admin_router":
|
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
|
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":
|
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
|
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}")
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Marketplace import services (MarketplaceProduct
|
|
||||||
@@ -22,8 +22,10 @@ from app.exceptions import (
|
|||||||
OnboardingSyncNotCompleteException,
|
OnboardingSyncNotCompleteException,
|
||||||
VendorNotFoundException,
|
VendorNotFoundException,
|
||||||
)
|
)
|
||||||
from app.services.letzshop.credentials_service import LetzshopCredentialsService
|
from app.modules.marketplace.services.letzshop import (
|
||||||
from app.services.letzshop.order_service import LetzshopOrderService
|
LetzshopCredentialsService,
|
||||||
|
LetzshopOrderService,
|
||||||
|
)
|
||||||
from app.modules.marketplace.models import (
|
from app.modules.marketplace.models import (
|
||||||
OnboardingStatus,
|
OnboardingStatus,
|
||||||
OnboardingStep,
|
OnboardingStep,
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ from typing import Any, Callable
|
|||||||
|
|
||||||
from app.core.database import SessionLocal
|
from app.core.database import SessionLocal
|
||||||
from app.services.admin_notification_service import admin_notification_service
|
from app.services.admin_notification_service import admin_notification_service
|
||||||
from app.services.letzshop import LetzshopClientError
|
from app.modules.marketplace.services.letzshop import (
|
||||||
from app.services.letzshop.credentials_service import LetzshopCredentialsService
|
LetzshopClientError,
|
||||||
from app.services.letzshop.order_service import LetzshopOrderService
|
LetzshopCredentialsService,
|
||||||
from app.services.letzshop.vendor_sync_service import LetzshopVendorSyncService
|
LetzshopOrderService,
|
||||||
|
LetzshopVendorSyncService,
|
||||||
|
)
|
||||||
from app.modules.marketplace.models import LetzshopHistoricalImportJob
|
from app.modules.marketplace.models import LetzshopHistoricalImportJob
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -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/`
|
- **New modules**: Should create migrations in their own `migrations/versions/`
|
||||||
- **Future reorganization**: Existing migrations will be moved to modules pre-production
|
- **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
|
||||||
|
<script src="{{ url_for('{module}_static', path='vendor/js/{module}.js') }}"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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
|
## Architecture Validation Rules
|
||||||
|
|
||||||
The architecture validator (`scripts/validate_architecture.py`) enforces module structure:
|
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-013 | INFO | config.py should export `config` or `config_class` |
|
||||||
| MOD-014 | WARNING | Migrations must follow naming convention |
|
| MOD-014 | WARNING | Migrations must follow naming convention |
|
||||||
| MOD-015 | WARNING | Migrations directory must have `__init__.py` files |
|
| 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:
|
Run validation:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
170
docs/development/migration/module-autodiscovery-migration.md
Normal file
170
docs/development/migration/module-autodiscovery-migration.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
@@ -224,6 +224,9 @@ class ArchitectureValidator:
|
|||||||
# Validate module structure
|
# Validate module structure
|
||||||
self._validate_modules(target)
|
self._validate_modules(target)
|
||||||
|
|
||||||
|
# Validate legacy locations (must be in modules)
|
||||||
|
self._validate_legacy_locations(target)
|
||||||
|
|
||||||
return self.result
|
return self.result
|
||||||
|
|
||||||
def validate_file(self, file_path: Path, quiet: bool = False) -> ValidationResult:
|
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'",
|
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]:
|
def _get_rule(self, rule_id: str) -> dict[str, Any]:
|
||||||
"""Get rule configuration by ID"""
|
"""Get rule configuration by ID"""
|
||||||
# Look in different rule categories
|
# Look in different rule categories
|
||||||
|
|||||||
Reference in New Issue
Block a user