feat(loyalty): Phase 6 — admin GDPR, bulk ops, point restore, cascade
Some checks failed
Some checks failed
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:
@@ -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)",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user