diff --git a/backend/alembic/versions/a1b2c3d4e5f6_add_organizations_table.py b/backend/alembic/versions/a1b2c3d4e5f6_add_organizations_table.py new file mode 100644 index 0000000..09f825d --- /dev/null +++ b/backend/alembic/versions/a1b2c3d4e5f6_add_organizations_table.py @@ -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') diff --git a/backend/app/crud/__init__.py b/backend/app/crud/__init__.py index 02cf482..c169f8b 100644 --- a/backend/app/crud/__init__.py +++ b/backend/app/crud/__init__.py @@ -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"] diff --git a/backend/app/crud/crud_championship.py b/backend/app/crud/crud_championship.py index b8770ab..35b032a 100644 --- a/backend/app/crud/crud_championship.py +++ b/backend/app/crud/crud_championship.py @@ -3,6 +3,7 @@ import uuid from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.models.championship import Championship 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: 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() @@ -26,7 +29,7 @@ async def list_all( skip: int = 0, limit: int = 50, ) -> 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: q = q.where(Championship.status == status) q = q.offset(skip).limit(limit) @@ -34,14 +37,16 @@ async def list_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["judges"] = _serialize(data.judges) payload["categories"] = _serialize(data.categories) + if org_id: + payload["org_id"] = org_id champ = Championship(**payload) db.add(champ) await db.commit() - await db.refresh(champ) + await db.refresh(champ, attribute_names=["organization"]) return champ diff --git a/backend/app/crud/crud_organization.py b/backend/app/crud/crud_organization.py new file mode 100644 index 0000000..c68fc59 --- /dev/null +++ b/backend/app/crud/crud_organization.py @@ -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() diff --git a/backend/app/crud/crud_user.py b/backend/app/crud/crud_user.py index 4ee8ede..6fb8dd4 100644 --- a/backend/app/crud/crud_user.py +++ b/backend/app/crud/crud_user.py @@ -2,7 +2,9 @@ import uuid from sqlalchemy import select 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.schemas.user import UserRegister, UserUpdate 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: 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() 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() @@ -25,23 +31,40 @@ async def create(db: AsyncSession, data: UserRegister) -> User: hashed_password=hash_password(data.password), full_name=data.full_name, phone=data.phone, - role=data.requested_role, - organization_name=data.organization_name, instagram_handle=data.instagram_handle, + role=data.requested_role, # Members are auto-approved; organizers require admin review status="approved" if data.requested_role == "member" else "pending", ) 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.refresh(user) + await db.refresh(user, attribute_names=["organization"]) return 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) + + # 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.refresh(user) + await db.refresh(user, attribute_names=["organization"]) 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]: - 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()) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0fa623a..d5924e5 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,4 +1,5 @@ from app.models.user import User, RefreshToken +from app.models.organization import Organization from app.models.championship import Championship from app.models.registration import Registration from app.models.participant import ParticipantList @@ -7,6 +8,7 @@ from app.models.notification import NotificationLog __all__ = [ "User", "RefreshToken", + "Organization", "Championship", "Registration", "ParticipantList", diff --git a/backend/app/models/championship.py b/backend/app/models/championship.py index 96f251d..53610ee 100644 --- a/backend/app/models/championship.py +++ b/backend/app/models/championship.py @@ -1,7 +1,7 @@ import uuid 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 app.database import Base @@ -11,6 +11,7 @@ class Championship(Base): __tablename__ = "championships" 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) description: Mapped[str | None] = mapped_column(Text) 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() ) + 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] participant_list: Mapped["ParticipantList | None"] = relationship(back_populates="championship", uselist=False, cascade="all, delete-orphan") # type: ignore[name-defined] diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 15d80c1..b417916 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -15,8 +15,6 @@ class User(Base): 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)) - # Organizer-specific fields - organization_name: Mapped[str | None] = mapped_column(String(255)) instagram_handle: Mapped[str | None] = mapped_column(String(100)) role: Mapped[str] = mapped_column(String(20), nullable=False, default="member") # 'pending' | 'approved' | 'rejected' @@ -30,6 +28,7 @@ class User(Base): 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] 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): diff --git a/backend/app/routers/championships.py b/backend/app/routers/championships.py index 8b05797..7e8f953 100644 --- a/backend/app/routers/championships.py +++ b/backend/app/routers/championships.py @@ -38,10 +38,11 @@ async def get_championship( @router.post("", response_model=ChampionshipOut, status_code=status.HTTP_201_CREATED) async def create_championship( data: ChampionshipCreate, - _user: User = Depends(get_organizer), + user: User = Depends(get_organizer), 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) diff --git a/backend/app/schemas/auth/__init__.py b/backend/app/schemas/auth/__init__.py new file mode 100644 index 0000000..b77916c --- /dev/null +++ b/backend/app/schemas/auth/__init__.py @@ -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"] diff --git a/backend/app/schemas/auth/logout_request.py b/backend/app/schemas/auth/logout_request.py new file mode 100644 index 0000000..ae57598 --- /dev/null +++ b/backend/app/schemas/auth/logout_request.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class LogoutRequest(BaseModel): + refresh_token: str diff --git a/backend/app/schemas/auth/refresh_request.py b/backend/app/schemas/auth/refresh_request.py new file mode 100644 index 0000000..72b7b76 --- /dev/null +++ b/backend/app/schemas/auth/refresh_request.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class RefreshRequest(BaseModel): + refresh_token: str diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth/register_response.py similarity index 52% rename from backend/app/schemas/auth.py rename to backend/app/schemas/auth/register_response.py index bd94cf8..2cce85f 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth/register_response.py @@ -3,27 +3,6 @@ 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 - - -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): """ Returned after registration. diff --git a/backend/app/schemas/auth/token_pair.py b/backend/app/schemas/auth/token_pair.py new file mode 100644 index 0000000..d2d0bbf --- /dev/null +++ b/backend/app/schemas/auth/token_pair.py @@ -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 diff --git a/backend/app/schemas/auth/token_refreshed.py b/backend/app/schemas/auth/token_refreshed.py new file mode 100644 index 0000000..e866a35 --- /dev/null +++ b/backend/app/schemas/auth/token_refreshed.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class TokenRefreshed(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" diff --git a/backend/app/schemas/championship/__init__.py b/backend/app/schemas/championship/__init__.py new file mode 100644 index 0000000..5d16e8b --- /dev/null +++ b/backend/app/schemas/championship/__init__.py @@ -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"] diff --git a/backend/app/schemas/championship/create.py b/backend/app/schemas/championship/create.py new file mode 100644 index 0000000..7a3031f --- /dev/null +++ b/backend/app/schemas/championship/create.py @@ -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 diff --git a/backend/app/schemas/championship.py b/backend/app/schemas/championship/out.py similarity index 52% rename from backend/app/schemas/championship.py rename to backend/app/schemas/championship/out.py index 1e1d933..2496d4a 100644 --- a/backend/app/schemas/championship.py +++ b/backend/app/schemas/championship/out.py @@ -4,43 +4,14 @@ from datetime import datetime from pydantic import BaseModel, model_validator - -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 +from app.schemas.organization import OrganizationBrief class ChampionshipOut(BaseModel): model_config = {"from_attributes": True} id: uuid.UUID + org_id: uuid.UUID | None = None title: str description: str | None location: str | None @@ -56,6 +27,7 @@ class ChampionshipOut(BaseModel): source: str instagram_media_id: str | None image_url: str | None + organization: OrganizationBrief | None = None created_at: datetime updated_at: datetime diff --git a/backend/app/schemas/championship/update.py b/backend/app/schemas/championship/update.py new file mode 100644 index 0000000..2e58385 --- /dev/null +++ b/backend/app/schemas/championship/update.py @@ -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 diff --git a/backend/app/schemas/organization/__init__.py b/backend/app/schemas/organization/__init__.py new file mode 100644 index 0000000..1c70c64 --- /dev/null +++ b/backend/app/schemas/organization/__init__.py @@ -0,0 +1,4 @@ +from app.schemas.organization.out import OrganizationOut +from app.schemas.organization.brief import OrganizationBrief + +__all__ = ["OrganizationOut", "OrganizationBrief"] diff --git a/backend/app/schemas/organization/brief.py b/backend/app/schemas/organization/brief.py new file mode 100644 index 0000000..45d3381 --- /dev/null +++ b/backend/app/schemas/organization/brief.py @@ -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 diff --git a/backend/app/schemas/organization/out.py b/backend/app/schemas/organization/out.py new file mode 100644 index 0000000..abb99cf --- /dev/null +++ b/backend/app/schemas/organization/out.py @@ -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 diff --git a/backend/app/schemas/participant/__init__.py b/backend/app/schemas/participant/__init__.py new file mode 100644 index 0000000..20678bb --- /dev/null +++ b/backend/app/schemas/participant/__init__.py @@ -0,0 +1,4 @@ +from app.schemas.participant.out import ParticipantListOut +from app.schemas.participant.publish import ParticipantListPublish + +__all__ = ["ParticipantListOut", "ParticipantListPublish"] diff --git a/backend/app/schemas/participant.py b/backend/app/schemas/participant/out.py similarity index 81% rename from backend/app/schemas/participant.py rename to backend/app/schemas/participant/out.py index 8b3c553..d63a37e 100644 --- a/backend/app/schemas/participant.py +++ b/backend/app/schemas/participant/out.py @@ -13,7 +13,3 @@ class ParticipantListOut(BaseModel): published_at: datetime | None notes: str | None created_at: datetime - - -class ParticipantListPublish(BaseModel): - notes: str | None = None diff --git a/backend/app/schemas/participant/publish.py b/backend/app/schemas/participant/publish.py new file mode 100644 index 0000000..64aa160 --- /dev/null +++ b/backend/app/schemas/participant/publish.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class ParticipantListPublish(BaseModel): + notes: str | None = None diff --git a/backend/app/schemas/registration.py b/backend/app/schemas/registration.py deleted file mode 100644 index 67f0153..0000000 --- a/backend/app/schemas/registration.py +++ /dev/null @@ -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 diff --git a/backend/app/schemas/registration/__init__.py b/backend/app/schemas/registration/__init__.py new file mode 100644 index 0000000..100a052 --- /dev/null +++ b/backend/app/schemas/registration/__init__.py @@ -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", +] diff --git a/backend/app/schemas/registration/create.py b/backend/app/schemas/registration/create.py new file mode 100644 index 0000000..bd64bd1 --- /dev/null +++ b/backend/app/schemas/registration/create.py @@ -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 diff --git a/backend/app/schemas/registration/list_item.py b/backend/app/schemas/registration/list_item.py new file mode 100644 index 0000000..e11bb61 --- /dev/null +++ b/backend/app/schemas/registration/list_item.py @@ -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 diff --git a/backend/app/schemas/registration/out.py b/backend/app/schemas/registration/out.py new file mode 100644 index 0000000..43fd6d8 --- /dev/null +++ b/backend/app/schemas/registration/out.py @@ -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 diff --git a/backend/app/schemas/registration/update.py b/backend/app/schemas/registration/update.py new file mode 100644 index 0000000..8df51e8 --- /dev/null +++ b/backend/app/schemas/registration/update.py @@ -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 diff --git a/backend/app/schemas/registration/with_user.py b/backend/app/schemas/registration/with_user.py new file mode 100644 index 0000000..d63e906 --- /dev/null +++ b/backend/app/schemas/registration/with_user.py @@ -0,0 +1,6 @@ +from app.schemas.registration.out import RegistrationOut +from app.schemas.user import UserOut + + +class RegistrationWithUser(RegistrationOut): + user: UserOut diff --git a/backend/app/schemas/user/__init__.py b/backend/app/schemas/user/__init__.py new file mode 100644 index 0000000..9110635 --- /dev/null +++ b/backend/app/schemas/user/__init__.py @@ -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"] diff --git a/backend/app/schemas/user/login.py b/backend/app/schemas/user/login.py new file mode 100644 index 0000000..dbe6edd --- /dev/null +++ b/backend/app/schemas/user/login.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel, EmailStr + + +class UserLogin(BaseModel): + email: EmailStr + password: str diff --git a/backend/app/schemas/user/out.py b/backend/app/schemas/user/out.py new file mode 100644 index 0000000..c799481 --- /dev/null +++ b/backend/app/schemas/user/out.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user/register.py similarity index 53% rename from backend/app/schemas/user.py rename to backend/app/schemas/user/register.py index 3c5d896..d14f9a5 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user/register.py @@ -1,5 +1,3 @@ -import uuid -from datetime import datetime from typing import Literal from pydantic import BaseModel, EmailStr, field_validator @@ -22,31 +20,3 @@ class UserRegister(BaseModel): if info.data.get("requested_role") == "organizer" and not v: raise ValueError("Organization name is required for organizer registration") 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 diff --git a/backend/app/schemas/user/update.py b/backend/app/schemas/user/update.py new file mode 100644 index 0000000..2dc8952 --- /dev/null +++ b/backend/app/schemas/user/update.py @@ -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 diff --git a/backend/seed.py b/backend/seed.py index 95b24c5..d3c1cb1 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -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 """ import asyncio @@ -7,6 +7,7 @@ from datetime import UTC, datetime, timedelta from app.database import AsyncSessionLocal from app.models.championship import Championship +from app.models.organization import Organization from app.models.user import User from app.services.auth_service import hash_password from sqlalchemy import select @@ -29,6 +30,7 @@ async def seed(): "password": "Org1234", "role": "organizer", "status": "approved", + "instagram_handle": "@ekaterina_pole", }, { "email": "member@pole.dev", @@ -36,6 +38,7 @@ async def seed(): "password": "Member1234", "role": "member", "status": "approved", + "instagram_handle": "@anna_petrova", }, { "email": "pending@pole.dev", @@ -57,11 +60,11 @@ async def seed(): full_name=ud["full_name"], role=ud["role"], status=ud["status"], + instagram_handle=ud.get("instagram_handle"), ) db.add(user) print(f" Created user: {ud['email']}") else: - # Update role/status if needed user.role = ud["role"] user.status = ud["status"] user.hashed_password = hash_password(ud["password"]) @@ -70,6 +73,27 @@ async def seed(): 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_data = [ { @@ -115,11 +139,12 @@ async def seed(): ) champ = result.scalar_one_or_none() if champ is None: - champ = Championship(**cd) + champ = Championship(org_id=org.id, **cd) db.add(champ) print(f" Created championship: {cd['title']}") else: - print(f" Championship already exists: {cd['title']}") + champ.org_id = org.id + print(f" Updated championship: {cd['title']}") await db.commit() print("\nSeed complete!") diff --git a/web/src/app/(app)/championships/[id]/page.tsx b/web/src/app/(app)/championships/[id]/page.tsx index d5ddaf9..c6b4a73 100644 --- a/web/src/app/(app)/championships/[id]/page.tsx +++ b/web/src/app/(app)/championships/[id]/page.tsx @@ -8,7 +8,7 @@ import { RegistrationTimeline } from "@/components/registrations/RegistrationTim import { StatusBadge } from "@/components/shared/StatusBadge"; import { Button } from "@/components/ui/button"; 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 }> }) { const { id } = use(params); @@ -54,7 +54,6 @@ export default function ChampionshipDetailPage({ params }: { params: Promise<{ i
{championship.subtitle}
}{user.email}
- {user.organization_name && ( + {user.organization?.name && (