commit 1c5719ac85b2b9c48674758b8b82717d50c2ff7b Author: Dianaka123 Date: Sun Feb 22 22:47:10 2026 +0300 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 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..ac1cfd4 --- /dev/null +++ b/.claude/settings.json @@ -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\")" + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..4920601 --- /dev/null +++ b/.claude/settings.local.json @@ -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:*)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..122ff80 --- /dev/null +++ b/.env.example @@ -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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9505eb9 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..da5f0fc --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..27e668c --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..b01e517 --- /dev/null +++ b/backend/alembic/env.py @@ -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()) diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/0001_initial_schema.py b/backend/alembic/versions/0001_initial_schema.py new file mode 100644 index 0000000..04d7e99 --- /dev/null +++ b/backend/alembic/versions/0001_initial_schema.py @@ -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") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..8b79226 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/crud/__init__.py b/backend/app/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/crud/crud_championship.py b/backend/app/crud/crud_championship.py new file mode 100644 index 0000000..327f22f --- /dev/null +++ b/backend/app/crud/crud_championship.py @@ -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() diff --git a/backend/app/crud/crud_participant.py b/backend/app/crud/crud_participant.py new file mode 100644 index 0000000..ed62983 --- /dev/null +++ b/backend/app/crud/crud_participant.py @@ -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 diff --git a/backend/app/crud/crud_refresh_token.py b/backend/app/crud/crud_refresh_token.py new file mode 100644 index 0000000..7e2863e --- /dev/null +++ b/backend/app/crud/crud_refresh_token.py @@ -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 diff --git a/backend/app/crud/crud_registration.py b/backend/app/crud/crud_registration.py new file mode 100644 index 0000000..635669a --- /dev/null +++ b/backend/app/crud/crud_registration.py @@ -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() diff --git a/backend/app/crud/crud_user.py b/backend/app/crud/crud_user.py new file mode 100644 index 0000000..6252d1f --- /dev/null +++ b/backend/app/crud/crud_user.py @@ -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 diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..ac98d87 --- /dev/null +++ b/backend/app/database.py @@ -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 diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..98b80cf --- /dev/null +++ b/backend/app/dependencies.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..38321ca --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..606d9d1 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/championship.py b/backend/app/models/championship.py new file mode 100644 index 0000000..9669de4 --- /dev/null +++ b/backend/app/models/championship.py @@ -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" + ) diff --git a/backend/app/models/notification_log.py b/backend/app/models/notification_log.py new file mode 100644 index 0000000..8ec14ce --- /dev/null +++ b/backend/app/models/notification_log.py @@ -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" + ) diff --git a/backend/app/models/participant_list.py b/backend/app/models/participant_list.py new file mode 100644 index 0000000..f1f060e --- /dev/null +++ b/backend/app/models/participant_list.py @@ -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() diff --git a/backend/app/models/refresh_token.py b/backend/app/models/refresh_token.py new file mode 100644 index 0000000..7d2d4e2 --- /dev/null +++ b/backend/app/models/refresh_token.py @@ -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") diff --git a/backend/app/models/registration.py b/backend/app/models/registration.py new file mode 100644 index 0000000..bff05fa --- /dev/null +++ b/backend/app/models/registration.py @@ -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" + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..3c3539d --- /dev/null +++ b/backend/app/models/user.py @@ -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" + ) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..0ea0130 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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) diff --git a/backend/app/routers/championships.py b/backend/app/routers/championships.py new file mode 100644 index 0000000..390f867 --- /dev/null +++ b/backend/app/routers/championships.py @@ -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) diff --git a/backend/app/routers/participant_lists.py b/backend/app/routers/participant_lists.py new file mode 100644 index 0000000..b1bd8db --- /dev/null +++ b/backend/app/routers/participant_lists.py @@ -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) diff --git a/backend/app/routers/registrations.py b/backend/app/routers/registrations.py new file mode 100644 index 0000000..165575e --- /dev/null +++ b/backend/app/routers/registrations.py @@ -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) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..49ad55d --- /dev/null +++ b/backend/app/routers/users.py @@ -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) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..ded0fa7 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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} diff --git a/backend/app/schemas/championship.py b/backend/app/schemas/championship.py new file mode 100644 index 0000000..75f2c42 --- /dev/null +++ b/backend/app/schemas/championship.py @@ -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} diff --git a/backend/app/schemas/participant_list.py b/backend/app/schemas/participant_list.py new file mode 100644 index 0000000..b2d27e5 --- /dev/null +++ b/backend/app/schemas/participant_list.py @@ -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} diff --git a/backend/app/schemas/registration.py b/backend/app/schemas/registration.py new file mode 100644 index 0000000..652cbd7 --- /dev/null +++ b/backend/app/schemas/registration.py @@ -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} diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..582622e --- /dev/null +++ b/backend/app/schemas/user.py @@ -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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..4ebc816 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -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() diff --git a/backend/app/services/instagram_service.py b/backend/app/services/instagram_service.py new file mode 100644 index 0000000..c6ad59c --- /dev/null +++ b/backend/app/services/instagram_service.py @@ -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) diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 0000000..6d2f444 --- /dev/null +++ b/backend/app/services/notification_service.py @@ -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() diff --git a/backend/app/services/participant_service.py b/backend/app/services/participant_service.py new file mode 100644 index 0000000..f1b60fd --- /dev/null +++ b/backend/app/services/participant_service.py @@ -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 diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..2f4c80e --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..e6faecc --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..440be82 --- /dev/null +++ b/backend/tests/conftest.py @@ -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() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..735e96c --- /dev/null +++ b/backend/tests/test_auth.py @@ -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 diff --git a/backend/tests/test_instagram_parser.py b/backend/tests/test_instagram_parser.py new file mode 100644 index 0000000..7253ca0 --- /dev/null +++ b/backend/tests/test_instagram_parser.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e5bca6c --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/mobile b/mobile new file mode 160000 index 0000000..76ceb04 --- /dev/null +++ b/mobile @@ -0,0 +1 @@ +Subproject commit 76ceb04245ef34eda3834cd4a162440022cda30e diff --git a/start-backend.bat b/start-backend.bat new file mode 100644 index 0000000..daa95e3 --- /dev/null +++ b/start-backend.bat @@ -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 diff --git a/start-mobile.bat b/start-mobile.bat new file mode 100644 index 0000000..c1967ec --- /dev/null +++ b/start-mobile.bat @@ -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