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

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