POL-127: Add organizations table and championship ownership

- Create organizations table with Alembic migration (3-phase: create table, migrate data, drop old column)
- Add org_id FK on championships linking to organizations
- Refactor all schemas into one-class-per-file packages (auth, championship, organization, participant, registration, user)
- Update CRUD layer with selectinload for organization relationships
- Update frontend types and components to use nested organization object
- Remove phantom Championship fields (subtitle, venue, accent_color) from frontend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dianaka123
2026-03-01 22:09:10 +03:00
parent 96e02bf64a
commit d4f0a05707
44 changed files with 450 additions and 183 deletions

View File

@@ -0,0 +1,86 @@
"""add organizations table and championship ownership
Revision ID: a1b2c3d4e5f6
Revises: 43d947192af5
Create Date: 2026-03-01 18:00:00.000000
"""
import uuid
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, None] = '43d947192af5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Phase 1: Create organizations table
op.create_table('organizations',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('instagram', sa.String(length=100), nullable=True),
sa.Column('email', sa.String(length=255), nullable=True),
sa.Column('city', sa.String(length=100), nullable=True),
sa.Column('logo_url', sa.String(length=500), nullable=True),
sa.Column('verified', sa.Boolean(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('block_reason', sa.String(length=500), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id'),
)
# Phase 1b: Add org_id to championships
with op.batch_alter_table('championships') as batch_op:
batch_op.add_column(sa.Column('org_id', sa.Uuid(), nullable=True))
batch_op.create_foreign_key('fk_championships_org_id', 'organizations', ['org_id'], ['id'], ondelete='SET NULL')
# Phase 2: Migrate existing organizer data to organizations table
conn = op.get_bind()
rows = conn.execute(
sa.text("SELECT id, organization_name FROM users WHERE role = 'organizer' AND organization_name IS NOT NULL")
).fetchall()
for row in rows:
org_id = str(uuid.uuid4())
conn.execute(
sa.text(
"INSERT INTO organizations (id, user_id, name, verified, status, created_at, updated_at) "
"VALUES (:id, :user_id, :name, 0, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
),
{"id": org_id, "user_id": str(row[0]), "name": row[1]}
)
# Phase 3: Remove organization_name from users (keep instagram_handle)
with op.batch_alter_table('users') as batch_op:
batch_op.drop_column('organization_name')
def downgrade() -> None:
# Re-add organization_name to users
with op.batch_alter_table('users') as batch_op:
batch_op.add_column(sa.Column('organization_name', sa.String(length=255), nullable=True))
# Migrate data back from organizations to users
conn = op.get_bind()
orgs = conn.execute(sa.text("SELECT user_id, name FROM organizations")).fetchall()
for org in orgs:
conn.execute(
sa.text("UPDATE users SET organization_name = :name WHERE id = :user_id"),
{"name": org[1], "user_id": str(org[0])}
)
# Remove org_id from championships
with op.batch_alter_table('championships') as batch_op:
batch_op.drop_constraint('fk_championships_org_id', type_='foreignkey')
batch_op.drop_column('org_id')
op.drop_table('organizations')

View File

@@ -1,3 +1,3 @@
from app.crud import crud_user, crud_championship, crud_registration, crud_participant from app.crud import crud_user, crud_organization, crud_championship, crud_registration, crud_participant
__all__ = ["crud_user", "crud_championship", "crud_registration", "crud_participant"] __all__ = ["crud_user", "crud_organization", "crud_championship", "crud_registration", "crud_participant"]

View File

@@ -3,6 +3,7 @@ import uuid
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.championship import Championship from app.models.championship import Championship
from app.schemas.championship import ChampionshipCreate, ChampionshipUpdate from app.schemas.championship import ChampionshipCreate, ChampionshipUpdate
@@ -16,7 +17,9 @@ def _serialize(value) -> str | None:
async def get(db: AsyncSession, champ_id: str | uuid.UUID) -> Championship | None: async def get(db: AsyncSession, champ_id: str | uuid.UUID) -> Championship | None:
cid = champ_id if isinstance(champ_id, uuid.UUID) else uuid.UUID(str(champ_id)) cid = champ_id if isinstance(champ_id, uuid.UUID) else uuid.UUID(str(champ_id))
result = await db.execute(select(Championship).where(Championship.id == cid)) result = await db.execute(
select(Championship).where(Championship.id == cid).options(selectinload(Championship.organization))
)
return result.scalar_one_or_none() return result.scalar_one_or_none()
@@ -26,7 +29,7 @@ async def list_all(
skip: int = 0, skip: int = 0,
limit: int = 50, limit: int = 50,
) -> list[Championship]: ) -> list[Championship]:
q = select(Championship).order_by(Championship.event_date.asc()) q = select(Championship).order_by(Championship.event_date.asc()).options(selectinload(Championship.organization))
if status: if status:
q = q.where(Championship.status == status) q = q.where(Championship.status == status)
q = q.offset(skip).limit(limit) q = q.offset(skip).limit(limit)
@@ -34,14 +37,16 @@ async def list_all(
return list(result.scalars().all()) return list(result.scalars().all())
async def create(db: AsyncSession, data: ChampionshipCreate) -> Championship: async def create(db: AsyncSession, data: ChampionshipCreate, org_id: uuid.UUID | None = None) -> Championship:
payload = data.model_dump(exclude={"judges", "categories"}) payload = data.model_dump(exclude={"judges", "categories"})
payload["judges"] = _serialize(data.judges) payload["judges"] = _serialize(data.judges)
payload["categories"] = _serialize(data.categories) payload["categories"] = _serialize(data.categories)
if org_id:
payload["org_id"] = org_id
champ = Championship(**payload) champ = Championship(**payload)
db.add(champ) db.add(champ)
await db.commit() await db.commit()
await db.refresh(champ) await db.refresh(champ, attribute_names=["organization"])
return champ return champ

View File

@@ -0,0 +1,11 @@
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.organization import Organization
async def get_by_user_id(db: AsyncSession, user_id: uuid.UUID) -> Organization | None:
result = await db.execute(select(Organization).where(Organization.user_id == user_id))
return result.scalar_one_or_none()

View File

@@ -2,7 +2,9 @@ import uuid
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.organization import Organization
from app.models.user import User from app.models.user import User
from app.schemas.user import UserRegister, UserUpdate from app.schemas.user import UserRegister, UserUpdate
from app.services.auth_service import hash_password from app.services.auth_service import hash_password
@@ -10,12 +12,16 @@ from app.services.auth_service import hash_password
async def get_by_id(db: AsyncSession, user_id: str | uuid.UUID) -> User | None: async def get_by_id(db: AsyncSession, user_id: str | uuid.UUID) -> User | None:
uid = user_id if isinstance(user_id, uuid.UUID) else uuid.UUID(str(user_id)) uid = user_id if isinstance(user_id, uuid.UUID) else uuid.UUID(str(user_id))
result = await db.execute(select(User).where(User.id == uid)) result = await db.execute(
select(User).where(User.id == uid).options(selectinload(User.organization))
)
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_by_email(db: AsyncSession, email: str) -> User | None: async def get_by_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(select(User).where(User.email == email.lower())) result = await db.execute(
select(User).where(User.email == email.lower()).options(selectinload(User.organization))
)
return result.scalar_one_or_none() return result.scalar_one_or_none()
@@ -25,23 +31,40 @@ async def create(db: AsyncSession, data: UserRegister) -> User:
hashed_password=hash_password(data.password), hashed_password=hash_password(data.password),
full_name=data.full_name, full_name=data.full_name,
phone=data.phone, phone=data.phone,
role=data.requested_role,
organization_name=data.organization_name,
instagram_handle=data.instagram_handle, instagram_handle=data.instagram_handle,
role=data.requested_role,
# Members are auto-approved; organizers require admin review # Members are auto-approved; organizers require admin review
status="approved" if data.requested_role == "member" else "pending", status="approved" if data.requested_role == "member" else "pending",
) )
db.add(user) db.add(user)
await db.flush() # get user.id for the FK
# Create Organization row for organizer registrations
if data.requested_role == "organizer" and data.organization_name:
org = Organization(
user_id=user.id,
name=data.organization_name,
status="pending",
verified=False,
)
db.add(org)
await db.commit() await db.commit()
await db.refresh(user) await db.refresh(user, attribute_names=["organization"])
return user return user
async def update(db: AsyncSession, user: User, data: UserUpdate) -> User: async def update(db: AsyncSession, user: User, data: UserUpdate) -> User:
for field, value in data.model_dump(exclude_none=True).items(): user_fields = data.model_dump(exclude_none=True, exclude={"organization_name"})
for field, value in user_fields.items():
setattr(user, field, value) setattr(user, field, value)
# Route org field updates to Organization table
if data.organization_name is not None and user.organization:
user.organization.name = data.organization_name
await db.commit() await db.commit()
await db.refresh(user) await db.refresh(user, attribute_names=["organization"])
return user return user
@@ -53,5 +76,7 @@ async def set_status(db: AsyncSession, user: User, status: str) -> User:
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[User]: async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[User]:
result = await db.execute(select(User).offset(skip).limit(limit)) result = await db.execute(
select(User).options(selectinload(User.organization)).offset(skip).limit(limit)
)
return list(result.scalars().all()) return list(result.scalars().all())

View File

@@ -1,4 +1,5 @@
from app.models.user import User, RefreshToken from app.models.user import User, RefreshToken
from app.models.organization import Organization
from app.models.championship import Championship from app.models.championship import Championship
from app.models.registration import Registration from app.models.registration import Registration
from app.models.participant import ParticipantList from app.models.participant import ParticipantList
@@ -7,6 +8,7 @@ from app.models.notification import NotificationLog
__all__ = [ __all__ = [
"User", "User",
"RefreshToken", "RefreshToken",
"Organization",
"Championship", "Championship",
"Registration", "Registration",
"ParticipantList", "ParticipantList",

View File

@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Float, Integer, String, Text, Uuid, func from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
@@ -11,6 +11,7 @@ class Championship(Base):
__tablename__ = "championships" __tablename__ = "championships"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
org_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), ForeignKey("organizations.id", ondelete="SET NULL"))
title: Mapped[str] = mapped_column(String(255), nullable=False) title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
location: Mapped[str | None] = mapped_column(String(500)) location: Mapped[str | None] = mapped_column(String(500))
@@ -37,5 +38,6 @@ class Championship(Base):
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
) )
organization: Mapped["Organization | None"] = relationship(back_populates="championships") # type: ignore[name-defined]
registrations: Mapped[list["Registration"]] = relationship(back_populates="championship", cascade="all, delete-orphan") # type: ignore[name-defined] registrations: Mapped[list["Registration"]] = relationship(back_populates="championship", cascade="all, delete-orphan") # type: ignore[name-defined]
participant_list: Mapped["ParticipantList | None"] = relationship(back_populates="championship", uselist=False, cascade="all, delete-orphan") # type: ignore[name-defined] participant_list: Mapped["ParticipantList | None"] = relationship(back_populates="championship", uselist=False, cascade="all, delete-orphan") # type: ignore[name-defined]

View File

@@ -15,8 +15,6 @@ class User(Base):
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: 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)) phone: Mapped[str | None] = mapped_column(String(50))
# Organizer-specific fields
organization_name: Mapped[str | None] = mapped_column(String(255))
instagram_handle: Mapped[str | None] = mapped_column(String(100)) instagram_handle: Mapped[str | None] = mapped_column(String(100))
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member") role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
# 'pending' | 'approved' | 'rejected' # 'pending' | 'approved' | 'rejected'
@@ -30,6 +28,7 @@ class User(Base):
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(back_populates="user", cascade="all, delete-orphan") refresh_tokens: Mapped[list["RefreshToken"]] = relationship(back_populates="user", cascade="all, delete-orphan")
registrations: Mapped[list["Registration"]] = relationship(back_populates="user") # type: ignore[name-defined] registrations: Mapped[list["Registration"]] = relationship(back_populates="user") # type: ignore[name-defined]
notification_logs: Mapped[list["NotificationLog"]] = relationship(back_populates="user") # type: ignore[name-defined] notification_logs: Mapped[list["NotificationLog"]] = relationship(back_populates="user") # type: ignore[name-defined]
organization: Mapped["Organization | None"] = relationship(back_populates="user", uselist=False) # type: ignore[name-defined]
class RefreshToken(Base): class RefreshToken(Base):

View File

@@ -38,10 +38,11 @@ async def get_championship(
@router.post("", response_model=ChampionshipOut, status_code=status.HTTP_201_CREATED) @router.post("", response_model=ChampionshipOut, status_code=status.HTTP_201_CREATED)
async def create_championship( async def create_championship(
data: ChampionshipCreate, data: ChampionshipCreate,
_user: User = Depends(get_organizer), user: User = Depends(get_organizer),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
return await crud_championship.create(db, data) org_id = user.organization.id if user.organization else None
return await crud_championship.create(db, data, org_id=org_id)
@router.patch("/{champ_id}", response_model=ChampionshipOut) @router.patch("/{champ_id}", response_model=ChampionshipOut)

View File

@@ -0,0 +1,7 @@
from app.schemas.auth.token_pair import TokenPair
from app.schemas.auth.refresh_request import RefreshRequest
from app.schemas.auth.token_refreshed import TokenRefreshed
from app.schemas.auth.logout_request import LogoutRequest
from app.schemas.auth.register_response import RegisterResponse
__all__ = ["TokenPair", "RefreshRequest", "TokenRefreshed", "LogoutRequest", "RegisterResponse"]

View File

@@ -0,0 +1,5 @@
from pydantic import BaseModel
class LogoutRequest(BaseModel):
refresh_token: str

View File

@@ -0,0 +1,5 @@
from pydantic import BaseModel
class RefreshRequest(BaseModel):
refresh_token: str

View File

@@ -3,27 +3,6 @@ from pydantic import BaseModel
from app.schemas.user import UserOut from app.schemas.user import UserOut
class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: UserOut
class RefreshRequest(BaseModel):
refresh_token: str
class TokenRefreshed(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class LogoutRequest(BaseModel):
refresh_token: str
class RegisterResponse(BaseModel): class RegisterResponse(BaseModel):
""" """
Returned after registration. Returned after registration.

View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel
from app.schemas.user import UserOut
class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: UserOut

View File

@@ -0,0 +1,7 @@
from pydantic import BaseModel
class TokenRefreshed(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"

View File

@@ -0,0 +1,5 @@
from app.schemas.championship.create import ChampionshipCreate
from app.schemas.championship.update import ChampionshipUpdate
from app.schemas.championship.out import ChampionshipOut
__all__ = ["ChampionshipCreate", "ChampionshipUpdate", "ChampionshipOut"]

View File

@@ -0,0 +1,19 @@
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
form_url: str | None = None
entry_fee: float | None = None
video_max_duration: int | None = None
judges: list[dict] | None = None # [{name, bio, instagram}]
categories: list[str] | None = None
status: str = "draft"
image_url: str | None = None

View File

@@ -4,43 +4,14 @@ from datetime import datetime
from pydantic import BaseModel, model_validator from pydantic import BaseModel, model_validator
from app.schemas.organization import OrganizationBrief
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
form_url: str | None = None
entry_fee: float | None = None
video_max_duration: int | None = None
judges: list[dict] | None = None # [{name, bio, instagram}]
categories: list[str] | 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
form_url: str | None = None
entry_fee: float | None = None
video_max_duration: int | None = None
judges: list[dict] | None = None
categories: list[str] | None = None
status: str | None = None
image_url: str | None = None
class ChampionshipOut(BaseModel): class ChampionshipOut(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
id: uuid.UUID id: uuid.UUID
org_id: uuid.UUID | None = None
title: str title: str
description: str | None description: str | None
location: str | None location: str | None
@@ -56,6 +27,7 @@ class ChampionshipOut(BaseModel):
source: str source: str
instagram_media_id: str | None instagram_media_id: str | None
image_url: str | None image_url: str | None
organization: OrganizationBrief | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@@ -0,0 +1,19 @@
from datetime import datetime
from pydantic import BaseModel
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
form_url: str | None = None
entry_fee: float | None = None
video_max_duration: int | None = None
judges: list[dict] | None = None
categories: list[str] | None = None
status: str | None = None
image_url: str | None = None

View File

@@ -0,0 +1,4 @@
from app.schemas.organization.out import OrganizationOut
from app.schemas.organization.brief import OrganizationBrief
__all__ = ["OrganizationOut", "OrganizationBrief"]

View File

@@ -0,0 +1,13 @@
import uuid
from pydantic import BaseModel
class OrganizationBrief(BaseModel):
"""Minimal org info for embedding in ChampionshipOut."""
model_config = {"from_attributes": True}
id: uuid.UUID
name: str
instagram: str | None
logo_url: str | None

View File

@@ -0,0 +1,16 @@
import uuid
from pydantic import BaseModel
class OrganizationOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
name: str
instagram: str | None
email: str | None
city: str | None
logo_url: str | None
verified: bool
status: str

View File

@@ -0,0 +1,4 @@
from app.schemas.participant.out import ParticipantListOut
from app.schemas.participant.publish import ParticipantListPublish
__all__ = ["ParticipantListOut", "ParticipantListPublish"]

View File

@@ -13,7 +13,3 @@ class ParticipantListOut(BaseModel):
published_at: datetime | None published_at: datetime | None
notes: str | None notes: str | None
created_at: datetime created_at: datetime
class ParticipantListPublish(BaseModel):
notes: str | None = None

View File

@@ -0,0 +1,5 @@
from pydantic import BaseModel
class ParticipantListPublish(BaseModel):
notes: str | None = None

View File

@@ -1,57 +0,0 @@
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, model_validator
from app.schemas.user import UserOut
class RegistrationCreate(BaseModel):
championship_id: uuid.UUID
category: str | None = None
level: str | None = None
notes: str | None = None
class RegistrationUpdate(BaseModel):
status: str | None = None
video_url: str | None = None
category: str | None = None
level: str | None = None
notes: str | None = None
class RegistrationOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
championship_id: uuid.UUID
user_id: uuid.UUID
category: str | None
level: str | None
notes: str | None
status: str
video_url: str | None
submitted_at: datetime
decided_at: datetime | None
class RegistrationListItem(RegistrationOut):
championship_title: str | None = None
championship_event_date: datetime | None = None
championship_location: str | None = None
@model_validator(mode="before")
@classmethod
def extract_championship(cls, data: Any) -> Any:
if hasattr(data, "championship") and data.championship:
champ = data.championship
data.__dict__["championship_title"] = champ.title
data.__dict__["championship_event_date"] = champ.event_date
data.__dict__["championship_location"] = champ.location
return data
class RegistrationWithUser(RegistrationOut):
user: UserOut

View File

@@ -0,0 +1,13 @@
from app.schemas.registration.create import RegistrationCreate
from app.schemas.registration.update import RegistrationUpdate
from app.schemas.registration.out import RegistrationOut
from app.schemas.registration.list_item import RegistrationListItem
from app.schemas.registration.with_user import RegistrationWithUser
__all__ = [
"RegistrationCreate",
"RegistrationUpdate",
"RegistrationOut",
"RegistrationListItem",
"RegistrationWithUser",
]

View File

@@ -0,0 +1,10 @@
import uuid
from pydantic import BaseModel
class RegistrationCreate(BaseModel):
championship_id: uuid.UUID
category: str | None = None
level: str | None = None
notes: str | None = None

View File

@@ -0,0 +1,22 @@
from datetime import datetime
from typing import Any
from pydantic import model_validator
from app.schemas.registration.out import RegistrationOut
class RegistrationListItem(RegistrationOut):
championship_title: str | None = None
championship_event_date: datetime | None = None
championship_location: str | None = None
@model_validator(mode="before")
@classmethod
def extract_championship(cls, data: Any) -> Any:
if hasattr(data, "championship") and data.championship:
champ = data.championship
data.__dict__["championship_title"] = champ.title
data.__dict__["championship_event_date"] = champ.event_date
data.__dict__["championship_location"] = champ.location
return data

View File

@@ -0,0 +1,19 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class RegistrationOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
championship_id: uuid.UUID
user_id: uuid.UUID
category: str | None
level: str | None
notes: str | None
status: str
video_url: str | None
submitted_at: datetime
decided_at: datetime | None

View File

@@ -0,0 +1,9 @@
from pydantic import BaseModel
class RegistrationUpdate(BaseModel):
status: str | None = None
video_url: str | None = None
category: str | None = None
level: str | None = None
notes: str | None = None

View File

@@ -0,0 +1,6 @@
from app.schemas.registration.out import RegistrationOut
from app.schemas.user import UserOut
class RegistrationWithUser(RegistrationOut):
user: UserOut

View File

@@ -0,0 +1,6 @@
from app.schemas.user.register import UserRegister
from app.schemas.user.login import UserLogin
from app.schemas.user.out import UserOut
from app.schemas.user.update import UserUpdate
__all__ = ["UserRegister", "UserLogin", "UserOut", "UserUpdate"]

View File

@@ -0,0 +1,6 @@
from pydantic import BaseModel, EmailStr
class UserLogin(BaseModel):
email: EmailStr
password: str

View File

@@ -0,0 +1,21 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
from app.schemas.organization import OrganizationOut
class UserOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
email: str
full_name: str
phone: str | None
role: str
status: str
instagram_handle: str | None
organization: OrganizationOut | None = None
expo_push_token: str | None
created_at: datetime

View File

@@ -1,5 +1,3 @@
import uuid
from datetime import datetime
from typing import Literal from typing import Literal
from pydantic import BaseModel, EmailStr, field_validator from pydantic import BaseModel, EmailStr, field_validator
@@ -22,31 +20,3 @@ class UserRegister(BaseModel):
if info.data.get("requested_role") == "organizer" and not v: if info.data.get("requested_role") == "organizer" and not v:
raise ValueError("Organization name is required for organizer registration") raise ValueError("Organization name is required for organizer registration")
return v return v
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
email: str
full_name: str
phone: str | None
role: str
status: str
organization_name: str | None
instagram_handle: str | None
expo_push_token: str | None
created_at: datetime
class UserUpdate(BaseModel):
full_name: str | None = None
phone: str | None = None
organization_name: str | None = None
instagram_handle: str | None = None
expo_push_token: str | None = None

View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel
class UserUpdate(BaseModel):
full_name: str | None = None
phone: str | None = None
instagram_handle: str | None = None
expo_push_token: str | None = None
# Org fields — routed to Organization table in CRUD
organization_name: str | None = None

View File

@@ -1,4 +1,4 @@
"""Seed script — creates test users and one championship. """Seed script — creates test users, organization, and championships.
Run from backend/: .venv/Scripts/python seed.py Run from backend/: .venv/Scripts/python seed.py
""" """
import asyncio import asyncio
@@ -7,6 +7,7 @@ from datetime import UTC, datetime, timedelta
from app.database import AsyncSessionLocal from app.database import AsyncSessionLocal
from app.models.championship import Championship from app.models.championship import Championship
from app.models.organization import Organization
from app.models.user import User from app.models.user import User
from app.services.auth_service import hash_password from app.services.auth_service import hash_password
from sqlalchemy import select from sqlalchemy import select
@@ -29,6 +30,7 @@ async def seed():
"password": "Org1234", "password": "Org1234",
"role": "organizer", "role": "organizer",
"status": "approved", "status": "approved",
"instagram_handle": "@ekaterina_pole",
}, },
{ {
"email": "member@pole.dev", "email": "member@pole.dev",
@@ -36,6 +38,7 @@ async def seed():
"password": "Member1234", "password": "Member1234",
"role": "member", "role": "member",
"status": "approved", "status": "approved",
"instagram_handle": "@anna_petrova",
}, },
{ {
"email": "pending@pole.dev", "email": "pending@pole.dev",
@@ -57,11 +60,11 @@ async def seed():
full_name=ud["full_name"], full_name=ud["full_name"],
role=ud["role"], role=ud["role"],
status=ud["status"], status=ud["status"],
instagram_handle=ud.get("instagram_handle"),
) )
db.add(user) db.add(user)
print(f" Created user: {ud['email']}") print(f" Created user: {ud['email']}")
else: else:
# Update role/status if needed
user.role = ud["role"] user.role = ud["role"]
user.status = ud["status"] user.status = ud["status"]
user.hashed_password = hash_password(ud["password"]) user.hashed_password = hash_password(ud["password"])
@@ -70,6 +73,27 @@ async def seed():
await db.flush() await db.flush()
# ── Organization ──────────────────────────────────────────────────────
organizer = created_users["organizer@pole.dev"]
result = await db.execute(select(Organization).where(Organization.user_id == organizer.id))
org = result.scalar_one_or_none()
if org is None:
org = Organization(
user_id=organizer.id,
name="Pole Studio Minsk",
instagram="@polestudio_minsk",
email="organizer@pole.dev",
city="Minsk",
verified=True,
status="active",
)
db.add(org)
print(f" Created organization: {org.name}")
else:
print(f" Organization already exists: {org.name}")
await db.flush()
# ── Championships ────────────────────────────────────────────────────── # ── Championships ──────────────────────────────────────────────────────
championships_data = [ championships_data = [
{ {
@@ -115,11 +139,12 @@ async def seed():
) )
champ = result.scalar_one_or_none() champ = result.scalar_one_or_none()
if champ is None: if champ is None:
champ = Championship(**cd) champ = Championship(org_id=org.id, **cd)
db.add(champ) db.add(champ)
print(f" Created championship: {cd['title']}") print(f" Created championship: {cd['title']}")
else: else:
print(f" Championship already exists: {cd['title']}") champ.org_id = org.id
print(f" Updated championship: {cd['title']}")
await db.commit() await db.commit()
print("\nSeed complete!") print("\nSeed complete!")

View File

@@ -8,7 +8,7 @@ import { RegistrationTimeline } from "@/components/registrations/RegistrationTim
import { StatusBadge } from "@/components/shared/StatusBadge"; import { StatusBadge } from "@/components/shared/StatusBadge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { MapPin, Building2, Calendar, CreditCard, Film, ExternalLink } from "lucide-react"; import { MapPin, Calendar, CreditCard, Film, ExternalLink } from "lucide-react";
export default function ChampionshipDetailPage({ params }: { params: Promise<{ id: string }> }) { export default function ChampionshipDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params); const { id } = use(params);
@@ -54,7 +54,6 @@ export default function ChampionshipDetailPage({ params }: { params: Promise<{ i
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<h1 className="font-display text-3xl font-bold tracking-wide">{championship.title}</h1> <h1 className="font-display text-3xl font-bold tracking-wide">{championship.title}</h1>
{championship.subtitle && <p className="text-muted-foreground mt-1">{championship.subtitle}</p>}
</div> </div>
<StatusBadge status={championship.status} /> <StatusBadge status={championship.status} />
</div> </div>
@@ -67,12 +66,6 @@ export default function ChampionshipDetailPage({ params }: { params: Promise<{ i
<span className="text-muted-foreground">{championship.location}</span> <span className="text-muted-foreground">{championship.location}</span>
</div> </div>
)} )}
{championship.venue && (
<div className="flex items-center gap-2.5 rounded-xl bg-surface-elevated border border-border/30 px-4 py-3 text-sm">
<Building2 size={16} className="text-purple-accent shrink-0" />
<span className="text-muted-foreground">{championship.venue}</span>
</div>
)}
{eventDate && ( {eventDate && (
<div className="flex items-center gap-2.5 rounded-xl bg-surface-elevated border border-border/30 px-4 py-3 text-sm"> <div className="flex items-center gap-2.5 rounded-xl bg-surface-elevated border border-border/30 px-4 py-3 text-sm">
<Calendar size={16} className="text-gold-accent shrink-0" /> <Calendar size={16} className="text-gold-accent shrink-0" />

View File

@@ -64,13 +64,13 @@ export default function ProfilePage() {
<span className="text-sm text-foreground">{user.phone}</span> <span className="text-sm text-foreground">{user.phone}</span>
</div> </div>
)} )}
{user.organization_name && ( {user.organization?.name && (
<div className="flex items-center justify-between py-2"> <div className="flex items-center justify-between py-2">
<span className="flex items-center gap-2 text-sm text-dim"> <span className="flex items-center gap-2 text-sm text-dim">
<Building2 size={14} /> <Building2 size={14} />
Organization Organization
</span> </span>
<span className="text-sm text-foreground">{user.organization_name}</span> <span className="text-sm text-foreground">{user.organization.name}</span>
</div> </div>
)} )}
{user.instagram_handle && ( {user.instagram_handle && (

View File

@@ -35,10 +35,10 @@ export function UserCard({ user, onApprove, onReject, isActing }: Props) {
<span className={`h-2 w-2 rounded-full shrink-0 ${STATUS_DOT[user.status] ?? "bg-dim"}`} /> <span className={`h-2 w-2 rounded-full shrink-0 ${STATUS_DOT[user.status] ?? "bg-dim"}`} />
</div> </div>
<p className="text-sm text-muted-foreground">{user.email}</p> <p className="text-sm text-muted-foreground">{user.email}</p>
{user.organization_name && ( {user.organization?.name && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground"> <p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Building2 size={12} className="text-dim" /> <Building2 size={12} className="text-dim" />
{user.organization_name} {user.organization.name}
</p> </p>
)} )}
{user.phone && ( {user.phone && (

View File

@@ -27,6 +27,6 @@ export const authApi = {
me: () => apiClient.get<UserOut>("/auth/me").then((r) => r.data), me: () => apiClient.get<UserOut>("/auth/me").then((r) => r.data),
updateMe: (data: Partial<Pick<UserOut, "full_name" | "phone" | "organization_name" | "instagram_handle">>) => updateMe: (data: Partial<Pick<UserOut, "full_name" | "phone" | "instagram_handle">> & { organization_name?: string }) =>
apiClient.patch<UserOut>("/auth/me", data).then((r) => r.data), apiClient.patch<UserOut>("/auth/me", data).then((r) => r.data),
}; };

View File

@@ -1,10 +1,16 @@
export interface OrganizationBrief {
id: string;
name: string;
instagram: string | null;
logo_url: string | null;
}
export interface Championship { export interface Championship {
id: string; id: string;
org_id: string | null;
title: string; title: string;
subtitle: string | null;
description: string | null; description: string | null;
location: string | null; location: string | null;
venue: string | null;
event_date: string | null; event_date: string | null;
registration_open_at: string | null; registration_open_at: string | null;
registration_close_at: string | null; registration_close_at: string | null;
@@ -14,7 +20,7 @@ export interface Championship {
status: "draft" | "open" | "closed" | "completed"; status: "draft" | "open" | "closed" | "completed";
source: string; source: string;
image_url: string | null; image_url: string | null;
accent_color: string | null; organization: OrganizationBrief | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }

View File

@@ -1,3 +1,14 @@
export interface OrganizationOut {
id: string;
name: string;
instagram: string | null;
email: string | null;
city: string | null;
logo_url: string | null;
verified: boolean;
status: string;
}
export interface UserOut { export interface UserOut {
id: string; id: string;
email: string; email: string;
@@ -5,7 +16,7 @@ export interface UserOut {
phone: string | null; phone: string | null;
role: "member" | "organizer" | "admin"; role: "member" | "organizer" | "admin";
status: "pending" | "approved" | "rejected"; status: "pending" | "approved" | "rejected";
organization_name: string | null;
instagram_handle: string | null; instagram_handle: string | null;
organization: OrganizationOut | null;
created_at: string; created_at: string;
} }