Skip to content
This repository was archived by the owner on Apr 28, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions app/models/ticket.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

from sqlalchemy import Integer, String
from datetime import datetime

from sqlalchemy import DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column

from app.extensions import db
Expand All @@ -13,11 +15,19 @@ class Ticket(db.Model):
event_name: Mapped[str] = mapped_column(String(255), nullable=False)
capacity: Mapped[int] = mapped_column(Integer, nullable=False)
reserved_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
reservation_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
reservation_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
reservation_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
reservation_confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

def to_dict(self) -> dict[str, int | str]:
def to_dict(self) -> dict[str, int | str | None]:
return {
"id": self.id,
"event_name": self.event_name,
"capacity": self.capacity,
"reserved_count": self.reserved_count,
"reservation_status": self.reservation_status,
"reservation_created_at": self.reservation_created_at.isoformat() if self.reservation_created_at else None,
"reservation_expires_at": self.reservation_expires_at.isoformat() if self.reservation_expires_at else None,
"reservation_confirmed_at": self.reservation_confirmed_at.isoformat() if self.reservation_confirmed_at else None,
}
81 changes: 66 additions & 15 deletions app/routes/ticket_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,21 @@
from http import HTTPStatus
from typing import Any

from flask import Blueprint, jsonify, request
from flask import Blueprint, current_app, jsonify, request
from flask.typing import ResponseReturnValue

from app.extensions import db
from app.models.ticket import Ticket
from app.services.booking_service import reserve_ticket
from app.services.booking_service import (
NoActiveTentativeReservationError,
ReservationError,
TentativeReservationExpiredError,
TicketAlreadyConfirmedError,
TicketAlreadyReservedError,
TicketNotFoundError,
confirm_ticket,
reserve_ticket,
)


ticket_bp: Blueprint = Blueprint("tickets", __name__, url_prefix="/api/tickets")
Expand Down Expand Up @@ -40,20 +49,62 @@ def create_ticket() -> ResponseReturnValue:
return jsonify(ticket.to_dict()), HTTPStatus.CREATED


@ticket_bp.post("/<int:ticket_id>/reserve")
def _error_response(error: ReservationError, status: HTTPStatus) -> ResponseReturnValue:
return (
jsonify(
{
"error_code": error.error_code,
"message": error.message,
"details": None,
}
),
status,
)


@ticket_bp.post("/<int:ticket_id>/reservation")
def reserve(ticket_id: int) -> ResponseReturnValue:
payload: dict[str, Any] = request.get_json(silent=True) or {}
ttl_seconds: int = int(current_app.config.get("RESERVATION_TTL_SECONDS", 60))

try:
quantity: int = int(payload.get("quantity", 1))
except (TypeError, ValueError):
return jsonify({"error": "quantity must be an integer"}), HTTPStatus.BAD_REQUEST

updated_ticket: Ticket = reserve_ticket(ticket_id=ticket_id, ttl_seconds=ttl_seconds)
except TicketNotFoundError as error:
return _error_response(error, HTTPStatus.NOT_FOUND)
except (TicketAlreadyReservedError, TicketAlreadyConfirmedError) as error:
return _error_response(error, HTTPStatus.CONFLICT)

return (
jsonify(
{
"ticket_id": updated_ticket.id,
"status": updated_ticket.reservation_status,
"reservation_expires_at": updated_ticket.reservation_expires_at.isoformat()
if updated_ticket.reservation_expires_at
else None,
}
),
HTTPStatus.CREATED,
)


@ticket_bp.post("/<int:ticket_id>/confirm")
def confirm(ticket_id: int) -> ResponseReturnValue:
try:
updated_ticket: Ticket = reserve_ticket(ticket_id=ticket_id, quantity=quantity)
except LookupError as error:
return jsonify({"error": str(error)}), HTTPStatus.NOT_FOUND
except ValueError as error:
return jsonify({"error": str(error)}), HTTPStatus.BAD_REQUEST

return jsonify(updated_ticket.to_dict()), HTTPStatus.OK
updated_ticket: Ticket = confirm_ticket(ticket_id=ticket_id)
except TicketNotFoundError as error:
return _error_response(error, HTTPStatus.NOT_FOUND)
except (NoActiveTentativeReservationError, TentativeReservationExpiredError) as error:
return _error_response(error, HTTPStatus.CONFLICT)

return (
jsonify(
{
"ticket_id": updated_ticket.id,
"status": updated_ticket.reservation_status,
"reservation_confirmed_at": updated_ticket.reservation_confirmed_at.isoformat()
if updated_ticket.reservation_confirmed_at
else None,
}
),
HTTPStatus.OK,
)
163 changes: 153 additions & 10 deletions app/services/booking_service.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,165 @@
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone

from sqlalchemy import and_, or_, update

from app.extensions import db
from app.models.ticket import Ticket


def reserve_ticket(ticket_id: int, quantity: int) -> Ticket:
if quantity <= 0:
raise ValueError("quantity must be greater than zero")
@dataclass(slots=True)
class ReservationError(Exception):
error_code: str
message: str


class TicketNotFoundError(ReservationError):
def __init__(self) -> None:
super().__init__(error_code="TICKET_NOT_FOUND", message="ticket not found")


class TicketAlreadyReservedError(ReservationError):
def __init__(self) -> None:
super().__init__(
error_code="TICKET_ALREADY_RESERVED",
message="ticket already reserved and pending confirmation",
)


class TicketAlreadyConfirmedError(ReservationError):
def __init__(self) -> None:
super().__init__(error_code="TICKET_ALREADY_CONFIRMED", message="ticket already confirmed")


class NoActiveTentativeReservationError(ReservationError):
def __init__(self) -> None:
super().__init__(
error_code="NO_ACTIVE_TENTATIVE_RESERVATION",
message="no active tentative reservation to confirm",
)


class TentativeReservationExpiredError(ReservationError):
def __init__(self) -> None:
super().__init__(
error_code="TENTATIVE_RESERVATION_EXPIRED",
message="tentative reservation expired at runtime",
)


AWAITING_CONFIRMATION: str = "AWAITING_CONFIRMATION"
CONFIRMED: str = "CONFIRMED"


def _utcnow() -> datetime:
return datetime.now(timezone.utc)


def _as_utc(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)


def reserve_ticket(ticket_id: int, ttl_seconds: int) -> Ticket:
now_utc: datetime = _utcnow()
ticket: Ticket | None = db.session.get(Ticket, ticket_id)
if ticket is None:
raise LookupError("ticket not found")
raise TicketNotFoundError()

remaining_capacity: int = ticket.capacity - ticket.reserved_count
if quantity > remaining_capacity:
raise ValueError("not enough capacity")
if ticket.reservation_status == CONFIRMED:
raise TicketAlreadyConfirmedError()

# Intentionally vulnerable read-check-write flow for workshop race conditions.
ticket.reserved_count = ticket.reserved_count + quantity
ticket_expires_at_utc: datetime | None = _as_utc(ticket.reservation_expires_at)
if (
ticket.reservation_status == AWAITING_CONFIRMATION
and ticket_expires_at_utc is not None
and ticket_expires_at_utc > now_utc
):
raise TicketAlreadyReservedError()

if ttl_seconds <= 0:
ttl_seconds = 60

expires_at: datetime = now_utc + timedelta(seconds=ttl_seconds)

now_naive_utc: datetime = now_utc.replace(tzinfo=None)
update_result = db.session.execute(
update(Ticket)
.where(Ticket.id == ticket_id)
.where(
or_(
Ticket.reservation_status.is_(None),
and_(
Ticket.reservation_status == AWAITING_CONFIRMATION,
Ticket.reservation_expires_at <= now_naive_utc,
),
)
)
.values(
reservation_status=AWAITING_CONFIRMATION,
reservation_created_at=now_utc,
reservation_expires_at=expires_at,
reservation_confirmed_at=None,
)
)
if update_result.rowcount == 0:
current_ticket: Ticket | None = db.session.get(Ticket, ticket_id)
if current_ticket is None:
raise TicketNotFoundError()
if current_ticket.reservation_status == CONFIRMED:
raise TicketAlreadyConfirmedError()
current_expires_at_utc: datetime | None = _as_utc(current_ticket.reservation_expires_at)
if (
current_ticket.reservation_status == AWAITING_CONFIRMATION
and current_expires_at_utc is not None
and current_expires_at_utc > now_utc
):
raise TicketAlreadyReservedError()

db.session.commit()

updated_ticket: Ticket | None = db.session.get(Ticket, ticket_id)
if updated_ticket is None:
raise TicketNotFoundError()

return updated_ticket


def confirm_ticket(ticket_id: int) -> Ticket:
now_utc: datetime = _utcnow()

ticket: Ticket | None = db.session.get(Ticket, ticket_id)
if ticket is None:
raise TicketNotFoundError()

if ticket.reservation_status == CONFIRMED:
return ticket

if ticket.reservation_status != AWAITING_CONFIRMATION:
raise NoActiveTentativeReservationError()

reservation_expires_at_utc: datetime | None = _as_utc(ticket.reservation_expires_at)
if reservation_expires_at_utc is None or reservation_expires_at_utc <= now_utc:
raise TentativeReservationExpiredError()

db.session.execute(
update(Ticket)
.where(Ticket.id == ticket_id)
.where(Ticket.reservation_status == AWAITING_CONFIRMATION)
.values(
reservation_status=CONFIRMED,
reservation_confirmed_at=now_utc,
)
)
db.session.commit()
return ticket

confirmed_ticket: Ticket | None = db.session.get(Ticket, ticket_id)
if confirmed_ticket is None:
raise TicketNotFoundError()

return confirmed_ticket
1 change: 1 addition & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class Config:
SQLALCHEMY_TRACK_MODIFICATIONS: bool = False
JSON_SORT_KEYS: bool = False
TESTING: bool = False
RESERVATION_TTL_SECONDS: int = 60


class DevelopmentConfig(Config):
Expand Down
8 changes: 8 additions & 0 deletions docs/architecture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ Alternative run command:
flask --app run.py run
```

Recommended quick-run shortcuts:
```bash
sh run.sh --local
sh run.sh --docker
sh run.sh --docker-detached
sh run.sh --down
```

Optional container fallback:
```bash
docker build -t gdg-workshop-ai-booking .
Expand Down
Loading