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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user