first commit
This commit is contained in:
commit
34ea15a54d
246 changed files with 18660 additions and 0 deletions
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Python
|
||||
backend/.venv/
|
||||
backend/__pycache__/
|
||||
backend/**/__pycache__/
|
||||
backend/*.py[cod]
|
||||
backend/.pytest_cache/
|
||||
backend/.mypy_cache/
|
||||
backend/.ruff_cache/
|
||||
|
||||
# Environment & secrets
|
||||
.env
|
||||
backend/.env
|
||||
frontend/.env
|
||||
frontend/.env.local
|
||||
frontend/.env.*.local
|
||||
|
||||
# Media & uploads
|
||||
backend/uploads/
|
||||
|
||||
# Logs & OS
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Build
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
91
README.md
Normal file
91
README.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# Desi Clans
|
||||
|
||||
Premium Bengaluru-based platform for **Indian cultural events**—Holi, Garba, Dandiya, and related celebrations.
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technologies |
|
||||
|-------|----------------|
|
||||
| Frontend | React (JSX), Vite, Tailwind CSS 3, React Router, Axios, GSAP, Lucide React |
|
||||
| Backend | FastAPI, Python, SQLAlchemy, Pydantic, Alembic, JWT, PostgreSQL |
|
||||
|
||||
---
|
||||
|
||||
## Repository structure
|
||||
|
||||
```
|
||||
DESI-CLANS/
|
||||
├── frontend/ # Vite + React foundation
|
||||
├── backend/ # FastAPI foundation (+ routers for next phase)
|
||||
├── docs/ # PRD, architecture, API, database, design system
|
||||
├── scripts/ # Dev helpers (PowerShell + shell)
|
||||
├── docker-compose.yml # Local PostgreSQL
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
### 1. Database
|
||||
|
||||
```bash
|
||||
docker compose up -d db
|
||||
```
|
||||
|
||||
Default URL matches `backend/.env.example`:
|
||||
|
||||
`postgresql://desiclans:desiclans_dev@localhost:5432/desiclans`
|
||||
|
||||
### 2. Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate # Windows
|
||||
# source .venv/bin/activate # macOS/Linux
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # if .env does not exist
|
||||
python scripts/migrate.py # optional until business routers are enabled
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
Or from repo root (Windows): `.\scripts\dev-backend.ps1`
|
||||
|
||||
Health check: `http://localhost:8000/api/health`
|
||||
|
||||
### 3. Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
cp .env.example .env
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Or: `.\scripts\dev-frontend.ps1`
|
||||
|
||||
App: `http://localhost:5173` (proxies `/api` to the backend in dev)
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | Purpose |
|
||||
|-----|---------|
|
||||
| [docs/PRD.md](./docs/PRD.md) | Product scope |
|
||||
| [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | System design & phases |
|
||||
| [docs/DESIGN_SYSTEM.md](./docs/DESIGN_SYSTEM.md) | UI tokens & components |
|
||||
| [docs/API_SPEC.md](./docs/API_SPEC.md) | REST API |
|
||||
| [docs/DATABASE.md](./docs/DATABASE.md) | Schema |
|
||||
| [docs/PROGRESS.md](./docs/PROGRESS.md) | Implementation status |
|
||||
|
||||
---
|
||||
|
||||
## Implementation status
|
||||
|
||||
**Phase 1 foundation** (current): project scaffolding, env templates, dev scripts, health endpoint, frontend shell—**no business features wired yet**.
|
||||
|
||||
See [docs/PROGRESS.md](./docs/PROGRESS.md) for the checklist.
|
||||
50
backend/.env.example
Normal file
50
backend/.env.example
Normal 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
18
backend/Dockerfile
Normal 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
67
backend/README.md
Normal 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
42
backend/alembic.ini
Normal 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
67
backend/alembic/env.py
Normal 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()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal 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"}
|
||||
|
|
@ -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")
|
||||
39
backend/alembic/versions/20260404_0002_outbound_clicks.py
Normal file
39
backend/alembic/versions/20260404_0002_outbound_clicks.py
Normal 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")
|
||||
|
|
@ -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")
|
||||
22
backend/alembic/versions/20260404_0004_platform_highape.py
Normal file
22
backend/alembic/versions/20260404_0004_platform_highape.py
Normal 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
|
||||
123
backend/alembic/versions/20260404_0005_events_venues_catalog.py
Normal file
123
backend/alembic/versions/20260404_0005_events_venues_catalog.py
Normal 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)
|
||||
34
backend/alembic/versions/20260404_0006_users.py
Normal file
34
backend/alembic/versions/20260404_0006_users.py
Normal 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")
|
||||
129
backend/alembic/versions/20260404_0007_orders_tickets.py
Normal file
129
backend/alembic/versions/20260404_0007_orders_tickets.py
Normal 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)
|
||||
73
backend/alembic/versions/20260404_0008_payments.py
Normal file
73
backend/alembic/versions/20260404_0008_payments.py
Normal 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,
|
||||
)
|
||||
42
backend/alembic/versions/20260404_0009_digital_tickets.py
Normal file
42
backend/alembic/versions/20260404_0009_digital_tickets.py
Normal 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
1
backend/app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
3
backend/app/api/__init__.py
Normal file
3
backend/app/api/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from app.api.router import api_router
|
||||
|
||||
__all__ = ["api_router"]
|
||||
14
backend/app/api/router.py
Normal file
14
backend/app/api/router.py
Normal 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)
|
||||
0
backend/app/api/routes/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
157
backend/app/api/routes/admin/router.py
Normal file
157
backend/app/api/routes/admin/router.py
Normal 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)
|
||||
42
backend/app/api/routes/auth.py
Normal file
42
backend/app/api/routes/auth.py
Normal 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
|
||||
33
backend/app/api/routes/events.py
Normal file
33
backend/app/api/routes/events.py
Normal 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)
|
||||
24
backend/app/api/routes/health.py
Normal file
24
backend/app/api/routes/health.py
Normal 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
|
||||
72
backend/app/api/routes/orders.py
Normal file
72
backend/app/api/routes/orders.py
Normal 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)
|
||||
37
backend/app/api/routes/tickets.py
Normal file
37
backend/app/api/routes/tickets.py
Normal 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)
|
||||
18
backend/app/api/routes/venues.py
Normal file
18
backend/app/api/routes/venues.py
Normal 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)
|
||||
31
backend/app/api/routes/webhooks.py
Normal file
31
backend/app/api/routes/webhooks.py
Normal 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
56
backend/app/auth.py
Normal 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
5
backend/app/config.py
Normal 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"]
|
||||
5
backend/app/core/__init__.py
Normal file
5
backend/app/core/__init__.py
Normal 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
105
backend/app/core/config.py
Normal 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
76
backend/app/core/deps.py
Normal 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))]
|
||||
23
backend/app/core/errors.py
Normal file
23
backend/app/core/errors.py
Normal 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"})
|
||||
37
backend/app/core/rate_limit.py
Normal file
37
backend/app/core/rate_limit.py
Normal 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)
|
||||
72
backend/app/core/security.py
Normal file
72
backend/app/core/security.py
Normal 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
6
backend/app/database.py
Normal 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"]
|
||||
4
backend/app/db/__init__.py
Normal file
4
backend/app/db/__init__.py
Normal 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
5
backend/app/db/base.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
23
backend/app/db/session.py
Normal file
23
backend/app/db/session.py
Normal 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
43
backend/app/main.py
Normal 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()
|
||||
50
backend/app/models/__init__.py
Normal file
50
backend/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
115
backend/app/models/catalog.py
Normal file
115
backend/app/models/catalog.py
Normal 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")
|
||||
105
backend/app/models/entities.py
Normal file
105
backend/app/models/entities.py
Normal 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
101
backend/app/models/enums.py
Normal 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"
|
||||
|
||||
|
||||
89
backend/app/models/orders.py
Normal file
89
backend/app/models/orders.py
Normal 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")
|
||||
19
backend/app/models/payments.py
Normal file
19
backend/app/models/payments.py
Normal 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())
|
||||
19
backend/app/models/user.py
Normal file
19
backend/app/models/user.py
Normal 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())
|
||||
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
238
backend/app/routers/admin.py
Normal file
238
backend/app/routers/admin.py
Normal 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}
|
||||
44
backend/app/routers/admin_users.py
Normal file
44
backend/app/routers/admin_users.py
Normal 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()
|
||||
)
|
||||
40
backend/app/routers/analytics.py
Normal file
40
backend/app/routers/analytics.py
Normal 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]
|
||||
17
backend/app/routers/auth.py
Normal file
17
backend/app/routers/auth.py
Normal 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))
|
||||
151
backend/app/routers/public.py
Normal file
151
backend/app/routers/public.py
Normal 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))
|
||||
13
backend/app/routers/share.py
Normal file
13
backend/app/routers/share.py
Normal 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))
|
||||
3
backend/app/schemas/__init__.py
Normal file
3
backend/app/schemas/__init__.py
Normal 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
|
||||
181
backend/app/schemas/admin.py
Normal file
181
backend/app/schemas/admin.py
Normal 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
|
||||
47
backend/app/schemas/auth.py
Normal file
47
backend/app/schemas/auth.py
Normal 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}
|
||||
62
backend/app/schemas/catalog.py
Normal file
62
backend/app/schemas/catalog.py
Normal 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]
|
||||
170
backend/app/schemas/domain.py
Normal file
170
backend/app/schemas/domain.py
Normal 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
|
||||
7
backend/app/schemas/health.py
Normal file
7
backend/app/schemas/health.py
Normal 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"])
|
||||
90
backend/app/schemas/orders.py
Normal file
90
backend/app/schemas/orders.py
Normal 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}
|
||||
41
backend/app/schemas/tickets.py
Normal file
41
backend/app/schemas/tickets.py
Normal 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
|
||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
0
backend/app/services/admin/__init__.py
Normal file
0
backend/app/services/admin/__init__.py
Normal file
126
backend/app/services/admin/catalog.py
Normal file
126
backend/app/services/admin/catalog.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.models.catalog import Event, TicketType, Venue
|
||||
from app.models.enums import TicketTypeStatus
|
||||
from app.schemas.admin import (
|
||||
EventCreate,
|
||||
EventUpdate,
|
||||
TicketTypeCreate,
|
||||
TicketTypeUpdate,
|
||||
VenueCreate,
|
||||
VenueUpdate,
|
||||
)
|
||||
|
||||
|
||||
def list_venues_admin(db: Session) -> list[Venue]:
|
||||
return list(db.scalars(select(Venue).order_by(Venue.name.asc())).all())
|
||||
|
||||
|
||||
def create_venue(db: Session, body: VenueCreate) -> Venue:
|
||||
if db.scalar(select(Venue.id).where(Venue.slug == body.slug)):
|
||||
raise AppError("Venue slug already exists", status_code=409)
|
||||
venue = Venue(**body.model_dump())
|
||||
db.add(venue)
|
||||
db.commit()
|
||||
db.refresh(venue)
|
||||
return venue
|
||||
|
||||
|
||||
def update_venue(db: Session, venue_id: int, body: VenueUpdate) -> Venue:
|
||||
venue = db.get(Venue, venue_id)
|
||||
if not venue:
|
||||
raise AppError("Venue not found", status_code=404)
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
if "slug" in data and data["slug"] != venue.slug:
|
||||
if db.scalar(select(Venue.id).where(Venue.slug == data["slug"])):
|
||||
raise AppError("Venue slug already exists", status_code=409)
|
||||
for key, value in data.items():
|
||||
setattr(venue, key, value)
|
||||
db.commit()
|
||||
db.refresh(venue)
|
||||
return venue
|
||||
|
||||
|
||||
def list_events_admin(db: Session) -> list[Event]:
|
||||
return list(db.scalars(select(Event).order_by(Event.starts_at.desc())).all())
|
||||
|
||||
|
||||
def create_event(db: Session, body: EventCreate) -> Event:
|
||||
if not db.get(Venue, body.venue_id):
|
||||
raise AppError("Venue not found", status_code=400)
|
||||
if db.scalar(select(Event.id).where(Event.slug == body.slug)):
|
||||
raise AppError("Event slug already exists", status_code=409)
|
||||
event = Event(**body.model_dump())
|
||||
db.add(event)
|
||||
db.commit()
|
||||
db.refresh(event)
|
||||
return event
|
||||
|
||||
|
||||
def update_event(db: Session, event_id: int, body: EventUpdate) -> Event:
|
||||
event = db.get(Event, event_id)
|
||||
if not event:
|
||||
raise AppError("Event not found", status_code=404)
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
if "venue_id" in data and not db.get(Venue, data["venue_id"]):
|
||||
raise AppError("Venue not found", status_code=400)
|
||||
if "slug" in data and data["slug"] != event.slug:
|
||||
if db.scalar(select(Event.id).where(Event.slug == data["slug"])):
|
||||
raise AppError("Event slug already exists", status_code=409)
|
||||
for key, value in data.items():
|
||||
setattr(event, key, value)
|
||||
db.commit()
|
||||
db.refresh(event)
|
||||
return event
|
||||
|
||||
|
||||
def ticket_type_to_admin(tier: TicketType) -> dict:
|
||||
return {
|
||||
"id": tier.id,
|
||||
"event_id": tier.event_id,
|
||||
"name": tier.name,
|
||||
"description": tier.description,
|
||||
"price_paise": tier.price_paise,
|
||||
"quantity_total": tier.quantity_total,
|
||||
"quantity_sold": tier.quantity_sold,
|
||||
"remaining": max(tier.quantity_total - tier.quantity_sold, 0),
|
||||
"status": tier.status,
|
||||
"sort_order": tier.sort_order,
|
||||
}
|
||||
|
||||
|
||||
def list_ticket_types(db: Session, event_id: int | None = None) -> list[dict]:
|
||||
query = select(TicketType).order_by(TicketType.event_id.asc(), TicketType.sort_order.asc())
|
||||
if event_id is not None:
|
||||
query = query.where(TicketType.event_id == event_id)
|
||||
return [ticket_type_to_admin(row) for row in db.scalars(query).all()]
|
||||
|
||||
|
||||
def create_ticket_type(db: Session, body: TicketTypeCreate) -> TicketType:
|
||||
if not db.get(Event, body.event_id):
|
||||
raise AppError("Event not found", status_code=400)
|
||||
tier = TicketType(**body.model_dump())
|
||||
db.add(tier)
|
||||
db.commit()
|
||||
db.refresh(tier)
|
||||
return tier
|
||||
|
||||
|
||||
def update_ticket_type(db: Session, tier_id: int, body: TicketTypeUpdate) -> TicketType:
|
||||
tier = db.get(TicketType, tier_id)
|
||||
if not tier:
|
||||
raise AppError("Ticket type not found", status_code=404)
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
if "quantity_total" in data and data["quantity_total"] < tier.quantity_sold:
|
||||
raise AppError("Total quantity cannot be less than sold count", status_code=400)
|
||||
for key, value in data.items():
|
||||
setattr(tier, key, value)
|
||||
if tier.quantity_sold >= tier.quantity_total:
|
||||
tier.status = TicketTypeStatus.sold_out
|
||||
elif tier.status == TicketTypeStatus.sold_out and tier.quantity_sold < tier.quantity_total:
|
||||
tier.status = TicketTypeStatus.active
|
||||
db.commit()
|
||||
db.refresh(tier)
|
||||
return tier
|
||||
82
backend/app/services/admin/dashboard.py
Normal file
82
backend/app/services/admin/dashboard.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.catalog import Event, TicketType
|
||||
from app.models.enums import EventStatus, OrderStatus, TicketStatus
|
||||
from app.models.orders import Order, Ticket
|
||||
from app.schemas.admin import DashboardMetrics, EventStatsRow
|
||||
|
||||
|
||||
def get_dashboard_metrics(db: Session) -> DashboardMetrics:
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
revenue = db.scalar(
|
||||
select(func.coalesce(func.sum(Order.total_paise), 0)).where(Order.status == OrderStatus.paid)
|
||||
) or 0
|
||||
|
||||
tickets_sold = db.scalar(select(func.coalesce(func.sum(TicketType.quantity_sold), 0))) or 0
|
||||
tickets_total = db.scalar(select(func.coalesce(func.sum(TicketType.quantity_total), 0))) or 0
|
||||
tickets_remaining = max(int(tickets_total) - int(tickets_sold), 0)
|
||||
|
||||
orders_total = db.scalar(select(func.count(Order.id))) or 0
|
||||
orders_paid = db.scalar(select(func.count(Order.id)).where(Order.status == OrderStatus.paid)) or 0
|
||||
orders_pending = db.scalar(select(func.count(Order.id)).where(Order.status == OrderStatus.pending)) or 0
|
||||
|
||||
attendance = db.scalar(select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.used)) or 0
|
||||
|
||||
upcoming_events = (
|
||||
db.scalar(
|
||||
select(func.count(Event.id)).where(
|
||||
Event.status == EventStatus.published,
|
||||
Event.starts_at >= now,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
return DashboardMetrics(
|
||||
total_revenue_paise=int(revenue),
|
||||
tickets_sold=int(tickets_sold),
|
||||
tickets_remaining=tickets_remaining,
|
||||
orders_total=int(orders_total),
|
||||
orders_paid=int(orders_paid),
|
||||
orders_pending=int(orders_pending),
|
||||
attendance=int(attendance),
|
||||
upcoming_events=int(upcoming_events),
|
||||
)
|
||||
|
||||
|
||||
def get_event_statistics(db: Session) -> list[EventStatsRow]:
|
||||
events = db.scalars(select(Event).order_by(Event.starts_at.desc())).all()
|
||||
rows: list[EventStatsRow] = []
|
||||
|
||||
for event in events:
|
||||
tiers = db.scalars(select(TicketType).where(TicketType.event_id == event.id)).all()
|
||||
sold = sum(t.quantity_sold for t in tiers)
|
||||
total = sum(t.quantity_total for t in tiers)
|
||||
revenue = db.scalar(
|
||||
select(func.coalesce(func.sum(Order.total_paise), 0)).where(
|
||||
Order.event_id == event.id,
|
||||
Order.status == OrderStatus.paid,
|
||||
)
|
||||
) or 0
|
||||
attendance = db.scalar(
|
||||
select(func.count(Ticket.id)).where(
|
||||
Ticket.event_id == event.id,
|
||||
Ticket.status == TicketStatus.used,
|
||||
)
|
||||
) or 0
|
||||
rows.append(
|
||||
EventStatsRow(
|
||||
event_id=event.id,
|
||||
event_title=event.title,
|
||||
starts_at=event.starts_at,
|
||||
tickets_sold=sold,
|
||||
tickets_remaining=max(total - sold, 0),
|
||||
revenue_paise=int(revenue),
|
||||
attendance=int(attendance),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
40
backend/app/services/admin/orders.py
Normal file
40
backend/app/services/admin/orders.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.models.enums import OrderStatus
|
||||
from app.models.orders import Order
|
||||
|
||||
|
||||
def list_orders_admin(db: Session, status: OrderStatus | None = None) -> list[Order]:
|
||||
query = select(Order).order_by(Order.created_at.desc())
|
||||
if status is not None:
|
||||
query = query.where(Order.status == status)
|
||||
return list(db.scalars(query).all())
|
||||
|
||||
|
||||
def get_order_admin(db: Session, order_id: int) -> Order:
|
||||
order = db.get(Order, order_id)
|
||||
if not order:
|
||||
raise AppError("Order not found", status_code=404)
|
||||
return order
|
||||
|
||||
|
||||
def list_payments_admin(db: Session) -> list[dict]:
|
||||
orders = db.scalars(
|
||||
select(Order)
|
||||
.where(Order.status.in_([OrderStatus.paid, OrderStatus.pending, OrderStatus.failed]))
|
||||
.order_by(Order.created_at.desc())
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"order_id": order.id,
|
||||
"event_id": order.event_id,
|
||||
"status": order.status,
|
||||
"total_paise": order.total_paise,
|
||||
"provider_payment_id": order.provider_payment_id,
|
||||
"payment_reference": order.payment_reference,
|
||||
"paid_at": order.paid_at,
|
||||
}
|
||||
for order in orders
|
||||
]
|
||||
39
backend/app/services/alerts.py
Normal file
39
backend/app/services/alerts.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models import AlertDispatch, OutboundClick
|
||||
from app.services.email import send_email
|
||||
|
||||
TICKET_KINDS = ("bookmyshow", "district")
|
||||
|
||||
|
||||
def maybe_notify_click_spike(db: Session) -> None:
|
||||
if not settings.smtp_configured:
|
||||
return
|
||||
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
count = (
|
||||
db.query(OutboundClick)
|
||||
.filter(OutboundClick.created_at >= since, OutboundClick.link_kind.in_(TICKET_KINDS))
|
||||
.count()
|
||||
)
|
||||
if count < settings.alert_click_threshold:
|
||||
return
|
||||
|
||||
alert_key = f"click_spike_{date.today().isoformat()}"
|
||||
if db.query(AlertDispatch).filter(AlertDispatch.alert_key == alert_key).first():
|
||||
return
|
||||
|
||||
send_email(
|
||||
subject=f"Desi Clans — ticket click spike ({count} in 24h)",
|
||||
body=(
|
||||
f"Ticket outbound clicks in the last 24 hours: {count}\n"
|
||||
f"Threshold: {settings.alert_click_threshold}\n"
|
||||
f"City: Bengaluru\n"
|
||||
"Check admin analytics for details."
|
||||
),
|
||||
)
|
||||
db.add(AlertDispatch(alert_key=alert_key))
|
||||
db.commit()
|
||||
38
backend/app/services/calendar.py
Normal file
38
backend/app/services/calendar.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.models import EventEdition
|
||||
|
||||
|
||||
def _fmt_ics(dt: datetime) -> str:
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
|
||||
def build_ics(edition: EventEdition, page_url: str) -> str:
|
||||
start = edition.event_date or datetime.now(timezone.utc) + timedelta(days=7)
|
||||
end = start + timedelta(hours=4)
|
||||
uid = f"desi-clans-{edition.slug}@bengaluru"
|
||||
location = edition.venue_name or edition.city
|
||||
summary = edition.title.replace(",", "\\,")
|
||||
desc = (edition.meta_description or edition.description or edition.title).replace("\n", "\\n")
|
||||
|
||||
lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//Desi Clans//Bengaluru Events//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
"BEGIN:VEVENT",
|
||||
f"UID:{uid}",
|
||||
f"DTSTAMP:{_fmt_ics(datetime.now(timezone.utc))}",
|
||||
f"DTSTART:{_fmt_ics(start)}",
|
||||
f"DTEND:{_fmt_ics(end)}",
|
||||
f"SUMMARY:{summary}",
|
||||
f"DESCRIPTION:{desc} — {page_url}",
|
||||
f"LOCATION:{location}",
|
||||
f"URL:{page_url}",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
]
|
||||
return "\r\n".join(lines) + "\r\n"
|
||||
95
backend/app/services/catalog.py
Normal file
95
backend/app/services/catalog.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.models.catalog import Event, EventMedia, TicketType, Venue
|
||||
from app.models.enums import EventStatus, EventType
|
||||
from app.schemas.catalog import EventDetail, EventSummary, TicketTypePublic, VenuePublic
|
||||
|
||||
|
||||
def _ticket_type_public(ticket: TicketType) -> TicketTypePublic:
|
||||
return TicketTypePublic(
|
||||
id=ticket.id,
|
||||
name=ticket.name,
|
||||
description=ticket.description,
|
||||
price_paise=ticket.price_paise,
|
||||
status=ticket.status,
|
||||
sort_order=ticket.sort_order,
|
||||
remaining_quantity=max(ticket.quantity_total - ticket.quantity_sold, 0),
|
||||
)
|
||||
|
||||
|
||||
def list_events(
|
||||
db: Session,
|
||||
*,
|
||||
category: EventType | None,
|
||||
status: EventStatus | None,
|
||||
upcoming: bool,
|
||||
) -> list[Event]:
|
||||
query = (
|
||||
select(Event)
|
||||
.options(joinedload(Event.venue))
|
||||
.order_by(Event.starts_at.asc())
|
||||
)
|
||||
|
||||
if status is not None:
|
||||
query = query.where(Event.status == status)
|
||||
if category is not None:
|
||||
query = query.where(Event.category == category)
|
||||
if upcoming:
|
||||
now = datetime.now(timezone.utc)
|
||||
query = query.where(Event.starts_at >= now)
|
||||
|
||||
return list(db.scalars(query).unique().all())
|
||||
|
||||
|
||||
def get_event_by_slug(db: Session, slug: str) -> Event:
|
||||
event = db.scalar(
|
||||
select(Event)
|
||||
.options(
|
||||
joinedload(Event.venue),
|
||||
joinedload(Event.media),
|
||||
joinedload(Event.ticket_types),
|
||||
)
|
||||
.where(Event.slug == slug)
|
||||
)
|
||||
if not event:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event not found")
|
||||
return event
|
||||
|
||||
|
||||
def event_to_summary(event: Event) -> EventSummary:
|
||||
return EventSummary.model_validate(event)
|
||||
|
||||
|
||||
def event_to_detail(event: Event) -> EventDetail:
|
||||
return EventDetail(
|
||||
id=event.id,
|
||||
slug=event.slug,
|
||||
title=event.title,
|
||||
category=event.category,
|
||||
status=event.status,
|
||||
starts_at=event.starts_at,
|
||||
ends_at=event.ends_at,
|
||||
is_featured=event.is_featured,
|
||||
cover_image_url=event.cover_image_url,
|
||||
cover_image_alt=event.cover_image_alt,
|
||||
venue=VenuePublic.model_validate(event.venue),
|
||||
description=event.description,
|
||||
meta_description=event.meta_description,
|
||||
media=event.media,
|
||||
ticket_types=[_ticket_type_public(ticket) for ticket in event.ticket_types],
|
||||
)
|
||||
|
||||
|
||||
def list_venues(db: Session) -> list[Venue]:
|
||||
return list(db.scalars(select(Venue).order_by(Venue.name.asc())).all())
|
||||
|
||||
|
||||
def get_venue_by_id(db: Session, venue_id: int) -> Venue:
|
||||
venue = db.get(Venue, venue_id)
|
||||
if not venue:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Venue not found")
|
||||
return venue
|
||||
60
backend/app/services/editions.py
Normal file
60
backend/app/services/editions.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
from app.models import EventEdition, MediaAsset
|
||||
from app.schemas import EditionTicketsPublic, EventEditionAdmin, EventEditionPublic, MediaAssetPublic
|
||||
|
||||
|
||||
def tickets_for_edition(edition: EventEdition) -> EditionTicketsPublic:
|
||||
return EditionTicketsPublic(
|
||||
bookmyshow=edition.bookmyshow_url,
|
||||
district=edition.district_url,
|
||||
instagram=edition.instagram_url,
|
||||
)
|
||||
|
||||
|
||||
def edition_to_public(edition: EventEdition, include_unpublished_media: bool = False) -> EventEditionPublic:
|
||||
cover = None
|
||||
if edition.cover_image_path:
|
||||
from app.services.media import public_url_for_path, thumb_url_for_path
|
||||
|
||||
cover = public_url_for_path(edition.cover_image_path)
|
||||
|
||||
media: list[MediaAssetPublic] = []
|
||||
for m in edition.media:
|
||||
if not include_unpublished_media and not m.is_published:
|
||||
continue
|
||||
from app.services.media import public_url_for_path, thumb_url_for_path
|
||||
|
||||
media.append(
|
||||
MediaAssetPublic(
|
||||
id=m.id,
|
||||
url=public_url_for_path(m.file_path),
|
||||
thumb_url=thumb_url_for_path(m.file_path),
|
||||
caption=m.caption,
|
||||
sort_order=m.sort_order,
|
||||
)
|
||||
)
|
||||
|
||||
return EventEditionPublic(
|
||||
id=edition.id,
|
||||
title=edition.title,
|
||||
slug=edition.slug,
|
||||
event_type=edition.event_type,
|
||||
city=edition.city,
|
||||
venue_name=edition.venue_name,
|
||||
event_date=edition.event_date,
|
||||
description=edition.description,
|
||||
is_featured=edition.is_featured,
|
||||
cover_image_url=cover,
|
||||
cover_thumb_url=thumb_url_for_path(edition.cover_image_path) if edition.cover_image_path else None,
|
||||
tickets=tickets_for_edition(edition),
|
||||
meta_description=edition.meta_description,
|
||||
media=media,
|
||||
)
|
||||
|
||||
|
||||
def edition_to_admin(edition: EventEdition) -> EventEditionAdmin:
|
||||
base = edition_to_public(edition, include_unpublished_media=True)
|
||||
return EventEditionAdmin(
|
||||
**base.model_dump(),
|
||||
is_published=edition.is_published,
|
||||
sort_order=edition.sort_order,
|
||||
)
|
||||
21
backend/app/services/email.py
Normal file
21
backend/app/services/email.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def send_email(subject: str, body: str) -> None:
|
||||
if not settings.smtp_configured:
|
||||
return
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = settings.smtp_from or settings.smtp_user or "desi-clans@localhost"
|
||||
msg["To"] = settings.alert_notify_email
|
||||
msg.set_content(body)
|
||||
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=20) as server:
|
||||
if settings.smtp_use_tls:
|
||||
server.starttls()
|
||||
if settings.smtp_user:
|
||||
server.login(settings.smtp_user, settings.smtp_password)
|
||||
server.send_message(msg)
|
||||
11
backend/app/services/health.py
Normal file
11
backend/app/services/health.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
def check_database(db: Session) -> bool:
|
||||
try:
|
||||
db.execute(text("SELECT 1"))
|
||||
return True
|
||||
except SQLAlchemyError:
|
||||
return False
|
||||
70
backend/app/services/media.py
Normal file
70
backend/app/services/media.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import io
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import UploadFile
|
||||
from PIL import Image
|
||||
|
||||
from app.storage import get_storage
|
||||
|
||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
MAX_BYTES = 8 * 1024 * 1024
|
||||
|
||||
VARIANTS = {
|
||||
"lg": 2400,
|
||||
"md": 1200,
|
||||
"thumb": 400,
|
||||
}
|
||||
|
||||
|
||||
def public_url_for_path(file_path: str) -> str:
|
||||
normalized = file_path.replace("\\", "/").lstrip("/")
|
||||
return get_storage().public_url(normalized)
|
||||
|
||||
|
||||
def thumb_path_for(file_path: str) -> str | None:
|
||||
normalized = file_path.replace("\\", "/")
|
||||
if "_lg." in normalized:
|
||||
return re.sub(r"_lg\.(\w+)$", r"_thumb.\1", normalized)
|
||||
base, ext = normalized.rsplit(".", 1) if "." in normalized else (normalized, "webp")
|
||||
return f"{base}_thumb.{ext}"
|
||||
|
||||
|
||||
def thumb_url_for_path(file_path: str) -> str | None:
|
||||
thumb = thumb_path_for(file_path)
|
||||
if not thumb:
|
||||
return None
|
||||
return get_storage().public_url(thumb)
|
||||
|
||||
|
||||
def _encode_variant(img: Image.Image, max_edge: int) -> bytes:
|
||||
work = img.convert("RGB") if img.mode in ("RGBA", "P") else img
|
||||
w, h = work.size
|
||||
if max(w, h) > max_edge:
|
||||
ratio = max_edge / max(w, h)
|
||||
work = work.resize((int(w * ratio), int(h * ratio)), Image.Resampling.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
work.save(buf, format="WEBP", quality=85 if max_edge >= 1200 else 80)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def save_image(upload: UploadFile) -> str:
|
||||
suffix = Path(upload.filename or "").suffix.lower()
|
||||
if suffix not in ALLOWED_EXTENSIONS:
|
||||
raise ValueError("Only JPG, PNG, and WebP images are allowed")
|
||||
|
||||
raw = upload.file.read()
|
||||
if len(raw) > MAX_BYTES:
|
||||
raise ValueError("Image must be under 8 MB")
|
||||
|
||||
storage = get_storage()
|
||||
base = uuid.uuid4().hex
|
||||
primary_key = f"{base}_lg.webp"
|
||||
|
||||
with Image.open(io.BytesIO(raw)) as img:
|
||||
for variant, edge in VARIANTS.items():
|
||||
key = f"{base}_{variant}.webp"
|
||||
storage.save(key, _encode_variant(img, edge), "image/webp")
|
||||
|
||||
return primary_key
|
||||
335
backend/app/services/orders.py
Normal file
335
backend/app/services/orders.py
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import secrets
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.errors import AppError
|
||||
from app.models.catalog import Event, TicketType
|
||||
from app.models.enums import EventStatus, OrderStatus, TicketStatus, TicketTypeStatus
|
||||
from app.models.orders import Order, OrderItem, Ticket
|
||||
from app.models.user import User
|
||||
from app.schemas.orders import OrderCreate
|
||||
from app.services.payments import get_payment_gateway
|
||||
from app.services.tickets import generate_qr_token
|
||||
|
||||
|
||||
class InsufficientInventoryError(AppError):
|
||||
def __init__(self, message: str = "Not enough tickets available") -> None:
|
||||
super().__init__(message, status_code=409)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _remaining(ticket_type: TicketType) -> int:
|
||||
return max(ticket_type.quantity_total - ticket_type.quantity_sold, 0)
|
||||
|
||||
|
||||
def _ticket_type_public_row(ticket_type: TicketType) -> dict:
|
||||
return {
|
||||
"id": ticket_type.id,
|
||||
"event_id": ticket_type.event_id,
|
||||
"name": ticket_type.name,
|
||||
"description": ticket_type.description,
|
||||
"price_paise": ticket_type.price_paise,
|
||||
"status": ticket_type.status,
|
||||
"sort_order": ticket_type.sort_order,
|
||||
"remaining_quantity": _remaining(ticket_type),
|
||||
}
|
||||
|
||||
|
||||
def list_event_ticket_types(db: Session, event_id: int) -> list[dict]:
|
||||
event = db.get(Event, event_id)
|
||||
if not event or event.status != EventStatus.published:
|
||||
raise AppError("Event not found", status_code=404)
|
||||
|
||||
rows = db.scalars(
|
||||
select(TicketType)
|
||||
.where(TicketType.event_id == event_id)
|
||||
.order_by(TicketType.sort_order.asc(), TicketType.id.asc())
|
||||
).all()
|
||||
return [_ticket_type_public_row(row) for row in rows]
|
||||
|
||||
|
||||
def _generate_ticket_code() -> str:
|
||||
return f"DC-{secrets.token_hex(8).upper()}"
|
||||
|
||||
|
||||
def expire_stale_pending_orders(db: Session) -> None:
|
||||
settings = get_settings()
|
||||
cutoff = _utcnow()
|
||||
stale_orders = list(
|
||||
db.scalars(
|
||||
select(Order)
|
||||
.where(
|
||||
Order.status == OrderStatus.pending,
|
||||
Order.expires_at.is_not(None),
|
||||
Order.expires_at < cutoff,
|
||||
)
|
||||
.with_for_update()
|
||||
).all()
|
||||
)
|
||||
for order in stale_orders:
|
||||
cancel_pending_order(db, order, status=OrderStatus.failed)
|
||||
|
||||
|
||||
def get_order_by_provider_order_id(db: Session, provider_order_id: str) -> Order | None:
|
||||
return db.scalar(
|
||||
select(Order)
|
||||
.options(joinedload(Order.items))
|
||||
.where(Order.provider_order_id == provider_order_id)
|
||||
)
|
||||
|
||||
|
||||
def get_order_by_idempotency_key(db: Session, key: str) -> Order | None:
|
||||
return db.scalar(
|
||||
select(Order)
|
||||
.options(joinedload(Order.items))
|
||||
.where(Order.idempotency_key == key)
|
||||
)
|
||||
|
||||
|
||||
def _reserve_inventory(
|
||||
db: Session,
|
||||
body: OrderCreate,
|
||||
) -> tuple[int, list[tuple[TicketType, int, int]]]:
|
||||
line_map: dict[int, int] = defaultdict(int)
|
||||
for line in body.items:
|
||||
line_map[line.ticket_type_id] += line.quantity
|
||||
|
||||
if not line_map:
|
||||
raise AppError("Order must include at least one ticket", status_code=400)
|
||||
|
||||
ticket_type_ids = sorted(line_map.keys())
|
||||
locked_types = list(
|
||||
db.scalars(
|
||||
select(TicketType)
|
||||
.where(TicketType.id.in_(ticket_type_ids))
|
||||
.order_by(TicketType.id.asc())
|
||||
.with_for_update()
|
||||
).all()
|
||||
)
|
||||
|
||||
if len(locked_types) != len(ticket_type_ids):
|
||||
raise AppError("One or more ticket types are invalid", status_code=400)
|
||||
|
||||
total_paise = 0
|
||||
priced_lines: list[tuple[TicketType, int, int]] = []
|
||||
|
||||
for ticket_type in locked_types:
|
||||
qty = line_map[ticket_type.id]
|
||||
if ticket_type.event_id != body.event_id:
|
||||
raise AppError("Ticket type does not belong to this event", status_code=400)
|
||||
if ticket_type.status != TicketTypeStatus.active:
|
||||
raise InsufficientInventoryError(f"{ticket_type.name} is not available")
|
||||
remaining = _remaining(ticket_type)
|
||||
if qty > remaining:
|
||||
raise InsufficientInventoryError(
|
||||
f"Only {remaining} tickets left for {ticket_type.name}",
|
||||
)
|
||||
line_total = ticket_type.price_paise * qty
|
||||
total_paise += line_total
|
||||
priced_lines.append((ticket_type, qty, line_total))
|
||||
|
||||
return total_paise, priced_lines
|
||||
|
||||
|
||||
def _apply_inventory_reservation(priced_lines: list[tuple[TicketType, int, int]]) -> None:
|
||||
for ticket_type, qty, _line_total in priced_lines:
|
||||
ticket_type.quantity_sold += qty
|
||||
if ticket_type.quantity_sold >= ticket_type.quantity_total:
|
||||
ticket_type.quantity_sold = min(ticket_type.quantity_sold, ticket_type.quantity_total)
|
||||
ticket_type.status = TicketTypeStatus.sold_out
|
||||
|
||||
|
||||
def _release_inventory_for_order(db: Session, order: Order) -> None:
|
||||
for item in order.items:
|
||||
ticket_type = db.get(TicketType, item.ticket_type_id)
|
||||
if not ticket_type:
|
||||
continue
|
||||
ticket_type.quantity_sold = max(ticket_type.quantity_sold - item.quantity, 0)
|
||||
if ticket_type.status == TicketTypeStatus.sold_out and _remaining(ticket_type) > 0:
|
||||
ticket_type.status = TicketTypeStatus.active
|
||||
|
||||
|
||||
def create_order(
|
||||
db: Session,
|
||||
user: User,
|
||||
body: OrderCreate,
|
||||
*,
|
||||
idempotency_key: str | None = None,
|
||||
) -> Order:
|
||||
expire_stale_pending_orders(db)
|
||||
|
||||
if idempotency_key:
|
||||
existing = get_order_by_idempotency_key(db, idempotency_key)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
event = db.get(Event, body.event_id)
|
||||
if not event or event.status != EventStatus.published:
|
||||
raise AppError("Event not found or not available for purchase", status_code=404)
|
||||
|
||||
total_paise, priced_lines = _reserve_inventory(db, body)
|
||||
|
||||
settings = get_settings()
|
||||
gateway = get_payment_gateway(settings)
|
||||
|
||||
order = Order(
|
||||
user_id=user.id,
|
||||
event_id=body.event_id,
|
||||
status=OrderStatus.pending,
|
||||
total_paise=total_paise,
|
||||
currency="INR",
|
||||
idempotency_key=idempotency_key,
|
||||
expires_at=_utcnow() + timedelta(minutes=settings.order_payment_timeout_minutes),
|
||||
)
|
||||
db.add(order)
|
||||
db.flush()
|
||||
|
||||
for ticket_type, qty, line_total in priced_lines:
|
||||
db.add(
|
||||
OrderItem(
|
||||
order_id=order.id,
|
||||
ticket_type_id=ticket_type.id,
|
||||
quantity=qty,
|
||||
unit_price_paise=ticket_type.price_paise,
|
||||
line_total_paise=line_total,
|
||||
)
|
||||
)
|
||||
|
||||
_apply_inventory_reservation(priced_lines)
|
||||
|
||||
provider_order = gateway.create_provider_order(
|
||||
amount_paise=total_paise,
|
||||
currency="INR",
|
||||
receipt=str(order.id),
|
||||
notes={"desi_clans_order_id": str(order.id), "user_id": str(user.id)},
|
||||
)
|
||||
order.provider_order_id = provider_order.provider_order_id
|
||||
order.payment_reference = provider_order.provider_order_id
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
db.refresh(order)
|
||||
_ = order.items
|
||||
return order
|
||||
|
||||
|
||||
def fulfill_order_payment(db: Session, order: Order, *, provider_payment_id: str) -> Order:
|
||||
if order.status == OrderStatus.paid:
|
||||
return order
|
||||
|
||||
existing_ticket = db.scalar(select(Ticket).where(Ticket.order_id == order.id).limit(1))
|
||||
if not existing_ticket:
|
||||
items = db.scalars(select(OrderItem).where(OrderItem.order_id == order.id)).all()
|
||||
issued: list[Ticket] = []
|
||||
for item in items:
|
||||
for _ in range(item.quantity):
|
||||
issued.append(
|
||||
Ticket(
|
||||
code=_generate_ticket_code(),
|
||||
qr_token=generate_qr_token(),
|
||||
order_id=order.id,
|
||||
order_item_id=item.id,
|
||||
ticket_type_id=item.ticket_type_id,
|
||||
event_id=order.event_id,
|
||||
user_id=order.user_id,
|
||||
status=TicketStatus.valid,
|
||||
)
|
||||
)
|
||||
db.add_all(issued)
|
||||
|
||||
order.status = OrderStatus.paid
|
||||
order.provider_payment_id = provider_payment_id
|
||||
order.paid_at = _utcnow()
|
||||
order.expires_at = None
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
_ = order.items
|
||||
return order
|
||||
|
||||
|
||||
def cancel_pending_order(
|
||||
db: Session,
|
||||
order: Order,
|
||||
*,
|
||||
status: OrderStatus = OrderStatus.cancelled,
|
||||
) -> Order:
|
||||
if order.status != OrderStatus.pending:
|
||||
raise AppError("Only pending orders can be cancelled", status_code=409)
|
||||
_release_inventory_for_order(db, order)
|
||||
order.status = status
|
||||
order.expires_at = None
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return order
|
||||
|
||||
|
||||
def get_order_for_user(db: Session, order_id: int, user_id: int) -> Order:
|
||||
expire_stale_pending_orders(db)
|
||||
order = db.scalar(
|
||||
select(Order)
|
||||
.options(joinedload(Order.items))
|
||||
.where(Order.id == order_id, Order.user_id == user_id)
|
||||
)
|
||||
if not order:
|
||||
raise AppError("Order not found", status_code=404)
|
||||
return order
|
||||
|
||||
|
||||
def list_user_tickets(db: Session, user_id: int) -> list[Ticket]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(Ticket)
|
||||
.options(
|
||||
joinedload(Ticket.order_item).joinedload(OrderItem.ticket_type),
|
||||
joinedload(Ticket.order).joinedload(Order.event),
|
||||
)
|
||||
.where(Ticket.user_id == user_id)
|
||||
.order_by(Ticket.created_at.desc())
|
||||
)
|
||||
.unique()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def get_ticket_for_user(db: Session, ticket_id: int, user_id: int) -> Ticket:
|
||||
ticket = db.scalar(
|
||||
select(Ticket)
|
||||
.options(
|
||||
joinedload(Ticket.order_item).joinedload(OrderItem.ticket_type),
|
||||
joinedload(Ticket.order).joinedload(Order.event),
|
||||
)
|
||||
.where(Ticket.id == ticket_id, Ticket.user_id == user_id)
|
||||
)
|
||||
if not ticket:
|
||||
raise AppError("Ticket not found", status_code=404)
|
||||
return ticket
|
||||
|
||||
|
||||
def ticket_to_public(ticket: Ticket) -> dict:
|
||||
ticket_type_name = None
|
||||
event_title = None
|
||||
if ticket.order_item and ticket.order_item.ticket_type:
|
||||
ticket_type_name = ticket.order_item.ticket_type.name
|
||||
if ticket.order and ticket.order.event:
|
||||
event_title = ticket.order.event.title
|
||||
return {
|
||||
"id": ticket.id,
|
||||
"code": ticket.code,
|
||||
"order_id": ticket.order_id,
|
||||
"ticket_type_id": ticket.ticket_type_id,
|
||||
"event_id": ticket.event_id,
|
||||
"status": ticket.status,
|
||||
"created_at": ticket.created_at,
|
||||
"ticket_type_name": ticket_type_name,
|
||||
"event_title": event_title,
|
||||
}
|
||||
10
backend/app/services/outbound.py
Normal file
10
backend/app/services/outbound.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from urllib.parse import urlparse
|
||||
|
||||
ALLOWED_SCHEMES = {"http", "https"}
|
||||
|
||||
|
||||
def validate_outbound_url(url: str) -> str:
|
||||
parsed = urlparse(url.strip())
|
||||
if parsed.scheme not in ALLOWED_SCHEMES or not parsed.netloc:
|
||||
raise ValueError("Only http and https URLs are allowed")
|
||||
return url.strip()
|
||||
3
backend/app/services/payments/__init__.py
Normal file
3
backend/app/services/payments/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from app.services.payments.gateway import get_payment_gateway
|
||||
|
||||
__all__ = ["get_payment_gateway"]
|
||||
220
backend/app/services/payments/gateway.py
Normal file
220
backend/app/services/payments/gateway.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderOrder:
|
||||
provider_order_id: str
|
||||
amount_paise: int
|
||||
currency: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PaymentVerification:
|
||||
provider_order_id: str
|
||||
provider_payment_id: str
|
||||
amount_paise: int
|
||||
currency: str
|
||||
|
||||
|
||||
class PaymentGateway(ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def provider_name(self) -> str: ...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def public_key_id(self) -> str: ...
|
||||
|
||||
@abstractmethod
|
||||
def create_provider_order(
|
||||
self,
|
||||
*,
|
||||
amount_paise: int,
|
||||
currency: str,
|
||||
receipt: str,
|
||||
notes: dict[str, str],
|
||||
) -> ProviderOrder: ...
|
||||
|
||||
@abstractmethod
|
||||
def verify_checkout_signature(
|
||||
self,
|
||||
*,
|
||||
provider_order_id: str,
|
||||
provider_payment_id: str,
|
||||
signature: str,
|
||||
) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def verify_webhook_signature(self, body: bytes, signature: str | None) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def parse_webhook_payment(self, payload: dict) -> PaymentVerification | None: ...
|
||||
|
||||
|
||||
class MockPaymentGateway(PaymentGateway):
|
||||
"""Local/test gateway — signatures derived server-side only (never exposed to clients)."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
self._secret = settings.payment_mock_secret
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "mock"
|
||||
|
||||
@property
|
||||
def public_key_id(self) -> str:
|
||||
return "mock_public_key"
|
||||
|
||||
def create_provider_order(
|
||||
self,
|
||||
*,
|
||||
amount_paise: int,
|
||||
currency: str,
|
||||
receipt: str,
|
||||
notes: dict[str, str],
|
||||
) -> ProviderOrder:
|
||||
token = secrets.token_hex(6)
|
||||
return ProviderOrder(
|
||||
provider_order_id=f"mock_order_{receipt}_{token}",
|
||||
amount_paise=amount_paise,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
def _expected_signature(self, provider_order_id: str, provider_payment_id: str) -> str:
|
||||
message = f"{provider_order_id}|{provider_payment_id}".encode()
|
||||
return hmac.new(self._secret.encode(), message, hashlib.sha256).hexdigest()
|
||||
|
||||
def verify_checkout_signature(
|
||||
self,
|
||||
*,
|
||||
provider_order_id: str,
|
||||
provider_payment_id: str,
|
||||
signature: str,
|
||||
) -> None:
|
||||
expected = self._expected_signature(provider_order_id, provider_payment_id)
|
||||
if not hmac.compare_digest(expected, signature):
|
||||
raise ValueError("Invalid payment signature")
|
||||
|
||||
def verify_webhook_signature(self, body: bytes, signature: str | None) -> None:
|
||||
if not signature:
|
||||
raise ValueError("Missing webhook signature")
|
||||
expected = hmac.new(self._secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected, signature):
|
||||
raise ValueError("Invalid webhook signature")
|
||||
|
||||
def parse_webhook_payment(self, payload: dict) -> PaymentVerification | None:
|
||||
event = payload.get("event")
|
||||
if event != "payment.captured":
|
||||
return None
|
||||
entity = payload.get("payload", {}).get("payment", {}).get("entity", {})
|
||||
if not entity:
|
||||
return None
|
||||
return PaymentVerification(
|
||||
provider_order_id=entity.get("order_id", ""),
|
||||
provider_payment_id=entity.get("id", ""),
|
||||
amount_paise=int(entity.get("amount", 0)),
|
||||
currency=entity.get("currency", "INR"),
|
||||
)
|
||||
|
||||
def build_mock_checkout_signature(self, provider_order_id: str, provider_payment_id: str) -> str:
|
||||
return self._expected_signature(provider_order_id, provider_payment_id)
|
||||
|
||||
|
||||
class RazorpayPaymentGateway(PaymentGateway):
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
if not settings.razorpay_key_id or not settings.razorpay_key_secret:
|
||||
raise RuntimeError("Razorpay credentials are not configured")
|
||||
import razorpay
|
||||
|
||||
self._client = razorpay.Client(auth=(settings.razorpay_key_id, settings.razorpay_key_secret))
|
||||
self._key_id = settings.razorpay_key_id
|
||||
self._webhook_secret = settings.razorpay_webhook_secret or ""
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "razorpay"
|
||||
|
||||
@property
|
||||
def public_key_id(self) -> str:
|
||||
return self._key_id
|
||||
|
||||
def create_provider_order(
|
||||
self,
|
||||
*,
|
||||
amount_paise: int,
|
||||
currency: str,
|
||||
receipt: str,
|
||||
notes: dict[str, str],
|
||||
) -> ProviderOrder:
|
||||
data = self._client.order.create(
|
||||
{
|
||||
"amount": amount_paise,
|
||||
"currency": currency,
|
||||
"receipt": receipt,
|
||||
"notes": notes,
|
||||
}
|
||||
)
|
||||
return ProviderOrder(
|
||||
provider_order_id=data["id"],
|
||||
amount_paise=int(data["amount"]),
|
||||
currency=data["currency"],
|
||||
)
|
||||
|
||||
def verify_checkout_signature(
|
||||
self,
|
||||
*,
|
||||
provider_order_id: str,
|
||||
provider_payment_id: str,
|
||||
signature: str,
|
||||
) -> None:
|
||||
self._client.utility.verify_payment_signature(
|
||||
{
|
||||
"razorpay_order_id": provider_order_id,
|
||||
"razorpay_payment_id": provider_payment_id,
|
||||
"razorpay_signature": signature,
|
||||
}
|
||||
)
|
||||
|
||||
def verify_webhook_signature(self, body: bytes, signature: str | None) -> None:
|
||||
if not self._webhook_secret:
|
||||
raise RuntimeError("RAZORPAY_WEBHOOK_SECRET is not configured")
|
||||
if not signature:
|
||||
raise ValueError("Missing webhook signature")
|
||||
self._client.utility.verify_webhook_signature(body.decode(), signature, self._webhook_secret)
|
||||
|
||||
def parse_webhook_payment(self, payload: dict) -> PaymentVerification | None:
|
||||
event = payload.get("event")
|
||||
if event not in ("payment.captured", "order.paid"):
|
||||
return None
|
||||
payment_entity = payload.get("payload", {}).get("payment", {}).get("entity")
|
||||
if not payment_entity and event == "order.paid":
|
||||
payment_entity = payload.get("payload", {}).get("order", {}).get("entity")
|
||||
if not payment_entity:
|
||||
return None
|
||||
order_id = payment_entity.get("order_id") or payment_entity.get("id")
|
||||
payment_id = payment_entity.get("id") if event == "payment.captured" else payment_entity.get("payment_id")
|
||||
if not order_id or not payment_id:
|
||||
return None
|
||||
return PaymentVerification(
|
||||
provider_order_id=order_id,
|
||||
provider_payment_id=payment_id,
|
||||
amount_paise=int(payment_entity.get("amount", 0)),
|
||||
currency=payment_entity.get("currency", "INR"),
|
||||
)
|
||||
|
||||
|
||||
def get_payment_gateway(settings: Settings | None = None) -> PaymentGateway:
|
||||
settings = settings or get_settings()
|
||||
mode = settings.payment_provider.lower()
|
||||
if mode == "razorpay":
|
||||
return RazorpayPaymentGateway(settings)
|
||||
return MockPaymentGateway(settings)
|
||||
203
backend/app/services/payments/service.py
Normal file
203
backend/app/services/payments/service.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import json
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.errors import AppError
|
||||
from app.models.enums import OrderStatus
|
||||
from app.models.orders import Order
|
||||
from app.models.payments import PaymentWebhookEvent
|
||||
from app.services.orders import fulfill_order_payment, get_order_by_provider_order_id
|
||||
from app.schemas.orders import PaymentCheckoutPublic
|
||||
from app.services.payments.gateway import PaymentGateway, PaymentVerification, get_payment_gateway
|
||||
|
||||
|
||||
class PaymentError(AppError):
|
||||
pass
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _record_webhook_event(
|
||||
db: Session,
|
||||
*,
|
||||
provider: str,
|
||||
provider_event_id: str,
|
||||
event_type: str,
|
||||
payload: dict,
|
||||
) -> bool:
|
||||
"""Return False if event was already processed (idempotent)."""
|
||||
existing = db.scalar(
|
||||
select(PaymentWebhookEvent).where(PaymentWebhookEvent.provider_event_id == provider_event_id)
|
||||
)
|
||||
if existing:
|
||||
return False
|
||||
db.add(
|
||||
PaymentWebhookEvent(
|
||||
provider=provider,
|
||||
provider_event_id=provider_event_id,
|
||||
event_type=event_type,
|
||||
payload=json.dumps(payload),
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
return True
|
||||
|
||||
|
||||
def confirm_payment_for_order(
|
||||
db: Session,
|
||||
order: Order,
|
||||
verification: PaymentVerification,
|
||||
*,
|
||||
provider_payment_id: str,
|
||||
) -> Order:
|
||||
if order.status == OrderStatus.paid:
|
||||
return order
|
||||
if order.status not in (OrderStatus.pending,):
|
||||
raise PaymentError("Order is not awaiting payment", status_code=409)
|
||||
|
||||
if order.provider_order_id and verification.provider_order_id != order.provider_order_id:
|
||||
raise PaymentError("Payment order mismatch", status_code=400)
|
||||
if verification.amount_paise != order.total_paise:
|
||||
raise PaymentError("Payment amount mismatch", status_code=400)
|
||||
if verification.currency.upper() != order.currency.upper():
|
||||
raise PaymentError("Payment currency mismatch", status_code=400)
|
||||
|
||||
return fulfill_order_payment(db, order, provider_payment_id=provider_payment_id)
|
||||
|
||||
|
||||
def verify_checkout_and_confirm(
|
||||
db: Session,
|
||||
*,
|
||||
order: Order,
|
||||
provider_order_id: str,
|
||||
provider_payment_id: str,
|
||||
signature: str,
|
||||
gateway: PaymentGateway | None = None,
|
||||
) -> Order:
|
||||
gateway = gateway or get_payment_gateway()
|
||||
if order.provider_order_id != provider_order_id:
|
||||
raise PaymentError("Invalid payment order", status_code=400)
|
||||
try:
|
||||
gateway.verify_checkout_signature(
|
||||
provider_order_id=provider_order_id,
|
||||
provider_payment_id=provider_payment_id,
|
||||
signature=signature,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise PaymentError("Payment verification failed", status_code=400) from exc
|
||||
|
||||
verification = PaymentVerification(
|
||||
provider_order_id=provider_order_id,
|
||||
provider_payment_id=provider_payment_id,
|
||||
amount_paise=order.total_paise,
|
||||
currency=order.currency,
|
||||
)
|
||||
return confirm_payment_for_order(db, order, verification, provider_payment_id=provider_payment_id)
|
||||
|
||||
|
||||
def process_webhook(
|
||||
db: Session,
|
||||
*,
|
||||
body: bytes,
|
||||
signature: str | None,
|
||||
gateway: PaymentGateway | None = None,
|
||||
) -> dict:
|
||||
gateway = gateway or get_payment_gateway()
|
||||
try:
|
||||
gateway.verify_webhook_signature(body, signature)
|
||||
except Exception as exc:
|
||||
raise PaymentError("Invalid webhook signature", status_code=400) from exc
|
||||
|
||||
payload = json.loads(body.decode())
|
||||
event_type = payload.get("event", "unknown")
|
||||
provider_event_id = payload.get("id") or payload.get("event_id") or secrets.token_hex(8)
|
||||
|
||||
if not _record_webhook_event(
|
||||
db,
|
||||
provider=gateway.provider_name,
|
||||
provider_event_id=str(provider_event_id),
|
||||
event_type=event_type,
|
||||
payload=payload,
|
||||
):
|
||||
db.commit()
|
||||
return {"status": "duplicate_ignored"}
|
||||
|
||||
if event_type == "payment.failed":
|
||||
order = _order_from_webhook_payload(db, payload)
|
||||
if order and order.status == OrderStatus.pending:
|
||||
from app.services.orders import cancel_pending_order
|
||||
|
||||
cancel_pending_order(db, order, status=OrderStatus.failed)
|
||||
db.commit()
|
||||
return {"status": "payment_failed_handled"}
|
||||
|
||||
verification = gateway.parse_webhook_payment(payload)
|
||||
if verification is None:
|
||||
db.commit()
|
||||
return {"status": "ignored"}
|
||||
|
||||
order = get_order_by_provider_order_id(db, verification.provider_order_id)
|
||||
if not order:
|
||||
db.commit()
|
||||
return {"status": "order_not_found"}
|
||||
|
||||
confirm_payment_for_order(
|
||||
db,
|
||||
order,
|
||||
verification,
|
||||
provider_payment_id=verification.provider_payment_id,
|
||||
)
|
||||
db.commit()
|
||||
return {"status": "paid"}
|
||||
|
||||
|
||||
def _order_from_webhook_payload(db: Session, payload: dict) -> Order | None:
|
||||
entity = payload.get("payload", {}).get("payment", {}).get("entity", {})
|
||||
provider_order_id = entity.get("order_id")
|
||||
if not provider_order_id:
|
||||
return None
|
||||
return get_order_by_provider_order_id(db, provider_order_id)
|
||||
|
||||
|
||||
def build_checkout_payload(order: Order, gateway: PaymentGateway | None = None) -> PaymentCheckoutPublic:
|
||||
gateway = gateway or get_payment_gateway()
|
||||
settings = get_settings()
|
||||
return PaymentCheckoutPublic(
|
||||
provider=gateway.provider_name,
|
||||
key_id=gateway.public_key_id,
|
||||
provider_order_id=order.provider_order_id or "",
|
||||
amount_paise=order.total_paise,
|
||||
currency=order.currency,
|
||||
order_id=order.id,
|
||||
expires_at=order.expires_at,
|
||||
timeout_minutes=settings.order_payment_timeout_minutes,
|
||||
)
|
||||
|
||||
|
||||
def mock_complete_pending_order(db: Session, order: Order) -> Order:
|
||||
settings = get_settings()
|
||||
if settings.payment_provider.lower() != "mock":
|
||||
raise PaymentError("Mock completion is not enabled", status_code=404)
|
||||
gateway = get_payment_gateway(settings)
|
||||
from app.services.payments.gateway import MockPaymentGateway
|
||||
|
||||
if not isinstance(gateway, MockPaymentGateway):
|
||||
raise PaymentError("Mock gateway required", status_code=404)
|
||||
|
||||
provider_order_id = order.provider_order_id or ""
|
||||
provider_payment_id = f"mock_pay_{order.id}"
|
||||
signature = gateway.build_mock_checkout_signature(provider_order_id, provider_payment_id)
|
||||
return verify_checkout_and_confirm(
|
||||
db,
|
||||
order=order,
|
||||
provider_order_id=provider_order_id,
|
||||
provider_payment_id=provider_payment_id,
|
||||
signature=signature,
|
||||
gateway=gateway,
|
||||
)
|
||||
16
backend/app/services/qr.py
Normal file
16
backend/app/services/qr.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import base64
|
||||
import io
|
||||
import json
|
||||
|
||||
import qrcode
|
||||
|
||||
|
||||
def build_qr_payload(qr_token: str) -> str:
|
||||
return json.dumps({"v": 1, "token": qr_token}, separators=(",", ":"))
|
||||
|
||||
|
||||
def qr_png_base64(data: str) -> str:
|
||||
image = qrcode.make(data, box_size=8, border=2)
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
return base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||
37
backend/app/services/share_preview.py
Normal file
37
backend/app/services/share_preview.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from html import escape
|
||||
|
||||
from app.config import settings
|
||||
from app.schemas import EventEditionPublic
|
||||
|
||||
|
||||
def render_share_html(public: EventEditionPublic, slug: str) -> str:
|
||||
title = escape(public.title)
|
||||
desc = escape(
|
||||
public.meta_description
|
||||
or public.description
|
||||
or f"{public.title} — Desi Clans, Bengaluru"
|
||||
)
|
||||
page_url = f"{settings.public_site_url.rstrip('/')}/events/{slug}"
|
||||
image = public.cover_image_url or (public.media[0].url if public.media else "")
|
||||
if image and image.startswith("/"):
|
||||
image = f"{settings.public_api_url.rstrip('/')}{image}"
|
||||
image_tag = f'<meta property="og:image" content="{escape(image)}" />' if image else ""
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>{title} | Desi Clans</title>
|
||||
<meta name="description" content="{desc}" />
|
||||
<meta property="og:title" content="{title}" />
|
||||
<meta property="og:description" content="{desc}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="{escape(page_url)}" />
|
||||
{image_tag}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta http-equiv="refresh" content="0;url={escape(page_url)}" />
|
||||
</head>
|
||||
<body>
|
||||
<p><a href="{escape(page_url)}">{title}</a> — Bengaluru</p>
|
||||
</body>
|
||||
</html>"""
|
||||
142
backend/app/services/tickets.py
Normal file
142
backend/app/services/tickets.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import secrets
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.errors import AppError
|
||||
from app.models.catalog import Event
|
||||
from app.models.orders import Order, OrderItem, Ticket
|
||||
from app.models.enums import TicketStatus
|
||||
from app.models.user import User
|
||||
from app.services.qr import build_qr_payload, qr_png_base64
|
||||
|
||||
|
||||
def generate_qr_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _load_ticket_detail(db: Session, ticket_id: int, user_id: int | None = None) -> Ticket:
|
||||
query = (
|
||||
select(Ticket)
|
||||
.options(
|
||||
joinedload(Ticket.order_item).joinedload(OrderItem.ticket_type),
|
||||
joinedload(Ticket.order).joinedload(Order.event).joinedload(Event.venue),
|
||||
)
|
||||
.where(Ticket.id == ticket_id)
|
||||
)
|
||||
if user_id is not None:
|
||||
query = query.where(Ticket.user_id == user_id)
|
||||
ticket = db.scalar(query)
|
||||
if not ticket:
|
||||
raise AppError("Ticket not found", status_code=404)
|
||||
if ticket.order and ticket.order.status.value != "paid":
|
||||
raise AppError("Ticket not available", status_code=404)
|
||||
return ticket
|
||||
|
||||
|
||||
def ticket_to_detail(db: Session, ticket: Ticket) -> dict:
|
||||
if not ticket.qr_token:
|
||||
ticket.qr_token = generate_qr_token()
|
||||
db.commit()
|
||||
db.refresh(ticket)
|
||||
|
||||
event = ticket.order.event if ticket.order else None
|
||||
venue = event.venue if event else None
|
||||
user = db.get(User, ticket.user_id)
|
||||
ticket_type = ticket.order_item.ticket_type if ticket.order_item else None
|
||||
|
||||
payload = build_qr_payload(ticket.qr_token)
|
||||
|
||||
return {
|
||||
"id": ticket.id,
|
||||
"code": ticket.code,
|
||||
"status": ticket.status,
|
||||
"order_id": ticket.order_id,
|
||||
"event_id": ticket.event_id,
|
||||
"event_title": event.title if event else "Event",
|
||||
"event_starts_at": event.starts_at if event else ticket.created_at,
|
||||
"event_ends_at": event.ends_at if event else None,
|
||||
"venue_name": venue.name if venue else "Bengaluru",
|
||||
"venue_area": venue.area if venue else "",
|
||||
"venue_city": venue.city if venue else "Bengaluru",
|
||||
"attendee_name": (user.full_name if user and user.full_name else user.email if user else "Guest"),
|
||||
"attendee_email": user.email if user else "",
|
||||
"ticket_type_name": ticket_type.name if ticket_type else "General",
|
||||
"ticket_type_id": ticket.ticket_type_id,
|
||||
"qr_token": ticket.qr_token,
|
||||
"qr_payload": payload,
|
||||
"qr_image_base64": qr_png_base64(payload),
|
||||
"used_at": ticket.used_at,
|
||||
"created_at": ticket.created_at,
|
||||
}
|
||||
|
||||
|
||||
def get_ticket_detail_for_user(db: Session, ticket_id: int, user_id: int) -> dict:
|
||||
ticket = _load_ticket_detail(db, ticket_id, user_id=user_id)
|
||||
return ticket_to_detail(db, ticket)
|
||||
|
||||
|
||||
def verify_ticket_at_gate(db: Session, qr_token: str) -> dict:
|
||||
ticket = db.scalar(select(Ticket).where(Ticket.qr_token == qr_token).with_for_update())
|
||||
if not ticket:
|
||||
raise AppError("Invalid ticket", status_code=404)
|
||||
|
||||
order = db.get(Order, ticket.order_id)
|
||||
event = db.get(Event, ticket.event_id) if ticket.event_id else None
|
||||
event_title = event.title if event else "Event"
|
||||
|
||||
if ticket.status == TicketStatus.used:
|
||||
return {
|
||||
"valid": False,
|
||||
"status": ticket.status,
|
||||
"ticket_id": ticket.id,
|
||||
"ticket_code": ticket.code,
|
||||
"event_title": event_title,
|
||||
"message": "Ticket already used — QR cannot be reused",
|
||||
}
|
||||
|
||||
if ticket.status == TicketStatus.cancelled:
|
||||
return {
|
||||
"valid": False,
|
||||
"status": ticket.status,
|
||||
"ticket_id": ticket.id,
|
||||
"ticket_code": ticket.code,
|
||||
"event_title": event_title,
|
||||
"message": "Ticket cancelled",
|
||||
}
|
||||
|
||||
if order is None or order.status.value != "paid":
|
||||
return {
|
||||
"valid": False,
|
||||
"status": ticket.status,
|
||||
"ticket_id": ticket.id,
|
||||
"ticket_code": ticket.code,
|
||||
"event_title": event_title,
|
||||
"message": "Order not paid",
|
||||
}
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ticket.status = TicketStatus.used
|
||||
ticket.used_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"status": TicketStatus.used,
|
||||
"ticket_id": ticket.id,
|
||||
"ticket_code": ticket.code,
|
||||
"event_title": event_title,
|
||||
"message": "Entry verified",
|
||||
}
|
||||
|
||||
|
||||
def assert_checkin_authorized(settings: Settings, api_key: str | None) -> None:
|
||||
expected = settings.ticket_verify_api_key
|
||||
if settings.environment.lower() == "development" and not expected:
|
||||
return
|
||||
if not expected or not api_key:
|
||||
raise AppError("Unauthorized", status_code=401)
|
||||
if not secrets.compare_digest(api_key, expected):
|
||||
raise AppError("Unauthorized", status_code=401)
|
||||
41
backend/app/services/users.py
Normal file
41
backend/app/services/users.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import hash_password, verify_password
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import RegisterRequest
|
||||
|
||||
|
||||
class EmailAlreadyRegisteredError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_user_by_email(db: Session, email: str) -> User | None:
|
||||
return db.query(User).filter(User.email == email).first()
|
||||
|
||||
|
||||
def get_user_by_id(db: Session, user_id: int) -> User | None:
|
||||
return db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
|
||||
def register_user(db: Session, body: RegisterRequest) -> User:
|
||||
if get_user_by_email(db, body.email):
|
||||
raise EmailAlreadyRegisteredError()
|
||||
user = User(
|
||||
email=body.email,
|
||||
hashed_password=hash_password(body.password),
|
||||
full_name=body.full_name,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def authenticate_user(db: Session, email: str, password: str) -> User | None:
|
||||
user = get_user_by_email(db, email)
|
||||
if not user or not user.is_active:
|
||||
return None
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
return user
|
||||
5
backend/app/storage.py
Normal file
5
backend/app/storage.py
Normal 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"]
|
||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
74
backend/app/utils/storage.py
Normal file
74
backend/app/utils/storage.py
Normal 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
19
backend/requirements.txt
Normal 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
|
||||
|
||||
20
backend/scripts/migrate.py
Normal file
20
backend/scripts/migrate.py
Normal 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
55
backend/scripts/seed.py
Normal 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()
|
||||
40
backend/scripts/seed_admin.py
Normal file
40
backend/scripts/seed_admin.py
Normal 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()
|
||||
187
backend/scripts/seed_catalog.py
Normal file
187
backend/scripts/seed_catalog.py
Normal 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()
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
4
backend/tests/conftest.py
Normal file
4
backend/tests/conftest.py
Normal 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
56
backend/tests/helpers.py
Normal 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()
|
||||
50
backend/tests/test_admin_api.py
Normal file
50
backend/tests/test_admin_api.py
Normal 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
|
||||
95
backend/tests/test_auth.py
Normal file
95
backend/tests/test_auth.py
Normal 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()
|
||||
64
backend/tests/test_catalog_api.py
Normal file
64
backend/tests/test_catalog_api.py
Normal 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
|
||||
61
backend/tests/test_digital_tickets.py
Normal file
61
backend/tests/test_digital_tickets.py
Normal 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()
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue