Full app rebuild: FastAPI backend + React Native mobile with auth, championships, admin
Backend (FastAPI + SQLAlchemy + SQLite): - JWT auth with access/refresh tokens, bcrypt password hashing - User model with member/organizer/admin roles, auto-approve members - Championship, Registration, ParticipantList, Notification models - Alembic async migrations, seed data with test users - Registration endpoint returns tokens for members, pending for organizers - /registrations/my returns championship title/date/location via eager loading - Admin endpoints: list users, approve/reject organizers Mobile (React Native + Expo + TypeScript): - Zustand auth store, Axios client with token refresh interceptor - Role-based registration (Member vs Organizer) with contextual form labels - Tab navigation with Ionicons, safe area headers, admin tab for admin role - Championships list with status badges, detail screen with registration progress - My Registrations with championship title, progress bar, and tap-to-navigate - Admin panel with pending/all filter, approve/reject with confirmation - Profile screen with role badge, Ionicons info rows, sign out - Password visibility toggle (Ionicons), keyboard flow hints (returnKeyType) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
28
backend/app/config.py
Normal file
28
backend/app/config.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
# Default: SQLite for local dev. Set DATABASE_URL=postgresql+asyncpg://... for production.
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./poledance.db"
|
||||
|
||||
SECRET_KEY: str = "dev-secret-change-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
INSTAGRAM_USER_ID: str = ""
|
||||
INSTAGRAM_ACCESS_TOKEN: str = ""
|
||||
INSTAGRAM_POLL_INTERVAL: int = 1800
|
||||
|
||||
EXPO_ACCESS_TOKEN: str = ""
|
||||
|
||||
CORS_ORIGINS: str = "http://localhost:8081,exp://"
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
||||
|
||||
|
||||
settings = Settings()
|
||||
3
backend/app/crud/__init__.py
Normal file
3
backend/app/crud/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.crud import crud_user, crud_championship, crud_registration, crud_participant
|
||||
|
||||
__all__ = ["crud_user", "crud_championship", "crud_registration", "crud_participant"]
|
||||
63
backend/app/crud/crud_championship.py
Normal file
63
backend/app/crud/crud_championship.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.championship import Championship
|
||||
from app.schemas.championship import ChampionshipCreate, ChampionshipUpdate
|
||||
|
||||
|
||||
def _serialize(value) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
async def get(db: AsyncSession, champ_id: str | uuid.UUID) -> Championship | None:
|
||||
cid = champ_id if isinstance(champ_id, uuid.UUID) else uuid.UUID(str(champ_id))
|
||||
result = await db.execute(select(Championship).where(Championship.id == cid))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_all(
|
||||
db: AsyncSession,
|
||||
status: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[Championship]:
|
||||
q = select(Championship).order_by(Championship.event_date.asc())
|
||||
if status:
|
||||
q = q.where(Championship.status == status)
|
||||
q = q.offset(skip).limit(limit)
|
||||
result = await db.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(db: AsyncSession, data: ChampionshipCreate) -> Championship:
|
||||
payload = data.model_dump(exclude={"judges", "categories"})
|
||||
payload["judges"] = _serialize(data.judges)
|
||||
payload["categories"] = _serialize(data.categories)
|
||||
champ = Championship(**payload)
|
||||
db.add(champ)
|
||||
await db.commit()
|
||||
await db.refresh(champ)
|
||||
return champ
|
||||
|
||||
|
||||
async def update(db: AsyncSession, champ: Championship, data: ChampionshipUpdate) -> Championship:
|
||||
raw = data.model_dump(exclude_none=True, exclude={"judges", "categories"})
|
||||
for field, value in raw.items():
|
||||
setattr(champ, field, value)
|
||||
if data.judges is not None:
|
||||
champ.judges = _serialize(data.judges)
|
||||
if data.categories is not None:
|
||||
champ.categories = _serialize(data.categories)
|
||||
await db.commit()
|
||||
await db.refresh(champ)
|
||||
return champ
|
||||
|
||||
|
||||
async def delete(db: AsyncSession, champ: Championship) -> None:
|
||||
await db.delete(champ)
|
||||
await db.commit()
|
||||
34
backend/app/crud/crud_participant.py
Normal file
34
backend/app/crud/crud_participant.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.participant import ParticipantList
|
||||
|
||||
|
||||
async def get_for_championship(db: AsyncSession, championship_id: str | uuid.UUID) -> ParticipantList | None:
|
||||
cid = championship_id if isinstance(championship_id, uuid.UUID) else uuid.UUID(str(championship_id))
|
||||
result = await db.execute(select(ParticipantList).where(ParticipantList.championship_id == cid))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_or_get(db: AsyncSession, championship_id: uuid.UUID, published_by: uuid.UUID) -> ParticipantList:
|
||||
existing = await get_for_championship(db, championship_id)
|
||||
if existing:
|
||||
return existing
|
||||
pl = ParticipantList(championship_id=championship_id, published_by=published_by)
|
||||
db.add(pl)
|
||||
await db.commit()
|
||||
await db.refresh(pl)
|
||||
return pl
|
||||
|
||||
|
||||
async def publish(db: AsyncSession, pl: ParticipantList, notes: str | None = None) -> ParticipantList:
|
||||
pl.is_published = True
|
||||
pl.published_at = datetime.now(UTC)
|
||||
if notes is not None:
|
||||
pl.notes = notes
|
||||
await db.commit()
|
||||
await db.refresh(pl)
|
||||
return pl
|
||||
86
backend/app/crud/crud_registration.py
Normal file
86
backend/app/crud/crud_registration.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.registration import Registration
|
||||
from app.schemas.registration import RegistrationCreate, RegistrationUpdate
|
||||
|
||||
|
||||
async def get(db: AsyncSession, reg_id: str | uuid.UUID) -> Registration | None:
|
||||
rid = reg_id if isinstance(reg_id, uuid.UUID) else uuid.UUID(str(reg_id))
|
||||
result = await db.execute(
|
||||
select(Registration).where(Registration.id == rid).options(selectinload(Registration.user))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_by_user_and_championship(
|
||||
db: AsyncSession, user_id: uuid.UUID, championship_id: uuid.UUID
|
||||
) -> Registration | None:
|
||||
result = await db.execute(
|
||||
select(Registration).where(
|
||||
Registration.user_id == user_id,
|
||||
Registration.championship_id == championship_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_for_championship(
|
||||
db: AsyncSession, championship_id: str | uuid.UUID, skip: int = 0, limit: int = 100
|
||||
) -> list[Registration]:
|
||||
cid = championship_id if isinstance(championship_id, uuid.UUID) else uuid.UUID(str(championship_id))
|
||||
result = await db.execute(
|
||||
select(Registration)
|
||||
.where(Registration.championship_id == cid)
|
||||
.options(selectinload(Registration.user))
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def list_for_user(db: AsyncSession, user_id: uuid.UUID, skip: int = 0, limit: int = 50) -> list[Registration]:
|
||||
result = await db.execute(
|
||||
select(Registration)
|
||||
.where(Registration.user_id == user_id)
|
||||
.options(selectinload(Registration.championship))
|
||||
.order_by(Registration.submitted_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(db: AsyncSession, user_id: uuid.UUID, data: RegistrationCreate) -> Registration:
|
||||
reg = Registration(
|
||||
championship_id=data.championship_id,
|
||||
user_id=user_id,
|
||||
category=data.category,
|
||||
level=data.level,
|
||||
notes=data.notes,
|
||||
status="submitted",
|
||||
)
|
||||
db.add(reg)
|
||||
await db.commit()
|
||||
await db.refresh(reg)
|
||||
return reg
|
||||
|
||||
|
||||
async def update(db: AsyncSession, reg: Registration, data: RegistrationUpdate) -> Registration:
|
||||
raw = data.model_dump(exclude_none=True)
|
||||
for field, value in raw.items():
|
||||
setattr(reg, field, value)
|
||||
if "status" in raw and raw["status"] in ("accepted", "rejected", "waitlisted"):
|
||||
reg.decided_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(reg)
|
||||
return reg
|
||||
|
||||
|
||||
async def delete(db: AsyncSession, reg: Registration) -> None:
|
||||
await db.delete(reg)
|
||||
await db.commit()
|
||||
57
backend/app/crud/crud_user.py
Normal file
57
backend/app/crud/crud_user.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserRegister, UserUpdate
|
||||
from app.services.auth_service import hash_password
|
||||
|
||||
|
||||
async def get_by_id(db: AsyncSession, user_id: str | uuid.UUID) -> User | None:
|
||||
uid = user_id if isinstance(user_id, uuid.UUID) else uuid.UUID(str(user_id))
|
||||
result = await db.execute(select(User).where(User.id == uid))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
result = await db.execute(select(User).where(User.email == email.lower()))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create(db: AsyncSession, data: UserRegister) -> User:
|
||||
user = User(
|
||||
email=data.email.lower(),
|
||||
hashed_password=hash_password(data.password),
|
||||
full_name=data.full_name,
|
||||
phone=data.phone,
|
||||
role=data.requested_role,
|
||||
organization_name=data.organization_name,
|
||||
instagram_handle=data.instagram_handle,
|
||||
# Members are auto-approved; organizers require admin review
|
||||
status="approved" if data.requested_role == "member" else "pending",
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def update(db: AsyncSession, user: User, data: UserUpdate) -> User:
|
||||
for field, value in data.model_dump(exclude_none=True).items():
|
||||
setattr(user, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def set_status(db: AsyncSession, user: User, status: str) -> User:
|
||||
user.status = status
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[User]:
|
||||
result = await db.execute(select(User).offset(skip).limit(limit))
|
||||
return list(result.scalars().all())
|
||||
17
backend/app/database.py
Normal file
17
backend/app/database.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
_connect_args = {"check_same_thread": False} if settings.DATABASE_URL.startswith("sqlite") else {}
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False, connect_args=_connect_args)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
42
backend/app/dependencies.py
Normal file
42
backend/app/dependencies.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.services.auth_service import decode_access_token
|
||||
from app.crud import crud_user
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
|
||||
|
||||
user = await crud_user.get_by_id(db, payload["sub"])
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
async def get_approved_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
if current_user.status != "approved":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account not yet approved")
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_organizer(current_user: User = Depends(get_approved_user)) -> User:
|
||||
if current_user.role not in ("organizer", "admin"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Organizer access required")
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_admin(current_user: User = Depends(get_approved_user)) -> User:
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return current_user
|
||||
41
backend/app/main.py
Normal file
41
backend/app/main.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import auth, championships, registrations, participant_lists, users
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Start Instagram sync scheduler if configured
|
||||
if settings.INSTAGRAM_USER_ID and settings.INSTAGRAM_ACCESS_TOKEN:
|
||||
from app.services.instagram_service import start_scheduler
|
||||
scheduler = start_scheduler()
|
||||
yield
|
||||
scheduler.shutdown()
|
||||
else:
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Pole Dance Championships API", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||
app.include_router(users.router, prefix="/api/v1/users", tags=["users"])
|
||||
app.include_router(championships.router, prefix="/api/v1/championships", tags=["championships"])
|
||||
app.include_router(registrations.router, prefix="/api/v1/registrations", tags=["registrations"])
|
||||
app.include_router(participant_lists.router, prefix="/api/v1", tags=["participant-lists"])
|
||||
|
||||
|
||||
@app.get("/internal/health", tags=["health"])
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
14
backend/app/models/__init__.py
Normal file
14
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from app.models.user import User, RefreshToken
|
||||
from app.models.championship import Championship
|
||||
from app.models.registration import Registration
|
||||
from app.models.participant import ParticipantList
|
||||
from app.models.notification import NotificationLog
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"RefreshToken",
|
||||
"Championship",
|
||||
"Registration",
|
||||
"ParticipantList",
|
||||
"NotificationLog",
|
||||
]
|
||||
42
backend/app/models/championship.py
Normal file
42
backend/app/models/championship.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, Integer, String, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Championship(Base):
|
||||
__tablename__ = "championships"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
location: Mapped[str | None] = mapped_column(String(500))
|
||||
event_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
registration_open_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
registration_close_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# Extended fields
|
||||
form_url: Mapped[str | None] = mapped_column(String(2048))
|
||||
entry_fee: Mapped[float | None] = mapped_column(Float)
|
||||
video_max_duration: Mapped[int | None] = mapped_column(Integer) # seconds
|
||||
judges: Mapped[str | None] = mapped_column(Text) # JSON string: [{name, bio, instagram}]
|
||||
categories: Mapped[str | None] = mapped_column(Text) # JSON string: [str]
|
||||
|
||||
# Status: 'draft' | 'open' | 'closed' | 'completed'
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft")
|
||||
# Source: 'manual' | 'instagram'
|
||||
source: Mapped[str] = mapped_column(String(20), nullable=False, default="manual")
|
||||
instagram_media_id: Mapped[str | None] = mapped_column(String(255), unique=True)
|
||||
image_url: Mapped[str | None] = mapped_column(String(2048))
|
||||
raw_caption_text: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
registrations: Mapped[list["Registration"]] = relationship(back_populates="championship", cascade="all, delete-orphan") # type: ignore[name-defined]
|
||||
participant_list: Mapped["ParticipantList | None"] = relationship(back_populates="championship", uselist=False, cascade="all, delete-orphan") # type: ignore[name-defined]
|
||||
27
backend/app/models/notification.py
Normal file
27
backend/app/models/notification.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class NotificationLog(Base):
|
||||
__tablename__ = "notification_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
registration_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("registrations.id", ondelete="SET NULL")
|
||||
)
|
||||
type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
delivery_status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="notification_logs") # type: ignore[name-defined]
|
||||
registration: Mapped["Registration | None"] = relationship(back_populates="notification_logs") # type: ignore[name-defined]
|
||||
25
backend/app/models/participant.py
Normal file
25
backend/app/models/participant.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ParticipantList(Base):
|
||||
__tablename__ = "participant_lists"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
championship_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("championships.id", ondelete="CASCADE"), nullable=False, unique=True
|
||||
)
|
||||
published_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
|
||||
)
|
||||
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
championship: Mapped["Championship"] = relationship(back_populates="participant_list") # type: ignore[name-defined]
|
||||
36
backend/app/models/registration.py
Normal file
36
backend/app/models/registration.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Registration(Base):
|
||||
__tablename__ = "registrations"
|
||||
__table_args__ = (UniqueConstraint("championship_id", "user_id", name="uq_registration_champ_user"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
championship_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("championships.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
category: Mapped[str | None] = mapped_column(String(255))
|
||||
level: Mapped[str | None] = mapped_column(String(100))
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# Multi-stage status:
|
||||
# 'submitted' → 'form_submitted' → 'payment_pending' → 'payment_confirmed' →
|
||||
# 'video_submitted' → 'accepted' | 'rejected' | 'waitlisted'
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="submitted")
|
||||
video_url: Mapped[str | None] = mapped_column(String(2048))
|
||||
|
||||
submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
championship: Mapped["Championship"] = relationship(back_populates="registrations") # type: ignore[name-defined]
|
||||
user: Mapped["User"] = relationship(back_populates="registrations") # type: ignore[name-defined]
|
||||
notification_logs: Mapped[list["NotificationLog"]] = relationship(back_populates="registration") # type: ignore[name-defined]
|
||||
44
backend/app/models/user.py
Normal file
44
backend/app/models/user.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
phone: Mapped[str | None] = mapped_column(String(50))
|
||||
# Organizer-specific fields
|
||||
organization_name: Mapped[str | None] = mapped_column(String(255))
|
||||
instagram_handle: Mapped[str | None] = mapped_column(String(100))
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
|
||||
# 'pending' | 'approved' | 'rejected'
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
expo_push_token: Mapped[str | None] = mapped_column(String(255))
|
||||
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()
|
||||
)
|
||||
|
||||
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
registrations: Mapped[list["Registration"]] = relationship(back_populates="user") # type: ignore[name-defined]
|
||||
notification_logs: Mapped[list["NotificationLog"]] = relationship(back_populates="user") # type: ignore[name-defined]
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"))
|
||||
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="refresh_tokens")
|
||||
3
backend/app/routers/__init__.py
Normal file
3
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.routers import auth, championships, registrations, participant_lists, users
|
||||
|
||||
__all__ = ["auth", "championships", "registrations", "participant_lists", "users"]
|
||||
79
backend/app/routers/auth.py
Normal file
79
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import crud_user
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import LogoutRequest, RefreshRequest, RegisterResponse, TokenPair, TokenRefreshed
|
||||
from app.schemas.user import UserLogin, UserOut, UserRegister, UserUpdate
|
||||
from app.services.auth_service import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
revoke_refresh_token,
|
||||
rotate_refresh_token,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(data: UserRegister, db: AsyncSession = Depends(get_db)):
|
||||
if await crud_user.get_by_email(db, data.email):
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
user = await crud_user.create(db, data)
|
||||
# Members are auto-approved — issue tokens immediately so they can log in right away
|
||||
if user.role == "member":
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = await create_refresh_token(db, user.id)
|
||||
return RegisterResponse(
|
||||
user=UserOut.model_validate(user),
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
# Organizers must wait for admin approval
|
||||
return RegisterResponse(user=UserOut.model_validate(user))
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenPair)
|
||||
async def login(data: UserLogin, db: AsyncSession = Depends(get_db)):
|
||||
user = await crud_user.get_by_email(db, data.email)
|
||||
if not user or not verify_password(data.password, user.hashed_password):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = await create_refresh_token(db, user.id)
|
||||
return TokenPair(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user=UserOut.model_validate(user),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenRefreshed)
|
||||
async def refresh(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await rotate_refresh_token(db, data.refresh_token)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
new_refresh, user_id = result
|
||||
access_token = create_access_token(user_id)
|
||||
return TokenRefreshed(access_token=access_token, refresh_token=new_refresh)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(data: LogoutRequest, db: AsyncSession = Depends(get_db)):
|
||||
await revoke_refresh_token(db, data.refresh_token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserOut)
|
||||
async def update_me(
|
||||
data: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await crud_user.update(db, current_user, data)
|
||||
69
backend/app/routers/championships.py
Normal file
69
backend/app/routers/championships.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import crud_championship
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_approved_user, get_organizer
|
||||
from app.models.user import User
|
||||
from app.schemas.championship import ChampionshipCreate, ChampionshipOut, ChampionshipUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[ChampionshipOut])
|
||||
async def list_championships(
|
||||
status: str | None = Query(None),
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await crud_championship.list_all(db, status=status, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.get("/{champ_id}", response_model=ChampionshipOut)
|
||||
async def get_championship(
|
||||
champ_id: uuid.UUID,
|
||||
_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
champ = await crud_championship.get(db, champ_id)
|
||||
if not champ:
|
||||
raise HTTPException(status_code=404, detail="Championship not found")
|
||||
return champ
|
||||
|
||||
|
||||
@router.post("", response_model=ChampionshipOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_championship(
|
||||
data: ChampionshipCreate,
|
||||
_user: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await crud_championship.create(db, data)
|
||||
|
||||
|
||||
@router.patch("/{champ_id}", response_model=ChampionshipOut)
|
||||
async def update_championship(
|
||||
champ_id: uuid.UUID,
|
||||
data: ChampionshipUpdate,
|
||||
_user: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
champ = await crud_championship.get(db, champ_id)
|
||||
if not champ:
|
||||
raise HTTPException(status_code=404, detail="Championship not found")
|
||||
return await crud_championship.update(db, champ, data)
|
||||
|
||||
|
||||
@router.delete("/{champ_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_championship(
|
||||
champ_id: uuid.UUID,
|
||||
_user: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
champ = await crud_championship.get(db, champ_id)
|
||||
if not champ:
|
||||
raise HTTPException(status_code=404, detail="Championship not found")
|
||||
await crud_championship.delete(db, champ)
|
||||
55
backend/app/routers/participant_lists.py
Normal file
55
backend/app/routers/participant_lists.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import crud_participant, crud_registration
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_approved_user, get_organizer
|
||||
from app.models.user import User
|
||||
from app.schemas.participant import ParticipantListOut, ParticipantListPublish
|
||||
from app.schemas.registration import RegistrationWithUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/championships/{champ_id}/participant-list", response_model=ParticipantListOut | None)
|
||||
async def get_participant_list(
|
||||
champ_id: uuid.UUID,
|
||||
_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await crud_participant.get_for_championship(db, champ_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/championships/{champ_id}/participant-list/publish",
|
||||
response_model=ParticipantListOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def publish_participant_list(
|
||||
champ_id: uuid.UUID,
|
||||
data: ParticipantListPublish,
|
||||
current_user: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
pl = await crud_participant.create_or_get(db, champ_id, current_user.id)
|
||||
if pl.is_published:
|
||||
raise HTTPException(status_code=409, detail="Participant list already published")
|
||||
pl = await crud_participant.publish(db, pl, data.notes)
|
||||
|
||||
# TODO: send push notifications to accepted participants
|
||||
return pl
|
||||
|
||||
|
||||
@router.get(
|
||||
"/championships/{champ_id}/participant-list/registrations",
|
||||
response_model=list[RegistrationWithUser],
|
||||
)
|
||||
async def list_accepted_registrations(
|
||||
champ_id: uuid.UUID,
|
||||
_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
regs = await crud_registration.list_for_championship(db, champ_id)
|
||||
return [r for r in regs if r.status == "accepted"]
|
||||
108
backend/app/routers/registrations.py
Normal file
108
backend/app/routers/registrations.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import crud_championship, crud_registration
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_approved_user, get_organizer
|
||||
from app.models.user import User
|
||||
from app.schemas.registration import (
|
||||
RegistrationCreate,
|
||||
RegistrationListItem,
|
||||
RegistrationOut,
|
||||
RegistrationUpdate,
|
||||
RegistrationWithUser,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=RegistrationOut, status_code=status.HTTP_201_CREATED)
|
||||
async def register_for_championship(
|
||||
data: RegistrationCreate,
|
||||
current_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
champ = await crud_championship.get(db, data.championship_id)
|
||||
if not champ:
|
||||
raise HTTPException(status_code=404, detail="Championship not found")
|
||||
if champ.status != "open":
|
||||
raise HTTPException(status_code=400, detail="Registration is not open for this championship")
|
||||
|
||||
existing = await crud_registration.get_by_user_and_championship(db, current_user.id, data.championship_id)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Already registered for this championship")
|
||||
|
||||
return await crud_registration.create(db, current_user.id, data)
|
||||
|
||||
|
||||
@router.get("/my", response_model=list[RegistrationListItem])
|
||||
async def my_registrations(
|
||||
current_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await crud_registration.list_for_user(db, current_user.id)
|
||||
|
||||
|
||||
@router.get("/{reg_id}", response_model=RegistrationOut)
|
||||
async def get_registration(
|
||||
reg_id: uuid.UUID,
|
||||
current_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
reg = await crud_registration.get(db, reg_id)
|
||||
if not reg:
|
||||
raise HTTPException(status_code=404, detail="Registration not found")
|
||||
if reg.user_id != current_user.id and current_user.role not in ("organizer", "admin"):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return reg
|
||||
|
||||
|
||||
@router.patch("/{reg_id}", response_model=RegistrationOut)
|
||||
async def update_registration(
|
||||
reg_id: uuid.UUID,
|
||||
data: RegistrationUpdate,
|
||||
current_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
reg = await crud_registration.get(db, reg_id)
|
||||
if not reg:
|
||||
raise HTTPException(status_code=404, detail="Registration not found")
|
||||
|
||||
# Members can only update their own registration (video_url, notes)
|
||||
if current_user.role == "member":
|
||||
if reg.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
allowed_fields = {"video_url", "notes"}
|
||||
update_data = data.model_dump(exclude_none=True)
|
||||
if not set(update_data.keys()).issubset(allowed_fields):
|
||||
raise HTTPException(status_code=403, detail="Members can only update video_url and notes")
|
||||
|
||||
return await crud_registration.update(db, reg, data)
|
||||
|
||||
|
||||
@router.delete("/{reg_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def cancel_registration(
|
||||
reg_id: uuid.UUID,
|
||||
current_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
reg = await crud_registration.get(db, reg_id)
|
||||
if not reg:
|
||||
raise HTTPException(status_code=404, detail="Registration not found")
|
||||
if reg.user_id != current_user.id and current_user.role not in ("organizer", "admin"):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
await crud_registration.delete(db, reg)
|
||||
|
||||
|
||||
# Organizer: list all registrations for a championship
|
||||
@router.get("/championship/{champ_id}", response_model=list[RegistrationWithUser])
|
||||
async def list_registrations_for_championship(
|
||||
champ_id: uuid.UUID,
|
||||
_user: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
):
|
||||
return await crud_registration.list_for_championship(db, champ_id, skip=skip, limit=limit)
|
||||
46
backend/app/routers/users.py
Normal file
46
backend/app/routers/users.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import crud_user
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_admin
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserOut])
|
||||
async def list_users(
|
||||
_admin: User = Depends(get_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
):
|
||||
return await crud_user.list_all(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.patch("/{user_id}/approve", response_model=UserOut)
|
||||
async def approve_user(
|
||||
user_id: uuid.UUID,
|
||||
_admin: User = Depends(get_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await crud_user.get_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return await crud_user.set_status(db, user, "approved")
|
||||
|
||||
|
||||
@router.patch("/{user_id}/reject", response_model=UserOut)
|
||||
async def reject_user(
|
||||
user_id: uuid.UUID,
|
||||
_admin: User = Depends(get_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await crud_user.get_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return await crud_user.set_status(db, user, "rejected")
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
36
backend/app/schemas/auth.py
Normal file
36
backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.user import UserOut
|
||||
|
||||
|
||||
class TokenPair(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserOut
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TokenRefreshed(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class LogoutRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
"""
|
||||
Returned after registration.
|
||||
Members get tokens immediately (auto-approved).
|
||||
Organizers only get the user object (pending approval).
|
||||
"""
|
||||
user: UserOut
|
||||
access_token: str | None = None
|
||||
refresh_token: str | None = None
|
||||
token_type: str = "bearer"
|
||||
73
backend/app/schemas/championship.py
Normal file
73
backend/app/schemas/championship.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, model_validator
|
||||
|
||||
|
||||
class ChampionshipCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
event_date: datetime | None = None
|
||||
registration_open_at: datetime | None = None
|
||||
registration_close_at: datetime | None = None
|
||||
form_url: str | None = None
|
||||
entry_fee: float | None = None
|
||||
video_max_duration: int | None = None
|
||||
judges: list[dict] | None = None # [{name, bio, instagram}]
|
||||
categories: list[str] | None = None
|
||||
status: str = "draft"
|
||||
image_url: str | None = None
|
||||
|
||||
|
||||
class ChampionshipUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
event_date: datetime | None = None
|
||||
registration_open_at: datetime | None = None
|
||||
registration_close_at: datetime | None = None
|
||||
form_url: str | None = None
|
||||
entry_fee: float | None = None
|
||||
video_max_duration: int | None = None
|
||||
judges: list[dict] | None = None
|
||||
categories: list[str] | None = None
|
||||
status: str | None = None
|
||||
image_url: str | None = None
|
||||
|
||||
|
||||
class ChampionshipOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
description: str | None
|
||||
location: str | None
|
||||
event_date: datetime | None
|
||||
registration_open_at: datetime | None
|
||||
registration_close_at: datetime | None
|
||||
form_url: str | None
|
||||
entry_fee: float | None
|
||||
video_max_duration: int | None
|
||||
judges: list[dict] | None
|
||||
categories: list[str] | None
|
||||
status: str
|
||||
source: str
|
||||
instagram_media_id: str | None
|
||||
image_url: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def parse_json_fields(cls, v):
|
||||
# judges and categories are stored as JSON strings in the DB
|
||||
if hasattr(v, "__dict__"):
|
||||
raw_j = getattr(v, "judges", None)
|
||||
raw_c = getattr(v, "categories", None)
|
||||
if isinstance(raw_j, str):
|
||||
v.__dict__["judges"] = json.loads(raw_j)
|
||||
if isinstance(raw_c, str):
|
||||
v.__dict__["categories"] = json.loads(raw_c)
|
||||
return v
|
||||
19
backend/app/schemas/participant.py
Normal file
19
backend/app/schemas/participant.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ParticipantListOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
championship_id: uuid.UUID
|
||||
is_published: bool
|
||||
published_at: datetime | None
|
||||
notes: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ParticipantListPublish(BaseModel):
|
||||
notes: str | None = None
|
||||
57
backend/app/schemas/registration.py
Normal file
57
backend/app/schemas/registration.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, model_validator
|
||||
|
||||
from app.schemas.user import UserOut
|
||||
|
||||
|
||||
class RegistrationCreate(BaseModel):
|
||||
championship_id: uuid.UUID
|
||||
category: str | None = None
|
||||
level: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class RegistrationUpdate(BaseModel):
|
||||
status: str | None = None
|
||||
video_url: str | None = None
|
||||
category: str | None = None
|
||||
level: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class RegistrationOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
championship_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
category: str | None
|
||||
level: str | None
|
||||
notes: str | None
|
||||
status: str
|
||||
video_url: str | None
|
||||
submitted_at: datetime
|
||||
decided_at: datetime | None
|
||||
|
||||
|
||||
class RegistrationListItem(RegistrationOut):
|
||||
championship_title: str | None = None
|
||||
championship_event_date: datetime | None = None
|
||||
championship_location: str | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def extract_championship(cls, data: Any) -> Any:
|
||||
if hasattr(data, "championship") and data.championship:
|
||||
champ = data.championship
|
||||
data.__dict__["championship_title"] = champ.title
|
||||
data.__dict__["championship_event_date"] = champ.event_date
|
||||
data.__dict__["championship_location"] = champ.location
|
||||
return data
|
||||
|
||||
|
||||
class RegistrationWithUser(RegistrationOut):
|
||||
user: UserOut
|
||||
52
backend/app/schemas/user.py
Normal file
52
backend/app/schemas/user.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
|
||||
|
||||
class UserRegister(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
full_name: str
|
||||
phone: str | None = None
|
||||
# Role requested at registration: 'member' or 'organizer'
|
||||
requested_role: Literal["member", "organizer"] = "member"
|
||||
# Organizer-only fields
|
||||
organization_name: str | None = None
|
||||
instagram_handle: str | None = None
|
||||
|
||||
@field_validator("organization_name")
|
||||
@classmethod
|
||||
def org_name_required_for_organizer(cls, v, info):
|
||||
if info.data.get("requested_role") == "organizer" and not v:
|
||||
raise ValueError("Organization name is required for organizer registration")
|
||||
return v
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
full_name: str
|
||||
phone: str | None
|
||||
role: str
|
||||
status: str
|
||||
organization_name: str | None
|
||||
instagram_handle: str | None
|
||||
expo_push_token: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
full_name: str | None = None
|
||||
phone: str | None = None
|
||||
organization_name: str | None = None
|
||||
instagram_handle: str | None = None
|
||||
expo_push_token: str | None = None
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
81
backend/app/services/auth_service.py
Normal file
81
backend/app/services/auth_service.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.user import RefreshToken
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
|
||||
|
||||
def create_access_token(user_id: uuid.UUID) -> str:
|
||||
expire = datetime.now(UTC) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return jwt.encode({"sub": str(user_id), "exp": expire}, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict | None:
|
||||
try:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
async def create_refresh_token(db: AsyncSession, user_id: uuid.UUID) -> str:
|
||||
raw = str(uuid.uuid4())
|
||||
expires_at = datetime.now(UTC) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
record = RefreshToken(user_id=user_id, token_hash=_hash_token(raw), expires_at=expires_at)
|
||||
db.add(record)
|
||||
await db.commit()
|
||||
return raw
|
||||
|
||||
|
||||
async def rotate_refresh_token(db: AsyncSession, raw_token: str) -> tuple[str, uuid.UUID] | None:
|
||||
"""Validate old token, revoke it, issue a new one. Returns (new_raw, user_id) or None."""
|
||||
from sqlalchemy import select
|
||||
|
||||
token_hash = _hash_token(raw_token)
|
||||
result = await db.execute(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.token_hash == token_hash,
|
||||
RefreshToken.revoked.is_(False),
|
||||
RefreshToken.expires_at > datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return None
|
||||
|
||||
record.revoked = True
|
||||
await db.flush()
|
||||
|
||||
new_raw = str(uuid.uuid4())
|
||||
expires_at = datetime.now(UTC) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
new_record = RefreshToken(user_id=record.user_id, token_hash=_hash_token(new_raw), expires_at=expires_at)
|
||||
db.add(new_record)
|
||||
await db.commit()
|
||||
return new_raw, record.user_id
|
||||
|
||||
|
||||
async def revoke_refresh_token(db: AsyncSession, raw_token: str) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
token_hash = _hash_token(raw_token)
|
||||
result = await db.execute(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
|
||||
record = result.scalar_one_or_none()
|
||||
if record:
|
||||
record.revoked = True
|
||||
await db.commit()
|
||||
Reference in New Issue
Block a user