Files
orion/app/modules/tenancy/routes/api/admin_stores.py
Samir Boulahtit 9bceeaac9c feat(arch): implement soft delete for business-critical models
Adds SoftDeleteMixin (deleted_at + deleted_by_id) with automatic query
filtering via do_orm_execute event. Soft-deleted records are invisible
by default; bypass with execution_options={"include_deleted": True}.

Models: User, Merchant, Store, StoreUser, Customer, Order, Product,
LoyaltyProgram, LoyaltyCard.

Infrastructure:
- SoftDeleteMixin in models/database/base.py
- Auto query filter registered on SessionLocal and test sessions
- soft_delete(), restore(), soft_delete_cascade() in app/core/soft_delete.py
- Alembic migration adding columns to 9 tables
- Partial unique indexes on users.email/username, stores.store_code/subdomain

Service changes:
- admin_service: delete_user, delete_store → soft_delete/soft_delete_cascade
- merchant_service: delete_merchant → soft_delete_cascade (stores→children)
- store_team_service: remove_team_member → soft_delete (fixes is_active bug)
- product_service: delete_product → soft_delete
- program_service: delete_program → soft_delete_cascade

Admin API:
- include_deleted/only_deleted query params on admin list endpoints
- PUT restore endpoints for users, merchants, stores

Tests: 9 unit tests for soft-delete infrastructure.
Docs: docs/backend/soft-delete.md + follow-up proposals.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:08:07 +01:00

337 lines
12 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.auth import UserContext
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
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_user_id=store.merchant.owner.id,
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),
merchant_id: int | None = Query(None, description="Filter by merchant ID"),
include_deleted: bool = Query(False, description="Include soft-deleted stores"),
only_deleted: bool = Query(False, description="Show only soft-deleted stores (trash view)"),
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,
merchant_id=merchant_id,
include_deleted=include_deleted,
only_deleted=only_deleted,
)
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)."""
stats = admin_service.get_store_statistics(db)
return StoreStatsResponse(
total=stats["total"],
verified=stats["verified"],
pending=stats["pending"],
inactive=stats["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_user_id=store.merchant.owner_user_id,
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 handled at the Merchant level.
# Use PUT /api/v1/admin/merchants/{id}/transfer-ownership instead.
@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}
@admin_stores_router.put("/{store_id}/restore")
def restore_store(
store_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Restore a soft-deleted store (Admin only).
This only restores the store record itself.
Child records (products, customers, etc.) must be restored separately.
"""
from app.core.soft_delete import restore
from app.modules.tenancy.models import Store
restored = restore(db, Store, store_id, restored_by_id=current_admin.id)
db.commit()
logger.info(f"Store {store_id} restored by admin {current_admin.username}")
return {"message": f"Store '{restored.name}' restored successfully", "store_id": store_id}