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