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:
@@ -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')
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
11
backend/app/crud/crud_organization.py
Normal file
11
backend/app/crud/crud_organization.py
Normal 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()
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
7
backend/app/schemas/auth/__init__.py
Normal file
7
backend/app/schemas/auth/__init__.py
Normal 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"]
|
||||||
5
backend/app/schemas/auth/logout_request.py
Normal file
5
backend/app/schemas/auth/logout_request.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
5
backend/app/schemas/auth/refresh_request.py
Normal file
5
backend/app/schemas/auth/refresh_request.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
@@ -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.
|
||||||
10
backend/app/schemas/auth/token_pair.py
Normal file
10
backend/app/schemas/auth/token_pair.py
Normal 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
|
||||||
7
backend/app/schemas/auth/token_refreshed.py
Normal file
7
backend/app/schemas/auth/token_refreshed.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class TokenRefreshed(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
5
backend/app/schemas/championship/__init__.py
Normal file
5
backend/app/schemas/championship/__init__.py
Normal 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"]
|
||||||
19
backend/app/schemas/championship/create.py
Normal file
19
backend/app/schemas/championship/create.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
19
backend/app/schemas/championship/update.py
Normal file
19
backend/app/schemas/championship/update.py
Normal 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
|
||||||
4
backend/app/schemas/organization/__init__.py
Normal file
4
backend/app/schemas/organization/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from app.schemas.organization.out import OrganizationOut
|
||||||
|
from app.schemas.organization.brief import OrganizationBrief
|
||||||
|
|
||||||
|
__all__ = ["OrganizationOut", "OrganizationBrief"]
|
||||||
13
backend/app/schemas/organization/brief.py
Normal file
13
backend/app/schemas/organization/brief.py
Normal 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
|
||||||
16
backend/app/schemas/organization/out.py
Normal file
16
backend/app/schemas/organization/out.py
Normal 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
|
||||||
4
backend/app/schemas/participant/__init__.py
Normal file
4
backend/app/schemas/participant/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from app.schemas.participant.out import ParticipantListOut
|
||||||
|
from app.schemas.participant.publish import ParticipantListPublish
|
||||||
|
|
||||||
|
__all__ = ["ParticipantListOut", "ParticipantListPublish"]
|
||||||
@@ -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
|
|
||||||
5
backend/app/schemas/participant/publish.py
Normal file
5
backend/app/schemas/participant/publish.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ParticipantListPublish(BaseModel):
|
||||||
|
notes: str | None = None
|
||||||
@@ -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
|
|
||||||
13
backend/app/schemas/registration/__init__.py
Normal file
13
backend/app/schemas/registration/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
10
backend/app/schemas/registration/create.py
Normal file
10
backend/app/schemas/registration/create.py
Normal 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
|
||||||
22
backend/app/schemas/registration/list_item.py
Normal file
22
backend/app/schemas/registration/list_item.py
Normal 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
|
||||||
19
backend/app/schemas/registration/out.py
Normal file
19
backend/app/schemas/registration/out.py
Normal 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
|
||||||
9
backend/app/schemas/registration/update.py
Normal file
9
backend/app/schemas/registration/update.py
Normal 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
|
||||||
6
backend/app/schemas/registration/with_user.py
Normal file
6
backend/app/schemas/registration/with_user.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from app.schemas.registration.out import RegistrationOut
|
||||||
|
from app.schemas.user import UserOut
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationWithUser(RegistrationOut):
|
||||||
|
user: UserOut
|
||||||
6
backend/app/schemas/user/__init__.py
Normal file
6
backend/app/schemas/user/__init__.py
Normal 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"]
|
||||||
6
backend/app/schemas/user/login.py
Normal file
6
backend/app/schemas/user/login.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
21
backend/app/schemas/user/out.py
Normal file
21
backend/app/schemas/user/out.py
Normal 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
|
||||||
@@ -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
|
|
||||||
10
backend/app/schemas/user/update.py
Normal file
10
backend/app/schemas/user/update.py
Normal 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
|
||||||
@@ -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!")
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user