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,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,
}