feat(loyalty): Phase 6 — admin GDPR, bulk ops, point restore, cascade
Some checks failed
CI / ruff (push) Successful in 15s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running

Admin operations for production management:

- GDPR anonymization: DELETE /admin/loyalty/cards/customer/{id}
  Nulls customer_id, deactivates cards, scrubs PII from transaction
  notes. Keeps aggregate data for reporting.
- Bulk deactivate: POST /admin/loyalty/merchants/{id}/cards/bulk/deactivate
  and POST /store/loyalty/cards/bulk/deactivate (merchant_owner only).
  Deactivates multiple cards with audit trail.
- Point restore: POST /admin/loyalty/cards/{id}/restore-points
  Creates ADMIN_ADJUSTMENT transaction with positive delta. Reuses
  existing adjust_points service method.
- Cascade restore: POST /admin/loyalty/merchants/{id}/restore-deleted
  Restores all soft-deleted programs and cards for a merchant.

Service methods: anonymize_cards_for_customer, bulk_deactivate_cards,
restore_deleted_cards, restore_deleted_programs.

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 22:40:34 +02:00
parent 4c1608f78a
commit 8cd09f3f89
4 changed files with 354 additions and 0 deletions

View File

@@ -11,6 +11,7 @@ Platform admin endpoints for:
import logging
from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import (
@@ -765,3 +766,153 @@ def debug_recent_enrollments(
})
return {"enrollments": results}
# =============================================================================
# Phase 6: Admin Operations (GDPR, Bulk, Point Restore)
# =============================================================================
class GDPRAnonymizeResponse(BaseModel):
"""Response for GDPR customer anonymization."""
cards_anonymized: int
customer_id: int
message: str
class BulkDeactivateRequest(BaseModel):
"""Request for bulk card deactivation."""
card_ids: list[int] = Field(..., min_length=1, max_length=1000)
reason: str = Field(..., min_length=1, max_length=500)
class BulkDeactivateResponse(BaseModel):
cards_deactivated: int
message: str
class PointsRestoreRequest(BaseModel):
"""Request for admin point restore."""
points: int = Field(..., gt=0)
reason: str = Field(..., min_length=1, max_length=500)
class PointsRestoreResponse(BaseModel):
success: bool
card_id: int
points_restored: int
new_balance: int
message: str
@router.delete(
"/cards/customer/{customer_id}",
response_model=GDPRAnonymizeResponse,
)
def gdpr_anonymize_customer(
customer_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
GDPR: Anonymize a customer's loyalty cards.
Nulls customer_id on all cards, deactivates them, and scrubs PII
from transaction notes. Keeps transaction rows for aggregate reporting.
"""
count = card_service.anonymize_cards_for_customer(
db, customer_id, admin_user_id=current_user.id
)
return GDPRAnonymizeResponse(
cards_anonymized=count,
customer_id=customer_id,
message=f"Anonymized {count} card(s) for customer {customer_id}",
)
@router.post(
"/merchants/{merchant_id}/cards/bulk/deactivate",
response_model=BulkDeactivateResponse,
)
def bulk_deactivate_cards(
data: BulkDeactivateRequest,
merchant_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Bulk deactivate multiple loyalty cards for a merchant."""
count = card_service.bulk_deactivate_cards(
db,
card_ids=data.card_ids,
merchant_id=merchant_id,
reason=data.reason,
)
return BulkDeactivateResponse(
cards_deactivated=count,
message=f"Deactivated {count} card(s)",
)
@router.post(
"/cards/{card_id}/restore-points",
response_model=PointsRestoreResponse,
)
def restore_points(
data: PointsRestoreRequest,
card_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Admin: Restore expired or voided points to a card.
Creates an ADMIN_ADJUSTMENT transaction with a positive delta.
"""
from app.modules.loyalty.services.points_service import points_service
result = points_service.adjust_points(
db,
card_id=card_id,
points_delta=data.points,
reason=f"Admin restore: {data.reason}",
)
return PointsRestoreResponse(
success=True,
card_id=card_id,
points_restored=data.points,
new_balance=result["points_balance"],
message=f"Restored {data.points} points",
)
@router.post(
"/merchants/{merchant_id}/restore-deleted",
)
def restore_deleted_merchant_data(
merchant_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Cascade restore: restore all soft-deleted programs and cards
for a merchant.
"""
programs_restored = program_service.restore_deleted_programs(db, merchant_id)
cards_restored = card_service.restore_deleted_cards(db, merchant_id)
logger.info(
f"Cascade restore for merchant {merchant_id}: "
f"{programs_restored} programs, {cards_restored} cards"
)
return {
"merchant_id": merchant_id,
"programs_restored": programs_restored,
"cards_restored": cards_restored,
"message": f"Restored {programs_restored} program(s) and {cards_restored} card(s)",
}