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:
Dianaka123
2026-02-22 22:47:10 +03:00
commit 1c5719ac85
54 changed files with 2383 additions and 0 deletions

14
.claude/settings.json Normal file
View File

@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(curl:*)",
"Bash(netstat:*)",
"Bash(findstr:*)",
"Bash(taskkill:*)",
"Bash(cmd.exe /c \"taskkill /PID 35364 /F\")",
"Bash(cmd.exe /c \"taskkill /PID 35364 /F 2>&1\")",
"Bash(echo:*)",
"Bash(cmd.exe /c \"ipconfig\")"
]
}
}

View File

@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(npx expo:*)",
"Bash(pip install:*)",
"Bash(python -m pytest:*)",
"Bash(python:*)",
"Bash(/d/PoleDanceApp/backend/.venv/Scripts/pip install:*)"
]
}
}

14
.env.example Normal file
View File

@@ -0,0 +1,14 @@
# PostgreSQL
POSTGRES_PASSWORD=changeme
# JWT — generate with: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=changeme
# Instagram Graph API
# 1. Convert your Instagram account to Business/Creator and link it to a Facebook Page
# 2. Create a Facebook App at developers.facebook.com
# 3. Add Instagram Graph API product; grant instagram_basic + pages_read_engagement
# 4. Generate a long-lived User Access Token (valid 60 days; auto-refreshed by the app)
INSTAGRAM_USER_ID=123456789
INSTAGRAM_ACCESS_TOKEN=EAAxxxxxx...
INSTAGRAM_POLL_INTERVAL=1800 # seconds between Instagram polls (default 30 min)

47
.gitignore vendored Normal file
View File

@@ -0,0 +1,47 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
.venv/
venv/
*.db
*.db-shm
*.db-wal
# Alembic
backend/alembic/versions/__pycache__/
# Environment
.env
*.env.local
# Node / Expo
node_modules/
.expo/
dist/
web-build/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
npm-debug.*
yarn-debug.*
yarn-error.*
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Logs
*.log

10
backend/Dockerfile Normal file
View File

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

38
backend/alembic.ini Normal file
View File

@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = driver://user:pass@localhost/dbname
[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

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

@@ -0,0 +1,48 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings
from app.database import Base
import app.models # noqa: F401 — registers all models with Base.metadata
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_migrations_online() -> None:
connectable = create_async_engine(settings.database_url)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View File

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

View File

@@ -0,0 +1,195 @@
"""initial schema
Revision ID: 0001
Revises:
Create Date: 2026-02-22
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0001"
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:
op.create_table(
"users",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("hashed_password", sa.String(255), nullable=False),
sa.Column("full_name", sa.String(255), nullable=False),
sa.Column("phone", sa.String(50), nullable=True),
sa.Column("role", sa.String(20), nullable=False, server_default="member"),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("expo_push_token", sa.String(512), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
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(255), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked", sa.Boolean(), nullable=False, server_default="false"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index("idx_refresh_tokens_user_id", "refresh_tokens", ["user_id"])
op.create_table(
"championships",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("title", sa.String(500), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("location", sa.String(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("status", sa.String(30), nullable=False, server_default="draft"),
sa.Column("source", sa.String(20), nullable=False, server_default="manual"),
sa.Column("instagram_media_id", sa.String(100), nullable=True),
sa.Column("image_url", sa.String(1000), nullable=True),
sa.Column("raw_caption_text", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("instagram_media_id"),
)
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(255), nullable=True),
sa.Column("level", sa.String(255), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("status", sa.String(20), nullable=False, server_default="submitted"),
sa.Column(
"submitted_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
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_index(
"idx_registrations_championship_id", "registrations", ["championship_id"]
)
op.create_index("idx_registrations_user_id", "registrations", ["user_id"])
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=False),
sa.Column("is_published", sa.Boolean(), nullable=False, server_default="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("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["championship_id"], ["championships.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["published_by"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("championship_id"),
)
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(50), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column(
"sent_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("delivery_status", sa.String(30), server_default="pending"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["registration_id"], ["registrations.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_notification_log_user_id", "notification_log", ["user_id"])
def downgrade() -> None:
op.drop_table("notification_log")
op.drop_table("participant_lists")
op.drop_table("registrations")
op.drop_table("championships")
op.drop_table("refresh_tokens")
op.drop_table("users")

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

22
backend/app/config.py Normal file
View File

@@ -0,0 +1,22 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# Database
database_url: str = "postgresql+asyncpg://poledance:poledance@localhost:5432/poledance"
# JWT
secret_key: str = "dev-secret-key-change-in-production"
algorithm: str = "HS256"
access_token_expire_minutes: int = 15
refresh_token_expire_days: int = 7
# Instagram Graph API
instagram_user_id: str = ""
instagram_access_token: str = ""
instagram_poll_interval: int = 1800 # seconds
settings = Settings()

View File

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

View 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

View 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

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

View 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

47
backend/app/database.py Normal file
View File

@@ -0,0 +1,47 @@
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
class Base(DeclarativeBase):
pass
def _make_engine():
return create_async_engine(settings.database_url, echo=False)
def _make_session_factory(engine):
return async_sessionmaker(engine, expire_on_commit=False)
# Lazily initialised on first use so tests can patch settings before import
_engine = None
_session_factory = None
def get_engine():
global _engine
if _engine is None:
_engine = _make_engine()
return _engine
def get_session_factory():
global _session_factory
if _session_factory is None:
_session_factory = _make_session_factory(get_engine())
return _session_factory
# Alias kept for Alembic and bot usage
AsyncSessionLocal = None # populated on first call to get_session_factory()
async def get_db() -> AsyncGenerator[AsyncSession, None]:
factory = get_session_factory()
async with factory() as session:
yield session

View File

@@ -0,0 +1,53 @@
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.crud import crud_user
from app.database import AsyncSession, get_db
from app.models.user import User
from app.services.auth_service import decode_access_token
bearer_scheme = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
try:
payload = decode_access_token(credentials.credentials)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token"
)
user = await crud_user.get(db, payload["sub"])
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
async def get_approved_user(user: User = Depends(get_current_user)) -> User:
if user.status != "approved":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account pending approval",
)
return user
async def get_organizer(user: User = Depends(get_approved_user)) -> User:
if user.role not in ("organizer", "admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Organizer access required",
)
return user
async def get_admin(user: User = Depends(get_approved_user)) -> User:
if user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return user

56
backend/app/main.py Normal file
View File

@@ -0,0 +1,56 @@
from contextlib import asynccontextmanager
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, championships, participant_lists, registrations, users
from app.services.instagram_service import poll_instagram, refresh_instagram_token
@asynccontextmanager
async def lifespan(app: FastAPI):
scheduler = AsyncIOScheduler()
scheduler.add_job(
poll_instagram,
"interval",
seconds=settings.instagram_poll_interval,
id="instagram_poll",
)
scheduler.add_job(
refresh_instagram_token,
"interval",
weeks=1,
id="instagram_token_refresh",
)
scheduler.start()
yield
scheduler.shutdown()
app = FastAPI(
title="Pole Dance Championships API",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # tighten in Phase 7
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
PREFIX = "/api/v1"
app.include_router(auth.router, prefix=PREFIX)
app.include_router(users.router, prefix=PREFIX)
app.include_router(championships.router, prefix=PREFIX)
app.include_router(registrations.router, prefix=PREFIX)
app.include_router(participant_lists.router, prefix=PREFIX)
@app.get("/internal/health", tags=["internal"])
async def health():
return {"status": "ok"}

View File

@@ -0,0 +1,15 @@
from app.models.user import User
from app.models.refresh_token import RefreshToken
from app.models.championship import Championship
from app.models.registration import Registration
from app.models.participant_list import ParticipantList
from app.models.notification_log import NotificationLog
__all__ = [
"User",
"RefreshToken",
"Championship",
"Registration",
"ParticipantList",
"NotificationLog",
]

View File

@@ -0,0 +1,41 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
class Championship(Base):
__tablename__ = "championships"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
title: Mapped[str] = mapped_column(String(500), 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))
status: Mapped[str] = mapped_column(String(30), nullable=False, default="draft")
# 'draft' | 'open' | 'closed' | 'completed'
source: Mapped[str] = mapped_column(String(20), nullable=False, default="manual")
# 'manual' | 'instagram'
instagram_media_id: Mapped[str | None] = mapped_column(String(100), unique=True)
image_url: Mapped[str | None] = mapped_column(String(1000))
raw_caption_text: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now
)
registrations: Mapped[list["Registration"]] = relationship(
back_populates="championship", cascade="all, delete-orphan"
)
participant_list: Mapped["ParticipantList | None"] = relationship(
back_populates="championship", cascade="all, delete-orphan"
)

View File

@@ -0,0 +1,34 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
class NotificationLog(Base):
__tablename__ = "notification_log"
__table_args__ = (Index("idx_notification_log_user_id", "user_id"),)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
registration_id: Mapped[uuid.UUID | None] = mapped_column(
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), default=_now)
delivery_status: Mapped[str] = mapped_column(String(30), default="pending")
user: Mapped["User"] = relationship(back_populates="notification_logs")
registration: Mapped["Registration | None"] = relationship(
back_populates="notification_logs"
)

View File

@@ -0,0 +1,33 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
class ParticipantList(Base):
__tablename__ = "participant_lists"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
championship_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("championships.id", ondelete="CASCADE"), unique=True, nullable=False
)
published_by: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id"), nullable=False
)
is_published: Mapped[bool] = mapped_column(Boolean, nullable=False, 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), default=_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now
)
championship: Mapped["Championship"] = relationship(back_populates="participant_list")
organizer: Mapped["User"] = relationship()

View File

@@ -0,0 +1,27 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
__table_args__ = (Index("idx_refresh_tokens_user_id", "user_id"),)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
token_hash: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
user: Mapped["User"] = relationship(back_populates="refresh_tokens")

View File

@@ -0,0 +1,45 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
class Registration(Base):
__tablename__ = "registrations"
__table_args__ = (
UniqueConstraint("championship_id", "user_id", name="uq_registration_champ_user"),
Index("idx_registrations_championship_id", "championship_id"),
Index("idx_registrations_user_id", "user_id"),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
championship_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("championships.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
category: Mapped[str | None] = mapped_column(String(255))
level: Mapped[str | None] = mapped_column(String(255))
notes: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="submitted")
# 'submitted' | 'accepted' | 'rejected' | 'waitlisted'
submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now
)
championship: Mapped["Championship"] = relationship(back_populates="registrations")
user: Mapped["User"] = relationship(back_populates="registrations")
notification_logs: Mapped[list["NotificationLog"]] = relationship(
back_populates="registration"
)

View File

@@ -0,0 +1,40 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
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))
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
# 'member' | 'organizer' | 'admin'
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
# 'pending' | 'approved' | 'rejected'
expo_push_token: Mapped[str | None] = mapped_column(String(512))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now
)
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
registrations: Mapped[list["Registration"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
notification_logs: Mapped[list["NotificationLog"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)

View File

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

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

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

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

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

View File

View File

@@ -0,0 +1,36 @@
import uuid
from pydantic import BaseModel, EmailStr
class RegisterRequest(BaseModel):
email: EmailStr
password: str
full_name: str
phone: str | None = None
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str
class UserOut(BaseModel):
id: uuid.UUID
email: str
full_name: str
phone: str | None
role: str
status: str
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,43 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
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
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
status: str | None = None
image_url: str | None = None
class ChampionshipOut(BaseModel):
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
status: str
source: str
image_url: str | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,19 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class ParticipantListUpsert(BaseModel):
notes: str | None = None
class ParticipantListOut(BaseModel):
id: uuid.UUID
championship_id: uuid.UUID
published_by: uuid.UUID
is_published: bool
published_at: datetime | None
notes: str | None
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,29 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class RegistrationCreate(BaseModel):
championship_id: uuid.UUID
category: str | None = None
level: str | None = None
notes: str | None = None
class RegistrationStatusUpdate(BaseModel):
status: str # 'accepted' | 'rejected' | 'waitlisted'
class RegistrationOut(BaseModel):
id: uuid.UUID
championship_id: uuid.UUID
user_id: uuid.UUID
category: str | None
level: str | None
notes: str | None
status: str
submitted_at: datetime
decided_at: datetime | None
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,26 @@
import uuid
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
email: EmailStr
password: str
full_name: str
phone: str | None = None
role: str = "member"
class UserOut(BaseModel):
id: uuid.UUID
email: str
full_name: str
phone: str | None
role: str
status: str
model_config = {"from_attributes": True}
class PushTokenUpdate(BaseModel):
expo_push_token: str

View File

View File

@@ -0,0 +1,54 @@
import hashlib
import uuid
from datetime import datetime, timedelta, timezone
import jwt
from passlib.context import CryptContext
from app.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(user_id: str, role: str, status: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.access_token_expire_minutes
)
payload = {
"sub": user_id,
"role": role,
"status": status,
"exp": expire,
"type": "access",
}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def create_refresh_token() -> tuple[str, str, datetime]:
"""Returns (raw_token, hashed_token, expires_at)."""
raw = str(uuid.uuid4())
hashed = hashlib.sha256(raw.encode()).hexdigest()
expires_at = datetime.now(timezone.utc) + timedelta(
days=settings.refresh_token_expire_days
)
return raw, hashed, expires_at
def decode_access_token(token: str) -> dict:
"""Raises jwt.InvalidTokenError on failure."""
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("type") != "access":
raise jwt.InvalidTokenError("Not an access token")
return payload
def hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()

View File

@@ -0,0 +1,226 @@
"""
Instagram Graph API polling service.
Setup requirements:
1. Convert organizer's Instagram to Business/Creator account and link to a Facebook Page.
2. Create a Facebook App at developers.facebook.com.
3. Add Instagram Graph API product with permissions: instagram_basic, pages_read_engagement.
4. Generate a long-lived User Access Token (valid 60 days) and set INSTAGRAM_ACCESS_TOKEN in .env.
5. Find your Instagram numeric user ID and set INSTAGRAM_USER_ID in .env.
The scheduler runs every INSTAGRAM_POLL_INTERVAL seconds (default: 1800 = 30 min).
Token is refreshed weekly to prevent expiry.
"""
import logging
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_session_factory
from app.models.championship import Championship
logger = logging.getLogger(__name__)
GRAPH_BASE = "https://graph.facebook.com/v21.0"
# Russian month names → month number
RU_MONTHS = {
"января": 1, "февраля": 2, "марта": 3, "апреля": 4,
"мая": 5, "июня": 6, "июля": 7, "августа": 8,
"сентября": 9, "октября": 10, "ноября": 11, "декабря": 12,
}
LOCATION_PREFIXES = ["место:", "адрес:", "location:", "venue:", "зал:", "address:"]
DATE_PATTERNS = [
# 15 марта 2025
(
r"\b(\d{1,2})\s+("
+ "|".join(RU_MONTHS.keys())
+ r")\s+(\d{4})\b",
"ru",
),
# 15.03.2025
(r"\b(\d{1,2})\.(\d{2})\.(\d{4})\b", "dot"),
# March 15 2025 or March 15, 2025
(
r"\b(January|February|March|April|May|June|July|August|September|October|November|December)"
r"\s+(\d{1,2}),?\s+(\d{4})\b",
"en",
),
]
EN_MONTHS = {
"january": 1, "february": 2, "march": 3, "april": 4,
"may": 5, "june": 6, "july": 7, "august": 8,
"september": 9, "october": 10, "november": 11, "december": 12,
}
@dataclass
class ParsedChampionship:
title: str
description: Optional[str]
location: Optional[str]
event_date: Optional[datetime]
raw_caption_text: str
image_url: Optional[str]
def parse_caption(text: str, image_url: str | None = None) -> ParsedChampionship:
lines = [line.strip() for line in text.strip().splitlines() if line.strip()]
title = lines[0] if lines else "Untitled Championship"
description = "\n".join(lines[1:]) if len(lines) > 1 else None
location = None
for line in lines:
lower = line.lower()
for prefix in LOCATION_PREFIXES:
if lower.startswith(prefix):
location = line[len(prefix):].strip()
break
event_date = _extract_date(text)
return ParsedChampionship(
title=title,
description=description,
location=location,
event_date=event_date,
raw_caption_text=text,
image_url=image_url,
)
def _extract_date(text: str) -> Optional[datetime]:
for pattern, fmt in DATE_PATTERNS:
m = re.search(pattern, text, re.IGNORECASE)
if not m:
continue
try:
if fmt == "ru":
day, month_name, year = int(m.group(1)), m.group(2).lower(), int(m.group(3))
month = RU_MONTHS.get(month_name)
if month:
return datetime(year, month, day, tzinfo=timezone.utc)
elif fmt == "dot":
day, month, year = int(m.group(1)), int(m.group(2)), int(m.group(3))
return datetime(year, month, day, tzinfo=timezone.utc)
elif fmt == "en":
month_name, day, year = m.group(1).lower(), int(m.group(2)), int(m.group(3))
month = EN_MONTHS.get(month_name)
if month:
return datetime(year, month, day, tzinfo=timezone.utc)
except ValueError:
continue
return None
async def _upsert_championship(
session: AsyncSession,
instagram_media_id: str,
parsed: ParsedChampionship,
) -> Championship:
result = await session.execute(
select(Championship).where(
Championship.instagram_media_id == instagram_media_id
)
)
champ = result.scalar_one_or_none()
if champ:
champ.title = parsed.title
champ.description = parsed.description
champ.location = parsed.location
champ.event_date = parsed.event_date
champ.raw_caption_text = parsed.raw_caption_text
champ.image_url = parsed.image_url
else:
champ = Championship(
title=parsed.title,
description=parsed.description,
location=parsed.location,
event_date=parsed.event_date,
status="draft",
source="instagram",
instagram_media_id=instagram_media_id,
raw_caption_text=parsed.raw_caption_text,
image_url=parsed.image_url,
)
session.add(champ)
await session.commit()
return champ
async def poll_instagram() -> None:
"""Fetch recent posts from the monitored Instagram account and sync championships."""
if not settings.instagram_user_id or not settings.instagram_access_token:
logger.warning("Instagram credentials not configured — skipping poll")
return
url = (
f"{GRAPH_BASE}/{settings.instagram_user_id}/media"
f"?fields=id,caption,media_url,timestamp"
f"&access_token={settings.instagram_access_token}"
)
try:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.get(url)
response.raise_for_status()
data = response.json()
except Exception as exc:
logger.error("Instagram API request failed: %s", exc)
return
posts = data.get("data", [])
logger.info("Instagram poll: fetched %d posts", len(posts))
async with get_session_factory()() as session:
for post in posts:
media_id = post.get("id")
caption = post.get("caption", "")
image_url = post.get("media_url")
if not caption:
continue
try:
parsed = parse_caption(caption, image_url)
await _upsert_championship(session, media_id, parsed)
logger.info("Synced championship from Instagram post %s: %s", media_id, parsed.title)
except Exception as exc:
logger.error("Failed to sync Instagram post %s: %s", media_id, exc)
async def refresh_instagram_token() -> None:
"""Refresh the long-lived Instagram token before it expires (run weekly)."""
if not settings.instagram_access_token:
return
url = (
f"{GRAPH_BASE}/oauth/access_token"
f"?grant_type=ig_refresh_token"
f"&access_token={settings.instagram_access_token}"
)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
response.raise_for_status()
new_token = response.json().get("access_token")
if new_token:
# In a production setup, persist the new token to the DB or secrets manager.
# For now, log it so it can be manually updated in .env.
logger.warning(
"Instagram token refreshed. Update INSTAGRAM_ACCESS_TOKEN in .env:\n%s",
new_token,
)
except Exception as exc:
logger.error("Failed to refresh Instagram token: %s", exc)

View File

@@ -0,0 +1,44 @@
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notification_log import NotificationLog
from app.models.user import User
EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send"
async def send_push_notification(
db: AsyncSession,
user: User,
title: str,
body: str,
notif_type: str,
registration_id: str | None = None,
) -> None:
delivery_status = "skipped"
if user.expo_push_token:
payload = {
"to": user.expo_push_token,
"title": title,
"body": body,
"data": {"type": notif_type, "registration_id": registration_id},
"sound": "default",
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(EXPO_PUSH_URL, json=payload)
delivery_status = "sent" if response.status_code == 200 else "failed"
except Exception:
delivery_status = "failed"
log = NotificationLog(
user_id=user.id,
registration_id=registration_id,
type=notif_type,
title=title,
body=body,
delivery_status=delivery_status,
)
db.add(log)
await db.commit()

View File

@@ -0,0 +1,49 @@
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import crud_championship, crud_participant, crud_registration, crud_user
from app.models.participant_list import ParticipantList
from app.models.user import User
from app.services import notification_service
async def publish_participant_list(
db: AsyncSession,
championship_id: uuid.UUID,
organizer: User,
) -> ParticipantList:
pl = await crud_participant.get_by_championship(db, championship_id)
if not pl:
raise ValueError("Participant list not found — create it first")
pl = await crud_participant.publish(db, pl)
championship = await crud_championship.get(db, championship_id)
registrations = await crud_registration.list_by_championship(db, championship_id)
for reg in registrations:
user = await crud_user.get(db, reg.user_id)
if not user:
continue
if reg.status == "accepted":
title = "Congratulations!"
body = f"You've been accepted to {championship.title}!"
elif reg.status == "rejected":
title = "Application Update"
body = f"Unfortunately, your application to {championship.title} was not accepted this time."
else:
title = "Application Update"
body = f"You are on the waitlist for {championship.title}."
await notification_service.send_push_notification(
db=db,
user=user,
title=title,
body=body,
notif_type=reg.status,
registration_id=str(reg.id),
)
return pl

2
backend/pytest.ini Normal file
View File

@@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto

18
backend/requirements.txt Normal file
View File

@@ -0,0 +1,18 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
sqlalchemy==2.0.36
asyncpg==0.30.0
alembic==1.14.0
pydantic-settings==2.7.0
pydantic[email]==2.10.3
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
PyJWT==2.10.1
python-multipart==0.0.20
httpx==0.28.1
apscheduler==3.11.0
slowapi==0.1.9
pytest==8.3.4
pytest-asyncio==0.25.2
pytest-httpx==0.35.0
aiosqlite==0.20.0

View File

55
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,55 @@
import asyncio
import os
# Override DATABASE_URL before any app code is imported so the lazy engine
# initialises with SQLite (no asyncpg required in the test environment).
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from app.database import Base, get_db
from app.main import app
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="session")
async def db_engine():
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(db_engine):
factory = async_sessionmaker(db_engine, expire_on_commit=False)
async with factory() as session:
yield session
await session.rollback()
@pytest_asyncio.fixture
async def client(db_session):
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac
app.dependency_overrides.clear()

View File

@@ -0,0 +1,89 @@
import pytest
@pytest.mark.asyncio
async def test_register_and_login(client):
# Register
res = await client.post(
"/api/v1/auth/register",
json={
"email": "test@example.com",
"password": "secret123",
"full_name": "Test User",
},
)
assert res.status_code == 201
tokens = res.json()
assert "access_token" in tokens
assert "refresh_token" in tokens
# Duplicate registration should fail
res2 = await client.post(
"/api/v1/auth/register",
json={
"email": "test@example.com",
"password": "secret123",
"full_name": "Test User",
},
)
assert res2.status_code == 409
# Login with correct credentials
res3 = await client.post(
"/api/v1/auth/login",
json={"email": "test@example.com", "password": "secret123"},
)
assert res3.status_code == 200
# Login with wrong password
res4 = await client.post(
"/api/v1/auth/login",
json={"email": "test@example.com", "password": "wrong"},
)
assert res4.status_code == 401
@pytest.mark.asyncio
async def test_me_requires_auth(client):
res = await client.get("/api/v1/auth/me")
assert res.status_code in (401, 403) # missing Authorization header
@pytest.mark.asyncio
async def test_pending_user_cannot_access_championships(client):
await client.post(
"/api/v1/auth/register",
json={"email": "pending@example.com", "password": "pw", "full_name": "Pending"},
)
login = await client.post(
"/api/v1/auth/login",
json={"email": "pending@example.com", "password": "pw"},
)
token = login.json()["access_token"]
res = await client.get(
"/api/v1/championships",
headers={"Authorization": f"Bearer {token}"},
)
assert res.status_code == 403
@pytest.mark.asyncio
async def test_token_refresh(client):
await client.post(
"/api/v1/auth/register",
json={"email": "refresh@example.com", "password": "pw", "full_name": "Refresh"},
)
login = await client.post(
"/api/v1/auth/login",
json={"email": "refresh@example.com", "password": "pw"},
)
refresh_token = login.json()["refresh_token"]
res = await client.post("/api/v1/auth/refresh", json={"refresh_token": refresh_token})
assert res.status_code == 200
new_tokens = res.json()
assert "access_token" in new_tokens
# Old refresh token should now be revoked
res2 = await client.post("/api/v1/auth/refresh", json={"refresh_token": refresh_token})
assert res2.status_code == 401

View File

@@ -0,0 +1,46 @@
from datetime import datetime, timezone
from app.services.instagram_service import parse_caption
def test_parse_basic_russian_post():
text = """Открытый Чемпионат по Pole Dance
Место: Москва, ул. Арбат, 10
Дата: 15 марта 2026
Регистрация открыта!"""
result = parse_caption(text)
assert result.title == "Открытый Чемпионат по Pole Dance"
assert result.location == "Москва, ул. Арбат, 10"
assert result.event_date == datetime(2026, 3, 15, tzinfo=timezone.utc)
def test_parse_dot_date_format():
text = "Summer Cup\nLocation: Saint Petersburg\n15.07.2026"
result = parse_caption(text)
assert result.event_date == datetime(2026, 7, 15, tzinfo=timezone.utc)
assert result.location == "Saint Petersburg"
def test_parse_english_date():
text = "Winter Championship\nVenue: Moscow Arena\nJanuary 20, 2027"
result = parse_caption(text)
assert result.event_date == datetime(2027, 1, 20, tzinfo=timezone.utc)
def test_parse_no_date_returns_none():
text = "Some announcement\nNo date here"
result = parse_caption(text)
assert result.event_date is None
assert result.title == "Some announcement"
def test_parse_with_image_url():
text = "Spring Cup"
result = parse_caption(text, image_url="https://example.com/img.jpg")
assert result.image_url == "https://example.com/img.jpg"
def test_parse_empty_caption():
result = parse_caption("")
assert result.title == "Untitled Championship"
assert result.description is None

41
docker-compose.yml Normal file
View File

@@ -0,0 +1,41 @@
version: "3.9"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: poledance
POSTGRES_USER: poledance
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U poledance"]
interval: 5s
timeout: 5s
retries: 10
backend:
build: ./backend
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
environment:
DATABASE_URL: postgresql+asyncpg://poledance:${POSTGRES_PASSWORD}@postgres:5432/poledance
SECRET_KEY: ${SECRET_KEY}
ALGORITHM: HS256
ACCESS_TOKEN_EXPIRE_MINUTES: 15
REFRESH_TOKEN_EXPIRE_DAYS: 7
INSTAGRAM_USER_ID: ${INSTAGRAM_USER_ID:-}
INSTAGRAM_ACCESS_TOKEN: ${INSTAGRAM_ACCESS_TOKEN:-}
INSTAGRAM_POLL_INTERVAL: ${INSTAGRAM_POLL_INTERVAL:-1800}
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
volumes:
- ./backend:/app
volumes:
postgres_data:

1
mobile Submodule

Submodule mobile added at 76ceb04245

8
start-backend.bat Normal file
View File

@@ -0,0 +1,8 @@
@echo off
cd /d D:\PoleDanceApp\backend
echo Starting Pole Dance Championships Backend...
echo API docs: http://localhost:8000/docs
echo Health: http://localhost:8000/internal/health
echo.
.venv\Scripts\python.exe -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
pause

8
start-mobile.bat Normal file
View File

@@ -0,0 +1,8 @@
@echo off
cd /d D:\PoleDanceApp\mobile
echo Starting Expo Mobile App...
echo Scan the QR code with Expo Go on your phone.
echo Your phone must be on the same Wi-Fi as this computer (IP: 10.4.4.24)
echo.
npx expo start --lan
pause