first commit
This commit is contained in:
commit
34ea15a54d
246 changed files with 18660 additions and 0 deletions
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue