Files
orion/app/modules/tenancy/routes/api/admin_merchants.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

431 lines
15 KiB
Python

# app/modules/tenancy/routes/api/admin_merchants.py
"""
Merchant management endpoints for admin.
"""
import logging
from datetime import UTC, datetime
from fastapi import APIRouter, Body, Depends, Path, Query, Request
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.core.environment import should_use_secure_cookies
from app.modules.tenancy.exceptions import (
ConfirmationRequiredException,
MerchantHasStoresException,
)
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.schemas.merchant import (
MerchantCreate,
MerchantCreateResponse,
MerchantDetailResponse,
MerchantListResponse,
MerchantResponse,
MerchantTransferOwnership,
MerchantTransferOwnershipResponse,
MerchantUpdate,
)
from app.modules.tenancy.services.merchant_service import merchant_service
admin_merchants_router = APIRouter(prefix="/merchants")
logger = logging.getLogger(__name__)
@admin_merchants_router.post("", response_model=MerchantCreateResponse)
def create_merchant_with_owner(
request: Request,
merchant_data: MerchantCreate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Create a new merchant with owner user account (Admin only).
This endpoint:
1. Creates a new merchant record
2. Creates an owner user account with owner_email (if not exists)
3. Sends email verification + welcome email to owner
4. Returns credentials (temporary password shown ONCE if new user created)
**Email Fields:**
- `owner_email`: Used for owner's login/authentication (stored in users.email)
- `contact_email`: Public business contact (stored in merchants.contact_email)
Returns merchant details with owner credentials.
"""
merchant, owner_user, temp_password = merchant_service.create_merchant_with_owner(
db, merchant_data
)
db.commit() # ✅ ARCH: Commit at API level for transaction control
# Send verification email to new owner (only for newly created users)
if temp_password:
try:
from app.modules.messaging.services.email_service import EmailService
from app.modules.tenancy.models import EmailVerificationToken
plaintext_token = EmailVerificationToken.create_for_user(db, owner_user.id)
scheme = "https" if should_use_secure_cookies() else "http"
host = request.headers.get("host", "localhost:8000")
verification_link = f"{scheme}://{host}/verify-email?token={plaintext_token}"
email_service = EmailService(db)
email_service.send_template(
template_code="email_verification",
to_email=owner_user.email,
to_name=owner_user.username,
language="en",
variables={
"first_name": owner_user.username,
"verification_link": verification_link,
"expiry_hours": str(EmailVerificationToken.TOKEN_EXPIRY_HOURS),
"platform_name": "Orion",
},
)
db.commit()
logger.info(f"Verification email sent to {owner_user.email}")
except Exception as e:
db.rollback()
logger.error(f"Failed to send verification email: {e}") # noqa: SEC021
return MerchantCreateResponse(
merchant=MerchantResponse(
id=merchant.id,
name=merchant.name,
description=merchant.description,
owner_user_id=merchant.owner_user_id,
contact_email=merchant.contact_email,
contact_phone=merchant.contact_phone,
website=merchant.website,
business_address=merchant.business_address,
tax_number=merchant.tax_number,
is_active=merchant.is_active,
is_verified=merchant.is_verified,
created_at=merchant.created_at.isoformat(),
updated_at=merchant.updated_at.isoformat(),
),
owner_user_id=owner_user.id,
owner_username=owner_user.username,
owner_email=owner_user.email,
temporary_password=temp_password or "N/A (Existing user)",
login_url="http://localhost:8000/admin/login",
)
@admin_merchants_router.get("", response_model=MerchantListResponse)
def get_all_merchants(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
search: str | None = Query(None, description="Search by merchant name"),
is_active: bool | None = Query(None),
is_verified: bool | None = Query(None),
include_deleted: bool = Query(False, description="Include soft-deleted merchants"),
only_deleted: bool = Query(False, description="Show only soft-deleted merchants (trash view)"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get all merchants with filtering (Admin only)."""
merchants, total = merchant_service.get_merchants(
db,
skip=skip,
limit=limit,
search=search,
is_active=is_active,
is_verified=is_verified,
include_deleted=include_deleted,
only_deleted=only_deleted,
)
return MerchantListResponse(
merchants=[
MerchantResponse(
id=c.id,
name=c.name,
description=c.description,
owner_user_id=c.owner_user_id,
owner_email=c.owner.email if c.owner else None,
contact_email=c.contact_email,
contact_phone=c.contact_phone,
website=c.website,
business_address=c.business_address,
tax_number=c.tax_number,
is_active=c.is_active,
is_verified=c.is_verified,
created_at=c.created_at.isoformat(),
updated_at=c.updated_at.isoformat(),
store_count=c.store_count,
)
for c in merchants
],
total=total,
skip=skip,
limit=limit,
)
@admin_merchants_router.get("/{merchant_id}", response_model=MerchantDetailResponse)
def get_merchant_details(
merchant_id: int = Path(..., description="Merchant ID"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get detailed merchant information including store counts (Admin only).
"""
merchant = merchant_service.get_merchant_by_id(db, merchant_id)
# Count stores
store_count = len(merchant.stores)
active_store_count = sum(1 for v in merchant.stores if v.is_active)
# Build stores list for detail view
stores_list = [
{
"id": v.id,
"store_code": v.store_code,
"name": v.name,
"subdomain": v.subdomain,
"is_active": v.is_active,
"is_verified": v.is_verified,
}
for v in merchant.stores
]
return MerchantDetailResponse(
id=merchant.id,
name=merchant.name,
description=merchant.description,
owner_user_id=merchant.owner_user_id,
owner_email=merchant.owner.email if merchant.owner else None,
owner_username=merchant.owner.username if merchant.owner else None,
contact_email=merchant.contact_email,
contact_phone=merchant.contact_phone,
website=merchant.website,
business_address=merchant.business_address,
tax_number=merchant.tax_number,
is_active=merchant.is_active,
is_verified=merchant.is_verified,
created_at=merchant.created_at.isoformat(),
updated_at=merchant.updated_at.isoformat(),
store_count=store_count,
active_store_count=active_store_count,
stores=stores_list,
)
@admin_merchants_router.put("/{merchant_id}", response_model=MerchantResponse)
def update_merchant(
merchant_id: int = Path(..., description="Merchant ID"),
merchant_update: MerchantUpdate = Body(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Update merchant information (Admin only).
**Can update:**
- Basic info: name, description
- Business contact: contact_email, contact_phone, website
- Business details: business_address, tax_number
- Status: is_active, is_verified
**Cannot update:**
- `owner_user_id` (would require ownership transfer feature)
"""
merchant = merchant_service.update_merchant(db, merchant_id, merchant_update)
db.commit() # ✅ ARCH: Commit at API level for transaction control
return MerchantResponse(
id=merchant.id,
name=merchant.name,
description=merchant.description,
owner_user_id=merchant.owner_user_id,
contact_email=merchant.contact_email,
contact_phone=merchant.contact_phone,
website=merchant.website,
business_address=merchant.business_address,
tax_number=merchant.tax_number,
is_active=merchant.is_active,
is_verified=merchant.is_verified,
created_at=merchant.created_at.isoformat(),
updated_at=merchant.updated_at.isoformat(),
)
@admin_merchants_router.put("/{merchant_id}/verification", response_model=MerchantResponse)
def toggle_merchant_verification(
merchant_id: int = Path(..., description="Merchant ID"),
verification_data: dict = Body(..., example={"is_verified": True}),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Toggle merchant verification status (Admin only).
Request body: { "is_verified": true/false }
"""
is_verified = verification_data.get("is_verified", False)
merchant = merchant_service.toggle_verification(db, merchant_id, is_verified)
db.commit() # ✅ ARCH: Commit at API level for transaction control
return MerchantResponse(
id=merchant.id,
name=merchant.name,
description=merchant.description,
owner_user_id=merchant.owner_user_id,
contact_email=merchant.contact_email,
contact_phone=merchant.contact_phone,
website=merchant.website,
business_address=merchant.business_address,
tax_number=merchant.tax_number,
is_active=merchant.is_active,
is_verified=merchant.is_verified,
created_at=merchant.created_at.isoformat(),
updated_at=merchant.updated_at.isoformat(),
)
@admin_merchants_router.put("/{merchant_id}/status", response_model=MerchantResponse)
def toggle_merchant_status(
merchant_id: int = Path(..., description="Merchant ID"),
status_data: dict = Body(..., example={"is_active": True}),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Toggle merchant active status (Admin only).
Request body: { "is_active": true/false }
"""
is_active = status_data.get("is_active", True)
merchant = merchant_service.toggle_active(db, merchant_id, is_active)
db.commit() # ✅ ARCH: Commit at API level for transaction control
return MerchantResponse(
id=merchant.id,
name=merchant.name,
description=merchant.description,
owner_user_id=merchant.owner_user_id,
contact_email=merchant.contact_email,
contact_phone=merchant.contact_phone,
website=merchant.website,
business_address=merchant.business_address,
tax_number=merchant.tax_number,
is_active=merchant.is_active,
is_verified=merchant.is_verified,
created_at=merchant.created_at.isoformat(),
updated_at=merchant.updated_at.isoformat(),
)
@admin_merchants_router.post(
"/{merchant_id}/transfer-ownership",
response_model=MerchantTransferOwnershipResponse,
)
def transfer_merchant_ownership(
merchant_id: int = Path(..., description="Merchant ID"),
transfer_data: MerchantTransferOwnership = Body(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Transfer merchant ownership to another user (Admin only).
**This is a critical operation that:**
- Changes the merchant's owner_user_id
- Updates all associated stores' owner_user_id
- Creates audit trail
⚠️ **This action is logged and should be used carefully.**
**Requires:**
- `new_owner_user_id`: ID of user who will become owner
- `confirm_transfer`: Must be true
- `transfer_reason`: Optional reason for audit trail
"""
merchant, old_owner, new_owner = merchant_service.transfer_ownership(
db, merchant_id, transfer_data
)
db.commit() # ✅ ARCH: Commit at API level for transaction control
return MerchantTransferOwnershipResponse(
message="Ownership transferred successfully",
merchant_id=merchant.id,
merchant_name=merchant.name,
old_owner={
"id": old_owner.id,
"username": old_owner.username,
"email": old_owner.email,
},
new_owner={
"id": new_owner.id,
"username": new_owner.username,
"email": new_owner.email,
},
transferred_at=datetime.now(UTC),
transfer_reason=transfer_data.transfer_reason,
)
@admin_merchants_router.delete("/{merchant_id}")
def delete_merchant(
merchant_id: int = Path(..., description="Merchant ID"),
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 merchant and all associated stores (Admin only).
⚠️ **WARNING: This is destructive and will delete:**
- Merchant account
- All stores under this merchant
- All products under those stores
- All orders, customers, team members
Requires confirmation parameter: `confirm=true`
"""
if not confirm:
raise ConfirmationRequiredException(
operation="delete_merchant",
message="Deletion requires confirmation parameter: confirm=true",
)
# Get merchant to check store count
merchant = merchant_service.get_merchant_by_id(db, merchant_id)
store_count = len(merchant.stores)
if store_count > 0:
raise MerchantHasStoresException(merchant_id, store_count)
merchant_service.delete_merchant(db, merchant_id)
db.commit() # ✅ ARCH: Commit at API level for transaction control
return {"message": f"Merchant {merchant_id} deleted successfully"}
@admin_merchants_router.put("/{merchant_id}/restore")
def restore_merchant(
merchant_id: int = Path(..., description="Merchant ID"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Restore a soft-deleted merchant (Admin only).
This only restores the merchant record itself.
Stores and their children must be restored separately.
"""
from app.core.soft_delete import restore
from app.modules.tenancy.models import Merchant
restored = restore(db, Merchant, merchant_id, restored_by_id=current_admin.id)
db.commit()
logger.info(f"Merchant {merchant_id} restored by admin {current_admin.username}")
return {"message": f"Merchant '{restored.name}' restored successfully", "merchant_id": merchant_id}