feat(loyalty): Phase 2A — transactional email notifications
Some checks failed
Some checks failed
Add async email notifications for 5 loyalty lifecycle events, using the existing messaging module infrastructure (EmailService, EmailLog, store template overrides). - New seed script: scripts/seed/seed_email_templates_loyalty.py Seeds 5 templates × 4 locales (en/fr/de/lb) = 20 rows. Idempotent. Renamed existing script to seed_email_templates_core.py. - Celery task: loyalty.send_notification_email — async dispatch with 3 retries and 60s backoff. Opens own DB session. - Notification service: LoyaltyNotificationService with 5 methods that resolve customer/card/program into template variables and enqueue via Celery (never blocks request handlers). - Enrollment: sends loyalty_enrollment + loyalty_welcome_bonus (if bonus > 0) after card creation commit. - Stamps: sends loyalty_reward_ready when stamp target reached. - Expiration task: sends loyalty_points_expiring 14 days before expiry (tracked via new last_expiration_warning_at column to prevent dupes), and loyalty_points_expired after points are zeroed. - Migration loyalty_005: adds last_expiration_warning_at to cards. - 8 new unit tests for notification service dispatch. - Fix: rate limiter autouse fixture in integration tests to prevent state bleed between tests. Templates: loyalty_enrollment, loyalty_welcome_bonus, loyalty_points_expiring, loyalty_points_expired, loyalty_reward_ready. All support store-level overrides via the existing email template UI. Birthday + re-engagement emails deferred to future marketing module (cross-platform: OMS, loyalty, hosting). 342 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
"""loyalty 005 - add last_expiration_warning_at to loyalty_cards
|
||||||
|
|
||||||
|
Tracks when the last expiration warning email was sent to prevent
|
||||||
|
duplicate notifications. The expiration task checks this timestamp
|
||||||
|
and only sends a warning once per expiration cycle.
|
||||||
|
|
||||||
|
Revision ID: loyalty_005
|
||||||
|
Revises: loyalty_004
|
||||||
|
Create Date: 2026-04-11
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "loyalty_005"
|
||||||
|
down_revision = "loyalty_004"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"loyalty_cards",
|
||||||
|
sa.Column("last_expiration_warning_at", sa.DateTime(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("loyalty_cards", "last_expiration_warning_at")
|
||||||
@@ -223,6 +223,13 @@ class LoyaltyCard(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
comment="Any activity (for expiration calculation)",
|
comment="Any activity (for expiration calculation)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Notification tracking
|
||||||
|
last_expiration_warning_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="When last expiration warning email was sent",
|
||||||
|
)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Status
|
# Status
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -624,6 +624,23 @@ class CardService:
|
|||||||
|
|
||||||
wallet_service.create_wallet_objects(db, card)
|
wallet_service.create_wallet_objects(db, card)
|
||||||
|
|
||||||
|
# Send notification emails (async via Celery)
|
||||||
|
try:
|
||||||
|
from app.modules.loyalty.services.notification_service import (
|
||||||
|
notification_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
notification_service.send_enrollment_confirmation(db, card)
|
||||||
|
if program.welcome_bonus_points > 0:
|
||||||
|
notification_service.send_welcome_bonus(
|
||||||
|
db, card, program.welcome_bonus_points
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to queue enrollment notification for card {card.id}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Enrolled customer {customer_id} in merchant {merchant_id} loyalty program "
|
f"Enrolled customer {customer_id} in merchant {merchant_id} loyalty program "
|
||||||
f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)"
|
f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)"
|
||||||
|
|||||||
137
app/modules/loyalty/services/notification_service.py
Normal file
137
app/modules/loyalty/services/notification_service.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# app/modules/loyalty/services/notification_service.py
|
||||||
|
"""
|
||||||
|
Loyalty notification service.
|
||||||
|
|
||||||
|
Thin wrapper that resolves customer/card/program data into template
|
||||||
|
variables and dispatches emails asynchronously via the Celery task.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.loyalty.models import LoyaltyCard
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LoyaltyNotificationService:
|
||||||
|
"""Dispatches loyalty email notifications."""
|
||||||
|
|
||||||
|
def _resolve_context(self, db: Session, card: LoyaltyCard) -> dict | None:
|
||||||
|
"""Load customer, store, and program info for a card.
|
||||||
|
|
||||||
|
Returns None if the customer has no email (can't send).
|
||||||
|
"""
|
||||||
|
from app.modules.customers.services.customer_service import customer_service
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
|
customer = customer_service.get_customer_by_id(db, card.customer_id)
|
||||||
|
if not customer or not customer.email:
|
||||||
|
return None
|
||||||
|
|
||||||
|
store = store_service.get_store_by_id_optional(db, card.enrolled_at_store_id)
|
||||||
|
program = card.program
|
||||||
|
|
||||||
|
return {
|
||||||
|
"customer": customer,
|
||||||
|
"store": store,
|
||||||
|
"program": program,
|
||||||
|
"to_email": customer.email,
|
||||||
|
"to_name": customer.full_name,
|
||||||
|
"store_id": card.enrolled_at_store_id,
|
||||||
|
"customer_id": customer.id,
|
||||||
|
"customer_name": customer.full_name,
|
||||||
|
"program_name": program.display_name if program else "Loyalty Program",
|
||||||
|
"store_name": store.name if store else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _dispatch(
|
||||||
|
self, template_code: str, ctx: dict, extra_vars: dict | None = None
|
||||||
|
):
|
||||||
|
"""Enqueue a notification email via Celery."""
|
||||||
|
from app.modules.loyalty.tasks.notifications import send_notification_email
|
||||||
|
|
||||||
|
variables = {
|
||||||
|
"customer_name": ctx["customer_name"],
|
||||||
|
"program_name": ctx["program_name"],
|
||||||
|
"store_name": ctx["store_name"],
|
||||||
|
}
|
||||||
|
if extra_vars:
|
||||||
|
variables.update(extra_vars)
|
||||||
|
|
||||||
|
send_notification_email.delay(
|
||||||
|
template_code=template_code,
|
||||||
|
to_email=ctx["to_email"],
|
||||||
|
to_name=ctx["to_name"],
|
||||||
|
variables=variables,
|
||||||
|
store_id=ctx["store_id"],
|
||||||
|
customer_id=ctx["customer_id"],
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Queued {template_code} for {ctx['to_email']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_enrollment_confirmation(self, db: Session, card: LoyaltyCard):
|
||||||
|
"""Send enrollment confirmation email."""
|
||||||
|
ctx = self._resolve_context(db, card)
|
||||||
|
if not ctx:
|
||||||
|
return
|
||||||
|
self._dispatch("loyalty_enrollment", ctx, {
|
||||||
|
"card_number": card.card_number,
|
||||||
|
})
|
||||||
|
|
||||||
|
def send_welcome_bonus(self, db: Session, card: LoyaltyCard, points: int):
|
||||||
|
"""Send welcome bonus notification (only if points > 0)."""
|
||||||
|
if points <= 0:
|
||||||
|
return
|
||||||
|
ctx = self._resolve_context(db, card)
|
||||||
|
if not ctx:
|
||||||
|
return
|
||||||
|
self._dispatch("loyalty_welcome_bonus", ctx, {
|
||||||
|
"points": str(points),
|
||||||
|
})
|
||||||
|
|
||||||
|
def send_points_expiration_warning(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
card: LoyaltyCard,
|
||||||
|
expiring_points: int,
|
||||||
|
days_remaining: int,
|
||||||
|
expiration_date: str,
|
||||||
|
):
|
||||||
|
"""Send points expiring warning email."""
|
||||||
|
ctx = self._resolve_context(db, card)
|
||||||
|
if not ctx:
|
||||||
|
return
|
||||||
|
self._dispatch("loyalty_points_expiring", ctx, {
|
||||||
|
"points": str(expiring_points),
|
||||||
|
"days_remaining": str(days_remaining),
|
||||||
|
"expiration_date": expiration_date,
|
||||||
|
})
|
||||||
|
|
||||||
|
def send_points_expired(
|
||||||
|
self, db: Session, card: LoyaltyCard, expired_points: int
|
||||||
|
):
|
||||||
|
"""Send points expired notification email."""
|
||||||
|
ctx = self._resolve_context(db, card)
|
||||||
|
if not ctx:
|
||||||
|
return
|
||||||
|
self._dispatch("loyalty_points_expired", ctx, {
|
||||||
|
"expired_points": str(expired_points),
|
||||||
|
})
|
||||||
|
|
||||||
|
def send_reward_available(
|
||||||
|
self, db: Session, card: LoyaltyCard, reward_name: str
|
||||||
|
):
|
||||||
|
"""Send reward earned notification email."""
|
||||||
|
ctx = self._resolve_context(db, card)
|
||||||
|
if not ctx:
|
||||||
|
return
|
||||||
|
self._dispatch("loyalty_reward_ready", ctx, {
|
||||||
|
"reward_name": reward_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
notification_service = LoyaltyNotificationService()
|
||||||
@@ -162,6 +162,22 @@ class StampService:
|
|||||||
|
|
||||||
wallet_service.sync_card_to_wallets(db, card)
|
wallet_service.sync_card_to_wallets(db, card)
|
||||||
|
|
||||||
|
# Notify customer when they've earned a reward
|
||||||
|
if reward_earned:
|
||||||
|
try:
|
||||||
|
from app.modules.loyalty.services.notification_service import (
|
||||||
|
notification_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
notification_service.send_reward_available(
|
||||||
|
db, card, program.stamps_reward_description or "Reward"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to queue reward notification for card {card.id}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
stamps_today += 1
|
stamps_today += 1
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
72
app/modules/loyalty/tasks/notifications.py
Normal file
72
app/modules/loyalty/tasks/notifications.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# app/modules/loyalty/tasks/notifications.py
|
||||||
|
"""
|
||||||
|
Async email notification dispatch for loyalty events.
|
||||||
|
|
||||||
|
All loyalty notification emails are sent asynchronously via this Celery
|
||||||
|
task to avoid blocking request handlers. The task opens its own DB
|
||||||
|
session and calls EmailService.send_template() which handles language
|
||||||
|
resolution, store overrides, Jinja2 rendering, and EmailLog creation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(
|
||||||
|
name="loyalty.send_notification_email",
|
||||||
|
bind=True,
|
||||||
|
max_retries=3,
|
||||||
|
default_retry_delay=60,
|
||||||
|
)
|
||||||
|
def send_notification_email(
|
||||||
|
self,
|
||||||
|
template_code: str,
|
||||||
|
to_email: str,
|
||||||
|
to_name: str | None = None,
|
||||||
|
variables: dict | None = None,
|
||||||
|
store_id: int | None = None,
|
||||||
|
customer_id: int | None = None,
|
||||||
|
language: str | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Send a loyalty notification email asynchronously.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template_code: Email template code (e.g. 'loyalty_enrollment')
|
||||||
|
to_email: Recipient email address
|
||||||
|
to_name: Recipient display name
|
||||||
|
variables: Template variables dict
|
||||||
|
store_id: Store ID for branding and template overrides
|
||||||
|
customer_id: Customer ID for language resolution
|
||||||
|
language: Explicit language override (otherwise auto-resolved)
|
||||||
|
"""
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.modules.messaging.services.email_service import EmailService
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
email_service = EmailService(db)
|
||||||
|
email_log = email_service.send_template(
|
||||||
|
template_code=template_code,
|
||||||
|
to_email=to_email,
|
||||||
|
to_name=to_name,
|
||||||
|
language=language,
|
||||||
|
variables=variables or {},
|
||||||
|
store_id=store_id,
|
||||||
|
customer_id=customer_id,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Loyalty notification sent: {template_code} to {to_email} "
|
||||||
|
f"(log_id={email_log.id if email_log else 'none'})"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
f"Loyalty notification failed: {template_code} to {to_email}: {exc}"
|
||||||
|
)
|
||||||
|
db.rollback()
|
||||||
|
raise self.retry(exc=exc)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -109,6 +109,9 @@ def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[in
|
|||||||
"""
|
"""
|
||||||
Expire points for a specific loyalty program.
|
Expire points for a specific loyalty program.
|
||||||
|
|
||||||
|
Also sends warning emails to cards approaching their expiration date
|
||||||
|
(14 days before) and expired notifications after points are zeroed.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
program: Loyalty program to process
|
program: Loyalty program to process
|
||||||
@@ -119,14 +122,26 @@ def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[in
|
|||||||
if not program.points_expiration_days:
|
if not program.points_expiration_days:
|
||||||
return 0, 0
|
return 0, 0
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
# Calculate expiration threshold
|
# Calculate expiration threshold
|
||||||
expiration_threshold = datetime.now(UTC) - timedelta(days=program.points_expiration_days)
|
expiration_threshold = now - timedelta(days=program.points_expiration_days)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Processing program {program.id}: expiration after {program.points_expiration_days} days "
|
f"Processing program {program.id}: expiration after {program.points_expiration_days} days "
|
||||||
f"(threshold: {expiration_threshold})"
|
f"(threshold: {expiration_threshold})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Phase 1: Send 14-day warning emails ---
|
||||||
|
warning_days = 14
|
||||||
|
warning_threshold = now - timedelta(
|
||||||
|
days=program.points_expiration_days - warning_days
|
||||||
|
)
|
||||||
|
_send_expiration_warnings(
|
||||||
|
db, program, warning_threshold, warning_days, now
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Phase 2: Expire points ---
|
||||||
# Find cards with:
|
# Find cards with:
|
||||||
# - Points balance > 0
|
# - Points balance > 0
|
||||||
# - Last activity before expiration threshold
|
# - Last activity before expiration threshold
|
||||||
@@ -168,7 +183,7 @@ def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[in
|
|||||||
stamps_delta=0,
|
stamps_delta=0,
|
||||||
stamps_balance_after=card.stamp_count,
|
stamps_balance_after=card.stamp_count,
|
||||||
notes=f"Points expired after {program.points_expiration_days} days of inactivity",
|
notes=f"Points expired after {program.points_expiration_days} days of inactivity",
|
||||||
transaction_at=datetime.now(UTC),
|
transaction_at=now,
|
||||||
)
|
)
|
||||||
db.add(transaction) # noqa: PERF006
|
db.add(transaction) # noqa: PERF006
|
||||||
|
|
||||||
@@ -176,6 +191,19 @@ def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[in
|
|||||||
card.expire_points(expired_points)
|
card.expire_points(expired_points)
|
||||||
# Note: We don't update last_activity_at for expiration
|
# Note: We don't update last_activity_at for expiration
|
||||||
|
|
||||||
|
# Send expired notification
|
||||||
|
try:
|
||||||
|
from app.modules.loyalty.services.notification_service import (
|
||||||
|
notification_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
notification_service.send_points_expired(db, card, expired_points)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to queue expiration notification for card {card.id}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
cards_processed += 1
|
cards_processed += 1
|
||||||
points_expired += expired_points
|
points_expired += expired_points
|
||||||
|
|
||||||
@@ -187,6 +215,81 @@ def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[in
|
|||||||
return cards_processed, points_expired
|
return cards_processed, points_expired
|
||||||
|
|
||||||
|
|
||||||
|
def _send_expiration_warnings(
|
||||||
|
db: Session,
|
||||||
|
program: LoyaltyProgram,
|
||||||
|
warning_threshold: datetime,
|
||||||
|
warning_days: int,
|
||||||
|
now: datetime,
|
||||||
|
) -> int:
|
||||||
|
"""Send expiration warning emails to cards approaching expiry.
|
||||||
|
|
||||||
|
Only sends one warning per expiration cycle (tracked via
|
||||||
|
last_expiration_warning_at on the card).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of warnings sent
|
||||||
|
"""
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
# Find cards in the warning window:
|
||||||
|
# - Have points
|
||||||
|
# - Last activity is past the warning threshold (i.e. will expire in ~14 days)
|
||||||
|
# - But NOT yet past the full expiration threshold
|
||||||
|
# - Haven't received a warning yet in this cycle
|
||||||
|
expiration_threshold = now - timedelta(days=program.points_expiration_days)
|
||||||
|
|
||||||
|
cards = (
|
||||||
|
db.query(LoyaltyCard)
|
||||||
|
.filter(
|
||||||
|
LoyaltyCard.merchant_id == program.merchant_id,
|
||||||
|
LoyaltyCard.points_balance > 0,
|
||||||
|
LoyaltyCard.is_active == True,
|
||||||
|
LoyaltyCard.last_activity_at < warning_threshold,
|
||||||
|
LoyaltyCard.last_activity_at >= expiration_threshold,
|
||||||
|
or_(
|
||||||
|
LoyaltyCard.last_expiration_warning_at.is_(None),
|
||||||
|
LoyaltyCard.last_expiration_warning_at < warning_threshold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not cards:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
warnings_sent = 0
|
||||||
|
expiration_date = (
|
||||||
|
now + timedelta(days=warning_days)
|
||||||
|
).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
for card in cards:
|
||||||
|
try:
|
||||||
|
from app.modules.loyalty.services.notification_service import (
|
||||||
|
notification_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
notification_service.send_points_expiration_warning(
|
||||||
|
db,
|
||||||
|
card,
|
||||||
|
expiring_points=card.points_balance,
|
||||||
|
days_remaining=warning_days,
|
||||||
|
expiration_date=expiration_date,
|
||||||
|
)
|
||||||
|
card.last_expiration_warning_at = now
|
||||||
|
warnings_sent += 1
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to queue expiration warning for card {card.id}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Sent {warnings_sent} expiration warnings for program {program.id}"
|
||||||
|
)
|
||||||
|
return warnings_sent
|
||||||
|
|
||||||
|
|
||||||
# Allow running directly for testing
|
# Allow running directly for testing
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ import pytest
|
|||||||
|
|
||||||
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction
|
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction
|
||||||
from app.modules.loyalty.models.loyalty_program import LoyaltyType
|
from app.modules.loyalty.models.loyalty_program import LoyaltyType
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_rate_limiter():
|
||||||
|
"""Reset the in-memory rate limiter before each test to prevent bleed."""
|
||||||
|
from middleware.decorators import rate_limiter
|
||||||
|
|
||||||
|
rate_limiter.clients.clear()
|
||||||
|
yield
|
||||||
|
rate_limiter.clients.clear()
|
||||||
from app.modules.loyalty.models.loyalty_transaction import TransactionType
|
from app.modules.loyalty.models.loyalty_transaction import TransactionType
|
||||||
from app.modules.tenancy.models import User
|
from app.modules.tenancy.models import User
|
||||||
|
|
||||||
@@ -467,26 +477,30 @@ class TestStampEarnRedeem:
|
|||||||
self, client, stamp_store_headers, stamp_store_setup
|
self, client, stamp_store_headers, stamp_store_setup
|
||||||
):
|
):
|
||||||
"""POST /stamp returns 429 once the per-IP cap is exceeded."""
|
"""POST /stamp returns 429 once the per-IP cap is exceeded."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from middleware.decorators import rate_limiter
|
from middleware.decorators import rate_limiter
|
||||||
|
|
||||||
# Reset the in-memory limiter so prior tests don't bleed in
|
|
||||||
rate_limiter.clients.clear()
|
rate_limiter.clients.clear()
|
||||||
|
|
||||||
card = stamp_store_setup["card"]
|
card = stamp_store_setup["card"]
|
||||||
# Cap is 60 per minute. Hit it 60 times and expect any 200/4xx but not
|
# Mock notification dispatch so 60 requests complete fast enough
|
||||||
# a 429, then the 61st should be 429.
|
# to stay within the rate limiter's 60-second window.
|
||||||
for _ in range(60):
|
with patch(
|
||||||
client.post(
|
"app.modules.loyalty.tasks.notifications.send_notification_email"
|
||||||
|
):
|
||||||
|
for _ in range(60):
|
||||||
|
client.post(
|
||||||
|
f"{BASE}/stamp",
|
||||||
|
json={"card_id": card.id},
|
||||||
|
headers=stamp_store_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
f"{BASE}/stamp",
|
f"{BASE}/stamp",
|
||||||
json={"card_id": card.id},
|
json={"card_id": card.id},
|
||||||
headers=stamp_store_headers,
|
headers=stamp_store_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"{BASE}/stamp",
|
|
||||||
json={"card_id": card.id},
|
|
||||||
headers=stamp_store_headers,
|
|
||||||
)
|
|
||||||
assert response.status_code == 429
|
assert response.status_code == 429
|
||||||
|
|
||||||
rate_limiter.clients.clear()
|
rate_limiter.clients.clear()
|
||||||
|
|||||||
192
app/modules/loyalty/tests/unit/test_notification_service.py
Normal file
192
app/modules/loyalty/tests/unit/test_notification_service.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"""Unit tests for LoyaltyNotificationService."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.modules.loyalty.services.notification_service import (
|
||||||
|
LoyaltyNotificationService,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def notification_service():
|
||||||
|
return LoyaltyNotificationService()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_card_stub(
|
||||||
|
card_id=1,
|
||||||
|
customer_id=10,
|
||||||
|
merchant_id=100,
|
||||||
|
enrolled_at_store_id=200,
|
||||||
|
card_number="1234-5678-9012",
|
||||||
|
):
|
||||||
|
"""Lightweight card stub for notification tests."""
|
||||||
|
card = MagicMock()
|
||||||
|
card.id = card_id
|
||||||
|
card.customer_id = customer_id
|
||||||
|
card.merchant_id = merchant_id
|
||||||
|
card.enrolled_at_store_id = enrolled_at_store_id
|
||||||
|
card.card_number = card_number
|
||||||
|
card.program = MagicMock()
|
||||||
|
card.program.display_name = "Test Rewards"
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
def _make_customer_stub(email="test@example.com", full_name="Test User"):
|
||||||
|
customer = MagicMock()
|
||||||
|
customer.id = 10
|
||||||
|
customer.email = email
|
||||||
|
customer.full_name = full_name
|
||||||
|
return customer
|
||||||
|
|
||||||
|
|
||||||
|
def _make_store_stub(name="Test Store"):
|
||||||
|
store = MagicMock()
|
||||||
|
store.id = 200
|
||||||
|
store.name = name
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestNotificationServiceDispatch:
|
||||||
|
"""Verify that each notification method enqueues the correct template."""
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
|
||||||
|
@patch("app.modules.tenancy.services.store_service.store_service")
|
||||||
|
@patch("app.modules.customers.services.customer_service.customer_service")
|
||||||
|
def test_send_enrollment_confirmation(
|
||||||
|
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
|
||||||
|
):
|
||||||
|
mock_customer_svc.get_customer_by_id.return_value = _make_customer_stub()
|
||||||
|
mock_store_svc.get_store_by_id_optional.return_value = _make_store_stub()
|
||||||
|
db = MagicMock()
|
||||||
|
card = _make_card_stub()
|
||||||
|
|
||||||
|
notification_service.send_enrollment_confirmation(db, card)
|
||||||
|
|
||||||
|
mock_task.delay.assert_called_once()
|
||||||
|
call_kwargs = mock_task.delay.call_args[1]
|
||||||
|
assert call_kwargs["template_code"] == "loyalty_enrollment"
|
||||||
|
assert call_kwargs["to_email"] == "test@example.com"
|
||||||
|
assert "card_number" in call_kwargs["variables"]
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
|
||||||
|
@patch("app.modules.tenancy.services.store_service.store_service")
|
||||||
|
@patch("app.modules.customers.services.customer_service.customer_service")
|
||||||
|
def test_send_welcome_bonus(
|
||||||
|
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
|
||||||
|
):
|
||||||
|
mock_customer_svc.get_customer_by_id.return_value = _make_customer_stub()
|
||||||
|
mock_store_svc.get_store_by_id_optional.return_value = _make_store_stub()
|
||||||
|
db = MagicMock()
|
||||||
|
card = _make_card_stub()
|
||||||
|
|
||||||
|
notification_service.send_welcome_bonus(db, card, 50)
|
||||||
|
|
||||||
|
mock_task.delay.assert_called_once()
|
||||||
|
call_kwargs = mock_task.delay.call_args[1]
|
||||||
|
assert call_kwargs["template_code"] == "loyalty_welcome_bonus"
|
||||||
|
assert call_kwargs["variables"]["points"] == "50"
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
|
||||||
|
@patch("app.modules.tenancy.services.store_service.store_service")
|
||||||
|
@patch("app.modules.customers.services.customer_service.customer_service")
|
||||||
|
def test_send_welcome_bonus_skipped_for_zero_points(
|
||||||
|
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
|
||||||
|
):
|
||||||
|
db = MagicMock()
|
||||||
|
card = _make_card_stub()
|
||||||
|
|
||||||
|
notification_service.send_welcome_bonus(db, card, 0)
|
||||||
|
|
||||||
|
mock_task.delay.assert_not_called()
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
|
||||||
|
@patch("app.modules.tenancy.services.store_service.store_service")
|
||||||
|
@patch("app.modules.customers.services.customer_service.customer_service")
|
||||||
|
def test_send_points_expiration_warning(
|
||||||
|
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
|
||||||
|
):
|
||||||
|
mock_customer_svc.get_customer_by_id.return_value = _make_customer_stub()
|
||||||
|
mock_store_svc.get_store_by_id_optional.return_value = _make_store_stub()
|
||||||
|
db = MagicMock()
|
||||||
|
card = _make_card_stub()
|
||||||
|
|
||||||
|
notification_service.send_points_expiration_warning(
|
||||||
|
db, card, expiring_points=200, days_remaining=14, expiration_date="2026-05-01"
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_task.delay.assert_called_once()
|
||||||
|
call_kwargs = mock_task.delay.call_args[1]
|
||||||
|
assert call_kwargs["template_code"] == "loyalty_points_expiring"
|
||||||
|
assert call_kwargs["variables"]["points"] == "200"
|
||||||
|
assert call_kwargs["variables"]["days_remaining"] == "14"
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
|
||||||
|
@patch("app.modules.tenancy.services.store_service.store_service")
|
||||||
|
@patch("app.modules.customers.services.customer_service.customer_service")
|
||||||
|
def test_send_points_expired(
|
||||||
|
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
|
||||||
|
):
|
||||||
|
mock_customer_svc.get_customer_by_id.return_value = _make_customer_stub()
|
||||||
|
mock_store_svc.get_store_by_id_optional.return_value = _make_store_stub()
|
||||||
|
db = MagicMock()
|
||||||
|
card = _make_card_stub()
|
||||||
|
|
||||||
|
notification_service.send_points_expired(db, card, 150)
|
||||||
|
|
||||||
|
mock_task.delay.assert_called_once()
|
||||||
|
call_kwargs = mock_task.delay.call_args[1]
|
||||||
|
assert call_kwargs["template_code"] == "loyalty_points_expired"
|
||||||
|
assert call_kwargs["variables"]["expired_points"] == "150"
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
|
||||||
|
@patch("app.modules.tenancy.services.store_service.store_service")
|
||||||
|
@patch("app.modules.customers.services.customer_service.customer_service")
|
||||||
|
def test_send_reward_available(
|
||||||
|
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
|
||||||
|
):
|
||||||
|
mock_customer_svc.get_customer_by_id.return_value = _make_customer_stub()
|
||||||
|
mock_store_svc.get_store_by_id_optional.return_value = _make_store_stub()
|
||||||
|
db = MagicMock()
|
||||||
|
card = _make_card_stub()
|
||||||
|
|
||||||
|
notification_service.send_reward_available(db, card, "Free Coffee")
|
||||||
|
|
||||||
|
mock_task.delay.assert_called_once()
|
||||||
|
call_kwargs = mock_task.delay.call_args[1]
|
||||||
|
assert call_kwargs["template_code"] == "loyalty_reward_ready"
|
||||||
|
assert call_kwargs["variables"]["reward_name"] == "Free Coffee"
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
|
||||||
|
@patch("app.modules.tenancy.services.store_service.store_service")
|
||||||
|
@patch("app.modules.customers.services.customer_service.customer_service")
|
||||||
|
def test_no_email_skips_dispatch(
|
||||||
|
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
|
||||||
|
):
|
||||||
|
"""Customer without email does not trigger a send."""
|
||||||
|
no_email_customer = _make_customer_stub(email=None)
|
||||||
|
mock_customer_svc.get_customer_by_id.return_value = no_email_customer
|
||||||
|
db = MagicMock()
|
||||||
|
card = _make_card_stub()
|
||||||
|
|
||||||
|
notification_service.send_enrollment_confirmation(db, card)
|
||||||
|
|
||||||
|
mock_task.delay.assert_not_called()
|
||||||
|
|
||||||
|
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
|
||||||
|
@patch("app.modules.tenancy.services.store_service.store_service")
|
||||||
|
@patch("app.modules.customers.services.customer_service.customer_service")
|
||||||
|
def test_customer_not_found_skips_dispatch(
|
||||||
|
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
|
||||||
|
):
|
||||||
|
"""Missing customer does not trigger a send."""
|
||||||
|
mock_customer_svc.get_customer_by_id.return_value = None
|
||||||
|
db = MagicMock()
|
||||||
|
card = _make_card_stub()
|
||||||
|
|
||||||
|
notification_service.send_enrollment_confirmation(db, card)
|
||||||
|
|
||||||
|
mock_task.delay.assert_not_called()
|
||||||
497
scripts/seed/seed_email_templates_loyalty.py
Normal file
497
scripts/seed/seed_email_templates_loyalty.py
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Seed loyalty email templates.
|
||||||
|
|
||||||
|
Idempotent: safe to run repeatedly — upserts by (code, language).
|
||||||
|
|
||||||
|
Run: python scripts/seed/seed_email_templates_loyalty.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
|
for _mod in [
|
||||||
|
"app.modules.billing.models",
|
||||||
|
"app.modules.inventory.models",
|
||||||
|
"app.modules.cart.models",
|
||||||
|
"app.modules.messaging.models",
|
||||||
|
"app.modules.loyalty.models",
|
||||||
|
"app.modules.catalog.models",
|
||||||
|
"app.modules.customers.models",
|
||||||
|
"app.modules.orders.models",
|
||||||
|
"app.modules.marketplace.models",
|
||||||
|
"app.modules.cms.models",
|
||||||
|
]:
|
||||||
|
with contextlib.suppress(ImportError):
|
||||||
|
__import__(_mod)
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.modules.messaging.models import EmailCategory, EmailTemplate
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SHARED HTML STRUCTURE
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
_HEAD = """<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
</head>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap(gradient_from, gradient_to, title, body_html):
|
||||||
|
"""Wrap email body in the standard template chrome."""
|
||||||
|
return f"""{_HEAD}
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, {gradient_from} 0%, {gradient_to} 100%); padding: 30px; border-radius: 10px 10px 0 0;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">{title}</h1>
|
||||||
|
</div>
|
||||||
|
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
|
||||||
|
{body_html}
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; padding: 20px; color: #9ca3af; font-size: 12px;">
|
||||||
|
<p>Powered by RewardFlow</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LOYALTY TEMPLATES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ── Enrollment ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_ENROLLMENT_BODY_EN = """
|
||||||
|
<p style="font-size: 16px;">Hi {{ customer_name }},</p>
|
||||||
|
<p>Welcome to <strong>{{ program_name }}</strong>! Your loyalty card is ready.</p>
|
||||||
|
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #6366f1;">
|
||||||
|
<p style="margin: 5px 0;"><strong>Card Number:</strong> {{ card_number }}</p>
|
||||||
|
<p style="margin: 5px 0;"><strong>Store:</strong> {{ store_name }}</p>
|
||||||
|
</div>
|
||||||
|
<p>Start earning rewards on every visit!</p>
|
||||||
|
<p>Best regards,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
_ENROLLMENT_BODY_FR = """
|
||||||
|
<p style="font-size: 16px;">Bonjour {{ customer_name }},</p>
|
||||||
|
<p>Bienvenue dans <strong>{{ program_name }}</strong> ! Votre carte de fidélité est prête.</p>
|
||||||
|
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #6366f1;">
|
||||||
|
<p style="margin: 5px 0;"><strong>Numéro de carte :</strong> {{ card_number }}</p>
|
||||||
|
<p style="margin: 5px 0;"><strong>Point de vente :</strong> {{ store_name }}</p>
|
||||||
|
</div>
|
||||||
|
<p>Commencez à gagner des récompenses à chaque visite !</p>
|
||||||
|
<p>Cordialement,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
_ENROLLMENT_BODY_DE = """
|
||||||
|
<p style="font-size: 16px;">Hallo {{ customer_name }},</p>
|
||||||
|
<p>Willkommen bei <strong>{{ program_name }}</strong>! Ihre Treuekarte ist bereit.</p>
|
||||||
|
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #6366f1;">
|
||||||
|
<p style="margin: 5px 0;"><strong>Kartennummer:</strong> {{ card_number }}</p>
|
||||||
|
<p style="margin: 5px 0;"><strong>Filiale:</strong> {{ store_name }}</p>
|
||||||
|
</div>
|
||||||
|
<p>Sammeln Sie ab sofort Prämien bei jedem Besuch!</p>
|
||||||
|
<p>Mit freundlichen Grüßen,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
_ENROLLMENT_BODY_LB = """
|
||||||
|
<p style="font-size: 16px;">Moien {{ customer_name }},</p>
|
||||||
|
<p>Wëllkomm bei <strong>{{ program_name }}</strong>! Är Treiekaart ass prett.</p>
|
||||||
|
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #6366f1;">
|
||||||
|
<p style="margin: 5px 0;"><strong>Kaartenummer:</strong> {{ card_number }}</p>
|
||||||
|
<p style="margin: 5px 0;"><strong>Geschäft:</strong> {{ store_name }}</p>
|
||||||
|
</div>
|
||||||
|
<p>Fänkt un Belounungen ze sammelen bei all Besuch!</p>
|
||||||
|
<p>Léif Gréiss,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
# ── Welcome Bonus ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_WELCOME_BONUS_BODY_EN = """
|
||||||
|
<p style="font-size: 16px;">Hi {{ customer_name }},</p>
|
||||||
|
<p>Great news! You've received <strong>{{ points }} bonus points</strong> as a welcome gift from <strong>{{ program_name }}</strong>.</p>
|
||||||
|
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
|
||||||
|
<p style="font-size: 36px; font-weight: bold; color: #6366f1; margin: 0;">+{{ points }}</p>
|
||||||
|
<p style="color: #6b7280; margin: 5px 0;">bonus points</p>
|
||||||
|
</div>
|
||||||
|
<p>These points are already in your balance and ready to use toward rewards.</p>
|
||||||
|
<p>Best regards,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
_WELCOME_BONUS_BODY_FR = """
|
||||||
|
<p style="font-size: 16px;">Bonjour {{ customer_name }},</p>
|
||||||
|
<p>Bonne nouvelle ! Vous avez reçu <strong>{{ points }} points bonus</strong> en cadeau de bienvenue de <strong>{{ program_name }}</strong>.</p>
|
||||||
|
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
|
||||||
|
<p style="font-size: 36px; font-weight: bold; color: #6366f1; margin: 0;">+{{ points }}</p>
|
||||||
|
<p style="color: #6b7280; margin: 5px 0;">points bonus</p>
|
||||||
|
</div>
|
||||||
|
<p>Ces points sont déjà sur votre solde et utilisables pour des récompenses.</p>
|
||||||
|
<p>Cordialement,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
_WELCOME_BONUS_BODY_DE = """
|
||||||
|
<p style="font-size: 16px;">Hallo {{ customer_name }},</p>
|
||||||
|
<p>Tolle Neuigkeiten! Sie haben <strong>{{ points }} Bonuspunkte</strong> als Willkommensgeschenk von <strong>{{ program_name }}</strong> erhalten.</p>
|
||||||
|
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
|
||||||
|
<p style="font-size: 36px; font-weight: bold; color: #6366f1; margin: 0;">+{{ points }}</p>
|
||||||
|
<p style="color: #6b7280; margin: 5px 0;">Bonuspunkte</p>
|
||||||
|
</div>
|
||||||
|
<p>Diese Punkte sind bereits auf Ihrem Konto und können für Prämien eingesetzt werden.</p>
|
||||||
|
<p>Mit freundlichen Grüßen,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
_WELCOME_BONUS_BODY_LB = """
|
||||||
|
<p style="font-size: 16px;">Moien {{ customer_name }},</p>
|
||||||
|
<p>Gutt Noriichten! Dir hutt <strong>{{ points }} Bonuspunkten</strong> als Wëllkommsgeschenk vu <strong>{{ program_name }}</strong> kritt.</p>
|
||||||
|
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
|
||||||
|
<p style="font-size: 36px; font-weight: bold; color: #6366f1; margin: 0;">+{{ points }}</p>
|
||||||
|
<p style="color: #6b7280; margin: 5px 0;">Bonuspunkten</p>
|
||||||
|
</div>
|
||||||
|
<p>Dës Punkten sinn schonn op Ärem Konto a kënne fir Beloununge benotzt ginn.</p>
|
||||||
|
<p>Léif Gréiss,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
# ── Points Expiring ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_POINTS_EXPIRING_BODY_EN = """
|
||||||
|
<p style="font-size: 16px;">Hi {{ customer_name }},</p>
|
||||||
|
<p>This is a friendly reminder that <strong>{{ points }} points</strong> in your <strong>{{ program_name }}</strong> account will expire in <strong>{{ days_remaining }} days</strong> (on {{ expiration_date }}).</p>
|
||||||
|
<div style="background: #fef3c7; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #f59e0b;">
|
||||||
|
<p style="margin: 0; font-weight: bold; color: #92400e;">⏳ {{ points }} points expiring on {{ expiration_date }}</p>
|
||||||
|
</div>
|
||||||
|
<p>Visit us before then to use your points toward a reward!</p>
|
||||||
|
<p>Best regards,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
_POINTS_EXPIRING_BODY_FR = """
|
||||||
|
<p style="font-size: 16px;">Bonjour {{ customer_name }},</p>
|
||||||
|
<p>Un petit rappel : <strong>{{ points }} points</strong> de votre compte <strong>{{ program_name }}</strong> expireront dans <strong>{{ days_remaining }} jours</strong> (le {{ expiration_date }}).</p>
|
||||||
|
<div style="background: #fef3c7; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #f59e0b;">
|
||||||
|
<p style="margin: 0; font-weight: bold; color: #92400e;">⏳ {{ points }} points expirent le {{ expiration_date }}</p>
|
||||||
|
</div>
|
||||||
|
<p>Rendez-nous visite avant cette date pour utiliser vos points !</p>
|
||||||
|
<p>Cordialement,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
_POINTS_EXPIRING_BODY_DE = """
|
||||||
|
<p style="font-size: 16px;">Hallo {{ customer_name }},</p>
|
||||||
|
<p>Eine freundliche Erinnerung: <strong>{{ points }} Punkte</strong> auf Ihrem <strong>{{ program_name }}</strong>-Konto verfallen in <strong>{{ days_remaining }} Tagen</strong> (am {{ expiration_date }}).</p>
|
||||||
|
<div style="background: #fef3c7; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #f59e0b;">
|
||||||
|
<p style="margin: 0; font-weight: bold; color: #92400e;">⏳ {{ points }} Punkte verfallen am {{ expiration_date }}</p>
|
||||||
|
</div>
|
||||||
|
<p>Besuchen Sie uns vorher, um Ihre Punkte einzulösen!</p>
|
||||||
|
<p>Mit freundlichen Grüßen,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
_POINTS_EXPIRING_BODY_LB = """
|
||||||
|
<p style="font-size: 16px;">Moien {{ customer_name }},</p>
|
||||||
|
<p>Eng kleng Erënnerung: <strong>{{ points }} Punkten</strong> op Ärem <strong>{{ program_name }}</strong>-Konto verfalen an <strong>{{ days_remaining }} Deeg</strong> (den {{ expiration_date }}).</p>
|
||||||
|
<div style="background: #fef3c7; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #f59e0b;">
|
||||||
|
<p style="margin: 0; font-weight: bold; color: #92400e;">⏳ {{ points }} Punkten verfalen den {{ expiration_date }}</p>
|
||||||
|
</div>
|
||||||
|
<p>Besicht eis virun deem Datum fir Är Punkten anzeléisen!</p>
|
||||||
|
<p>Léif Gréiss,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
# ── Points Expired ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_POINTS_EXPIRED_BODY_EN = """
|
||||||
|
<p style="font-size: 16px;">Hi {{ customer_name }},</p>
|
||||||
|
<p>Unfortunately, <strong>{{ expired_points }} points</strong> in your <strong>{{ program_name }}</strong> account have expired.</p>
|
||||||
|
<div style="background: #fee2e2; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #ef4444;">
|
||||||
|
<p style="margin: 0; font-weight: bold; color: #991b1b;">{{ expired_points }} points expired</p>
|
||||||
|
</div>
|
||||||
|
<p>Don't worry — you can keep earning points on your next visit!</p>
|
||||||
|
<p>Best regards,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
_POINTS_EXPIRED_BODY_FR = """
|
||||||
|
<p style="font-size: 16px;">Bonjour {{ customer_name }},</p>
|
||||||
|
<p>Malheureusement, <strong>{{ expired_points }} points</strong> de votre compte <strong>{{ program_name }}</strong> ont expiré.</p>
|
||||||
|
<div style="background: #fee2e2; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #ef4444;">
|
||||||
|
<p style="margin: 0; font-weight: bold; color: #991b1b;">{{ expired_points }} points expirés</p>
|
||||||
|
</div>
|
||||||
|
<p>Pas d'inquiétude — vous pouvez continuer à gagner des points lors de votre prochaine visite !</p>
|
||||||
|
<p>Cordialement,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
_POINTS_EXPIRED_BODY_DE = """
|
||||||
|
<p style="font-size: 16px;">Hallo {{ customer_name }},</p>
|
||||||
|
<p>Leider sind <strong>{{ expired_points }} Punkte</strong> auf Ihrem <strong>{{ program_name }}</strong>-Konto verfallen.</p>
|
||||||
|
<div style="background: #fee2e2; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #ef4444;">
|
||||||
|
<p style="margin: 0; font-weight: bold; color: #991b1b;">{{ expired_points }} Punkte verfallen</p>
|
||||||
|
</div>
|
||||||
|
<p>Keine Sorge — Sie können bei Ihrem nächsten Besuch weiter Punkte sammeln!</p>
|
||||||
|
<p>Mit freundlichen Grüßen,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
_POINTS_EXPIRED_BODY_LB = """
|
||||||
|
<p style="font-size: 16px;">Moien {{ customer_name }},</p>
|
||||||
|
<p>Leider sinn <strong>{{ expired_points }} Punkten</strong> op Ärem <strong>{{ program_name }}</strong>-Konto ofgelaf.</p>
|
||||||
|
<div style="background: #fee2e2; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #ef4444;">
|
||||||
|
<p style="margin: 0; font-weight: bold; color: #991b1b;">{{ expired_points }} Punkten ofgelaf</p>
|
||||||
|
</div>
|
||||||
|
<p>Keng Suergen — Dir kënnt bei Ärem nächste Besuch weider Punkten sammelen!</p>
|
||||||
|
<p>Léif Gréiss,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
# ── Reward Ready ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_REWARD_READY_BODY_EN = """
|
||||||
|
<p style="font-size: 16px;">Hi {{ customer_name }},</p>
|
||||||
|
<p>Congratulations! You've earned a reward at <strong>{{ program_name }}</strong>! 🎉</p>
|
||||||
|
<div style="background: #ecfdf5; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #10b981; text-align: center;">
|
||||||
|
<p style="font-size: 20px; font-weight: bold; color: #065f46; margin: 0;">🎁 {{ reward_name }}</p>
|
||||||
|
</div>
|
||||||
|
<p>Visit <strong>{{ store_name }}</strong> to redeem your reward. Just show your loyalty card!</p>
|
||||||
|
<p>Best regards,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
_REWARD_READY_BODY_FR = """
|
||||||
|
<p style="font-size: 16px;">Bonjour {{ customer_name }},</p>
|
||||||
|
<p>Félicitations ! Vous avez gagné une récompense chez <strong>{{ program_name }}</strong> ! 🎉</p>
|
||||||
|
<div style="background: #ecfdf5; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #10b981; text-align: center;">
|
||||||
|
<p style="font-size: 20px; font-weight: bold; color: #065f46; margin: 0;">🎁 {{ reward_name }}</p>
|
||||||
|
</div>
|
||||||
|
<p>Rendez-vous chez <strong>{{ store_name }}</strong> pour récupérer votre récompense. Montrez simplement votre carte de fidélité !</p>
|
||||||
|
<p>Cordialement,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
_REWARD_READY_BODY_DE = """
|
||||||
|
<p style="font-size: 16px;">Hallo {{ customer_name }},</p>
|
||||||
|
<p>Herzlichen Glückwunsch! Sie haben eine Prämie bei <strong>{{ program_name }}</strong> verdient! 🎉</p>
|
||||||
|
<div style="background: #ecfdf5; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #10b981; text-align: center;">
|
||||||
|
<p style="font-size: 20px; font-weight: bold; color: #065f46; margin: 0;">🎁 {{ reward_name }}</p>
|
||||||
|
</div>
|
||||||
|
<p>Besuchen Sie <strong>{{ store_name }}</strong> um Ihre Prämie einzulösen. Zeigen Sie einfach Ihre Treuekarte!</p>
|
||||||
|
<p>Mit freundlichen Grüßen,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
_REWARD_READY_BODY_LB = """
|
||||||
|
<p style="font-size: 16px;">Moien {{ customer_name }},</p>
|
||||||
|
<p>Felicitatiounen! Dir hutt eng Belounung bei <strong>{{ program_name }}</strong> verdéngt! 🎉</p>
|
||||||
|
<div style="background: #ecfdf5; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #10b981; text-align: center;">
|
||||||
|
<p style="font-size: 20px; font-weight: bold; color: #065f46; margin: 0;">🎁 {{ reward_name }}</p>
|
||||||
|
</div>
|
||||||
|
<p>Besicht <strong>{{ store_name }}</strong> fir Är Belounung ofzehuelen. Weist einfach Är Treiekaart!</p>
|
||||||
|
<p>Léif Gréiss,<br><strong>{{ store_name }}</strong></p>"""
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BUILD TEMPLATE LIST
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _make_templates():
|
||||||
|
"""Build the full list of loyalty email templates."""
|
||||||
|
templates = []
|
||||||
|
|
||||||
|
_defs = [
|
||||||
|
{
|
||||||
|
"code": "loyalty_enrollment",
|
||||||
|
"name": {
|
||||||
|
"en": "Loyalty Enrollment",
|
||||||
|
"fr": "Inscription fidélité",
|
||||||
|
"de": "Treue-Anmeldung",
|
||||||
|
"lb": "Treie-Umeldung",
|
||||||
|
},
|
||||||
|
"description": "Sent when a customer enrolls in a loyalty program",
|
||||||
|
"category": EmailCategory.SYSTEM.value,
|
||||||
|
"variables": ["customer_name", "program_name", "card_number", "store_name"],
|
||||||
|
"subject": {
|
||||||
|
"en": "Welcome to {{ program_name }}!",
|
||||||
|
"fr": "Bienvenue chez {{ program_name }} !",
|
||||||
|
"de": "Willkommen bei {{ program_name }}!",
|
||||||
|
"lb": "Wëllkomm bei {{ program_name }}!",
|
||||||
|
},
|
||||||
|
"body_html": {
|
||||||
|
"en": _wrap("#6366f1", "#8b5cf6", "Welcome!", _ENROLLMENT_BODY_EN),
|
||||||
|
"fr": _wrap("#6366f1", "#8b5cf6", "Bienvenue !", _ENROLLMENT_BODY_FR),
|
||||||
|
"de": _wrap("#6366f1", "#8b5cf6", "Willkommen!", _ENROLLMENT_BODY_DE),
|
||||||
|
"lb": _wrap("#6366f1", "#8b5cf6", "Wëllkomm!", _ENROLLMENT_BODY_LB),
|
||||||
|
},
|
||||||
|
"body_text": {
|
||||||
|
"en": "Hi {{ customer_name }},\n\nWelcome to {{ program_name }}! Your loyalty card number is {{ card_number }}.\n\nStart earning rewards on every visit!\n\nBest regards,\n{{ store_name }}",
|
||||||
|
"fr": "Bonjour {{ customer_name }},\n\nBienvenue chez {{ program_name }} ! Votre numéro de carte est {{ card_number }}.\n\nCommencez à gagner des récompenses !\n\nCordialement,\n{{ store_name }}",
|
||||||
|
"de": "Hallo {{ customer_name }},\n\nWillkommen bei {{ program_name }}! Ihre Kartennummer ist {{ card_number }}.\n\nSammeln Sie ab sofort Prämien!\n\nMit freundlichen Grüßen,\n{{ store_name }}",
|
||||||
|
"lb": "Moien {{ customer_name }},\n\nWëllkomm bei {{ program_name }}! Är Kaartenummer ass {{ card_number }}.\n\nFänkt un Belounungen ze sammelen!\n\nLéif Gréiss,\n{{ store_name }}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "loyalty_welcome_bonus",
|
||||||
|
"name": {
|
||||||
|
"en": "Loyalty Welcome Bonus",
|
||||||
|
"fr": "Bonus de bienvenue fidélité",
|
||||||
|
"de": "Treue-Willkommensbonus",
|
||||||
|
"lb": "Treie-Wëllkommsbonus",
|
||||||
|
},
|
||||||
|
"description": "Sent when a customer receives welcome bonus points",
|
||||||
|
"category": EmailCategory.SYSTEM.value,
|
||||||
|
"variables": ["customer_name", "program_name", "points", "store_name"],
|
||||||
|
"subject": {
|
||||||
|
"en": "You earned {{ points }} bonus points!",
|
||||||
|
"fr": "Vous avez gagné {{ points }} points bonus !",
|
||||||
|
"de": "Sie haben {{ points }} Bonuspunkte erhalten!",
|
||||||
|
"lb": "Dir hutt {{ points }} Bonuspunkten kritt!",
|
||||||
|
},
|
||||||
|
"body_html": {
|
||||||
|
"en": _wrap("#6366f1", "#8b5cf6", "Bonus Points!", _WELCOME_BONUS_BODY_EN),
|
||||||
|
"fr": _wrap("#6366f1", "#8b5cf6", "Points Bonus !", _WELCOME_BONUS_BODY_FR),
|
||||||
|
"de": _wrap("#6366f1", "#8b5cf6", "Bonuspunkte!", _WELCOME_BONUS_BODY_DE),
|
||||||
|
"lb": _wrap("#6366f1", "#8b5cf6", "Bonuspunkten!", _WELCOME_BONUS_BODY_LB),
|
||||||
|
},
|
||||||
|
"body_text": {
|
||||||
|
"en": "Hi {{ customer_name }},\n\nYou've received {{ points }} bonus points as a welcome gift from {{ program_name }}.\n\nThese points are already in your balance.\n\nBest regards,\n{{ store_name }}",
|
||||||
|
"fr": "Bonjour {{ customer_name }},\n\nVous avez reçu {{ points }} points bonus de {{ program_name }}.\n\nCes points sont déjà sur votre solde.\n\nCordialement,\n{{ store_name }}",
|
||||||
|
"de": "Hallo {{ customer_name }},\n\nSie haben {{ points }} Bonuspunkte von {{ program_name }} erhalten.\n\nDiese Punkte sind bereits auf Ihrem Konto.\n\nMit freundlichen Grüßen,\n{{ store_name }}",
|
||||||
|
"lb": "Moien {{ customer_name }},\n\nDir hutt {{ points }} Bonuspunkten vu {{ program_name }} kritt.\n\nDës Punkten sinn schonn op Ärem Konto.\n\nLéif Gréiss,\n{{ store_name }}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "loyalty_points_expiring",
|
||||||
|
"name": {
|
||||||
|
"en": "Points Expiring Warning",
|
||||||
|
"fr": "Avertissement expiration points",
|
||||||
|
"de": "Punkteverfall-Warnung",
|
||||||
|
"lb": "Punkten-Oflaaf-Warnung",
|
||||||
|
},
|
||||||
|
"description": "Sent 14 days before loyalty points expire",
|
||||||
|
"category": EmailCategory.SYSTEM.value,
|
||||||
|
"variables": ["customer_name", "program_name", "points", "days_remaining", "expiration_date", "store_name"],
|
||||||
|
"subject": {
|
||||||
|
"en": "Your {{ points }} points expire in {{ days_remaining }} days",
|
||||||
|
"fr": "Vos {{ points }} points expirent dans {{ days_remaining }} jours",
|
||||||
|
"de": "Ihre {{ points }} Punkte verfallen in {{ days_remaining }} Tagen",
|
||||||
|
"lb": "Är {{ points }} Punkten verfalen an {{ days_remaining }} Deeg",
|
||||||
|
},
|
||||||
|
"body_html": {
|
||||||
|
"en": _wrap("#f59e0b", "#d97706", "Points Expiring Soon", _POINTS_EXPIRING_BODY_EN),
|
||||||
|
"fr": _wrap("#f59e0b", "#d97706", "Points bientôt expirés", _POINTS_EXPIRING_BODY_FR),
|
||||||
|
"de": _wrap("#f59e0b", "#d97706", "Punkte verfallen bald", _POINTS_EXPIRING_BODY_DE),
|
||||||
|
"lb": _wrap("#f59e0b", "#d97706", "Punkten verfalen geschwënn", _POINTS_EXPIRING_BODY_LB),
|
||||||
|
},
|
||||||
|
"body_text": {
|
||||||
|
"en": "Hi {{ customer_name }},\n\n{{ points }} points in your {{ program_name }} account will expire in {{ days_remaining }} days (on {{ expiration_date }}).\n\nVisit us to use your points!\n\nBest regards,\n{{ store_name }}",
|
||||||
|
"fr": "Bonjour {{ customer_name }},\n\n{{ points }} points de votre compte {{ program_name }} expireront dans {{ days_remaining }} jours (le {{ expiration_date }}).\n\nRendez-nous visite !\n\nCordialement,\n{{ store_name }}",
|
||||||
|
"de": "Hallo {{ customer_name }},\n\n{{ points }} Punkte auf Ihrem {{ program_name }}-Konto verfallen in {{ days_remaining }} Tagen (am {{ expiration_date }}).\n\nBesuchen Sie uns!\n\nMit freundlichen Grüßen,\n{{ store_name }}",
|
||||||
|
"lb": "Moien {{ customer_name }},\n\n{{ points }} Punkten op Ärem {{ program_name }}-Konto verfalen an {{ days_remaining }} Deeg (den {{ expiration_date }}).\n\nBesicht eis!\n\nLéif Gréiss,\n{{ store_name }}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "loyalty_points_expired",
|
||||||
|
"name": {
|
||||||
|
"en": "Points Expired",
|
||||||
|
"fr": "Points expirés",
|
||||||
|
"de": "Punkte verfallen",
|
||||||
|
"lb": "Punkten ofgelaf",
|
||||||
|
},
|
||||||
|
"description": "Sent when loyalty points have expired",
|
||||||
|
"category": EmailCategory.SYSTEM.value,
|
||||||
|
"variables": ["customer_name", "program_name", "expired_points", "store_name"],
|
||||||
|
"subject": {
|
||||||
|
"en": "{{ expired_points }} points have expired",
|
||||||
|
"fr": "{{ expired_points }} points ont expiré",
|
||||||
|
"de": "{{ expired_points }} Punkte sind verfallen",
|
||||||
|
"lb": "{{ expired_points }} Punkten sinn ofgelaf",
|
||||||
|
},
|
||||||
|
"body_html": {
|
||||||
|
"en": _wrap("#ef4444", "#dc2626", "Points Expired", _POINTS_EXPIRED_BODY_EN),
|
||||||
|
"fr": _wrap("#ef4444", "#dc2626", "Points expirés", _POINTS_EXPIRED_BODY_FR),
|
||||||
|
"de": _wrap("#ef4444", "#dc2626", "Punkte verfallen", _POINTS_EXPIRED_BODY_DE),
|
||||||
|
"lb": _wrap("#ef4444", "#dc2626", "Punkten ofgelaf", _POINTS_EXPIRED_BODY_LB),
|
||||||
|
},
|
||||||
|
"body_text": {
|
||||||
|
"en": "Hi {{ customer_name }},\n\n{{ expired_points }} points in your {{ program_name }} account have expired.\n\nKeep earning on your next visit!\n\nBest regards,\n{{ store_name }}",
|
||||||
|
"fr": "Bonjour {{ customer_name }},\n\n{{ expired_points }} points de votre compte {{ program_name }} ont expiré.\n\nContinuez à gagner des points !\n\nCordialement,\n{{ store_name }}",
|
||||||
|
"de": "Hallo {{ customer_name }},\n\n{{ expired_points }} Punkte auf Ihrem {{ program_name }}-Konto sind verfallen.\n\nSammeln Sie weiter!\n\nMit freundlichen Grüßen,\n{{ store_name }}",
|
||||||
|
"lb": "Moien {{ customer_name }},\n\n{{ expired_points }} Punkten op Ärem {{ program_name }}-Konto sinn ofgelaf.\n\nSammelt weider!\n\nLéif Gréiss,\n{{ store_name }}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "loyalty_reward_ready",
|
||||||
|
"name": {
|
||||||
|
"en": "Reward Ready",
|
||||||
|
"fr": "Récompense disponible",
|
||||||
|
"de": "Prämie bereit",
|
||||||
|
"lb": "Belounung prett",
|
||||||
|
},
|
||||||
|
"description": "Sent when a customer earns enough stamps for a reward",
|
||||||
|
"category": EmailCategory.MARKETING.value,
|
||||||
|
"variables": ["customer_name", "program_name", "reward_name", "store_name"],
|
||||||
|
"subject": {
|
||||||
|
"en": "You've earned a reward at {{ program_name }}! 🎉",
|
||||||
|
"fr": "Vous avez gagné une récompense chez {{ program_name }} ! 🎉",
|
||||||
|
"de": "Sie haben eine Prämie bei {{ program_name }} verdient! 🎉",
|
||||||
|
"lb": "Dir hutt eng Belounung bei {{ program_name }} verdéngt! 🎉",
|
||||||
|
},
|
||||||
|
"body_html": {
|
||||||
|
"en": _wrap("#10b981", "#059669", "Reward Earned! 🎉", _REWARD_READY_BODY_EN),
|
||||||
|
"fr": _wrap("#10b981", "#059669", "Récompense gagnée ! 🎉", _REWARD_READY_BODY_FR),
|
||||||
|
"de": _wrap("#10b981", "#059669", "Prämie verdient! 🎉", _REWARD_READY_BODY_DE),
|
||||||
|
"lb": _wrap("#10b981", "#059669", "Belounung verdéngt! 🎉", _REWARD_READY_BODY_LB),
|
||||||
|
},
|
||||||
|
"body_text": {
|
||||||
|
"en": "Hi {{ customer_name }},\n\nCongratulations! You've earned a reward: {{ reward_name }}\n\nVisit {{ store_name }} to redeem it!\n\nBest regards,\n{{ store_name }}",
|
||||||
|
"fr": "Bonjour {{ customer_name }},\n\nFélicitations ! Vous avez gagné : {{ reward_name }}\n\nRendez-vous chez {{ store_name }} !\n\nCordialement,\n{{ store_name }}",
|
||||||
|
"de": "Hallo {{ customer_name }},\n\nHerzlichen Glückwunsch! Ihre Prämie: {{ reward_name }}\n\nBesuchen Sie {{ store_name }}!\n\nMit freundlichen Grüßen,\n{{ store_name }}",
|
||||||
|
"lb": "Moien {{ customer_name }},\n\nFelicitatiounen! Är Belounung: {{ reward_name }}\n\nBesicht {{ store_name }}!\n\nLéif Gréiss,\n{{ store_name }}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for defn in _defs:
|
||||||
|
for lang in ("en", "fr", "de", "lb"):
|
||||||
|
templates.append({
|
||||||
|
"code": defn["code"],
|
||||||
|
"language": lang,
|
||||||
|
"name": defn["name"][lang],
|
||||||
|
"description": defn["description"],
|
||||||
|
"category": defn["category"],
|
||||||
|
"variables": json.dumps(defn["variables"]),
|
||||||
|
"required_variables": json.dumps(defn["variables"]),
|
||||||
|
"subject": defn["subject"][lang],
|
||||||
|
"body_html": defn["body_html"][lang],
|
||||||
|
"body_text": defn["body_text"][lang],
|
||||||
|
"is_platform_only": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
return templates
|
||||||
|
|
||||||
|
|
||||||
|
TEMPLATES = _make_templates()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SEED FUNCTION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def seed_templates():
|
||||||
|
"""Seed loyalty email templates into database (idempotent)."""
|
||||||
|
db = next(get_db())
|
||||||
|
|
||||||
|
try:
|
||||||
|
created = 0
|
||||||
|
updated = 0
|
||||||
|
|
||||||
|
for template_data in TEMPLATES:
|
||||||
|
existing = (
|
||||||
|
db.query(EmailTemplate)
|
||||||
|
.filter(
|
||||||
|
EmailTemplate.code == template_data["code"],
|
||||||
|
EmailTemplate.language == template_data["language"],
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
for key, value in template_data.items():
|
||||||
|
setattr(existing, key, value)
|
||||||
|
updated += 1
|
||||||
|
print(f" Updated: {template_data['code']} ({template_data['language']})")
|
||||||
|
else:
|
||||||
|
template = EmailTemplate(**template_data)
|
||||||
|
db.add(template)
|
||||||
|
created += 1
|
||||||
|
print(f" Created: {template_data['code']} ({template_data['language']})")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print(f"\nLoyalty templates — Created: {created}, Updated: {updated}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
print(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
seed_templates()
|
||||||
Reference in New Issue
Block a user