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/crud/__init__.py
Normal file
0
backend/app/crud/__init__.py
Normal file
64
backend/app/crud/crud_championship.py
Normal file
64
backend/app/crud/crud_championship.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.championship import Championship
|
||||
|
||||
|
||||
def _uuid(v: str | uuid.UUID) -> uuid.UUID:
|
||||
return v if isinstance(v, uuid.UUID) else uuid.UUID(str(v))
|
||||
|
||||
|
||||
async def get(db: AsyncSession, championship_id: str | uuid.UUID) -> Championship | None:
|
||||
result = await db.execute(
|
||||
select(Championship).where(Championship.id == _uuid(championship_id))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_by_instagram_id(
|
||||
db: AsyncSession, instagram_media_id: str
|
||||
) -> Championship | None:
|
||||
result = await db.execute(
|
||||
select(Championship).where(
|
||||
Championship.instagram_media_id == instagram_media_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_all(
|
||||
db: AsyncSession,
|
||||
status: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
) -> list[Championship]:
|
||||
q = select(Championship)
|
||||
if status:
|
||||
q = q.where(Championship.status == status)
|
||||
q = q.order_by(Championship.event_date.asc().nullslast()).offset(skip).limit(limit)
|
||||
result = await db.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(db: AsyncSession, **kwargs) -> Championship:
|
||||
champ = Championship(**kwargs)
|
||||
db.add(champ)
|
||||
await db.commit()
|
||||
await db.refresh(champ)
|
||||
return champ
|
||||
|
||||
|
||||
async def update(db: AsyncSession, champ: Championship, **kwargs) -> Championship:
|
||||
for key, value in kwargs.items():
|
||||
if value is not None:
|
||||
setattr(champ, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(champ)
|
||||
return champ
|
||||
|
||||
|
||||
async def delete(db: AsyncSession, champ: Championship) -> None:
|
||||
await db.delete(champ)
|
||||
await db.commit()
|
||||
62
backend/app/crud/crud_participant.py
Normal file
62
backend/app/crud/crud_participant.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.participant_list import ParticipantList
|
||||
|
||||
|
||||
def _uuid(v: str | uuid.UUID) -> uuid.UUID:
|
||||
return v if isinstance(v, uuid.UUID) else uuid.UUID(str(v))
|
||||
|
||||
|
||||
async def get_by_championship(
|
||||
db: AsyncSession, championship_id: str | uuid.UUID
|
||||
) -> ParticipantList | None:
|
||||
result = await db.execute(
|
||||
select(ParticipantList).where(
|
||||
ParticipantList.championship_id == _uuid(championship_id)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def upsert(
|
||||
db: AsyncSession,
|
||||
championship_id: uuid.UUID,
|
||||
published_by: uuid.UUID,
|
||||
notes: str | None,
|
||||
) -> ParticipantList:
|
||||
existing = await get_by_championship(db, championship_id)
|
||||
if existing:
|
||||
existing.notes = notes
|
||||
existing.published_by = published_by
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
return existing
|
||||
pl = ParticipantList(
|
||||
championship_id=championship_id,
|
||||
published_by=published_by,
|
||||
notes=notes,
|
||||
)
|
||||
db.add(pl)
|
||||
await db.commit()
|
||||
await db.refresh(pl)
|
||||
return pl
|
||||
|
||||
|
||||
async def publish(db: AsyncSession, pl: ParticipantList) -> ParticipantList:
|
||||
pl.is_published = True
|
||||
pl.published_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(pl)
|
||||
return pl
|
||||
|
||||
|
||||
async def unpublish(db: AsyncSession, pl: ParticipantList) -> ParticipantList:
|
||||
pl.is_published = False
|
||||
pl.published_at = None
|
||||
await db.commit()
|
||||
await db.refresh(pl)
|
||||
return pl
|
||||
43
backend/app/crud/crud_refresh_token.py
Normal file
43
backend/app/crud/crud_refresh_token.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.refresh_token import RefreshToken
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
token_hash: str,
|
||||
expires_at: datetime,
|
||||
) -> RefreshToken:
|
||||
rt = RefreshToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at)
|
||||
db.add(rt)
|
||||
await db.commit()
|
||||
await db.refresh(rt)
|
||||
return rt
|
||||
|
||||
|
||||
async def get_by_hash(db: AsyncSession, token_hash: str) -> RefreshToken | None:
|
||||
result = await db.execute(
|
||||
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def revoke(db: AsyncSession, rt: RefreshToken) -> None:
|
||||
rt.revoked = True
|
||||
await db.commit()
|
||||
|
||||
|
||||
def is_valid(rt: RefreshToken) -> bool:
|
||||
if rt.revoked:
|
||||
return False
|
||||
now = datetime.now(timezone.utc)
|
||||
expires = rt.expires_at
|
||||
# SQLite returns naive datetimes; normalise to UTC-aware for comparison
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=timezone.utc)
|
||||
return expires > now
|
||||
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 datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.registration import Registration
|
||||
|
||||
|
||||
def _uuid(v: str | uuid.UUID) -> uuid.UUID:
|
||||
return v if isinstance(v, uuid.UUID) else uuid.UUID(str(v))
|
||||
|
||||
|
||||
async def get(db: AsyncSession, registration_id: str | uuid.UUID) -> Registration | None:
|
||||
result = await db.execute(
|
||||
select(Registration).where(Registration.id == _uuid(registration_id))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_by_champ_and_user(
|
||||
db: AsyncSession, championship_id: str | uuid.UUID, user_id: str | uuid.UUID
|
||||
) -> Registration | None:
|
||||
result = await db.execute(
|
||||
select(Registration).where(
|
||||
Registration.championship_id == _uuid(championship_id),
|
||||
Registration.user_id == _uuid(user_id),
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_by_user(db: AsyncSession, user_id: str | uuid.UUID) -> list[Registration]:
|
||||
result = await db.execute(
|
||||
select(Registration)
|
||||
.where(Registration.user_id == _uuid(user_id))
|
||||
.order_by(Registration.submitted_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def list_by_championship(
|
||||
db: AsyncSession, championship_id: str | uuid.UUID
|
||||
) -> list[Registration]:
|
||||
result = await db.execute(
|
||||
select(Registration)
|
||||
.where(Registration.championship_id == _uuid(championship_id))
|
||||
.order_by(Registration.submitted_at.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
championship_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
category: str | None,
|
||||
level: str | None,
|
||||
notes: str | None,
|
||||
) -> Registration:
|
||||
reg = Registration(
|
||||
championship_id=championship_id,
|
||||
user_id=user_id,
|
||||
category=category,
|
||||
level=level,
|
||||
notes=notes,
|
||||
)
|
||||
db.add(reg)
|
||||
await db.commit()
|
||||
await db.refresh(reg)
|
||||
return reg
|
||||
|
||||
|
||||
async def update_status(
|
||||
db: AsyncSession, reg: Registration, status: str
|
||||
) -> Registration:
|
||||
reg.status = status
|
||||
reg.decided_at = datetime.now(timezone.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()
|
||||
72
backend/app/crud/crud_user.py
Normal file
72
backend/app/crud/crud_user.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
from app.services.auth_service import hash_password
|
||||
|
||||
|
||||
async def get_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get(db: AsyncSession, user_id: str | uuid.UUID) -> User | None:
|
||||
uid = uuid.UUID(str(user_id)) if not isinstance(user_id, uuid.UUID) else user_id
|
||||
result = await db.execute(select(User).where(User.id == uid))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
email: str,
|
||||
password: str,
|
||||
full_name: str,
|
||||
phone: str | None = None,
|
||||
role: str = "member",
|
||||
status: str = "pending",
|
||||
) -> User:
|
||||
user = User(
|
||||
email=email,
|
||||
hashed_password=hash_password(password),
|
||||
full_name=full_name,
|
||||
phone=phone,
|
||||
role=role,
|
||||
status=status,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def list_all(
|
||||
db: AsyncSession,
|
||||
status: str | None = None,
|
||||
role: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[User]:
|
||||
q = select(User)
|
||||
if status:
|
||||
q = q.where(User.status == status)
|
||||
if role:
|
||||
q = q.where(User.role == role)
|
||||
q = q.offset(skip).limit(limit)
|
||||
result = await db.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
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 set_push_token(db: AsyncSession, user: User, token: str) -> User:
|
||||
user.expo_push_token = token
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
Reference in New Issue
Block a user