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)",
}

View File

@@ -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)"}

View File

@@ -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()

View File

@@ -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()