From 3c150bc267e450b0c2266708ef875c59a9a6cb0f Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Sat, 9 May 2026 22:14:41 +0300 Subject: [PATCH 01/38] fix(pydantic): public models cause validation errors --- backend/app/schemas/task.py | 5 ++--- backend/app/schemas/team.py | 23 +++++++++++++++++++---- backend/app/schemas/tournament.py | 10 +++++----- backend/app/schemas/user.py | 10 +++++----- 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index 5d51960..b80950f 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -19,15 +19,14 @@ class TaskBase(BaseModel): end_time: datetime requirements: list[str] = Field(...) + +class TaskCreate(TaskBase): @field_validator("title") @classmethod def check_title(cls, value: str): if not value.strip(): raise ValueError("Title cannot be empty") return value.strip() - - -class TaskCreate(TaskBase): @field_validator("start_time") @classmethod def start_not_past(cls, value: datetime): diff --git a/backend/app/schemas/team.py b/backend/app/schemas/team.py index 712d526..424e44f 100644 --- a/backend/app/schemas/team.py +++ b/backend/app/schemas/team.py @@ -15,22 +15,30 @@ class TeamMemberBase(BaseModel): telegram_username: str educational_institution: str + +class TeamMemberCreate(BaseModel): + full_name: str = Field(..., min_length=3) + email: EmailStr = Field(..., description="Contact email") + telegram_username: str + educational_institution: str + @field_validator("email") @classmethod def normalize_email(cls, value: EmailStr): return value.lower() -class TeamMemberCreate(BaseModel): - pass - - class TeamMemberUpdate(BaseModel): full_name: str | None = Field(None, min_length=3) email: EmailStr | None = Field(None, description="Contact email") telegram_username: str | None = None educational_institution: str | None = None + @field_validator("email") + @classmethod + def normalize_email(cls, value: EmailStr): + return value.lower() + class TeamMemberPublic(BaseModel): pass @@ -41,6 +49,8 @@ class TeamBase(BaseModel): team_email: EmailStr = Field(..., description="Contact email") contact_info: PhoneNumber = Field(..., description="Phone number") + +class TeamCreate(TeamBase): @field_validator("team_email") @classmethod def normalize_email(cls, value: EmailStr): @@ -52,6 +62,11 @@ class TeamUpdate(BaseModel): team_email: EmailStr | None = None contact_info: PhoneNumber | None = None + @field_validator("team_email") + @classmethod + def normalize_email(cls, value: EmailStr): + return value.lower() + class TeamModel(TeamBase): captain: TeamMemberPublic diff --git a/backend/app/schemas/tournament.py b/backend/app/schemas/tournament.py index 7c2d67d..9e33bb6 100644 --- a/backend/app/schemas/tournament.py +++ b/backend/app/schemas/tournament.py @@ -44,8 +44,12 @@ class TournamentBase(BaseModel): max_people_in_team: int = Field(..., gt=0) max_teams: int = Field(..., gt=0) + +class TournamentCreate(TournamentBase): + juries: list[int | str] = Field(..., description="Jury ids") + @model_validator(mode="after") - def validate_dates(self) -> "TournamentBase": + def validate_dates(self) -> "TournamentCreate": now = drop_time(datetime.now(timezone.utc).replace(tzinfo=None)) @@ -61,10 +65,6 @@ def validate_dates(self) -> "TournamentBase": return self -class TournamentCreate(TournamentBase): - juries: list[int | str] = Field(..., description="Jury ids") - - class TournamentUpdate(BaseModel): title: StrippedStr | None = Field(None, min_length=3) description: str | None = None diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index e9f337b..4d829ec 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -15,6 +15,11 @@ class UserBase(BaseModel): full_name: str = Field(..., description="Username") + +class UserCreate(UserBase): + firebase_uid: str = Field(..., description="Firebase user id") + email: EmailStr = Field(..., description="Email") + @field_validator("full_name") @classmethod def check_name(cls, value: str): @@ -23,11 +28,6 @@ def check_name(cls, value: str): return value -class UserCreate(UserBase): - firebase_uid: str = Field(..., description="Firebase user id") - email: EmailStr = Field(..., description="Email") - - class UserUpdate(UserBase): full_name: str | None = None email: str | None = None From 296bfa24b56dde07213eb4e56fc4737db8143f86 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Sat, 9 May 2026 21:41:49 +0300 Subject: [PATCH 02/38] fix(models): no Tournament.submissions field --- backend/app/dependencies/current_user.py | 27 +++++++++++++++++++----- backend/app/models/tournament.py | 10 +++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/backend/app/dependencies/current_user.py b/backend/app/dependencies/current_user.py index 9579b8e..788eb1d 100644 --- a/backend/app/dependencies/current_user.py +++ b/backend/app/dependencies/current_user.py @@ -86,15 +86,32 @@ async def get_user(identifier: str | int, session: SessionDep): statement = select(User).where(User.id == identifier) else: raise ValueError("Wrong user identifier type") - + + # Eager load all relationships needed for CurrentUser response + # Load created_tournaments with their nested relationships statement = statement.options( - selectinload(User.roles), - selectinload(User.notifications), selectinload(User.created_tournaments).selectinload(Tournament.juries), + selectinload(User.created_tournaments).selectinload(Tournament.teams), + selectinload(User.created_tournaments).selectinload(Tournament.tasks), selectinload(User.created_tournaments).selectinload(Tournament.status), - selectinload(User.evaluates_in), + selectinload(User.created_tournaments).selectinload(Tournament.creator), + # Load evaluates_in tournaments with their nested relationships + selectinload(User.evaluates_in).selectinload(Tournament.juries), + selectinload(User.evaluates_in).selectinload(Tournament.teams), + selectinload(User.evaluates_in).selectinload(Tournament.tasks), + selectinload(User.evaluates_in).selectinload(Tournament.status), + selectinload(User.evaluates_in).selectinload(Tournament.creator), + # Load participates_in tournaments with their nested relationships + selectinload(User.participates_in).selectinload(Tournament.juries), + selectinload(User.participates_in).selectinload(Tournament.teams), + selectinload(User.participates_in).selectinload(Tournament.tasks), + selectinload(User.participates_in).selectinload(Tournament.status), + selectinload(User.participates_in).selectinload(Tournament.creator), + # Load other top-level relationships + selectinload(User.roles), + selectinload(User.notifications) ) - user = (await session.execute(statement)).scalar_one_or_none() + user = (await session.execute(statement)).scalar() if not user: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found!") diff --git a/backend/app/models/tournament.py b/backend/app/models/tournament.py index a0d5c30..a787ed2 100644 --- a/backend/app/models/tournament.py +++ b/backend/app/models/tournament.py @@ -6,6 +6,8 @@ from .base import Base from .mixin import PKMixin, OptionMixin from .task import Task +from .team import Team +from .submission import Submission tournament_juries = Table( "tournament_juries", @@ -47,6 +49,14 @@ class Tournament(Base, PKMixin): lazy="selectin", cascade="all, delete-orphan", ) + submissions: Mapped[list["Submission"]] = relationship( + "Submission", + secondary=lambda: Team.__table__, + primaryjoin=lambda: Tournament.id == Team.tournament_id, + secondaryjoin=lambda: Team.id == Submission.team_id, + viewonly=True, + lazy="selectin", + ) juries: Mapped[list["User"]] = relationship( back_populates="evaluates_in", lazy="selectin", secondary=tournament_juries ) From 0b08eaee6c3b860f4043b2dcf92b2636de8486c3 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Tue, 12 May 2026 22:26:55 +0300 Subject: [PATCH 03/38] feat: implement all necessary models and schemas for jury panel --- backend/alembic/versions/041a22bc2dd7_.py | 80 +++++++ backend/alembic/versions/042d39143291_.py | 38 ++++ backend/alembic/versions/0a7a46f486f9_.py | 147 +++++++++++++ backend/alembic/versions/1bd3d49f4a5d_.py | 36 ++++ backend/alembic/versions/3086dc6c8e3b_.py | 46 ++++ backend/alembic/versions/3a6e9da6c41d_.py | 38 ++++ backend/alembic/versions/58d30e2d08d7_.py | 38 ++++ backend/alembic/versions/6175928612e4_.py | 48 +++++ ...2e_add_juryassignmentstatusoption_table.py | 46 ++++ backend/alembic/versions/7145b4774739_.py | 44 ++++ backend/alembic/versions/9f92bb2d0e7c_.py | 58 ++++++ .../versions/f731d7aac974_fix_schema.py | 60 ++++++ backend/alembic/versions/f9a31f841ce0_.py | 36 ++++ backend/app/config.py | 20 +- backend/app/core/seeds/__init__.py | 2 - backend/app/models/__init__.py | 11 +- backend/app/models/evaluation.py | 104 ++++++---- backend/app/models/submission.py | 20 +- backend/app/models/task.py | 43 ++++ backend/app/models/team.py | 15 +- backend/app/models/tournament.py | 14 +- backend/app/schemas/__init__.py | 32 ++- backend/app/schemas/evaluation.py | 72 ++++++- backend/app/schemas/notification.py | 4 +- backend/app/schemas/submission.py | 22 +- backend/app/schemas/task.py | 36 +++- backend/app/schemas/team.py | 29 +-- backend/app/schemas/tournament.py | 35 ++-- backend/app/schemas/user.py | 1 - backend/tests/factories.py | 80 ++++++- backend/tests/test_models.py | 35 ++-- shared/app_config.json | 196 ++---------------- 32 files changed, 1143 insertions(+), 343 deletions(-) create mode 100644 backend/alembic/versions/041a22bc2dd7_.py create mode 100644 backend/alembic/versions/042d39143291_.py create mode 100644 backend/alembic/versions/0a7a46f486f9_.py create mode 100644 backend/alembic/versions/1bd3d49f4a5d_.py create mode 100644 backend/alembic/versions/3086dc6c8e3b_.py create mode 100644 backend/alembic/versions/3a6e9da6c41d_.py create mode 100644 backend/alembic/versions/58d30e2d08d7_.py create mode 100644 backend/alembic/versions/6175928612e4_.py create mode 100644 backend/alembic/versions/641c9fa7192e_add_juryassignmentstatusoption_table.py create mode 100644 backend/alembic/versions/7145b4774739_.py create mode 100644 backend/alembic/versions/9f92bb2d0e7c_.py create mode 100644 backend/alembic/versions/f731d7aac974_fix_schema.py create mode 100644 backend/alembic/versions/f9a31f841ce0_.py diff --git a/backend/alembic/versions/041a22bc2dd7_.py b/backend/alembic/versions/041a22bc2dd7_.py new file mode 100644 index 0000000..12987e1 --- /dev/null +++ b/backend/alembic/versions/041a22bc2dd7_.py @@ -0,0 +1,80 @@ +"""empty message + +Revision ID: 041a22bc2dd7 +Revises: 0a7a46f486f9 +Create Date: 2026-05-10 07:48:08.241897 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '041a22bc2dd7' +down_revision: Union[str, Sequence[str], None] = '0a7a46f486f9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Step 1: First, drop the current FK constraints that reference submissions.team_id + op.execute("ALTER TABLE evaluations DROP CONSTRAINT IF EXISTS evaluations_submission_id_fkey") + op.execute("ALTER TABLE jury_assignments DROP CONSTRAINT IF EXISTS jury_assignments_submission_id_fkey") + op.execute("ALTER TABLE submission_urls DROP CONSTRAINT IF EXISTS submission_urls_submission_id_fkey") + + # Step 2: Now restructure the submissions table + # Drop the current team_id primary key and make id the primary key + op.execute("ALTER TABLE submissions DROP CONSTRAINT IF EXISTS submissions_pkey") + op.execute("ALTER TABLE submissions ADD PRIMARY KEY (id)") + + # Add unique constraint on team_id to preserve the uniqueness requirement + op.execute("ALTER TABLE submissions ADD CONSTRAINT submissions_team_id_key UNIQUE(team_id)") + + # Step 3: Make task_id NOT NULL + op.execute("ALTER TABLE submissions ALTER COLUMN task_id SET NOT NULL") + + # Step 4: Add the composite unique constraint on (team_id, task_id) + op.execute("ALTER TABLE submissions ADD CONSTRAINT submissions_team_id_task_id_key UNIQUE(team_id, task_id)") + + # Step 5: Now recreate the foreign keys to reference submissions.id + op.execute("ALTER TABLE evaluations ADD CONSTRAINT evaluations_submission_id_fkey FOREIGN KEY(submission_id) REFERENCES submissions (id) ON DELETE CASCADE") + + op.execute("ALTER TABLE jury_assignments ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT now()") + op.execute("ALTER TABLE jury_assignments ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT now()") + op.execute("ALTER TABLE jury_assignments DROP COLUMN IF EXISTS assigned_at") + op.execute("ALTER TABLE jury_assignments ADD CONSTRAINT jury_assignments_submission_id_fkey FOREIGN KEY(submission_id) REFERENCES submissions (id) ON DELETE CASCADE") + + op.execute("ALTER TABLE submission_urls ADD CONSTRAINT submission_urls_submission_id_fkey FOREIGN KEY(submission_id) REFERENCES submissions (id) ON DELETE CASCADE") + + +def downgrade() -> None: + """Downgrade schema.""" + # Step 1: Drop the new constraints and FK + op.execute("ALTER TABLE submission_urls DROP CONSTRAINT IF EXISTS submission_urls_submission_id_fkey") + op.execute("ALTER TABLE jury_assignments DROP CONSTRAINT IF EXISTS jury_assignments_submission_id_fkey") + op.execute("ALTER TABLE evaluations DROP CONSTRAINT IF EXISTS evaluations_submission_id_fkey") + + # Step 2: Restore jury_assignments columns + op.execute("ALTER TABLE jury_assignments DROP COLUMN IF EXISTS created_at") + op.execute("ALTER TABLE jury_assignments DROP COLUMN IF EXISTS updated_at") + op.execute("ALTER TABLE jury_assignments ADD COLUMN assigned_at TIMESTAMP DEFAULT now()") + + # Step 3: Restore submissions table structure + # Drop the new primary key and constraints + op.execute("ALTER TABLE submissions DROP CONSTRAINT IF EXISTS submissions_team_id_task_id_key") + op.execute("ALTER TABLE submissions DROP CONSTRAINT IF EXISTS submissions_team_id_key") + op.execute("ALTER TABLE submissions DROP CONSTRAINT IF EXISTS submissions_pkey") + + # Restore team_id as primary key + op.execute("ALTER TABLE submissions ADD PRIMARY KEY (team_id)") + + # Make task_id nullable again + op.execute("ALTER TABLE submissions ALTER COLUMN task_id DROP NOT NULL") + + # Step 4: Recreate the old foreign keys (referencing team_id) + op.execute("ALTER TABLE submission_urls ADD CONSTRAINT submission_urls_submission_id_fkey FOREIGN KEY(submission_id) REFERENCES submissions (team_id) ON DELETE CASCADE") + op.execute("ALTER TABLE jury_assignments ADD CONSTRAINT jury_assignments_submission_id_fkey FOREIGN KEY(submission_id) REFERENCES submissions (team_id) ON DELETE CASCADE") + op.execute("ALTER TABLE evaluations ADD CONSTRAINT evaluations_submission_id_fkey FOREIGN KEY(submission_id) REFERENCES submissions (team_id) ON DELETE CASCADE") diff --git a/backend/alembic/versions/042d39143291_.py b/backend/alembic/versions/042d39143291_.py new file mode 100644 index 0000000..4d2d290 --- /dev/null +++ b/backend/alembic/versions/042d39143291_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 042d39143291 +Revises: 3a6e9da6c41d +Create Date: 2026-05-11 20:07:30.066682 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '042d39143291' +down_revision: Union[str, Sequence[str], None] = '3a6e9da6c41d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('evaluations', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('evaluations_assignment_id_jury_id_submission_id_key'), type_='unique') + batch_op.create_unique_constraint(None, ['jury_id', 'submission_id']) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('evaluations', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='unique') + batch_op.create_unique_constraint(batch_op.f('evaluations_assignment_id_jury_id_submission_id_key'), ['assignment_id', 'jury_id', 'submission_id'], postgresql_nulls_not_distinct=False) + + # ### end Alembic commands ### diff --git a/backend/alembic/versions/0a7a46f486f9_.py b/backend/alembic/versions/0a7a46f486f9_.py new file mode 100644 index 0000000..53a4ee4 --- /dev/null +++ b/backend/alembic/versions/0a7a46f486f9_.py @@ -0,0 +1,147 @@ +"""empty message + +Revision ID: 0a7a46f486f9 +Revises: dfc9ca7d5a57 +Create Date: 2026-05-09 23:21:36.265107 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0a7a46f486f9' +down_revision: Union[str, Sequence[str], None] = 'dfc9ca7d5a57' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('task_evaluation_categories', + sa.Column('task_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('order', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('task_evaluation_criteria', + sa.Column('category_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=True), + sa.Column('weight', sa.Integer(), nullable=False), + sa.Column('max_score', sa.Integer(), nullable=False), + sa.Column('order', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['category_id'], ['task_evaluation_categories.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('jury_assignments', + sa.Column('task_id', sa.Integer(), nullable=False), + sa.Column('submission_id', sa.Integer(), nullable=False), + sa.Column('jury_id', sa.Integer(), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('assigned_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['jury_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['submission_id'], ['submissions.team_id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('submission_id', 'jury_id') + ) + op.create_table('criterion_scores', + sa.Column('evaluation_id', sa.Integer(), nullable=False), + sa.Column('criterion_id', sa.Integer(), nullable=False), + sa.Column('score', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['criterion_id'], ['task_evaluation_criteria.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['evaluation_id'], ['evaluations.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('evaluation_id', 'criterion_id') + ) + op.drop_table('evaluation_requirements') + op.drop_table('requirement_evaluations') + with op.batch_alter_table('evaluations', schema=None) as batch_op: + batch_op.add_column(sa.Column('assignment_id', sa.Integer(), nullable=False)) + batch_op.add_column(sa.Column('comment', sa.String(), nullable=True)) + batch_op.add_column(sa.Column('created_at', sa.DateTime(), nullable=True)) + batch_op.drop_constraint(batch_op.f('evaluations_submission_id_jury_id_key'), type_='unique') + batch_op.create_unique_constraint(None, ['assignment_id']) + batch_op.drop_constraint(batch_op.f('evaluations_submission_id_fkey'), type_='foreignkey') + batch_op.create_foreign_key(None, 'submissions', ['submission_id'], ['team_id'], ondelete='CASCADE') + batch_op.create_foreign_key(None, 'jury_assignments', ['assignment_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('submission_urls', schema=None) as batch_op: + batch_op.add_column(sa.Column('value', sa.String(), nullable=False)) + batch_op.add_column(sa.Column('id', sa.Integer(), nullable=False)) + batch_op.drop_constraint(batch_op.f('submission_urls_submission_id_fkey'), type_='foreignkey') + batch_op.create_foreign_key(None, 'submissions', ['submission_id'], ['team_id'], ondelete='CASCADE') + + with op.batch_alter_table('submissions', schema=None) as batch_op: + batch_op.add_column(sa.Column('task_id', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('id', sa.Integer(), nullable=False)) + batch_op.create_foreign_key(None, 'tasks', ['task_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('tasks', schema=None) as batch_op: + batch_op.add_column(sa.Column('evaluation_deadline', sa.DateTime(), nullable=True)) + batch_op.add_column(sa.Column('min_reviews_per_submission', sa.Integer(), nullable=False)) + batch_op.add_column(sa.Column('score_scale_max', sa.Integer(), nullable=False)) + batch_op.add_column(sa.Column('leaderboard_visibility', sa.Boolean(), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('tasks', schema=None) as batch_op: + batch_op.drop_column('leaderboard_visibility') + batch_op.drop_column('score_scale_max') + batch_op.drop_column('min_reviews_per_submission') + batch_op.drop_column('evaluation_deadline') + + with op.batch_alter_table('submissions', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint(None, type_='unique') + batch_op.drop_column('id') + batch_op.drop_column('task_id') + + with op.batch_alter_table('submission_urls', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key(batch_op.f('submission_urls_submission_id_fkey'), 'submissions', ['submission_id'], ['team_id'], ondelete='CASCADE') + batch_op.drop_column('id') + batch_op.drop_column('value') + + with op.batch_alter_table('evaluations', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key(batch_op.f('evaluations_submission_id_fkey'), 'submissions', ['submission_id'], ['team_id'], ondelete='CASCADE') + batch_op.drop_constraint(None, type_='unique') + batch_op.create_unique_constraint(batch_op.f('evaluations_submission_id_jury_id_key'), ['submission_id', 'jury_id'], postgresql_nulls_not_distinct=False) + batch_op.drop_column('created_at') + batch_op.drop_column('comment') + batch_op.drop_column('assignment_id') + + op.create_table('evaluation_requirements', + sa.Column('requirement_evaluation_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('requirement_id', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['requirement_evaluation_id'], ['requirement_evaluations.id'], name=op.f('evaluation_requirements_requirement_evaluation_id_fkey'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['requirement_id'], ['task_requirement_options.name'], name=op.f('evaluation_requirements_requirement_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('requirement_evaluation_id', 'requirement_id', name=op.f('evaluation_requirements_pkey')) + ) + op.create_table('requirement_evaluations', + sa.Column('evaluation_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('score', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.ForeignKeyConstraint(['evaluation_id'], ['evaluations.id'], name=op.f('requirement_evaluations_evaluation_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('requirement_evaluations_pkey')) + ) + op.drop_table('criterion_scores') + op.drop_table('jury_assignments') + op.drop_table('task_evaluation_criteria') + op.drop_table('task_evaluation_categories') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/1bd3d49f4a5d_.py b/backend/alembic/versions/1bd3d49f4a5d_.py new file mode 100644 index 0000000..9a840d5 --- /dev/null +++ b/backend/alembic/versions/1bd3d49f4a5d_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: 1bd3d49f4a5d +Revises: 9f92bb2d0e7c +Create Date: 2026-05-10 08:19:27.226157 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '1bd3d49f4a5d' +down_revision: Union[str, Sequence[str], None] = '9f92bb2d0e7c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('submission_urls', schema=None) as batch_op: + batch_op.drop_column('id') + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('submission_urls', schema=None) as batch_op: + batch_op.add_column(sa.Column('id', sa.INTEGER(), autoincrement=False, nullable=False)) + + # ### end Alembic commands ### diff --git a/backend/alembic/versions/3086dc6c8e3b_.py b/backend/alembic/versions/3086dc6c8e3b_.py new file mode 100644 index 0000000..7af31c1 --- /dev/null +++ b/backend/alembic/versions/3086dc6c8e3b_.py @@ -0,0 +1,46 @@ +"""empty message + +Revision ID: 3086dc6c8e3b +Revises: 641c9fa7192e +Create Date: 2026-05-12 08:38:58.285894 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3086dc6c8e3b' +down_revision: Union[str, Sequence[str], None] = '641c9fa7192e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('jury_assignment_statuses', + sa.Column('name', sa.String(), nullable=False), + sa.Column('display_name', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('name') + ) + with op.batch_alter_table('jury_assignments', schema=None) as batch_op: + batch_op.add_column(sa.Column('status_id', sa.String(), nullable=False)) + batch_op.create_foreign_key(None, 'jury_assignment_statuses', ['status_id'], ['name'], ondelete='CASCADE') + batch_op.drop_column('status') + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('jury_assignments', schema=None) as batch_op: + batch_op.add_column(sa.Column('status', sa.VARCHAR(), autoincrement=False, nullable=False)) + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_column('status_id') + + op.drop_table('jury_assignment_statuses') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/3a6e9da6c41d_.py b/backend/alembic/versions/3a6e9da6c41d_.py new file mode 100644 index 0000000..1378e36 --- /dev/null +++ b/backend/alembic/versions/3a6e9da6c41d_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 3a6e9da6c41d +Revises: 58d30e2d08d7 +Create Date: 2026-05-11 20:04:35.029704 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3a6e9da6c41d' +down_revision: Union[str, Sequence[str], None] = '58d30e2d08d7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('evaluations', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('evaluations_assignment_id_jury_id_key'), type_='unique') + batch_op.create_unique_constraint(None, ['assignment_id', 'jury_id', 'submission_id']) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('evaluations', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='unique') + batch_op.create_unique_constraint(batch_op.f('evaluations_assignment_id_jury_id_key'), ['assignment_id', 'jury_id'], postgresql_nulls_not_distinct=False) + + # ### end Alembic commands ### diff --git a/backend/alembic/versions/58d30e2d08d7_.py b/backend/alembic/versions/58d30e2d08d7_.py new file mode 100644 index 0000000..368c03b --- /dev/null +++ b/backend/alembic/versions/58d30e2d08d7_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 58d30e2d08d7 +Revises: 6175928612e4 +Create Date: 2026-05-11 20:02:41.760584 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '58d30e2d08d7' +down_revision: Union[str, Sequence[str], None] = '6175928612e4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('evaluations', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('evaluations_assignment_id_key'), type_='unique') + batch_op.create_unique_constraint(None, ['assignment_id', 'jury_id']) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('evaluations', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='unique') + batch_op.create_unique_constraint(batch_op.f('evaluations_assignment_id_key'), ['assignment_id'], postgresql_nulls_not_distinct=False) + + # ### end Alembic commands ### diff --git a/backend/alembic/versions/6175928612e4_.py b/backend/alembic/versions/6175928612e4_.py new file mode 100644 index 0000000..66c796d --- /dev/null +++ b/backend/alembic/versions/6175928612e4_.py @@ -0,0 +1,48 @@ +"""empty message + +Revision ID: 6175928612e4 +Revises: f9a31f841ce0 +Create Date: 2026-05-11 08:11:09.616681 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '6175928612e4' +down_revision: Union[str, Sequence[str], None] = 'f9a31f841ce0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('evaluations', schema=None) as batch_op: + batch_op.add_column(sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False)) + batch_op.add_column(sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False)) + batch_op.drop_column('submitted_at') + + with op.batch_alter_table('submissions', schema=None) as batch_op: + batch_op.add_column(sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False)) + batch_op.add_column(sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('submissions', schema=None) as batch_op: + batch_op.drop_column('updated_at') + batch_op.drop_column('created_at') + + with op.batch_alter_table('evaluations', schema=None) as batch_op: + batch_op.add_column(sa.Column('submitted_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True)) + batch_op.drop_column('updated_at') + batch_op.drop_column('created_at') + + # ### end Alembic commands ### diff --git a/backend/alembic/versions/641c9fa7192e_add_juryassignmentstatusoption_table.py b/backend/alembic/versions/641c9fa7192e_add_juryassignmentstatusoption_table.py new file mode 100644 index 0000000..6f6da36 --- /dev/null +++ b/backend/alembic/versions/641c9fa7192e_add_juryassignmentstatusoption_table.py @@ -0,0 +1,46 @@ +"""Add JuryAssignmentStatusOption table + +Revision ID: 641c9fa7192e +Revises: 042d39143291 +Create Date: 2026-05-12 08:35:01.926903 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '641c9fa7192e' +down_revision: Union[str, Sequence[str], None] = '042d39143291' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('jury_assignment_statuses', + sa.Column('name', sa.String(), nullable=False), + sa.Column('display_name', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('name') + ) + with op.batch_alter_table('jury_assignments', schema=None) as batch_op: + batch_op.add_column(sa.Column('status_id', sa.String(), nullable=False, server_default='assigned')) + batch_op.create_foreign_key(None, 'jury_assignment_statuses', ['status_id'], ['name'], ondelete='CASCADE') + batch_op.drop_column('status') + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('jury_assignments', schema=None) as batch_op: + batch_op.add_column(sa.Column('status', sa.VARCHAR(), autoincrement=False, nullable=False)) + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_column('status_id') + + op.drop_table('jury_assignment_statuses') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/7145b4774739_.py b/backend/alembic/versions/7145b4774739_.py new file mode 100644 index 0000000..22a0980 --- /dev/null +++ b/backend/alembic/versions/7145b4774739_.py @@ -0,0 +1,44 @@ +"""empty message + +Revision ID: 7145b4774739 +Revises: 3086dc6c8e3b +Create Date: 2026-05-12 17:56:17.608823 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '7145b4774739' +down_revision: Union[str, Sequence[str], None] = '3086dc6c8e3b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('tasks', schema=None) as batch_op: + batch_op.add_column(sa.Column('max_score', sa.Integer(), nullable=False)) + batch_op.add_column(sa.Column('is_leaderboard_visible', sa.Boolean(), nullable=False)) + batch_op.drop_column('leaderboard_visibility') + batch_op.drop_column('score_scale_max') + batch_op.drop_column('evaluation_deadline') + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('tasks', schema=None) as batch_op: + batch_op.add_column(sa.Column('evaluation_deadline', postgresql.TIMESTAMP(), autoincrement=False, nullable=True)) + batch_op.add_column(sa.Column('score_scale_max', sa.INTEGER(), autoincrement=False, nullable=False)) + batch_op.add_column(sa.Column('leaderboard_visibility', sa.BOOLEAN(), autoincrement=False, nullable=False)) + batch_op.drop_column('is_leaderboard_visible') + batch_op.drop_column('max_score') + + # ### end Alembic commands ### diff --git a/backend/alembic/versions/9f92bb2d0e7c_.py b/backend/alembic/versions/9f92bb2d0e7c_.py new file mode 100644 index 0000000..169519a --- /dev/null +++ b/backend/alembic/versions/9f92bb2d0e7c_.py @@ -0,0 +1,58 @@ +"""empty message + +Revision ID: 9f92bb2d0e7c +Revises: 041a22bc2dd7 +Create Date: 2026-05-10 08:10:15.499841 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9f92bb2d0e7c' +down_revision: Union[str, Sequence[str], None] = '041a22bc2dd7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('submissions', + sa.Column('team_id', sa.Integer(), nullable=False), + sa.Column('task_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('team_id', 'task_id') + ) + with op.batch_alter_table('evaluations', schema=None) as batch_op: + batch_op.create_foreign_key(None, 'submissions', ['submission_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('jury_assignments', schema=None) as batch_op: + batch_op.create_foreign_key(None, 'submissions', ['submission_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('submission_urls', schema=None) as batch_op: + batch_op.create_foreign_key(None, 'submissions', ['submission_id'], ['id'], ondelete='CASCADE') + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('submission_urls', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + + with op.batch_alter_table('jury_assignments', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + + with op.batch_alter_table('evaluations', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + + op.drop_table('submissions') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/f731d7aac974_fix_schema.py b/backend/alembic/versions/f731d7aac974_fix_schema.py new file mode 100644 index 0000000..d9fe0af --- /dev/null +++ b/backend/alembic/versions/f731d7aac974_fix_schema.py @@ -0,0 +1,60 @@ +"""fix_schema + +Revision ID: f731d7aac974 +Revises: 7145b4774739 +Create Date: 2026-05-12 22:01:42.766688 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'f731d7aac974' +down_revision: Union[str, Sequence[str], None] = '7145b4774739' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("ALTER TABLE evaluations DROP CONSTRAINT IF EXISTS evaluations_submission_id_fkey") + op.execute("ALTER TABLE jury_assignments DROP CONSTRAINT IF EXISTS jury_assignments_submission_id_fkey") + op.execute("ALTER TABLE submission_urls DROP CONSTRAINT IF EXISTS submission_urls_submission_id_fkey") + + op.execute("ALTER TABLE submissions DROP CONSTRAINT IF EXISTS submissions_team_id_task_id_key") + op.execute("ALTER TABLE submissions DROP CONSTRAINT IF EXISTS submissions_pkey") + op.execute("ALTER TABLE submissions DROP COLUMN IF EXISTS id") + op.execute("ALTER TABLE submissions DROP COLUMN IF EXISTS created_at") + op.execute("ALTER TABLE submissions DROP COLUMN IF EXISTS updated_at") + op.execute("ALTER TABLE submissions DROP CONSTRAINT IF EXISTS submissions_team_id_key") + op.execute("ALTER TABLE submissions ADD CONSTRAINT submissions_pkey PRIMARY KEY (team_id)") + + op.execute("ALTER TABLE evaluations ADD CONSTRAINT evaluations_submission_id_fkey FOREIGN KEY(submission_id) REFERENCES submissions (team_id) ON DELETE CASCADE") + op.execute("ALTER TABLE jury_assignments ADD CONSTRAINT jury_assignments_submission_id_fkey FOREIGN KEY(submission_id) REFERENCES submissions (team_id) ON DELETE CASCADE") + op.execute("ALTER TABLE submission_urls ADD CONSTRAINT submission_urls_submission_id_fkey FOREIGN KEY(submission_id) REFERENCES submissions (team_id) ON DELETE CASCADE") + + op.execute("ALTER TABLE task_evaluation_categories DROP COLUMN \"order\"") + op.execute("ALTER TABLE task_evaluation_criteria DROP COLUMN \"order\"") + + +def downgrade() -> None: + op.execute("ALTER TABLE task_evaluation_criteria ADD COLUMN \"order\" INTEGER NOT NULL DEFAULT 0") + op.execute("ALTER TABLE task_evaluation_categories ADD COLUMN \"order\" INTEGER NOT NULL DEFAULT 0") + + op.execute("ALTER TABLE evaluations DROP CONSTRAINT IF EXISTS evaluations_submission_id_fkey") + op.execute("ALTER TABLE jury_assignments DROP CONSTRAINT IF EXISTS jury_assignments_submission_id_fkey") + op.execute("ALTER TABLE submission_urls DROP CONSTRAINT IF EXISTS submission_urls_submission_id_fkey") + + op.execute("ALTER TABLE submissions DROP CONSTRAINT IF EXISTS submissions_pkey") + op.execute("ALTER TABLE submissions ADD COLUMN id SERIAL") + op.execute("ALTER TABLE submissions ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT now()") + op.execute("ALTER TABLE submissions ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT now()") + op.execute("ALTER TABLE submissions ADD PRIMARY KEY (id)") + op.execute("ALTER TABLE submissions ADD CONSTRAINT submissions_team_id_key UNIQUE(team_id)") + op.execute("ALTER TABLE submissions ADD CONSTRAINT submissions_team_id_task_id_key UNIQUE(team_id, task_id)") + + op.execute("ALTER TABLE submission_urls ADD CONSTRAINT submission_urls_submission_id_fkey FOREIGN KEY(submission_id) REFERENCES submissions (id) ON DELETE CASCADE") + op.execute("ALTER TABLE jury_assignments ADD CONSTRAINT jury_assignments_submission_id_fkey FOREIGN KEY(submission_id) REFERENCES submissions (id) ON DELETE CASCADE") + op.execute("ALTER TABLE evaluations ADD CONSTRAINT evaluations_submission_id_fkey FOREIGN KEY(submission_id) REFERENCES submissions (id) ON DELETE CASCADE") diff --git a/backend/alembic/versions/f9a31f841ce0_.py b/backend/alembic/versions/f9a31f841ce0_.py new file mode 100644 index 0000000..30ae326 --- /dev/null +++ b/backend/alembic/versions/f9a31f841ce0_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: f9a31f841ce0 +Revises: 1bd3d49f4a5d +Create Date: 2026-05-10 08:21:03.815535 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f9a31f841ce0' +down_revision: Union[str, Sequence[str], None] = '1bd3d49f4a5d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('submission_urls', schema=None) as batch_op: + batch_op.drop_column('id') + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('submission_urls', schema=None) as batch_op: + batch_op.add_column(sa.Column('id', sa.INTEGER(), autoincrement=False, nullable=False)) + + # ### end Alembic commands ### diff --git a/backend/app/config.py b/backend/app/config.py index 294e9b1..b6fe4c4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -25,12 +25,6 @@ class OptionConfig(BaseModel): display_name: str -class RequirementOptionConfig(BaseModel): - name: str - display_name: str - category_id: str - - class CategoryConfig(BaseModel): name: str main_id: str | None = None @@ -44,10 +38,10 @@ class SharedAppConfig(BaseModel): roles: list[RoleConfig] tournament_statuses: list[OptionConfig] task_statuses: list[OptionConfig] + jury_assignment_statuses: list[OptionConfig] categories: list[CategoryConfig] role_request_options: list[OptionConfig] news_categories: list[NewsCategoryConfig] - requirement_options: list[RequirementOptionConfig] def load_shared_app_config() -> SharedAppConfig: @@ -127,6 +121,10 @@ def TOURNAMENT_STATUS_OPTIONS(self) -> list[dict[str, Any]]: def TASK_STATUS_OPTIONS(self) -> list[dict[str, Any]]: return [status.model_dump() for status in self.SHARED_APP_CONFIG.task_statuses] + @property + def JURY_ASSIGNMENT_STATUS_OPTIONS(self) -> list[dict[str, Any]]: + return [status.model_dump() for status in self.SHARED_APP_CONFIG.jury_assignment_statuses] + @property def NEWS_CATEGORY_OPTIONS(self) -> list[dict[str, Any]]: return [ @@ -149,6 +147,10 @@ def TOURNAMENT_STATUS_NAMES(self) -> SimpleNamespace: def TASK_STATUS_NAMES(self) -> SimpleNamespace: return option_names(self.SHARED_APP_CONFIG.task_statuses) + @property + def JURY_ASSIGNMENT_STATUS_NAMES(self) -> SimpleNamespace: + return option_names(self.SHARED_APP_CONFIG.jury_assignment_statuses) + @property def TOURNAMENT_CREATOR_ROLES(self) -> list[str]: return [self.ROLE_NAMES.ADMIN, self.ROLE_NAMES.ORGANIZER] @@ -161,10 +163,6 @@ def ROLE_REQUEST_OPTION_NAMES(self) -> SimpleNamespace: def ROLE_REQUEST_INFO_OPTIONS(self) -> list[dict[str, Any]]: return [opt.model_dump() for opt in self.SHARED_APP_CONFIG.role_request_options] - @property - def REQUIREMENT_OPTIONS(self) -> list[dict[str, Any]]: - return [opt.model_dump() for opt in self.SHARED_APP_CONFIG.requirement_options] - @property def TASK_CATEGORIES(self) -> SimpleNamespace: return option_names(self.SHARED_APP_CONFIG.categories) diff --git a/backend/app/core/seeds/__init__.py b/backend/app/core/seeds/__init__.py index 434da22..0f2eba4 100644 --- a/backend/app/core/seeds/__init__.py +++ b/backend/app/core/seeds/__init__.py @@ -3,7 +3,6 @@ from .roles import init_roles from .status import init_task_statuses, init_tournament_statuses from .role_requests import init_role_request_options -from .task_requirement_options import init_task_requirement_options async def init_static_data(session): @@ -13,4 +12,3 @@ async def init_static_data(session): await init_news_categories(session) await init_categories(session) await init_role_request_options(session) - await init_task_requirement_options(session) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ff90ef8..99d8805 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,11 +1,18 @@ from .base import Base -from .evaluation import SubmissionEvaluation, RequirementEvaluation +from .evaluation import JuryAssignment, JuryAssignmentStatusOption, CriterionScore, SubmissionEvaluation from .news import News, NewsCattegory from .notification import Notification from .role_request import RoleRequest, RoleRequestInfo, RoleRequestInfoOption from .role import Role from .submission import Submission, SubmissionUrl, SubmissionUrlOption -from .task import Task, TaskRequirementCategory, TaskRequirementOption, TaskStatusOption +from .task import ( + Task, + TaskEvaluationCategory, + TaskEvaluationCriterion, + TaskRequirementCategory, + TaskRequirementOption, + TaskStatusOption, +) from .team import Team, TeamMember from .tournament import Tournament, TournamentStatusOption from .user import User diff --git a/backend/app/models/evaluation.py b/backend/app/models/evaluation.py index c0517f8..41db788 100644 --- a/backend/app/models/evaluation.py +++ b/backend/app/models/evaluation.py @@ -1,60 +1,94 @@ -from sqlalchemy import ForeignKey, Table, Column, UniqueConstraint -from sqlalchemy.orm import mapped_column, Mapped, relationship +from datetime import datetime + +from sqlalchemy import ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship from .base import Base -from .mixin import PKMixin - -evaluation_requirements = Table( - "evaluation_requirements", - Base.metadata, - Column( - "requirement_evaluation_id", - ForeignKey("requirement_evaluations.id", ondelete="CASCADE"), - primary_key=True, - ), - Column( - "requirement_id", - ForeignKey("task_requirement_options.name", ondelete="CASCADE"), - primary_key=True, - ), -) - - -class SubmissionEvaluation(Base, PKMixin): - __tablename__ = "evaluations" +from .mixin import PKMixin, DatetimeMixin, OptionMixin + + +class JuryAssignmentStatusOption(Base, OptionMixin): + __tablename__ = "jury_assignment_statuses" + jury_assignments: Mapped[list["JuryAssignment"]] = relationship( + back_populates="status", lazy="selectin", cascade="all, delete-orphan" + ) + + def __repr__(self): + return f"" + + +class JuryAssignment(Base, PKMixin, DatetimeMixin): + __tablename__ = "jury_assignments" __table_args__ = (UniqueConstraint("submission_id", "jury_id"),) + + task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE")) + submission_id: Mapped[int] = mapped_column( + ForeignKey("submissions.team_id", ondelete="CASCADE"), nullable=False + ) + jury_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + status_id: Mapped[str] = mapped_column( + ForeignKey("jury_assignment_statuses.name", ondelete="CASCADE"), + default="assigned", + ) + status: Mapped["JuryAssignmentStatusOption"] = relationship( + back_populates="jury_assignments", lazy="selectin" + ) + + task: Mapped["Task"] = relationship(back_populates="jury_assignments", lazy="selectin") + submission: Mapped["Submission"] = relationship( + back_populates="assignments", lazy="selectin" + ) + jury: Mapped["User"] = relationship(lazy="selectin") + evaluation: Mapped["SubmissionEvaluation"] = relationship( + back_populates="assignment", + lazy="selectin", + cascade="all, delete-orphan", + uselist=False, + ) + + +class SubmissionEvaluation(Base, PKMixin, DatetimeMixin): + __tablename__ = "evaluations" + __table_args__ = (UniqueConstraint('jury_id', 'submission_id'),) + + assignment_id: Mapped[int] = mapped_column( + ForeignKey("jury_assignments.id", ondelete="CASCADE"), nullable=False + ) submission_id: Mapped[int] = mapped_column( ForeignKey("submissions.team_id", ondelete="CASCADE"), nullable=False ) jury_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) + comment: Mapped[str | None] = mapped_column(nullable=True) + assignment: Mapped["JuryAssignment"] = relationship( + back_populates="evaluation", lazy="selectin" + ) submission: Mapped["Submission"] = relationship( back_populates="evaluations", lazy="selectin" ) jury: Mapped["User"] = relationship(lazy="selectin") - requirement_evaluations: Mapped[list["RequirementEvaluation"]] = relationship( + criterion_scores: Mapped[list["CriterionScore"]] = relationship( back_populates="evaluation", lazy="selectin", cascade="all, delete-orphan" ) - def __repr__(self): - return f"" +class CriterionScore(Base, PKMixin): + __tablename__ = "criterion_scores" + __table_args__ = (UniqueConstraint("evaluation_id", "criterion_id"),) -class RequirementEvaluation(Base, PKMixin): - __tablename__ = "requirement_evaluations" evaluation_id: Mapped[int] = mapped_column( ForeignKey("evaluations.id", ondelete="CASCADE"), nullable=False ) - evaluation: Mapped["SubmissionEvaluation"] = relationship( - back_populates="requirement_evaluations", lazy="selectin" + criterion_id: Mapped[int] = mapped_column( + ForeignKey("task_evaluation_criteria.id", ondelete="CASCADE"), nullable=False ) score: Mapped[int] - requirement: Mapped[list["TaskRequirementOption"]] = relationship( - secondary=evaluation_requirements, lazy="selectin" - ) - def __repr__(self): - requirement_count = len(self.requirement) if "requirement" in self.__dict__ else "?" - return f"" + evaluation: Mapped["SubmissionEvaluation"] = relationship( + back_populates="criterion_scores", lazy="selectin" + ) + criterion: Mapped["TaskEvaluationCriterion"] = relationship( + back_populates="criterion_scores", lazy="selectin" + ) diff --git a/backend/app/models/submission.py b/backend/app/models/submission.py index 2cd73b7..ec78c89 100644 --- a/backend/app/models/submission.py +++ b/backend/app/models/submission.py @@ -9,14 +9,21 @@ class Submission(Base): team_id: Mapped[int] = mapped_column( ForeignKey("teams.id", ondelete="CASCADE"), primary_key=True ) + task_id: Mapped[int] = mapped_column( + ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False + ) team: Mapped["Team"] = relationship( back_populates="submission", single_parent=True, lazy="selectin" ) + task: Mapped["Task"] = relationship(lazy="selectin") urls: Mapped[list["SubmissionUrl"]] = relationship( back_populates="submission", lazy="selectin", cascade="all, delete-orphan" ) evaluations: Mapped[list["SubmissionEvaluation"]] = relationship( + back_populates="submission", cascade="all, delete-orphan" + ) + assignments: Mapped[list["JuryAssignment"]] = relationship( back_populates="submission", cascade="all, delete-orphan", lazy="selectin" ) @@ -32,22 +39,17 @@ class SubmissionUrl(Base): url_id: Mapped[int] = mapped_column( ForeignKey("submission_url_options.name", ondelete="CASCADE"), primary_key=True ) + value: Mapped[str] - submission: Mapped["Submission"] = relationship( - back_populates="urls", lazy="selectin" - ) + submission: Mapped["Submission"] = relationship(back_populates="urls", lazy="selectin") url: Mapped["SubmissionUrlOption"] = relationship(lazy="selectin") def __repr__(self): - return ( - f"" - ) + return f"" class SubmissionUrlOption(Base, OptionMixin): __tablename__ = "submission_url_options" def __repr__(self): - return ( - f"" - ) + return f"" diff --git a/backend/app/models/task.py b/backend/app/models/task.py index bad5c2a..d53e41b 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -3,6 +3,7 @@ from .base import Base from .mixin import PKMixin, OptionMixin +from .evaluation import JuryAssignment from datetime import datetime task_requirements = Table( @@ -24,6 +25,9 @@ class Task(Base, PKMixin): description: Mapped[str] = mapped_column(nullable=True) start_time: Mapped[datetime] end_time: Mapped[datetime] + min_reviews_per_submission: Mapped[int] = mapped_column(default=1) + max_score: Mapped[int] = mapped_column(default=100) + is_leaderboard_visible: Mapped[bool] = mapped_column(default=False) tournament_id: Mapped[int] = mapped_column( ForeignKey( "tournaments.id", @@ -45,6 +49,12 @@ class Task(Base, PKMixin): requirements: Mapped[list["TaskRequirementOption"]] = relationship( secondary=task_requirements, lazy="selectin" ) + evaluation_categories: Mapped[list["TaskEvaluationCategory"]] = relationship( + back_populates="task", lazy="selectin", cascade="all, delete-orphan" + ) + jury_assignments: Mapped[list["JuryAssignment"]] = relationship( + back_populates="task", lazy="selectin", cascade="all, delete-orphan" + ) def __repr__(self): return f"" @@ -95,3 +105,36 @@ class TaskRequirementCategory(Base, OptionMixin): def __repr__(self): return f"" + + +class TaskEvaluationCategory(Base, PKMixin): + __tablename__ = "task_evaluation_categories" + + task_id: Mapped[int] = mapped_column( + ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] + + task: Mapped["Task"] = relationship(back_populates="evaluation_categories", lazy="selectin") + criteria: Mapped[list["TaskEvaluationCriterion"]] = relationship( + back_populates="category", lazy="selectin", cascade="all, delete-orphan" + ) + + +class TaskEvaluationCriterion(Base, PKMixin): + __tablename__ = "task_evaluation_criteria" + + category_id: Mapped[int] = mapped_column( + ForeignKey("task_evaluation_categories.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] + description: Mapped[str | None] = mapped_column(nullable=True) + weight: Mapped[int] = mapped_column(default=1) + max_score: Mapped[int] = mapped_column(default=10) + + category: Mapped["TaskEvaluationCategory"] = relationship( + back_populates="criteria", lazy="selectin" + ) + criterion_scores: Mapped[list["CriterionScore"]] = relationship( + back_populates="criterion", lazy="selectin" + ) \ No newline at end of file diff --git a/backend/app/models/team.py b/backend/app/models/team.py index 196e653..8a8a019 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -19,17 +19,14 @@ class Team(Base, PKMixin): ForeignKey("team_members.id", ondelete="CASCADE"), nullable=True ) - tournament: Mapped["Tournament"] = relationship( - back_populates="teams", lazy="selectin" - ) + tournament: Mapped["Tournament"] = relationship(back_populates="teams", lazy="selectin") members: Mapped[list["TeamMember"]] = relationship( back_populates="team", foreign_keys="TeamMember.team_id", cascade="all, delete-orphan", - lazy="selectin", ) captain: Mapped["TeamMember"] = relationship( - "TeamMember", foreign_keys="Team.captain_id", lazy="selectin", post_update=True + "TeamMember", foreign_keys="Team.captain_id", post_update=True ) submission: Mapped["Submission"] = relationship( @@ -48,9 +45,7 @@ class TeamMember(Base, PKMixin): telegram: Mapped[str] = mapped_column(nullable=False) educational_institution: Mapped[Optional[str]] = mapped_column(nullable=True) team_id: Mapped[int] = mapped_column( - ForeignKey( - "teams.id", use_alter=True, name="fk_teammember_team", ondelete="CASCADE" - ) + ForeignKey("teams.id", use_alter=True, name="fk_teammember_team", ondelete="CASCADE") ) # This field exists so we can impose contraints related to it tournament_id: Mapped[int] = mapped_column( @@ -59,9 +54,7 @@ class TeamMember(Base, PKMixin): __table_args__ = ( UniqueConstraint("tournament_id", "email", name="uq_tournament_member_email"), - UniqueConstraint( - "tournament_id", "telegram", name="uq_tournament_member_telegram" - ), + UniqueConstraint("tournament_id", "telegram", name="uq_tournament_member_telegram"), UniqueConstraint("team_id", "email", name="uq_team_member_email"), UniqueConstraint("team_id", "telegram", name="uq_team_member_telegram"), ) diff --git a/backend/app/models/tournament.py b/backend/app/models/tournament.py index a787ed2..033b7ba 100644 --- a/backend/app/models/tournament.py +++ b/backend/app/models/tournament.py @@ -42,13 +42,6 @@ class Tournament(Base, PKMixin): teams: Mapped[list["Team"]] = relationship( back_populates="tournament", lazy="selectin", cascade="all, delete-orphan" ) - tasks: Mapped[list["Task"]] = relationship( - "Task", - back_populates="tournament", - foreign_keys="Task.tournament_id", - lazy="selectin", - cascade="all, delete-orphan", - ) submissions: Mapped[list["Submission"]] = relationship( "Submission", secondary=lambda: Team.__table__, @@ -57,6 +50,13 @@ class Tournament(Base, PKMixin): viewonly=True, lazy="selectin", ) + tasks: Mapped[list["Task"]] = relationship( + "Task", + back_populates="tournament", + foreign_keys="Task.tournament_id", + lazy="selectin", + cascade="all, delete-orphan", + ) juries: Mapped[list["User"]] = relationship( back_populates="evaluates_in", lazy="selectin", secondary=tournament_juries ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 8812684..2b791d8 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,12 +1,37 @@ -from .evaluation import RequirementEvaluationModel, SubmissionEvaluationModel +from .evaluation import ( + CriterionScoreCreate, + CriterionScorePublic, + EvaluationLeaderboardEntry, + FinishEvaluationResponse, + JuryAssignmentPublic, + JuryAssignmentPublic, + SubmissionEvaluationCreate, + SubmissionEvaluationPublic, + SubmissionEvaluationUpdate +) from .news import NewsPublic from .notification import NotificationPublic, NotificationCreate from .option import OptionPublic, OptionUpdate from .role_request import RoleRequestPublic, RoleRequestCreate from .role import RoleCreate, RolePublic, RoleUpdate -from .submission import SubmissionUrlOptionModel, SubmissionUrlModel, SubmissionPublic +from .submission import ( + SubmissionCreateUrl, + SubmissionCreate, + SubmissionModel, + SubmissionPublic, + SubmissionUrlModel, +) from .task_options import TaskRequirementOptionCreate, TaskRequirementOptionPublic -from .task import TaskBase, TaskCreate, TaskUpdate, TaskPublic +from .task import ( + TaskBase, + TaskCreate, + TaskEvaluationCategoryCreate, + TaskEvaluationCategoryPublic, + TaskEvaluationCriterionCreate, + TaskEvaluationCriterionPublic, + TaskPublic, + TaskUpdate, +) from .team import ( TeamModel, TeamPublic, @@ -31,3 +56,4 @@ TournamentCreate.model_rebuild() TournamentUpdate.model_rebuild() TournamentPublic.model_rebuild() +TournamentPublicMinimal.model_rebuild() diff --git a/backend/app/schemas/evaluation.py b/backend/app/schemas/evaluation.py index cdfe16e..4ab0e54 100644 --- a/backend/app/schemas/evaluation.py +++ b/backend/app/schemas/evaluation.py @@ -1,14 +1,76 @@ -from pydantic import BaseModel, Field, ConfigDict +from datetime import datetime +from pydantic import BaseModel, ConfigDict, Field -class RequirementEvaluationModel(BaseModel): +from .submission import SubmissionPublic +from .task import TaskPublic +from .datetime import DatetimePublic + + +class CriterionScoreBase(BaseModel): model_config = ConfigDict(from_attributes=True) + criterion_id: int = Field(..., gt=0) score: int = Field(..., ge=0) +class CriterionScoreCreate(CriterionScoreBase): + pass + +class CriterionScorePublic(CriterionScoreBase): + id: int + + +class SubmissionEvaluationBase(BaseModel): + comment: str | None = None + +class SubmissionEvaluationCreate(SubmissionEvaluationBase): + criterion_scores: list[CriterionScoreCreate] = Field([], min_length=1) + +class SubmissionEvaluationUpdate(SubmissionEvaluationBase): + comment: str | None = None + criterion_scores: list[CriterionScoreCreate] = Field([], min_length=1) + + +class SubmissionEvaluationPublic(SubmissionEvaluationBase): + id: int + assignment_id: int + submission_id: int + jury_id: int + comment: str | None + created_at: datetime | None + criterion_scores: list[CriterionScorePublic] = Field(default_factory=list) -class SubmissionEvaluationModel(BaseModel): + +class JuryAssignmentStatusPublic(BaseModel): + model_config = ConfigDict(from_attributes=True) + + name: str + display_name: str + +class JuryAssignmentBase(BaseModel): model_config = ConfigDict(from_attributes=True) - submission_id: int = Field(..., gt=0) - jury_id: int = Field(..., gt=0) + id: int + task_id: int + submission_id: int + jury_id: int + status_id: str + +class JuryAssignmentPublic(JuryAssignmentBase): + status: JuryAssignmentStatusPublic + submission: SubmissionPublic + task: TaskPublic + evaluation: SubmissionEvaluationPublic | None = None + +class EvaluationLeaderboardEntry(BaseModel): + submission_id: int + team_id: int + team_name: str + average_score: float + total_score: float + submitted_reviews: int + + +class FinishEvaluationResponse(BaseModel): + task_id: int + status_id: str diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index 1a88d66..7fd0b54 100644 --- a/backend/app/schemas/notification.py +++ b/backend/app/schemas/notification.py @@ -8,11 +8,11 @@ class NotificationBase(BaseModel): user_id: int | None = Field(None, description="Notification receiver id") -from .user import UserMinimalPublic +from .user import UserPublic class NotificationPublic(NotificationBase): - user: UserMinimalPublic | None = Field(None, description="Notification receiver") + user: UserPublic | None = Field(None, description="Notification receiver") class NotificationCreate(NotificationBase): diff --git a/backend/app/schemas/submission.py b/backend/app/schemas/submission.py index 618ea52..ae9f77b 100644 --- a/backend/app/schemas/submission.py +++ b/backend/app/schemas/submission.py @@ -1,5 +1,4 @@ from pydantic import BaseModel, Field, ConfigDict -from .team import TeamPublic class SubmissionUrlOptionModel(BaseModel): @@ -12,15 +11,26 @@ class SubmissionUrlModel(BaseModel): model_config = ConfigDict(from_attributes=True) url_id: str = Field(..., description="ID of the URL option") - url: SubmissionUrlOptionModel | None + url: SubmissionUrlOptionModel | None = None + value: str | None = None -class SubmissionBase(BaseModel): +class SubmissionCreateUrl(BaseModel): + url_id: str + value: str + + +class SubmissionCreate(BaseModel): + team_id: int + urls: list[SubmissionCreateUrl] + + +class SubmissionModel(BaseModel): model_config = ConfigDict(from_attributes=True) team_id: int = Field(...) + task_id: int = Field(...) + urls: list[SubmissionUrlModel] = Field(default_factory=list) -class SubmissionPublic(SubmissionBase): - team: TeamPublic - urls: list[SubmissionUrlModel] = Field(default_factory=list) \ No newline at end of file +SubmissionPublic = SubmissionModel diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index b80950f..c26aa3c 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -19,14 +19,15 @@ class TaskBase(BaseModel): end_time: datetime requirements: list[str] = Field(...) - -class TaskCreate(TaskBase): @field_validator("title") @classmethod def check_title(cls, value: str): if not value.strip(): raise ValueError("Title cannot be empty") return value.strip() + + +class TaskCreate(TaskBase): @field_validator("start_time") @classmethod def start_not_past(cls, value: datetime): @@ -72,3 +73,34 @@ def transform_requirements(cls, value): if isinstance(value, list) and len(value) > 0 and not isinstance(value[0], str): return [req.name for req in value] return value + + +class TaskEvaluationCategoryCreate(BaseModel): + name: str + + +class TaskEvaluationCategoryPublic(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + task_id: int + name: str + criteria: list["TaskEvaluationCriterionPublic"] = [] + + +class TaskEvaluationCriterionCreate(BaseModel): + name: str + description: str | None = None + weight: int = 1 + max_score: int = 10 + + +class TaskEvaluationCriterionPublic(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + category_id: int + name: str + description: str | None + weight: int + max_score: int diff --git a/backend/app/schemas/team.py b/backend/app/schemas/team.py index 424e44f..8118060 100644 --- a/backend/app/schemas/team.py +++ b/backend/app/schemas/team.py @@ -2,11 +2,11 @@ from typing import TYPE_CHECKING -from pydantic import BaseModel, Field, EmailStr, field_validator +from pydantic import BaseModel, Field, EmailStr, field_validator, ConfigDict from pydantic_extra_types.phone_numbers import PhoneNumber if TYPE_CHECKING: - from .tournament import TournamentPublicMinimal + from .tournament import TournamentPublic class TeamMemberBase(BaseModel): @@ -15,30 +15,22 @@ class TeamMemberBase(BaseModel): telegram_username: str educational_institution: str - -class TeamMemberCreate(BaseModel): - full_name: str = Field(..., min_length=3) - email: EmailStr = Field(..., description="Contact email") - telegram_username: str - educational_institution: str - @field_validator("email") @classmethod def normalize_email(cls, value: EmailStr): return value.lower() +class TeamMemberCreate(BaseModel): + pass + + class TeamMemberUpdate(BaseModel): full_name: str | None = Field(None, min_length=3) email: EmailStr | None = Field(None, description="Contact email") telegram_username: str | None = None educational_institution: str | None = None - @field_validator("email") - @classmethod - def normalize_email(cls, value: EmailStr): - return value.lower() - class TeamMemberPublic(BaseModel): pass @@ -49,8 +41,6 @@ class TeamBase(BaseModel): team_email: EmailStr = Field(..., description="Contact email") contact_info: PhoneNumber = Field(..., description="Phone number") - -class TeamCreate(TeamBase): @field_validator("team_email") @classmethod def normalize_email(cls, value: EmailStr): @@ -62,11 +52,6 @@ class TeamUpdate(BaseModel): team_email: EmailStr | None = None contact_info: PhoneNumber | None = None - @field_validator("team_email") - @classmethod - def normalize_email(cls, value: EmailStr): - return value.lower() - class TeamModel(TeamBase): captain: TeamMemberPublic @@ -74,5 +59,5 @@ class TeamModel(TeamBase): class TeamPublic(TeamBase): - tournament: "TournamentPublicMinimal" + tournament: "TournamentPublic" members: list[TeamMemberPublic] diff --git a/backend/app/schemas/tournament.py b/backend/app/schemas/tournament.py index 9e33bb6..1c6b110 100644 --- a/backend/app/schemas/tournament.py +++ b/backend/app/schemas/tournament.py @@ -44,12 +44,8 @@ class TournamentBase(BaseModel): max_people_in_team: int = Field(..., gt=0) max_teams: int = Field(..., gt=0) - -class TournamentCreate(TournamentBase): - juries: list[int | str] = Field(..., description="Jury ids") - @model_validator(mode="after") - def validate_dates(self) -> "TournamentCreate": + def validate_dates(self) -> "TournamentBase": now = drop_time(datetime.now(timezone.utc).replace(tzinfo=None)) @@ -65,14 +61,16 @@ def validate_dates(self) -> "TournamentCreate": return self +class TournamentCreate(TournamentBase): + juries: list[int | str] = Field(..., description="Jury ids") + + class TournamentUpdate(BaseModel): title: StrippedStr | None = Field(None, min_length=3) description: str | None = None start_date: NaiveDatetime | None = None reg_start: NaiveDatetime | None = None reg_end: NaiveDatetime | None = None - min_people_in_team: int | None = Field(None, gt=0) - max_people_in_team: int | None = Field(None, gt=0) max_teams: int | None = Field(None, gt=0) juries: list[int | str] | None = Field(None, description="Jury ids") @@ -99,23 +97,14 @@ def validate_dates_update(self) -> "TournamentUpdate": return self - @model_validator(mode="after") - def validate_limits(self) -> "TournamentUpdate": - - if self.min_people_in_team and self.max_people_in_team: - if self.min_people_in_team > self.max_people_in_team: - raise ValueError( - "min_people_in_team cannot be greater than max_people_in_team" - ) - return self - class TournamentStatusOptionModel(BaseModel): model_config = ConfigDict(from_attributes=True) name: StrippedStr = Field(..., min_length=3) -class TournamentPublicMinimal(TournamentBase): + +class TournamentPublic(TournamentBase): model_config = ConfigDict(from_attributes=True) id: int @@ -124,11 +113,15 @@ class TournamentPublicMinimal(TournamentBase): status: OptionPublic tasks: list[TaskPublic] active_task: TaskPublic | None + teams: list[TeamPublic] juries: list["UserMinimalPublic"] status_name: str = Field(validation_alias=AliasPath("status", "display_name")) -class TournamentPublic(TournamentPublicMinimal): - model_config = ConfigDict(from_attributes=True) - teams: list[TeamPublic] +class TournamentPublicMinimal(TournamentBase): + model_config = ConfigDict(from_attributes=True) + id: int + end_date: datetime | None + status: OptionPublic + status_name: str = Field(validation_alias=AliasPath("status", "display_name")) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 4d829ec..245f9f0 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -45,7 +45,6 @@ class UserMinimalPublic(UserBase): telegram: str | None github: str | None discord: str | None - is_jury: bool class UserPublic(UserBase): diff --git a/backend/tests/factories.py b/backend/tests/factories.py index 662c21c..51cf041 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -20,10 +20,14 @@ Submission, SubmissionUrl, SubmissionUrlOption, + JuryAssignment, + JuryAssignmentStatusOption, SubmissionEvaluation, - RequirementEvaluation, Notification, RoleRequest, + TaskEvaluationCategory, + TaskEvaluationCriterion, + CriterionScore, ) import datetime @@ -42,6 +46,13 @@ class Meta: name = Faker("name") display_name = factory.LazyAttribute(lambda f: f.name.upper()) +class BaseDatetimeFactory(BaseFactory): + class Meta: + abstract = True + + created_at = factory.Faker('date_time') + updated_at = factory.LazyAttribute(lambda o: o.created_at + datetime.timedelta(hours=1)) + class UserFactory(BaseFactory): class Meta: @@ -173,6 +184,20 @@ class Meta: ) +class JuryAssignmentStatusOptionFactory(BaseOptionFactory): + class Meta: + model = JuryAssignmentStatusOption + + name = factory.Iterator([option["name"] for option in settings.JURY_ASSIGNMENT_STATUS_OPTIONS]) + display_name = factory.LazyAttribute( + lambda status: next( + option["display_name"] + for option in settings.JURY_ASSIGNMENT_STATUS_OPTIONS + if option["name"] == status.name + ) + ) + + class TaskFactory(BaseFactory): class Meta: model = Task @@ -183,6 +208,9 @@ class Meta: end_time = Faker("future_datetime") tournament = factory.SubFactory(TournamentFactory) status_id = factory.Iterator([option["name"] for option in settings.TASK_STATUS_OPTIONS]) + min_reviews_per_submission = 2 + max_score = 10 + is_leaderboard_visible = True @classmethod def _adjust_kwargs(cls, **kwargs): @@ -210,6 +238,7 @@ class Meta: model = Submission team = factory.SubFactory(TeamFactory) + task = factory.LazyAttribute(lambda obj: TaskFactory.build(tournament=obj.team.tournament)) class SubmissionUrlOptionFactory(BaseOptionFactory): @@ -225,21 +254,62 @@ class Meta: submission = factory.SubFactory(SubmissionFactory) url = factory.SubFactory(SubmissionUrlOptionFactory) + value = factory.Sequence(lambda n: f"https://example.com/submission/{n}") -class SubmissionEvaluationFactory(BaseFactory): +class TaskEvaluationCategoryFactory(BaseFactory): class Meta: - model = SubmissionEvaluation + model = TaskEvaluationCategory + + task = factory.SubFactory(TaskFactory) + name = factory.Sequence(lambda n: f"Category {n}") + + +class TaskEvaluationCriterionFactory(BaseFactory): + class Meta: + model = TaskEvaluationCriterion + + category = factory.SubFactory(TaskEvaluationCategoryFactory) + name = factory.Sequence(lambda n: f"Criterion {n}") + description = None + weight = 1 + max_score = 10 + + +class JuryAssignmentFactory(BaseDatetimeFactory): + class Meta: + model = JuryAssignment submission = factory.SubFactory(SubmissionFactory) + task = factory.SelfAttribute("submission.task") jury = factory.SubFactory(UserFactory) + status_id = factory.Iterator([option["name"] for option in settings.JURY_ASSIGNMENT_STATUS_OPTIONS]) + + @classmethod + def _adjust_kwargs(cls, **kwargs): + status_id = kwargs.get("status_id") + if isinstance(status_id, JuryAssignmentStatusOption): + kwargs["status"] = status_id + kwargs.pop("status_id") + return super()._adjust_kwargs(**kwargs) + + +class SubmissionEvaluationFactory(BaseFactory): + class Meta: + model = SubmissionEvaluation + + assignment = factory.SubFactory(JuryAssignmentFactory) + submission = factory.SelfAttribute("assignment.submission") + jury = factory.SelfAttribute("assignment.jury") + comment = None -class RequirementEvaluationFactory(BaseFactory): +class CriterionScoreFactory(BaseFactory): class Meta: - model = RequirementEvaluation + model = CriterionScore evaluation = factory.SubFactory(SubmissionEvaluationFactory) + criterion = factory.SubFactory(TaskEvaluationCriterionFactory) score = factory.Faker("pyint", min_value=0, max_value=100) diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 4bc9670..09630db 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -20,7 +20,7 @@ SubmissionUrl, SubmissionUrlOption, SubmissionEvaluation, - RequirementEvaluation, + CriterionScore, Notification, RoleRequest, ) @@ -41,8 +41,9 @@ SubmissionFactory, SubmissionUrlFactory, SubmissionUrlOptionFactory, + TaskEvaluationCriterionFactory, SubmissionEvaluationFactory, - RequirementEvaluationFactory, + CriterionScoreFactory, NotificationFactory, RoleRequestFactory, ) @@ -230,7 +231,7 @@ async def test_tournament_cascade_delete_children(db_session, create): submission = await create(SubmissionFactory, team=team) url = await create(SubmissionUrlFactory, submission=submission) evaluation = await create(SubmissionEvaluationFactory, submission=submission) - requirement_evaluation = await create(RequirementEvaluationFactory, evaluation=evaluation) + criterion_score = await create(CriterionScoreFactory, evaluation=evaluation) ids = { "task": task.id, @@ -239,7 +240,7 @@ async def test_tournament_cascade_delete_children(db_session, create): "submission": submission.team_id, "url": (url.submission_id, url.url_id), "evaluation": evaluation.id, - "requirement_evaluation": requirement_evaluation.id, + "criterion_score": criterion_score.id, } await db_session.delete(tournament) @@ -251,7 +252,7 @@ async def test_tournament_cascade_delete_children(db_session, create): assert await db_session.get(Submission, ids["submission"]) is None assert await db_session.get(SubmissionUrl, ids["url"]) is None assert await db_session.get(SubmissionEvaluation, ids["evaluation"]) is None - assert await db_session.get(RequirementEvaluation, ids["requirement_evaluation"]) is None + assert await db_session.get(CriterionScore, ids["criterion_score"]) is None # TASK TESTS @@ -539,8 +540,8 @@ async def test_create_submission_evaluation(create): assert evaluation.jury is not None -async def test_create_requirement_evaluation(create): - evaluation = await create(RequirementEvaluationFactory) +async def test_create_criterion_score(create): + evaluation = await create(CriterionScoreFactory) assert evaluation.evaluation_id is not None assert evaluation.evaluation is not None @@ -550,8 +551,8 @@ async def test_create_requirement_evaluation(create): async def test_evaluation_multiple_requirements(create): evaluation = await create(SubmissionEvaluationFactory) - req1 = await create(RequirementEvaluationFactory, evaluation=evaluation) - req2 = await create(RequirementEvaluationFactory, evaluation=evaluation) + req1 = await create(CriterionScoreFactory, evaluation=evaluation) + req2 = await create(CriterionScoreFactory, evaluation=evaluation) assert req1.evaluation_id == evaluation.id assert req2.evaluation_id == evaluation.id @@ -601,24 +602,24 @@ async def test_evaluation_jury_relationship(db_session, create): assert db_eval.jury.id == evaluation.jury.id -async def test_requirement_evaluation_option_relationship(db_session, create): - option = await create(TaskRequirementOptionFactory) +async def test_criterion_score_option_relationship(db_session, create): + option = await create(TaskEvaluationCriterionFactory) req_eval = await create( - RequirementEvaluationFactory, - requirement=[option], + CriterionScoreFactory, + criterion=option, ) stmt = ( - select(RequirementEvaluation) - .where(RequirementEvaluation.id == req_eval.id) - .options(selectinload(RequirementEvaluation.requirement)) + select(CriterionScore) + .where(CriterionScore.id == req_eval.id) + .options(selectinload(CriterionScore.criterion)) ) result = await db_session.execute(stmt) db_req_eval = result.scalar_one() - assert db_req_eval.requirement[0].name == option.name + assert db_req_eval.criterion.name == option.name # NOTIFICATION TESTS diff --git a/shared/app_config.json b/shared/app_config.json index 2784184..86d7e95 100644 --- a/shared/app_config.json +++ b/shared/app_config.json @@ -52,6 +52,20 @@ "display_name": "Evaluated" } ], + "jury_assignment_statuses": [ + { + "name": "assigned", + "display_name": "Assigned" + }, + { + "name": "submitted", + "display_name": "Submitted" + }, + { + "name": "evaluated", + "display_name": "Evaluated" + } + ], "categories": [ { "name": "Languages", @@ -175,187 +189,5 @@ "display_name": "Освіта", "categoryColor": "bg-teal-100 text-teal-700" } - ], - "requirement_options": [ - { - "name": "Python", - "display_name": "Python", - "category_id": "Languages" - }, - { - "name": "JavaScript", - "display_name": "JavaScript", - "category_id": "Languages" - }, - { - "name": "TypeScript", - "display_name": "TypeScript", - "category_id": "Languages" - }, - { - "name": "Java", - "display_name": "Java", - "category_id": "Languages" - }, - { - "name": "C#", - "display_name": "C#", - "category_id": "Languages" - }, - { - "name": "Go", - "display_name": "Go", - "category_id": "Languages" - }, - { - "name": "Rust", - "display_name": "Rust", - "category_id": "Languages" - }, - { - "name": "FastAPI", - "display_name": "FastAPI", - "category_id": "Frameworks" - }, - { - "name": "Django", - "display_name": "Django", - "category_id": "Frameworks" - }, - { - "name": "Flask", - "display_name": "Flask", - "category_id": "Frameworks" - }, - { - "name": "Express", - "display_name": "Express", - "category_id": "Frameworks" - }, - { - "name": "Spring Boot", - "display_name": "Spring Boot", - "category_id": "Frameworks" - }, - { - "name": "React", - "display_name": "React", - "category_id": "JS Frameworks" - }, - { - "name": "Vue", - "display_name": "Vue", - "category_id": "JS Frameworks" - }, - { - "name": "Angular", - "display_name": "Angular", - "category_id": "JS Frameworks" - }, - { - "name": "Next.js", - "display_name": "Next.js", - "category_id": "JS Frameworks" - }, - { - "name": "Redux", - "display_name": "Redux", - "category_id": "State Management" - }, - { - "name": "Zustand", - "display_name": "Zustand", - "category_id": "State Management" - }, - { - "name": "Recoil", - "display_name": "Recoil", - "category_id": "State Management" - }, - { - "name": "PostgreSQL", - "display_name": "PostgreSQL", - "category_id": "SQL" - }, - { - "name": "MySQL", - "display_name": "MySQL", - "category_id": "SQL" - }, - { - "name": "SQLite", - "display_name": "SQLite", - "category_id": "SQL" - }, - { - "name": "MongoDB", - "display_name": "MongoDB", - "category_id": "NoSQL" - }, - { - "name": "Redis", - "display_name": "Redis", - "category_id": "NoSQL" - }, - { - "name": "Firebase", - "display_name": "Firebase", - "category_id": "NoSQL" - }, - { - "name": "Pinecone", - "display_name": "Pinecone", - "category_id": "Vector DB" - }, - { - "name": "Weaviate", - "display_name": "Weaviate", - "category_id": "Vector DB" - }, - { - "name": "Docker", - "display_name": "Docker", - "category_id": "DevOps" - }, - { - "name": "Kubernetes", - "display_name": "Kubernetes", - "category_id": "DevOps" - }, - { - "name": "GitHub Actions", - "display_name": "GitHub Actions", - "category_id": "DevOps" - }, - { - "name": "AWS", - "display_name": "AWS", - "category_id": "Cloud" - }, - { - "name": "Google Cloud", - "display_name": "Google Cloud", - "category_id": "Cloud" - }, - { - "name": "Azure", - "display_name": "Azure", - "category_id": "Cloud" - }, - { - "name": "Prometheus", - "display_name": "Prometheus", - "category_id": "Monitoring" - }, - { - "name": "Grafana", - "display_name": "Grafana", - "category_id": "Monitoring" - }, - { - "name": "ELK Stack", - "display_name": "ELK Stack", - "category_id": "Monitoring" - } ] } \ No newline at end of file From 2958f50e3bc5ef920958dbd664bf078c5e983ba0 Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Tue, 12 May 2026 22:29:06 +0300 Subject: [PATCH 04/38] feat: implement backend for jury panel --- backend/app/__init__.py | 2 + backend/app/admin/__init__.py | 30 +- backend/app/config.py | 4 +- .../core/seeds/task_requirement_options.py | 4 +- backend/app/dependencies/__init__.py | 1 + backend/app/dependencies/current_user.py | 35 +- backend/app/dependencies/jury.py | 31 ++ backend/app/models/__init__.py | 7 +- backend/app/models/evaluation.py | 6 +- backend/app/models/task.py | 6 +- backend/app/models/user.py | 2 +- backend/app/routes/jury.py | 459 ++++++++++++++++++ backend/app/routes/tasks.py | 29 +- backend/app/routes/tournaments.py | 7 - backend/app/schemas/__init__.py | 4 +- backend/app/schemas/evaluation.py | 7 + backend/app/schemas/user.py | 3 +- backend/app/utils/__init__.py | 4 +- backend/app/utils/assignments.py | 43 ++ backend/app/utils/criterions.py | 13 + backend/app/utils/tasks.py | 26 +- backend/tests/factories.py | 11 +- backend/tests/routes/test_jury.py | 85 ++++ 23 files changed, 756 insertions(+), 63 deletions(-) create mode 100644 backend/app/dependencies/jury.py create mode 100644 backend/app/routes/jury.py create mode 100644 backend/app/utils/assignments.py create mode 100644 backend/app/utils/criterions.py create mode 100644 backend/tests/routes/test_jury.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index f904fb8..271253d 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -3,6 +3,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +import app.routes.jury as jury import app.routes.news as news import app.routes.profile as profile import app.routes.role_requests as role_requests @@ -38,6 +39,7 @@ async def lifespan(app: FastAPI): app.state.user_websocket_sessions = {} +app.include_router(jury.router) app.include_router(news.router) app.include_router(profile.router) app.include_router(role_requests.router) diff --git a/backend/app/admin/__init__.py b/backend/app/admin/__init__.py index fb8d850..18ba621 100644 --- a/backend/app/admin/__init__.py +++ b/backend/app/admin/__init__.py @@ -10,14 +10,18 @@ from app.db import engine, AsyncSessionLocal from app.utils import reject_role_request, approve_role_request from app.models import ( + JuryAssignment, Notification, - RequirementEvaluation, + CriterionScore, Role, RoleRequest, Submission, SubmissionEvaluation, SubmissionUrlOption, + SubmissionUrl, Task, + TaskEvaluationCategory, + TaskEvaluationCriterion, TaskRequirementCategory, TaskRequirementOption, TaskStatusOption, @@ -197,11 +201,27 @@ class SubmissionUrlOptionAdmin(NamePrimaryKeyAdmin, model=SubmissionUrlOption): pass +class SubmissionUrlAdmin(BaseModelView, model=SubmissionUrl): + pass + + class SubmissionEvaluationAdmin(BaseModelView, model=SubmissionEvaluation): pass -class RequirementEvaluationAdmin(BaseModelView, model=RequirementEvaluation): +class JuryAssignmentAdmin(BaseModelView, model=JuryAssignment): + pass + + +class TaskEvaluationCategoryAdmin(BaseModelView, model=TaskEvaluationCategory): + pass + + +class TaskEvaluationCriterionAdmin(BaseModelView, model=TaskEvaluationCriterion): + pass + + +class CriterionScoreAdmin(BaseModelView, model=CriterionScore): pass @@ -312,14 +332,18 @@ def setup_admin(app): admin.add_view(TournamentStatusOptionAdmin) admin.add_view(TaskAdmin) admin.add_view(TaskStatusOptionAdmin) + admin.add_view(TaskEvaluationCategoryAdmin) + admin.add_view(TaskEvaluationCriterionAdmin) admin.add_view(TaskRequirementCategoryAdmin) admin.add_view(TaskRequirementOptionAdmin) admin.add_view(TeamAdmin) admin.add_view(TeamMemberAdmin) admin.add_view(SubmissionAdmin) admin.add_view(SubmissionUrlOptionAdmin) + admin.add_view(SubmissionUrlAdmin) + admin.add_view(JuryAssignmentAdmin) admin.add_view(SubmissionEvaluationAdmin) - admin.add_view(RequirementEvaluationAdmin) + admin.add_view(CriterionScoreAdmin) admin.add_view(NotificationAdmin) admin.add_view(NewsAdmin) admin.add_view(NewsCategoryAdmin) diff --git a/backend/app/config.py b/backend/app/config.py index b6fe4c4..2b74e0d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -123,7 +123,9 @@ def TASK_STATUS_OPTIONS(self) -> list[dict[str, Any]]: @property def JURY_ASSIGNMENT_STATUS_OPTIONS(self) -> list[dict[str, Any]]: - return [status.model_dump() for status in self.SHARED_APP_CONFIG.jury_assignment_statuses] + return [ + status.model_dump() for status in self.SHARED_APP_CONFIG.jury_assignment_statuses + ] @property def NEWS_CATEGORY_OPTIONS(self) -> list[dict[str, Any]]: diff --git a/backend/app/core/seeds/task_requirement_options.py b/backend/app/core/seeds/task_requirement_options.py index f861262..cc5962c 100644 --- a/backend/app/core/seeds/task_requirement_options.py +++ b/backend/app/core/seeds/task_requirement_options.py @@ -9,9 +9,7 @@ async def init_task_requirement_options(session: AsyncSession): options = settings.REQUIREMENT_OPTIONS for opt in options: - stmt = select(TaskRequirementOption).where( - TaskRequirementOption.name == opt["name"] - ) + stmt = select(TaskRequirementOption).where(TaskRequirementOption.name == opt["name"]) result = await session.execute(stmt) existing_option = result.scalar_one_or_none() diff --git a/backend/app/dependencies/__init__.py b/backend/app/dependencies/__init__.py index e9d2615..2ab5bb0 100644 --- a/backend/app/dependencies/__init__.py +++ b/backend/app/dependencies/__init__.py @@ -7,5 +7,6 @@ get_or_create_user_from_token, current_user_dependency, ) +from .jury import organizer_or_admin_dependency, assigned_jury_dependency from .role_request import RoleRequestDep, get_role_request from .session import SessionDep, get_session diff --git a/backend/app/dependencies/current_user.py b/backend/app/dependencies/current_user.py index 788eb1d..19a0085 100644 --- a/backend/app/dependencies/current_user.py +++ b/backend/app/dependencies/current_user.py @@ -2,7 +2,6 @@ from typing import Annotated from fastapi import Depends, HTTPException, status from sqlalchemy import select, or_ -from sqlalchemy.orm import selectinload from fastapi.security import HTTPBearer from firebase_admin import auth from firebase_admin.auth import ( @@ -14,15 +13,13 @@ ) from .session import SessionDep -from app.models import User, Tournament +from app.models import User from app.firebase import firebase async def get_or_create_user_from_token(token: dict, session: SessionDep) -> User: try: - u: auth.UserRecord = await asyncio.to_thread( - auth.get_user_by_email, token["email"] - ) + u: auth.UserRecord = await asyncio.to_thread(auth.get_user_by_email, token["email"]) except auth.UserNotFoundError as e: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Firebase user not found" @@ -45,7 +42,7 @@ async def get_or_create_user_from_token(token: dict, session: SessionDep) -> Use user = User(firebase_uid=u.uid, full_name=u.display_name, email=u.email) session.add(user) await session.commit() - user = await get_user(u.uid, session) + await session.refresh(user) return user @@ -60,7 +57,6 @@ async def get_current_user( else: token = auth.verify_id_token(token, firebase) user = await get_or_create_user_from_token(token, session) - user = await get_user(user.id, session) except ( ValueError, InvalidIdTokenError, @@ -86,31 +82,6 @@ async def get_user(identifier: str | int, session: SessionDep): statement = select(User).where(User.id == identifier) else: raise ValueError("Wrong user identifier type") - - # Eager load all relationships needed for CurrentUser response - # Load created_tournaments with their nested relationships - statement = statement.options( - selectinload(User.created_tournaments).selectinload(Tournament.juries), - selectinload(User.created_tournaments).selectinload(Tournament.teams), - selectinload(User.created_tournaments).selectinload(Tournament.tasks), - selectinload(User.created_tournaments).selectinload(Tournament.status), - selectinload(User.created_tournaments).selectinload(Tournament.creator), - # Load evaluates_in tournaments with their nested relationships - selectinload(User.evaluates_in).selectinload(Tournament.juries), - selectinload(User.evaluates_in).selectinload(Tournament.teams), - selectinload(User.evaluates_in).selectinload(Tournament.tasks), - selectinload(User.evaluates_in).selectinload(Tournament.status), - selectinload(User.evaluates_in).selectinload(Tournament.creator), - # Load participates_in tournaments with their nested relationships - selectinload(User.participates_in).selectinload(Tournament.juries), - selectinload(User.participates_in).selectinload(Tournament.teams), - selectinload(User.participates_in).selectinload(Tournament.tasks), - selectinload(User.participates_in).selectinload(Tournament.status), - selectinload(User.participates_in).selectinload(Tournament.creator), - # Load other top-level relationships - selectinload(User.roles), - selectinload(User.notifications) - ) user = (await session.execute(statement)).scalar() if not user: diff --git a/backend/app/dependencies/jury.py b/backend/app/dependencies/jury.py new file mode 100644 index 0000000..2f6ad4b --- /dev/null +++ b/backend/app/dependencies/jury.py @@ -0,0 +1,31 @@ +from .current_user import CurrentUserDep +from .session import SessionDep +from app.models import User +from fastapi import HTTPException, status, Depends + + +# Permission dependencies +async def get_organizer_or_admin( + tournament_id: int, current_user: CurrentUserDep, session: SessionDep +) -> User: + from app.utils import get_tournament + + tournament = await get_tournament(tournament_id, session) + if tournament.creator_id != current_user.id and not current_user.is_admin: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Permission denied") + return current_user + + +async def get_assigned_jury( + assignment_id: int, current_user: CurrentUserDep, session: SessionDep +) -> User: + from app.utils import get_assignment + + assignment = await get_assignment(assignment_id, session) + if assignment.jury_id != current_user.id and not current_user.is_admin: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Permission denied") + return current_user + + +organizer_or_admin_dependency = Depends(get_organizer_or_admin) +assigned_jury_dependency = Depends(get_assigned_jury) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 99d8805..69c26d0 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,10 @@ from .base import Base -from .evaluation import JuryAssignment, JuryAssignmentStatusOption, CriterionScore, SubmissionEvaluation +from .evaluation import ( + JuryAssignment, + JuryAssignmentStatusOption, + CriterionScore, + SubmissionEvaluation, +) from .news import News, NewsCattegory from .notification import Notification from .role_request import RoleRequest, RoleRequestInfo, RoleRequestInfoOption diff --git a/backend/app/models/evaluation.py b/backend/app/models/evaluation.py index 41db788..f4961dc 100644 --- a/backend/app/models/evaluation.py +++ b/backend/app/models/evaluation.py @@ -14,7 +14,9 @@ class JuryAssignmentStatusOption(Base, OptionMixin): ) def __repr__(self): - return f"" + return ( + f"" + ) class JuryAssignment(Base, PKMixin, DatetimeMixin): @@ -49,7 +51,7 @@ class JuryAssignment(Base, PKMixin, DatetimeMixin): class SubmissionEvaluation(Base, PKMixin, DatetimeMixin): __tablename__ = "evaluations" - __table_args__ = (UniqueConstraint('jury_id', 'submission_id'),) + __table_args__ = (UniqueConstraint("jury_id", "submission_id"),) assignment_id: Mapped[int] = mapped_column( ForeignKey("jury_assignments.id", ondelete="CASCADE"), nullable=False diff --git a/backend/app/models/task.py b/backend/app/models/task.py index d53e41b..212da65 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -115,7 +115,9 @@ class TaskEvaluationCategory(Base, PKMixin): ) name: Mapped[str] - task: Mapped["Task"] = relationship(back_populates="evaluation_categories", lazy="selectin") + task: Mapped["Task"] = relationship( + back_populates="evaluation_categories", lazy="selectin" + ) criteria: Mapped[list["TaskEvaluationCriterion"]] = relationship( back_populates="category", lazy="selectin", cascade="all, delete-orphan" ) @@ -137,4 +139,4 @@ class TaskEvaluationCriterion(Base, PKMixin): ) criterion_scores: Mapped[list["CriterionScore"]] = relationship( back_populates="criterion", lazy="selectin" - ) \ No newline at end of file + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 2fe29d3..24db097 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -65,7 +65,7 @@ class User(Base, PKMixin): @property def is_admin(self) -> bool: return any(role.name == settings.ROLE_NAMES.ADMIN for role in self.roles) - + @property def is_jury(self) -> bool: return len(self.evaluates_in) > 0 diff --git a/backend/app/routes/jury.py b/backend/app/routes/jury.py new file mode 100644 index 0000000..a6ae1b0 --- /dev/null +++ b/backend/app/routes/jury.py @@ -0,0 +1,459 @@ +import random + +from fastapi import APIRouter, HTTPException, status +from sqlalchemy import select, update +from sqlalchemy.orm import selectinload +from app.config import settings + +from app.dependencies import ( + CurrentUserDep, + SessionDep, + current_user_dependency, + organizer_or_admin_dependency, + assigned_jury_dependency, +) +from app.models import ( + JuryAssignment, + CriterionScore, + Submission, + SubmissionEvaluation, + SubmissionUrl, + Task, + TaskEvaluationCategory, + Team, +) +from app.schemas import ( + EvaluationLeaderboardEntry, + JuryAssignmentPublic, + JuryAssignmentPublic, + SubmissionEvaluationCreate, + SubmissionEvaluationUpdate, + SubmissionEvaluationPublic, + TaskPublic, +) +from app.utils import get_task_by_tournament, get_tournament, get_assignment, get_criterion + +router = APIRouter(tags=["jury"]) + + +def _criterion_map(task: Task) -> dict[int, object]: + criteria = {} + for category in task.evaluation_categories: + for criterion in category.criteria: + criteria[criterion.id] = criterion + return criteria + + +def _calculate_evaluation_total(evaluation: SubmissionEvaluation) -> float: + weighted = 0.0 + total_weight = 0 + for item in evaluation.criterion_scores: + weight = item.criterion.weight + total_weight += weight + weighted += (item.score / item.criterion.max_score) * weight * 100 + if total_weight == 0: + return 0.0 + return round(weighted / total_weight, 2) + + +async def _task_leaderboard( + task_id: int, session: SessionDep +) -> list[EvaluationLeaderboardEntry]: + statement = ( + select(Submission) + .where(Submission.task_id == task_id) + .options( + selectinload(Submission.team), + selectinload(Submission.evaluations) + .selectinload(SubmissionEvaluation.criterion_scores) + .selectinload(CriterionScore.criterion), + ) + ) + submissions = (await session.execute(statement)).scalars().unique().all() + leaderboard = [] + for submission in submissions: + totals = [ + _calculate_evaluation_total(evaluation) for evaluation in submission.evaluations + ] + if totals: + average_score = round(sum(totals) / len(totals), 2) + total_score = round(sum(totals), 2) + else: + average_score = 0.0 + total_score = 0.0 + leaderboard.append( + EvaluationLeaderboardEntry( + submission_id=submission.team_id, + team_id=submission.team_id, + team_name=submission.team.name, + average_score=average_score, + total_score=total_score, + submitted_reviews=len(totals), + ) + ) + return sorted(leaderboard, key=lambda item: (-item.average_score, item.team_name.lower())) + + +@router.get( + "/jury/tasks", response_model=list[TaskPublic], dependencies=[current_user_dependency] +) +async def jury_tasks(current_user: CurrentUserDep, session: SessionDep): + statement = ( + select(Task) + .join(Task.jury_assignments) + .where( + JuryAssignment.jury_id == current_user.id, + Task.status_id == settings.TASK_STATUS_NAMES.SUBMISSION_CLOSED, + ) + .options( + selectinload(Task.evaluation_categories).selectinload( + TaskEvaluationCategory.criteria + ) + ) + .distinct() + ) + return (await session.execute(statement)).scalars().unique().all() + + +@router.get( + "/jury/tasks/{task_id}/assignments", + response_model=list[JuryAssignmentPublic], + dependencies=[current_user_dependency], +) +async def jury_task_assignments( + task_id: int, current_user: CurrentUserDep, session: SessionDep +): + statement = ( + select(JuryAssignment) + .where(JuryAssignment.task_id == task_id, JuryAssignment.jury_id == current_user.id) + .options( + selectinload(JuryAssignment.status), + selectinload(JuryAssignment.task) + .selectinload(Task.evaluation_categories) + .selectinload(TaskEvaluationCategory.criteria), + selectinload(JuryAssignment.submission) + .selectinload(Submission.team) + .selectinload(Team.members), + selectinload(JuryAssignment.submission) + .selectinload(Submission.team) + .selectinload(Team.tournament), + selectinload(JuryAssignment.submission) + .selectinload(Submission.urls) + .selectinload(SubmissionUrl.url), + selectinload(JuryAssignment.evaluation).selectinload( + SubmissionEvaluation.criterion_scores + ), + ) + ) + return (await session.execute(statement)).scalars().unique().all() + + +@router.get( + "/jury/assignments/{assignment_id}", + response_model=JuryAssignmentPublic, + dependencies=[current_user_dependency, assigned_jury_dependency], +) +async def jury_assignment_detail(assignment_id: int, session: SessionDep): + assignment = await get_assignment(assignment_id, session) + return assignment + + +@router.post( + "/jury/assignments/{assignment_id}/evaluation", + response_model=SubmissionEvaluationPublic, + status_code=status.HTTP_201_CREATED, + dependencies=[current_user_dependency, assigned_jury_dependency], +) +async def create_evaluation( + assignment_id: int, + payload: SubmissionEvaluationCreate, + current_user: CurrentUserDep, + session: SessionDep, +): + assignment = await get_assignment(assignment_id, session) + if assignment.evaluation is not None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Evaluation already exists") + + criteria = _criterion_map(assignment.task) + evaluation = SubmissionEvaluation( + assignment_id=assignment.id, + submission_id=assignment.submission_id, + jury_id=current_user.id, + comment=payload.comment, + ) + for item in payload.criterion_scores: + criterion = criteria.get(item.criterion_id) + if criterion is None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Criterion not found") + if item.score > criterion.max_score: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Score for criterion {criterion.name} exceeds max score", + ) + evaluation.criterion_scores.append(CriterionScore(**item.model_dump())) + + assignment.status_id = settings.JURY_ASSIGNMENT_STATUS_NAMES.SUBMITTED + session.add(evaluation) + await session.commit() + await session.refresh(evaluation) + return evaluation + + +@router.patch( + "/jury/assignments/{assignment_id}/evaluation", + response_model=SubmissionEvaluationPublic, + dependencies=[current_user_dependency, assigned_jury_dependency], +) +async def update_evaluation( + assignment_id: int, + update_data: SubmissionEvaluationUpdate, + current_user: CurrentUserDep, + session: SessionDep, +): + payload = update_data.model_dump(exclude_unset=True) + assignment = await get_assignment(assignment_id, session) + if assignment.evaluation is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Evaluation not found") + + criterions = _criterion_map(assignment.task) + + for item in payload.pop("criterion_scores"): + criterion = await get_criterion(item["criterion_id"], session) + if item["score"] > criterions[item["criterion_id"]].max_score: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Score for criterion {criterion.name} exceeds max score", + ) + criterion.score = item["score"] + + if update_data: + result = await session.execute( + update(SubmissionEvaluation) + .where(SubmissionEvaluation.id == assignment.evaluation.id) + .values(**payload) + .returning(SubmissionEvaluation) + ) + evaluation = result.scalar_one_or_none() + if not evaluation: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Evaluation not found" + ) + await session.commit() + await session.refresh(assignment.evaluation) + return assignment.evaluation + + +@router.post( + "/tournaments/{tournament_id}/tasks/{task_id}/jury-assignments/generate", + response_model=list[JuryAssignmentPublic], + dependencies=[current_user_dependency, organizer_or_admin_dependency], +) +async def generate_jury_assignments( + tournament_id: int, + task_id: int, + session: SessionDep, +): + task = await get_task_by_tournament(tournament_id, task_id, session) + tournament = task.tournament + + juries = list(tournament.juries) + submissions = ( + ( + await session.execute( + select(Submission) + .join(Submission.team) + .where(Submission.task_id == task.id, Team.tournament_id == tournament.id) + .options(selectinload(Submission.team)) + ) + ) + .scalars() + .unique() + .all() + ) + if not submissions: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="No submissions to assign") + if len(juries) < task.min_reviews_per_submission: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="Not enough juries to satisfy minimum reviews per submission", + ) + + existing = ( + ( + await session.execute( + select(JuryAssignment).where(JuryAssignment.task_id == task.id) + ) + ) + .scalars() + .all() + ) + if existing: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="Assignments already generated for this task", + ) + + jury_loads = {jury.id: 0 for jury in juries} + created = [] + randomized_submissions = list(submissions) + random.shuffle(randomized_submissions) + for submission in randomized_submissions: + available = sorted( + juries, + key=lambda jury: (jury_loads[jury.id], random.random()), + ) + selected = available[: task.min_reviews_per_submission] + for jury in selected: + assignment = JuryAssignment( + task_id=task.id, + submission_id=submission.team_id, + jury_id=jury.id, + status_id=settings.JURY_ASSIGNMENT_STATUS_NAMES.ASSIGNED, + ) + session.add(assignment) + created.append(assignment) + jury_loads[jury.id] += 1 + + await session.commit() + + # Refresh created assignments with status and evaluation relationships + created_ids = [a.id for a in created] + statement = ( + select(JuryAssignment) + .where(JuryAssignment.id.in_(created_ids)) + .options(selectinload(JuryAssignment.status), selectinload(JuryAssignment.evaluation)) + ) + result = (await session.execute(statement)).scalars().unique().all() + return result + + +@router.get( + "/tournaments/{tournament_id}/tasks/{task_id}/jury-assignments", + response_model=list[JuryAssignmentPublic], + dependencies=[current_user_dependency, organizer_or_admin_dependency], +) +async def get_task_assignments( + tournament_id: int, + task_id: int, + current_user: CurrentUserDep, + session: SessionDep, +): + task = await get_task_by_tournament(tournament_id, task_id, session) + statement = ( + select(JuryAssignment) + .where(JuryAssignment.task_id == task.id) + .options( + selectinload(JuryAssignment.status), + selectinload(JuryAssignment.task) + .selectinload(Task.evaluation_categories) + .selectinload(TaskEvaluationCategory.criteria), + selectinload(JuryAssignment.submission) + .selectinload(Submission.team) + .selectinload(Team.members), + selectinload(JuryAssignment.submission) + .selectinload(Submission.team) + .selectinload(Team.tournament), + selectinload(JuryAssignment.submission) + .selectinload(Submission.urls) + .selectinload(SubmissionUrl.url), + selectinload(JuryAssignment.evaluation).selectinload( + SubmissionEvaluation.criterion_scores + ), + ) + ) + return (await session.execute(statement)).scalars().unique().all() + + +@router.post( + "/tournaments/{tournament_id}/tasks/{task_id}/finish-evaluation", + response_model=TaskPublic, + dependencies=[organizer_or_admin_dependency], +) +async def finish_evaluation( + tournament_id: int, + task_id: int, + current_user: CurrentUserDep, + session: SessionDep, +): + task = await get_task_by_tournament(tournament_id, task_id, session) + if task.status_id != settings.TASK_STATUS_NAMES.SUBMISSION_CLOSED: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="The submissions are still open to be received!", + ) + + jury_assignmnents = ( + ( + await session.execute( + select(JuryAssignment).where( + JuryAssignment.task_id == task.id, + JuryAssignment.jury_id == current_user.id, + ) + ) + ) + .scalars() + .all() + ) + + for a in jury_assignmnents: + a.status_id = settings.JURY_ASSIGNMENT_STATUS_NAMES.EVALUATED + + await session.commit() + + nonevaluated_assignments = [ + a + for a in task.jury_assignments + if a.status != settings.JURY_ASSIGNMENT_STATUS_NAMES.EVALUATED + ] + # If all the submissions were evaluated, mark the task as evaluated + if len(nonevaluated_assignments) == 0: + task.status_id = settings.TASK_STATUS_NAMES.EVALUATED + await session.commit() + await session.refresh(task) + return task + + +# All the leaderboard functionality is poor quality +# TODO?: refactor leaderboard functionality if we have enough time + + +@router.get( + "/tournaments/{tournament_id}/tasks/{task_id}/leaderboard", + response_model=list[EvaluationLeaderboardEntry], +) +async def task_leaderboard(tournament_id: int, task_id: int, session: SessionDep): + task = await get_task_by_tournament(tournament_id, task_id, session) + tournament = task.tournament + if ( + not task.is_leaderboard_visible + or tournament.status_id != settings.TOURNAMENT_STATUS_NAMES.FINISHED + ): + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Leaderboard is hidden") + return await _task_leaderboard(task.id, session) + + +@router.get( + "/tournaments/{tournament_id}/leaderboard", + response_model=list[EvaluationLeaderboardEntry], +) +async def tournament_leaderboard(tournament_id: int, session: SessionDep): + tournament = await get_tournament(tournament_id, session) + team_scores: dict[int, EvaluationLeaderboardEntry] = {} + for task in tournament.tasks: + if task.status_id != settings.TASK_STATUS_NAMES.EVALUATED: + continue + for entry in await _task_leaderboard(task.id, session): + existing = team_scores.get(entry.team_id) + if existing is None: + team_scores[entry.team_id] = entry.model_copy() + continue + existing.total_score = round(existing.total_score + entry.average_score, 2) + existing.submitted_reviews += entry.submitted_reviews + existing.average_score = round( + existing.total_score + / max(1, len([t for t in tournament.tasks if t.status_id == "evaluated"])), + 2, + ) + return sorted( + team_scores.values(), key=lambda item: (-item.total_score, item.team_name.lower()) + ) diff --git a/backend/app/routes/tasks.py b/backend/app/routes/tasks.py index 1e57ec0..99a14ac 100644 --- a/backend/app/routes/tasks.py +++ b/backend/app/routes/tasks.py @@ -1,11 +1,12 @@ from fastapi import status, HTTPException from fastapi.routing import APIRouter from sqlalchemy import select +from sqlalchemy.orm import selectinload from app.config import settings from app.dependencies import SessionDep -from app.models import Task -from app.schemas import TaskCreate, TaskUpdate, TaskPublic +from app.models import Task, Submission, SubmissionUrl, Team +from app.schemas import TaskCreate, TaskUpdate, TaskPublic, SubmissionCreate, SubmissionModel from app.utils import TaskStatus, update_tasks_status from app.utils import get_requirements, get_task from app.dependencies import current_user_dependency @@ -108,3 +109,27 @@ async def delete_task(task_id: int, session: SessionDep): await session.delete(task) await session.commit() + + +@router.post( + "/{task_id}/submissions/", + response_model=SubmissionModel, + status_code=status.HTTP_201_CREATED, +) +async def create_submission( + tournament_id: int, task_id: int, submission_data: SubmissionCreate, session: SessionDep +): + team = await session.get(Team, submission_data.team_id) + if not team: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Team not found") + + new_submission = Submission(team_id=submission_data.team_id, task_id=task_id) + + for item in submission_data.urls: + new_submission.urls.append(SubmissionUrl(url_id=item.url_id, value=item.value)) + + session.add(new_submission) + await session.commit() + await session.refresh(new_submission, ["urls"]) + + return new_submission diff --git a/backend/app/routes/tournaments.py b/backend/app/routes/tournaments.py index d486247..f33e1e8 100644 --- a/backend/app/routes/tournaments.py +++ b/backend/app/routes/tournaments.py @@ -11,7 +11,6 @@ from app.dependencies import SessionDep, CurrentUserDep from app.utils import get_tournament, tournament_load_options, get_status_by_name from app.dependencies import get_user, current_user_dependency -from app.schemas import SubmissionPublic router = APIRouter(prefix="/tournaments", tags=["tournaments"]) @@ -29,12 +28,6 @@ async def tournaments(session: SessionDep): async def tournament(tournament_id: int, session: SessionDep): return await get_tournament(tournament_id, session) -@router.get( - "/{tournament_id}/submissions", response_model=list[SubmissionPublic], status_code=status.HTTP_200_OK -) -async def submissions(tournament_id: int, session: SessionDep, current_user: CurrentUserDep): - tournament = await get_tournament(tournament_id, session) - return tournament.submissions @router.post("/", response_model=TournamentPublic, status_code=status.HTTP_201_CREATED) async def create_tournament( diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 2b791d8..cda7969 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -7,7 +7,7 @@ JuryAssignmentPublic, SubmissionEvaluationCreate, SubmissionEvaluationPublic, - SubmissionEvaluationUpdate + SubmissionEvaluationUpdate, ) from .news import NewsPublic from .notification import NotificationPublic, NotificationCreate @@ -46,7 +46,7 @@ TournamentCreate, TournamentPublic, TournamentStatusOptionModel, - TournamentPublicMinimal + TournamentPublicMinimal, ) from .user import UserModel, UserPublic, UserUpdate, UserCreate, CurrentUser, UserMinimalPublic diff --git a/backend/app/schemas/evaluation.py b/backend/app/schemas/evaluation.py index 4ab0e54..afb3ebb 100644 --- a/backend/app/schemas/evaluation.py +++ b/backend/app/schemas/evaluation.py @@ -13,9 +13,11 @@ class CriterionScoreBase(BaseModel): criterion_id: int = Field(..., gt=0) score: int = Field(..., ge=0) + class CriterionScoreCreate(CriterionScoreBase): pass + class CriterionScorePublic(CriterionScoreBase): id: int @@ -23,9 +25,11 @@ class CriterionScorePublic(CriterionScoreBase): class SubmissionEvaluationBase(BaseModel): comment: str | None = None + class SubmissionEvaluationCreate(SubmissionEvaluationBase): criterion_scores: list[CriterionScoreCreate] = Field([], min_length=1) + class SubmissionEvaluationUpdate(SubmissionEvaluationBase): comment: str | None = None criterion_scores: list[CriterionScoreCreate] = Field([], min_length=1) @@ -47,6 +51,7 @@ class JuryAssignmentStatusPublic(BaseModel): name: str display_name: str + class JuryAssignmentBase(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -56,12 +61,14 @@ class JuryAssignmentBase(BaseModel): jury_id: int status_id: str + class JuryAssignmentPublic(JuryAssignmentBase): status: JuryAssignmentStatusPublic submission: SubmissionPublic task: TaskPublic evaluation: SubmissionEvaluationPublic | None = None + class EvaluationLeaderboardEntry(BaseModel): submission_id: int team_id: int diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 245f9f0..39e7486 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -38,6 +38,7 @@ class UserUpdate(UserBase): class UserMinimalPublic(UserBase): """Minimal user schema without circular relationships for use in nested contexts""" + id: int email: EmailStr firebase_uid: str @@ -56,7 +57,7 @@ class UserPublic(UserBase): github: str | None discord: str | None is_jury: bool - evaluates_in: list['TournamentPublic'] + evaluates_in: list["TournamentPublic"] class UserModel(UserBase): diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py index b4b8072..6ed60a6 100644 --- a/backend/app/utils/__init__.py +++ b/backend/app/utils/__init__.py @@ -2,6 +2,8 @@ from .fsm import * from .notifications import send_notification from .users import get_user_by_email, get_user_by_firebase_uid, get_user_by_id -from .tasks import get_requirements, get_task +from .tasks import get_requirements, get_task, get_task_by_tournament from .tournaments import get_tournament, tournament_load_options from .roles import get_role +from .assignments import get_assignment +from .criterions import get_criterion diff --git a/backend/app/utils/assignments.py b/backend/app/utils/assignments.py new file mode 100644 index 0000000..e22e387 --- /dev/null +++ b/backend/app/utils/assignments.py @@ -0,0 +1,43 @@ +from sqlalchemy import select +from sqlalchemy.orm import selectinload +from app.models import ( + JuryAssignment, + Task, + Submission, + TaskEvaluationCategory, + Team, + SubmissionEvaluation, + SubmissionUrl, +) +from app.dependencies import SessionDep +from fastapi import HTTPException, status + + +async def get_assignment(assignment_id: int, session: SessionDep) -> JuryAssignment: + statement = ( + select(JuryAssignment) + .where(JuryAssignment.id == assignment_id) + .options( + selectinload(JuryAssignment.status), + selectinload(JuryAssignment.task).selectinload(Task.evaluation_categories), + selectinload(JuryAssignment.task) + .selectinload(Task.evaluation_categories) + .selectinload(TaskEvaluationCategory.criteria), + selectinload(JuryAssignment.submission) + .selectinload(Submission.team) + .selectinload(Team.members), + selectinload(JuryAssignment.submission) + .selectinload(Submission.team) + .selectinload(Team.tournament), + selectinload(JuryAssignment.submission) + .selectinload(Submission.urls) + .selectinload(SubmissionUrl.url), + selectinload(JuryAssignment.evaluation).selectinload( + SubmissionEvaluation.criterion_scores + ), + ) + ) + assignment = (await session.execute(statement)).scalar_one_or_none() + if assignment is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Assignment not found") + return assignment diff --git a/backend/app/utils/criterions.py b/backend/app/utils/criterions.py new file mode 100644 index 0000000..783e15f --- /dev/null +++ b/backend/app/utils/criterions.py @@ -0,0 +1,13 @@ +from sqlalchemy import select +from app.models import CriterionScore +from app.dependencies import SessionDep +from fastapi import HTTPException, status + + +async def get_criterion(criterion_id: int, session: SessionDep) -> CriterionScore: + statement = select(CriterionScore).where(CriterionScore.criterion_id == criterion_id) + + criterion = (await session.execute(statement)).scalar() + if not criterion: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Criterion not found!") + return criterion diff --git a/backend/app/utils/tasks.py b/backend/app/utils/tasks.py index 8fc077b..8bbd795 100644 --- a/backend/app/utils/tasks.py +++ b/backend/app/utils/tasks.py @@ -1,17 +1,39 @@ from fastapi import HTTPException, status from sqlalchemy import select +from sqlalchemy.orm import selectinload from app.dependencies.session import SessionDep -from app.models import Task, TaskRequirementOption +from app.models import Task, TaskEvaluationCategory, TaskRequirementOption +from .tournaments import get_tournament async def get_task(task_id: int, session: SessionDep) -> Task: - statement = select(Task).where(Task.id == task_id) + statement = ( + select(Task) + .where(Task.id == task_id) + .options( + selectinload(Task.evaluation_categories).selectinload( + TaskEvaluationCategory.criteria + ) + ) + ) task = (await session.execute(statement)).scalar() if not task: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Task not found!") return task +async def get_task_by_tournament( + tournament_id: int, task_id: int, session: SessionDep +) -> Task: + tournament = await get_tournament(tournament_id, session) + task = await get_task(task_id, session) + if task.tournament_id != tournament.id: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, detail="Task does not belong to this tournament" + ) + return task + + async def get_requirements( requirement_names: list[str], session: SessionDep ) -> list[TaskRequirementOption]: diff --git a/backend/tests/factories.py b/backend/tests/factories.py index 51cf041..7bdb142 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -46,11 +46,12 @@ class Meta: name = Faker("name") display_name = factory.LazyAttribute(lambda f: f.name.upper()) + class BaseDatetimeFactory(BaseFactory): class Meta: abstract = True - created_at = factory.Faker('date_time') + created_at = factory.Faker("date_time") updated_at = factory.LazyAttribute(lambda o: o.created_at + datetime.timedelta(hours=1)) @@ -188,7 +189,9 @@ class JuryAssignmentStatusOptionFactory(BaseOptionFactory): class Meta: model = JuryAssignmentStatusOption - name = factory.Iterator([option["name"] for option in settings.JURY_ASSIGNMENT_STATUS_OPTIONS]) + name = factory.Iterator( + [option["name"] for option in settings.JURY_ASSIGNMENT_STATUS_OPTIONS] + ) display_name = factory.LazyAttribute( lambda status: next( option["display_name"] @@ -283,7 +286,9 @@ class Meta: submission = factory.SubFactory(SubmissionFactory) task = factory.SelfAttribute("submission.task") jury = factory.SubFactory(UserFactory) - status_id = factory.Iterator([option["name"] for option in settings.JURY_ASSIGNMENT_STATUS_OPTIONS]) + status_id = factory.Iterator( + [option["name"] for option in settings.JURY_ASSIGNMENT_STATUS_OPTIONS] + ) @classmethod def _adjust_kwargs(cls, **kwargs): diff --git a/backend/tests/routes/test_jury.py b/backend/tests/routes/test_jury.py new file mode 100644 index 0000000..47258c0 --- /dev/null +++ b/backend/tests/routes/test_jury.py @@ -0,0 +1,85 @@ +from app import app +from app.dependencies import get_current_user +from app.models import TaskEvaluationCategory, TaskEvaluationCriterion +from tests.factories import ( + JuryAssignmentStatusOptionFactory, + RoleFactory, + SubmissionUrlOptionFactory, + TaskFactory, + TaskStatusOptionFactory, + TeamFactory, + TeamMemberFactory, + TournamentFactory, + TournamentStatusOptionFactory, + UserFactory, +) +from app.config import settings + + +async def test_generate_assignments_and_submit_evaluation(create, client, db_session): + admin_role = await create(RoleFactory, name=settings.ROLE_NAMES.ADMIN) + organizer = await create(UserFactory, roles=[admin_role]) + jury1 = await create(UserFactory) + jury2 = await create(UserFactory) + participant = await create(UserFactory) + + tournament_status = await create( + TournamentStatusOptionFactory, name=settings.TOURNAMENT_STATUS_NAMES.DRAFT + ) + await create(TaskStatusOptionFactory, name=settings.TASK_STATUS_NAMES.DRAFT) + await create( + JuryAssignmentStatusOptionFactory, name=settings.JURY_ASSIGNMENT_STATUS_NAMES.ASSIGNED + ) + + tournament = await create(TournamentFactory, creator=organizer, status=tournament_status) + await db_session.refresh(tournament, ["juries"]) + tournament.juries.extend([jury1, jury2]) + + task = await create(TaskFactory, tournament=tournament, min_reviews_per_submission=2) + category = TaskEvaluationCategory(task=task, name="Functionality") + criterion = TaskEvaluationCriterion( + category=category, + name="Completeness", + weight=1, + max_score=10, + ) + db_session.add_all([category, criterion]) + + team = await create(TeamFactory, tournament=tournament) + await create(TeamMemberFactory, team=team, tournament=tournament, email=participant.email) + option = await create(SubmissionUrlOptionFactory, name="github") + await db_session.commit() + + app.dependency_overrides[get_current_user] = lambda: participant + submission_resp = await client.post( + f"/tournaments/{tournament.id}/tasks/{task.id}/submissions/", + json={ + "team_id": team.id, + "urls": [{"url_id": option.name, "value": "https://github.com/example/repo"}], + }, + ) + assert submission_resp.status_code == 201 + + app.dependency_overrides[get_current_user] = lambda: organizer + generate_resp = await client.post( + f"/tournaments/{tournament.id}/tasks/{task.id}/jury-assignments/generate" + ) + assert generate_resp.status_code == 200 + assert len(generate_resp.json()) == 2 + + app.dependency_overrides[get_current_user] = lambda: jury1 + assignments_resp = await client.get(f"/jury/tasks/{task.id}/assignments") + assert assignments_resp.status_code == 200 + assignment = assignments_resp.json()[0] + + evaluation_resp = await client.post( + f"/jury/assignments/{assignment['id']}/evaluation", + json={ + "comment": "Solid work", + "criterion_scores": [{"criterion_id": criterion.id, "score": 8}], + }, + ) + assert evaluation_resp.status_code == 201 + assert evaluation_resp.json()["comment"] == "Solid work" + + app.dependency_overrides.pop(get_current_user) From a43ef35891b31f9f7657f9616e8167905ae5ca9a Mon Sep 17 00:00:00 2001 From: Fundi1330 Date: Tue, 12 May 2026 22:29:49 +0300 Subject: [PATCH 05/38] feat: implement jury panel on frontend --- frontend/package-lock.json | 46 +++- frontend/src/api/requests/auth.ts | 8 + frontend/src/api/requests/finishEvaluation.ts | 18 ++ .../src/api/requests/getJuryAssignments.ts | 18 ++ frontend/src/api/requests/getJuryTasks.ts | 12 + frontend/src/api/requests/index.ts | 1 - .../api/requests/saveAssignmentEvaluation.ts | 28 +++ frontend/src/api/requests/types.ts | 110 +++++++++ .../JuryPanel/EvaluateTournamentPage.tsx | 114 +++++++-- frontend/src/pages/JuryPanel/JuryPanel.tsx | 51 ++++- .../JuryPanel/components/EvaluationsModal.tsx | 148 ++++++++++++ .../JuryPanel/components/SubmissionCard.tsx | 59 ++++- .../JuryPanel/components/TournamentCard.tsx | 42 +++- .../__snapshots__/Profile.test.tsx.snap | 216 ++++++++++++++++++ frontend/src/routers/Router.tsx | 6 - 15 files changed, 808 insertions(+), 69 deletions(-) create mode 100644 frontend/src/api/requests/auth.ts create mode 100644 frontend/src/api/requests/finishEvaluation.ts create mode 100644 frontend/src/api/requests/getJuryAssignments.ts create mode 100644 frontend/src/api/requests/getJuryTasks.ts create mode 100644 frontend/src/api/requests/saveAssignmentEvaluation.ts create mode 100644 frontend/src/api/requests/types.ts create mode 100644 frontend/src/pages/Profile/__snapshots__/Profile.test.tsx.snap diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 07af8b5..32f2d83 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -157,6 +157,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -529,6 +530,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -577,6 +579,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1303,6 +1306,7 @@ "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.9.tgz", "integrity": "sha512-3gtUX0e584MYkKBQMgSECMvE1Dwzg+eONefDQ0wxVSe5YMBsZwdN5pL7UapwWBlV8+i8QCztF9TP947tEjZAGA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@firebase/component": "0.7.1", "@firebase/logger": "0.5.0", @@ -1369,6 +1373,7 @@ "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.9.tgz", "integrity": "sha512-e5LzqjO69/N2z7XcJeuMzIp4wWnW696dQeaHAUpQvGk89gIWHAIvG6W+mA3UotGW6jBoqdppEJ9DnuwbcBByug==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@firebase/app": "0.14.9", "@firebase/component": "0.7.1", @@ -1384,7 +1389,8 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@firebase/auth": { "version": "1.12.1", @@ -1835,6 +1841,7 @@ "integrity": "sha512-/gnejm7MKkVIXnSJGpc9L2CvvvzJvtDPeAEq5jAwgVlf/PeNxot+THx/bpD20wQ8uL5sz0xqgXy1nisOYMU+mw==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" }, @@ -2854,6 +2861,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz", "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.95.2" }, @@ -3005,8 +3013,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3099,6 +3106,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3109,6 +3117,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3164,6 +3173,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -3428,6 +3438,7 @@ "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.4", @@ -3557,6 +3568,7 @@ "integrity": "sha512-EgFR7nlj5iTDYZYCvavjFokNYwr3c3ry0sFiCg+N7B233Nwp+NNx7eoF/XvMWDCKY71xXAG3kFkt97ZHBJVL8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.1.4", "fflate": "^0.8.2", @@ -3594,6 +3606,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3789,6 +3802,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4073,7 +4087,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cva": { "version": "1.0.0-beta.4", @@ -4185,8 +4200,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -4397,6 +4411,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4694,6 +4709,7 @@ "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.10.0.tgz", "integrity": "sha512-tAjHnEirksqWpa+NKDUSUMjulOnsTcsPC1X1rQ+gwPtjlhJS572na91CwaBXQJHXharIrfj7sw/okDkXOsphjA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@firebase/ai": "2.9.0", "@firebase/analytics": "0.10.20", @@ -5952,7 +5968,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6140,6 +6155,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -6366,7 +6382,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6382,7 +6397,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -6441,6 +6455,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6450,6 +6465,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6462,6 +6478,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz", "integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6504,14 +6521,14 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -6596,7 +6613,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -7178,6 +7196,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7281,6 +7300,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -7356,6 +7376,7 @@ "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.4", "@vitest/mocker": "4.1.4", @@ -7700,6 +7721,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/api/requests/auth.ts b/frontend/src/api/requests/auth.ts new file mode 100644 index 0000000..3bdbc34 --- /dev/null +++ b/frontend/src/api/requests/auth.ts @@ -0,0 +1,8 @@ +import type { User } from "firebase/auth"; + +export const authHeaders = async (user: User) => { + const token = await user.getIdToken(); + return { + Authorization: `Bearer ${token}`, + }; +}; diff --git a/frontend/src/api/requests/finishEvaluation.ts b/frontend/src/api/requests/finishEvaluation.ts new file mode 100644 index 0000000..4f3ed9a --- /dev/null +++ b/frontend/src/api/requests/finishEvaluation.ts @@ -0,0 +1,18 @@ +import type { User } from "firebase/auth"; + +import apiClient from "../client"; +import { authHeaders } from "./auth"; +import type { JuryTask } from "./types"; + +export const finishEvaluation = async ( + tournamentId: number, + taskId: number, + user: User, +) => { + const resp = await apiClient.post( + `/tournaments/${tournamentId}/tasks/${taskId}/finish-evaluation`, + {}, + { headers: await authHeaders(user) }, + ); + return resp.data; +}; diff --git a/frontend/src/api/requests/getJuryAssignments.ts b/frontend/src/api/requests/getJuryAssignments.ts new file mode 100644 index 0000000..e3f451b --- /dev/null +++ b/frontend/src/api/requests/getJuryAssignments.ts @@ -0,0 +1,18 @@ +import type { User } from "firebase/auth"; + +import apiClient from "../client"; +import { authHeaders } from "./auth"; +import type { JuryAssignment } from "./types"; + +export const getJuryAssignments = async ( + taskId: string | number, + user: User, +) => { + const resp = await apiClient.get( + `/jury/tasks/${taskId}/assignments`, + { + headers: await authHeaders(user), + }, + ); + return resp.data; +}; diff --git a/frontend/src/api/requests/getJuryTasks.ts b/frontend/src/api/requests/getJuryTasks.ts new file mode 100644 index 0000000..abf8952 --- /dev/null +++ b/frontend/src/api/requests/getJuryTasks.ts @@ -0,0 +1,12 @@ +import type { User } from "firebase/auth"; + +import apiClient from "../client"; +import { authHeaders } from "./auth"; +import type { JuryTask } from "./types"; + +export const getJuryTasks = async (user: User) => { + const resp = await apiClient.get("/jury/tasks", { + headers: await authHeaders(user), + }); + return resp.data; +}; diff --git a/frontend/src/api/requests/index.ts b/frontend/src/api/requests/index.ts index 5525e89..2dba2f0 100644 --- a/frontend/src/api/requests/index.ts +++ b/frontend/src/api/requests/index.ts @@ -1,3 +1,2 @@ export * from "./deleteUser"; export * from "./getUser"; -export * from './getSubmissions' \ No newline at end of file diff --git a/frontend/src/api/requests/saveAssignmentEvaluation.ts b/frontend/src/api/requests/saveAssignmentEvaluation.ts new file mode 100644 index 0000000..b8b4dfa --- /dev/null +++ b/frontend/src/api/requests/saveAssignmentEvaluation.ts @@ -0,0 +1,28 @@ +import type { User } from "firebase/auth"; + +import apiClient from "../client"; +import { authHeaders } from "./auth"; +import type { CriterionScore, SubmissionEvaluation } from "./types"; + +export const saveAssignmentEvaluation = async ( + assignmentId: number, + data: { comment?: string; criterion_scores: CriterionScore[] }, + user: User, + hasExistingEvaluation: boolean, +) => { + const config = { + headers: await authHeaders(user), + }; + const resp = hasExistingEvaluation + ? await apiClient.patch( + `/jury/assignments/${assignmentId}/evaluation`, + data, + config, + ) + : await apiClient.post( + `/jury/assignments/${assignmentId}/evaluation`, + data, + config, + ); + return resp.data; +}; diff --git a/frontend/src/api/requests/types.ts b/frontend/src/api/requests/types.ts new file mode 100644 index 0000000..35d2422 --- /dev/null +++ b/frontend/src/api/requests/types.ts @@ -0,0 +1,110 @@ +// TODO: Move the types to src/types +export interface TaskStatusOption { + name: string; + display_name: string; +} + +export interface TournamentStatusOption { + name: string; + display_name: string; +} + +export interface AppConfig { + task_statuses: TaskStatusOption[]; + tournament_statuses: TournamentStatusOption[]; +} + +export interface TeamMember { + id: number; + full_name: string; + email: string; + telegram: string; + educational_institution?: string | null; +} + +export interface TournamentSummary { + id: number; + title: string; + description: string; +} + +export interface Team { + id: number; + name: string; + team_email: string; + contact_info: string; + tournament: TournamentSummary; + members: TeamMember[]; +} + +export interface SubmissionUrl { + id: number; + url_id: string; + value: string; + url: { + name: string; + display_name: string; + }; +} + +export interface EvaluationCriterion { + id: number; + name: string; + description?: string | null; + weight: number; + max_score: number; +} + +export interface EvaluationCategory { + id: number; + name: string; + criteria: EvaluationCriterion[]; +} + +export interface JuryTask { + id: number; + title: string; + description?: string | null; + status_id: string; + start_time: string; + end_time: string; + min_reviews_per_submission: number; + max_score: number; + is_leaderboard_visible: boolean; + evaluation_categories: EvaluationCategory[]; +} + +export interface CriterionScore { + id?: number; + criterion_id: number; + score: number; +} + +export interface SubmissionEvaluation { + id: number; + assignment_id: number; + submission_id: number; + jury_id: number; + comment?: string | null; + created_at?: string | null; + criterion_scores: CriterionScore[]; +} + +export interface Submission { + id: number; + team_id: number; + task_id: number; + team: Team; + urls: SubmissionUrl[]; +} + +export interface JuryAssignment { + id: number; + task_id: number; + submission_id: number; + jury_id: number; + status: string; + submission: Submission; + task: JuryTask; + evaluation?: SubmissionEvaluation | null; +} diff --git a/frontend/src/pages/JuryPanel/EvaluateTournamentPage.tsx b/frontend/src/pages/JuryPanel/EvaluateTournamentPage.tsx index 558f40d..df4eebe 100644 --- a/frontend/src/pages/JuryPanel/EvaluateTournamentPage.tsx +++ b/frontend/src/pages/JuryPanel/EvaluateTournamentPage.tsx @@ -1,32 +1,112 @@ -import { getSubmissions } from "@/api/requests"; +import { finishEvaluation, getJuryAssignments, type JuryAssignment } from "@/api/requests"; import { auth } from "@/firebase"; -import type { RootState } from "@/store"; -import { useQuery } from "@tanstack/react-query"; -import { useSelector } from "react-redux"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; import { useParams } from "react-router-dom"; +import { toast } from "sonner"; + +import EvaluationsModal from "./components/EvaluationsModal"; import SubmissionCard from "./components/SubmissionCard"; +import { taskStatusByName } from "@/config/appConfig"; const EvaluateTournamentPage = () => { - const {id} = useParams(); + const { id } = useParams(); + const [selectedAssignment, setSelectedAssignment] = useState(null); + const queryClient = useQueryClient(); const user = auth.currentUser; + const { - data: submissions, + data: assignments = [], isLoading, error, } = useQuery({ - queryKey: ["tournament", id], + queryKey: ["jury-task", id], queryFn: async () => { - if (!id) return; - if (!user) return; - return await getSubmissions(id, user); + if (!id) throw new Error("Task id is missing"); + if (!user) throw new Error("User is not authenticated"); + return getJuryAssignments(id, user); + }, + enabled: !!id && !!user, + }); + + const task = assignments[0]?.task; + const tournamentId = assignments[0]?.submission.team.tournament.id; + const allReviewed = assignments.length > 0 && assignments.every((a) => a.evaluation); + const isFinalized = task?.status_id === taskStatusByName.evaluated.name; + + const finishMutation = useMutation({ + mutationFn: async () => { + if (!tournamentId || !id || !user) throw new Error("Missing context"); + return finishEvaluation(tournamentId, Number(id), user); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["jury-task", id] }); + toast.success("Round finalized successfully"); + }, + onError: (err) => { + toast.error((err as Error)?.message || "Failed to finalize round"); }, - enabled: !!id, - retry: 1, }); - if (isLoading || !submissions) return <>Loading... + return ( +
+
+
+

Assigned round

+

{task?.title || "Loading round"}

+

+ {task?.description || "Review every assigned submission using the round rubric."} +

+
+ + {isFinalized ? ( +
+

This round has been finalized.

+
+ ) : ( +
+
+

Round status

+

+ {allReviewed + ? "All assigned submissions have been reviewed. An organizer can finalize this round." + : `${assignments.filter((a) => a.evaluation).length}/${assignments.length} submissions reviewed.`} +

+
+ {allReviewed && } +
+ )} + + {isLoading &&
Loading submissions...
} + {error &&
Failed to load assigned submissions.
} + + {!isLoading && !error && assignments.length === 0 ? ( +
+ No submissions assigned for this round. +
+ ) :
+ {assignments.map((assignment) => ( + + ))} + +
+ + } +
+ + setSelectedAssignment(null)} isFinalized={isFinalized} /> +
+ ); +}; - return
{submissions.map((s) => )}
; -} - -export default EvaluateTournamentPage; \ No newline at end of file +export default EvaluateTournamentPage; diff --git a/frontend/src/pages/JuryPanel/JuryPanel.tsx b/frontend/src/pages/JuryPanel/JuryPanel.tsx index 3b68316..11b3086 100644 --- a/frontend/src/pages/JuryPanel/JuryPanel.tsx +++ b/frontend/src/pages/JuryPanel/JuryPanel.tsx @@ -1,15 +1,46 @@ -import type { RootState } from "@/store"; -import { useSelector } from "react-redux"; +import { getJuryTasks } from "@/api/requests"; +import { auth } from "@/firebase"; +import { useQuery } from "@tanstack/react-query"; import TournamentCard from "./components/TournamentCard"; const JuryPanel = () => { - const user = useSelector((state: RootState) => state.user.user); - return
-

Jury panel

-
- {user?.evaluates_in && user.evaluates_in.map((t: any) => )} + const user = auth.currentUser; + const { data: tasks = [], isLoading, error } = useQuery({ + queryKey: ["jury-tasks"], + queryFn: async () => { + if (!user) throw new Error("User is not authenticated"); + return getJuryTasks(user); + }, + enabled: !!user, + }); + + return ( +
+
+
+

Jury panel

+

+ Review only the rounds assigned to you. Each task opens its own rubric and submission list. +

-
; -} -export default JuryPanel; \ No newline at end of file + {isLoading &&
Loading tasks...
} + {error &&
Failed to load jury tasks.
} + + {!isLoading && !error && tasks.length === 0 ? ( +
+ No jury assignments yet. +
+ ) : ( +
+ {tasks.map((task) => ( + + ))} +
+ )} +
+
+ ); +}; + +export default JuryPanel; diff --git a/frontend/src/pages/JuryPanel/components/EvaluationsModal.tsx b/frontend/src/pages/JuryPanel/components/EvaluationsModal.tsx index e69de29..78ac06c 100644 --- a/frontend/src/pages/JuryPanel/components/EvaluationsModal.tsx +++ b/frontend/src/pages/JuryPanel/components/EvaluationsModal.tsx @@ -0,0 +1,148 @@ +import { saveAssignmentEvaluation, type JuryAssignment } from "@/api/requests"; +import { taskStatusByName } from "@/config/appConfig"; +import { auth } from "@/firebase"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import {RiCloseLargeLine} from 'react-icons/ri'; + +interface EvaluationsModalProps { + assignment: JuryAssignment | null; + onClose: () => void; + isFinalized?: boolean; +} + +const EvaluationsModal = ({ assignment, onClose}: EvaluationsModalProps) => { + const queryClient = useQueryClient(); + const [comment, setComment] = useState(""); + const [requirements, setRequirements] = useState>({}); + + useEffect(() => { + if (!assignment) return; + setComment(assignment.evaluation?.comment || ""); + setRequirements( + Object.fromEntries( + (assignment.evaluation?.criterion_scores || []).map((item) => [item.criterion_id, item.score]), + ), + ); + }, [assignment]); + + const mutation = useMutation({ + mutationFn: async () => { + if (!assignment || !auth.currentUser) throw new Error("Missing assignment context"); + return saveAssignmentEvaluation( + assignment.id, + { + comment, + criterion_scores: assignment.task.evaluation_categories.flatMap((category) => + category.criteria.map((criterion) => ({ + criterion_id: criterion.id, + score: requirements[criterion.id] ?? 0, + })), + ), + }, + auth.currentUser, + !!assignment.evaluation, + ); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["jury-task", assignment?.task_id] }); + onClose(); + }, + }); + const isFinalized = assignment?.task.status_id === taskStatusByName.evaluated.name; + + if (!assignment) return null; + + return ( +
+
+
+
+

Evaluation

+

{assignment.submission.team.name}

+

{assignment.task.title}

+
+ +
+ + {isFinalized && ( +
+ This round has been finalized. Evaluations cannot be modified. +
+ )} + +
+ {assignment.task.evaluation_categories.map((category) => ( +
+

{category.name}

+
+ {category.criteria.map((criterion) => ( + + ))} +
+
+ ))} + +
+ +