first commit

This commit is contained in:
Anurag Jangid 2026-10-08 02:52:58 +05:30
commit 34ea15a54d
246 changed files with 18660 additions and 0 deletions

View file

@ -0,0 +1 @@

View file

View file

@ -0,0 +1,126 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.models.catalog import Event, TicketType, Venue
from app.models.enums import TicketTypeStatus
from app.schemas.admin import (
EventCreate,
EventUpdate,
TicketTypeCreate,
TicketTypeUpdate,
VenueCreate,
VenueUpdate,
)
def list_venues_admin(db: Session) -> list[Venue]:
return list(db.scalars(select(Venue).order_by(Venue.name.asc())).all())
def create_venue(db: Session, body: VenueCreate) -> Venue:
if db.scalar(select(Venue.id).where(Venue.slug == body.slug)):
raise AppError("Venue slug already exists", status_code=409)
venue = Venue(**body.model_dump())
db.add(venue)
db.commit()
db.refresh(venue)
return venue
def update_venue(db: Session, venue_id: int, body: VenueUpdate) -> Venue:
venue = db.get(Venue, venue_id)
if not venue:
raise AppError("Venue not found", status_code=404)
data = body.model_dump(exclude_unset=True)
if "slug" in data and data["slug"] != venue.slug:
if db.scalar(select(Venue.id).where(Venue.slug == data["slug"])):
raise AppError("Venue slug already exists", status_code=409)
for key, value in data.items():
setattr(venue, key, value)
db.commit()
db.refresh(venue)
return venue
def list_events_admin(db: Session) -> list[Event]:
return list(db.scalars(select(Event).order_by(Event.starts_at.desc())).all())
def create_event(db: Session, body: EventCreate) -> Event:
if not db.get(Venue, body.venue_id):
raise AppError("Venue not found", status_code=400)
if db.scalar(select(Event.id).where(Event.slug == body.slug)):
raise AppError("Event slug already exists", status_code=409)
event = Event(**body.model_dump())
db.add(event)
db.commit()
db.refresh(event)
return event
def update_event(db: Session, event_id: int, body: EventUpdate) -> Event:
event = db.get(Event, event_id)
if not event:
raise AppError("Event not found", status_code=404)
data = body.model_dump(exclude_unset=True)
if "venue_id" in data and not db.get(Venue, data["venue_id"]):
raise AppError("Venue not found", status_code=400)
if "slug" in data and data["slug"] != event.slug:
if db.scalar(select(Event.id).where(Event.slug == data["slug"])):
raise AppError("Event slug already exists", status_code=409)
for key, value in data.items():
setattr(event, key, value)
db.commit()
db.refresh(event)
return event
def ticket_type_to_admin(tier: TicketType) -> dict:
return {
"id": tier.id,
"event_id": tier.event_id,
"name": tier.name,
"description": tier.description,
"price_paise": tier.price_paise,
"quantity_total": tier.quantity_total,
"quantity_sold": tier.quantity_sold,
"remaining": max(tier.quantity_total - tier.quantity_sold, 0),
"status": tier.status,
"sort_order": tier.sort_order,
}
def list_ticket_types(db: Session, event_id: int | None = None) -> list[dict]:
query = select(TicketType).order_by(TicketType.event_id.asc(), TicketType.sort_order.asc())
if event_id is not None:
query = query.where(TicketType.event_id == event_id)
return [ticket_type_to_admin(row) for row in db.scalars(query).all()]
def create_ticket_type(db: Session, body: TicketTypeCreate) -> TicketType:
if not db.get(Event, body.event_id):
raise AppError("Event not found", status_code=400)
tier = TicketType(**body.model_dump())
db.add(tier)
db.commit()
db.refresh(tier)
return tier
def update_ticket_type(db: Session, tier_id: int, body: TicketTypeUpdate) -> TicketType:
tier = db.get(TicketType, tier_id)
if not tier:
raise AppError("Ticket type not found", status_code=404)
data = body.model_dump(exclude_unset=True)
if "quantity_total" in data and data["quantity_total"] < tier.quantity_sold:
raise AppError("Total quantity cannot be less than sold count", status_code=400)
for key, value in data.items():
setattr(tier, key, value)
if tier.quantity_sold >= tier.quantity_total:
tier.status = TicketTypeStatus.sold_out
elif tier.status == TicketTypeStatus.sold_out and tier.quantity_sold < tier.quantity_total:
tier.status = TicketTypeStatus.active
db.commit()
db.refresh(tier)
return tier

View file

@ -0,0 +1,82 @@
from datetime import datetime, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.catalog import Event, TicketType
from app.models.enums import EventStatus, OrderStatus, TicketStatus
from app.models.orders import Order, Ticket
from app.schemas.admin import DashboardMetrics, EventStatsRow
def get_dashboard_metrics(db: Session) -> DashboardMetrics:
now = datetime.now(timezone.utc)
revenue = db.scalar(
select(func.coalesce(func.sum(Order.total_paise), 0)).where(Order.status == OrderStatus.paid)
) or 0
tickets_sold = db.scalar(select(func.coalesce(func.sum(TicketType.quantity_sold), 0))) or 0
tickets_total = db.scalar(select(func.coalesce(func.sum(TicketType.quantity_total), 0))) or 0
tickets_remaining = max(int(tickets_total) - int(tickets_sold), 0)
orders_total = db.scalar(select(func.count(Order.id))) or 0
orders_paid = db.scalar(select(func.count(Order.id)).where(Order.status == OrderStatus.paid)) or 0
orders_pending = db.scalar(select(func.count(Order.id)).where(Order.status == OrderStatus.pending)) or 0
attendance = db.scalar(select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.used)) or 0
upcoming_events = (
db.scalar(
select(func.count(Event.id)).where(
Event.status == EventStatus.published,
Event.starts_at >= now,
)
)
or 0
)
return DashboardMetrics(
total_revenue_paise=int(revenue),
tickets_sold=int(tickets_sold),
tickets_remaining=tickets_remaining,
orders_total=int(orders_total),
orders_paid=int(orders_paid),
orders_pending=int(orders_pending),
attendance=int(attendance),
upcoming_events=int(upcoming_events),
)
def get_event_statistics(db: Session) -> list[EventStatsRow]:
events = db.scalars(select(Event).order_by(Event.starts_at.desc())).all()
rows: list[EventStatsRow] = []
for event in events:
tiers = db.scalars(select(TicketType).where(TicketType.event_id == event.id)).all()
sold = sum(t.quantity_sold for t in tiers)
total = sum(t.quantity_total for t in tiers)
revenue = db.scalar(
select(func.coalesce(func.sum(Order.total_paise), 0)).where(
Order.event_id == event.id,
Order.status == OrderStatus.paid,
)
) or 0
attendance = db.scalar(
select(func.count(Ticket.id)).where(
Ticket.event_id == event.id,
Ticket.status == TicketStatus.used,
)
) or 0
rows.append(
EventStatsRow(
event_id=event.id,
event_title=event.title,
starts_at=event.starts_at,
tickets_sold=sold,
tickets_remaining=max(total - sold, 0),
revenue_paise=int(revenue),
attendance=int(attendance),
)
)
return rows

View file

@ -0,0 +1,40 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.models.enums import OrderStatus
from app.models.orders import Order
def list_orders_admin(db: Session, status: OrderStatus | None = None) -> list[Order]:
query = select(Order).order_by(Order.created_at.desc())
if status is not None:
query = query.where(Order.status == status)
return list(db.scalars(query).all())
def get_order_admin(db: Session, order_id: int) -> Order:
order = db.get(Order, order_id)
if not order:
raise AppError("Order not found", status_code=404)
return order
def list_payments_admin(db: Session) -> list[dict]:
orders = db.scalars(
select(Order)
.where(Order.status.in_([OrderStatus.paid, OrderStatus.pending, OrderStatus.failed]))
.order_by(Order.created_at.desc())
).all()
return [
{
"order_id": order.id,
"event_id": order.event_id,
"status": order.status,
"total_paise": order.total_paise,
"provider_payment_id": order.provider_payment_id,
"payment_reference": order.payment_reference,
"paid_at": order.paid_at,
}
for order in orders
]

View file

@ -0,0 +1,39 @@
from datetime import date, datetime, timedelta, timezone
from sqlalchemy.orm import Session
from app.config import settings
from app.models import AlertDispatch, OutboundClick
from app.services.email import send_email
TICKET_KINDS = ("bookmyshow", "district")
def maybe_notify_click_spike(db: Session) -> None:
if not settings.smtp_configured:
return
since = datetime.now(timezone.utc) - timedelta(hours=24)
count = (
db.query(OutboundClick)
.filter(OutboundClick.created_at >= since, OutboundClick.link_kind.in_(TICKET_KINDS))
.count()
)
if count < settings.alert_click_threshold:
return
alert_key = f"click_spike_{date.today().isoformat()}"
if db.query(AlertDispatch).filter(AlertDispatch.alert_key == alert_key).first():
return
send_email(
subject=f"Desi Clans — ticket click spike ({count} in 24h)",
body=(
f"Ticket outbound clicks in the last 24 hours: {count}\n"
f"Threshold: {settings.alert_click_threshold}\n"
f"City: Bengaluru\n"
"Check admin analytics for details."
),
)
db.add(AlertDispatch(alert_key=alert_key))
db.commit()

View file

@ -0,0 +1,38 @@
from datetime import datetime, timedelta, timezone
from app.models import EventEdition
def _fmt_ics(dt: datetime) -> str:
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.strftime("%Y%m%dT%H%M%SZ")
def build_ics(edition: EventEdition, page_url: str) -> str:
start = edition.event_date or datetime.now(timezone.utc) + timedelta(days=7)
end = start + timedelta(hours=4)
uid = f"desi-clans-{edition.slug}@bengaluru"
location = edition.venue_name or edition.city
summary = edition.title.replace(",", "\\,")
desc = (edition.meta_description or edition.description or edition.title).replace("\n", "\\n")
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Desi Clans//Bengaluru Events//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
f"UID:{uid}",
f"DTSTAMP:{_fmt_ics(datetime.now(timezone.utc))}",
f"DTSTART:{_fmt_ics(start)}",
f"DTEND:{_fmt_ics(end)}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{desc} — {page_url}",
f"LOCATION:{location}",
f"URL:{page_url}",
"END:VEVENT",
"END:VCALENDAR",
]
return "\r\n".join(lines) + "\r\n"

View file

@ -0,0 +1,95 @@
from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from app.models.catalog import Event, EventMedia, TicketType, Venue
from app.models.enums import EventStatus, EventType
from app.schemas.catalog import EventDetail, EventSummary, TicketTypePublic, VenuePublic
def _ticket_type_public(ticket: TicketType) -> TicketTypePublic:
return TicketTypePublic(
id=ticket.id,
name=ticket.name,
description=ticket.description,
price_paise=ticket.price_paise,
status=ticket.status,
sort_order=ticket.sort_order,
remaining_quantity=max(ticket.quantity_total - ticket.quantity_sold, 0),
)
def list_events(
db: Session,
*,
category: EventType | None,
status: EventStatus | None,
upcoming: bool,
) -> list[Event]:
query = (
select(Event)
.options(joinedload(Event.venue))
.order_by(Event.starts_at.asc())
)
if status is not None:
query = query.where(Event.status == status)
if category is not None:
query = query.where(Event.category == category)
if upcoming:
now = datetime.now(timezone.utc)
query = query.where(Event.starts_at >= now)
return list(db.scalars(query).unique().all())
def get_event_by_slug(db: Session, slug: str) -> Event:
event = db.scalar(
select(Event)
.options(
joinedload(Event.venue),
joinedload(Event.media),
joinedload(Event.ticket_types),
)
.where(Event.slug == slug)
)
if not event:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event not found")
return event
def event_to_summary(event: Event) -> EventSummary:
return EventSummary.model_validate(event)
def event_to_detail(event: Event) -> EventDetail:
return EventDetail(
id=event.id,
slug=event.slug,
title=event.title,
category=event.category,
status=event.status,
starts_at=event.starts_at,
ends_at=event.ends_at,
is_featured=event.is_featured,
cover_image_url=event.cover_image_url,
cover_image_alt=event.cover_image_alt,
venue=VenuePublic.model_validate(event.venue),
description=event.description,
meta_description=event.meta_description,
media=event.media,
ticket_types=[_ticket_type_public(ticket) for ticket in event.ticket_types],
)
def list_venues(db: Session) -> list[Venue]:
return list(db.scalars(select(Venue).order_by(Venue.name.asc())).all())
def get_venue_by_id(db: Session, venue_id: int) -> Venue:
venue = db.get(Venue, venue_id)
if not venue:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Venue not found")
return venue

View file

@ -0,0 +1,60 @@
from app.models import EventEdition, MediaAsset
from app.schemas import EditionTicketsPublic, EventEditionAdmin, EventEditionPublic, MediaAssetPublic
def tickets_for_edition(edition: EventEdition) -> EditionTicketsPublic:
return EditionTicketsPublic(
bookmyshow=edition.bookmyshow_url,
district=edition.district_url,
instagram=edition.instagram_url,
)
def edition_to_public(edition: EventEdition, include_unpublished_media: bool = False) -> EventEditionPublic:
cover = None
if edition.cover_image_path:
from app.services.media import public_url_for_path, thumb_url_for_path
cover = public_url_for_path(edition.cover_image_path)
media: list[MediaAssetPublic] = []
for m in edition.media:
if not include_unpublished_media and not m.is_published:
continue
from app.services.media import public_url_for_path, thumb_url_for_path
media.append(
MediaAssetPublic(
id=m.id,
url=public_url_for_path(m.file_path),
thumb_url=thumb_url_for_path(m.file_path),
caption=m.caption,
sort_order=m.sort_order,
)
)
return EventEditionPublic(
id=edition.id,
title=edition.title,
slug=edition.slug,
event_type=edition.event_type,
city=edition.city,
venue_name=edition.venue_name,
event_date=edition.event_date,
description=edition.description,
is_featured=edition.is_featured,
cover_image_url=cover,
cover_thumb_url=thumb_url_for_path(edition.cover_image_path) if edition.cover_image_path else None,
tickets=tickets_for_edition(edition),
meta_description=edition.meta_description,
media=media,
)
def edition_to_admin(edition: EventEdition) -> EventEditionAdmin:
base = edition_to_public(edition, include_unpublished_media=True)
return EventEditionAdmin(
**base.model_dump(),
is_published=edition.is_published,
sort_order=edition.sort_order,
)

View file

@ -0,0 +1,21 @@
import smtplib
from email.message import EmailMessage
from app.config import settings
def send_email(subject: str, body: str) -> None:
if not settings.smtp_configured:
return
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = settings.smtp_from or settings.smtp_user or "desi-clans@localhost"
msg["To"] = settings.alert_notify_email
msg.set_content(body)
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=20) as server:
if settings.smtp_use_tls:
server.starttls()
if settings.smtp_user:
server.login(settings.smtp_user, settings.smtp_password)
server.send_message(msg)

View file

@ -0,0 +1,11 @@
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
def check_database(db: Session) -> bool:
try:
db.execute(text("SELECT 1"))
return True
except SQLAlchemyError:
return False

View file

@ -0,0 +1,70 @@
import io
import re
import uuid
from pathlib import Path
from fastapi import UploadFile
from PIL import Image
from app.storage import get_storage
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
MAX_BYTES = 8 * 1024 * 1024
VARIANTS = {
"lg": 2400,
"md": 1200,
"thumb": 400,
}
def public_url_for_path(file_path: str) -> str:
normalized = file_path.replace("\\", "/").lstrip("/")
return get_storage().public_url(normalized)
def thumb_path_for(file_path: str) -> str | None:
normalized = file_path.replace("\\", "/")
if "_lg." in normalized:
return re.sub(r"_lg\.(\w+)$", r"_thumb.\1", normalized)
base, ext = normalized.rsplit(".", 1) if "." in normalized else (normalized, "webp")
return f"{base}_thumb.{ext}"
def thumb_url_for_path(file_path: str) -> str | None:
thumb = thumb_path_for(file_path)
if not thumb:
return None
return get_storage().public_url(thumb)
def _encode_variant(img: Image.Image, max_edge: int) -> bytes:
work = img.convert("RGB") if img.mode in ("RGBA", "P") else img
w, h = work.size
if max(w, h) > max_edge:
ratio = max_edge / max(w, h)
work = work.resize((int(w * ratio), int(h * ratio)), Image.Resampling.LANCZOS)
buf = io.BytesIO()
work.save(buf, format="WEBP", quality=85 if max_edge >= 1200 else 80)
return buf.getvalue()
def save_image(upload: UploadFile) -> str:
suffix = Path(upload.filename or "").suffix.lower()
if suffix not in ALLOWED_EXTENSIONS:
raise ValueError("Only JPG, PNG, and WebP images are allowed")
raw = upload.file.read()
if len(raw) > MAX_BYTES:
raise ValueError("Image must be under 8 MB")
storage = get_storage()
base = uuid.uuid4().hex
primary_key = f"{base}_lg.webp"
with Image.open(io.BytesIO(raw)) as img:
for variant, edge in VARIANTS.items():
key = f"{base}_{variant}.webp"
storage.save(key, _encode_variant(img, edge), "image/webp")
return primary_key

View file

@ -0,0 +1,335 @@
import secrets
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from app.core.config import get_settings
from app.core.errors import AppError
from app.models.catalog import Event, TicketType
from app.models.enums import EventStatus, OrderStatus, TicketStatus, TicketTypeStatus
from app.models.orders import Order, OrderItem, Ticket
from app.models.user import User
from app.schemas.orders import OrderCreate
from app.services.payments import get_payment_gateway
from app.services.tickets import generate_qr_token
class InsufficientInventoryError(AppError):
def __init__(self, message: str = "Not enough tickets available") -> None:
super().__init__(message, status_code=409)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _remaining(ticket_type: TicketType) -> int:
return max(ticket_type.quantity_total - ticket_type.quantity_sold, 0)
def _ticket_type_public_row(ticket_type: TicketType) -> dict:
return {
"id": ticket_type.id,
"event_id": ticket_type.event_id,
"name": ticket_type.name,
"description": ticket_type.description,
"price_paise": ticket_type.price_paise,
"status": ticket_type.status,
"sort_order": ticket_type.sort_order,
"remaining_quantity": _remaining(ticket_type),
}
def list_event_ticket_types(db: Session, event_id: int) -> list[dict]:
event = db.get(Event, event_id)
if not event or event.status != EventStatus.published:
raise AppError("Event not found", status_code=404)
rows = db.scalars(
select(TicketType)
.where(TicketType.event_id == event_id)
.order_by(TicketType.sort_order.asc(), TicketType.id.asc())
).all()
return [_ticket_type_public_row(row) for row in rows]
def _generate_ticket_code() -> str:
return f"DC-{secrets.token_hex(8).upper()}"
def expire_stale_pending_orders(db: Session) -> None:
settings = get_settings()
cutoff = _utcnow()
stale_orders = list(
db.scalars(
select(Order)
.where(
Order.status == OrderStatus.pending,
Order.expires_at.is_not(None),
Order.expires_at < cutoff,
)
.with_for_update()
).all()
)
for order in stale_orders:
cancel_pending_order(db, order, status=OrderStatus.failed)
def get_order_by_provider_order_id(db: Session, provider_order_id: str) -> Order | None:
return db.scalar(
select(Order)
.options(joinedload(Order.items))
.where(Order.provider_order_id == provider_order_id)
)
def get_order_by_idempotency_key(db: Session, key: str) -> Order | None:
return db.scalar(
select(Order)
.options(joinedload(Order.items))
.where(Order.idempotency_key == key)
)
def _reserve_inventory(
db: Session,
body: OrderCreate,
) -> tuple[int, list[tuple[TicketType, int, int]]]:
line_map: dict[int, int] = defaultdict(int)
for line in body.items:
line_map[line.ticket_type_id] += line.quantity
if not line_map:
raise AppError("Order must include at least one ticket", status_code=400)
ticket_type_ids = sorted(line_map.keys())
locked_types = list(
db.scalars(
select(TicketType)
.where(TicketType.id.in_(ticket_type_ids))
.order_by(TicketType.id.asc())
.with_for_update()
).all()
)
if len(locked_types) != len(ticket_type_ids):
raise AppError("One or more ticket types are invalid", status_code=400)
total_paise = 0
priced_lines: list[tuple[TicketType, int, int]] = []
for ticket_type in locked_types:
qty = line_map[ticket_type.id]
if ticket_type.event_id != body.event_id:
raise AppError("Ticket type does not belong to this event", status_code=400)
if ticket_type.status != TicketTypeStatus.active:
raise InsufficientInventoryError(f"{ticket_type.name} is not available")
remaining = _remaining(ticket_type)
if qty > remaining:
raise InsufficientInventoryError(
f"Only {remaining} tickets left for {ticket_type.name}",
)
line_total = ticket_type.price_paise * qty
total_paise += line_total
priced_lines.append((ticket_type, qty, line_total))
return total_paise, priced_lines
def _apply_inventory_reservation(priced_lines: list[tuple[TicketType, int, int]]) -> None:
for ticket_type, qty, _line_total in priced_lines:
ticket_type.quantity_sold += qty
if ticket_type.quantity_sold >= ticket_type.quantity_total:
ticket_type.quantity_sold = min(ticket_type.quantity_sold, ticket_type.quantity_total)
ticket_type.status = TicketTypeStatus.sold_out
def _release_inventory_for_order(db: Session, order: Order) -> None:
for item in order.items:
ticket_type = db.get(TicketType, item.ticket_type_id)
if not ticket_type:
continue
ticket_type.quantity_sold = max(ticket_type.quantity_sold - item.quantity, 0)
if ticket_type.status == TicketTypeStatus.sold_out and _remaining(ticket_type) > 0:
ticket_type.status = TicketTypeStatus.active
def create_order(
db: Session,
user: User,
body: OrderCreate,
*,
idempotency_key: str | None = None,
) -> Order:
expire_stale_pending_orders(db)
if idempotency_key:
existing = get_order_by_idempotency_key(db, idempotency_key)
if existing:
return existing
event = db.get(Event, body.event_id)
if not event or event.status != EventStatus.published:
raise AppError("Event not found or not available for purchase", status_code=404)
total_paise, priced_lines = _reserve_inventory(db, body)
settings = get_settings()
gateway = get_payment_gateway(settings)
order = Order(
user_id=user.id,
event_id=body.event_id,
status=OrderStatus.pending,
total_paise=total_paise,
currency="INR",
idempotency_key=idempotency_key,
expires_at=_utcnow() + timedelta(minutes=settings.order_payment_timeout_minutes),
)
db.add(order)
db.flush()
for ticket_type, qty, line_total in priced_lines:
db.add(
OrderItem(
order_id=order.id,
ticket_type_id=ticket_type.id,
quantity=qty,
unit_price_paise=ticket_type.price_paise,
line_total_paise=line_total,
)
)
_apply_inventory_reservation(priced_lines)
provider_order = gateway.create_provider_order(
amount_paise=total_paise,
currency="INR",
receipt=str(order.id),
notes={"desi_clans_order_id": str(order.id), "user_id": str(user.id)},
)
order.provider_order_id = provider_order.provider_order_id
order.payment_reference = provider_order.provider_order_id
try:
db.commit()
except Exception:
db.rollback()
raise
db.refresh(order)
_ = order.items
return order
def fulfill_order_payment(db: Session, order: Order, *, provider_payment_id: str) -> Order:
if order.status == OrderStatus.paid:
return order
existing_ticket = db.scalar(select(Ticket).where(Ticket.order_id == order.id).limit(1))
if not existing_ticket:
items = db.scalars(select(OrderItem).where(OrderItem.order_id == order.id)).all()
issued: list[Ticket] = []
for item in items:
for _ in range(item.quantity):
issued.append(
Ticket(
code=_generate_ticket_code(),
qr_token=generate_qr_token(),
order_id=order.id,
order_item_id=item.id,
ticket_type_id=item.ticket_type_id,
event_id=order.event_id,
user_id=order.user_id,
status=TicketStatus.valid,
)
)
db.add_all(issued)
order.status = OrderStatus.paid
order.provider_payment_id = provider_payment_id
order.paid_at = _utcnow()
order.expires_at = None
db.commit()
db.refresh(order)
_ = order.items
return order
def cancel_pending_order(
db: Session,
order: Order,
*,
status: OrderStatus = OrderStatus.cancelled,
) -> Order:
if order.status != OrderStatus.pending:
raise AppError("Only pending orders can be cancelled", status_code=409)
_release_inventory_for_order(db, order)
order.status = status
order.expires_at = None
db.commit()
db.refresh(order)
return order
def get_order_for_user(db: Session, order_id: int, user_id: int) -> Order:
expire_stale_pending_orders(db)
order = db.scalar(
select(Order)
.options(joinedload(Order.items))
.where(Order.id == order_id, Order.user_id == user_id)
)
if not order:
raise AppError("Order not found", status_code=404)
return order
def list_user_tickets(db: Session, user_id: int) -> list[Ticket]:
return list(
db.scalars(
select(Ticket)
.options(
joinedload(Ticket.order_item).joinedload(OrderItem.ticket_type),
joinedload(Ticket.order).joinedload(Order.event),
)
.where(Ticket.user_id == user_id)
.order_by(Ticket.created_at.desc())
)
.unique()
.all()
)
def get_ticket_for_user(db: Session, ticket_id: int, user_id: int) -> Ticket:
ticket = db.scalar(
select(Ticket)
.options(
joinedload(Ticket.order_item).joinedload(OrderItem.ticket_type),
joinedload(Ticket.order).joinedload(Order.event),
)
.where(Ticket.id == ticket_id, Ticket.user_id == user_id)
)
if not ticket:
raise AppError("Ticket not found", status_code=404)
return ticket
def ticket_to_public(ticket: Ticket) -> dict:
ticket_type_name = None
event_title = None
if ticket.order_item and ticket.order_item.ticket_type:
ticket_type_name = ticket.order_item.ticket_type.name
if ticket.order and ticket.order.event:
event_title = ticket.order.event.title
return {
"id": ticket.id,
"code": ticket.code,
"order_id": ticket.order_id,
"ticket_type_id": ticket.ticket_type_id,
"event_id": ticket.event_id,
"status": ticket.status,
"created_at": ticket.created_at,
"ticket_type_name": ticket_type_name,
"event_title": event_title,
}

View file

@ -0,0 +1,10 @@
from urllib.parse import urlparse
ALLOWED_SCHEMES = {"http", "https"}
def validate_outbound_url(url: str) -> str:
parsed = urlparse(url.strip())
if parsed.scheme not in ALLOWED_SCHEMES or not parsed.netloc:
raise ValueError("Only http and https URLs are allowed")
return url.strip()

View file

@ -0,0 +1,3 @@
from app.services.payments.gateway import get_payment_gateway
__all__ = ["get_payment_gateway"]

View file

@ -0,0 +1,220 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
from abc import ABC, abstractmethod
from dataclasses import dataclass
from app.core.config import Settings, get_settings
@dataclass(frozen=True)
class ProviderOrder:
provider_order_id: str
amount_paise: int
currency: str
@dataclass(frozen=True)
class PaymentVerification:
provider_order_id: str
provider_payment_id: str
amount_paise: int
currency: str
class PaymentGateway(ABC):
@property
@abstractmethod
def provider_name(self) -> str: ...
@property
@abstractmethod
def public_key_id(self) -> str: ...
@abstractmethod
def create_provider_order(
self,
*,
amount_paise: int,
currency: str,
receipt: str,
notes: dict[str, str],
) -> ProviderOrder: ...
@abstractmethod
def verify_checkout_signature(
self,
*,
provider_order_id: str,
provider_payment_id: str,
signature: str,
) -> None: ...
@abstractmethod
def verify_webhook_signature(self, body: bytes, signature: str | None) -> None: ...
@abstractmethod
def parse_webhook_payment(self, payload: dict) -> PaymentVerification | None: ...
class MockPaymentGateway(PaymentGateway):
"""Local/test gateway — signatures derived server-side only (never exposed to clients)."""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._secret = settings.payment_mock_secret
@property
def provider_name(self) -> str:
return "mock"
@property
def public_key_id(self) -> str:
return "mock_public_key"
def create_provider_order(
self,
*,
amount_paise: int,
currency: str,
receipt: str,
notes: dict[str, str],
) -> ProviderOrder:
token = secrets.token_hex(6)
return ProviderOrder(
provider_order_id=f"mock_order_{receipt}_{token}",
amount_paise=amount_paise,
currency=currency,
)
def _expected_signature(self, provider_order_id: str, provider_payment_id: str) -> str:
message = f"{provider_order_id}|{provider_payment_id}".encode()
return hmac.new(self._secret.encode(), message, hashlib.sha256).hexdigest()
def verify_checkout_signature(
self,
*,
provider_order_id: str,
provider_payment_id: str,
signature: str,
) -> None:
expected = self._expected_signature(provider_order_id, provider_payment_id)
if not hmac.compare_digest(expected, signature):
raise ValueError("Invalid payment signature")
def verify_webhook_signature(self, body: bytes, signature: str | None) -> None:
if not signature:
raise ValueError("Missing webhook signature")
expected = hmac.new(self._secret.encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise ValueError("Invalid webhook signature")
def parse_webhook_payment(self, payload: dict) -> PaymentVerification | None:
event = payload.get("event")
if event != "payment.captured":
return None
entity = payload.get("payload", {}).get("payment", {}).get("entity", {})
if not entity:
return None
return PaymentVerification(
provider_order_id=entity.get("order_id", ""),
provider_payment_id=entity.get("id", ""),
amount_paise=int(entity.get("amount", 0)),
currency=entity.get("currency", "INR"),
)
def build_mock_checkout_signature(self, provider_order_id: str, provider_payment_id: str) -> str:
return self._expected_signature(provider_order_id, provider_payment_id)
class RazorpayPaymentGateway(PaymentGateway):
def __init__(self, settings: Settings) -> None:
if not settings.razorpay_key_id or not settings.razorpay_key_secret:
raise RuntimeError("Razorpay credentials are not configured")
import razorpay
self._client = razorpay.Client(auth=(settings.razorpay_key_id, settings.razorpay_key_secret))
self._key_id = settings.razorpay_key_id
self._webhook_secret = settings.razorpay_webhook_secret or ""
@property
def provider_name(self) -> str:
return "razorpay"
@property
def public_key_id(self) -> str:
return self._key_id
def create_provider_order(
self,
*,
amount_paise: int,
currency: str,
receipt: str,
notes: dict[str, str],
) -> ProviderOrder:
data = self._client.order.create(
{
"amount": amount_paise,
"currency": currency,
"receipt": receipt,
"notes": notes,
}
)
return ProviderOrder(
provider_order_id=data["id"],
amount_paise=int(data["amount"]),
currency=data["currency"],
)
def verify_checkout_signature(
self,
*,
provider_order_id: str,
provider_payment_id: str,
signature: str,
) -> None:
self._client.utility.verify_payment_signature(
{
"razorpay_order_id": provider_order_id,
"razorpay_payment_id": provider_payment_id,
"razorpay_signature": signature,
}
)
def verify_webhook_signature(self, body: bytes, signature: str | None) -> None:
if not self._webhook_secret:
raise RuntimeError("RAZORPAY_WEBHOOK_SECRET is not configured")
if not signature:
raise ValueError("Missing webhook signature")
self._client.utility.verify_webhook_signature(body.decode(), signature, self._webhook_secret)
def parse_webhook_payment(self, payload: dict) -> PaymentVerification | None:
event = payload.get("event")
if event not in ("payment.captured", "order.paid"):
return None
payment_entity = payload.get("payload", {}).get("payment", {}).get("entity")
if not payment_entity and event == "order.paid":
payment_entity = payload.get("payload", {}).get("order", {}).get("entity")
if not payment_entity:
return None
order_id = payment_entity.get("order_id") or payment_entity.get("id")
payment_id = payment_entity.get("id") if event == "payment.captured" else payment_entity.get("payment_id")
if not order_id or not payment_id:
return None
return PaymentVerification(
provider_order_id=order_id,
provider_payment_id=payment_id,
amount_paise=int(payment_entity.get("amount", 0)),
currency=payment_entity.get("currency", "INR"),
)
def get_payment_gateway(settings: Settings | None = None) -> PaymentGateway:
settings = settings or get_settings()
mode = settings.payment_provider.lower()
if mode == "razorpay":
return RazorpayPaymentGateway(settings)
return MockPaymentGateway(settings)

View file

@ -0,0 +1,203 @@
import json
import secrets
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from app.core.config import Settings, get_settings
from app.core.errors import AppError
from app.models.enums import OrderStatus
from app.models.orders import Order
from app.models.payments import PaymentWebhookEvent
from app.services.orders import fulfill_order_payment, get_order_by_provider_order_id
from app.schemas.orders import PaymentCheckoutPublic
from app.services.payments.gateway import PaymentGateway, PaymentVerification, get_payment_gateway
class PaymentError(AppError):
pass
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _record_webhook_event(
db: Session,
*,
provider: str,
provider_event_id: str,
event_type: str,
payload: dict,
) -> bool:
"""Return False if event was already processed (idempotent)."""
existing = db.scalar(
select(PaymentWebhookEvent).where(PaymentWebhookEvent.provider_event_id == provider_event_id)
)
if existing:
return False
db.add(
PaymentWebhookEvent(
provider=provider,
provider_event_id=provider_event_id,
event_type=event_type,
payload=json.dumps(payload),
)
)
db.flush()
return True
def confirm_payment_for_order(
db: Session,
order: Order,
verification: PaymentVerification,
*,
provider_payment_id: str,
) -> Order:
if order.status == OrderStatus.paid:
return order
if order.status not in (OrderStatus.pending,):
raise PaymentError("Order is not awaiting payment", status_code=409)
if order.provider_order_id and verification.provider_order_id != order.provider_order_id:
raise PaymentError("Payment order mismatch", status_code=400)
if verification.amount_paise != order.total_paise:
raise PaymentError("Payment amount mismatch", status_code=400)
if verification.currency.upper() != order.currency.upper():
raise PaymentError("Payment currency mismatch", status_code=400)
return fulfill_order_payment(db, order, provider_payment_id=provider_payment_id)
def verify_checkout_and_confirm(
db: Session,
*,
order: Order,
provider_order_id: str,
provider_payment_id: str,
signature: str,
gateway: PaymentGateway | None = None,
) -> Order:
gateway = gateway or get_payment_gateway()
if order.provider_order_id != provider_order_id:
raise PaymentError("Invalid payment order", status_code=400)
try:
gateway.verify_checkout_signature(
provider_order_id=provider_order_id,
provider_payment_id=provider_payment_id,
signature=signature,
)
except Exception as exc:
raise PaymentError("Payment verification failed", status_code=400) from exc
verification = PaymentVerification(
provider_order_id=provider_order_id,
provider_payment_id=provider_payment_id,
amount_paise=order.total_paise,
currency=order.currency,
)
return confirm_payment_for_order(db, order, verification, provider_payment_id=provider_payment_id)
def process_webhook(
db: Session,
*,
body: bytes,
signature: str | None,
gateway: PaymentGateway | None = None,
) -> dict:
gateway = gateway or get_payment_gateway()
try:
gateway.verify_webhook_signature(body, signature)
except Exception as exc:
raise PaymentError("Invalid webhook signature", status_code=400) from exc
payload = json.loads(body.decode())
event_type = payload.get("event", "unknown")
provider_event_id = payload.get("id") or payload.get("event_id") or secrets.token_hex(8)
if not _record_webhook_event(
db,
provider=gateway.provider_name,
provider_event_id=str(provider_event_id),
event_type=event_type,
payload=payload,
):
db.commit()
return {"status": "duplicate_ignored"}
if event_type == "payment.failed":
order = _order_from_webhook_payload(db, payload)
if order and order.status == OrderStatus.pending:
from app.services.orders import cancel_pending_order
cancel_pending_order(db, order, status=OrderStatus.failed)
db.commit()
return {"status": "payment_failed_handled"}
verification = gateway.parse_webhook_payment(payload)
if verification is None:
db.commit()
return {"status": "ignored"}
order = get_order_by_provider_order_id(db, verification.provider_order_id)
if not order:
db.commit()
return {"status": "order_not_found"}
confirm_payment_for_order(
db,
order,
verification,
provider_payment_id=verification.provider_payment_id,
)
db.commit()
return {"status": "paid"}
def _order_from_webhook_payload(db: Session, payload: dict) -> Order | None:
entity = payload.get("payload", {}).get("payment", {}).get("entity", {})
provider_order_id = entity.get("order_id")
if not provider_order_id:
return None
return get_order_by_provider_order_id(db, provider_order_id)
def build_checkout_payload(order: Order, gateway: PaymentGateway | None = None) -> PaymentCheckoutPublic:
gateway = gateway or get_payment_gateway()
settings = get_settings()
return PaymentCheckoutPublic(
provider=gateway.provider_name,
key_id=gateway.public_key_id,
provider_order_id=order.provider_order_id or "",
amount_paise=order.total_paise,
currency=order.currency,
order_id=order.id,
expires_at=order.expires_at,
timeout_minutes=settings.order_payment_timeout_minutes,
)
def mock_complete_pending_order(db: Session, order: Order) -> Order:
settings = get_settings()
if settings.payment_provider.lower() != "mock":
raise PaymentError("Mock completion is not enabled", status_code=404)
gateway = get_payment_gateway(settings)
from app.services.payments.gateway import MockPaymentGateway
if not isinstance(gateway, MockPaymentGateway):
raise PaymentError("Mock gateway required", status_code=404)
provider_order_id = order.provider_order_id or ""
provider_payment_id = f"mock_pay_{order.id}"
signature = gateway.build_mock_checkout_signature(provider_order_id, provider_payment_id)
return verify_checkout_and_confirm(
db,
order=order,
provider_order_id=provider_order_id,
provider_payment_id=provider_payment_id,
signature=signature,
gateway=gateway,
)

View file

@ -0,0 +1,16 @@
import base64
import io
import json
import qrcode
def build_qr_payload(qr_token: str) -> str:
return json.dumps({"v": 1, "token": qr_token}, separators=(",", ":"))
def qr_png_base64(data: str) -> str:
image = qrcode.make(data, box_size=8, border=2)
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return base64.b64encode(buffer.getvalue()).decode("ascii")

View file

@ -0,0 +1,37 @@
from html import escape
from app.config import settings
from app.schemas import EventEditionPublic
def render_share_html(public: EventEditionPublic, slug: str) -> str:
title = escape(public.title)
desc = escape(
public.meta_description
or public.description
or f"{public.title} — Desi Clans, Bengaluru"
)
page_url = f"{settings.public_site_url.rstrip('/')}/events/{slug}"
image = public.cover_image_url or (public.media[0].url if public.media else "")
if image and image.startswith("/"):
image = f"{settings.public_api_url.rstrip('/')}{image}"
image_tag = f'<meta property="og:image" content="{escape(image)}" />' if image else ""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{title} | Desi Clans</title>
<meta name="description" content="{desc}" />
<meta property="og:title" content="{title}" />
<meta property="og:description" content="{desc}" />
<meta property="og:type" content="website" />
<meta property="og:url" content="{escape(page_url)}" />
{image_tag}
<meta name="twitter:card" content="summary_large_image" />
<meta http-equiv="refresh" content="0;url={escape(page_url)}" />
</head>
<body>
<p><a href="{escape(page_url)}">{title}</a> — Bengaluru</p>
</body>
</html>"""

View file

@ -0,0 +1,142 @@
import secrets
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from app.core.config import Settings, get_settings
from app.core.errors import AppError
from app.models.catalog import Event
from app.models.orders import Order, OrderItem, Ticket
from app.models.enums import TicketStatus
from app.models.user import User
from app.services.qr import build_qr_payload, qr_png_base64
def generate_qr_token() -> str:
return secrets.token_urlsafe(32)
def _load_ticket_detail(db: Session, ticket_id: int, user_id: int | None = None) -> Ticket:
query = (
select(Ticket)
.options(
joinedload(Ticket.order_item).joinedload(OrderItem.ticket_type),
joinedload(Ticket.order).joinedload(Order.event).joinedload(Event.venue),
)
.where(Ticket.id == ticket_id)
)
if user_id is not None:
query = query.where(Ticket.user_id == user_id)
ticket = db.scalar(query)
if not ticket:
raise AppError("Ticket not found", status_code=404)
if ticket.order and ticket.order.status.value != "paid":
raise AppError("Ticket not available", status_code=404)
return ticket
def ticket_to_detail(db: Session, ticket: Ticket) -> dict:
if not ticket.qr_token:
ticket.qr_token = generate_qr_token()
db.commit()
db.refresh(ticket)
event = ticket.order.event if ticket.order else None
venue = event.venue if event else None
user = db.get(User, ticket.user_id)
ticket_type = ticket.order_item.ticket_type if ticket.order_item else None
payload = build_qr_payload(ticket.qr_token)
return {
"id": ticket.id,
"code": ticket.code,
"status": ticket.status,
"order_id": ticket.order_id,
"event_id": ticket.event_id,
"event_title": event.title if event else "Event",
"event_starts_at": event.starts_at if event else ticket.created_at,
"event_ends_at": event.ends_at if event else None,
"venue_name": venue.name if venue else "Bengaluru",
"venue_area": venue.area if venue else "",
"venue_city": venue.city if venue else "Bengaluru",
"attendee_name": (user.full_name if user and user.full_name else user.email if user else "Guest"),
"attendee_email": user.email if user else "",
"ticket_type_name": ticket_type.name if ticket_type else "General",
"ticket_type_id": ticket.ticket_type_id,
"qr_token": ticket.qr_token,
"qr_payload": payload,
"qr_image_base64": qr_png_base64(payload),
"used_at": ticket.used_at,
"created_at": ticket.created_at,
}
def get_ticket_detail_for_user(db: Session, ticket_id: int, user_id: int) -> dict:
ticket = _load_ticket_detail(db, ticket_id, user_id=user_id)
return ticket_to_detail(db, ticket)
def verify_ticket_at_gate(db: Session, qr_token: str) -> dict:
ticket = db.scalar(select(Ticket).where(Ticket.qr_token == qr_token).with_for_update())
if not ticket:
raise AppError("Invalid ticket", status_code=404)
order = db.get(Order, ticket.order_id)
event = db.get(Event, ticket.event_id) if ticket.event_id else None
event_title = event.title if event else "Event"
if ticket.status == TicketStatus.used:
return {
"valid": False,
"status": ticket.status,
"ticket_id": ticket.id,
"ticket_code": ticket.code,
"event_title": event_title,
"message": "Ticket already used — QR cannot be reused",
}
if ticket.status == TicketStatus.cancelled:
return {
"valid": False,
"status": ticket.status,
"ticket_id": ticket.id,
"ticket_code": ticket.code,
"event_title": event_title,
"message": "Ticket cancelled",
}
if order is None or order.status.value != "paid":
return {
"valid": False,
"status": ticket.status,
"ticket_id": ticket.id,
"ticket_code": ticket.code,
"event_title": event_title,
"message": "Order not paid",
}
from datetime import datetime, timezone
ticket.status = TicketStatus.used
ticket.used_at = datetime.now(timezone.utc)
db.commit()
return {
"valid": True,
"status": TicketStatus.used,
"ticket_id": ticket.id,
"ticket_code": ticket.code,
"event_title": event_title,
"message": "Entry verified",
}
def assert_checkin_authorized(settings: Settings, api_key: str | None) -> None:
expected = settings.ticket_verify_api_key
if settings.environment.lower() == "development" and not expected:
return
if not expected or not api_key:
raise AppError("Unauthorized", status_code=401)
if not secrets.compare_digest(api_key, expected):
raise AppError("Unauthorized", status_code=401)

View file

@ -0,0 +1,41 @@
from sqlalchemy.orm import Session
from app.core.security import hash_password, verify_password
from app.models.user import User
from app.schemas.auth import RegisterRequest
class EmailAlreadyRegisteredError(Exception):
pass
def get_user_by_email(db: Session, email: str) -> User | None:
return db.query(User).filter(User.email == email).first()
def get_user_by_id(db: Session, user_id: int) -> User | None:
return db.query(User).filter(User.id == user_id).first()
def register_user(db: Session, body: RegisterRequest) -> User:
if get_user_by_email(db, body.email):
raise EmailAlreadyRegisteredError()
user = User(
email=body.email,
hashed_password=hash_password(body.password),
full_name=body.full_name,
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
def authenticate_user(db: Session, email: str, password: str) -> User | None:
user = get_user_by_email(db, email)
if not user or not user.is_active:
return None
if not verify_password(password, user.hashed_password):
return None
return user