Files
orion/app/modules/tenancy/routes/api/admin_stores.py
Samir Boulahtit f20266167d
Some checks failed
CI / ruff (push) Failing after 7s
CI / pytest (push) Failing after 1s
CI / architecture (push) Failing after 9s
CI / dependency-scanning (push) Successful in 27s
CI / audit (push) Successful in 8s
CI / docs (push) Has been skipped
fix(lint): auto-fix ruff violations and tune lint rules
- Auto-fixed 4,496 lint issues (import sorting, modern syntax, etc.)
- Added ignore rules for patterns intentional in this codebase:
  E402 (late imports), E712 (SQLAlchemy filters), B904 (raise from),
  SIM108/SIM105/SIM117 (readability preferences)
- Added per-file ignores for tests and scripts
- Excluded broken scripts/rename_terminology.py (has curly quotes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:10:42 +01:00

318 lines
11 KiB
Python

# app/modules/tenancy/routes/api/admin_stores.py
"""
Store management endpoints for admin.
Architecture Notes:
- All business logic is in store_service (no direct DB operations here)
- Uses domain exceptions from app/exceptions/store.py
- Exception handler middleware converts domain exceptions to HTTP responses
"""
import logging
from fastapi import APIRouter, Body, 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.modules.tenancy.exceptions import ConfirmationRequiredException
from app.modules.tenancy.schemas.store import (
StoreCreate,
StoreCreateResponse,
StoreDetailResponse,
StoreListResponse,
StoreStatsResponse,
StoreUpdate,
)
from app.modules.tenancy.services.admin_service import admin_service
from app.modules.tenancy.services.store_service import store_service
from models.schema.auth import UserContext
admin_stores_router = APIRouter(prefix="/stores")
logger = logging.getLogger(__name__)
@admin_stores_router.post("", response_model=StoreCreateResponse)
def create_store(
store_data: StoreCreate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Create a new store (storefront/brand) under an existing merchant (Admin only).
This endpoint:
1. Validates that the parent merchant exists
2. Creates a new store record linked to the merchant
3. Sets up default roles (Owner, Manager, Editor, Viewer)
The store inherits owner and contact information from its parent merchant.
"""
store = admin_service.create_store(db=db, store_data=store_data)
db.commit()
return StoreCreateResponse(
# Store fields
id=store.id,
store_code=store.store_code,
subdomain=store.subdomain,
name=store.name,
description=store.description,
merchant_id=store.merchant_id,
letzshop_csv_url_fr=store.letzshop_csv_url_fr,
letzshop_csv_url_en=store.letzshop_csv_url_en,
letzshop_csv_url_de=store.letzshop_csv_url_de,
is_active=store.is_active,
is_verified=store.is_verified,
created_at=store.created_at,
updated_at=store.updated_at,
# Merchant info
merchant_name=store.merchant.name,
merchant_contact_email=store.merchant.contact_email,
merchant_contact_phone=store.merchant.contact_phone,
merchant_website=store.merchant.website,
# Owner info (from merchant)
owner_email=store.merchant.owner.email,
owner_username=store.merchant.owner.username,
login_url=f"http://localhost:8000/store/{store.subdomain}/login",
)
@admin_stores_router.get("", response_model=StoreListResponse)
def get_all_stores_admin(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
search: str | None = Query(None, description="Search by name or store code"),
is_active: bool | None = Query(None),
is_verified: bool | None = Query(None),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get all stores with filtering (Admin only)."""
stores, total = admin_service.get_all_stores(
db=db,
skip=skip,
limit=limit,
search=search,
is_active=is_active,
is_verified=is_verified,
)
return StoreListResponse(stores=stores, total=total, skip=skip, limit=limit)
@admin_stores_router.get("/stats", response_model=StoreStatsResponse)
def get_store_statistics_endpoint(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get store statistics for admin dashboard (Admin only)."""
from app.modules.tenancy.models import Store
# Query store statistics directly to avoid analytics module dependency
total = db.query(Store).count()
verified = db.query(Store).filter(Store.is_verified == True).count()
active = db.query(Store).filter(Store.is_active == True).count()
inactive = total - active
pending = db.query(Store).filter(
Store.is_active == True, Store.is_verified == False
).count()
return StoreStatsResponse(
total=total,
verified=verified,
pending=pending,
inactive=inactive,
)
def _build_store_detail_response(store) -> StoreDetailResponse:
"""
Helper to build StoreDetailResponse with resolved contact info.
Contact fields are resolved using store override or merchant fallback.
Inheritance flags indicate if value comes from merchant.
"""
contact_info = store.get_contact_info_with_inheritance()
return StoreDetailResponse(
# Store fields
id=store.id,
store_code=store.store_code,
subdomain=store.subdomain,
name=store.name,
description=store.description,
merchant_id=store.merchant_id,
letzshop_csv_url_fr=store.letzshop_csv_url_fr,
letzshop_csv_url_en=store.letzshop_csv_url_en,
letzshop_csv_url_de=store.letzshop_csv_url_de,
is_active=store.is_active,
is_verified=store.is_verified,
created_at=store.created_at,
updated_at=store.updated_at,
# Merchant info
merchant_name=store.merchant.name,
# Owner details (from merchant)
owner_email=store.merchant.owner.email,
owner_username=store.merchant.owner.username,
# Resolved contact info with inheritance flags
**contact_info,
# Original merchant values for UI reference
merchant_contact_email=store.merchant.contact_email,
merchant_contact_phone=store.merchant.contact_phone,
merchant_website=store.merchant.website,
merchant_business_address=store.merchant.business_address,
merchant_tax_number=store.merchant.tax_number,
)
@admin_stores_router.get("/{store_identifier}", response_model=StoreDetailResponse)
def get_store_details(
store_identifier: str = Path(..., description="Store ID or store_code"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get detailed store information including merchant and owner details (Admin only).
Accepts either store ID (integer) or store_code (string).
Returns store info with merchant contact details, owner info, and
resolved contact fields (store override or merchant default).
Raises:
StoreNotFoundException: If store not found (404)
"""
store = store_service.get_store_by_identifier(db, store_identifier)
return _build_store_detail_response(store)
@admin_stores_router.put("/{store_identifier}", response_model=StoreDetailResponse)
def update_store(
store_identifier: str = Path(..., description="Store ID or store_code"),
store_update: StoreUpdate = Body(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Update store information (Admin only).
Accepts either store ID (integer) or store_code (string).
**Can update:**
- Basic info: name, description, subdomain
- Marketplace URLs
- Status: is_active, is_verified
- Contact info: contact_email, contact_phone, website, business_address, tax_number
(these override merchant defaults; set to empty to reset to inherit)
**Cannot update:**
- `store_code` (immutable)
Raises:
StoreNotFoundException: If store not found (404)
"""
store = store_service.get_store_by_identifier(db, store_identifier)
store = admin_service.update_store(db, store.id, store_update)
db.commit()
return _build_store_detail_response(store)
# NOTE: Ownership transfer is now at the Merchant level.
# Use PUT /api/v1/admin/merchants/{id}/transfer-ownership instead.
# This endpoint is kept for backwards compatibility but may be removed in future versions.
@admin_stores_router.put("/{store_identifier}/verification", response_model=StoreDetailResponse)
def toggle_store_verification(
store_identifier: str = Path(..., description="Store ID or store_code"),
verification_data: dict = Body(..., example={"is_verified": True}),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Set store verification status (Admin only).
Accepts either store ID (integer) or store_code (string).
Request body: { "is_verified": true/false }
Raises:
StoreNotFoundException: If store not found (404)
"""
store = store_service.get_store_by_identifier(db, store_identifier)
if "is_verified" in verification_data:
store, message = store_service.set_verification(
db, store.id, verification_data["is_verified"]
)
db.commit() # ✅ ARCH: Commit at API level for transaction control
logger.info(f"Store verification updated: {message}")
return _build_store_detail_response(store)
@admin_stores_router.put("/{store_identifier}/status", response_model=StoreDetailResponse)
def toggle_store_status(
store_identifier: str = Path(..., description="Store ID or store_code"),
status_data: dict = Body(..., example={"is_active": True}),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Set store active status (Admin only).
Accepts either store ID (integer) or store_code (string).
Request body: { "is_active": true/false }
Raises:
StoreNotFoundException: If store not found (404)
"""
store = store_service.get_store_by_identifier(db, store_identifier)
if "is_active" in status_data:
store, message = store_service.set_status(
db, store.id, status_data["is_active"]
)
db.commit() # ✅ ARCH: Commit at API level for transaction control
logger.info(f"Store status updated: {message}")
return _build_store_detail_response(store)
@admin_stores_router.delete("/{store_identifier}")
def delete_store(
store_identifier: str = Path(..., description="Store ID or store_code"),
confirm: bool = Query(False, description="Must be true to confirm deletion"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Delete store and all associated data (Admin only).
Accepts either store ID (integer) or store_code (string).
⚠️ **WARNING: This is destructive and will delete:**
- Store account
- All products
- All orders
- All customers
- All team members
Requires confirmation parameter: `confirm=true`
Raises:
ConfirmationRequiredException: If confirm=true not provided (400)
StoreNotFoundException: If store not found (404)
"""
if not confirm:
raise ConfirmationRequiredException(
operation="delete_store",
message="Deletion requires confirmation parameter: confirm=true",
)
store = store_service.get_store_by_identifier(db, store_identifier)
message = admin_service.delete_store(db, store.id)
db.commit()
return {"message": message}