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

50
backend/.env.example Normal file
View file

@ -0,0 +1,50 @@
# Core
ENVIRONMENT=development
DATABASE_URL=postgresql://desiclans:desiclans_dev@localhost:5432/desiclans
# Use a long random value in production (32+ chars). Never commit real secrets.
SECRET_KEY=change-me-to-a-long-random-string-in-production
# Attendee JWT lifetime (minutes). Default 1440 = 24h.
ACCESS_TOKEN_EXPIRE_MINUTES=1440
# Payments — mock (local) or razorpay (production)
PAYMENT_PROVIDER=mock
PAYMENT_MOCK_SECRET=dev-mock-payment-secret-change-me
ORDER_PAYMENT_TIMEOUT_MINUTES=15
# RAZORPAY_KEY_ID=
# RAZORPAY_KEY_SECRET=
# RAZORPAY_WEBHOOK_SECRET=
# Gate / scanner API key for POST /api/tickets/verify (required in production, min 16 chars)
# TICKET_VERIFY_API_KEY=generate-a-long-random-string-for-gate-scanners
# CORS (comma-separated origins)
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# Public URLs (SEO, share previews, calendar links)
PUBLIC_SITE_URL=http://localhost:5173
PUBLIC_API_URL=http://localhost:8000
# Media
UPLOAD_DIR=uploads
STORAGE_BACKEND=local
# Seed script defaults (development only)
ADMIN_EMAIL=admin@desiclans.in
ADMIN_PASSWORD=changeme
# Optional SMTP alerts
# SMTP_HOST=
# SMTP_PORT=587
# SMTP_USER=
# SMTP_PASSWORD=
# SMTP_FROM=
# SMTP_USE_TLS=true
# ALERT_NOTIFY_EMAIL=
# ALERT_CLICK_THRESHOLD=50
# Optional S3 (when STORAGE_BACKEND=s3)
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=
# AWS_REGION=ap-south-1
# S3_BUCKET=
# S3_PUBLIC_BASE_URL=

18
backend/Dockerfile Normal file
View file

@ -0,0 +1,18 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev gcc && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini .
COPY scripts ./scripts
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["sh", "-c", "python scripts/migrate.py && uvicorn app.main:app --host 0.0.0.0 --port 8000"]

67
backend/README.md Normal file
View file

@ -0,0 +1,67 @@
# Desi Clans — Backend
FastAPI application with layered architecture, PostgreSQL, SQLAlchemy, Alembic, and JWT-ready configuration.
## Layout
```
app/
├── main.py # Application factory
├── core/ # Settings, dependencies, errors
├── db/ # SQLAlchemy engine, session, Base
├── models/ # ORM entities & enums
├── schemas/ # Pydantic request/response models
├── api/ # API routers
│ └── routes/ # Route modules (health, …)
├── services/ # Business logic
└── utils/ # Storage and helpers
```
Legacy route modules under `app/routers/` remain for a future mount phase.
## Prerequisites
- Python 3.11+
- PostgreSQL 15+ (local or Docker)
## Setup
```bash
cd backend
python -m venv .venv
# Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
```
Start Postgres (from repo root):
```bash
docker compose up -d db
```
Run migrations and optional seed:
```bash
python scripts/migrate.py
python scripts/seed_catalog.py
python scripts/seed.py
```
Start API:
```bash
uvicorn app.main:app --reload --port 8000
```
Health (includes DB check): [http://localhost:8000/api/health](http://localhost:8000/api/health)
## Tests
```bash
pytest tests/
```
## Environment
Copy `.env.example` to `.env`. Required: `DATABASE_URL`, `SECRET_KEY`.

42
backend/alembic.ini Normal file
View file

@ -0,0 +1,42 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

67
backend/alembic/env.py Normal file
View file

@ -0,0 +1,67 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import get_settings
from app.db.base import Base
from app.models import ( # noqa: F401
AdminUser,
AlertDispatch,
Event,
EventEdition,
EventMedia,
MediaAsset,
NewsletterSubscriber,
Order,
OrderItem,
OutboundClick,
PaymentWebhookEvent,
PlatformLink,
Ticket,
TicketType,
User,
Venue,
)
config = context.config
settings = get_settings()
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,31 @@
"""edition ticket urls and meta description
Revision ID: 20260404_0001
Revises:
Create Date: 2026-04-04
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260404_0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TABLE event_editions ADD COLUMN IF NOT EXISTS bookmyshow_url VARCHAR(512)")
op.execute("ALTER TABLE event_editions ADD COLUMN IF NOT EXISTS district_url VARCHAR(512)")
op.execute("ALTER TABLE event_editions ADD COLUMN IF NOT EXISTS instagram_url VARCHAR(512)")
op.execute("ALTER TABLE event_editions ADD COLUMN IF NOT EXISTS meta_description VARCHAR(320)")
def downgrade() -> None:
op.drop_column("event_editions", "meta_description")
op.drop_column("event_editions", "instagram_url")
op.drop_column("event_editions", "district_url")
op.drop_column("event_editions", "bookmyshow_url")

View file

@ -0,0 +1,39 @@
"""outbound click analytics table
Revision ID: 20260404_0002
Revises: 20260404_0001
Create Date: 2026-04-04
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260404_0002"
down_revision: Union[str, None] = "20260404_0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"outbound_clicks",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("link_kind", sa.String(length=64), nullable=False),
sa.Column("destination_url", sa.String(length=512), nullable=False),
sa.Column("edition_id", sa.Integer(), nullable=True),
sa.Column("referrer", sa.String(length=512), nullable=True),
sa.Column("user_agent", sa.String(length=512), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["edition_id"], ["event_editions.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_outbound_clicks_link_kind", "outbound_clicks", ["link_kind"])
op.create_index("ix_outbound_clicks_edition_id", "outbound_clicks", ["edition_id"])
op.create_index("ix_outbound_clicks_created_at", "outbound_clicks", ["created_at"])
def downgrade() -> None:
op.drop_table("outbound_clicks")

View file

@ -0,0 +1,56 @@
"""admin roles, subscribers, alert log
Revision ID: 20260404_0003
Revises: 20260404_0002
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260404_0003"
down_revision: Union[str, None] = "20260404_0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
DO $$ BEGIN
CREATE TYPE adminrole AS ENUM ('super_admin', 'editor');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
"""
)
op.execute(
"ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS role adminrole NOT NULL DEFAULT 'super_admin'"
)
op.execute(
"""
CREATE TABLE IF NOT EXISTS newsletter_subscribers (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
neighborhood VARCHAR(120),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
op.execute(
"""
CREATE TABLE IF NOT EXISTS alert_dispatches (
id SERIAL PRIMARY KEY,
alert_key VARCHAR(120) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS alert_dispatches")
op.execute("DROP TABLE IF EXISTS newsletter_subscribers")
op.execute("ALTER TABLE admin_users DROP COLUMN IF EXISTS role")

View file

@ -0,0 +1,22 @@
"""add highape platform kind
Revision ID: 20260404_0004
Revises: 20260404_0003
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260404_0004"
down_revision: Union[str, None] = "20260404_0003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TYPE platformkind ADD VALUE IF NOT EXISTS 'highape'")
def downgrade() -> None:
pass

View file

@ -0,0 +1,123 @@
"""events venues event_media ticket_types catalog
Revision ID: 20260404_0005
Revises: 20260404_0004
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "20260404_0005"
down_revision: Union[str, None] = "20260404_0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
event_category = postgresql.ENUM(
"holi",
"garba",
"dandiya",
"bhajan_clubbing",
"special",
name="eventtype",
create_type=False,
)
event_status = postgresql.ENUM(
"draft",
"published",
"cancelled",
name="eventstatus",
create_type=False,
)
ticket_type_status = postgresql.ENUM(
"active",
"sold_out",
"inactive",
name="tickettypestatus",
create_type=False,
)
def upgrade() -> None:
op.create_table(
"venues",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("slug", sa.String(length=120), nullable=False),
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("area", sa.String(length=120), nullable=False),
sa.Column("city", sa.String(length=80), nullable=False, server_default="Bengaluru"),
sa.Column("address", sa.String(length=512), nullable=False),
sa.Column("map_url", sa.String(length=512), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_venues_slug", "venues", ["slug"], unique=True)
event_category.create(op.get_bind(), checkfirst=True)
event_status.create(op.get_bind(), checkfirst=True)
ticket_type_status.create(op.get_bind(), checkfirst=True)
op.create_table(
"events",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("slug", sa.String(length=200), nullable=False),
sa.Column("title", sa.String(length=240), nullable=False),
sa.Column("category", event_category, nullable=False),
sa.Column("status", event_status, nullable=False, server_default="draft"),
sa.Column("venue_id", sa.Integer(), sa.ForeignKey("venues.id", ondelete="RESTRICT"), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("starts_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ends_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("is_featured", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("cover_image_url", sa.String(length=1024), nullable=True),
sa.Column("cover_image_alt", sa.String(length=300), nullable=True),
sa.Column("meta_description", sa.String(length=320), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_events_slug", "events", ["slug"], unique=True)
op.create_index("ix_events_category", "events", ["category"])
op.create_index("ix_events_status", "events", ["status"])
op.create_index("ix_events_venue_id", "events", ["venue_id"])
op.create_index("ix_events_starts_at", "events", ["starts_at"])
op.create_index("ix_events_status_starts_at", "events", ["status", "starts_at"])
op.create_index("ix_events_category_status", "events", ["category", "status"])
op.create_table(
"event_media",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("event_id", sa.Integer(), sa.ForeignKey("events.id", ondelete="CASCADE"), nullable=False),
sa.Column("image_url", sa.String(length=1024), nullable=False),
sa.Column("alt_text", sa.String(length=300), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_event_media_event_id", "event_media", ["event_id"])
op.create_table(
"ticket_types",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("event_id", sa.Integer(), sa.ForeignKey("events.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(length=120), nullable=False),
sa.Column("description", sa.String(length=512), nullable=True),
sa.Column("price_paise", sa.Integer(), nullable=False),
sa.Column("quantity_total", sa.Integer(), nullable=False),
sa.Column("quantity_sold", sa.Integer(), nullable=False, server_default="0"),
sa.Column("status", ticket_type_status, nullable=False, server_default="active"),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_ticket_types_event_id", "ticket_types", ["event_id"])
def downgrade() -> None:
op.drop_table("ticket_types")
op.drop_table("event_media")
op.drop_table("events")
op.drop_table("venues")
ticket_type_status.drop(op.get_bind(), checkfirst=True)
event_status.drop(op.get_bind(), checkfirst=True)

View file

@ -0,0 +1,34 @@
"""attendee users table
Revision ID: 20260404_0006
Revises: 20260404_0005
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260404_0006"
down_revision: Union[str, None] = "20260404_0005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("hashed_password", sa.String(length=255), nullable=False),
sa.Column("full_name", sa.String(length=120), nullable=True),
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
def downgrade() -> None:
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_table("users")

View file

@ -0,0 +1,129 @@
"""orders order_items tickets
Revision ID: 20260404_0007
Revises: 20260404_0006
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "20260404_0007"
down_revision: Union[str, None] = "20260404_0006"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
order_status = postgresql.ENUM(
"pending",
"paid",
"cancelled",
"failed",
name="orderstatus",
create_type=False,
)
ticket_status = postgresql.ENUM(
"valid",
"cancelled",
"redeemed",
name="ticketstatus",
create_type=False,
)
def upgrade() -> None:
order_status.create(op.get_bind(), checkfirst=True)
ticket_status.create(op.get_bind(), checkfirst=True)
op.create_table(
"orders",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("event_id", sa.Integer(), nullable=False),
sa.Column("status", order_status, nullable=False, server_default="pending"),
sa.Column("total_paise", sa.Integer(), nullable=False),
sa.Column("currency", sa.String(length=3), nullable=False, server_default="INR"),
sa.Column("payment_reference", sa.String(length=64), nullable=True),
sa.Column("paid_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["event_id"], ["events.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_orders_event_id"), "orders", ["event_id"], unique=False)
op.create_index(op.f("ix_orders_status"), "orders", ["status"], unique=False)
op.create_index(op.f("ix_orders_user_id"), "orders", ["user_id"], unique=False)
op.create_table(
"order_items",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("order_id", sa.Integer(), nullable=False),
sa.Column("ticket_type_id", sa.Integer(), nullable=False),
sa.Column("quantity", sa.Integer(), nullable=False),
sa.Column("unit_price_paise", sa.Integer(), nullable=False),
sa.Column("line_total_paise", sa.Integer(), nullable=False),
sa.CheckConstraint("quantity > 0", name="ck_order_items_quantity_positive"),
sa.ForeignKeyConstraint(["order_id"], ["orders.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["ticket_type_id"], ["ticket_types.id"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_order_items_order_id"), "order_items", ["order_id"], unique=False)
op.create_index(op.f("ix_order_items_ticket_type_id"), "order_items", ["ticket_type_id"], unique=False)
op.create_table(
"tickets",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("code", sa.String(length=32), nullable=False),
sa.Column("order_id", sa.Integer(), nullable=False),
sa.Column("order_item_id", sa.Integer(), nullable=False),
sa.Column("ticket_type_id", sa.Integer(), nullable=False),
sa.Column("event_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("status", ticket_status, nullable=False, server_default="valid"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["event_id"], ["events.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["order_id"], ["orders.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["order_item_id"], ["order_items.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["ticket_type_id"], ["ticket_types.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("code"),
)
op.create_index(op.f("ix_tickets_code"), "tickets", ["code"], unique=True)
op.create_index(op.f("ix_tickets_event_id"), "tickets", ["event_id"], unique=False)
op.create_index(op.f("ix_tickets_order_id"), "tickets", ["order_id"], unique=False)
op.create_index(op.f("ix_tickets_order_item_id"), "tickets", ["order_item_id"], unique=False)
op.create_index(op.f("ix_tickets_status"), "tickets", ["status"], unique=False)
op.create_index(op.f("ix_tickets_ticket_type_id"), "tickets", ["ticket_type_id"], unique=False)
op.create_index(op.f("ix_tickets_user_id"), "tickets", ["user_id"], unique=False)
op.create_index("ix_tickets_user_status", "tickets", ["user_id", "status"], unique=False)
op.create_check_constraint(
"ck_ticket_types_sold_not_exceed_total",
"ticket_types",
"quantity_sold <= quantity_total",
)
def downgrade() -> None:
op.drop_constraint("ck_ticket_types_sold_not_exceed_total", "ticket_types", type_="check")
op.drop_index("ix_tickets_user_status", table_name="tickets")
op.drop_index(op.f("ix_tickets_user_id"), table_name="tickets")
op.drop_index(op.f("ix_tickets_ticket_type_id"), table_name="tickets")
op.drop_index(op.f("ix_tickets_status"), table_name="tickets")
op.drop_index(op.f("ix_tickets_order_item_id"), table_name="tickets")
op.drop_index(op.f("ix_tickets_order_id"), table_name="tickets")
op.drop_index(op.f("ix_tickets_event_id"), table_name="tickets")
op.drop_index(op.f("ix_tickets_code"), table_name="tickets")
op.drop_table("tickets")
op.drop_index(op.f("ix_order_items_ticket_type_id"), table_name="order_items")
op.drop_index(op.f("ix_order_items_order_id"), table_name="order_items")
op.drop_table("order_items")
op.drop_index(op.f("ix_orders_user_id"), table_name="orders")
op.drop_index(op.f("ix_orders_status"), table_name="orders")
op.drop_index(op.f("ix_orders_event_id"), table_name="orders")
op.drop_table("orders")
ticket_status.drop(op.get_bind(), checkfirst=True)
order_status.drop(op.get_bind(), checkfirst=True)

View file

@ -0,0 +1,73 @@
"""payment fields and webhook idempotency
Revision ID: 20260404_0008
Revises: 20260404_0007
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260404_0008"
down_revision: Union[str, None] = "20260404_0007"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.alter_column(
"orders",
"payment_reference",
existing_type=sa.String(length=64),
type_=sa.String(length=128),
existing_nullable=True,
)
op.add_column("orders", sa.Column("provider_order_id", sa.String(length=64), nullable=True))
op.add_column("orders", sa.Column("provider_payment_id", sa.String(length=64), nullable=True))
op.add_column("orders", sa.Column("idempotency_key", sa.String(length=64), nullable=True))
op.add_column("orders", sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f("ix_orders_provider_order_id"), "orders", ["provider_order_id"], unique=True)
op.create_index(op.f("ix_orders_provider_payment_id"), "orders", ["provider_payment_id"], unique=False)
op.create_index(op.f("ix_orders_idempotency_key"), "orders", ["idempotency_key"], unique=True)
op.create_index(op.f("ix_orders_expires_at"), "orders", ["expires_at"], unique=False)
op.create_table(
"payment_webhook_events",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("provider", sa.String(length=32), nullable=False),
sa.Column("provider_event_id", sa.String(length=128), nullable=False),
sa.Column("event_type", sa.String(length=64), nullable=False),
sa.Column("payload", sa.Text(), nullable=False),
sa.Column("processed_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("provider_event_id"),
)
op.create_index(op.f("ix_payment_webhook_events_provider"), "payment_webhook_events", ["provider"], unique=False)
op.create_index(
op.f("ix_payment_webhook_events_provider_event_id"),
"payment_webhook_events",
["provider_event_id"],
unique=True,
)
def downgrade() -> None:
op.drop_index(op.f("ix_payment_webhook_events_provider_event_id"), table_name="payment_webhook_events")
op.drop_index(op.f("ix_payment_webhook_events_provider"), table_name="payment_webhook_events")
op.drop_table("payment_webhook_events")
op.drop_index(op.f("ix_orders_expires_at"), table_name="orders")
op.drop_index(op.f("ix_orders_idempotency_key"), table_name="orders")
op.drop_index(op.f("ix_orders_provider_payment_id"), table_name="orders")
op.drop_index(op.f("ix_orders_provider_order_id"), table_name="orders")
op.drop_column("orders", "expires_at")
op.drop_column("orders", "idempotency_key")
op.drop_column("orders", "provider_payment_id")
op.drop_column("orders", "provider_order_id")
op.alter_column(
"orders",
"payment_reference",
existing_type=sa.String(length=128),
type_=sa.String(length=64),
existing_nullable=True,
)

View file

@ -0,0 +1,42 @@
"""digital ticket qr tokens and used status
Revision ID: 20260404_0009
Revises: 20260404_0008
"""
import secrets
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260404_0009"
down_revision: Union[str, None] = "20260404_0008"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TYPE ticketstatus RENAME VALUE 'redeemed' TO 'used'")
op.add_column("tickets", sa.Column("qr_token", sa.String(length=64), nullable=True))
op.add_column("tickets", sa.Column("used_at", sa.DateTime(timezone=True), nullable=True))
connection = op.get_bind()
rows = connection.execute(sa.text("SELECT id FROM tickets WHERE qr_token IS NULL")).fetchall()
for row in rows:
token = secrets.token_urlsafe(32)
connection.execute(
sa.text("UPDATE tickets SET qr_token = :token WHERE id = :id"),
{"token": token, "id": row.id},
)
op.alter_column("tickets", "qr_token", nullable=False)
op.create_index(op.f("ix_tickets_qr_token"), "tickets", ["qr_token"], unique=True)
def downgrade() -> None:
op.drop_index(op.f("ix_tickets_qr_token"), table_name="tickets")
op.drop_column("tickets", "used_at")
op.drop_column("tickets", "qr_token")
op.execute("ALTER TYPE ticketstatus RENAME VALUE 'used' TO 'redeemed'")

1
backend/app/__init__.py Normal file
View file

@ -0,0 +1 @@

View file

@ -0,0 +1,3 @@
from app.api.router import api_router
__all__ = ["api_router"]

14
backend/app/api/router.py Normal file
View file

@ -0,0 +1,14 @@
from fastapi import APIRouter
from app.api.routes import auth, events, health, orders, tickets, venues, webhooks
from app.api.routes.admin.router import router as admin_router
api_router = APIRouter()
api_router.include_router(health.router)
api_router.include_router(admin_router)
api_router.include_router(auth.router)
api_router.include_router(webhooks.router)
api_router.include_router(events.router)
api_router.include_router(orders.router)
api_router.include_router(tickets.router)
api_router.include_router(venues.router)

View file

View file

@ -0,0 +1,157 @@
from fastapi import APIRouter, Header, HTTPException, Query, Request, status
from app.core.deps import DbSession, EditorAdmin, SettingsDep
from app.schemas.admin import (
AdminLoginRequest,
AdminToken,
AdminUserPublic,
DashboardMetrics,
EventAdmin,
EventCreate,
EventStatsRow,
EventUpdate,
OrderAdmin,
PaymentAdmin,
TicketTypeAdmin,
TicketTypeCreate,
TicketTypeUpdate,
VenueAdmin,
VenueCreate,
VenueUpdate,
)
from app.schemas.tickets import TicketVerifyRequest, TicketVerifyResponse
from app.core.config import get_settings
from app.core.rate_limit import enforce_rate_limit
from app.core.security import create_admin_access_token, verify_password
from app.models.entities import AdminUser
from app.models.enums import OrderStatus
from app.services.admin.catalog import (
create_event,
create_ticket_type,
create_venue,
list_events_admin,
list_ticket_types,
list_venues_admin,
ticket_type_to_admin,
update_event,
update_ticket_type,
update_venue,
)
from app.services.admin.dashboard import get_dashboard_metrics, get_event_statistics
from app.services.admin.orders import get_order_admin, list_orders_admin, list_payments_admin
from app.services.tickets import assert_checkin_authorized, verify_ticket_at_gate
router = APIRouter(prefix="/admin", tags=["admin"])
@router.post("/auth/login", response_model=AdminToken)
def admin_login(body: AdminLoginRequest, request: Request, db: DbSession) -> AdminToken:
enforce_rate_limit(request, scope="admin_login", limit=20, window_seconds=60)
admin = db.query(AdminUser).filter(AdminUser.email == body.email.strip().lower()).first()
if not admin or not verify_password(body.password, admin.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = create_admin_access_token(admin.id, get_settings())
return AdminToken(access_token=token)
@router.get("/auth/me", response_model=AdminUserPublic)
def admin_me(admin: EditorAdmin) -> AdminUser:
return admin
@router.get("/dashboard/metrics", response_model=DashboardMetrics)
def dashboard_metrics(db: DbSession, _: EditorAdmin) -> DashboardMetrics:
return get_dashboard_metrics(db)
@router.get("/stats/events", response_model=list[EventStatsRow])
def event_statistics(db: DbSession, _: EditorAdmin) -> list[EventStatsRow]:
return get_event_statistics(db)
@router.get("/venues", response_model=list[VenueAdmin])
def venues_list(db: DbSession, _: EditorAdmin) -> list[VenueAdmin]:
return [VenueAdmin.model_validate(v) for v in list_venues_admin(db)]
@router.post("/venues", response_model=VenueAdmin, status_code=201)
def venues_create(body: VenueCreate, db: DbSession, _: EditorAdmin) -> VenueAdmin:
return VenueAdmin.model_validate(create_venue(db, body))
@router.patch("/venues/{venue_id}", response_model=VenueAdmin)
def venues_update(venue_id: int, body: VenueUpdate, db: DbSession, _: EditorAdmin) -> VenueAdmin:
return VenueAdmin.model_validate(update_venue(db, venue_id, body))
@router.get("/events", response_model=list[EventAdmin])
def events_list(db: DbSession, _: EditorAdmin) -> list[EventAdmin]:
return [EventAdmin.model_validate(e) for e in list_events_admin(db)]
@router.post("/events", response_model=EventAdmin, status_code=201)
def events_create(body: EventCreate, db: DbSession, _: EditorAdmin) -> EventAdmin:
return EventAdmin.model_validate(create_event(db, body))
@router.patch("/events/{event_id}", response_model=EventAdmin)
def events_update(event_id: int, body: EventUpdate, db: DbSession, _: EditorAdmin) -> EventAdmin:
return EventAdmin.model_validate(update_event(db, event_id, body))
@router.get("/ticket-types", response_model=list[TicketTypeAdmin])
def ticket_types_list(
db: DbSession,
_: EditorAdmin,
event_id: int | None = Query(default=None),
) -> list[TicketTypeAdmin]:
return [TicketTypeAdmin.model_validate(row) for row in list_ticket_types(db, event_id)]
@router.post("/ticket-types", response_model=TicketTypeAdmin, status_code=201)
def ticket_types_create(body: TicketTypeCreate, db: DbSession, _: EditorAdmin) -> TicketTypeAdmin:
tier = create_ticket_type(db, body)
return TicketTypeAdmin.model_validate(ticket_type_to_admin(tier))
@router.patch("/ticket-types/{tier_id}", response_model=TicketTypeAdmin)
def ticket_types_update(
tier_id: int,
body: TicketTypeUpdate,
db: DbSession,
_: EditorAdmin,
) -> TicketTypeAdmin:
tier = update_ticket_type(db, tier_id, body)
return TicketTypeAdmin.model_validate(ticket_type_to_admin(tier))
@router.get("/orders", response_model=list[OrderAdmin])
def orders_list(
db: DbSession,
_: EditorAdmin,
status: OrderStatus | None = Query(default=None),
) -> list[OrderAdmin]:
return [OrderAdmin.model_validate(o) for o in list_orders_admin(db, status)]
@router.get("/orders/{order_id}", response_model=OrderAdmin)
def orders_detail(order_id: int, db: DbSession, _: EditorAdmin) -> OrderAdmin:
return OrderAdmin.model_validate(get_order_admin(db, order_id))
@router.get("/payments", response_model=list[PaymentAdmin])
def payments_list(db: DbSession, _: EditorAdmin) -> list[PaymentAdmin]:
return [PaymentAdmin.model_validate(row) for row in list_payments_admin(db)]
@router.post("/tickets/verify", response_model=TicketVerifyResponse)
def admin_verify_ticket(
body: TicketVerifyRequest,
db: DbSession,
_: EditorAdmin,
settings: SettingsDep,
x_check_in_key: str | None = Header(default=None, alias="X-Check-In-Key"),
) -> TicketVerifyResponse:
assert_checkin_authorized(settings, x_check_in_key)
result = verify_ticket_at_gate(db, body.qr_token.strip())
return TicketVerifyResponse.model_validate(result)

View file

@ -0,0 +1,42 @@
from fastapi import APIRouter, HTTPException, Request, status
from app.core.deps import CurrentUser, DbSession, SettingsDep
from app.core.rate_limit import enforce_rate_limit
from app.core.security import create_user_access_token
from app.models.user import User
from app.schemas.auth import LoginRequest, RegisterRequest, Token, UserPublic
from app.services.users import EmailAlreadyRegisteredError, authenticate_user, register_user
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=Token, status_code=status.HTTP_201_CREATED)
def register(body: RegisterRequest, request: Request, db: DbSession, settings: SettingsDep) -> Token:
enforce_rate_limit(request, scope="auth_register", limit=10, window_seconds=60)
try:
user = register_user(db, body)
except EmailAlreadyRegisteredError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="An account with this email already exists",
)
token = create_user_access_token(user.id, settings)
return Token(access_token=token)
@router.post("/login", response_model=Token)
def login(body: LoginRequest, request: Request, db: DbSession, settings: SettingsDep) -> Token:
enforce_rate_limit(request, scope="auth_login", limit=20, window_seconds=60)
user = authenticate_user(db, body.email, body.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
)
token = create_user_access_token(user.id, settings)
return Token(access_token=token)
@router.get("/me", response_model=UserPublic)
def me(user: CurrentUser) -> User:
return user

View file

@ -0,0 +1,33 @@
from fastapi import APIRouter, Query
from app.core.deps import DbSession
from app.models.enums import EventStatus, EventType
from app.schemas.catalog import EventDetail, EventSummary
from app.schemas.orders import EventTicketTypePublic
from app.services.catalog import event_to_detail, event_to_summary, get_event_by_slug, list_events
from app.services.orders import list_event_ticket_types
router = APIRouter(prefix="/events", tags=["events"])
@router.get("", response_model=list[EventSummary])
def get_events(
db: DbSession,
category: EventType | None = Query(default=None),
status: EventStatus | None = Query(default=EventStatus.published),
upcoming: bool = Query(default=True),
) -> list[EventSummary]:
rows = list_events(db, category=category, status=status, upcoming=upcoming)
return [event_to_summary(row) for row in rows]
@router.get("/{event_id:int}/tickets", response_model=list[EventTicketTypePublic])
def get_event_tickets(event_id: int, db: DbSession) -> list[EventTicketTypePublic]:
rows = list_event_ticket_types(db, event_id)
return [EventTicketTypePublic.model_validate(row) for row in rows]
@router.get("/{slug}", response_model=EventDetail)
def get_event(slug: str, db: DbSession) -> EventDetail:
event = get_event_by_slug(db, slug=slug)
return event_to_detail(event)

View file

@ -0,0 +1,24 @@
from fastapi import APIRouter, status
from fastapi.responses import JSONResponse
from app.core.deps import DbSession
from app.schemas.health import HealthResponse
from app.services.health import check_database
router = APIRouter(tags=["health"])
@router.get("/health", response_model=HealthResponse)
def health_check(db: DbSession) -> HealthResponse | JSONResponse:
connected = check_database(db)
payload = HealthResponse(
status="ok" if connected else "degraded",
service="desi-clans-api",
database="connected" if connected else "unavailable",
)
if not connected:
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content=payload.model_dump(),
)
return payload

View file

@ -0,0 +1,72 @@
from fastapi import APIRouter, Header, HTTPException, Request
from app.core.deps import CurrentUser, DbSession
from app.core.rate_limit import enforce_rate_limit
from app.schemas.orders import OrderCheckoutResponse, OrderCreate, OrderPublic, PaymentVerifyRequest
from app.services.orders import cancel_pending_order, create_order, get_order_for_user
from app.core.config import get_settings
from app.services.payments.service import (
build_checkout_payload,
mock_complete_pending_order,
verify_checkout_and_confirm,
)
router = APIRouter(prefix="/orders", tags=["orders"])
@router.post("", response_model=OrderCheckoutResponse, status_code=201)
def post_order(
body: OrderCreate,
db: DbSession,
user: CurrentUser,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> OrderCheckoutResponse:
order = create_order(db, user, body, idempotency_key=idempotency_key)
payment = build_checkout_payload(order)
return OrderCheckoutResponse(
**OrderPublic.model_validate(order).model_dump(),
payment=payment,
)
@router.get("/{order_id}", response_model=OrderPublic)
def get_order(order_id: int, db: DbSession, user: CurrentUser) -> OrderPublic:
order = get_order_for_user(db, order_id, user.id)
return OrderPublic.model_validate(order)
@router.post("/{order_id}/payment/verify", response_model=OrderPublic)
def verify_order_payment(
order_id: int,
body: PaymentVerifyRequest,
request: Request,
db: DbSession,
user: CurrentUser,
) -> OrderPublic:
enforce_rate_limit(request, scope="payment_verify", limit=30, window_seconds=60)
order = get_order_for_user(db, order_id, user.id)
order = verify_checkout_and_confirm(
db,
order=order,
provider_order_id=body.provider_order_id,
provider_payment_id=body.provider_payment_id,
signature=body.signature,
)
return OrderPublic.model_validate(order)
@router.post("/{order_id}/cancel", response_model=OrderPublic)
def cancel_order(order_id: int, db: DbSession, user: CurrentUser) -> OrderPublic:
order = get_order_for_user(db, order_id, user.id)
order = cancel_pending_order(db, order)
return OrderPublic.model_validate(order)
@router.post("/{order_id}/payment/mock-complete", response_model=OrderPublic)
def dev_mock_complete_payment(order_id: int, db: DbSession, user: CurrentUser) -> OrderPublic:
settings = get_settings()
if settings.environment.lower() != "development" or settings.payment_provider.lower() != "mock":
raise HTTPException(status_code=404, detail="Not found")
order = get_order_for_user(db, order_id, user.id)
order = mock_complete_pending_order(db, order)
return OrderPublic.model_validate(order)

View file

@ -0,0 +1,37 @@
from fastapi import APIRouter, Header, Request
from app.core.config import get_settings
from app.core.deps import CurrentUser, DbSession
from app.core.rate_limit import enforce_rate_limit
from app.schemas.orders import TicketPublic
from app.schemas.tickets import TicketDetailPublic, TicketVerifyRequest, TicketVerifyResponse
from app.services.orders import get_ticket_for_user, list_user_tickets, ticket_to_public
from app.services.tickets import assert_checkin_authorized, get_ticket_detail_for_user, verify_ticket_at_gate
router = APIRouter(prefix="/tickets", tags=["tickets"])
@router.get("/my", response_model=list[TicketPublic])
def my_tickets(db: DbSession, user: CurrentUser) -> list[TicketPublic]:
rows = list_user_tickets(db, user.id)
return [TicketPublic.model_validate(ticket_to_public(row)) for row in rows]
@router.post("/verify", response_model=TicketVerifyResponse)
def verify_ticket(
body: TicketVerifyRequest,
request: Request,
db: DbSession,
x_check_in_key: str | None = Header(default=None, alias="X-Check-In-Key"),
) -> TicketVerifyResponse:
enforce_rate_limit(request, scope="ticket_verify", limit=60, window_seconds=60)
settings = get_settings()
assert_checkin_authorized(settings, x_check_in_key)
result = verify_ticket_at_gate(db, body.qr_token.strip())
return TicketVerifyResponse.model_validate(result)
@router.get("/{ticket_id}", response_model=TicketDetailPublic)
def get_ticket(ticket_id: int, db: DbSession, user: CurrentUser) -> TicketDetailPublic:
detail = get_ticket_detail_for_user(db, ticket_id, user.id)
return TicketDetailPublic.model_validate(detail)

View file

@ -0,0 +1,18 @@
from fastapi import APIRouter
from app.core.deps import DbSession
from app.schemas.catalog import VenuePublic
from app.services.catalog import get_venue_by_id, list_venues
router = APIRouter(prefix="/venues", tags=["venues"])
@router.get("", response_model=list[VenuePublic])
def get_venues(db: DbSession) -> list[VenuePublic]:
return [VenuePublic.model_validate(venue) for venue in list_venues(db)]
@router.get("/{venue_id}", response_model=VenuePublic)
def get_venue(venue_id: int, db: DbSession) -> VenuePublic:
venue = get_venue_by_id(db, venue_id=venue_id)
return VenuePublic.model_validate(venue)

View file

@ -0,0 +1,31 @@
from fastapi import APIRouter, Header, HTTPException, Request
from app.core.config import get_settings
from app.core.deps import DbSession
from app.services.payments.service import process_webhook
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
@router.post("/razorpay")
async def razorpay_webhook(
request: Request,
db: DbSession,
x_razorpay_signature: str | None = Header(default=None, alias="X-Razorpay-Signature"),
) -> dict:
body = await request.body()
return process_webhook(db, body=body, signature=x_razorpay_signature)
@router.post("/mock")
async def mock_payment_webhook(
request: Request,
db: DbSession,
x_mock_signature: str | None = Header(default=None, alias="X-Mock-Signature"),
) -> dict:
"""Mock provider webhook (development / tests only)."""
settings = get_settings()
if settings.environment.lower() != "development":
raise HTTPException(status_code=404, detail="Not found")
body = await request.body()
return process_webhook(db, body=body, signature=x_mock_signature)

56
backend/app/auth.py Normal file
View file

@ -0,0 +1,56 @@
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models import AdminRole, AdminUser
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer(auto_error=False)
ALGORITHM = "HS256"
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
return jwt.encode({"sub": subject, "exp": expire}, settings.secret_key, algorithm=ALGORITHM)
def get_current_admin(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: Session = Depends(get_db),
) -> AdminUser:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
try:
payload = jwt.decode(credentials.credentials, settings.secret_key, algorithms=[ALGORITHM])
email: str | None = payload.get("sub")
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
if not email:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = db.query(AdminUser).filter(AdminUser.email == email).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
def require_super_admin(
admin: AdminUser = Depends(get_current_admin),
) -> AdminUser:
if admin.role != AdminRole.super_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Super admin required")
return admin

5
backend/app/config.py Normal file
View file

@ -0,0 +1,5 @@
"""Backward-compatible settings import. Prefer `app.core.config`."""
from app.core.config import Settings, get_settings, settings
__all__ = ["Settings", "get_settings", "settings"]

View file

@ -0,0 +1,5 @@
from app.core.config import Settings, get_settings
settings = get_settings()
__all__ = ["Settings", "get_settings", "settings"]

105
backend/app/core/config.py Normal file
View file

@ -0,0 +1,105 @@
from functools import lru_cache
from pydantic import field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
_INSECURE_SECRET_KEYS = frozenset(
{
"change-me",
"change-me-to-a-long-random-string-in-production",
"changeme",
"secret",
}
)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
environment: str = "development"
database_url: str
secret_key: str
access_token_expire_minutes: int = 60 * 24
payment_provider: str = "mock"
payment_mock_secret: str = "dev-mock-payment-secret-change-me"
razorpay_key_id: str | None = None
razorpay_key_secret: str | None = None
razorpay_webhook_secret: str | None = None
order_payment_timeout_minutes: int = 15
ticket_verify_api_key: str | None = None
@field_validator("secret_key")
@classmethod
def secret_key_must_be_strong_in_production(cls, value: str, info) -> str:
environment = (info.data.get("environment") or "development").lower()
if environment == "production" and value.strip().lower() in _INSECURE_SECRET_KEYS:
raise ValueError("SECRET_KEY must be set to a long random value in production")
if environment == "production" and len(value) < 32:
raise ValueError("SECRET_KEY must be at least 32 characters in production")
return value
@field_validator("access_token_expire_minutes")
@classmethod
def token_ttl_reasonable(cls, value: int) -> int:
if value < 5 or value > 60 * 24 * 14:
raise ValueError("ACCESS_TOKEN_EXPIRE_MINUTES must be between 5 and 20160")
return value
cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173"
upload_dir: str = "uploads"
storage_backend: str = "local"
public_site_url: str = "http://localhost:5173"
public_api_url: str = "http://localhost:8000"
admin_email: str = "admin@desiclans.in"
admin_password: str = "changeme"
smtp_host: str | None = None
smtp_port: int = 587
smtp_user: str | None = None
smtp_password: str | None = None
smtp_from: str | None = None
smtp_use_tls: bool = True
alert_notify_email: str | None = None
alert_click_threshold: int = 50
aws_access_key_id: str | None = None
aws_secret_access_key: str | None = None
aws_region: str = "ap-south-1"
s3_bucket: str | None = None
s3_public_base_url: str | None = None
@property
def cors_origin_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@property
def smtp_configured(self) -> bool:
return bool(self.smtp_host and self.alert_notify_email)
@model_validator(mode="after")
def production_hardening(self) -> "Settings":
if self.environment.lower() != "production":
return self
if self.payment_provider.lower() == "mock":
raise ValueError("PAYMENT_PROVIDER must be razorpay in production")
if not self.ticket_verify_api_key or len(self.ticket_verify_api_key) < 16:
raise ValueError("TICKET_VERIFY_API_KEY is required in production (min 16 characters)")
if self.admin_password.strip().lower() in {"changeme", "change-me", "password"}:
raise ValueError("ADMIN_PASSWORD must be changed from the default in production")
if not self.cors_origin_list:
raise ValueError("CORS_ORIGINS must list at least one origin in production")
if self.payment_provider.lower() == "razorpay":
if not self.razorpay_key_id or not self.razorpay_key_secret or not self.razorpay_webhook_secret:
raise ValueError(
"RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET, and RAZORPAY_WEBHOOK_SECRET are required in production"
)
return self
@lru_cache
def get_settings() -> Settings:
return Settings()

76
backend/app/core/deps.py Normal file
View file

@ -0,0 +1,76 @@
from collections.abc import Callable, Generator
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.core.config import Settings, get_settings
from app.core.security import decode_admin_access_token, decode_user_access_token
from app.db.session import get_db as _get_db
from app.models.entities import AdminUser
from app.models.enums import AdminRole
from app.models.user import User
_bearer = HTTPBearer(auto_error=False)
def get_db() -> Generator[Session, None, None]:
yield from _get_db()
SettingsDep = Annotated[Settings, Depends(get_settings)]
DbSession = Annotated[Session, Depends(get_db)]
def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
db: DbSession,
settings: SettingsDep,
) -> User:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
user_id = decode_user_access_token(credentials.credentials, settings)
if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
def get_current_admin(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
db: DbSession,
settings: SettingsDep,
) -> AdminUser:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
admin_id = decode_admin_access_token(credentials.credentials, settings)
if admin_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
admin = db.query(AdminUser).filter(AdminUser.id == admin_id).first()
if not admin:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin not found")
return admin
CurrentAdmin = Annotated[AdminUser, Depends(get_current_admin)]
def require_admin_roles(*roles: AdminRole) -> Callable:
allowed = set(roles)
def _checker(admin: CurrentAdmin) -> AdminUser:
if admin.role not in allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
return admin
return _checker
EditorAdmin = Annotated[AdminUser, Depends(require_admin_roles(AdminRole.editor, AdminRole.super_admin))]
SuperAdmin = Annotated[AdminUser, Depends(require_admin_roles(AdminRole.super_admin))]

View file

@ -0,0 +1,23 @@
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
class AppError(Exception):
"""Application-level error mapped to an HTTP response."""
def __init__(self, message: str, status_code: int = 400) -> None:
self.message = message
self.status_code = status_code
super().__init__(message)
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def handle_app_error(_request: Request, exc: AppError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.message})
@app.exception_handler(Exception)
async def handle_unexpected_error(_request: Request, exc: Exception) -> JSONResponse:
if app.debug:
return JSONResponse(status_code=500, content={"detail": str(exc)})
return JSONResponse(status_code=500, content={"detail": "Internal server error"})

View file

@ -0,0 +1,37 @@
import threading
import time
from collections import defaultdict
from fastapi import Request
from app.core.errors import AppError
_buckets: dict[str, list[float]] = defaultdict(list)
_lock = threading.Lock()
def _client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client and request.client.host:
return request.client.host
return "unknown"
def enforce_rate_limit(request: Request, *, scope: str, limit: int, window_seconds: int) -> None:
"""In-memory sliding window limiter (per process). Use edge/WAF limits in production at scale."""
import os
if os.environ.get("DESI_CLANS_DISABLE_RATE_LIMIT", "").lower() in {"1", "true", "yes"}:
return
if limit <= 0:
return
key = f"{scope}:{_client_ip(request)}"
now = time.time()
with _lock:
window = _buckets[key]
window[:] = [stamp for stamp in window if now - stamp < window_seconds]
if len(window) >= limit:
raise AppError("Too many requests. Try again shortly.", status_code=429)
window.append(now)

View file

@ -0,0 +1,72 @@
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import Settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
TOKEN_TYPE_USER = "user"
TOKEN_TYPE_ADMIN = "admin"
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_user_access_token(user_id: int, settings: Settings) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {
"sub": str(user_id),
"exp": expire,
"type": TOKEN_TYPE_USER,
}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def decode_user_access_token(token: str, settings: Settings) -> int | None:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
except JWTError:
return None
if payload.get("type") != TOKEN_TYPE_USER:
return None
subject = payload.get("sub")
if not subject:
return None
try:
return int(subject)
except (TypeError, ValueError):
return None
def create_admin_access_token(admin_id: int, settings: Settings) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {
"sub": str(admin_id),
"exp": expire,
"type": TOKEN_TYPE_ADMIN,
}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def decode_admin_access_token(token: str, settings: Settings) -> int | None:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
except JWTError:
return None
if payload.get("type") != TOKEN_TYPE_ADMIN:
return None
subject = payload.get("sub")
if not subject:
return None
try:
return int(subject)
except (TypeError, ValueError):
return None

6
backend/app/database.py Normal file
View file

@ -0,0 +1,6 @@
"""Backward-compatible database import. Prefer `app.db`."""
from app.db.base import Base
from app.db.session import SessionLocal, engine, get_db
__all__ = ["Base", "SessionLocal", "engine", "get_db"]

View file

@ -0,0 +1,4 @@
from app.db.base import Base
from app.db.session import SessionLocal, engine, get_db
__all__ = ["Base", "SessionLocal", "engine", "get_db"]

5
backend/app/db/base.py Normal file
View file

@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

23
backend/app/db/session.py Normal file
View file

@ -0,0 +1,23 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
settings = get_settings()
engine = create_engine(
settings.database_url,
pool_pre_ping=True,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

43
backend/app/main.py Normal file
View file

@ -0,0 +1,43 @@
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api.router import api_router
from app.core.config import get_settings
from app.core.errors import register_exception_handlers
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(
title="Desi Clans API",
version="0.2.0",
debug=settings.environment.lower() == "development",
)
register_exception_handlers(app)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
if settings.storage_backend.lower() == "local":
upload_path = Path(settings.upload_dir)
upload_path.mkdir(parents=True, exist_ok=True)
app.mount("/media", StaticFiles(directory=str(upload_path)), name="media")
if settings.environment.lower() == "production":
app.openapi_url = None
app.include_router(api_router, prefix="/api")
return app
app = create_app()

View file

@ -0,0 +1,50 @@
from app.db.base import Base
from app.models.catalog import Event, EventMedia, TicketType, Venue
from app.models.user import User
from app.models.entities import (
AdminUser,
AlertDispatch,
EventEdition,
MediaAsset,
NewsletterSubscriber,
OutboundClick,
PlatformLink,
)
from app.models.enums import (
AdminRole,
EventStatus,
EventType,
OrderStatus,
PlatformKind,
TicketStatus,
TicketTypeStatus,
)
from app.models.orders import Order, OrderItem, Ticket
from app.models.payments import PaymentWebhookEvent
__all__ = [
"Base",
"AdminRole",
"AdminUser",
"AlertDispatch",
"Event",
"EventEdition",
"EventMedia",
"EventStatus",
"EventType",
"MediaAsset",
"NewsletterSubscriber",
"Order",
"OrderItem",
"OrderStatus",
"OutboundClick",
"PaymentWebhookEvent",
"PlatformKind",
"PlatformLink",
"Ticket",
"TicketStatus",
"TicketType",
"TicketTypeStatus",
"User",
"Venue",
]

View file

@ -0,0 +1,115 @@
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.enums import EventStatus, EventType, TicketTypeStatus
class Venue(Base):
__tablename__ = "venues"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
slug: Mapped[str] = mapped_column(String(120), unique=True, index=True)
name: Mapped[str] = mapped_column(String(200))
area: Mapped[str] = mapped_column(String(120))
city: Mapped[str] = mapped_column(String(80), default="Bengaluru")
address: Mapped[str] = mapped_column(String(512))
map_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)
events: Mapped[list["Event"]] = relationship("Event", back_populates="venue")
class Event(Base):
__tablename__ = "events"
__table_args__ = (
Index("ix_events_status_starts_at", "status", "starts_at"),
Index("ix_events_category_status", "category", "status"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
slug: Mapped[str] = mapped_column(String(200), unique=True, index=True)
title: Mapped[str] = mapped_column(String(240))
category: Mapped[EventType] = mapped_column(Enum(EventType), index=True)
status: Mapped[EventStatus] = mapped_column(Enum(EventStatus), default=EventStatus.draft, index=True)
venue_id: Mapped[int] = mapped_column(ForeignKey("venues.id", ondelete="RESTRICT"), index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
is_featured: Mapped[bool] = mapped_column(Boolean, default=False)
cover_image_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
cover_image_alt: Mapped[str | None] = mapped_column(String(300), nullable=True)
meta_description: Mapped[str | None] = mapped_column(String(320), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)
venue: Mapped["Venue"] = relationship("Venue", back_populates="events")
media: Mapped[list["EventMedia"]] = relationship(
"EventMedia",
back_populates="event",
cascade="all, delete-orphan",
order_by="EventMedia.sort_order",
)
ticket_types: Mapped[list["TicketType"]] = relationship(
"TicketType",
back_populates="event",
cascade="all, delete-orphan",
order_by="TicketType.sort_order",
)
class EventMedia(Base):
__tablename__ = "event_media"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
event_id: Mapped[int] = mapped_column(ForeignKey("events.id", ondelete="CASCADE"), index=True)
image_url: Mapped[str] = mapped_column(String(1024))
alt_text: Mapped[str | None] = mapped_column(String(300), nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
event: Mapped["Event"] = relationship("Event", back_populates="media")
class TicketType(Base):
__tablename__ = "ticket_types"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
event_id: Mapped[int] = mapped_column(ForeignKey("events.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(120))
description: Mapped[str | None] = mapped_column(String(512), nullable=True)
price_paise: Mapped[int] = mapped_column(Integer)
quantity_total: Mapped[int] = mapped_column(Integer)
quantity_sold: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[TicketTypeStatus] = mapped_column(Enum(TicketTypeStatus), default=TicketTypeStatus.active)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)
event: Mapped["Event"] = relationship("Event", back_populates="ticket_types")

View file

@ -0,0 +1,105 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.enums import AdminRole, EventType, PlatformKind
class AdminUser(Base):
__tablename__ = "admin_users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
role: Mapped[AdminRole] = mapped_column(Enum(AdminRole), default=AdminRole.super_admin)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class NewsletterSubscriber(Base):
__tablename__ = "newsletter_subscribers"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
neighborhood: Mapped[str | None] = mapped_column(String(120), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AlertDispatch(Base):
__tablename__ = "alert_dispatches"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
alert_key: Mapped[str] = mapped_column(String(120), unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class PlatformLink(Base):
__tablename__ = "platform_links"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[PlatformKind] = mapped_column(Enum(PlatformKind), index=True)
label: Mapped[str] = mapped_column(String(120))
url: Mapped[str] = mapped_column(String(512))
sort_order: Mapped[int] = mapped_column(Integer, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class EventEdition(Base):
__tablename__ = "event_editions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(200))
slug: Mapped[str] = mapped_column(String(200), unique=True, index=True)
event_type: Mapped[EventType] = mapped_column(Enum(EventType), index=True)
city: Mapped[str] = mapped_column(String(80), default="Bengaluru")
venue_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
event_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
is_featured: Mapped[bool] = mapped_column(Boolean, default=False)
cover_image_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
bookmyshow_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
district_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
instagram_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
meta_description: Mapped[str | None] = mapped_column(String(320), nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
media: Mapped[list["MediaAsset"]] = relationship(
"MediaAsset",
back_populates="edition",
cascade="all, delete-orphan",
order_by="MediaAsset.sort_order",
)
class OutboundClick(Base):
__tablename__ = "outbound_clicks"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
link_kind: Mapped[str] = mapped_column(String(64), index=True)
destination_url: Mapped[str] = mapped_column(String(512))
edition_id: Mapped[int | None] = mapped_column(
ForeignKey("event_editions.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
referrer: Mapped[str | None] = mapped_column(String(512), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
class MediaAsset(Base):
__tablename__ = "media_assets"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
edition_id: Mapped[int] = mapped_column(ForeignKey("event_editions.id", ondelete="CASCADE"), index=True)
file_path: Mapped[str] = mapped_column(String(512))
caption: Mapped[str | None] = mapped_column(String(300), nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
is_published: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
edition: Mapped["EventEdition"] = relationship("EventEdition", back_populates="media")

101
backend/app/models/enums.py Normal file
View file

@ -0,0 +1,101 @@
import enum
class EventType(str, enum.Enum):
holi = "holi"
garba = "garba"
dandiya = "dandiya"
bhajan_clubbing = "bhajan_clubbing"
special = "special"
class PlatformKind(str, enum.Enum):
bookmyshow = "bookmyshow"
district = "district"
instagram = "instagram"
highape = "highape"
youtube = "youtube"
whatsapp = "whatsapp"
website = "website"
other = "other"
class AdminRole(str, enum.Enum):
super_admin = "super_admin"
editor = "editor"
class EventStatus(str, enum.Enum):
draft = "draft"
published = "published"
cancelled = "cancelled"
class TicketTypeStatus(str, enum.Enum):
active = "active"
sold_out = "sold_out"
inactive = "inactive"
class OrderStatus(str, enum.Enum):
pending = "pending"
paid = "paid"
cancelled = "cancelled"
failed = "failed"
class TicketStatus(str, enum.Enum):
valid = "valid"
used = "used"
cancelled = "cancelled"

View file

@ -0,0 +1,89 @@
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import (
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.enums import OrderStatus, TicketStatus
if TYPE_CHECKING:
from app.models.catalog import Event, TicketType
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT"), index=True)
event_id: Mapped[int] = mapped_column(ForeignKey("events.id", ondelete="RESTRICT"), index=True)
status: Mapped[OrderStatus] = mapped_column(Enum(OrderStatus), default=OrderStatus.pending, index=True)
total_paise: Mapped[int] = mapped_column(Integer)
currency: Mapped[str] = mapped_column(String(3), default="INR")
payment_reference: Mapped[str | None] = mapped_column(String(128), nullable=True)
provider_order_id: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True, index=True)
provider_payment_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
idempotency_key: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True, index=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)
items: Mapped[list["OrderItem"]] = relationship(
"OrderItem",
back_populates="order",
cascade="all, delete-orphan",
)
tickets: Mapped[list["Ticket"]] = relationship(
"Ticket",
back_populates="order",
cascade="all, delete-orphan",
)
event: Mapped["Event"] = relationship("Event")
class OrderItem(Base):
__tablename__ = "order_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id", ondelete="CASCADE"), index=True)
ticket_type_id: Mapped[int] = mapped_column(ForeignKey("ticket_types.id", ondelete="RESTRICT"), index=True)
quantity: Mapped[int] = mapped_column(Integer)
unit_price_paise: Mapped[int] = mapped_column(Integer)
line_total_paise: Mapped[int] = mapped_column(Integer)
order: Mapped["Order"] = relationship("Order", back_populates="items")
ticket_type: Mapped["TicketType"] = relationship("TicketType")
tickets: Mapped[list["Ticket"]] = relationship("Ticket", back_populates="order_item")
class Ticket(Base):
__tablename__ = "tickets"
__table_args__ = (Index("ix_tickets_user_status", "user_id", "status"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
code: Mapped[str] = mapped_column(String(32), unique=True, index=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id", ondelete="CASCADE"), index=True)
order_item_id: Mapped[int] = mapped_column(ForeignKey("order_items.id", ondelete="CASCADE"), index=True)
ticket_type_id: Mapped[int] = mapped_column(ForeignKey("ticket_types.id", ondelete="RESTRICT"), index=True)
event_id: Mapped[int] = mapped_column(ForeignKey("events.id", ondelete="RESTRICT"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT"), index=True)
status: Mapped[TicketStatus] = mapped_column(Enum(TicketStatus), default=TicketStatus.valid, index=True)
qr_token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
order: Mapped["Order"] = relationship("Order", back_populates="tickets")
order_item: Mapped["OrderItem"] = relationship("OrderItem", back_populates="tickets")

View file

@ -0,0 +1,19 @@
from datetime import datetime
from sqlalchemy import DateTime, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class PaymentWebhookEvent(Base):
"""Idempotent record of processed payment provider webhook events."""
__tablename__ = "payment_webhook_events"
id: Mapped[int] = mapped_column(primary_key=True)
provider: Mapped[str] = mapped_column(String(32), index=True)
provider_event_id: Mapped[str] = mapped_column(String(128), unique=True, index=True)
event_type: Mapped[str] = mapped_column(String(64))
payload: Mapped[str] = mapped_column(Text)
processed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View file

@ -0,0 +1,19 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class User(Base):
"""Attendee account (not admin)."""
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
full_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,238 @@
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlalchemy.orm import Session, joinedload
from app.auth import get_current_admin
from app.database import get_db
from app.models import AdminUser, EventEdition, MediaAsset, PlatformLink
from app.schemas import (
EventEditionCreate,
EventEditionAdmin,
EventEditionUpdate,
MediaReorderItem,
PlatformLinkCreate,
PlatformLinkPublic,
PlatformLinkUpdate,
)
from app.services.editions import edition_to_admin, edition_to_public
from app.services.media import public_url_for_path, save_image
router = APIRouter(prefix="/api/admin", tags=["admin"])
@router.get("/me")
def me(admin: AdminUser = Depends(get_current_admin)):
return {"email": admin.email, "role": admin.role.value}
# --- Platform links ---
@router.get("/links", response_model=list[PlatformLinkPublic])
def list_links(db: Session = Depends(get_db), _: AdminUser = Depends(get_current_admin)):
rows = db.query(PlatformLink).order_by(PlatformLink.sort_order, PlatformLink.id).all()
return [PlatformLinkPublic.model_validate(r) for r in rows]
@router.post("/links", response_model=PlatformLinkPublic)
def create_link(
body: PlatformLinkCreate,
db: Session = Depends(get_db),
_: AdminUser = Depends(get_current_admin),
):
row = PlatformLink(**body.model_dump())
db.add(row)
db.commit()
db.refresh(row)
return PlatformLinkPublic.model_validate(row)
@router.patch("/links/{link_id}", response_model=PlatformLinkPublic)
def update_link(
link_id: int,
body: PlatformLinkUpdate,
db: Session = Depends(get_db),
_: AdminUser = Depends(get_current_admin),
):
row = db.query(PlatformLink).filter(PlatformLink.id == link_id).first()
if not row:
raise HTTPException(status_code=404, detail="Link not found")
for k, v in body.model_dump(exclude_unset=True).items():
setattr(row, k, v)
db.commit()
db.refresh(row)
return PlatformLinkPublic.model_validate(row)
@router.delete("/links/{link_id}")
def delete_link(link_id: int, db: Session = Depends(get_db), _: AdminUser = Depends(get_current_admin)):
row = db.query(PlatformLink).filter(PlatformLink.id == link_id).first()
if not row:
raise HTTPException(status_code=404, detail="Link not found")
db.delete(row)
db.commit()
return {"ok": True}
# --- Editions ---
@router.get("/editions", response_model=list[EventEditionAdmin])
def admin_list_editions(db: Session = Depends(get_db), _: AdminUser = Depends(get_current_admin)):
editions = (
db.query(EventEdition)
.options(joinedload(EventEdition.media))
.order_by(EventEdition.id.desc())
.all()
)
return [edition_to_admin(e) for e in editions]
@router.post("/editions", response_model=EventEditionAdmin)
def create_edition(
body: EventEditionCreate,
db: Session = Depends(get_db),
_: AdminUser = Depends(get_current_admin),
):
if db.query(EventEdition).filter(EventEdition.slug == body.slug).first():
raise HTTPException(status_code=400, detail="Slug already exists")
edition = EventEdition(city="Bengaluru", **body.model_dump())
db.add(edition)
db.commit()
db.refresh(edition)
edition = (
db.query(EventEdition)
.options(joinedload(EventEdition.media))
.filter(EventEdition.id == edition.id)
.one()
)
return edition_to_admin(edition)
@router.patch("/editions/{edition_id}", response_model=EventEditionAdmin)
def update_edition(
edition_id: int,
body: EventEditionUpdate,
db: Session = Depends(get_db),
_: AdminUser = Depends(get_current_admin),
):
edition = db.query(EventEdition).filter(EventEdition.id == edition_id).first()
if not edition:
raise HTTPException(status_code=404, detail="Edition not found")
data = body.model_dump(exclude_unset=True)
if "slug" in data and data["slug"] != edition.slug:
if db.query(EventEdition).filter(EventEdition.slug == data["slug"]).first():
raise HTTPException(status_code=400, detail="Slug already exists")
for k, v in data.items():
setattr(edition, k, v)
db.commit()
edition = (
db.query(EventEdition)
.options(joinedload(EventEdition.media))
.filter(EventEdition.id == edition_id)
.one()
)
return edition_to_admin(edition)
@router.post("/editions/{edition_id}/cover")
async def upload_cover(
edition_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: AdminUser = Depends(get_current_admin),
):
edition = db.query(EventEdition).filter(EventEdition.id == edition_id).first()
if not edition:
raise HTTPException(status_code=404, detail="Edition not found")
try:
path = save_image(file)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
edition.cover_image_path = path
db.commit()
return {"cover_image_url": public_url_for_path(path)}
@router.post("/editions/{edition_id}/media")
async def upload_media(
edition_id: int,
file: UploadFile = File(...),
caption: str | None = Form(default=None),
sort_order: int = Form(default=0),
db: Session = Depends(get_db),
_: AdminUser = Depends(get_current_admin),
):
edition = db.query(EventEdition).filter(EventEdition.id == edition_id).first()
if not edition:
raise HTTPException(status_code=404, detail="Edition not found")
try:
path = save_image(file)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
asset = MediaAsset(
edition_id=edition_id,
file_path=path,
caption=caption,
sort_order=sort_order,
is_published=True,
)
db.add(asset)
db.commit()
db.refresh(asset)
return {
"id": asset.id,
"url": public_url_for_path(path),
"caption": asset.caption,
"sort_order": asset.sort_order,
}
@router.patch("/media/{media_id}")
def update_media(
media_id: int,
caption: str | None = Form(default=None),
sort_order: int | None = Form(default=None),
is_published: bool | None = Form(default=None),
db: Session = Depends(get_db),
_: AdminUser = Depends(get_current_admin),
):
asset = db.query(MediaAsset).filter(MediaAsset.id == media_id).first()
if not asset:
raise HTTPException(status_code=404, detail="Media not found")
if caption is not None:
asset.caption = caption
if sort_order is not None:
asset.sort_order = sort_order
if is_published is not None:
asset.is_published = is_published
db.commit()
return {"ok": True}
@router.post("/editions/{edition_id}/media/reorder")
def reorder_media(
edition_id: int,
items: list[MediaReorderItem],
db: Session = Depends(get_db),
_: AdminUser = Depends(get_current_admin),
):
for item in items:
asset = (
db.query(MediaAsset)
.filter(MediaAsset.id == item.id, MediaAsset.edition_id == edition_id)
.first()
)
if asset:
asset.sort_order = item.sort_order
db.commit()
return {"ok": True}
@router.delete("/media/{media_id}")
def delete_media(media_id: int, db: Session = Depends(get_db), _: AdminUser = Depends(get_current_admin)):
asset = db.query(MediaAsset).filter(MediaAsset.id == media_id).first()
if not asset:
raise HTTPException(status_code=404, detail="Media not found")
db.delete(asset)
db.commit()
return {"ok": True}

View file

@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.auth import hash_password, require_super_admin
from app.database import get_db
from app.models import AdminUser
from app.schemas import AdminUserCreate, AdminUserPublic, SubscriberPublic
from app.models import NewsletterSubscriber
router = APIRouter(prefix="/api/admin", tags=["admin-users"])
@router.get("/users", response_model=list[AdminUserPublic])
def list_users(db: Session = Depends(get_db), _: AdminUser = Depends(require_super_admin)):
return db.query(AdminUser).order_by(AdminUser.id).all()
@router.post("/users", response_model=AdminUserPublic)
def create_user(
body: AdminUserCreate,
db: Session = Depends(get_db),
_: AdminUser = Depends(require_super_admin),
):
if db.query(AdminUser).filter(AdminUser.email == body.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
user = AdminUser(
email=body.email,
hashed_password=hash_password(body.password),
role=body.role,
)
db.add(user)
db.commit()
db.refresh(user)
return AdminUserPublic.model_validate(user)
@router.get("/subscribers", response_model=list[SubscriberPublic])
def list_subscribers(db: Session = Depends(get_db), _: AdminUser = Depends(require_super_admin)):
return (
db.query(NewsletterSubscriber)
.order_by(NewsletterSubscriber.id.desc())
.limit(500)
.all()
)

View file

@ -0,0 +1,40 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.auth import get_current_admin
from app.database import get_db
from app.models import AdminUser, OutboundClick
from app.schemas import ClickSummaryItem, OutboundClickPublic
router = APIRouter(prefix="/api/admin/analytics", tags=["analytics"])
@router.get("/clicks/summary", response_model=list[ClickSummaryItem])
def click_summary(
days: int = 30,
db: Session = Depends(get_db),
_: AdminUser = Depends(get_current_admin),
):
since = datetime.now(timezone.utc) - timedelta(days=max(1, min(days, 365)))
rows = (
db.query(OutboundClick.link_kind, func.count(OutboundClick.id))
.filter(OutboundClick.created_at >= since)
.group_by(OutboundClick.link_kind)
.order_by(func.count(OutboundClick.id).desc())
.all()
)
return [ClickSummaryItem(link_kind=kind, count=count) for kind, count in rows]
@router.get("/clicks/recent", response_model=list[OutboundClickPublic])
def recent_clicks(
limit: int = 50,
db: Session = Depends(get_db),
_: AdminUser = Depends(get_current_admin),
):
limit = max(1, min(limit, 200))
rows = db.query(OutboundClick).order_by(OutboundClick.id.desc()).limit(limit).all()
return [OutboundClickPublic.model_validate(r) for r in rows]

View file

@ -0,0 +1,17 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.auth import create_access_token, verify_password
from app.database import get_db
from app.models import AdminUser
from app.schemas import LoginRequest, Token
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login", response_model=Token)
def login(body: LoginRequest, db: Session = Depends(get_db)):
user = db.query(AdminUser).filter(AdminUser.email == body.email).first()
if not user or not verify_password(body.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
return Token(access_token=create_access_token(user.email))

View file

@ -0,0 +1,151 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from sqlalchemy.orm import Session, joinedload
from app.config import settings
from app.database import get_db
from app.models import EventEdition, EventType, NewsletterSubscriber, OutboundClick, PlatformLink
from app.schemas import EventEditionPublic, PlatformLinkPublic, SiteConfigPublic, SubscribeRequest
from app.services.alerts import maybe_notify_click_spike
from app.services.calendar import build_ics
from app.services.editions import edition_to_public
from app.services.outbound import validate_outbound_url
from app.services.share_preview import render_share_html
router = APIRouter(prefix="/api/public", tags=["public"])
TAGLINE = "Bengaluru's home for Holi, Garba, Dandiya & bhajan nights — across the city's best venues."
@router.get("/health")
def health():
return {"status": "ok", "city": "Bengaluru"}
@router.get("/config", response_model=SiteConfigPublic)
def site_config(db: Session = Depends(get_db)):
links = (
db.query(PlatformLink)
.filter(PlatformLink.is_active.is_(True))
.order_by(PlatformLink.sort_order, PlatformLink.id)
.all()
)
editions = (
db.query(EventEdition)
.options(joinedload(EventEdition.media))
.filter(EventEdition.is_published.is_(True))
.order_by(EventEdition.is_featured.desc(), EventEdition.sort_order, EventEdition.event_date.desc().nullslast())
.all()
)
featured = next((e for e in editions if e.is_featured), editions[0] if editions else None)
recent = [edition_to_public(e) for e in editions[:6]]
return SiteConfigPublic(
city="Bengaluru",
tagline=TAGLINE,
platform_links=[PlatformLinkPublic.model_validate(l) for l in links],
featured_edition=edition_to_public(featured) if featured else None,
recent_editions=recent,
)
@router.get("/editions", response_model=list[EventEditionPublic])
def list_editions(
db: Session = Depends(get_db),
event_type: EventType | None = Query(default=None),
):
q = (
db.query(EventEdition)
.options(joinedload(EventEdition.media))
.filter(EventEdition.is_published.is_(True))
)
if event_type is not None:
q = q.filter(EventEdition.event_type == event_type)
editions = q.order_by(EventEdition.event_date.desc().nullslast(), EventEdition.id.desc()).all()
return [edition_to_public(e) for e in editions]
@router.get("/editions/{slug}", response_model=EventEditionPublic)
def get_edition(slug: str, db: Session = Depends(get_db)):
edition = (
db.query(EventEdition)
.options(joinedload(EventEdition.media))
.filter(EventEdition.slug == slug, EventEdition.is_published.is_(True))
.first()
)
if not edition:
raise HTTPException(status_code=404, detail="Edition not found")
return edition_to_public(edition)
@router.get("/go")
def outbound_redirect(
request: Request,
url: str = Query(..., min_length=8, max_length=512),
kind: str = Query(default="other", max_length=64),
edition_id: int | None = Query(default=None),
db: Session = Depends(get_db),
):
try:
destination = validate_outbound_url(url)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid destination URL")
db.add(
OutboundClick(
link_kind=kind[:64],
destination_url=destination,
edition_id=edition_id,
referrer=request.headers.get("referer"),
user_agent=(request.headers.get("user-agent") or "")[:512],
)
)
db.commit()
maybe_notify_click_spike(db)
return RedirectResponse(destination, status_code=302)
@router.post("/subscribe")
def subscribe(body: SubscribeRequest, db: Session = Depends(get_db)):
existing = db.query(NewsletterSubscriber).filter(NewsletterSubscriber.email == body.email).first()
if existing:
return {"ok": True, "message": "Already on the list — we'll see you in Bengaluru."}
db.add(NewsletterSubscriber(email=body.email, neighborhood=body.neighborhood))
db.commit()
return {"ok": True, "message": "You're in! We'll email you when the next edition drops."}
@router.get("/editions/{slug}/calendar.ics", response_class=PlainTextResponse)
def edition_calendar(slug: str, db: Session = Depends(get_db)):
edition = (
db.query(EventEdition)
.filter(EventEdition.slug == slug, EventEdition.is_published.is_(True))
.first()
)
if not edition:
raise HTTPException(status_code=404, detail="Edition not found")
page_url = f"{settings.public_site_url.rstrip('/')}/events/{slug}"
return PlainTextResponse(
build_ics(edition, page_url),
media_type="text/calendar",
headers={"Content-Disposition": f'attachment; filename="{slug}.ics"'},
)
def _share_html_for_slug(slug: str, db: Session) -> str:
edition = (
db.query(EventEdition)
.options(joinedload(EventEdition.media))
.filter(EventEdition.slug == slug, EventEdition.is_published.is_(True))
.first()
)
if not edition:
raise HTTPException(status_code=404, detail="Edition not found")
return render_share_html(edition_to_public(edition), slug)
@router.get("/share/events/{slug}", response_class=HTMLResponse)
def share_event_preview_api(slug: str, db: Session = Depends(get_db)):
return HTMLResponse(_share_html_for_slug(slug, db))

View file

@ -0,0 +1,13 @@
from fastapi import APIRouter, Depends
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.routers.public import _share_html_for_slug
router = APIRouter(tags=["share"])
@router.get("/share/events/{slug}", response_class=HTMLResponse)
def share_event_short_url(slug: str, db: Session = Depends(get_db)):
return HTMLResponse(_share_html_for_slug(slug, db))

View file

@ -0,0 +1,3 @@
from app.schemas.auth import LoginRequest, RegisterRequest, Token, UserPublic
from app.schemas.domain import * # noqa: F403
from app.schemas.health import HealthResponse

View file

@ -0,0 +1,181 @@
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field
from app.models.enums import AdminRole, EventStatus, EventType, OrderStatus, TicketTypeStatus
class AdminLoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
class AdminToken(BaseModel):
access_token: str
token_type: str = "bearer"
class AdminUserPublic(BaseModel):
id: int
email: EmailStr
role: AdminRole
model_config = {"from_attributes": True}
class DashboardMetrics(BaseModel):
total_revenue_paise: int
tickets_sold: int
tickets_remaining: int
orders_total: int
orders_paid: int
orders_pending: int
attendance: int
upcoming_events: int
class EventStatsRow(BaseModel):
event_id: int
event_title: str
starts_at: datetime
tickets_sold: int
tickets_remaining: int
revenue_paise: int
attendance: int
class VenueAdmin(BaseModel):
id: int
slug: str
name: str
area: str
city: str
address: str
map_url: str | None = None
description: str | None = None
model_config = {"from_attributes": True}
class VenueCreate(BaseModel):
slug: str = Field(min_length=2, max_length=120)
name: str = Field(min_length=2, max_length=200)
area: str = Field(min_length=2, max_length=120)
city: str = Field(default="Bengaluru", max_length=80)
address: str = Field(min_length=5, max_length=512)
map_url: str | None = Field(default=None, max_length=512)
description: str | None = None
class VenueUpdate(BaseModel):
slug: str | None = Field(default=None, max_length=120)
name: str | None = Field(default=None, max_length=200)
area: str | None = Field(default=None, max_length=120)
city: str | None = Field(default=None, max_length=80)
address: str | None = Field(default=None, max_length=512)
map_url: str | None = Field(default=None, max_length=512)
description: str | None = None
class EventAdmin(BaseModel):
id: int
slug: str
title: str
category: EventType
status: EventStatus
venue_id: int
description: str | None
starts_at: datetime
ends_at: datetime | None
is_featured: bool
cover_image_url: str | None
cover_image_alt: str | None
model_config = {"from_attributes": True}
class EventCreate(BaseModel):
slug: str = Field(min_length=2, max_length=200)
title: str = Field(min_length=2, max_length=240)
category: EventType
status: EventStatus = EventStatus.draft
venue_id: int
description: str | None = None
starts_at: datetime
ends_at: datetime | None = None
is_featured: bool = False
cover_image_url: str | None = None
cover_image_alt: str | None = None
class EventUpdate(BaseModel):
slug: str | None = Field(default=None, max_length=200)
title: str | None = Field(default=None, max_length=240)
category: EventType | None = None
status: EventStatus | None = None
venue_id: int | None = None
description: str | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
is_featured: bool | None = None
cover_image_url: str | None = None
cover_image_alt: str | None = None
class TicketTypeAdmin(BaseModel):
id: int
event_id: int
name: str
description: str | None
price_paise: int
quantity_total: int
quantity_sold: int
remaining: int
status: TicketTypeStatus
sort_order: int
model_config = {"from_attributes": True}
class TicketTypeCreate(BaseModel):
event_id: int
name: str = Field(min_length=1, max_length=120)
description: str | None = Field(default=None, max_length=512)
price_paise: int = Field(ge=0)
quantity_total: int = Field(ge=0)
status: TicketTypeStatus = TicketTypeStatus.active
sort_order: int = 0
class TicketTypeUpdate(BaseModel):
name: str | None = Field(default=None, max_length=120)
description: str | None = Field(default=None, max_length=512)
price_paise: int | None = Field(default=None, ge=0)
quantity_total: int | None = Field(default=None, ge=0)
status: TicketTypeStatus | None = None
sort_order: int | None = None
class OrderAdmin(BaseModel):
id: int
user_id: int
event_id: int
status: OrderStatus
total_paise: int
currency: str
provider_order_id: str | None
provider_payment_id: str | None
paid_at: datetime | None
created_at: datetime
model_config = {"from_attributes": True}
class PaymentAdmin(BaseModel):
order_id: int
event_id: int
status: OrderStatus
total_paise: int
provider_payment_id: str | None
payment_reference: str | None
paid_at: datetime | None

View file

@ -0,0 +1,47 @@
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field, field_validator
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
@field_validator("email")
@classmethod
def normalize_email(cls, value: str) -> str:
return value.strip().lower()
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
full_name: str | None = Field(default=None, max_length=120)
@field_validator("email")
@classmethod
def normalize_email(cls, value: str) -> str:
return value.strip().lower()
@field_validator("full_name")
@classmethod
def strip_name(cls, value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None
class UserPublic(BaseModel):
id: int
email: EmailStr
full_name: str | None
is_active: bool
created_at: datetime
model_config = {"from_attributes": True}

View file

@ -0,0 +1,62 @@
from datetime import datetime
from pydantic import BaseModel, Field
from app.models.enums import EventStatus, EventType, TicketTypeStatus
class VenuePublic(BaseModel):
id: int
slug: str
name: str
area: str
city: str
address: str
map_url: str | None = None
description: str | None = None
model_config = {"from_attributes": True}
class EventMediaPublic(BaseModel):
id: int
image_url: str
alt_text: str | None
sort_order: int
model_config = {"from_attributes": True}
class TicketTypePublic(BaseModel):
id: int
name: str
description: str | None
price_paise: int = Field(ge=0)
status: TicketTypeStatus
sort_order: int
remaining_quantity: int = Field(ge=0)
model_config = {"from_attributes": True}
class EventSummary(BaseModel):
id: int
slug: str
title: str
category: EventType
status: EventStatus
starts_at: datetime
ends_at: datetime | None
is_featured: bool
cover_image_url: str | None
cover_image_alt: str | None
venue: VenuePublic
model_config = {"from_attributes": True}
class EventDetail(EventSummary):
description: str | None
meta_description: str | None
media: list[EventMediaPublic]
ticket_types: list[TicketTypePublic]

View file

@ -0,0 +1,170 @@
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field
from app.models.enums import AdminRole, EventType, PlatformKind
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class LoginRequest(BaseModel):
email: EmailStr
password: str
class SubscribeRequest(BaseModel):
email: EmailStr
neighborhood: str | None = Field(default=None, max_length=120)
class SubscriberPublic(BaseModel):
id: int
email: str
neighborhood: str | None
created_at: datetime
model_config = {"from_attributes": True}
class AdminUserPublic(BaseModel):
id: int
email: str
role: AdminRole
created_at: datetime
model_config = {"from_attributes": True}
class AdminUserCreate(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
role: AdminRole = AdminRole.editor
class PlatformLinkPublic(BaseModel):
id: int
kind: PlatformKind
label: str
url: str
sort_order: int
model_config = {"from_attributes": True}
class PlatformLinkCreate(BaseModel):
kind: PlatformKind
label: str = Field(max_length=120)
url: str = Field(max_length=512)
sort_order: int = 0
is_active: bool = True
class PlatformLinkUpdate(BaseModel):
kind: PlatformKind | None = None
label: str | None = Field(default=None, max_length=120)
url: str | None = Field(default=None, max_length=512)
sort_order: int | None = None
is_active: bool | None = None
class EditionTicketsPublic(BaseModel):
bookmyshow: str | None = None
district: str | None = None
instagram: str | None = None
class MediaAssetPublic(BaseModel):
id: int
url: str
thumb_url: str | None = None
caption: str | None
sort_order: int
model_config = {"from_attributes": True}
class ClickSummaryItem(BaseModel):
link_kind: str
count: int
class OutboundClickPublic(BaseModel):
id: int
link_kind: str
destination_url: str
edition_id: int | None
created_at: datetime
model_config = {"from_attributes": True}
class EventEditionPublic(BaseModel):
id: int
title: str
slug: str
event_type: EventType
city: str
venue_name: str | None
event_date: datetime | None
description: str | None
is_featured: bool
cover_image_url: str | None
cover_thumb_url: str | None = None
tickets: EditionTicketsPublic
meta_description: str | None
media: list[MediaAssetPublic]
model_config = {"from_attributes": True}
class EventEditionAdmin(EventEditionPublic):
is_published: bool
sort_order: int
class EventEditionCreate(BaseModel):
title: str = Field(max_length=200)
slug: str = Field(max_length=200)
event_type: EventType
venue_name: str | None = Field(default=None, max_length=200)
event_date: datetime | None = None
description: str | None = None
is_published: bool = False
is_featured: bool = False
sort_order: int = 0
bookmyshow_url: str | None = Field(default=None, max_length=512)
district_url: str | None = Field(default=None, max_length=512)
instagram_url: str | None = Field(default=None, max_length=512)
meta_description: str | None = Field(default=None, max_length=320)
class EventEditionUpdate(BaseModel):
title: str | None = Field(default=None, max_length=200)
slug: str | None = Field(default=None, max_length=200)
event_type: EventType | None = None
venue_name: str | None = None
event_date: datetime | None = None
description: str | None = None
is_published: bool | None = None
is_featured: bool | None = None
sort_order: int | None = None
bookmyshow_url: str | None = Field(default=None, max_length=512)
district_url: str | None = Field(default=None, max_length=512)
instagram_url: str | None = Field(default=None, max_length=512)
meta_description: str | None = Field(default=None, max_length=320)
class SiteConfigPublic(BaseModel):
city: str = "Bengaluru"
tagline: str
platform_links: list[PlatformLinkPublic]
featured_edition: EventEditionPublic | None
recent_editions: list[EventEditionPublic]
class MediaReorderItem(BaseModel):
id: int
sort_order: int

View file

@ -0,0 +1,7 @@
from pydantic import BaseModel, Field
class HealthResponse(BaseModel):
status: str = Field(examples=["ok"])
service: str = Field(examples=["desi-clans-api"])
database: str = Field(examples=["connected"])

View file

@ -0,0 +1,90 @@
from datetime import datetime
from pydantic import BaseModel, Field
from app.models.enums import OrderStatus, TicketStatus, TicketTypeStatus
class PaymentCheckoutPublic(BaseModel):
provider: str
key_id: str
provider_order_id: str
amount_paise: int
currency: str
order_id: int
expires_at: datetime | None = None
timeout_minutes: int
class EventTicketTypePublic(BaseModel):
id: int
event_id: int
name: str
description: str | None
price_paise: int = Field(ge=0)
status: TicketTypeStatus
sort_order: int
remaining_quantity: int = Field(ge=0)
model_config = {"from_attributes": True}
class OrderLineCreate(BaseModel):
ticket_type_id: int = Field(gt=0)
quantity: int = Field(ge=1, le=20)
class OrderCreate(BaseModel):
event_id: int = Field(gt=0)
items: list[OrderLineCreate] = Field(min_length=1, max_length=20)
class OrderItemPublic(BaseModel):
id: int
ticket_type_id: int
quantity: int
unit_price_paise: int
line_total_paise: int
model_config = {"from_attributes": True}
class OrderPublic(BaseModel):
id: int
event_id: int
status: OrderStatus
total_paise: int
currency: str
payment_reference: str | None
provider_order_id: str | None = None
provider_payment_id: str | None = None
expires_at: datetime | None = None
paid_at: datetime | None = None
created_at: datetime
items: list[OrderItemPublic]
model_config = {"from_attributes": True}
class OrderCheckoutResponse(OrderPublic):
payment: PaymentCheckoutPublic
class PaymentVerifyRequest(BaseModel):
provider_order_id: str = Field(min_length=1, max_length=64)
provider_payment_id: str = Field(min_length=1, max_length=64)
signature: str = Field(min_length=1, max_length=256)
class TicketPublic(BaseModel):
id: int
code: str
order_id: int
ticket_type_id: int
event_id: int
status: TicketStatus
created_at: datetime
ticket_type_name: str | None = None
event_title: str | None = None
model_config = {"from_attributes": True}

View file

@ -0,0 +1,41 @@
from datetime import datetime
from pydantic import BaseModel, Field
from app.models.enums import TicketStatus
class TicketVerifyRequest(BaseModel):
qr_token: str = Field(min_length=16, max_length=128)
class TicketVerifyResponse(BaseModel):
valid: bool
status: TicketStatus
ticket_id: int
ticket_code: str
event_title: str
message: str
class TicketDetailPublic(BaseModel):
id: int
code: str
status: TicketStatus
order_id: int
event_id: int
event_title: str
event_starts_at: datetime
event_ends_at: datetime | None
venue_name: str
venue_area: str
venue_city: str
attendee_name: str
attendee_email: str
ticket_type_name: str
ticket_type_id: int
qr_token: str
qr_payload: str
qr_image_base64: str
used_at: datetime | None
created_at: datetime

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

5
backend/app/storage.py Normal file
View file

@ -0,0 +1,5 @@
"""Backward-compatible storage import. Prefer `app.utils.storage`."""
from app.utils.storage import StorageBackend, get_storage
__all__ = ["StorageBackend", "get_storage"]

View file

View file

@ -0,0 +1,74 @@
from abc import ABC, abstractmethod
from pathlib import Path
from app.core.config import get_settings
settings = get_settings()
class StorageBackend(ABC):
@abstractmethod
def save(self, key: str, data: bytes) -> str:
raise NotImplementedError
@abstractmethod
def public_url(self, key: str) -> str:
raise NotImplementedError
class LocalStorage(StorageBackend):
def __init__(self, root: str) -> None:
self.root = Path(root)
self.root.mkdir(parents=True, exist_ok=True)
def save(self, key: str, data: bytes) -> str:
path = self.root / key.replace("\\", "/").lstrip("/")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return key.replace("\\", "/").lstrip("/")
def public_url(self, key: str) -> str:
normalized = key.replace("\\", "/").lstrip("/")
return f"/media/{normalized}"
class S3Storage(StorageBackend):
def __init__(self) -> None:
import boto3
if not settings.s3_bucket:
raise RuntimeError("S3_BUCKET is required when STORAGE_BACKEND=s3")
self.bucket = settings.s3_bucket
self.client = boto3.client(
"s3",
region_name=settings.aws_region,
aws_access_key_id=settings.aws_access_key_id,
aws_secret_access_key=settings.aws_secret_access_key,
)
base = settings.s3_public_base_url
self.public_base = (
base.rstrip("/") if base else f"https://{self.bucket}.s3.{settings.aws_region}.amazonaws.com"
)
def save(self, key: str, data: bytes) -> str:
normalized = key.replace("\\", "/").lstrip("/")
self.client.put_object(Bucket=self.bucket, Key=normalized, Body=data)
return normalized
def public_url(self, key: str) -> str:
normalized = key.replace("\\", "/").lstrip("/")
return f"{self.public_base}/{normalized}"
_storage: StorageBackend | None = None
def get_storage() -> StorageBackend:
global _storage
if _storage is None:
backend = settings.storage_backend.lower()
if backend == "s3":
_storage = S3Storage()
else:
_storage = LocalStorage(settings.upload_dir)
return _storage

19
backend/requirements.txt Normal file
View file

@ -0,0 +1,19 @@
alembic>=1.14,<2
bcrypt>=4.0,<5
boto3>=1.35,<2
email-validator>=2.2,<3
fastapi>=0.115,<1
passlib[bcrypt]>=1.7,<2
pillow>=11,<12
psycopg2-binary>=2.9,<3
pydantic>=2.10,<3
pydantic-settings>=2.6,<3
python-jose[cryptography]>=3.3,<4
python-multipart>=0.0.12,<1
sqlalchemy>=2.0,<3
uvicorn[standard]>=0.32,<1
httpx>=0.28,<1
pytest>=8,<9
razorpay>=1.4,<2
qrcode[pil]>=7.4,<9

View file

@ -0,0 +1,20 @@
"""Run Alembic migrations (upgrade head)."""
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def main():
result = subprocess.run(
[sys.executable, "-m", "alembic", "upgrade", "head"],
cwd=ROOT,
check=False,
)
raise SystemExit(result.returncode)
if __name__ == "__main__":
main()

55
backend/scripts/seed.py Normal file
View file

@ -0,0 +1,55 @@
"""Initialize admin user and default Bengaluru platform links."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.auth import hash_password
from app.config import settings
from app.database import SessionLocal, engine
from app.models import AdminUser, Base, PlatformKind, PlatformLink
DEFAULT_LINKS = [
(PlatformKind.bookmyshow, "BookMyShow", "https://in.bookmyshow.com/explore/home/bengaluru", 0),
(PlatformKind.district, "District", "https://www.district.in/events/bengaluru", 1),
(PlatformKind.instagram, "Instagram", "https://www.instagram.com/", 2),
(PlatformKind.highape, "HighApe", "https://highape.com/bangalore", 3),
]
REMOVED_KINDS = (PlatformKind.youtube, PlatformKind.whatsapp)
def main():
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
if not db.query(AdminUser).filter(AdminUser.email == settings.admin_email).first():
db.add(
AdminUser(
email=settings.admin_email,
hashed_password=hash_password(settings.admin_password),
)
)
print(f"Created admin: {settings.admin_email}")
if db.query(PlatformLink).count() == 0:
for kind, label, url, order in DEFAULT_LINKS:
db.add(PlatformLink(kind=kind, label=label, url=url, sort_order=order, is_active=True))
print("Seeded default platform links (update URLs in admin)")
else:
for kind in REMOVED_KINDS:
db.query(PlatformLink).filter(PlatformLink.kind == kind).delete()
for kind, label, url, order in DEFAULT_LINKS:
row = db.query(PlatformLink).filter(PlatformLink.kind == kind).first()
if not row:
db.add(PlatformLink(kind=kind, label=label, url=url, sort_order=order, is_active=True))
print("Synced platform links (removed YouTube/WhatsApp, ensured BMS/District/Insta/HighApe)")
db.commit()
finally:
db.close()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,40 @@
"""Ensure default admin user exists (catalog admin dashboard)."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sqlalchemy import select
from app.core.config import get_settings
from app.core.security import hash_password
from app.db.session import SessionLocal
from app.models.entities import AdminUser
from app.models.enums import AdminRole
def main() -> None:
settings = get_settings()
db = SessionLocal()
try:
email = settings.admin_email.strip().lower()
existing = db.scalar(select(AdminUser).where(AdminUser.email == email))
if not existing:
db.add(
AdminUser(
email=email,
hashed_password=hash_password(settings.admin_password),
role=AdminRole.super_admin,
)
)
db.commit()
print(f"Created admin user: {email}")
else:
print(f"Admin user already exists: {email}")
finally:
db.close()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,187 @@
"""Seed venues, events, media, and ticket types for Holi, Garba, Dandiya."""
import sys
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.catalog import Event, EventMedia, TicketType, Venue
from app.models.enums import EventStatus, EventType, TicketTypeStatus
IMG = lambda photo_id, w=1600: f"https://images.unsplash.com/{photo_id}?auto=format&fit=crop&w={w}&q=80"
GARBA_IMAGE = IMG("photo-1516450360457-62c5678aefa8")
GARBA_IMAGE_ALT = IMG("photo-1578662996442-48f60103fc96")
HOLI_IMAGE = IMG("photo-1524492412937-b28074a5d7da")
DANDIYA_IMAGE = IMG("photo-1533174072545-7a4b6ad7a6c3")
VENUES = [
{
"slug": "palace-grounds",
"name": "Palace Grounds",
"area": "Vasanth Nagar",
"address": "Palace Grounds, Jayamahal, Bengaluru, Karnataka 560006",
"map_url": "https://maps.google.com/?q=Palace+Grounds+Bengaluru",
"description": "Large open-air lawns suited to daylight Holi editions.",
},
{
"slug": "ktpo-whitefield",
"name": "KTPO Convention Centre",
"area": "Whitefield",
"address": "KTPO, Whitefield Main Rd, Bengaluru, Karnataka 560066",
"map_url": "https://maps.google.com/?q=KTPO+Whitefield",
"description": "Convention-scale halls and floors for Garba and Dandiya nights.",
},
{
"slug": "hsr-layout-grounds",
"name": "HSR Layout Club Grounds",
"area": "HSR Layout",
"address": "27th Main Rd, HSR Layout, Bengaluru, Karnataka 560102",
"map_url": "https://maps.google.com/?q=HSR+Layout+Bengaluru",
"description": "Neighbourhood grounds for intimate cultural socials.",
},
]
EVENTS = [
{
"slug": "rang-barse-palace-grounds-2026",
"title": "Rang Barse — Palace Grounds",
"category": EventType.holi,
"venue_slug": "palace-grounds",
"starts_at": datetime(2026, 3, 9, 10, 0, tzinfo=timezone.utc),
"ends_at": datetime(2026, 3, 9, 18, 0, tzinfo=timezone.utc),
"is_featured": True,
"cover": HOLI_IMAGE,
"cover_alt": "Crowd celebrating Holi with colour in the air",
"description": "Daylight Holi with live dhol, curated gulal zones, and family-friendly lawns.",
"media": [(HOLI_IMAGE, "Holi colour cloud above dancers", 0)],
"tickets": [
("General admission", "Full-day access with colour kit", 149900, 5000),
("Shade deck", "Reserved seating and meal credit", 299900, 400),
],
},
{
"slug": "navratri-nights-ktpo",
"title": "Navratri Nights",
"category": EventType.garba,
"venue_slug": "ktpo-whitefield",
"starts_at": datetime(2026, 10, 3, 19, 0, tzinfo=timezone.utc),
"ends_at": datetime(2026, 10, 4, 1, 0, tzinfo=timezone.utc),
"is_featured": True,
"cover": GARBA_IMAGE,
"cover_alt": "Women in traditional attire performing garba under evening lights",
"description": "Whitefield Garba with live folk vocals, circular floor, and midnight raas.",
"media": [
(GARBA_IMAGE, "Garba dancers in traditional dress", 0),
(GARBA_IMAGE_ALT, "Garba dancers in coordinated circle", 1),
],
"tickets": [
("Dance floor", "Standing access to main circle", 99900, 3000),
("Balcony lounge", "Elevated seating", 189900, 250),
],
},
{
"slug": "hsr-dandiya-social-2026",
"title": "HSR Dandiya Social",
"category": EventType.dandiya,
"venue_slug": "hsr-layout-grounds",
"starts_at": datetime(2026, 10, 11, 20, 0, tzinfo=timezone.utc),
"ends_at": datetime(2026, 10, 12, 1, 0, tzinfo=timezone.utc),
"is_featured": False,
"cover": DANDIYA_IMAGE,
"cover_alt": "Evening lanterns at a cultural celebration",
"description": "South Bengaluru Dandiya social with resident callers and midnight chai.",
"media": [(DANDIYA_IMAGE, "Festival lights over the dance floor", 0)],
"tickets": [("Early bird", "Full night access", 79900, 800)],
},
]
def upsert_venue(db, data: dict) -> Venue:
venue = db.scalar(select(Venue).where(Venue.slug == data["slug"]))
if venue:
for key, value in data.items():
setattr(venue, key, value)
return venue
venue = Venue(**data)
db.add(venue)
db.flush()
return venue
def seed() -> None:
db = SessionLocal()
try:
venues_by_slug = {}
for row in VENUES:
venues_by_slug[row["slug"]] = upsert_venue(db, row)
for row in EVENTS:
venue = venues_by_slug[row["venue_slug"]]
event = db.scalar(select(Event).where(Event.slug == row["slug"]))
if not event:
event = Event(
slug=row["slug"],
title=row["title"],
category=row["category"],
status=EventStatus.published,
venue_id=venue.id,
description=row["description"],
starts_at=row["starts_at"],
ends_at=row["ends_at"],
is_featured=row["is_featured"],
cover_image_url=row["cover"],
cover_image_alt=row["cover_alt"],
meta_description=row["description"][:300],
)
db.add(event)
db.flush()
else:
event.title = row["title"]
event.category = row["category"]
event.status = EventStatus.published
event.venue_id = venue.id
event.description = row["description"]
event.starts_at = row["starts_at"]
event.ends_at = row["ends_at"]
event.is_featured = row["is_featured"]
event.cover_image_url = row["cover"]
event.cover_image_alt = row["cover_alt"]
if not event.media:
for url, alt, order in row["media"]:
db.add(
EventMedia(
event_id=event.id,
image_url=url,
alt_text=alt,
sort_order=order,
)
)
if not event.ticket_types:
for idx, (name, desc, price_paise, qty) in enumerate(row["tickets"]):
db.add(
TicketType(
event_id=event.id,
name=name,
description=desc,
price_paise=price_paise,
quantity_total=qty,
status=TicketTypeStatus.active,
sort_order=idx,
)
)
db.commit()
print("Seeded catalog: venues, Holi, Garba, Dandiya events.")
finally:
db.close()
if __name__ == "__main__":
seed()

View file

View file

@ -0,0 +1,4 @@
import os
# Tests share one TestClient IP; disable in-process rate limits during pytest.
os.environ.setdefault("DESI_CLANS_DISABLE_RATE_LIMIT", "1")

56
backend/tests/helpers.py Normal file
View file

@ -0,0 +1,56 @@
import uuid
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def register_user() -> tuple[str, str]:
email = f"buyer-{uuid.uuid4().hex}@example.com"
password = "securepass1"
response = client.post(
"/api/auth/register",
json={"email": email, "password": password, "full_name": "Buyer"},
)
assert response.status_code == 201
token = response.json()["access_token"]
return email, token
def auth_headers(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def first_published_event() -> dict:
response = client.get("/api/events", params={"upcoming": "false"})
assert response.status_code == 200
events = response.json()
assert events, "Seed catalog required for order tests"
return events[0]
def pick_tier(tiers: list[dict], quantity: int = 1) -> dict | None:
return next(
(t for t in tiers if t["status"] == "active" and t["remaining_quantity"] >= quantity),
None,
)
def create_pending_order():
import pytest
_, token = register_user()
event = first_published_event()
tiers = client.get(f"/api/events/{event['id']}/tickets").json()
tier = pick_tier(tiers, 1)
if not tier:
pytest.skip("No ticket inventory available for test")
response = client.post(
"/api/orders",
headers={**auth_headers(token), "Idempotency-Key": uuid.uuid4().hex},
json={"event_id": event["id"], "items": [{"ticket_type_id": tier["id"], "quantity": 1}]},
)
assert response.status_code == 201
return token, response.json()

View file

@ -0,0 +1,50 @@
import uuid
from tests.helpers import auth_headers, client, register_user
def _admin_token() -> str:
from app.core.config import get_settings
settings = get_settings()
response = client.post(
"/api/admin/auth/login",
json={"email": settings.admin_email, "password": settings.admin_password},
)
if response.status_code != 200:
raise RuntimeError(f"Admin login failed: {response.text} — run scripts/seed_admin.py")
return response.json()["access_token"]
def test_attendee_cannot_access_admin_dashboard() -> None:
_, token = register_user()
response = client.get("/api/admin/dashboard/metrics", headers=auth_headers(token))
assert response.status_code == 401
def test_admin_dashboard_metrics() -> None:
token = _admin_token()
response = client.get("/api/admin/dashboard/metrics", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 200
body = response.json()
assert "total_revenue_paise" in body
assert "tickets_sold" in body
assert "upcoming_events" in body
def test_admin_lists_catalog() -> None:
token = _admin_token()
headers = {"Authorization": f"Bearer {token}"}
assert client.get("/api/admin/events", headers=headers).status_code == 200
assert client.get("/api/admin/venues", headers=headers).status_code == 200
assert client.get("/api/admin/orders", headers=headers).status_code == 200
assert client.get("/api/admin/payments", headers=headers).status_code == 200
assert client.get("/api/admin/stats/events", headers=headers).status_code == 200
def test_admin_invalid_login() -> None:
response = client.post(
"/api/admin/auth/login",
json={"email": f"nope-{uuid.uuid4().hex}@example.com", "password": "wrongpass"},
)
assert response.status_code == 401

View file

@ -0,0 +1,95 @@
import uuid
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def _unique_email() -> str:
return f"test-{uuid.uuid4().hex}@example.com"
def _register(email: str | None = None, password: str = "securepass1") -> tuple[str, dict]:
address = email or _unique_email()
response = client.post(
"/api/auth/register",
json={"email": address, "password": password, "full_name": "Test User"},
)
return address, response
def test_register_login_and_me() -> None:
email, register_response = _register()
assert register_response.status_code == 201
token = register_response.json()["access_token"]
assert token
login_response = client.post("/api/auth/login", json={"email": email, "password": "securepass1"})
assert login_response.status_code == 200
assert login_response.json()["token_type"] == "bearer"
me_response = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"})
assert me_response.status_code == 200
body = me_response.json()
assert body["email"] == email
assert body["full_name"] == "Test User"
assert body["is_active"] is True
assert "hashed_password" not in body
def test_register_duplicate_email() -> None:
email, first = _register()
assert first.status_code == 201
second = client.post(
"/api/auth/register",
json={"email": email, "password": "securepass1"},
)
assert second.status_code == 409
def test_register_password_too_short() -> None:
response = client.post(
"/api/auth/register",
json={"email": _unique_email(), "password": "short"},
)
assert response.status_code == 422
def test_login_invalid_credentials() -> None:
email, register_response = _register()
assert register_response.status_code == 201
wrong_password = client.post("/api/auth/login", json={"email": email, "password": "wrongpassword"})
assert wrong_password.status_code == 401
unknown = client.post("/api/auth/login", json={"email": "nobody@example.com", "password": "securepass1"})
assert unknown.status_code == 401
def test_me_requires_auth() -> None:
response = client.get("/api/auth/me")
assert response.status_code == 401
def test_me_rejects_invalid_token() -> None:
response = client.get("/api/auth/me", headers={"Authorization": "Bearer not-a-valid-jwt"})
assert response.status_code == 401
def test_passwords_are_hashed_in_database() -> None:
from app.db.session import SessionLocal
from app.services.users import get_user_by_email
email, register_response = _register(password="anotherpass9")
assert register_response.status_code == 201
db = SessionLocal()
try:
user = get_user_by_email(db, email)
assert user is not None
assert user.hashed_password != "anotherpass9"
assert user.hashed_password.startswith("$2")
finally:
db.close()

View file

@ -0,0 +1,64 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_list_events_published() -> None:
response = client.get("/api/events")
assert response.status_code == 200
body = response.json()
assert isinstance(body, list)
if body:
assert "slug" in body[0]
assert "venue" in body[0]
assert body[0]["status"] == "published"
def test_list_events_filter_category() -> None:
response = client.get("/api/events", params={"category": "garba", "upcoming": "false"})
assert response.status_code == 200
for item in response.json():
assert item["category"] == "garba"
def test_get_event_by_slug() -> None:
listing = client.get("/api/events", params={"upcoming": "false"})
assert listing.status_code == 200
if not listing.json():
return
slug = listing.json()[0]["slug"]
detail = client.get(f"/api/events/{slug}")
assert detail.status_code == 200
data = detail.json()
assert data["slug"] == slug
assert "ticket_types" in data
assert "media" in data
def test_get_event_not_found() -> None:
response = client.get("/api/events/does-not-exist-slug")
assert response.status_code == 404
def test_list_venues() -> None:
response = client.get("/api/venues")
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_get_venue_by_id() -> None:
listing = client.get("/api/venues")
assert listing.status_code == 200
if not listing.json():
return
venue_id = listing.json()[0]["id"]
response = client.get(f"/api/venues/{venue_id}")
assert response.status_code == 200
assert response.json()["id"] == venue_id
def test_get_venue_not_found() -> None:
response = client.get("/api/venues/999999")
assert response.status_code == 404

View file

@ -0,0 +1,61 @@
import json
from tests.helpers import auth_headers, client, create_pending_order
from app.core.config import get_settings
from app.services.payments.gateway import MockPaymentGateway, get_payment_gateway
def _pay_and_get_ticket():
token, checkout = create_pending_order()
gateway = get_payment_gateway()
assert isinstance(gateway, MockPaymentGateway)
payment = checkout["payment"]
payment_id = f"mock_pay_{checkout['id']}"
signature = gateway.build_mock_checkout_signature(payment["provider_order_id"], payment_id)
client.post(
f"/api/orders/{checkout['id']}/payment/verify",
headers=auth_headers(token),
json={
"provider_order_id": payment["provider_order_id"],
"provider_payment_id": payment_id,
"signature": signature,
},
)
tickets = client.get("/api/tickets/my", headers=auth_headers(token)).json()
ticket_id = tickets[0]["id"]
return token, ticket_id
def test_ticket_detail_includes_qr() -> None:
token, ticket_id = _pay_and_get_ticket()
response = client.get(f"/api/tickets/{ticket_id}", headers=auth_headers(token))
assert response.status_code == 200
body = response.json()
assert body["qr_image_base64"]
assert body["event_title"]
assert body["venue_name"]
assert body["ticket_type_name"]
assert body["code"].startswith("DC-")
payload = json.loads(body["qr_payload"])
assert payload["token"] == body["qr_token"]
def test_verify_marks_ticket_used_and_prevents_reuse() -> None:
token, ticket_id = _pay_and_get_ticket()
detail = client.get(f"/api/tickets/{ticket_id}", headers=auth_headers(token)).json()
qr_token = detail["qr_token"]
settings = get_settings()
headers = {}
if settings.ticket_verify_api_key:
headers["X-Check-In-Key"] = settings.ticket_verify_api_key
first = client.post("/api/tickets/verify", json={"qr_token": qr_token}, headers=headers)
assert first.status_code == 200
assert first.json()["valid"] is True
assert first.json()["status"] == "used"
second = client.post("/api/tickets/verify", json={"qr_token": qr_token}, headers=headers)
assert second.status_code == 200
assert second.json()["valid"] is False
assert "already used" in second.json()["message"].lower()

View file

@ -0,0 +1,19 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health_endpoint() -> None:
response = client.get("/api/health")
assert response.status_code in (200, 503)
body = response.json()
assert body["service"] == "desi-clans-api"
assert "database" in body
assert body["status"] in ("ok", "degraded")
if response.status_code == 200:
assert body["database"] == "connected"
else:
assert body["database"] == "unavailable"

View file

@ -0,0 +1,174 @@
import uuid
from concurrent.futures import ThreadPoolExecutor
import pytest
from sqlalchemy import select
from app.db.session import SessionLocal
from app.main import app
from app.models.catalog import TicketType
from app.models.enums import OrderStatus, TicketTypeStatus
from app.models.user import User
from app.schemas.orders import OrderCreate, OrderLineCreate
from app.services.orders import InsufficientInventoryError, create_order
from app.services.payments.gateway import MockPaymentGateway, get_payment_gateway
from tests.helpers import (
auth_headers,
client,
create_pending_order,
first_published_event,
pick_tier,
register_user,
)
def _complete_mock_payment(token: str, checkout: dict) -> None:
gateway = get_payment_gateway()
assert isinstance(gateway, MockPaymentGateway)
payment = checkout["payment"]
payment_id = f"mock_pay_{checkout['id']}"
signature = gateway.build_mock_checkout_signature(payment["provider_order_id"], payment_id)
response = client.post(
f"/api/orders/{checkout['id']}/payment/verify",
headers=auth_headers(token),
json={
"provider_order_id": payment["provider_order_id"],
"provider_payment_id": payment_id,
"signature": signature,
},
)
assert response.status_code == 200
def test_list_event_tickets() -> None:
event = first_published_event()
response = client.get(f"/api/events/{event['id']}/tickets")
assert response.status_code == 200
tiers = response.json()
assert tiers
assert tiers[0]["event_id"] == event["id"]
assert "remaining_quantity" in tiers[0]
assert "price_paise" in tiers[0]
def test_create_order_pending_until_verified() -> None:
token, checkout = create_pending_order()
assert checkout["status"] == OrderStatus.pending.value
assert checkout["payment"]["amount_paise"] == checkout["total_paise"]
assert checkout["payment"]["provider_order_id"]
tickets_before = client.get("/api/tickets/my", headers=auth_headers(token)).json()
assert not any(t["order_id"] == checkout["id"] for t in tickets_before)
_complete_mock_payment(token, checkout)
my_tickets = client.get("/api/tickets/my", headers=auth_headers(token))
assert my_tickets.status_code == 200
tickets = my_tickets.json()
assert any(t["order_id"] == checkout["id"] for t in tickets)
assert all(t["code"].startswith("DC-") for t in tickets)
def test_order_requires_authentication() -> None:
event = first_published_event()
response = client.post(
"/api/orders",
json={"event_id": event["id"], "items": [{"ticket_type_id": 1, "quantity": 1}]},
)
assert response.status_code == 401
def test_cannot_oversell_inventory() -> None:
_, token = register_user()
event = first_published_event()
tiers = client.get(f"/api/events/{event['id']}/tickets").json()
tier = pick_tier(tiers, 1)
if not tier:
pytest.skip("No ticket inventory available for test")
if tier["remaining_quantity"] >= 20:
pytest.skip("Quantity cap prevents oversell test for this tier")
response = client.post(
"/api/orders",
headers=auth_headers(token),
json={
"event_id": event["id"],
"items": [{"ticket_type_id": tier["id"], "quantity": tier["remaining_quantity"] + 1}],
},
)
assert response.status_code == 409
def test_server_calculates_total_not_client() -> None:
token, checkout = create_pending_order()
tiers = client.get(f"/api/events/{checkout['event_id']}/tickets").json()
tier = next(t for t in tiers if t["id"] == checkout["items"][0]["ticket_type_id"])
qty = checkout["items"][0]["quantity"]
assert checkout["total_paise"] == tier["price_paise"] * qty
def test_concurrent_purchases_do_not_oversell() -> None:
event = first_published_event()
db = SessionLocal()
try:
tier = db.scalar(
select(TicketType).where(TicketType.event_id == event["id"]).order_by(TicketType.id.asc())
)
assert tier is not None
tier.quantity_sold = max(tier.quantity_total - 1, 0)
tier.quantity_total = tier.quantity_sold + 1
tier.status = TicketTypeStatus.active
db.commit()
ticket_type_id = tier.id
event_id = event["id"]
finally:
db.close()
user_ids: list[int] = []
for _ in range(2):
email = f"race-{uuid.uuid4().hex}@example.com"
reg = client.post(
"/api/auth/register",
json={"email": email, "password": "securepass1"},
)
assert reg.status_code == 201
db = SessionLocal()
try:
user = db.scalar(select(User).where(User.email == email))
assert user is not None
user_ids.append(user.id)
finally:
db.close()
def _buy(user_id: int) -> str:
session = SessionLocal()
try:
user = session.get(User, user_id)
create_order(
session,
user,
OrderCreate(
event_id=event_id,
items=[OrderLineCreate(ticket_type_id=ticket_type_id, quantity=1)],
),
)
return "success"
except InsufficientInventoryError:
session.rollback()
return "conflict"
finally:
session.close()
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(_buy, user_ids))
assert results.count("success") == 1
assert results.count("conflict") == 1
db = SessionLocal()
try:
refreshed = db.get(TicketType, ticket_type_id)
assert refreshed is not None
assert refreshed.quantity_sold == refreshed.quantity_total
finally:
db.close()

Some files were not shown because too many files have changed in this diff Show more