Initial commit: Pole Dance Championships App
Full-stack mobile app for pole dance championship management. Backend: FastAPI + SQLAlchemy 2 (async) + SQLite (dev) / PostgreSQL (prod) - JWT auth with refresh token rotation - Championship CRUD with Instagram Graph API sync (APScheduler) - Registration flow with status management - Participant list publish with Expo push notifications - Alembic migrations, pytest test suite Mobile: React Native + Expo (TypeScript) - Auth gate: pending approval screen for new members - Championships list & detail screens - Registration form with status tracking - React Query + Zustand + React Navigation v6 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
77
backend/app/routers/auth.py
Normal file
77
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import crud_refresh_token, 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 LoginRequest, RefreshRequest, RegisterRequest, TokenResponse, UserOut
|
||||
from app.services.auth_service import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
hash_token,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
if await crud_user.get_by_email(db, body.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
|
||||
)
|
||||
user = await crud_user.create(
|
||||
db,
|
||||
email=body.email,
|
||||
password=body.password,
|
||||
full_name=body.full_name,
|
||||
phone=body.phone,
|
||||
)
|
||||
return await _issue_tokens(db, user)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
user = await crud_user.get_by_email(db, body.email)
|
||||
if not user or not verify_password(body.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
|
||||
)
|
||||
return await _issue_tokens(db, user)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
hashed = hash_token(body.refresh_token)
|
||||
rt = await crud_refresh_token.get_by_hash(db, hashed)
|
||||
if not rt or not crud_refresh_token.is_valid(rt):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token"
|
||||
)
|
||||
await crud_refresh_token.revoke(db, rt)
|
||||
user = await crud_user.get(db, rt.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return await _issue_tokens(db, user)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
hashed = hash_token(body.refresh_token)
|
||||
rt = await crud_refresh_token.get_by_hash(db, hashed)
|
||||
if rt:
|
||||
await crud_refresh_token.revoke(db, rt)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def me(user: User = Depends(get_current_user)):
|
||||
return user
|
||||
|
||||
|
||||
async def _issue_tokens(db: AsyncSession, user: User) -> TokenResponse:
|
||||
access = create_access_token(str(user.id), user.role, user.status)
|
||||
raw_rt, hashed_rt, expires_at = create_refresh_token()
|
||||
await crud_refresh_token.create(db, user.id, hashed_rt, expires_at)
|
||||
return TokenResponse(access_token=access, refresh_token=raw_rt)
|
||||
70
backend/app/routers/championships.py
Normal file
70
backend/app/routers/championships.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import crud_championship
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_admin, get_approved_user, get_organizer
|
||||
from app.models.user import User
|
||||
from app.schemas.championship import ChampionshipCreate, ChampionshipOut, ChampionshipUpdate
|
||||
|
||||
router = APIRouter(prefix="/championships", tags=["championships"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[ChampionshipOut])
|
||||
async def list_championships(
|
||||
status: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
_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("/{championship_id}", response_model=ChampionshipOut)
|
||||
async def get_championship(
|
||||
championship_id: str,
|
||||
_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
champ = await crud_championship.get(db, championship_id)
|
||||
if not champ:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return champ
|
||||
|
||||
|
||||
@router.post("", response_model=ChampionshipOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_championship(
|
||||
body: ChampionshipCreate,
|
||||
_organizer: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await crud_championship.create(db, **body.model_dump(), source="manual")
|
||||
|
||||
|
||||
@router.patch("/{championship_id}", response_model=ChampionshipOut)
|
||||
async def update_championship(
|
||||
championship_id: str,
|
||||
body: ChampionshipUpdate,
|
||||
_organizer: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
champ = await crud_championship.get(db, championship_id)
|
||||
if not champ:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
updates = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
return await crud_championship.update(db, champ, **updates)
|
||||
|
||||
|
||||
@router.delete("/{championship_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_championship(
|
||||
championship_id: str,
|
||||
_admin: User = Depends(get_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
champ = await crud_championship.get(db, championship_id)
|
||||
if not champ:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
await crud_championship.delete(db, champ)
|
||||
75
backend/app/routers/participant_lists.py
Normal file
75
backend/app/routers/participant_lists.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import crud_championship, crud_participant
|
||||
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_list import ParticipantListOut, ParticipantListUpsert
|
||||
from app.services import participant_service
|
||||
|
||||
router = APIRouter(prefix="/championships", tags=["participant-lists"])
|
||||
|
||||
|
||||
@router.get("/{championship_id}/participant-list", response_model=ParticipantListOut)
|
||||
async def get_participant_list(
|
||||
championship_id: str,
|
||||
_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
pl = await crud_participant.get_by_championship(db, championship_id)
|
||||
if not pl or not pl.is_published:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Participant list not published yet",
|
||||
)
|
||||
return pl
|
||||
|
||||
|
||||
@router.put("/{championship_id}/participant-list", response_model=ParticipantListOut)
|
||||
async def upsert_participant_list(
|
||||
championship_id: str,
|
||||
body: ParticipantListUpsert,
|
||||
organizer: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
champ = await crud_championship.get(db, championship_id)
|
||||
if not champ:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return await crud_participant.upsert(
|
||||
db,
|
||||
championship_id=uuid.UUID(championship_id),
|
||||
published_by=organizer.id,
|
||||
notes=body.notes,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{championship_id}/participant-list/publish", response_model=ParticipantListOut)
|
||||
async def publish_participant_list(
|
||||
championship_id: str,
|
||||
organizer: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
champ = await crud_championship.get(db, championship_id)
|
||||
if not champ:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
try:
|
||||
return await participant_service.publish_participant_list(
|
||||
db, uuid.UUID(championship_id), organizer
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{championship_id}/participant-list/unpublish", response_model=ParticipantListOut)
|
||||
async def unpublish_participant_list(
|
||||
championship_id: str,
|
||||
_organizer: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
pl = await crud_participant.get_by_championship(db, championship_id)
|
||||
if not pl:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return await crud_participant.unpublish(db, pl)
|
||||
123
backend/app/routers/registrations.py
Normal file
123
backend/app/routers/registrations.py
Normal file
@@ -0,0 +1,123 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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, RegistrationOut, RegistrationStatusUpdate
|
||||
|
||||
router = APIRouter(prefix="/registrations", tags=["registrations"])
|
||||
|
||||
|
||||
@router.post("", response_model=RegistrationOut, status_code=status.HTTP_201_CREATED)
|
||||
async def submit_registration(
|
||||
body: RegistrationCreate,
|
||||
current_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
champ = await crud_championship.get(db, body.championship_id)
|
||||
if not champ:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Championship not found")
|
||||
if champ.status != "open":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Registration is not open"
|
||||
)
|
||||
if champ.registration_close_at and champ.registration_close_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Registration deadline has passed"
|
||||
)
|
||||
existing = await crud_registration.get_by_champ_and_user(
|
||||
db, body.championship_id, current_user.id
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="You have already registered for this championship",
|
||||
)
|
||||
return await crud_registration.create(
|
||||
db,
|
||||
championship_id=uuid.UUID(body.championship_id),
|
||||
user_id=current_user.id,
|
||||
category=body.category,
|
||||
level=body.level,
|
||||
notes=body.notes,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/my", response_model=list[RegistrationOut])
|
||||
async def my_registrations(
|
||||
current_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await crud_registration.list_by_user(db, current_user.id)
|
||||
|
||||
|
||||
@router.get("/{registration_id}", response_model=RegistrationOut)
|
||||
async def get_registration(
|
||||
registration_id: str,
|
||||
current_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
reg = await crud_registration.get(db, registration_id)
|
||||
if not reg:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
if str(reg.user_id) != str(current_user.id) and current_user.role not in (
|
||||
"organizer",
|
||||
"admin",
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
return reg
|
||||
|
||||
|
||||
@router.get("/championship/{championship_id}", response_model=list[RegistrationOut])
|
||||
async def championship_registrations(
|
||||
championship_id: str,
|
||||
_organizer: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
champ = await crud_championship.get(db, championship_id)
|
||||
if not champ:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return await crud_registration.list_by_championship(db, championship_id)
|
||||
|
||||
|
||||
@router.patch("/{registration_id}/status", response_model=RegistrationOut)
|
||||
async def update_registration_status(
|
||||
registration_id: str,
|
||||
body: RegistrationStatusUpdate,
|
||||
_organizer: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
allowed = {"accepted", "rejected", "waitlisted"}
|
||||
if body.status not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Status must be one of: {', '.join(allowed)}",
|
||||
)
|
||||
reg = await crud_registration.get(db, registration_id)
|
||||
if not reg:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return await crud_registration.update_status(db, reg, body.status)
|
||||
|
||||
|
||||
@router.delete("/{registration_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def withdraw_registration(
|
||||
registration_id: str,
|
||||
current_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
reg = await crud_registration.get(db, registration_id)
|
||||
if not reg:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
if str(reg.user_id) != str(current_user.id):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
if reg.status != "submitted":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Only submitted registrations can be withdrawn",
|
||||
)
|
||||
await crud_registration.delete(db, reg)
|
||||
101
backend/app/routers/users.py
Normal file
101
backend/app/routers/users.py
Normal file
@@ -0,0 +1,101 @@
|
||||
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_admin, get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.user import PushTokenUpdate, UserCreate, UserOut
|
||||
from app.services import notification_service
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserOut])
|
||||
async def list_users(
|
||||
status: str | None = None,
|
||||
role: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
_admin: User = Depends(get_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await crud_user.list_all(db, status=status, role=role, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserOut)
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
_admin: User = Depends(get_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await crud_user.get(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
_admin: User = Depends(get_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if await crud_user.get_by_email(db, body.email):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
|
||||
return await crud_user.create(
|
||||
db,
|
||||
email=body.email,
|
||||
password=body.password,
|
||||
full_name=body.full_name,
|
||||
phone=body.phone,
|
||||
role=body.role,
|
||||
status="approved",
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{user_id}/approve", response_model=UserOut)
|
||||
async def approve_user(
|
||||
user_id: str,
|
||||
admin: User = Depends(get_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await crud_user.get(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
user = await crud_user.set_status(db, user, "approved")
|
||||
await notification_service.send_push_notification(
|
||||
db=db,
|
||||
user=user,
|
||||
title="Welcome!",
|
||||
body="Your account has been approved. You can now access the app.",
|
||||
notif_type="account_approved",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/{user_id}/reject", response_model=UserOut)
|
||||
async def reject_user(
|
||||
user_id: str,
|
||||
_admin: User = Depends(get_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await crud_user.get(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return await crud_user.set_status(db, user, "rejected")
|
||||
|
||||
|
||||
@router.patch("/{user_id}/push-token", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def update_push_token(
|
||||
user_id: str,
|
||||
body: PushTokenUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if str(current_user.id) != user_id and current_user.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
user = await crud_user.get(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
await crud_user.set_push_token(db, user, body.expo_push_token)
|
||||
Reference in New Issue
Block a user