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:
Dianaka123
2026-02-25 22:46:50 +03:00
parent 9eb68695e9
commit 789d2bf0a6
81 changed files with 16283 additions and 310 deletions

10
backend/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

38
backend/alembic.ini Normal file
View File

@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+asyncpg://pole:pole@localhost:5432/poledance
[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

59
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,59 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.config import settings
from app.database import Base
# Import all models so they are registered on Base.metadata
import app.models # noqa: F401
config = context.config
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 do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

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

View File

@@ -0,0 +1,32 @@
"""add organizer fields to users
Revision ID: 43d947192af5
Revises: 657f22c8aa55
Create Date: 2026-02-25 21:18:04.707870
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '43d947192af5'
down_revision: Union[str, None] = '657f22c8aa55'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('organization_name', sa.String(length=255), nullable=True))
op.add_column('users', sa.Column('instagram_handle', sa.String(length=100), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'instagram_handle')
op.drop_column('users', 'organization_name')
# ### end Alembic commands ###

View File

@@ -0,0 +1,125 @@
"""initial schema
Revision ID: 657f22c8aa55
Revises:
Create Date: 2026-02-25 00:23:12.480733
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '657f22c8aa55'
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:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('championships',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('location', sa.String(length=500), nullable=True),
sa.Column('event_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('registration_open_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('registration_close_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('form_url', sa.String(length=2048), nullable=True),
sa.Column('entry_fee', sa.Float(), nullable=True),
sa.Column('video_max_duration', sa.Integer(), nullable=True),
sa.Column('judges', sa.Text(), nullable=True),
sa.Column('categories', sa.Text(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('source', sa.String(length=20), nullable=False),
sa.Column('instagram_media_id', sa.String(length=255), nullable=True),
sa.Column('image_url', sa.String(length=2048), nullable=True),
sa.Column('raw_caption_text', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('instagram_media_id')
)
op.create_table('users',
sa.Column('id', sa.Uuid(), 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=255), nullable=False),
sa.Column('phone', sa.String(length=50), nullable=True),
sa.Column('role', sa.String(length=20), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('expo_push_token', sa.String(length=255), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_table('participant_lists',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('championship_id', sa.Uuid(), nullable=False),
sa.Column('published_by', sa.Uuid(), nullable=True),
sa.Column('is_published', sa.Boolean(), nullable=False),
sa.Column('published_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['championship_id'], ['championships.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['published_by'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('championship_id')
)
op.create_table('refresh_tokens',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('token_hash', sa.String(length=64), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('revoked', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_refresh_tokens_token_hash'), 'refresh_tokens', ['token_hash'], unique=False)
op.create_table('registrations',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('championship_id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('category', sa.String(length=255), nullable=True),
sa.Column('level', sa.String(length=100), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('status', sa.String(length=30), nullable=False),
sa.Column('video_url', sa.String(length=2048), nullable=True),
sa.Column('submitted_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['championship_id'], ['championships.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('championship_id', 'user_id', name='uq_registration_champ_user')
)
op.create_table('notification_log',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('registration_id', sa.Uuid(), nullable=True),
sa.Column('type', sa.String(length=50), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('body', sa.Text(), nullable=False),
sa.Column('sent_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('delivery_status', sa.String(length=20), nullable=False),
sa.ForeignKeyConstraint(['registration_id'], ['registrations.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('notification_log')
op.drop_table('registrations')
op.drop_index(op.f('ix_refresh_tokens_token_hash'), table_name='refresh_tokens')
op.drop_table('refresh_tokens')
op.drop_table('participant_lists')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_table('championships')
# ### end Alembic commands ###

0
backend/app/__init__.py Normal file
View File

28
backend/app/config.py Normal file
View 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()

View 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"]

View 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()

View 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

View 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()

View 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
View 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

View 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
View 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"}

View 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",
]

View 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]

View 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]

View 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]

View 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]

View 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")

View File

@@ -0,0 +1,3 @@
from app.routers import auth, championships, registrations, participant_lists, users
__all__ = ["auth", "championships", "registrations", "participant_lists", "users"]

View 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)

View 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)

View 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"]

View 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)

View 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")

View File

View 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"

View 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

View 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

View 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

View 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

View File

View 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()

18
backend/requirements.txt Normal file
View File

@@ -0,0 +1,18 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
sqlalchemy[asyncio]==2.0.36
alembic==1.14.0
aiosqlite==0.20.0
# asyncpg==0.30.0 # uncomment for PostgreSQL production use
pydantic==2.10.3
pydantic-settings==2.7.0
python-jose[cryptography]==3.3.0
bcrypt==4.2.1
pydantic[email]
python-multipart==0.0.20
httpx==0.28.1
apscheduler==3.10.4
slowapi==0.1.9
pytest==8.3.4
pytest-asyncio==0.24.0
pytest-httpx==0.35.0

134
backend/seed.py Normal file
View File

@@ -0,0 +1,134 @@
"""Seed script — creates test users and one championship.
Run from backend/: .venv/Scripts/python seed.py
"""
import asyncio
import json
from datetime import UTC, datetime, timedelta
from app.database import AsyncSessionLocal
from app.models.championship import Championship
from app.models.user import User
from app.services.auth_service import hash_password
from sqlalchemy import select
async def seed():
async with AsyncSessionLocal() as db:
# ── Users ──────────────────────────────────────────────────────────────
users_data = [
{
"email": "admin@pole.dev",
"full_name": "Diana Admin",
"password": "Admin1234",
"role": "admin",
"status": "approved",
},
{
"email": "organizer@pole.dev",
"full_name": "Ekaterina Organizer",
"password": "Org1234",
"role": "organizer",
"status": "approved",
},
{
"email": "member@pole.dev",
"full_name": "Anna Petrova",
"password": "Member1234",
"role": "member",
"status": "approved",
},
{
"email": "pending@pole.dev",
"full_name": "New Applicant",
"password": "Pending1234",
"role": "member",
"status": "pending",
},
]
created_users = {}
for ud in users_data:
result = await db.execute(select(User).where(User.email == ud["email"]))
user = result.scalar_one_or_none()
if user is None:
user = User(
email=ud["email"],
hashed_password=hash_password(ud["password"]),
full_name=ud["full_name"],
role=ud["role"],
status=ud["status"],
)
db.add(user)
print(f" Created user: {ud['email']}")
else:
# Update role/status if needed
user.role = ud["role"]
user.status = ud["status"]
user.hashed_password = hash_password(ud["password"])
print(f" Updated user: {ud['email']}")
created_users[ud["email"]] = user
await db.flush()
# ── Championships ──────────────────────────────────────────────────────
championships_data = [
{
"title": "Spring Open 2026",
"description": "Annual spring pole dance championship. All levels welcome.",
"location": "Cultural Center, Minsk",
"event_date": datetime(2026, 4, 15, 10, 0, tzinfo=UTC),
"registration_open_at": datetime(2026, 3, 1, 0, 0, tzinfo=UTC),
"registration_close_at": datetime(2026, 4, 1, 0, 0, tzinfo=UTC),
"form_url": "https://forms.example.com/spring2026",
"entry_fee": 50.0,
"video_max_duration": 180,
"judges": json.dumps([
{"name": "Oksana Ivanova", "bio": "Champion 2023", "instagram": "@oksana_pole"},
{"name": "Marta Sokolova", "bio": "Certified judge", "instagram": "@marta_pole"},
]),
"categories": json.dumps(["Novice", "Amateur", "Professional"]),
"status": "open",
"source": "manual",
"image_url": "https://images.unsplash.com/photo-1524594152303-9fd13543fe6e?w=800",
},
{
"title": "Summer Championship 2026",
"description": "The biggest pole dance event of the summer.",
"location": "Sports Palace, Minsk",
"event_date": datetime(2026, 7, 20, 9, 0, tzinfo=UTC),
"registration_open_at": datetime(2026, 6, 1, 0, 0, tzinfo=UTC),
"registration_close_at": datetime(2026, 7, 5, 0, 0, tzinfo=UTC),
"entry_fee": 75.0,
"video_max_duration": 240,
"judges": json.dumps([
{"name": "Elena Kozlova", "bio": "World finalist", "instagram": "@elena_wpc"},
]),
"categories": json.dumps(["Junior", "Senior", "Masters"]),
"status": "draft",
"source": "manual",
},
]
for cd in championships_data:
result = await db.execute(
select(Championship).where(Championship.title == cd["title"])
)
champ = result.scalar_one_or_none()
if champ is None:
champ = Championship(**cd)
db.add(champ)
print(f" Created championship: {cd['title']}")
else:
print(f" Championship already exists: {cd['title']}")
await db.commit()
print("\nSeed complete!")
print("\n=== TEST CREDENTIALS ===")
print("Admin: admin@pole.dev / Admin1234")
print("Organizer: organizer@pole.dev / Org1234")
print("Member: member@pole.dev / Member1234")
print("Pending: pending@pole.dev / Pending1234")
if __name__ == "__main__":
asyncio.run(seed())

View File