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:
14
.claude/settings.json
Normal file
14
.claude/settings.json
Normal 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\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
11
.claude/settings.local.json
Normal file
11
.claude/settings.local.json
Normal 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
14
.env.example
Normal 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
47
.gitignore
vendored
Normal 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
10
backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
38
backend/alembic.ini
Normal file
38
backend/alembic.ini
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url = 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
48
backend/alembic/env.py
Normal 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())
|
||||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
195
backend/alembic/versions/0001_initial_schema.py
Normal file
195
backend/alembic/versions/0001_initial_schema.py
Normal 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
0
backend/app/__init__.py
Normal file
22
backend/app/config.py
Normal file
22
backend/app/config.py
Normal 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()
|
||||||
0
backend/app/crud/__init__.py
Normal file
0
backend/app/crud/__init__.py
Normal file
64
backend/app/crud/crud_championship.py
Normal file
64
backend/app/crud/crud_championship.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.championship import Championship
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid(v: str | uuid.UUID) -> uuid.UUID:
|
||||||
|
return v if isinstance(v, uuid.UUID) else uuid.UUID(str(v))
|
||||||
|
|
||||||
|
|
||||||
|
async def get(db: AsyncSession, championship_id: str | uuid.UUID) -> Championship | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Championship).where(Championship.id == _uuid(championship_id))
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_by_instagram_id(
|
||||||
|
db: AsyncSession, instagram_media_id: str
|
||||||
|
) -> Championship | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Championship).where(
|
||||||
|
Championship.instagram_media_id == instagram_media_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_all(
|
||||||
|
db: AsyncSession,
|
||||||
|
status: str | None = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> list[Championship]:
|
||||||
|
q = select(Championship)
|
||||||
|
if status:
|
||||||
|
q = q.where(Championship.status == status)
|
||||||
|
q = q.order_by(Championship.event_date.asc().nullslast()).offset(skip).limit(limit)
|
||||||
|
result = await db.execute(q)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def create(db: AsyncSession, **kwargs) -> Championship:
|
||||||
|
champ = Championship(**kwargs)
|
||||||
|
db.add(champ)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(champ)
|
||||||
|
return champ
|
||||||
|
|
||||||
|
|
||||||
|
async def update(db: AsyncSession, champ: Championship, **kwargs) -> Championship:
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if value is not None:
|
||||||
|
setattr(champ, key, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(champ)
|
||||||
|
return champ
|
||||||
|
|
||||||
|
|
||||||
|
async def delete(db: AsyncSession, champ: Championship) -> None:
|
||||||
|
await db.delete(champ)
|
||||||
|
await db.commit()
|
||||||
62
backend/app/crud/crud_participant.py
Normal file
62
backend/app/crud/crud_participant.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.participant_list import ParticipantList
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid(v: str | uuid.UUID) -> uuid.UUID:
|
||||||
|
return v if isinstance(v, uuid.UUID) else uuid.UUID(str(v))
|
||||||
|
|
||||||
|
|
||||||
|
async def get_by_championship(
|
||||||
|
db: AsyncSession, championship_id: str | uuid.UUID
|
||||||
|
) -> ParticipantList | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ParticipantList).where(
|
||||||
|
ParticipantList.championship_id == _uuid(championship_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert(
|
||||||
|
db: AsyncSession,
|
||||||
|
championship_id: uuid.UUID,
|
||||||
|
published_by: uuid.UUID,
|
||||||
|
notes: str | None,
|
||||||
|
) -> ParticipantList:
|
||||||
|
existing = await get_by_championship(db, championship_id)
|
||||||
|
if existing:
|
||||||
|
existing.notes = notes
|
||||||
|
existing.published_by = published_by
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(existing)
|
||||||
|
return existing
|
||||||
|
pl = ParticipantList(
|
||||||
|
championship_id=championship_id,
|
||||||
|
published_by=published_by,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
db.add(pl)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(pl)
|
||||||
|
return pl
|
||||||
|
|
||||||
|
|
||||||
|
async def publish(db: AsyncSession, pl: ParticipantList) -> ParticipantList:
|
||||||
|
pl.is_published = True
|
||||||
|
pl.published_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(pl)
|
||||||
|
return pl
|
||||||
|
|
||||||
|
|
||||||
|
async def unpublish(db: AsyncSession, pl: ParticipantList) -> ParticipantList:
|
||||||
|
pl.is_published = False
|
||||||
|
pl.published_at = None
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(pl)
|
||||||
|
return pl
|
||||||
43
backend/app/crud/crud_refresh_token.py
Normal file
43
backend/app/crud/crud_refresh_token.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.refresh_token import RefreshToken
|
||||||
|
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
token_hash: str,
|
||||||
|
expires_at: datetime,
|
||||||
|
) -> RefreshToken:
|
||||||
|
rt = RefreshToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at)
|
||||||
|
db.add(rt)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(rt)
|
||||||
|
return rt
|
||||||
|
|
||||||
|
|
||||||
|
async def get_by_hash(db: AsyncSession, token_hash: str) -> RefreshToken | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke(db: AsyncSession, rt: RefreshToken) -> None:
|
||||||
|
rt.revoked = True
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid(rt: RefreshToken) -> bool:
|
||||||
|
if rt.revoked:
|
||||||
|
return False
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expires = rt.expires_at
|
||||||
|
# SQLite returns naive datetimes; normalise to UTC-aware for comparison
|
||||||
|
if expires.tzinfo is None:
|
||||||
|
expires = expires.replace(tzinfo=timezone.utc)
|
||||||
|
return expires > now
|
||||||
86
backend/app/crud/crud_registration.py
Normal file
86
backend/app/crud/crud_registration.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.registration import Registration
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid(v: str | uuid.UUID) -> uuid.UUID:
|
||||||
|
return v if isinstance(v, uuid.UUID) else uuid.UUID(str(v))
|
||||||
|
|
||||||
|
|
||||||
|
async def get(db: AsyncSession, registration_id: str | uuid.UUID) -> Registration | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Registration).where(Registration.id == _uuid(registration_id))
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_by_champ_and_user(
|
||||||
|
db: AsyncSession, championship_id: str | uuid.UUID, user_id: str | uuid.UUID
|
||||||
|
) -> Registration | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Registration).where(
|
||||||
|
Registration.championship_id == _uuid(championship_id),
|
||||||
|
Registration.user_id == _uuid(user_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_by_user(db: AsyncSession, user_id: str | uuid.UUID) -> list[Registration]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Registration)
|
||||||
|
.where(Registration.user_id == _uuid(user_id))
|
||||||
|
.order_by(Registration.submitted_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def list_by_championship(
|
||||||
|
db: AsyncSession, championship_id: str | uuid.UUID
|
||||||
|
) -> list[Registration]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Registration)
|
||||||
|
.where(Registration.championship_id == _uuid(championship_id))
|
||||||
|
.order_by(Registration.submitted_at.asc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
db: AsyncSession,
|
||||||
|
championship_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
category: str | None,
|
||||||
|
level: str | None,
|
||||||
|
notes: str | None,
|
||||||
|
) -> Registration:
|
||||||
|
reg = Registration(
|
||||||
|
championship_id=championship_id,
|
||||||
|
user_id=user_id,
|
||||||
|
category=category,
|
||||||
|
level=level,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
db.add(reg)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(reg)
|
||||||
|
return reg
|
||||||
|
|
||||||
|
|
||||||
|
async def update_status(
|
||||||
|
db: AsyncSession, reg: Registration, status: str
|
||||||
|
) -> Registration:
|
||||||
|
reg.status = status
|
||||||
|
reg.decided_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(reg)
|
||||||
|
return reg
|
||||||
|
|
||||||
|
|
||||||
|
async def delete(db: AsyncSession, reg: Registration) -> None:
|
||||||
|
await db.delete(reg)
|
||||||
|
await db.commit()
|
||||||
72
backend/app/crud/crud_user.py
Normal file
72
backend/app/crud/crud_user.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.auth_service import hash_password
|
||||||
|
|
||||||
|
|
||||||
|
async def get_by_email(db: AsyncSession, email: str) -> User | None:
|
||||||
|
result = await db.execute(select(User).where(User.email == email))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get(db: AsyncSession, user_id: str | uuid.UUID) -> User | None:
|
||||||
|
uid = uuid.UUID(str(user_id)) if not isinstance(user_id, uuid.UUID) else user_id
|
||||||
|
result = await db.execute(select(User).where(User.id == uid))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
db: AsyncSession,
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
full_name: str,
|
||||||
|
phone: str | None = None,
|
||||||
|
role: str = "member",
|
||||||
|
status: str = "pending",
|
||||||
|
) -> User:
|
||||||
|
user = User(
|
||||||
|
email=email,
|
||||||
|
hashed_password=hash_password(password),
|
||||||
|
full_name=full_name,
|
||||||
|
phone=phone,
|
||||||
|
role=role,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def list_all(
|
||||||
|
db: AsyncSession,
|
||||||
|
status: str | None = None,
|
||||||
|
role: str | None = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[User]:
|
||||||
|
q = select(User)
|
||||||
|
if status:
|
||||||
|
q = q.where(User.status == status)
|
||||||
|
if role:
|
||||||
|
q = q.where(User.role == role)
|
||||||
|
q = q.offset(skip).limit(limit)
|
||||||
|
result = await db.execute(q)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def set_status(db: AsyncSession, user: User, status: str) -> User:
|
||||||
|
user.status = status
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def set_push_token(db: AsyncSession, user: User, token: str) -> User:
|
||||||
|
user.expo_push_token = token
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
47
backend/app/database.py
Normal file
47
backend/app/database.py
Normal 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
|
||||||
53
backend/app/dependencies.py
Normal file
53
backend/app/dependencies.py
Normal 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
56
backend/app/main.py
Normal 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"}
|
||||||
15
backend/app/models/__init__.py
Normal file
15
backend/app/models/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
41
backend/app/models/championship.py
Normal file
41
backend/app/models/championship.py
Normal 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"
|
||||||
|
)
|
||||||
34
backend/app/models/notification_log.py
Normal file
34
backend/app/models/notification_log.py
Normal 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"
|
||||||
|
)
|
||||||
33
backend/app/models/participant_list.py
Normal file
33
backend/app/models/participant_list.py
Normal 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()
|
||||||
27
backend/app/models/refresh_token.py
Normal file
27
backend/app/models/refresh_token.py
Normal 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")
|
||||||
45
backend/app/models/registration.py
Normal file
45
backend/app/models/registration.py
Normal 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"
|
||||||
|
)
|
||||||
40
backend/app/models/user.py
Normal file
40
backend/app/models/user.py
Normal 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"
|
||||||
|
)
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
77
backend/app/routers/auth.py
Normal file
77
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.crud import crud_refresh_token, crud_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.auth import LoginRequest, RefreshRequest, RegisterRequest, TokenResponse, UserOut
|
||||||
|
from app.services.auth_service import (
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
hash_token,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
if await crud_user.get_by_email(db, body.email):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
|
||||||
|
)
|
||||||
|
user = await crud_user.create(
|
||||||
|
db,
|
||||||
|
email=body.email,
|
||||||
|
password=body.password,
|
||||||
|
full_name=body.full_name,
|
||||||
|
phone=body.phone,
|
||||||
|
)
|
||||||
|
return await _issue_tokens(db, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
user = await crud_user.get_by_email(db, body.email)
|
||||||
|
if not user or not verify_password(body.password, user.hashed_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
|
||||||
|
)
|
||||||
|
return await _issue_tokens(db, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
|
async def refresh(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
hashed = hash_token(body.refresh_token)
|
||||||
|
rt = await crud_refresh_token.get_by_hash(db, hashed)
|
||||||
|
if not rt or not crud_refresh_token.is_valid(rt):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token"
|
||||||
|
)
|
||||||
|
await crud_refresh_token.revoke(db, rt)
|
||||||
|
user = await crud_user.get(db, rt.user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
return await _issue_tokens(db, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def logout(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
hashed = hash_token(body.refresh_token)
|
||||||
|
rt = await crud_refresh_token.get_by_hash(db, hashed)
|
||||||
|
if rt:
|
||||||
|
await crud_refresh_token.revoke(db, rt)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserOut)
|
||||||
|
async def me(user: User = Depends(get_current_user)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def _issue_tokens(db: AsyncSession, user: User) -> TokenResponse:
|
||||||
|
access = create_access_token(str(user.id), user.role, user.status)
|
||||||
|
raw_rt, hashed_rt, expires_at = create_refresh_token()
|
||||||
|
await crud_refresh_token.create(db, user.id, hashed_rt, expires_at)
|
||||||
|
return TokenResponse(access_token=access, refresh_token=raw_rt)
|
||||||
70
backend/app/routers/championships.py
Normal file
70
backend/app/routers/championships.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.crud import crud_championship
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_admin, get_approved_user, get_organizer
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.championship import ChampionshipCreate, ChampionshipOut, ChampionshipUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/championships", tags=["championships"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ChampionshipOut])
|
||||||
|
async def list_championships(
|
||||||
|
status: str | None = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
_user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await crud_championship.list_all(db, status=status, skip=skip, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{championship_id}", response_model=ChampionshipOut)
|
||||||
|
async def get_championship(
|
||||||
|
championship_id: str,
|
||||||
|
_user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
champ = await crud_championship.get(db, championship_id)
|
||||||
|
if not champ:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
return champ
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ChampionshipOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_championship(
|
||||||
|
body: ChampionshipCreate,
|
||||||
|
_organizer: User = Depends(get_organizer),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await crud_championship.create(db, **body.model_dump(), source="manual")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{championship_id}", response_model=ChampionshipOut)
|
||||||
|
async def update_championship(
|
||||||
|
championship_id: str,
|
||||||
|
body: ChampionshipUpdate,
|
||||||
|
_organizer: User = Depends(get_organizer),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
champ = await crud_championship.get(db, championship_id)
|
||||||
|
if not champ:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
updates = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||||
|
return await crud_championship.update(db, champ, **updates)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{championship_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_championship(
|
||||||
|
championship_id: str,
|
||||||
|
_admin: User = Depends(get_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
champ = await crud_championship.get(db, championship_id)
|
||||||
|
if not champ:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
await crud_championship.delete(db, champ)
|
||||||
75
backend/app/routers/participant_lists.py
Normal file
75
backend/app/routers/participant_lists.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.crud import crud_championship, crud_participant
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_approved_user, get_organizer
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.participant_list import ParticipantListOut, ParticipantListUpsert
|
||||||
|
from app.services import participant_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/championships", tags=["participant-lists"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{championship_id}/participant-list", response_model=ParticipantListOut)
|
||||||
|
async def get_participant_list(
|
||||||
|
championship_id: str,
|
||||||
|
_user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
pl = await crud_participant.get_by_championship(db, championship_id)
|
||||||
|
if not pl or not pl.is_published:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Participant list not published yet",
|
||||||
|
)
|
||||||
|
return pl
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{championship_id}/participant-list", response_model=ParticipantListOut)
|
||||||
|
async def upsert_participant_list(
|
||||||
|
championship_id: str,
|
||||||
|
body: ParticipantListUpsert,
|
||||||
|
organizer: User = Depends(get_organizer),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
champ = await crud_championship.get(db, championship_id)
|
||||||
|
if not champ:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
return await crud_participant.upsert(
|
||||||
|
db,
|
||||||
|
championship_id=uuid.UUID(championship_id),
|
||||||
|
published_by=organizer.id,
|
||||||
|
notes=body.notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{championship_id}/participant-list/publish", response_model=ParticipantListOut)
|
||||||
|
async def publish_participant_list(
|
||||||
|
championship_id: str,
|
||||||
|
organizer: User = Depends(get_organizer),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
champ = await crud_championship.get(db, championship_id)
|
||||||
|
if not champ:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
try:
|
||||||
|
return await participant_service.publish_participant_list(
|
||||||
|
db, uuid.UUID(championship_id), organizer
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{championship_id}/participant-list/unpublish", response_model=ParticipantListOut)
|
||||||
|
async def unpublish_participant_list(
|
||||||
|
championship_id: str,
|
||||||
|
_organizer: User = Depends(get_organizer),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
pl = await crud_participant.get_by_championship(db, championship_id)
|
||||||
|
if not pl:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
return await crud_participant.unpublish(db, pl)
|
||||||
123
backend/app/routers/registrations.py
Normal file
123
backend/app/routers/registrations.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.crud import crud_championship, crud_registration
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_approved_user, get_organizer
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.registration import RegistrationCreate, RegistrationOut, RegistrationStatusUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/registrations", tags=["registrations"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=RegistrationOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def submit_registration(
|
||||||
|
body: RegistrationCreate,
|
||||||
|
current_user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
champ = await crud_championship.get(db, body.championship_id)
|
||||||
|
if not champ:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Championship not found")
|
||||||
|
if champ.status != "open":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Registration is not open"
|
||||||
|
)
|
||||||
|
if champ.registration_close_at and champ.registration_close_at < datetime.now(timezone.utc):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Registration deadline has passed"
|
||||||
|
)
|
||||||
|
existing = await crud_registration.get_by_champ_and_user(
|
||||||
|
db, body.championship_id, current_user.id
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="You have already registered for this championship",
|
||||||
|
)
|
||||||
|
return await crud_registration.create(
|
||||||
|
db,
|
||||||
|
championship_id=uuid.UUID(body.championship_id),
|
||||||
|
user_id=current_user.id,
|
||||||
|
category=body.category,
|
||||||
|
level=body.level,
|
||||||
|
notes=body.notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/my", response_model=list[RegistrationOut])
|
||||||
|
async def my_registrations(
|
||||||
|
current_user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await crud_registration.list_by_user(db, current_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{registration_id}", response_model=RegistrationOut)
|
||||||
|
async def get_registration(
|
||||||
|
registration_id: str,
|
||||||
|
current_user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
reg = await crud_registration.get(db, registration_id)
|
||||||
|
if not reg:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
if str(reg.user_id) != str(current_user.id) and current_user.role not in (
|
||||||
|
"organizer",
|
||||||
|
"admin",
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
return reg
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/championship/{championship_id}", response_model=list[RegistrationOut])
|
||||||
|
async def championship_registrations(
|
||||||
|
championship_id: str,
|
||||||
|
_organizer: User = Depends(get_organizer),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
champ = await crud_championship.get(db, championship_id)
|
||||||
|
if not champ:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
return await crud_registration.list_by_championship(db, championship_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{registration_id}/status", response_model=RegistrationOut)
|
||||||
|
async def update_registration_status(
|
||||||
|
registration_id: str,
|
||||||
|
body: RegistrationStatusUpdate,
|
||||||
|
_organizer: User = Depends(get_organizer),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
allowed = {"accepted", "rejected", "waitlisted"}
|
||||||
|
if body.status not in allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Status must be one of: {', '.join(allowed)}",
|
||||||
|
)
|
||||||
|
reg = await crud_registration.get(db, registration_id)
|
||||||
|
if not reg:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
return await crud_registration.update_status(db, reg, body.status)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{registration_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def withdraw_registration(
|
||||||
|
registration_id: str,
|
||||||
|
current_user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
reg = await crud_registration.get(db, registration_id)
|
||||||
|
if not reg:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
if str(reg.user_id) != str(current_user.id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
if reg.status != "submitted":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Only submitted registrations can be withdrawn",
|
||||||
|
)
|
||||||
|
await crud_registration.delete(db, reg)
|
||||||
101
backend/app/routers/users.py
Normal file
101
backend/app/routers/users.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.crud import crud_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_admin, get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user import PushTokenUpdate, UserCreate, UserOut
|
||||||
|
from app.services import notification_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[UserOut])
|
||||||
|
async def list_users(
|
||||||
|
status: str | None = None,
|
||||||
|
role: str | None = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
_admin: User = Depends(get_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await crud_user.list_all(db, status=status, role=role, skip=skip, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=UserOut)
|
||||||
|
async def get_user(
|
||||||
|
user_id: str,
|
||||||
|
_admin: User = Depends(get_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
user = await crud_user.get(db, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_user(
|
||||||
|
body: UserCreate,
|
||||||
|
_admin: User = Depends(get_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if await crud_user.get_by_email(db, body.email):
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
|
||||||
|
return await crud_user.create(
|
||||||
|
db,
|
||||||
|
email=body.email,
|
||||||
|
password=body.password,
|
||||||
|
full_name=body.full_name,
|
||||||
|
phone=body.phone,
|
||||||
|
role=body.role,
|
||||||
|
status="approved",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}/approve", response_model=UserOut)
|
||||||
|
async def approve_user(
|
||||||
|
user_id: str,
|
||||||
|
admin: User = Depends(get_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
user = await crud_user.get(db, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
user = await crud_user.set_status(db, user, "approved")
|
||||||
|
await notification_service.send_push_notification(
|
||||||
|
db=db,
|
||||||
|
user=user,
|
||||||
|
title="Welcome!",
|
||||||
|
body="Your account has been approved. You can now access the app.",
|
||||||
|
notif_type="account_approved",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}/reject", response_model=UserOut)
|
||||||
|
async def reject_user(
|
||||||
|
user_id: str,
|
||||||
|
_admin: User = Depends(get_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
user = await crud_user.get(db, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
return await crud_user.set_status(db, user, "rejected")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}/push-token", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def update_push_token(
|
||||||
|
user_id: str,
|
||||||
|
body: PushTokenUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if str(current_user.id) != user_id and current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
user = await crud_user.get(db, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
await crud_user.set_push_token(db, user, body.expo_push_token)
|
||||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
36
backend/app/schemas/auth.py
Normal file
36
backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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}
|
||||||
43
backend/app/schemas/championship.py
Normal file
43
backend/app/schemas/championship.py
Normal 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}
|
||||||
19
backend/app/schemas/participant_list.py
Normal file
19
backend/app/schemas/participant_list.py
Normal 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}
|
||||||
29
backend/app/schemas/registration.py
Normal file
29
backend/app/schemas/registration.py
Normal 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}
|
||||||
26
backend/app/schemas/user.py
Normal file
26
backend/app/schemas/user.py
Normal 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
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
54
backend/app/services/auth_service.py
Normal file
54
backend/app/services/auth_service.py
Normal 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()
|
||||||
226
backend/app/services/instagram_service.py
Normal file
226
backend/app/services/instagram_service.py
Normal 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)
|
||||||
44
backend/app/services/notification_service.py
Normal file
44
backend/app/services/notification_service.py
Normal 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()
|
||||||
49
backend/app/services/participant_service.py
Normal file
49
backend/app/services/participant_service.py
Normal 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
2
backend/pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
18
backend/requirements.txt
Normal file
18
backend/requirements.txt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.32.1
|
||||||
|
sqlalchemy==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
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
55
backend/tests/conftest.py
Normal file
55
backend/tests/conftest.py
Normal 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()
|
||||||
89
backend/tests/test_auth.py
Normal file
89
backend/tests/test_auth.py
Normal 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
|
||||||
46
backend/tests/test_instagram_parser.py
Normal file
46
backend/tests/test_instagram_parser.py
Normal 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
41
docker-compose.yml
Normal 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
1
mobile
Submodule
Submodule mobile added at 76ceb04245
8
start-backend.bat
Normal file
8
start-backend.bat
Normal 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
8
start-mobile.bat
Normal 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
|
||||||
Reference in New Issue
Block a user