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)",
|
||||
}
|
||||
|
||||
@@ -810,3 +810,39 @@ def adjust_points(
|
||||
)
|
||||
|
||||
return PointsAdjustResponse(**result)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Bulk Operations (Merchant Owner only)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post("/cards/bulk/deactivate")
|
||||
@rate_limit(max_requests=10, window_seconds=60)
|
||||
def bulk_deactivate_cards(
|
||||
request: Request,
|
||||
data: dict,
|
||||
current_user: User = Depends(get_current_store_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Bulk deactivate multiple loyalty cards (merchant_owner only)."""
|
||||
if current_user.role != "merchant_owner":
|
||||
raise AuthorizationException("Only merchant owners can bulk deactivate cards")
|
||||
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
store = store_service.get_store_by_id_optional(db, current_user.token_store_id)
|
||||
if not store:
|
||||
raise AuthorizationException("Store not found")
|
||||
|
||||
card_ids = data.get("card_ids", [])
|
||||
reason = data.get("reason", "Merchant bulk deactivation")
|
||||
|
||||
count = card_service.bulk_deactivate_cards(
|
||||
db,
|
||||
card_ids=card_ids,
|
||||
merchant_id=store.merchant_id,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
return {"cards_deactivated": count, "message": f"Deactivated {count} card(s)"}
|
||||
|
||||
@@ -848,5 +848,147 @@ class CardService:
|
||||
return tx_responses, total
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Admin Operations
|
||||
# =========================================================================
|
||||
|
||||
def anonymize_cards_for_customer(
|
||||
self,
|
||||
db: Session,
|
||||
customer_id: int,
|
||||
admin_user_id: int,
|
||||
) -> int:
|
||||
"""
|
||||
GDPR anonymization: null out customer_id and scrub PII on all
|
||||
loyalty cards belonging to this customer.
|
||||
|
||||
Transaction rows are kept for aggregate reporting but notes
|
||||
containing PII are scrubbed.
|
||||
|
||||
Args:
|
||||
customer_id: Customer to anonymize
|
||||
admin_user_id: Admin performing the action (for audit)
|
||||
|
||||
Returns:
|
||||
Number of cards anonymized
|
||||
"""
|
||||
cards = (
|
||||
db.query(LoyaltyCard)
|
||||
.filter(LoyaltyCard.customer_id == customer_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not cards:
|
||||
return 0
|
||||
|
||||
now = datetime.now(UTC)
|
||||
count = 0
|
||||
|
||||
for card in cards:
|
||||
# Create audit transaction before nulling customer_id
|
||||
db.add(
|
||||
LoyaltyTransaction(
|
||||
card_id=card.id,
|
||||
merchant_id=card.merchant_id,
|
||||
transaction_type=TransactionType.ADMIN_ADJUSTMENT.value,
|
||||
notes=f"GDPR anonymization by admin {admin_user_id}",
|
||||
transaction_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
# Null the customer reference
|
||||
card.customer_id = None
|
||||
card.is_active = False
|
||||
count += 1
|
||||
|
||||
# Scrub notes on existing transactions that might contain PII
|
||||
db.query(LoyaltyTransaction).filter(
|
||||
LoyaltyTransaction.card_id.in_([c.id for c in cards]),
|
||||
LoyaltyTransaction.notes.isnot(None),
|
||||
).update(
|
||||
{LoyaltyTransaction.notes: "GDPR scrubbed"},
|
||||
synchronize_session=False,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"GDPR: anonymized {count} cards for customer {customer_id} "
|
||||
f"by admin {admin_user_id}"
|
||||
)
|
||||
return count
|
||||
|
||||
def bulk_deactivate_cards(
|
||||
self,
|
||||
db: Session,
|
||||
card_ids: list[int],
|
||||
merchant_id: int,
|
||||
reason: str,
|
||||
) -> int:
|
||||
"""
|
||||
Deactivate multiple cards at once.
|
||||
|
||||
Only deactivates cards belonging to the specified merchant.
|
||||
|
||||
Returns:
|
||||
Number of cards deactivated
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
cards = (
|
||||
db.query(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.id.in_(card_ids),
|
||||
LoyaltyCard.merchant_id == merchant_id,
|
||||
LoyaltyCard.is_active == True, # noqa: E712
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
for card in cards:
|
||||
card.is_active = False
|
||||
db.add(
|
||||
LoyaltyTransaction(
|
||||
card_id=card.id,
|
||||
merchant_id=merchant_id,
|
||||
transaction_type=TransactionType.ADMIN_ADJUSTMENT.value,
|
||||
notes=f"Bulk deactivation: {reason}",
|
||||
transaction_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Bulk deactivated {len(cards)} cards for merchant {merchant_id}: {reason}"
|
||||
)
|
||||
return len(cards)
|
||||
|
||||
def restore_deleted_cards(
|
||||
self,
|
||||
db: Session,
|
||||
merchant_id: int,
|
||||
) -> int:
|
||||
"""Restore all soft-deleted cards for a merchant.
|
||||
|
||||
Returns number of cards restored.
|
||||
"""
|
||||
from sqlalchemy import update
|
||||
|
||||
result = db.execute(
|
||||
update(LoyaltyCard)
|
||||
.where(
|
||||
LoyaltyCard.merchant_id == merchant_id,
|
||||
LoyaltyCard.deleted_at.isnot(None),
|
||||
)
|
||||
.values(deleted_at=None, deleted_by_id=None)
|
||||
.execution_options(include_deleted=True)
|
||||
)
|
||||
db.commit()
|
||||
count = result.rowcount
|
||||
if count:
|
||||
logger.info(f"Restored {count} soft-deleted cards for merchant {merchant_id}")
|
||||
return count
|
||||
|
||||
|
||||
# Singleton instance
|
||||
card_service = CardService()
|
||||
|
||||
@@ -1127,5 +1127,30 @@ class ProgramService:
|
||||
return stats
|
||||
|
||||
|
||||
def restore_deleted_programs(self, db: Session, merchant_id: int) -> int:
|
||||
"""Restore all soft-deleted programs for a merchant.
|
||||
|
||||
Returns number of programs restored.
|
||||
"""
|
||||
from sqlalchemy import update
|
||||
|
||||
result = db.execute(
|
||||
update(LoyaltyProgram)
|
||||
.where(
|
||||
LoyaltyProgram.merchant_id == merchant_id,
|
||||
LoyaltyProgram.deleted_at.isnot(None),
|
||||
)
|
||||
.values(deleted_at=None, deleted_by_id=None)
|
||||
.execution_options(include_deleted=True)
|
||||
)
|
||||
db.commit()
|
||||
count = result.rowcount
|
||||
if count:
|
||||
logger.info(
|
||||
f"Restored {count} soft-deleted programs for merchant {merchant_id}"
|
||||
)
|
||||
return count
|
||||
|
||||
|
||||
# Singleton instance
|
||||
program_service = ProgramService()
|
||||
|
||||
Reference in New Issue
Block a user