diff --git a/README.en.md b/README.en.md
index 50c49c5..fb6580e 100644
--- a/README.en.md
+++ b/README.en.md
@@ -251,7 +251,7 @@ pip install -r ../requirements.txt
pytest
```
-There is **no** dedicated `pytest` coverage script in `requirements.txt`; add tools such as `pytest-cov` locally if you need coverage reports.
+If you don't wish to run tests marked as slow, then use the following command `pytest -m 'not slow'`
---
diff --git a/README.ua.md b/README.ua.md
index 14cbc80..3f72841 100644
--- a/README.ua.md
+++ b/README.ua.md
@@ -250,7 +250,7 @@ pip install -r ../requirements.txt
pytest
```
-**Немає** спеціального скрипту для покриття `pytest` в `requirements.txt`; додавайте інструменти на кшталт `pytest-cov` локально, якщо вам потрібні звіти про покриття.
+Щоб не запускати тести, позначені як повільні, напишіть `pytest -m 'not slow'`
---
diff --git a/backend/alembic/versions/127c3fae41db_.py b/backend/alembic/versions/127c3fae41db_.py
deleted file mode 100644
index 9dc9c84..0000000
--- a/backend/alembic/versions/127c3fae41db_.py
+++ /dev/null
@@ -1,276 +0,0 @@
-"""empty message
-
-Revision ID: 127c3fae41db
-Revises: 128f25c448b0
-Create Date: 2026-05-08 15:59:19.355990
-
-"""
-from typing import Sequence, Union
-
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision: str = '127c3fae41db'
-down_revision: Union[str, Sequence[str], None] = '128f25c448b0'
-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('news_categories',
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.PrimaryKeyConstraint('name')
- )
- with op.batch_alter_table('news_categories', schema=None) as batch_op:
- batch_op.create_index(batch_op.f('ix_news_categories_name'), ['name'], unique=False)
-
- op.create_table('role_request_info_options',
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.PrimaryKeyConstraint('name')
- )
- op.create_table('roles',
- sa.Column('description', sa.String(), nullable=False),
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.PrimaryKeyConstraint('name')
- )
- op.create_table('submission_url_options',
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.PrimaryKeyConstraint('name')
- )
- op.create_table('task_requirement_categories',
- sa.Column('main_id', sa.String(), nullable=True),
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.ForeignKeyConstraint(['main_id'], ['task_requirement_categories.name'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('name')
- )
- op.create_table('task_statuses',
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.PrimaryKeyConstraint('name')
- )
- op.create_table('tournament_status_options',
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.PrimaryKeyConstraint('name')
- )
- with op.batch_alter_table('tournament_status_options', schema=None) as batch_op:
- batch_op.create_index(batch_op.f('ix_tournament_status_options_name'), ['name'], unique=False)
-
- op.create_table('users',
- sa.Column('firebase_uid', sa.String(), nullable=False),
- sa.Column('full_name', sa.String(), nullable=False),
- sa.Column('email', sa.String(), nullable=False),
- sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
- sa.Column('telegram', sa.String(), nullable=True),
- sa.Column('github', sa.String(), nullable=True),
- sa.Column('discord', sa.String(), nullable=True),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('email'),
- sa.UniqueConstraint('firebase_uid')
- )
- op.create_table('news',
- sa.Column('title', sa.String(length=512), nullable=False),
- sa.Column('excerpt', sa.String(length=512), nullable=False),
- sa.Column('body', sa.String(), nullable=False),
- sa.Column('is_important', sa.Boolean(), nullable=False),
- sa.Column('category_name', sa.String(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
- sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
- sa.ForeignKeyConstraint(['category_name'], ['news_categories.name'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('notifications',
- sa.Column('body', sa.String(), nullable=False),
- sa.Column('user_id', sa.Integer(), nullable=True),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('role_requests',
- sa.Column('role_name', sa.String(), nullable=False),
- sa.Column('user_id', sa.Integer(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['role_name'], ['roles.name'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('task_requirement_options',
- sa.Column('category_id', sa.String(), nullable=False),
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.ForeignKeyConstraint(['category_id'], ['task_requirement_categories.name'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('name')
- )
- op.create_table('tasks',
- sa.Column('title', sa.String(), nullable=False),
- sa.Column('description', sa.String(), nullable=True),
- sa.Column('start_time', sa.DateTime(), nullable=False),
- sa.Column('end_time', sa.DateTime(), nullable=False),
- sa.Column('tournament_id', sa.Integer(), nullable=False),
- sa.Column('status_id', sa.String(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['status_id'], ['task_statuses.name'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], name='fk_task_tournament', ondelete='CASCADE', use_alter=True),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('tournaments',
- sa.Column('title', sa.String(), nullable=False),
- sa.Column('description', sa.String(), nullable=False),
- sa.Column('start_date', sa.DateTime(), nullable=False),
- sa.Column('reg_start', sa.DateTime(), nullable=False),
- sa.Column('reg_end', sa.DateTime(), nullable=False),
- sa.Column('min_people_in_team', sa.Integer(), nullable=False),
- sa.Column('max_people_in_team', sa.Integer(), nullable=False),
- sa.Column('max_teams', sa.Integer(), nullable=False),
- sa.Column('status_id', sa.String(), nullable=False),
- sa.Column('creator_id', sa.Integer(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['status_id'], ['tournament_status_options.name'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('user_roles',
- sa.Column('user_id', sa.Integer(), nullable=False),
- sa.Column('role_name', sa.String(), nullable=False),
- sa.ForeignKeyConstraint(['role_name'], ['roles.name'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('user_id', 'role_name')
- )
- op.create_table('role_request_info',
- sa.Column('request_id', sa.Integer(), nullable=False),
- sa.Column('option_name', sa.String(), nullable=False),
- sa.Column('value', sa.String(length=4096), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['option_name'], ['role_request_info_options.name'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['request_id'], ['role_requests.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('task_requirements',
- sa.Column('task_id', sa.Integer(), nullable=False),
- sa.Column('requirement_id', sa.String(), nullable=False),
- sa.ForeignKeyConstraint(['requirement_id'], ['task_requirement_options.name'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('task_id', 'requirement_id')
- )
- op.create_table('team_members',
- sa.Column('full_name', sa.String(), nullable=False),
- sa.Column('email', sa.String(), nullable=False),
- sa.Column('telegram', sa.String(), nullable=False),
- sa.Column('educational_institution', sa.String(), nullable=True),
- sa.Column('team_id', sa.Integer(), nullable=False),
- sa.Column('tournament_id', sa.Integer(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['team_id'], ['teams.id'], name='fk_teammember_team', ondelete='CASCADE', use_alter=True),
- sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('team_id', 'email', name='uq_team_member_email'),
- sa.UniqueConstraint('team_id', 'telegram', name='uq_team_member_telegram'),
- sa.UniqueConstraint('tournament_id', 'email', name='uq_tournament_member_email'),
- sa.UniqueConstraint('tournament_id', 'telegram', name='uq_tournament_member_telegram')
- )
- op.create_table('tournament_juries',
- sa.Column('jury_id', sa.Integer(), nullable=False),
- sa.Column('tournament_id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['jury_id'], ['users.id'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('jury_id', 'tournament_id')
- )
- op.create_table('teams',
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('team_email', sa.String(), nullable=False),
- sa.Column('contact_info', sa.String(), nullable=False),
- sa.Column('tournament_id', sa.Integer(), nullable=False),
- sa.Column('captain_id', sa.Integer(), nullable=True),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['captain_id'], ['team_members.id'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('contact_info'),
- sa.UniqueConstraint('contact_info', 'tournament_id'),
- sa.UniqueConstraint('name'),
- sa.UniqueConstraint('team_email')
- )
- op.create_table('submissions',
- sa.Column('team_id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('team_id')
- )
- op.create_table('evaluations',
- sa.Column('submission_id', sa.Integer(), nullable=False),
- sa.Column('jury_id', sa.Integer(), 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.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('submission_id', 'jury_id')
- )
- op.create_table('submission_urls',
- sa.Column('submission_id', sa.Integer(), nullable=False),
- sa.Column('url_id', sa.String(), nullable=False),
- sa.ForeignKeyConstraint(['submission_id'], ['submissions.team_id'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['url_id'], ['submission_url_options.name'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('submission_id', 'url_id')
- )
- op.create_table('requirement_evaluations',
- sa.Column('evaluation_id', sa.Integer(), nullable=False),
- sa.Column('score', sa.Integer(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['evaluation_id'], ['evaluations.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('evaluation_requirements',
- sa.Column('requirement_evaluation_id', sa.Integer(), nullable=False),
- sa.Column('requirement_id', sa.String(), nullable=False),
- sa.ForeignKeyConstraint(['requirement_evaluation_id'], ['requirement_evaluations.id'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['requirement_id'], ['task_requirement_options.name'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('requirement_evaluation_id', 'requirement_id')
- )
- # ### end Alembic commands ###
-
-
-def downgrade() -> None:
- """Downgrade schema."""
- # ### commands auto generated by Alembic - please adjust! ###
- op.drop_table('evaluation_requirements')
- op.drop_table('requirement_evaluations')
- op.drop_table('submission_urls')
- op.drop_table('evaluations')
- op.drop_table('submissions')
- op.drop_table('teams')
- op.drop_table('tournament_juries')
- op.drop_table('team_members')
- op.drop_table('task_requirements')
- op.drop_table('role_request_info')
- op.drop_table('user_roles')
- op.drop_table('tournaments')
- op.drop_table('tasks')
- op.drop_table('task_requirement_options')
- op.drop_table('role_requests')
- op.drop_table('notifications')
- op.drop_table('news')
- op.drop_table('users')
- with op.batch_alter_table('tournament_status_options', schema=None) as batch_op:
- batch_op.drop_index(batch_op.f('ix_tournament_status_options_name'))
-
- op.drop_table('tournament_status_options')
- op.drop_table('task_statuses')
- op.drop_table('task_requirement_categories')
- op.drop_table('submission_url_options')
- op.drop_table('roles')
- op.drop_table('role_request_info_options')
- with op.batch_alter_table('news_categories', schema=None) as batch_op:
- batch_op.drop_index(batch_op.f('ix_news_categories_name'))
-
- op.drop_table('news_categories')
- # ### end Alembic commands ###
diff --git a/backend/alembic/versions/128f25c448b0_.py b/backend/alembic/versions/128f25c448b0_.py
deleted file mode 100644
index f66b3a4..0000000
--- a/backend/alembic/versions/128f25c448b0_.py
+++ /dev/null
@@ -1,276 +0,0 @@
-"""empty message
-
-Revision ID: 128f25c448b0
-Revises: dfc9ca7d5a57
-Create Date: 2026-05-07 21:42:31.511438
-
-"""
-from typing import Sequence, Union
-
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision: str = '128f25c448b0'
-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('news_categories',
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.PrimaryKeyConstraint('name')
- )
- with op.batch_alter_table('news_categories', schema=None) as batch_op:
- batch_op.create_index(batch_op.f('ix_news_categories_name'), ['name'], unique=False)
-
- op.create_table('role_request_info_options',
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.PrimaryKeyConstraint('name')
- )
- op.create_table('roles',
- sa.Column('description', sa.String(), nullable=False),
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.PrimaryKeyConstraint('name')
- )
- op.create_table('submission_url_options',
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.PrimaryKeyConstraint('name')
- )
- op.create_table('task_requirement_categories',
- sa.Column('main_id', sa.String(), nullable=True),
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.ForeignKeyConstraint(['main_id'], ['task_requirement_categories.name'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('name')
- )
- op.create_table('task_statuses',
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.PrimaryKeyConstraint('name')
- )
- op.create_table('tournament_status_options',
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.PrimaryKeyConstraint('name')
- )
- with op.batch_alter_table('tournament_status_options', schema=None) as batch_op:
- batch_op.create_index(batch_op.f('ix_tournament_status_options_name'), ['name'], unique=False)
-
- op.create_table('users',
- sa.Column('firebase_uid', sa.String(), nullable=False),
- sa.Column('full_name', sa.String(), nullable=False),
- sa.Column('email', sa.String(), nullable=False),
- sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
- sa.Column('telegram', sa.String(), nullable=True),
- sa.Column('github', sa.String(), nullable=True),
- sa.Column('discord', sa.String(), nullable=True),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('email'),
- sa.UniqueConstraint('firebase_uid')
- )
- op.create_table('news',
- sa.Column('title', sa.String(length=512), nullable=False),
- sa.Column('excerpt', sa.String(length=512), nullable=False),
- sa.Column('body', sa.String(), nullable=False),
- sa.Column('is_important', sa.Boolean(), nullable=False),
- sa.Column('category_name', sa.String(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
- sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
- sa.ForeignKeyConstraint(['category_name'], ['news_categories.name'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('notifications',
- sa.Column('body', sa.String(), nullable=False),
- sa.Column('user_id', sa.Integer(), nullable=True),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('role_requests',
- sa.Column('role_name', sa.String(), nullable=False),
- sa.Column('user_id', sa.Integer(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['role_name'], ['roles.name'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('task_requirement_options',
- sa.Column('category_id', sa.String(), nullable=False),
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.ForeignKeyConstraint(['category_id'], ['task_requirement_categories.name'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('name')
- )
- op.create_table('tasks',
- sa.Column('title', sa.String(), nullable=False),
- sa.Column('description', sa.String(), nullable=True),
- sa.Column('start_time', sa.DateTime(), nullable=False),
- sa.Column('end_time', sa.DateTime(), nullable=False),
- sa.Column('tournament_id', sa.Integer(), nullable=False),
- sa.Column('status_id', sa.String(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['status_id'], ['task_statuses.name'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], name='fk_task_tournament', ondelete='CASCADE', use_alter=True),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('tournaments',
- sa.Column('title', sa.String(), nullable=False),
- sa.Column('description', sa.String(), nullable=False),
- sa.Column('start_date', sa.DateTime(), nullable=False),
- sa.Column('reg_start', sa.DateTime(), nullable=False),
- sa.Column('reg_end', sa.DateTime(), nullable=False),
- sa.Column('min_people_in_team', sa.Integer(), nullable=False),
- sa.Column('max_people_in_team', sa.Integer(), nullable=False),
- sa.Column('max_teams', sa.Integer(), nullable=False),
- sa.Column('status_id', sa.String(), nullable=False),
- sa.Column('creator_id', sa.Integer(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['status_id'], ['tournament_status_options.name'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('user_roles',
- sa.Column('user_id', sa.Integer(), nullable=False),
- sa.Column('role_name', sa.String(), nullable=False),
- sa.ForeignKeyConstraint(['role_name'], ['roles.name'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('user_id', 'role_name')
- )
- op.create_table('role_request_info',
- sa.Column('request_id', sa.Integer(), nullable=False),
- sa.Column('option_name', sa.String(), nullable=False),
- sa.Column('value', sa.String(length=4096), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['option_name'], ['role_request_info_options.name'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['request_id'], ['role_requests.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('task_requirements',
- sa.Column('task_id', sa.Integer(), nullable=False),
- sa.Column('requirement_id', sa.String(), nullable=False),
- sa.ForeignKeyConstraint(['requirement_id'], ['task_requirement_options.name'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('task_id', 'requirement_id')
- )
- op.create_table('team_members',
- sa.Column('full_name', sa.String(), nullable=False),
- sa.Column('email', sa.String(), nullable=False),
- sa.Column('telegram', sa.String(), nullable=False),
- sa.Column('educational_institution', sa.String(), nullable=True),
- sa.Column('team_id', sa.Integer(), nullable=False),
- sa.Column('tournament_id', sa.Integer(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['team_id'], ['teams.id'], name='fk_teammember_team', ondelete='CASCADE', use_alter=True),
- sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('team_id', 'email', name='uq_team_member_email'),
- sa.UniqueConstraint('team_id', 'telegram', name='uq_team_member_telegram'),
- sa.UniqueConstraint('tournament_id', 'email', name='uq_tournament_member_email'),
- sa.UniqueConstraint('tournament_id', 'telegram', name='uq_tournament_member_telegram')
- )
- op.create_table('tournament_juries',
- sa.Column('jury_id', sa.Integer(), nullable=False),
- sa.Column('tournament_id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['jury_id'], ['users.id'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('jury_id', 'tournament_id')
- )
- op.create_table('teams',
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('team_email', sa.String(), nullable=False),
- sa.Column('contact_info', sa.String(), nullable=False),
- sa.Column('tournament_id', sa.Integer(), nullable=False),
- sa.Column('captain_id', sa.Integer(), nullable=True),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['captain_id'], ['team_members.id'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('contact_info'),
- sa.UniqueConstraint('contact_info', 'tournament_id'),
- sa.UniqueConstraint('name'),
- sa.UniqueConstraint('team_email')
- )
- op.create_table('submissions',
- sa.Column('team_id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('team_id')
- )
- op.create_table('evaluations',
- sa.Column('submission_id', sa.Integer(), nullable=False),
- sa.Column('jury_id', sa.Integer(), 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.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('submission_id', 'jury_id')
- )
- op.create_table('submission_urls',
- sa.Column('submission_id', sa.Integer(), nullable=False),
- sa.Column('url_id', sa.String(), nullable=False),
- sa.ForeignKeyConstraint(['submission_id'], ['submissions.team_id'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['url_id'], ['submission_url_options.name'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('submission_id', 'url_id')
- )
- op.create_table('requirement_evaluations',
- sa.Column('evaluation_id', sa.Integer(), nullable=False),
- sa.Column('score', sa.Integer(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['evaluation_id'], ['evaluations.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('evaluation_requirements',
- sa.Column('requirement_evaluation_id', sa.Integer(), nullable=False),
- sa.Column('requirement_id', sa.String(), nullable=False),
- sa.ForeignKeyConstraint(['requirement_evaluation_id'], ['requirement_evaluations.id'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['requirement_id'], ['task_requirement_options.name'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('requirement_evaluation_id', 'requirement_id')
- )
- # ### end Alembic commands ###
-
-
-def downgrade() -> None:
- """Downgrade schema."""
- # ### commands auto generated by Alembic - please adjust! ###
- op.drop_table('evaluation_requirements')
- op.drop_table('requirement_evaluations')
- op.drop_table('submission_urls')
- op.drop_table('evaluations')
- op.drop_table('submissions')
- op.drop_table('teams')
- op.drop_table('tournament_juries')
- op.drop_table('team_members')
- op.drop_table('task_requirements')
- op.drop_table('role_request_info')
- op.drop_table('user_roles')
- op.drop_table('tournaments')
- op.drop_table('tasks')
- op.drop_table('task_requirement_options')
- op.drop_table('role_requests')
- op.drop_table('notifications')
- op.drop_table('news')
- op.drop_table('users')
- with op.batch_alter_table('tournament_status_options', schema=None) as batch_op:
- batch_op.drop_index(batch_op.f('ix_tournament_status_options_name'))
-
- op.drop_table('tournament_status_options')
- op.drop_table('task_statuses')
- op.drop_table('task_requirement_categories')
- op.drop_table('submission_url_options')
- op.drop_table('roles')
- op.drop_table('role_request_info_options')
- with op.batch_alter_table('news_categories', schema=None) as batch_op:
- batch_op.drop_index(batch_op.f('ix_news_categories_name'))
-
- op.drop_table('news_categories')
- # ### end Alembic commands ###
diff --git a/backend/alembic/versions/58852077e22f_.py b/backend/alembic/versions/58852077e22f_.py
new file mode 100644
index 0000000..548ebc1
--- /dev/null
+++ b/backend/alembic/versions/58852077e22f_.py
@@ -0,0 +1,67 @@
+"""empty message
+
+Revision ID: 58852077e22f
+Revises:
+Create Date: 2026-05-15 19:40:00.000000
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = '58852077e22f'
+down_revision: Union[str, Sequence[str], None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+def upgrade() -> None:
+ # --- 1. SUBMISSIONS TABLE ---
+ op.add_column('submissions', sa.Column('id', sa.Integer(), nullable=False))
+
+ # Drop existing PK. CASCADE automatically drops FKs in other tables pointing here.
+ op.execute('ALTER TABLE submissions DROP CONSTRAINT IF EXISTS submissions_pkey CASCADE')
+ op.execute('ALTER TABLE submissions ADD PRIMARY KEY (id)')
+
+ with op.batch_alter_table('submissions', schema=None) as batch_op:
+ batch_op.alter_column('task_id', existing_type=sa.INTEGER(), nullable=False)
+ batch_op.create_unique_constraint('uq_submissions_team_task', ['team_id', 'task_id'])
+
+ # --- 2. SUBMISSION_URLS TABLE ---
+ op.add_column('submission_urls', sa.Column('id', sa.Integer(), nullable=False))
+ op.execute('ALTER TABLE submission_urls DROP CONSTRAINT IF EXISTS submission_urls_pkey CASCADE')
+ op.execute('ALTER TABLE submission_urls ADD PRIMARY KEY (id)')
+
+ with op.batch_alter_table('submission_urls', schema=None) as batch_op:
+ batch_op.alter_column('value', existing_type=sa.VARCHAR(), nullable=False)
+ batch_op.create_unique_constraint('uq_submission_urls_sub_url', ['submission_id', 'url_id'])
+ # Link to new ID. Note: Old FK was likely dropped by CASCADE above.
+ batch_op.create_foreign_key('fk_submission_urls_submissions', 'submissions', ['submission_id'], ['id'], ondelete='CASCADE')
+
+ # --- 3. UPDATE EVALUATIONS ---
+ with op.batch_alter_table('evaluations', schema=None) as batch_op:
+ batch_op.alter_column('assignment_id', existing_type=sa.INTEGER(), nullable=False)
+ # Link to new ID.
+ batch_op.create_foreign_key('fk_evaluations_submissions', 'submissions', ['submission_id'], ['id'], ondelete='CASCADE')
+
+ # --- 4. UPDATE JURY_ASSIGNMENTS ---
+ with op.batch_alter_table('jury_assignments', schema=None) as batch_op:
+ # Link to new ID.
+ batch_op.create_foreign_key('fk_jury_assignments_submissions', 'submissions', ['submission_id'], ['id'], ondelete='CASCADE')
+
+def downgrade() -> None:
+ with op.batch_alter_table('jury_assignments', schema=None) as batch_op:
+ batch_op.drop_constraint('fk_jury_assignments_submissions', type_='foreignkey')
+
+ with op.batch_alter_table('evaluations', schema=None) as batch_op:
+ batch_op.drop_constraint('fk_evaluations_submissions', type_='foreignkey')
+ batch_op.alter_column('assignment_id', existing_type=sa.INTEGER(), nullable=True)
+
+ with op.batch_alter_table('submission_urls', schema=None) as batch_op:
+ batch_op.drop_constraint('fk_submission_urls_submissions', type_='foreignkey')
+ batch_op.drop_constraint('uq_submission_urls_sub_url', type_='unique')
+ batch_op.drop_column('id')
+
+ with op.batch_alter_table('submissions', schema=None) as batch_op:
+ batch_op.drop_constraint('uq_submissions_team_task', type_='unique')
+ batch_op.drop_column('id')
diff --git a/backend/alembic/versions/bd3c7aaffa33_init.py b/backend/alembic/versions/bd3c7aaffa33_init.py
deleted file mode 100644
index ec2bc11..0000000
--- a/backend/alembic/versions/bd3c7aaffa33_init.py
+++ /dev/null
@@ -1,335 +0,0 @@
-"""init
-
-Revision ID: bd3c7aaffa33
-Revises:
-Create Date: 2026-05-13 20:21:46.540959
-"""
-
-from typing import Sequence, Union
-
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision: str = "bd3c7aaffa33"
-down_revision: Union[str, Sequence[str], None] = None
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
-
-
-def upgrade() -> None:
- """Upgrade schema."""
-
- op.create_table(
- "jury_assignment_statuses",
- sa.Column("name", sa.String(), nullable=False),
- sa.Column("display_name", sa.String(), nullable=False),
- sa.PrimaryKeyConstraint("name"),
- )
-
- op.create_table(
- "task_evaluation_criteria",
- sa.Column("task_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("id", sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(
- ["task_id"],
- ["tasks.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_id", sa.String(), nullable=False),
- sa.Column("id", sa.Integer(), nullable=False),
- sa.Column(
- "created_at",
- sa.DateTime(),
- server_default=sa.text("now()"),
- nullable=False,
- ),
- sa.Column(
- "updated_at",
- sa.DateTime(),
- server_default=sa.text("now()"),
- nullable=False,
- ),
- sa.ForeignKeyConstraint(
- ["jury_id"],
- ["users.id"],
- ondelete="CASCADE",
- ),
- sa.ForeignKeyConstraint(
- ["status_id"],
- ["jury_assignment_statuses.name"],
- 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=True,
- )
- )
-
- batch_op.add_column(
- sa.Column(
- "comment",
- sa.String(),
- nullable=True,
- )
- )
-
- 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.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=True,
- )
- )
-
- 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.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(
- "min_reviews_per_submission",
- sa.Integer(),
- nullable=False,
- server_default="1",
- )
- )
-
- batch_op.add_column(
- sa.Column(
- "max_score",
- sa.Integer(),
- nullable=False,
- server_default="100",
- )
- )
-
- batch_op.add_column(
- sa.Column(
- "is_leaderboard_visible",
- sa.Boolean(),
- nullable=False,
- server_default=sa.text("false"),
- )
- )
-
- batch_op.create_foreign_key(
- "fk_task_tournament",
- "tournaments",
- ["tournament_id"],
- ["id"],
- ondelete="CASCADE",
- use_alter=True,
- )
-
- with op.batch_alter_table("team_members", schema=None) as batch_op:
- batch_op.create_foreign_key(
- "fk_teammember_team",
- "teams",
- ["team_id"],
- ["id"],
- ondelete="CASCADE",
- use_alter=True,
- )
-
-
-def downgrade() -> None:
- """Downgrade schema."""
-
- with op.batch_alter_table("team_members", schema=None) as batch_op:
- batch_op.drop_constraint(
- "fk_teammember_team",
- type_="foreignkey",
- )
-
- with op.batch_alter_table("tasks", schema=None) as batch_op:
- batch_op.drop_constraint(
- "fk_task_tournament",
- type_="foreignkey",
- )
-
- batch_op.drop_column("is_leaderboard_visible")
- batch_op.drop_column("max_score")
- batch_op.drop_column("min_reviews_per_submission")
-
- with op.batch_alter_table("submissions", schema=None) as batch_op:
- batch_op.drop_constraint(None, type_="foreignkey")
- batch_op.drop_column("task_id")
-
- with op.batch_alter_table("submission_urls", schema=None) as batch_op:
- 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_column("updated_at")
- batch_op.drop_column("created_at")
- batch_op.drop_column("comment")
- batch_op.drop_column("assignment_id")
-
- 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.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.drop_table("criterion_scores")
- op.drop_table("jury_assignments")
- op.drop_table("task_evaluation_criteria")
- op.drop_table("jury_assignment_statuses")
\ No newline at end of file
diff --git a/backend/alembic/versions/cfe129b6e3a3_.py b/backend/alembic/versions/cfe129b6e3a3_.py
deleted file mode 100644
index b347943..0000000
--- a/backend/alembic/versions/cfe129b6e3a3_.py
+++ /dev/null
@@ -1,276 +0,0 @@
-"""empty message
-
-Revision ID: cfe129b6e3a3
-Revises: 127c3fae41db
-Create Date: 2026-05-11 14:37:01.268953
-
-"""
-from typing import Sequence, Union
-
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision: str = 'cfe129b6e3a3'
-down_revision: Union[str, Sequence[str], None] = '127c3fae41db'
-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('news_categories',
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.PrimaryKeyConstraint('name')
- )
- with op.batch_alter_table('news_categories', schema=None) as batch_op:
- batch_op.create_index(batch_op.f('ix_news_categories_name'), ['name'], unique=False)
-
- op.create_table('role_request_info_options',
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.PrimaryKeyConstraint('name')
- )
- op.create_table('roles',
- sa.Column('description', sa.String(), nullable=False),
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.PrimaryKeyConstraint('name')
- )
- op.create_table('submission_url_options',
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.PrimaryKeyConstraint('name')
- )
- op.create_table('task_requirement_categories',
- sa.Column('main_id', sa.String(), nullable=True),
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.ForeignKeyConstraint(['main_id'], ['task_requirement_categories.name'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('name')
- )
- op.create_table('task_statuses',
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.PrimaryKeyConstraint('name')
- )
- op.create_table('tournament_status_options',
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.PrimaryKeyConstraint('name')
- )
- with op.batch_alter_table('tournament_status_options', schema=None) as batch_op:
- batch_op.create_index(batch_op.f('ix_tournament_status_options_name'), ['name'], unique=False)
-
- op.create_table('users',
- sa.Column('firebase_uid', sa.String(), nullable=False),
- sa.Column('full_name', sa.String(), nullable=False),
- sa.Column('email', sa.String(), nullable=False),
- sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
- sa.Column('telegram', sa.String(), nullable=True),
- sa.Column('github', sa.String(), nullable=True),
- sa.Column('discord', sa.String(), nullable=True),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('email'),
- sa.UniqueConstraint('firebase_uid')
- )
- op.create_table('news',
- sa.Column('title', sa.String(length=512), nullable=False),
- sa.Column('excerpt', sa.String(length=512), nullable=False),
- sa.Column('body', sa.String(), nullable=False),
- sa.Column('is_important', sa.Boolean(), nullable=False),
- sa.Column('category_name', sa.String(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
- sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
- sa.ForeignKeyConstraint(['category_name'], ['news_categories.name'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('notifications',
- sa.Column('body', sa.String(), nullable=False),
- sa.Column('user_id', sa.Integer(), nullable=True),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('role_requests',
- sa.Column('role_name', sa.String(), nullable=False),
- sa.Column('user_id', sa.Integer(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['role_name'], ['roles.name'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('task_requirement_options',
- sa.Column('category_id', sa.String(), nullable=False),
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('display_name', sa.String(), nullable=False),
- sa.ForeignKeyConstraint(['category_id'], ['task_requirement_categories.name'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('name')
- )
- op.create_table('tasks',
- sa.Column('title', sa.String(), nullable=False),
- sa.Column('description', sa.String(), nullable=True),
- sa.Column('start_time', sa.DateTime(), nullable=False),
- sa.Column('end_time', sa.DateTime(), nullable=False),
- sa.Column('tournament_id', sa.Integer(), nullable=False),
- sa.Column('status_id', sa.String(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['status_id'], ['task_statuses.name'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], name='fk_task_tournament', ondelete='CASCADE', use_alter=True),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('tournaments',
- sa.Column('title', sa.String(), nullable=False),
- sa.Column('description', sa.String(), nullable=False),
- sa.Column('start_date', sa.DateTime(), nullable=False),
- sa.Column('reg_start', sa.DateTime(), nullable=False),
- sa.Column('reg_end', sa.DateTime(), nullable=False),
- sa.Column('min_people_in_team', sa.Integer(), nullable=False),
- sa.Column('max_people_in_team', sa.Integer(), nullable=False),
- sa.Column('max_teams', sa.Integer(), nullable=False),
- sa.Column('status_id', sa.String(), nullable=False),
- sa.Column('creator_id', sa.Integer(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['status_id'], ['tournament_status_options.name'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('user_roles',
- sa.Column('user_id', sa.Integer(), nullable=False),
- sa.Column('role_name', sa.String(), nullable=False),
- sa.ForeignKeyConstraint(['role_name'], ['roles.name'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('user_id', 'role_name')
- )
- op.create_table('role_request_info',
- sa.Column('request_id', sa.Integer(), nullable=False),
- sa.Column('option_name', sa.String(), nullable=False),
- sa.Column('value', sa.String(length=4096), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['option_name'], ['role_request_info_options.name'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['request_id'], ['role_requests.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('task_requirements',
- sa.Column('task_id', sa.Integer(), nullable=False),
- sa.Column('requirement_id', sa.String(), nullable=False),
- sa.ForeignKeyConstraint(['requirement_id'], ['task_requirement_options.name'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('task_id', 'requirement_id')
- )
- op.create_table('team_members',
- sa.Column('full_name', sa.String(), nullable=False),
- sa.Column('email', sa.String(), nullable=False),
- sa.Column('telegram', sa.String(), nullable=False),
- sa.Column('educational_institution', sa.String(), nullable=True),
- sa.Column('team_id', sa.Integer(), nullable=False),
- sa.Column('tournament_id', sa.Integer(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['team_id'], ['teams.id'], name='fk_teammember_team', ondelete='CASCADE', use_alter=True),
- sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('team_id', 'email', name='uq_team_member_email'),
- sa.UniqueConstraint('team_id', 'telegram', name='uq_team_member_telegram'),
- sa.UniqueConstraint('tournament_id', 'email', name='uq_tournament_member_email'),
- sa.UniqueConstraint('tournament_id', 'telegram', name='uq_tournament_member_telegram')
- )
- op.create_table('tournament_juries',
- sa.Column('jury_id', sa.Integer(), nullable=False),
- sa.Column('tournament_id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['jury_id'], ['users.id'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('jury_id', 'tournament_id')
- )
- op.create_table('teams',
- sa.Column('name', sa.String(), nullable=False),
- sa.Column('team_email', sa.String(), nullable=False),
- sa.Column('contact_info', sa.String(), nullable=False),
- sa.Column('tournament_id', sa.Integer(), nullable=False),
- sa.Column('captain_id', sa.Integer(), nullable=True),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['captain_id'], ['team_members.id'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('contact_info'),
- sa.UniqueConstraint('contact_info', 'tournament_id'),
- sa.UniqueConstraint('name'),
- sa.UniqueConstraint('team_email')
- )
- op.create_table('submissions',
- sa.Column('team_id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('team_id')
- )
- op.create_table('evaluations',
- sa.Column('submission_id', sa.Integer(), nullable=False),
- sa.Column('jury_id', sa.Integer(), 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.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('submission_id', 'jury_id')
- )
- op.create_table('submission_urls',
- sa.Column('submission_id', sa.Integer(), nullable=False),
- sa.Column('url_id', sa.String(), nullable=False),
- sa.ForeignKeyConstraint(['submission_id'], ['submissions.team_id'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['url_id'], ['submission_url_options.name'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('submission_id', 'url_id')
- )
- op.create_table('requirement_evaluations',
- sa.Column('evaluation_id', sa.Integer(), nullable=False),
- sa.Column('score', sa.Integer(), nullable=False),
- sa.Column('id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['evaluation_id'], ['evaluations.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('evaluation_requirements',
- sa.Column('requirement_evaluation_id', sa.Integer(), nullable=False),
- sa.Column('requirement_id', sa.String(), nullable=False),
- sa.ForeignKeyConstraint(['requirement_evaluation_id'], ['requirement_evaluations.id'], ondelete='CASCADE'),
- sa.ForeignKeyConstraint(['requirement_id'], ['task_requirement_options.name'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('requirement_evaluation_id', 'requirement_id')
- )
- # ### end Alembic commands ###
-
-
-def downgrade() -> None:
- """Downgrade schema."""
- # ### commands auto generated by Alembic - please adjust! ###
- op.drop_table('evaluation_requirements')
- op.drop_table('requirement_evaluations')
- op.drop_table('submission_urls')
- op.drop_table('evaluations')
- op.drop_table('submissions')
- op.drop_table('teams')
- op.drop_table('tournament_juries')
- op.drop_table('team_members')
- op.drop_table('task_requirements')
- op.drop_table('role_request_info')
- op.drop_table('user_roles')
- op.drop_table('tournaments')
- op.drop_table('tasks')
- op.drop_table('task_requirement_options')
- op.drop_table('role_requests')
- op.drop_table('notifications')
- op.drop_table('news')
- op.drop_table('users')
- with op.batch_alter_table('tournament_status_options', schema=None) as batch_op:
- batch_op.drop_index(batch_op.f('ix_tournament_status_options_name'))
-
- op.drop_table('tournament_status_options')
- op.drop_table('task_statuses')
- op.drop_table('task_requirement_categories')
- op.drop_table('submission_url_options')
- op.drop_table('roles')
- op.drop_table('role_request_info_options')
- with op.batch_alter_table('news_categories', schema=None) as batch_op:
- batch_op.drop_index(batch_op.f('ix_news_categories_name'))
-
- op.drop_table('news_categories')
- # ### end Alembic commands ###
diff --git a/backend/alembic/versions/d146519cd8df_remove_is_global_column_from_.py b/backend/alembic/versions/d146519cd8df_remove_is_global_column_from_.py
deleted file mode 100644
index 8c7d57a..0000000
--- a/backend/alembic/versions/d146519cd8df_remove_is_global_column_from_.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""Remove is_global column from notifications table
-
-Revision ID: d146519cd8df
-Revises: 6150229200ba
-Create Date: 2026-05-03 20:18:01.936401
-
-"""
-from typing import Sequence, Union
-
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision: str = 'd146519cd8df'
-down_revision: Union[str, Sequence[str], None] = '6150229200ba'
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
-
-
-def upgrade() -> None:
- """Upgrade schema."""
- pass
-
-
-def downgrade() -> None:
- """Downgrade schema."""
- pass
diff --git a/backend/app/admin/templates/sqladmin/login.html b/backend/app/admin/templates/sqladmin/login.html
index 749ed2e..3b75c12 100644
--- a/backend/app/admin/templates/sqladmin/login.html
+++ b/backend/app/admin/templates/sqladmin/login.html
@@ -32,6 +32,18 @@
Login to {{ admin.title if admin else 'A
+
+
or
+
+
@@ -39,11 +51,12 @@ Login to {{ admin.title if admin else 'A
{% endblock %}
\ No newline at end of file
diff --git a/backend/app/core/seeds/__init__.py b/backend/app/core/seeds/__init__.py
index 0f2eba4..26d3d1f 100644
--- a/backend/app/core/seeds/__init__.py
+++ b/backend/app/core/seeds/__init__.py
@@ -3,6 +3,8 @@
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
+from .superuser import init_superuser
async def init_static_data(session):
@@ -12,3 +14,6 @@ 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)
+ await init_superuser(session)
+ await session.commit()
\ No newline at end of file
diff --git a/backend/app/core/seeds/superuser.py b/backend/app/core/seeds/superuser.py
new file mode 100644
index 0000000..9324eff
--- /dev/null
+++ b/backend/app/core/seeds/superuser.py
@@ -0,0 +1,59 @@
+from firebase_admin import auth
+from sqlalchemy import select
+from app.models import User, Role
+from app.config import settings
+
+
+async def init_superuser(
+ session,
+ email: str = "admin@example.com",
+ password: str = "Admin_Password_123",
+ full_name: str = "System Admin",
+):
+
+ firebase_uid = None
+ try:
+ fb_user = auth.get_user_by_email(email)
+ firebase_uid = fb_user.uid
+ print(f"User {email} already exists in Firebase. UID: {firebase_uid}")
+ except auth.UserNotFoundError:
+
+ fb_user = auth.create_user(
+ email=email, password=password, display_name=full_name
+ )
+ firebase_uid = fb_user.uid
+ print(f"Created new Firebase user: {email} with UID: {firebase_uid}")
+ except Exception as e:
+ print(f"Firebase error: {e}. Check if serviceAccountKey.json is valid.")
+ return
+
+ admin_role_name = settings.ROLE_NAMES.ADMIN
+ role_result = await session.execute(
+ select(Role).where(Role.name == admin_role_name)
+ )
+ admin_role = role_result.scalar_one_or_none()
+
+ if not admin_role:
+ print(f"Error: Role '{admin_role_name}' not found. Run init_db first.")
+ return
+
+ user_result = await session.execute(select(User).where(User.email == email))
+ user = user_result.scalar_one_or_none()
+
+ if not user:
+ new_user = User(
+ email=email,
+ firebase_uid=firebase_uid,
+ full_name=full_name,
+ roles=[admin_role],
+ )
+ session.add(new_user)
+ print(f"Superuser {email} added to local database.")
+ else:
+
+ user.firebase_uid = firebase_uid
+ if admin_role not in user.roles:
+ user.roles.append(admin_role)
+ print(f"User {email} updated in local database.")
+
+ await session.commit()
diff --git a/backend/app/dependencies/__init__.py b/backend/app/dependencies/__init__.py
index c62deef..695fb4e 100644
--- a/backend/app/dependencies/__init__.py
+++ b/backend/app/dependencies/__init__.py
@@ -10,5 +10,5 @@
)
from .role_request import RoleRequestDep, get_role_request
from .permissions import TournamentOwnerDep, TaskOwnerDep, TeamOwnerDep, task_owner_dependency, team_owner_dependency, tournament_owner_dependency
-from .jury import organizer_or_admin_dependency, assigned_jury_dependency, task_with_closed_submissions_status_dependency,\
-get_assigned_jury, get_organizer_or_admin, get_task_with_closed_submissions_status
\ No newline at end of file
+from .jury import organizer_or_admin_dependency, assigned_jury_dependency, closed_submission_task_dependency,\
+get_assigned_jury, get_organizer_or_admin, get_closed_submission_task, non_finished_tournament_dependency, get_non_finished_tournament
\ No newline at end of file
diff --git a/backend/app/dependencies/jury.py b/backend/app/dependencies/jury.py
index d08644e..867f93e 100644
--- a/backend/app/dependencies/jury.py
+++ b/backend/app/dependencies/jury.py
@@ -1,9 +1,9 @@
from .current_user import CurrentUserDep
from .session import SessionDep
-from app.models import User, Task
+from app.models import User, Task, Tournament
from fastapi import HTTPException, status, Depends
from app.config import settings
-from app.utils import get_task
+from app.utils import get_task, get_tournament
# Permission dependencies
async def get_organizer_or_admin(
@@ -27,7 +27,7 @@ async def get_assigned_jury(
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Permission denied")
return current_user
-async def get_task_with_closed_submissions_status(task_id: int, session: SessionDep) -> Task:
+async def get_closed_submission_task(task_id: int, session: SessionDep) -> Task:
task = await get_task(task_id, session)
if task.status_id != settings.TASK_STATUS_NAMES.SUBMISSION_CLOSED:
raise HTTPException(
@@ -36,6 +36,16 @@ async def get_task_with_closed_submissions_status(task_id: int, session: Session
)
return task
+async def get_non_finished_tournament(tournament_id: int, session: SessionDep) -> Tournament:
+ tournament = await get_tournament(tournament_id, session)
+ if tournament.status_id == settings.TOURNAMENT_STATUS_NAMES.FINISHED:
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ detail="The tournament has been finished!",
+ )
+ return tournament
+
organizer_or_admin_dependency = Depends(get_organizer_or_admin)
assigned_jury_dependency = Depends(get_assigned_jury)
-task_with_closed_submissions_status_dependency = Depends(get_task_with_closed_submissions_status)
+closed_submission_task_dependency = Depends(get_closed_submission_task)
+non_finished_tournament_dependency = Depends(get_non_finished_tournament)
diff --git a/backend/app/models/evaluation.py b/backend/app/models/evaluation.py
index 0cd3aae..2b5158c 100644
--- a/backend/app/models/evaluation.py
+++ b/backend/app/models/evaluation.py
@@ -1,4 +1,3 @@
-from datetime import datetime
from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -25,7 +24,7 @@ class JuryAssignment(Base, PKMixin, DatetimeMixin):
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
+ ForeignKey("submissions.id", ondelete="CASCADE"), nullable=False
)
jury_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
status_id: Mapped[str] = mapped_column(
@@ -64,7 +63,7 @@ class SubmissionEvaluation(Base, PKMixin, DatetimeMixin):
ForeignKey("jury_assignments.id", ondelete="CASCADE"), nullable=False
)
submission_id: Mapped[int] = mapped_column(
- ForeignKey("submissions.team_id", ondelete="CASCADE"), nullable=False
+ ForeignKey("submissions.id", ondelete="CASCADE"), nullable=False
)
jury_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
diff --git a/backend/app/models/submission.py b/backend/app/models/submission.py
index ec78c89..56542bc 100644
--- a/backend/app/models/submission.py
+++ b/backend/app/models/submission.py
@@ -1,20 +1,22 @@
from .base import Base
-from .mixin import OptionMixin
+from .mixin import OptionMixin, PKMixin
from sqlalchemy.orm import Mapped, mapped_column, relationship
-from sqlalchemy import ForeignKey
+from sqlalchemy import ForeignKey, UniqueConstraint
-class Submission(Base):
+class Submission(Base, PKMixin):
__tablename__ = "submissions"
+ __table_args__ = (UniqueConstraint("team_id", "task_id"),)
+
team_id: Mapped[int] = mapped_column(
- ForeignKey("teams.id", ondelete="CASCADE"), primary_key=True
+ ForeignKey("teams.id", ondelete="CASCADE"), nullable=False
)
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"
+ back_populates="submissions", lazy="selectin"
)
task: Mapped["Task"] = relationship(lazy="selectin")
urls: Mapped[list["SubmissionUrl"]] = relationship(
@@ -28,16 +30,17 @@ class Submission(Base):
)
def __repr__(self):
- return f""
+ return f""
-class SubmissionUrl(Base):
+class SubmissionUrl(Base, PKMixin):
__tablename__ = "submission_urls"
+ __table_args__ = (UniqueConstraint("submission_id", "url_id"),)
submission_id: Mapped[int] = mapped_column(
- ForeignKey("submissions.team_id", ondelete="CASCADE"), primary_key=True
+ ForeignKey("submissions.id", ondelete="CASCADE"), nullable=False
)
url_id: Mapped[int] = mapped_column(
- ForeignKey("submission_url_options.name", ondelete="CASCADE"), primary_key=True
+ ForeignKey("submission_url_options.name", ondelete="CASCADE"), nullable=False
)
value: Mapped[str]
@@ -45,7 +48,7 @@ class SubmissionUrl(Base):
url: Mapped["SubmissionUrlOption"] = relationship(lazy="selectin")
def __repr__(self):
- return f""
+ return f""
class SubmissionUrlOption(Base, OptionMixin):
diff --git a/backend/app/models/team.py b/backend/app/models/team.py
index 8a8a019..01ef071 100644
--- a/backend/app/models/team.py
+++ b/backend/app/models/team.py
@@ -24,12 +24,13 @@ class Team(Base, PKMixin):
back_populates="team",
foreign_keys="TeamMember.team_id",
cascade="all, delete-orphan",
+ lazy="selectin",
)
captain: Mapped["TeamMember"] = relationship(
"TeamMember", foreign_keys="Team.captain_id", post_update=True
)
- submission: Mapped["Submission"] = relationship(
+ submissions: Mapped[list["Submission"]] = relationship(
back_populates="team", cascade="all, delete-orphan"
)
diff --git a/backend/app/routes/jury.py b/backend/app/routes/jury.py
index daf121d..67e5255 100644
--- a/backend/app/routes/jury.py
+++ b/backend/app/routes/jury.py
@@ -11,7 +11,10 @@
current_user_dependency,
organizer_or_admin_dependency,
assigned_jury_dependency,
- task_with_closed_submissions_status_dependency
+ closed_submission_task_dependency,
+ get_organizer_or_admin,
+ get_non_finished_tournament,
+ non_finished_tournament_dependency
)
from app.models import (
JuryAssignment,
@@ -30,66 +33,13 @@
SubmissionEvaluationUpdate,
SubmissionEvaluationPublic,
TaskPublic,
+ TournamentPublic
)
-from app.utils import get_task_by_tournament, get_tournament, get_assignment, get_criterion_score
+from app.utils import get_task_by_tournament, get_tournament, get_assignment, get_criterion_score, get_criterion_map,\
+get_task_leaderboard, calculate_evaluation_average, finish_evaluation
router = APIRouter(tags=["jury"])
-
-def _criterion_map(task: Task) -> dict[int, object]:
- return {criterion.id: criterion for criterion in task.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]
)
@@ -110,7 +60,7 @@ async def jury_tasks(current_user: CurrentUserDep, session: SessionDep):
@router.get(
"/jury/tasks/{task_id}/assignments/",
response_model=list[JuryAssignmentPublic],
- dependencies=[task_with_closed_submissions_status_dependency],
+ dependencies=[closed_submission_task_dependency],
)
async def jury_task_assignments(
task_id: int, current_user: CurrentUserDep, session: SessionDep
@@ -171,7 +121,7 @@ async def create_evaluation(
if assignment.evaluation is not None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Evaluation already exists")
- criteria = _criterion_map(assignment.task)
+ criteria = get_criterion_map(assignment.task)
evaluation = SubmissionEvaluation(
assignment_id=assignment.id,
submission_id=assignment.submission_id,
@@ -204,7 +154,6 @@ async def create_evaluation(
async def update_evaluation(
assignment_id: int,
update_data: SubmissionEvaluationUpdate,
- current_user: CurrentUserDep,
session: SessionDep,
):
payload = update_data.model_dump(exclude_unset=True)
@@ -212,7 +161,7 @@ async def update_evaluation(
if assignment.evaluation is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Evaluation not found")
- criterions = _criterion_map(assignment.task)
+ criterions = get_criterion_map(assignment.task)
for item in payload.pop("criterion_scores", []):
criterion = await get_criterion_score(item["criterion_id"], session)
if item["score"] > criterions[item["criterion_id"]].max_score:
@@ -242,7 +191,7 @@ async def update_evaluation(
@router.post(
"/tournaments/{tournament_id}/tasks/{task_id}/assignments/generate/",
response_model=list[JuryAssignmentPublic],
- dependencies=[current_user_dependency, organizer_or_admin_dependency, task_with_closed_submissions_status_dependency],
+ dependencies=[current_user_dependency, organizer_or_admin_dependency, closed_submission_task_dependency],
)
async def generate_jury_assignments(
tournament_id: int,
@@ -302,7 +251,7 @@ async def generate_jury_assignments(
for jury in selected:
assignment = JuryAssignment(
task_id=task.id,
- submission_id=submission.team_id,
+ submission_id=submission.id,
jury_id=jury.id,
status_id=settings.JURY_ASSIGNMENT_STATUS_NAMES.ASSIGNED,
)
@@ -326,7 +275,7 @@ async def generate_jury_assignments(
@router.get(
"/tournaments/{tournament_id}/tasks/{task_id}/assignments/",
response_model=list[JuryAssignmentPublic],
- dependencies=[current_user_dependency, organizer_or_admin_dependency, task_with_closed_submissions_status_dependency],
+ dependencies=[current_user_dependency, organizer_or_admin_dependency, closed_submission_task_dependency],
)
async def get_task_assignments(
tournament_id: int,
@@ -360,51 +309,70 @@ async def get_task_assignments(
@router.post(
"/tournaments/{tournament_id}/tasks/{task_id}/finish-evaluation/",
response_model=TaskPublic,
- dependencies=[organizer_or_admin_dependency, task_with_closed_submissions_status_dependency],
+ dependencies=[closed_submission_task_dependency],
)
-async def finish_evaluation(
+async def finish_task_evaluation(
tournament_id: int,
task_id: int,
current_user: CurrentUserDep,
session: SessionDep,
):
+ # Works for both jury who has some assignments and admin/organizer
task = await get_task_by_tournament(tournament_id, task_id, session)
-
- jury_assignmnents = (
- (
- await session.execute(
- select(JuryAssignment).where(
- JuryAssignment.task_id == task.id,
- JuryAssignment.jury_id == current_user.id,
+ await get_non_finished_tournament(task.tournament.id, session)
+ jury_assignmnents = []
+ try:
+ await get_organizer_or_admin(tournament_id, current_user, session)
+ jury_assignmnents = (
+ (
+ await session.execute(
+ select(JuryAssignment).where(
+ JuryAssignment.task_id == task.id,
+ )
)
)
+ .scalars()
+ .all()
)
- .scalars()
- .all()
- )
+ except HTTPException as e:
+ if e.status_code == status.HTTP_403_FORBIDDEN:
+ 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.REVIEWED
+ return await finish_evaluation(task, jury_assignmnents, session)
- await session.commit()
- await session.refresh(task, ["jury_assignments"])
-
- nonevaluated_assignments = [
- a
- for a in task.jury_assignments
- if a.status.name != settings.JURY_ASSIGNMENT_STATUS_NAMES.REVIEWED
- ]
- # 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.post(
+ "/tournaments/{tournament_id}/finish-evaluation/",
+ response_model=TournamentPublic,
+ dependencies=[organizer_or_admin_dependency, current_user_dependency, non_finished_tournament_dependency],
+)
+async def finish_tournament_evaluation(
+ tournament_id: int,
+ session: SessionDep,
+):
+ tournament = await get_tournament(tournament_id, session)
+ for t in tournament.tasks:
+ await finish_evaluation(t, t.jury_assignments, session)
+
+ tournament.status_id = settings.TOURNAMENT_STATUS_NAMES.FINISHED
+ await session.commit()
+ await session.refresh(tournament, ["status"])
+ return tournament
+
+# Leaderboard routes
@router.get(
"/tournaments/{tournament_id}/tasks/{task_id}/leaderboard/",
@@ -418,7 +386,7 @@ async def task_leaderboard(tournament_id: int, task_id: int, session: SessionDep
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)
+ return await get_task_leaderboard(task.id, session)
@router.get(
@@ -431,18 +399,15 @@ async def tournament_leaderboard(tournament_id: int, session: SessionDep):
for task in tournament.tasks:
if task.status_id != settings.TASK_STATUS_NAMES.EVALUATED:
continue
- for entry in await _task_leaderboard(task.id, session):
+ for entry in await get_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,
- )
+
+ existing.average_score = calculate_evaluation_average([t for t in tournament.tasks if t.status_id == "evaluated"])
+
return sorted(
team_scores.values(), key=lambda item: (-item.total_score, item.team_name.lower())
)
diff --git a/backend/app/routes/submissions.py b/backend/app/routes/submissions.py
index 37e1db5..9c843ea 100644
--- a/backend/app/routes/submissions.py
+++ b/backend/app/routes/submissions.py
@@ -1,20 +1,19 @@
from fastapi import status, HTTPException
from fastapi.routing import APIRouter
-from sqlalchemy import select, update
+from sqlalchemy import select
from sqlalchemy.orm import selectinload
-from sqlalchemy.exc import IntegrityError
from app.dependencies import SessionDep
-from app.models import Submission, SubmissionUrl, SubmissionUrlOption, Team
-from app.schemas import SubmissionModel, SubmissionUrlOptionModel, SubmissionUrlModel
+from app.models import Submission, SubmissionUrl
+from app.schemas import SubmissionModel
router = APIRouter(prefix="/submissions", tags=["submissions"])
-async def get_submission(team_id: int, session: SessionDep) -> Submission:
+async def get_submission(submission_id: int, session: SessionDep) -> Submission:
statement = (
select(Submission)
- .where(Submission.team_id == team_id)
+ .where(Submission.id == submission_id)
.options(selectinload(Submission.urls).selectinload(SubmissionUrl.url))
)
result = await session.execute(statement)
@@ -35,9 +34,9 @@ async def submissions(session: SessionDep):
return users.scalars().all()
-@router.get("/{team_id}/", response_model=SubmissionModel, status_code=status.HTTP_200_OK)
-async def submission(team_id: int, session: SessionDep):
- return await get_submission(team_id, session)
+@router.get("/{submission_id}/", response_model=SubmissionModel, status_code=status.HTTP_200_OK)
+async def submission(submission_id: int, session: SessionDep):
+ return await get_submission(submission_id, session)
# Not ready yet
@@ -69,15 +68,15 @@ async def submission(team_id: int, session: SessionDep):
# @router.patch(
-# "/{team_id}/", response_model=SubmissionModel, status_code=status.HTTP_200_OK
+# "/{submission_id}/", response_model=SubmissionModel, status_code=status.HTTP_200_OK
# )
# async def update_submission():
# pass
-@router.delete("/{team_id}/", status_code=status.HTTP_204_NO_CONTENT)
-async def delete_submission(team_id: int, session: SessionDep):
- submission = await get_submission(team_id, session)
+@router.delete("/{submission_id}/", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_submission(submission_id: int, session: SessionDep):
+ submission = await get_submission(submission_id, session)
await session.delete(submission)
await session.commit()
diff --git a/backend/app/routes/tasks.py b/backend/app/routes/tasks.py
index 8c1427c..3a43531 100644
--- a/backend/app/routes/tasks.py
+++ b/backend/app/routes/tasks.py
@@ -1,11 +1,10 @@
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, TournamentOwnerDep, TaskOwnerDep, current_user_dependency
-from app.models import Task, TaskEvaluationCriterion, Submission, SubmissionUrl, Team, Tournament
+from app.dependencies import SessionDep, TaskOwnerDep
+from app.models import Task, TaskEvaluationCriterion, Submission, SubmissionUrl
from app.schemas import TaskCreate, TaskUpdate, TaskPublic, SubmissionCreate, SubmissionModel
from app.utils import TaskStatus, update_tasks_status, get_requirements, get_task, get_team
@@ -162,12 +161,16 @@ async def create_submission(
await get_team(submission_data.team_id, tournament_id, session)
new_submission = Submission(team_id=submission_data.team_id, task_id=task_id)
+ session.add(new_submission)
+ await session.flush()
- await session.refresh(new_submission, ["urls"])
for item in submission_data.urls:
- new_submission.urls.append(SubmissionUrl(url_id=item.url_id, value=item.value))
+ session.add(SubmissionUrl(
+ submission_id=new_submission.id,
+ url_id=item.url_id,
+ value=item.value,
+ ))
- session.add(new_submission)
await session.commit()
await session.refresh(new_submission, ["urls", "team"])
diff --git a/backend/app/routes/teams.py b/backend/app/routes/teams.py
index 6b2734d..0bdac08 100644
--- a/backend/app/routes/teams.py
+++ b/backend/app/routes/teams.py
@@ -57,13 +57,14 @@ async def create_team(tournament_id: int, team_data: TeamCreate, session: Sessio
captain = TeamMember(
**team_data.captain.model_dump(),
+ tournament_id=tournament_id,
team_id=new_team.id,
)
session.add(captain)
await session.flush()
members = [
- TeamMember(**m.model_dump(), team_id=new_team.id) for m in team_data.members
+ TeamMember(**m.model_dump(), tournament_id=tournament_id, team_id=new_team.id) for m in team_data.members
]
session.add_all(members)
new_team.captain_id = captain.id
diff --git a/backend/app/schemas/evaluation.py b/backend/app/schemas/evaluation.py
index afb3ebb..eb62996 100644
--- a/backend/app/schemas/evaluation.py
+++ b/backend/app/schemas/evaluation.py
@@ -75,7 +75,6 @@ class EvaluationLeaderboardEntry(BaseModel):
team_name: str
average_score: float
total_score: float
- submitted_reviews: int
class FinishEvaluationResponse(BaseModel):
diff --git a/backend/app/schemas/submission.py b/backend/app/schemas/submission.py
index 68e25d8..53856d4 100644
--- a/backend/app/schemas/submission.py
+++ b/backend/app/schemas/submission.py
@@ -29,6 +29,7 @@ class SubmissionCreate(BaseModel):
class SubmissionModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
+ id: int = Field(...)
team_id: int = Field(...)
task_id: int = Field(...)
team: TeamPublic | None = None
diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py
index 0a3d4cd..512c2f7 100644
--- a/backend/app/schemas/task.py
+++ b/backend/app/schemas/task.py
@@ -9,7 +9,7 @@
model_validator,
AfterValidator,
)
-
+from .option import OptionPublic
def make_naive(value: datetime) -> datetime:
if value.tzinfo is not None:
@@ -93,6 +93,7 @@ class TaskPublic(TaskBase):
id: int
tournament_id: int = Field(..., gt=0)
status_id: str = Field(...)
+ status: OptionPublic
criteria: list["TaskEvaluationCriterionPublic"] = []
@field_validator("requirements", mode="before")
diff --git a/backend/app/schemas/tournament.py b/backend/app/schemas/tournament.py
index 868baa9..0be5d67 100644
--- a/backend/app/schemas/tournament.py
+++ b/backend/app/schemas/tournament.py
@@ -55,11 +55,15 @@ def validate_dates(self) -> "TournamentCreate":
if drop_time(self.reg_start) < now:
raise ValueError("Registration cannot start in the past")
- if self.reg_end <= self.reg_start:
- raise ValueError("Registration end must be later than start")
+ if drop_time(self.reg_end) <= drop_time(self.reg_start):
+ raise ValueError(
+ "Registration end time must be later than the registration start time."
+ )
if self.start_date <= self.reg_end:
- raise ValueError("Tournament must start after registration ends")
+ raise ValueError(
+ "Tournament must start at least 24 hours after the registration ends."
+ )
return self
@@ -70,6 +74,8 @@ class TournamentUpdate(BaseModel):
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) # Додано, бо dev-валідатор їх використовує
+ max_people_in_team: int | None = Field(None, gt=0) # Додано, бо dev-валідатор їх використовує
max_teams: int | None = Field(None, gt=0)
juries: list[int | str] | None = Field(None, description="Jury ids")
@@ -78,24 +84,37 @@ def validate_dates_update(self) -> "TournamentUpdate":
now = drop_time(datetime.now(timezone.utc).replace(tzinfo=None))
if self.reg_start is not None and drop_time(self.reg_start) < now:
- raise ValueError("Registration start cannot be in the past")
+ raise ValueError("Registration start time cannot be in the past")
if self.reg_end is not None and drop_time(self.reg_end) < now:
- raise ValueError("Registration end cannot be in the past")
+ raise ValueError("Registration end time cannot be in the past")
if self.start_date is not None and drop_time(self.start_date) < now:
- raise ValueError("Tournament start cannot be in the past")
+ raise ValueError("Tournament start time cannot be in the past")
if self.reg_start and self.reg_end:
if self.reg_end <= self.reg_start:
- raise ValueError("Registration end must be later than start")
+ raise ValueError(
+ "Registration end time must be later than the start time"
+ )
if self.start_date and self.reg_end:
if self.start_date <= self.reg_end:
- raise ValueError("Tournament must start after registration ends")
+ raise ValueError(
+ "Tournament must start at least 24 hours after the registration ends"
+ )
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(
+ "Minimum people in team cannot be greater than maximum people"
+ )
+ return self
+
class TournamentStatusOptionModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py
index 28919dc..c109532 100644
--- a/backend/app/utils/__init__.py
+++ b/backend/app/utils/__init__.py
@@ -6,4 +6,4 @@
from .tournaments import get_tournament, tournament_load_options
from .roles import get_role
from .assignments import get_assignment
-from .criterions import get_criterion_score, get_task_evaluation_criterion
+from .criterions import get_criterion_score, get_task_evaluation_criterion, get_criterion_map
diff --git a/backend/app/utils/criterions.py b/backend/app/utils/criterions.py
index b25d9aa..cc40352 100644
--- a/backend/app/utils/criterions.py
+++ b/backend/app/utils/criterions.py
@@ -1,8 +1,10 @@
from sqlalchemy import select, or_
-from app.models import CriterionScore, TaskEvaluationCriterion
+from app.models import CriterionScore, TaskEvaluationCriterion, Task
from app.dependencies import SessionDep
from fastapi import HTTPException, status
+def get_criterion_map(task: Task) -> dict[int, TaskEvaluationCriterion]:
+ return {criterion.id: criterion for criterion in task.criteria}
async def get_criterion_score(criterion_id: int, session: SessionDep) -> CriterionScore:
statement = select(CriterionScore).where(CriterionScore.criterion_id == criterion_id)
diff --git a/backend/app/utils/routes/__init__.py b/backend/app/utils/routes/__init__.py
index c4276d6..aed5da3 100644
--- a/backend/app/utils/routes/__init__.py
+++ b/backend/app/utils/routes/__init__.py
@@ -9,3 +9,5 @@
approve_role_request_with_notification,
reject_role_request_with_notification,
)
+from .leaderboard import calculate_evaluation_total, get_task_leaderboard, calculate_evaluation_average
+from .jury import finish_evaluation
\ No newline at end of file
diff --git a/backend/app/utils/routes/jury.py b/backend/app/utils/routes/jury.py
new file mode 100644
index 0000000..92a54ef
--- /dev/null
+++ b/backend/app/utils/routes/jury.py
@@ -0,0 +1,29 @@
+from sqlalchemy import select
+from sqlalchemy.orm import selectinload
+
+from app.config import settings
+from app.models import Task, JuryAssignment
+from app.dependencies import SessionDep
+
+
+async def finish_evaluation(task: Task, assignments: list[JuryAssignment], session: SessionDep) -> Task:
+ for a in assignments:
+ a.status_id = settings.JURY_ASSIGNMENT_STATUS_NAMES.REVIEWED
+
+ await session.commit()
+
+ result = await session.execute(
+ select(JuryAssignment).where(JuryAssignment.task_id == task.id).options(selectinload(JuryAssignment.status))
+ )
+ all_assignments = result.scalars().all()
+
+ nonevaluated_assignments = [
+ a
+ for a in all_assignments
+ if a.status_id != settings.JURY_ASSIGNMENT_STATUS_NAMES.REVIEWED
+ ]
+ if len(nonevaluated_assignments) == 0:
+ task.status_id = settings.TASK_STATUS_NAMES.EVALUATED
+ await session.commit()
+ await session.refresh(task, ["status"])
+ return task
\ No newline at end of file
diff --git a/backend/app/utils/routes/leaderboard.py b/backend/app/utils/routes/leaderboard.py
new file mode 100644
index 0000000..bc35aa5
--- /dev/null
+++ b/backend/app/utils/routes/leaderboard.py
@@ -0,0 +1,61 @@
+
+from sqlalchemy import select
+from sqlalchemy.orm import selectinload
+
+from app.dependencies import (
+ SessionDep
+)
+from app.models import (
+ CriterionScore,
+ Submission,
+ SubmissionEvaluation,
+)
+from app.schemas import (
+ EvaluationLeaderboardEntry,
+)
+
+def calculate_evaluation_total(evaluation: SubmissionEvaluation) -> float:
+ total_score = 0
+ for item in evaluation.criterion_scores:
+ total_score += (item.score / item.criterion.max_score) * item.criterion.weight
+ return round(total_score, 2)
+
+def calculate_evaluation_average(totals: list[float]) -> float:
+ return round(sum(totals) / len(totals), 2)
+
+
+async def get_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 = calculate_evaluation_average(totals)
+ total_score = round(sum(totals), 2)
+ else:
+ average_score = 0.0
+ total_score = 0.0
+ leaderboard.append(
+ EvaluationLeaderboardEntry(
+ submission_id=submission.id,
+ team_id=submission.team_id,
+ team_name=submission.team.name,
+ average_score=average_score,
+ total_score=total_score,
+ )
+ )
+ return sorted(leaderboard, key=lambda item: (-item.average_score, item.team_name.lower()))
\ No newline at end of file
diff --git a/backend/pytest.ini b/backend/pytest.ini
index 75d353f..28cdd46 100644
--- a/backend/pytest.ini
+++ b/backend/pytest.ini
@@ -1,4 +1,7 @@
[pytest]
+strict_markers = true
+markers =
+ slow: marks tests as slow (deselect with '-m "not slow"')
addopts = -v
asyncio_mode = auto
diff --git a/backend/tests/routes/test_jury.py b/backend/tests/routes/test_jury.py
index c73152b..9cc0b6a 100644
--- a/backend/tests/routes/test_jury.py
+++ b/backend/tests/routes/test_jury.py
@@ -106,6 +106,7 @@ async def test_jury_assignment_draft(create, client, db_session):
app.dependency_overrides.pop(get_current_user)
+@pytest.mark.slow
async def test_update_evaluation(create, client, db_session):
jury = await create(UserFactory)
t = await create(TaskFactory)
@@ -143,7 +144,6 @@ async def test_generate_assignments_no_submissions(create, client, db_session):
organizer = await create(RoleFactory, name=settings.ROLE_NAMES.ORGANIZER)
creator = await create(UserFactory, roles=[organizer])
tournament = await create(TournamentFactory, creator=creator)
- await db_session.refresh(tournament, ['juries'])
closed = await create(TaskStatusOptionFactory, name=settings.TASK_STATUS_NAMES.SUBMISSION_CLOSED)
task = await create(TaskFactory, tournament=tournament, status=closed)
captain = await create(TeamMemberFactory, tournament=tournament)
@@ -158,11 +158,11 @@ async def test_generate_assignments_no_submissions(create, client, db_session):
app.dependency_overrides.pop(get_current_user)
+@pytest.mark.slow
async def test_generate_assignments_already_generated(create, client, db_session):
organizer = await create(RoleFactory, name=settings.ROLE_NAMES.ORGANIZER)
creator = await create(UserFactory, roles=[organizer])
tournament = await create(TournamentFactory, creator=creator)
- await db_session.refresh(tournament, ['juries'])
closed = await create(TaskStatusOptionFactory, name=settings.TASK_STATUS_NAMES.SUBMISSION_CLOSED)
task = await create(TaskFactory, tournament=tournament, status=closed, min_reviews_per_submission=0)
captain = await create(TeamMemberFactory, tournament=tournament)
@@ -182,7 +182,6 @@ async def test_generate_assignments_no_juries(create, client, db_session):
organizer = await create(RoleFactory, name=settings.ROLE_NAMES.ORGANIZER)
creator = await create(UserFactory, roles=[organizer])
tournament = await create(TournamentFactory, creator=creator, juries=[])
- await db_session.refresh(tournament, ['juries'])
closed = await create(TaskStatusOptionFactory, name=settings.TASK_STATUS_NAMES.SUBMISSION_CLOSED)
task = await create(TaskFactory, tournament=tournament, status=closed)
captain = await create(TeamMemberFactory, tournament=tournament)
@@ -196,8 +195,105 @@ async def test_generate_assignments_no_juries(create, client, db_session):
app.dependency_overrides.pop(get_current_user)
+@pytest.mark.slow
+async def test_finish_task_evaluation_jury(create, client, db_session):
+ jury = await create(UserFactory)
+ tournament = await create(TournamentFactory, juries=[jury])
+ await create(TaskStatusOptionFactory, name=settings.TASK_STATUS_NAMES.EVALUATED)
+ await create(JuryAssignmentStatusOptionFactory, name=settings.JURY_ASSIGNMENT_STATUS_NAMES.REVIEWED)
+ closed = await create(TaskStatusOptionFactory, name=settings.TASK_STATUS_NAMES.SUBMISSION_CLOSED)
+ task = await create(TaskFactory, tournament=tournament, status=closed)
+ captain = await create(TeamMemberFactory, tournament=tournament)
+ team = await create(TeamFactory, captain=captain, tournament=tournament)
+ submission = await create(SubmissionFactory, task=task, team=team)
+ await create(JuryAssignmentFactory, jury=jury, task=task, submission=submission)
+ app.dependency_overrides[get_current_user] = lambda: jury
+
+ resp = await client.post(f'/tournaments/{tournament.id}/tasks/{task.id}/finish-evaluation/')
+ assert resp.status_code == 200
+ assert resp.json()['status_id'] == settings.TASK_STATUS_NAMES.EVALUATED
+
+ app.dependency_overrides.pop(get_current_user)
+
+@pytest.mark.slow
+async def test_finish_task_evaluation_organizer(create, client, db_session):
+ organizer = await create(RoleFactory, name=settings.ROLE_NAMES.ORGANIZER)
+ creator = await create(UserFactory, roles=[organizer])
+ tournament = await create(TournamentFactory, creator=creator, juries=[])
+ await create(TaskStatusOptionFactory, name=settings.TASK_STATUS_NAMES.EVALUATED)
+ await create(JuryAssignmentStatusOptionFactory, name=settings.JURY_ASSIGNMENT_STATUS_NAMES.REVIEWED)
+ closed = await create(TaskStatusOptionFactory, name=settings.TASK_STATUS_NAMES.SUBMISSION_CLOSED)
+ task = await create(TaskFactory, tournament=tournament, status=closed)
+ captain = await create(TeamMemberFactory, tournament=tournament)
+ team = await create(TeamFactory, captain=captain, tournament=tournament)
+ submission = await create(SubmissionFactory, task=task, team=team)
+ await create(JuryAssignmentFactory, task=task, submission=submission)
+ app.dependency_overrides[get_current_user] = lambda: creator
+
+ resp = await client.post(f'/tournaments/{tournament.id}/tasks/{task.id}/finish-evaluation/')
+ assert resp.status_code == 200
+ assert resp.json()['status_id'] == settings.TASK_STATUS_NAMES.EVALUATED
+
+ app.dependency_overrides.pop(get_current_user)
+
+@pytest.mark.slow
+async def test_finish_tournament_evaluation_jury(create, client, db_session):
+ organizer = await create(RoleFactory, name=settings.ROLE_NAMES.ORGANIZER)
+ creator = await create(UserFactory, roles=[organizer])
+ jury = await create(UserFactory)
+ await create(TaskStatusOptionFactory, name=settings.TASK_STATUS_NAMES.EVALUATED)
+ await create(JuryAssignmentStatusOptionFactory, name=settings.JURY_ASSIGNMENT_STATUS_NAMES.REVIEWED)
+ assigned = await create(JuryAssignmentStatusOptionFactory, name=settings.JURY_ASSIGNMENT_STATUS_NAMES.ASSIGNED)
+ closed = await create(TaskStatusOptionFactory, name=settings.TASK_STATUS_NAMES.SUBMISSION_CLOSED)
+ tournament_statuses = {}
+ for opt_name in [o["name"] for o in settings.TOURNAMENT_STATUS_OPTIONS]:
+ tournament_statuses[opt_name] = await create(TournamentStatusOptionFactory, name=opt_name)
+ tournament = await create(TournamentFactory, creator=creator, juries=[], status=tournament_statuses["draft"])
+ task = await create(TaskFactory, tournament=tournament, status=closed)
+ team = await create(TeamFactory, tournament=tournament, captain=None)
+ captain = await create(TeamMemberFactory, tournament=tournament, team=team)
+ team.captain = captain
+ await db_session.flush()
+ submission = await create(SubmissionFactory, task=task, team=team)
+ await create(JuryAssignmentFactory, jury=jury, task=task, submission=submission, status=assigned)
+ app.dependency_overrides[get_current_user] = lambda: jury
+
+ resp = await client.post(f'/tournaments/{tournament.id}/finish-evaluation/')
+ assert resp.status_code == 403
+
+ app.dependency_overrides.pop(get_current_user)
+
+@pytest.mark.slow
+async def test_finish_tournament_evaluation_organizer(create, client, db_session):
+ organizer = await create(RoleFactory, name=settings.ROLE_NAMES.ORGANIZER)
+ creator = await create(UserFactory, roles=[organizer])
+ await create(TaskStatusOptionFactory, name=settings.TASK_STATUS_NAMES.EVALUATED)
+ await create(JuryAssignmentStatusOptionFactory, name=settings.JURY_ASSIGNMENT_STATUS_NAMES.REVIEWED)
+ assigned = await create(JuryAssignmentStatusOptionFactory, name=settings.JURY_ASSIGNMENT_STATUS_NAMES.ASSIGNED)
+ closed = await create(TaskStatusOptionFactory, name=settings.TASK_STATUS_NAMES.SUBMISSION_CLOSED)
+ tournament_statuses = {}
+ for opt_name in [o["name"] for o in settings.TOURNAMENT_STATUS_OPTIONS]:
+ tournament_statuses[opt_name] = await create(TournamentStatusOptionFactory, name=opt_name)
+ tournament = await create(TournamentFactory, creator=creator, juries=[], status=tournament_statuses["draft"])
+ task = await create(TaskFactory, tournament=tournament, status=closed)
+ team = await create(TeamFactory, tournament=tournament, captain=None)
+ captain = await create(TeamMemberFactory, tournament=tournament, team=team)
+ team.captain = captain
+ await db_session.flush()
+ submission = await create(SubmissionFactory, task=task, team=team)
+ await create(JuryAssignmentFactory, task=task, submission=submission, status=assigned)
+ app.dependency_overrides[get_current_user] = lambda: creator
+
+ resp = await client.post(f'/tournaments/{tournament.id}/finish-evaluation/')
+ assert resp.status_code == 200
+ assert resp.json()['status']['name'] == settings.TOURNAMENT_STATUS_NAMES.FINISHED
+
+ app.dependency_overrides.pop(get_current_user)
+
+
# Integration
+@pytest.mark.slow
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])
diff --git a/backend/tests/routes/test_leaderboard.py b/backend/tests/routes/test_leaderboard.py
new file mode 100644
index 0000000..3ffa831
--- /dev/null
+++ b/backend/tests/routes/test_leaderboard.py
@@ -0,0 +1,50 @@
+import pytest
+from tests.factories import (
+ TaskFactory,
+ TaskStatusOptionFactory,
+ TeamFactory,
+ TeamMemberFactory,
+ TournamentFactory,
+ TournamentStatusOptionFactory,
+ UserFactory,
+ JuryAssignmentFactory,
+ SubmissionFactory,
+ SubmissionEvaluationFactory,
+ TaskEvaluationCriterionFactory,
+ CriterionScoreFactory
+)
+from app.config import settings
+from app.utils import calculate_evaluation_total, calculate_evaluation_average
+
+@pytest.mark.slow
+async def test_task_leaderboard(create, client, db_session):
+ j1 = await create(UserFactory)
+ j2 = await create(UserFactory)
+ finished = await create(TournamentStatusOptionFactory, name=settings.TOURNAMENT_STATUS_NAMES.FINISHED)
+ tournament = await create(TournamentFactory, status=finished)
+ closed = await create(TaskStatusOptionFactory, name=settings.TASK_STATUS_NAMES.SUBMISSION_CLOSED)
+ task = await create(TaskFactory, tournament=tournament, status=closed, min_reviews_per_submission=0)
+ captain = await create(TeamMemberFactory, tournament=tournament)
+ team = await create(TeamFactory, captain=captain, tournament=tournament)
+ s1 = await create(SubmissionFactory, task=task, team=team)
+ a1 = await create(JuryAssignmentFactory, task=task, submission=s1, jury=j1)
+ a2 = await create(JuryAssignmentFactory, task=task, submission=s1, jury=j2)
+ e1 = await create(SubmissionEvaluationFactory, jury=j1, assignment=a1, submission=s1)
+ e2 = await create(SubmissionEvaluationFactory, jury=j2, assignment=a2, submission=s1)
+ await db_session.refresh(e1, ['criterion_scores'])
+ await db_session.refresh(e2, ['criterion_scores'])
+ c1 = await create(TaskEvaluationCriterionFactory, task=task)
+ c2 = await create(TaskEvaluationCriterionFactory, task=task)
+ await create(CriterionScoreFactory, score=7, evaluation=e1, criterion=c1)
+ await create(CriterionScoreFactory, score=52, evaluation=e2, criterion=c2)
+ totals = [calculate_evaluation_total(e1), calculate_evaluation_total(e2)]
+ total_calculated = sum(totals)
+ avg_calculated = calculate_evaluation_average(totals)
+
+
+ resp = await client.get(f'/tournaments/{tournament.id}/tasks/{task.id}/leaderboard/')
+ assert resp.status_code == 200
+ total_scores = [e['total_score'] for e in resp.json()]
+ average_scores = [e['average_score'] for e in resp.json()]
+ assert total_calculated in total_scores
+ assert avg_calculated in average_scores
diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py
index 09630db..3f91419 100644
--- a/backend/tests/test_models.py
+++ b/backend/tests/test_models.py
@@ -237,8 +237,8 @@ async def test_tournament_cascade_delete_children(db_session, create):
"task": task.id,
"team": team.id,
"member": member.id,
- "submission": submission.team_id,
- "url": (url.submission_id, url.url_id),
+ "submission": submission.id,
+ "url": url.id,
"evaluation": evaluation.id,
"criterion_score": criterion_score.id,
}
@@ -505,8 +505,8 @@ async def test_submission_urls_belong_to_submission(create):
url1 = await create(SubmissionUrlFactory, submission=submission)
url2 = await create(SubmissionUrlFactory, submission=submission)
- assert url1.submission_id == submission.team_id
- assert url2.submission_id == submission.team_id
+ assert url1.submission_id == submission.id
+ assert url2.submission_id == submission.id
async def test_submission_url_has_submission(db_session, create):
diff --git a/frontend/src/api/requests/types.ts b/frontend/src/api/requests/types.ts
index ffa0afd..355fe4c 100644
--- a/frontend/src/api/requests/types.ts
+++ b/frontend/src/api/requests/types.ts
@@ -60,6 +60,7 @@ export interface JuryTask {
title: string;
description?: string | null;
status_id: string;
+ status: TaskStatusOption;
start_time: string;
end_time: string;
min_reviews_per_submission: number;
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index 8a95ad2..ed6457e 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -144,7 +144,7 @@ export const Header = () => {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
- className="lg:hidden absolute top-full left-0 w-full bg-header-bg backdrop-blur-xl border-t border-white/10 shadow-2xl overflow-hidden origin-top"
+ className="lg:hidden absolute top-full left-0 w-full bg-header-bg backdrop-blur-xl border-t border-white/10 shadow-2xl origin-top"
>
{NAV_ITEMS.map(({ path, key }) => (
@@ -172,7 +172,7 @@ export const Header = () => {
{user?.uid ? (
- setIsMobileMenuOpen(false)}>
+
);
-};
+};
\ No newline at end of file
diff --git a/frontend/src/components/ui/CustomSelect.tsx b/frontend/src/components/ui/CustomSelect.tsx
index d14c8b1..76910c6 100644
--- a/frontend/src/components/ui/CustomSelect.tsx
+++ b/frontend/src/components/ui/CustomSelect.tsx
@@ -1,4 +1,5 @@
import React, { Fragment } from 'react';
+import { useTranslation } from 'react-i18next';
import { Listbox, Transition } from '@headlessui/react';
import { ChevronDown, Check, type LucideIcon } from 'lucide-react';
import { useFloating, offset, flip, shift, autoUpdate, size } from '@floating-ui/react';
@@ -23,6 +24,7 @@ interface CustomSelectProps {
}
const CustomSelect: React.FC
= ({ options, value, onChange, label, icon: Icon, disabled }) => {
+ const { t } = useTranslation("modals");
const { refs, floatingStyles } = useFloating({
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
@@ -43,7 +45,7 @@ const CustomSelect: React.FC = ({ options, value, onChange, l
return (
-
+
{label}
@@ -51,13 +53,13 @@ const CustomSelect: React.FC = ({ options, value, onChange, l
- {Icon && }
- {value?.label || 'Виберіть варіант'}
+ {Icon && }
+ {value?.label || t("custom_select.placeholder")}
-
+
= ({ options, value, onChange, l
{options.map((group) => (
-
+
{group.category}
{group.items.map((option) => (
@@ -82,8 +84,8 @@ const CustomSelect: React.FC
= ({ options, value, onChange, l
value={option}
className={({ active, selected }) => `
relative cursor-pointer select-none py-2 px-3 rounded-xl transition-all mb-0.5 last:mb-0
- ${active ? 'bg-slate-50 text-[#6D72F1]' : 'text-slate-600'}
- ${selected ? 'bg-[#6D72F1]/5 text-[#6D72F1]' : ''}
+ ${active ? 'bg-bg-body text-primary' : 'text-text-muted'}
+ ${selected ? 'bg-primary/5 text-primary' : ''}
`}
>
{({ selected }) => (
@@ -92,7 +94,7 @@ const CustomSelect: React.FC = ({ options, value, onChange, l
{option.label}
{selected && (
-
+
)}
)}
diff --git a/frontend/src/components/ui/DateTimePicker.tsx b/frontend/src/components/ui/DateTimePicker.tsx
index ee6fbd7..759e916 100644
--- a/frontend/src/components/ui/DateTimePicker.tsx
+++ b/frontend/src/components/ui/DateTimePicker.tsx
@@ -1,4 +1,5 @@
import React, { useState, Fragment } from "react";
+import { useTranslation } from "react-i18next";
import {
format,
addMonths,
@@ -12,9 +13,9 @@ import {
addDays,
setHours,
setMinutes,
- parseISO,
} from "date-fns";
-import { uk } from "date-fns/locale";
+import { naiveDateTimeToDate } from "@/utils/naiveDateTime";
+import { uk, enGB } from "date-fns/locale";
import { Popover, Transition } from "@headlessui/react";
import {
Calendar as CalendarIcon,
@@ -43,7 +44,18 @@ const DateTimePicker: React.FC
= ({
onChange,
label,
}) => {
- const dateValue = value ? parseISO(value) : new Date();
+ const { t, i18n } = useTranslation("modals");
+ const dateLocale = i18n.language === "en" ? enGB : uk;
+ const weekdayLabels = [
+ t("date_time_picker.weekdays.mon"),
+ t("date_time_picker.weekdays.tue"),
+ t("date_time_picker.weekdays.wed"),
+ t("date_time_picker.weekdays.thu"),
+ t("date_time_picker.weekdays.fri"),
+ t("date_time_picker.weekdays.sat"),
+ t("date_time_picker.weekdays.sun"),
+ ];
+ const dateValue = value ? (naiveDateTimeToDate(value) ?? new Date()) : new Date();
const [currentMonth, setCurrentMonth] = useState(new Date(dateValue));
const { refs, floatingStyles } = useFloating({
@@ -107,7 +119,7 @@ const DateTimePicker: React.FC = ({
type="button"
onClick={() => handleDateClick(cloneDay)}
className={`w-8 h-8 flex items-center justify-center rounded-xl text-[11px] font-bold transition-all
- ${!isCurrentMonth ? "text-slate-200" : isSelected ? "bg-[#6D72F1] text-white shadow-lg shadow-[#6D72F1]/30" : "text-slate-600 hover:bg-slate-50 hover:text-[#6D72F1]"}
+ ${!isCurrentMonth ? "text-text-muted/30" : isSelected ? "bg-primary text-white shadow-lg shadow-primary/30" : "text-text-muted hover:bg-bg-body hover:text-primary"}
`}
>
{format(day, "d")}
@@ -126,23 +138,23 @@ const DateTimePicker: React.FC = ({
return (
-
+
{label}
-
+
- {value ? format(dateValue, "dd.MM.yyyy") : "Виберіть дату"}
+ {value ? format(dateValue, "dd.MM.yyyy") : t("date_time_picker.pick_date")}
-
-
+
+
{format(dateValue, "HH:mm")}
@@ -159,35 +171,35 @@ const DateTimePicker: React.FC
= ({
-
+
-
- {format(currentMonth, "LLLL yyyy", { locale: uk })}
+
+ {format(currentMonth, "LLLL yyyy", { locale: dateLocale })}
- {["Пн", "Вв", "Ср", "Чт", "Пт", "Сб", "Нд"].map((d) => (
+ {weekdayLabels.map((d) => (
{d}
@@ -196,9 +208,9 @@ const DateTimePicker: React.FC
= ({
{renderCalendar()}
-
-
- Час
+
+
+ {t("date_time_picker.time")}
@@ -206,39 +218,39 @@ const DateTimePicker: React.FC
= ({
-
+
{format(dateValue, "HH")}
- :
+ :
-
+
{format(dateValue, "mm")}
@@ -255,7 +267,7 @@ const DateTimePicker: React.FC
= ({
setMinutes(setHours(new Date(dateValue), h), 0),
)
}
- className="px-2 py-1 bg-white border border-slate-200 rounded-lg text-[9px] font-black text-slate-500 hover:border-[#6D72F1] hover:text-[#6D72F1] transition-all"
+ className="px-2 py-1 bg-bg-card border border-border rounded-lg text-[9px] font-black text-text-muted hover:border-primary hover:text-primary transition-all"
>
{h}:00
diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx
new file mode 100644
index 0000000..56bd18e
--- /dev/null
+++ b/frontend/src/components/ui/Modal.tsx
@@ -0,0 +1,202 @@
+import { useEffect, type ReactNode, type MouseEvent } from "react";
+import { createPortal } from "react-dom";
+import { useTranslation } from "react-i18next";
+import { X } from "lucide-react";
+import { cn } from "@/utils/cn";
+
+/** Shared modal class tokens for modals with custom layout */
+export const modalClass = {
+ overlay:
+ "fixed inset-0 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm dark:bg-black/70 transition-colors",
+ backdrop: "absolute inset-0",
+ panel:
+ "relative w-full bg-bg-card border border-border shadow-2xl overflow-hidden flex flex-col transition-colors duration-300",
+ panelLg: "rounded-[2.5rem]",
+ panelMd: "rounded-2xl",
+ body: "flex-1 overflow-y-auto bg-bg-body p-6 md:p-8",
+ bodyInset: "flex-1 overflow-y-auto bg-bg-body/80 dark:bg-bg-body/50 p-6",
+ footer: "p-6 bg-bg-card border-t border-border flex gap-4 shrink-0",
+ header: "flex items-center justify-between p-6 border-b border-border shrink-0 bg-bg-card",
+ headerTitle: "text-lg font-bold text-text-main",
+ headerSubtitle: "text-xs text-text-muted mt-0.5",
+ brandedHeader:
+ "bg-gradient-to-r from-primary via-primary to-indigo-500 p-6 md:p-8 text-white shrink-0 relative",
+ brandedHeaderTitle: "text-xl md:text-2xl font-black uppercase tracking-tight",
+ label: "text-[10px] font-black text-text-muted uppercase tracking-widest",
+ input:
+ "w-full px-5 py-4 bg-bg-card border-2 border-border rounded-2xl outline-none font-bold text-text-main placeholder:text-text-muted/60 focus:border-primary transition-all",
+ textarea:
+ "w-full px-5 py-4 bg-bg-card border-2 border-border rounded-2xl outline-none font-medium text-text-main resize-none focus:border-primary transition-all",
+ card: "bg-bg-card p-5 rounded-2xl border border-border shadow-sm",
+ closeBtn:
+ "p-2 hover:bg-bg-body rounded-lg transition-colors text-text-muted hover:text-text-main",
+ closeBtnBranded:
+ "p-2 hover:bg-white/10 rounded-full transition-all text-white",
+ iconBox:
+ "w-20 h-20 bg-primary/10 text-primary rounded-[2rem] flex items-center justify-center mx-auto mb-6 shadow-inner",
+ primaryAction:
+ "py-4 bg-primary text-white rounded-2xl font-bold uppercase text-[10px] tracking-[0.2em] shadow-lg shadow-primary/20 hover:brightness-110 active:scale-[0.98] disabled:bg-border disabled:text-text-muted disabled:shadow-none transition-all",
+ secondaryAction:
+ "py-4 font-bold text-text-muted uppercase text-[10px] tracking-widest hover:text-text-main transition-colors",
+ ghostAction:
+ "w-full py-4 bg-bg-body text-text-muted rounded-2xl font-bold uppercase text-[11px] tracking-[0.15em] transition-all hover:bg-border hover:text-text-main active:scale-[0.98]",
+} as const;
+
+interface ModalProps {
+ isOpen: boolean;
+ onClose?: () => void;
+ children: ReactNode;
+ zIndex?: number;
+ usePortal?: boolean;
+ lockScroll?: boolean;
+ className?: string;
+ closeOnBackdrop?: boolean;
+}
+
+export function Modal({
+ isOpen,
+ onClose,
+ children,
+ zIndex = 200,
+ usePortal = true,
+ lockScroll = true,
+ className,
+ closeOnBackdrop = true,
+}: ModalProps) {
+ useEffect(() => {
+ if (!isOpen || !lockScroll) return;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = "unset";
+ };
+ }, [isOpen, lockScroll]);
+
+ if (!isOpen) return null;
+
+ const handleBackdrop = (e: MouseEvent) => {
+ if (closeOnBackdrop && e.target === e.currentTarget) onClose?.();
+ };
+
+ const el = (
+
+ {children}
+
+ );
+
+ return usePortal ? createPortal(el, document.body) : el;
+}
+
+interface ModalPanelProps {
+ children: ReactNode;
+ className?: string;
+ size?: "sm" | "md" | "lg" | "xl" | "full";
+ rounded?: "lg" | "md";
+ onClick?: (e: MouseEvent) => void;
+}
+
+const sizeClass = {
+ sm: "max-w-md",
+ md: "max-w-2xl",
+ lg: "max-w-4xl",
+ xl: "max-w-7xl",
+ full: "max-w-[96vw]",
+};
+
+export function ModalPanel({
+ children,
+ className,
+ size = "md",
+ rounded = "lg",
+ onClick,
+}: ModalPanelProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function ModalBackdrop({ onClose }: { onClose?: () => void }) {
+ return ;
+}
+
+interface ModalBrandedHeaderProps {
+ title: ReactNode;
+ onClose?: () => void;
+ children?: ReactNode;
+ className?: string;
+}
+
+export function ModalBrandedHeader({
+ title,
+ onClose,
+ children,
+ className,
+}: ModalBrandedHeaderProps) {
+ const { t } = useTranslation("modals");
+ return (
+
+
+
{title}
+ {onClose && (
+
+ )}
+
+ {children}
+
+ );
+}
+
+export function ModalBody({
+ children,
+ className,
+ inset = false,
+}: {
+ children: ReactNode;
+ className?: string;
+ inset?: boolean;
+}) {
+ return {children}
;
+}
+
+export function ModalFooter({ children, className }: { children: ReactNode; className?: string }) {
+ return {children}
;
+}
+
+export function ModalSimpleHeader({
+ title,
+ subtitle,
+ onClose,
+}: {
+ title: string;
+ subtitle?: ReactNode;
+ onClose: () => void;
+}) {
+ const { t } = useTranslation("modals");
+ return (
+
+
+
{title}
+ {subtitle ?
{subtitle}
: null}
+
+
+
+ );
+}
diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts
index d950c22..9562190 100644
--- a/frontend/src/components/ui/index.ts
+++ b/frontend/src/components/ui/index.ts
@@ -1,4 +1,5 @@
export * from "./Button";
+export * from "./Modal";
export * from "./BrandingPanel";
export * from "./Ticket3D";
// export * from "./Card";
diff --git a/frontend/src/i18n/config.ts b/frontend/src/i18n/config.ts
index 8b09579..5c5ab85 100644
--- a/frontend/src/i18n/config.ts
+++ b/frontend/src/i18n/config.ts
@@ -16,6 +16,7 @@ import ukProfile from "../locales/uk/profile.json";
import ukRoleRequest from "../locales/uk/roleRequest.json";
import ukJury from "../locales/uk/jury.json";
import ukNews from "../locales/uk/news.json";
+import ukModals from "../locales/uk/modals.json";
import ukTournament from "../locales/uk/tournament.json";
import enCommon from "../locales/en/common.json";
@@ -31,6 +32,7 @@ import enRules from "../locales/en/rules.json";
import enProfile from "../locales/en/profile.json";
import enRoleRequest from "../locales/en/roleRequest.json";
import enJury from "../locales/en/jury.json";
+import enModals from "../locales/en/modals.json";
import enNews from "../locales/en/news.json";
import enTournament from "../locales/en/tournament.json";
@@ -50,6 +52,7 @@ const resources = {
roleRequest: ukRoleRequest,
jury: ukJury,
news: ukNews,
+ modals: ukModals,
tournament: ukTournament,
},
en: {
@@ -66,6 +69,7 @@ const resources = {
profile: enProfile,
roleRequest: enRoleRequest,
jury: enJury,
+ modals: enModals,
news: enNews,
tournament: enTournament,
},
@@ -92,10 +96,13 @@ i18n
"profile",
"roleRequest",
"jury",
+ "modals",
+ "news",
+ "tournament",
],
defaultNS: "common",
debug: import.meta.env.DEV,
interpolation: { escapeValue: false },
});
-export default i18n;
+export default i18n;
\ No newline at end of file
diff --git a/frontend/src/locales/en/modals.json b/frontend/src/locales/en/modals.json
new file mode 100644
index 0000000..f1df62c
--- /dev/null
+++ b/frontend/src/locales/en/modals.json
@@ -0,0 +1,119 @@
+{
+ "common": {
+ "cancel": "Cancel",
+ "back": "Back",
+ "next": "Next",
+ "confirm": "Confirm",
+ "close": "Close",
+ "close_aria": "Close",
+ "tournament_label": "Tournament:",
+ "selected": "Selected",
+ "experts": "experts",
+ "no_description": "No description provided.",
+ "dash": "—",
+ "people_short": "people",
+ "user_initial": "U"
+ },
+ "fields": {
+ "title": "Title",
+ "description": "Description",
+ "name": "Name",
+ "start": "Start",
+ "deadline": "Deadline",
+ "registration": "Registration",
+ "opening": "Opening",
+ "closing": "Closing",
+ "start_date_time": "Start date & time",
+ "start_date": "Start date"
+ },
+ "date_time_picker": {
+ "pick_date": "Select date",
+ "time": "Time",
+ "weekdays": {
+ "mon": "Mon",
+ "tue": "Tue",
+ "wed": "Wed",
+ "thu": "Thu",
+ "fri": "Fri",
+ "sat": "Sat",
+ "sun": "Sun"
+ }
+ },
+ "custom_select": {
+ "placeholder": "Select an option"
+ },
+ "looking_for_team": {
+ "title": "Find a team",
+ "heading": "Looking for a team?",
+ "description": "Contact us — we'll help you find like-minded people and join a tournament today!",
+ "contact_cta": "Go to contact page"
+ },
+ "jury_selection": {
+ "title": "Select jury",
+ "empty": "No users found"
+ },
+ "task_management": {
+ "create_title": "New task",
+ "edit_title": "Update task",
+ "tech_stack": "Tech stack",
+ "add_requirement": "Add requirement",
+ "criteria": "Scoring criteria",
+ "criterion_placeholder": "Add criterion…",
+ "save": "Save task",
+ "validation": {
+ "required": "Required field"
+ }
+ },
+ "tournament_info": {
+ "default_status": "Tournament",
+ "about": "About the event",
+ "tasks": "Stages (Tasks)",
+ "active": "Active",
+ "no_tasks": "No tasks yet",
+ "teams": "Participating teams",
+ "teams_total": "Total: {{count}}",
+ "no_teams": "No teams have joined yet",
+ "timing": "Timing",
+ "registration_period": "Registration",
+ "event_period": "Event",
+ "configuration": "Configuration",
+ "max_teams": "Max teams",
+ "team_size": "Team size",
+ "jury": "Jury",
+ "no_jury": "No jury assigned",
+ "close": "Got it, close",
+ "contact_telegram": "TG:",
+ "contact_email": "Email:",
+ "contact_github": "GH:"
+ },
+ "create_tournament": {
+ "title": "New tournament",
+ "success_title": "Ready to launch",
+ "success_description": "Tournament created successfully. Add tasks and configure scoring in the organizer panel.",
+ "success_cta": "Go to dashboard",
+ "title_placeholder": "e.g. Winter Coding Cup 2024",
+ "title_tooltip": "Choose a clear name participants will see",
+ "description_tooltip": "Briefly describe rules and winning conditions",
+ "auto_start_hint": "The tournament will start automatically",
+ "min_team": "Min per team",
+ "max_team": "Max per team",
+ "team_limit": "Team limit",
+ "add_judges": "Add judges",
+ "judges_selected": "Selected: {{count}}",
+ "creating": "Creating…",
+ "create": "Create tournament"
+ },
+ "edit_tournament": {
+ "title": "Edit tournament",
+ "title_label": "Tournament name",
+ "title_tooltip": "Changing the name affects how it appears in all lists",
+ "description_tooltip": "You can update rules or tournament details",
+ "max_teams": "Teams (max)",
+ "min_players": "Players (min)",
+ "max_players": "Players (max)",
+ "jury_team": "Jury team",
+ "jury_count": "Experts assigned: {{count}}",
+ "saving": "Saving…",
+ "save": "Save changes"
+ }
+}
diff --git a/frontend/src/locales/uk/modals.json b/frontend/src/locales/uk/modals.json
new file mode 100644
index 0000000..d4f46f0
--- /dev/null
+++ b/frontend/src/locales/uk/modals.json
@@ -0,0 +1,119 @@
+{
+ "common": {
+ "cancel": "Скасувати",
+ "back": "Назад",
+ "next": "Далі",
+ "confirm": "Підтвердити",
+ "close": "Закрити",
+ "close_aria": "Закрити",
+ "tournament_label": "Турнір:",
+ "selected": "Обрано",
+ "experts": "експертів",
+ "no_description": "Опис не вказано.",
+ "dash": "—",
+ "people_short": "чол.",
+ "user_initial": "U"
+ },
+ "fields": {
+ "title": "Назва",
+ "description": "Опис",
+ "name": "Назва",
+ "start": "Старт",
+ "deadline": "Дедлайн",
+ "registration": "Реєстрація",
+ "opening": "Відкриття",
+ "closing": "Закриття",
+ "start_date_time": "Дата та час старту",
+ "start_date": "Дата старту"
+ },
+ "date_time_picker": {
+ "pick_date": "Виберіть дату",
+ "time": "Час",
+ "weekdays": {
+ "mon": "Пн",
+ "tue": "Вв",
+ "wed": "Ср",
+ "thu": "Чт",
+ "fri": "Пт",
+ "sat": "Сб",
+ "sun": "Нд"
+ }
+ },
+ "custom_select": {
+ "placeholder": "Виберіть варіант"
+ },
+ "looking_for_team": {
+ "title": "Пошук команди",
+ "heading": "Шукаєш команду?",
+ "description": "Зв'яжись з нами — ми допоможемо тобі знайти однодумців та приєднатися до турніру вже сьогодні!",
+ "contact_cta": "Перейти на сторінку контактів"
+ },
+ "jury_selection": {
+ "title": "Вибір журі",
+ "empty": "Користувачів не знайдено"
+ },
+ "task_management": {
+ "create_title": "Нове завдання",
+ "edit_title": "Оновити таск",
+ "tech_stack": "Стек технологій",
+ "add_requirement": "Додати вимогу",
+ "criteria": "Критерії оцінювання",
+ "criterion_placeholder": "Додати критерій...",
+ "save": "Зберегти завдання",
+ "validation": {
+ "required": "Обов'язкове поле"
+ }
+ },
+ "tournament_info": {
+ "default_status": "Турнір",
+ "about": "Про івент",
+ "tasks": "Етапи (Таски)",
+ "active": "Активне",
+ "no_tasks": "Таски відсутні",
+ "teams": "Команди-учасники",
+ "teams_total": "Всього: {{count}}",
+ "no_teams": "Команди ще не приєдналися",
+ "timing": "Таймінг",
+ "registration_period": "Реєстрація",
+ "event_period": "Проведення",
+ "configuration": "Конфігурація",
+ "max_teams": "Макс команд",
+ "team_size": "В команді",
+ "jury": "Журі",
+ "no_jury": "Журі не призначено",
+ "close": "Зрозуміло, закрити",
+ "contact_telegram": "TG:",
+ "contact_email": "Email:",
+ "contact_github": "GH:"
+ },
+ "create_tournament": {
+ "title": "Новий турнір",
+ "success_title": "Готово до запуску",
+ "success_description": "Турнір створено успішно. Тепер додайте завдання та налаштуйте бали у панелі керування.",
+ "success_cta": "До керування",
+ "title_placeholder": "Наприклад: Winter Coding Cup 2024",
+ "title_tooltip": "Вкажіть зрозумілу назву, яку бачитимуть учасники",
+ "description_tooltip": "Коротко про правила та умови перемоги",
+ "auto_start_hint": "Турнір розпочнеться автоматично",
+ "min_team": "Мін. у команді",
+ "max_team": "Макс. у команді",
+ "team_limit": "Ліміт команд",
+ "add_judges": "Додати суддів",
+ "judges_selected": "Зараз обрано: {{count}}",
+ "creating": "Створення...",
+ "create": "Створити турнір"
+ },
+ "edit_tournament": {
+ "title": "Редагування",
+ "title_label": "Назва турніру",
+ "title_tooltip": "Зміна назви вплине на відображення в усіх списках",
+ "description_tooltip": "Ви можете оновити правила або деталі турніру",
+ "max_teams": "Команд (Макс)",
+ "min_players": "Гравців (Мін)",
+ "max_players": "Гравців (Макс)",
+ "jury_team": "Суддівська команда",
+ "jury_count": "Залучено фахівців: {{count}}",
+ "saving": "Збереження...",
+ "save": "Зберегти зміни"
+ }
+}
diff --git a/frontend/src/pages/JuryPanel/EvaluateTournamentPage.tsx b/frontend/src/pages/JuryPanel/EvaluateTournamentPage.tsx
index a5e07d7..ae0d135 100644
--- a/frontend/src/pages/JuryPanel/EvaluateTournamentPage.tsx
+++ b/frontend/src/pages/JuryPanel/EvaluateTournamentPage.tsx
@@ -10,6 +10,7 @@ import { HiArrowLeft } from "react-icons/hi2";
import EvaluationsModal from "./components/EvaluationsModal";
import SubmissionCard from "./components/SubmissionCard";
import { JuryPanelShell } from "./components/JuryPanelShell";
+import { juryTaskQueryKey } from "./juryQueryKeys";
import { taskStatusByName } from "@/config/appConfig";
import { Button } from "@/components/ui/Button";
@@ -23,9 +24,9 @@ const EvaluateTournamentPage = () => {
const {
data: assignments = [],
isLoading,
- error,
+ isError,
} = useQuery({
- queryKey: ["jury-task", id],
+ queryKey: juryTaskQueryKey(id ?? ""),
queryFn: async () => {
if (!id) throw new Error("Task id is missing");
if (!user) throw new Error("User is not authenticated");
@@ -41,6 +42,7 @@ const EvaluateTournamentPage = () => {
const reviewedCount = assignments.filter((a) => a.evaluation).length;
const totalCount = assignments.length;
const progressPct = totalCount > 0 ? Math.round((reviewedCount / totalCount) * 100) : 0;
+ const showRoundMeta = !isLoading && !isError && totalCount > 0;
const finishMutation = useMutation({
mutationFn: async () => {
@@ -48,7 +50,9 @@ const EvaluateTournamentPage = () => {
return finishEvaluation(tournamentId, Number(id), user);
},
onSuccess: async () => {
- await queryClient.invalidateQueries({ queryKey: ["jury-task", id] });
+ if (id) {
+ await queryClient.invalidateQueries({ queryKey: juryTaskQueryKey(id) });
+ }
toast.success(t("evaluate.toast_finalized"));
},
onError: (err) => {
@@ -73,8 +77,9 @@ const EvaluateTournamentPage = () => {
title={task?.title || t("evaluate.loading_round")}
description={task?.description || t("evaluate.default_description")}
headerLeading={backLink}
+ headerBadge={task?.status.display_name}
>
- {!isLoading && !error && totalCount > 0 && !isFinalized ? (
+ {showRoundMeta && !isFinalized ? (
{t("evaluate.progress_label")}
@@ -102,19 +107,23 @@ const EvaluateTournamentPage = () => {
) : null}
- {isFinalized ? (
+ {showRoundMeta && isFinalized ? (
-
+
{t("evaluate.finalized_title")}
{t("evaluate.finalized_hint")}
- ) : (
+ ) : null}
+
+ {showRoundMeta && !isFinalized ? (
-
+
{t("evaluate.status_label")}
- {allReviewed ? t("evaluate.status_all_reviewed") : t("evaluate.status_partial", { reviewed: reviewedCount, total: totalCount })}
+ {allReviewed
+ ? t("evaluate.status_all_reviewed")
+ : t("evaluate.status_partial", { reviewed: reviewedCount, total: totalCount })}
{allReviewed ? t("evaluate.status_hint_all_reviewed") : t("evaluate.status_hint_partial")}
@@ -134,9 +143,9 @@ const EvaluateTournamentPage = () => {
) : null}
- )}
+ ) : null}
- {isLoading && (
+ {isLoading ? (
{
{t("evaluate.loading_hint")}
- )}
+ ) : null}
- {error && (
+ {isError ? (
{
{t("evaluate.error_title")}
{t("evaluate.error_hint")}
- )}
+ ) : null}
- {!isLoading && !error && assignments.length === 0 ? (
+ {!isLoading && !isError && assignments.length === 0 ? (
{t("evaluate.empty_title")}
{t("evaluate.empty_description")}
@@ -178,7 +187,7 @@ const EvaluateTournamentPage = () => {
) : null}
- {!isLoading && !error && assignments.length > 0 ? (
+ {!isLoading && !isError && assignments.length > 0 ? (
{assignments.map((assignment) => (
diff --git a/frontend/src/pages/JuryPanel/JuryPanel.tsx b/frontend/src/pages/JuryPanel/JuryPanel.tsx
index 2c1f54d..d9a6a5f 100644
--- a/frontend/src/pages/JuryPanel/JuryPanel.tsx
+++ b/frontend/src/pages/JuryPanel/JuryPanel.tsx
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import TournamentCard from "./components/TournamentCard";
+import { juryTasksQueryKey } from "./juryQueryKeys";
import { Hero } from "@/components/Hero";
import { Stars } from "@/components/Stars"
@@ -12,7 +13,7 @@ const JuryPanel = () => {
const user = auth.currentUser;
const { data: tasks = [], isLoading, error } = useQuery({
- queryKey: ["jury-tasks"],
+ queryKey: juryTasksQueryKey,
queryFn: async () => {
if (!user) throw new Error("User is not authenticated");
return getJuryTasks(user);
diff --git a/frontend/src/pages/JuryPanel/components/EvaluationsModal.tsx b/frontend/src/pages/JuryPanel/components/EvaluationsModal.tsx
index 0018703..aced248 100644
--- a/frontend/src/pages/JuryPanel/components/EvaluationsModal.tsx
+++ b/frontend/src/pages/JuryPanel/components/EvaluationsModal.tsx
@@ -9,6 +9,8 @@ import { RiCloseLargeLine } from "react-icons/ri";
import { Button } from "@/components/ui/Button";
import { cn } from "@/utils/cn";
+import { juryTaskQueryKey } from "../juryQueryKeys";
+
interface EvaluationsModalProps {
assignment: JuryAssignment | null;
onClose: () => void;
@@ -42,8 +44,14 @@ function EvaluationsModalInner({ assignment, onClose }: { assignment: JuryAssign
!!assignment.evaluation,
);
},
- onSuccess: async () => {
- await queryClient.invalidateQueries({ queryKey: ["jury-task", assignment.task_id] });
+ onSuccess: async (savedEvaluation) => {
+ const queryKey = juryTaskQueryKey(assignment.task_id);
+ queryClient.setQueryData
(queryKey, (previous) =>
+ previous?.map((item) =>
+ item.id === assignment.id ? { ...item, evaluation: savedEvaluation } : item,
+ ),
+ );
+ await queryClient.invalidateQueries({ queryKey });
onClose();
},
});
@@ -51,25 +59,28 @@ function EvaluationsModalInner({ assignment, onClose }: { assignment: JuryAssign
return (
-
+
-
+
{t("modal.eyebrow")}
-
+
{assignment.submission.team.name}
-
{assignment.task.title}
+
{assignment.task.title}
-
+
- {isFinalized && (
-
- {t("modal.finalized_banner")}
-
- )}
+
+ {isFinalized ? (
+
+ {t("modal.finalized_banner")}
+
+ ) : null}
-
- {assignment.task.criteria.map((criterion) => (
-
);
diff --git a/frontend/src/pages/JuryPanel/components/JuryPanelShell.tsx b/frontend/src/pages/JuryPanel/components/JuryPanelShell.tsx
index b88e21b..6cbdc16 100644
--- a/frontend/src/pages/JuryPanel/components/JuryPanelShell.tsx
+++ b/frontend/src/pages/JuryPanel/components/JuryPanelShell.tsx
@@ -1,11 +1,15 @@
import type { ReactNode } from "react";
import { cn } from "@/utils/cn";
+import { formatStatusLabel } from "../formatStatusLabel";
+
export interface JuryPanelShellProps {
eyebrow: string;
title: ReactNode;
description?: ReactNode;
headerLeading?: ReactNode;
+ /** Task status label shown beside the title (e.g. Submission Closed). */
+ headerBadge?: string;
children: ReactNode;
}
@@ -14,6 +18,7 @@ export function JuryPanelShell({
title,
description,
headerLeading,
+ headerBadge,
children,
}: JuryPanelShellProps) {
return (
@@ -31,13 +36,20 @@ export function JuryPanelShell({
{headerLeading ?
{headerLeading}
: null}
{eyebrow}
-
-
+
+
+ {headerBadge ? (
+
+
+ {formatStatusLabel(headerBadge)}
+
+
+ ) : null}
{description ? (
{description}
diff --git a/frontend/src/pages/JuryPanel/components/SubmissionCard.tsx b/frontend/src/pages/JuryPanel/components/SubmissionCard.tsx
index fdad504..388f144 100644
--- a/frontend/src/pages/JuryPanel/components/SubmissionCard.tsx
+++ b/frontend/src/pages/JuryPanel/components/SubmissionCard.tsx
@@ -24,8 +24,8 @@ const SubmissionCard = ({ assignment, onOpen }: SubmissionCardProps) => {
: "border-amber-500/30 ring-2 ring-amber-400/25 dark:border-amber-500/35",
)}
>
-
-
+
+
{t("submission.label")}
@@ -45,7 +45,7 @@ const SubmissionCard = ({ assignment, onOpen }: SubmissionCardProps) => {
-
+
{assignment.submission.urls.length > 0 ? (
assignment.submission.urls.map((item) => (
{
-
+