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) => ( -