Some checks failed
- Add admin store roles page with merchant→store cascading for superadmin and store-only selection for platform admin - Add permission catalog API with translated labels/descriptions (en/fr/de/lb) - Add permission translations to all 15 module locale files (60 files total) - Add info icon tooltips for permission descriptions in role editor - Add store roles menu item and admin menu item in module definition - Fix store-selector.js URL construction bug when apiEndpoint has query params - Add admin store roles API (CRUD + platform scoping) - Add integration tests for admin store roles and permission catalog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
312 lines
11 KiB
Python
312 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_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"),
|
|
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,
|
|
)
|
|
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}
|