From 8cd09f3f899cb53df618816e7214544632de1dc3 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sat, 11 Apr 2026 22:40:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(loyalty):=20Phase=206=20=E2=80=94=20admin?= =?UTF-8?q?=20GDPR,=20bulk=20ops,=20point=20restore,=20cascade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/modules/loyalty/routes/api/admin.py | 151 ++++++++++++++++++ app/modules/loyalty/routes/api/store.py | 36 +++++ app/modules/loyalty/services/card_service.py | 142 ++++++++++++++++ .../loyalty/services/program_service.py | 25 +++ 4 files changed, 354 insertions(+) diff --git a/app/modules/loyalty/routes/api/admin.py b/app/modules/loyalty/routes/api/admin.py index f0fab501..ea52a789 100644 --- a/app/modules/loyalty/routes/api/admin.py +++ b/app/modules/loyalty/routes/api/admin.py @@ -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)", + } diff --git a/app/modules/loyalty/routes/api/store.py b/app/modules/loyalty/routes/api/store.py index 2ed9add3..aa68cd6a 100644 --- a/app/modules/loyalty/routes/api/store.py +++ b/app/modules/loyalty/routes/api/store.py @@ -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)"} diff --git a/app/modules/loyalty/services/card_service.py b/app/modules/loyalty/services/card_service.py index 03ccb7bc..a5d25525 100644 --- a/app/modules/loyalty/services/card_service.py +++ b/app/modules/loyalty/services/card_service.py @@ -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() diff --git a/app/modules/loyalty/services/program_service.py b/app/modules/loyalty/services/program_service.py index 1e4a9e88..594f070c 100644 --- a/app/modules/loyalty/services/program_service.py +++ b/app/modules/loyalty/services/program_service.py @@ -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()