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:
10
backend/Dockerfile
Normal file
10
backend/Dockerfile
Normal 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
38
backend/alembic.ini
Normal 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
59
backend/alembic/env.py
Normal 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()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,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 ###
|
||||
125
backend/alembic/versions/657f22c8aa55_initial_schema.py
Normal file
125
backend/alembic/versions/657f22c8aa55_initial_schema.py
Normal 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
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()
|
||||
18
backend/requirements.txt
Normal file
18
backend/requirements.txt
Normal 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
134
backend/seed.py
Normal 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())
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
Reference in New Issue
Block a user