first commit
This commit is contained in:
commit
34ea15a54d
246 changed files with 18660 additions and 0 deletions
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
0
backend/app/services/admin/__init__.py
Normal file
0
backend/app/services/admin/__init__.py
Normal file
126
backend/app/services/admin/catalog.py
Normal file
126
backend/app/services/admin/catalog.py
Normal 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
|
||||
82
backend/app/services/admin/dashboard.py
Normal file
82
backend/app/services/admin/dashboard.py
Normal 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
|
||||
40
backend/app/services/admin/orders.py
Normal file
40
backend/app/services/admin/orders.py
Normal 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
|
||||
]
|
||||
39
backend/app/services/alerts.py
Normal file
39
backend/app/services/alerts.py
Normal 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()
|
||||
38
backend/app/services/calendar.py
Normal file
38
backend/app/services/calendar.py
Normal 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"
|
||||
95
backend/app/services/catalog.py
Normal file
95
backend/app/services/catalog.py
Normal 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
|
||||
60
backend/app/services/editions.py
Normal file
60
backend/app/services/editions.py
Normal 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,
|
||||
)
|
||||
21
backend/app/services/email.py
Normal file
21
backend/app/services/email.py
Normal 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)
|
||||
11
backend/app/services/health.py
Normal file
11
backend/app/services/health.py
Normal 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
|
||||
70
backend/app/services/media.py
Normal file
70
backend/app/services/media.py
Normal 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
|
||||
335
backend/app/services/orders.py
Normal file
335
backend/app/services/orders.py
Normal 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,
|
||||
}
|
||||
10
backend/app/services/outbound.py
Normal file
10
backend/app/services/outbound.py
Normal 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()
|
||||
3
backend/app/services/payments/__init__.py
Normal file
3
backend/app/services/payments/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from app.services.payments.gateway import get_payment_gateway
|
||||
|
||||
__all__ = ["get_payment_gateway"]
|
||||
220
backend/app/services/payments/gateway.py
Normal file
220
backend/app/services/payments/gateway.py
Normal 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)
|
||||
203
backend/app/services/payments/service.py
Normal file
203
backend/app/services/payments/service.py
Normal 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,
|
||||
)
|
||||
16
backend/app/services/qr.py
Normal file
16
backend/app/services/qr.py
Normal 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")
|
||||
37
backend/app/services/share_preview.py
Normal file
37
backend/app/services/share_preview.py
Normal 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>"""
|
||||
142
backend/app/services/tickets.py
Normal file
142
backend/app/services/tickets.py
Normal 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)
|
||||
41
backend/app/services/users.py
Normal file
41
backend/app/services/users.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue