11d3e637c1
Add background_config JSON field to LandingPage model, enabling per-landing animated backgrounds (aurora, sparkles, vortex, etc). - Add background_config column (JSON, nullable) with Alembic migration - Add validated background_config to create/update/detail/public schemas - Reuse ALLOWED_BG_TYPES and _validate_settings from branding module - Strip unknown keys via whitelist, validate all fields including reducedOnMobile
257 lines
7.4 KiB
Python
257 lines
7.4 KiB
Python
import secrets
|
|
|
|
import structlog
|
|
from sqlalchemy import func, select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database.models import GuestPurchase, GuestPurchaseStatus, LandingPage
|
|
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
|
|
async def get_landing_by_slug(db: AsyncSession, slug: str) -> LandingPage | None:
|
|
"""Get a landing page by its slug."""
|
|
result = await db.execute(select(LandingPage).where(LandingPage.slug == slug))
|
|
return result.scalars().first()
|
|
|
|
|
|
async def get_landing_by_id(db: AsyncSession, landing_id: int) -> LandingPage | None:
|
|
"""Get a landing page by its ID."""
|
|
result = await db.execute(select(LandingPage).where(LandingPage.id == landing_id))
|
|
return result.scalars().first()
|
|
|
|
|
|
async def get_active_landing_by_slug(db: AsyncSession, slug: str) -> LandingPage | None:
|
|
"""Get an active landing page by its slug."""
|
|
result = await db.execute(
|
|
select(LandingPage).where(
|
|
LandingPage.slug == slug,
|
|
LandingPage.is_active.is_(True),
|
|
)
|
|
)
|
|
return result.scalars().first()
|
|
|
|
|
|
async def get_all_landings(db: AsyncSession) -> list[LandingPage]:
|
|
"""Get all landing pages ordered by display_order."""
|
|
result = await db.execute(select(LandingPage).order_by(LandingPage.display_order, LandingPage.id))
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def create_landing(db: AsyncSession, **kwargs) -> LandingPage:
|
|
"""Create a new landing page."""
|
|
landing = LandingPage(**kwargs)
|
|
db.add(landing)
|
|
await db.flush()
|
|
await db.commit()
|
|
await db.refresh(landing)
|
|
logger.info(
|
|
'Created landing page',
|
|
slug=landing.slug,
|
|
landing_id=landing.id,
|
|
)
|
|
return landing
|
|
|
|
|
|
_LANDING_UPDATABLE_FIELDS = frozenset(
|
|
{
|
|
'slug',
|
|
'title',
|
|
'subtitle',
|
|
'is_active',
|
|
'features',
|
|
'footer_text',
|
|
'allowed_tariff_ids',
|
|
'allowed_periods',
|
|
'payment_methods',
|
|
'gift_enabled',
|
|
'custom_css',
|
|
'meta_title',
|
|
'meta_description',
|
|
'display_order',
|
|
'discount_percent',
|
|
'discount_overrides',
|
|
'discount_starts_at',
|
|
'discount_ends_at',
|
|
'discount_badge_text',
|
|
'background_config',
|
|
}
|
|
)
|
|
|
|
|
|
async def update_landing(db: AsyncSession, landing_id: int, data: dict) -> LandingPage | None:
|
|
"""Update a landing page by ID. Returns None if not found."""
|
|
landing = await get_landing_by_id(db, landing_id)
|
|
if landing is None:
|
|
return None
|
|
|
|
for key, value in data.items():
|
|
if key in _LANDING_UPDATABLE_FIELDS:
|
|
setattr(landing, key, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(landing)
|
|
logger.info(
|
|
'Updated landing page',
|
|
landing_id=landing.id,
|
|
slug=landing.slug,
|
|
updated_fields=list(data.keys()),
|
|
)
|
|
return landing
|
|
|
|
|
|
async def delete_landing(db: AsyncSession, landing_id: int) -> bool:
|
|
"""Delete a landing page by ID. Returns True if deleted."""
|
|
landing = await get_landing_by_id(db, landing_id)
|
|
if landing is None:
|
|
return False
|
|
|
|
await db.delete(landing)
|
|
await db.commit()
|
|
logger.info(
|
|
'Deleted landing page',
|
|
landing_id=landing_id,
|
|
slug=landing.slug,
|
|
)
|
|
return True
|
|
|
|
|
|
async def update_landing_order(db: AsyncSession, landing_ids: list[int]) -> None:
|
|
"""Set display_order for landing pages based on position in list."""
|
|
for order, landing_id in enumerate(landing_ids):
|
|
await db.execute(update(LandingPage).where(LandingPage.id == landing_id).values(display_order=order))
|
|
await db.commit()
|
|
logger.info('Updated landing page order', landing_ids=landing_ids)
|
|
|
|
|
|
def generate_purchase_token() -> str:
|
|
"""Generate a cryptographically secure purchase token."""
|
|
return secrets.token_urlsafe(48)
|
|
|
|
|
|
async def create_guest_purchase(db: AsyncSession, *, commit: bool = True, **kwargs) -> GuestPurchase:
|
|
"""Create a new guest purchase with an auto-generated token."""
|
|
if 'token' not in kwargs:
|
|
kwargs['token'] = generate_purchase_token()
|
|
|
|
purchase = GuestPurchase(**kwargs)
|
|
db.add(purchase)
|
|
await db.flush()
|
|
if commit:
|
|
await db.commit()
|
|
await db.refresh(purchase)
|
|
logger.info(
|
|
'Created guest purchase',
|
|
purchase_id=purchase.id,
|
|
token_prefix=purchase.token[:5],
|
|
status=purchase.status,
|
|
landing_id=purchase.landing_id,
|
|
)
|
|
return purchase
|
|
|
|
|
|
async def get_purchase_by_token(db: AsyncSession, token: str) -> GuestPurchase | None:
|
|
"""Get a guest purchase by its token."""
|
|
result = await db.execute(select(GuestPurchase).where(GuestPurchase.token == token))
|
|
return result.scalars().first()
|
|
|
|
|
|
_PURCHASE_UPDATABLE_FIELDS = frozenset(
|
|
{
|
|
'payment_id',
|
|
'paid_at',
|
|
'delivered_at',
|
|
'subscription_url',
|
|
'subscription_crypto_link',
|
|
'user_id',
|
|
}
|
|
)
|
|
|
|
|
|
async def update_purchase_status(
|
|
db: AsyncSession,
|
|
token: str,
|
|
status: GuestPurchaseStatus | str,
|
|
*,
|
|
commit: bool = True,
|
|
**extra_fields,
|
|
) -> GuestPurchase | None:
|
|
"""Update the status of a guest purchase and optional extra fields."""
|
|
purchase = await get_purchase_by_token(db, token)
|
|
if purchase is None:
|
|
return None
|
|
|
|
old_status = purchase.status
|
|
purchase.status = status.value if isinstance(status, GuestPurchaseStatus) else status
|
|
|
|
for key, value in extra_fields.items():
|
|
if key not in _PURCHASE_UPDATABLE_FIELDS:
|
|
logger.warning('Ignoring disallowed field in purchase update', field=key)
|
|
continue
|
|
setattr(purchase, key, value)
|
|
|
|
if commit:
|
|
await db.commit()
|
|
await db.refresh(purchase)
|
|
else:
|
|
await db.flush()
|
|
|
|
logger.info(
|
|
'Updated guest purchase status',
|
|
purchase_id=purchase.id,
|
|
token_prefix=token[:5],
|
|
old_status=old_status,
|
|
new_status=purchase.status,
|
|
)
|
|
return purchase
|
|
|
|
|
|
async def get_landing_purchase_stats(db: AsyncSession, landing_id: int) -> dict:
|
|
"""Get purchase counts grouped by status for a landing page."""
|
|
result = await db.execute(
|
|
select(
|
|
GuestPurchase.status,
|
|
func.count(GuestPurchase.id),
|
|
)
|
|
.where(GuestPurchase.landing_id == landing_id)
|
|
.group_by(GuestPurchase.status)
|
|
)
|
|
rows = result.all()
|
|
|
|
stats = {s.value: 0 for s in GuestPurchaseStatus}
|
|
stats['total'] = 0
|
|
for status_value, count in rows:
|
|
stats[status_value] = count
|
|
stats['total'] += count
|
|
|
|
return stats
|
|
|
|
|
|
async def get_all_landing_purchase_stats(db: AsyncSession) -> dict[int, dict]:
|
|
"""Get purchase counts grouped by landing_id and status in a single query.
|
|
|
|
Returns a dict mapping landing_id -> {status: count, 'total': count}.
|
|
"""
|
|
result = await db.execute(
|
|
select(
|
|
GuestPurchase.landing_id,
|
|
GuestPurchase.status,
|
|
func.count(GuestPurchase.id),
|
|
)
|
|
.where(GuestPurchase.landing_id.is_not(None))
|
|
.group_by(GuestPurchase.landing_id, GuestPurchase.status)
|
|
)
|
|
rows = result.all()
|
|
|
|
all_stats: dict[int, dict] = {}
|
|
for landing_id, status_value, count in rows:
|
|
if landing_id not in all_stats:
|
|
stats = {s.value: 0 for s in GuestPurchaseStatus}
|
|
stats['total'] = 0
|
|
all_stats[landing_id] = stats
|
|
all_stats[landing_id][status_value] = count
|
|
all_stats[landing_id]['total'] += count
|
|
|
|
return all_stats
|