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 import logging
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import ( from app.api.deps import (
@@ -765,3 +766,153 @@ def debug_recent_enrollments(
}) })
return {"enrollments": results} 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) 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 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 # Singleton instance
card_service = CardService() card_service = CardService()

View File

@@ -1127,5 +1127,30 @@ class ProgramService:
return stats 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 # Singleton instance
program_service = ProgramService() program_service = ProgramService()