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/3191704e4d9a_.py b/backend/alembic/versions/3191704e4d9a_.py deleted file mode 100644 index 8dbad0f..0000000 --- a/backend/alembic/versions/3191704e4d9a_.py +++ /dev/null @@ -1,50 +0,0 @@ -"""empty message - -Revision ID: 3191704e4d9a -Revises: d146519cd8df -Create Date: 2026-05-03 20:44:22.136819 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '3191704e4d9a' -down_revision: Union[str, Sequence[str], None] = 'd146519cd8df' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('news', schema=None) as batch_op: - batch_op.add_column(sa.Column('category_name', sa.String(), nullable=False)) - batch_op.create_foreign_key(None, 'news_categories', ['category_name'], ['name'], ondelete='CASCADE') - - with op.batch_alter_table('tasks', schema=None) as batch_op: - 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) - - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - 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') - - with op.batch_alter_table('news', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.drop_column('category_name') - - # ### end Alembic commands ### diff --git "a/backend/alembic/versions/395793e08475_\320\264\320\276\320\264\320\260\320\275\320\276_\320\275\320\276\320\262\321\226_\321\202\320\260\320\261\320\273\320\270\321\206\321\226.py" "b/backend/alembic/versions/395793e08475_\320\264\320\276\320\264\320\260\320\275\320\276_\320\275\320\276\320\262\321\226_\321\202\320\260\320\261\320\273\320\270\321\206\321\226.py" deleted file mode 100644 index e933f70..0000000 --- "a/backend/alembic/versions/395793e08475_\320\264\320\276\320\264\320\260\320\275\320\276_\320\275\320\276\320\262\321\226_\321\202\320\260\320\261\320\273\320\270\321\206\321\226.py" +++ /dev/null @@ -1,172 +0,0 @@ -"""додано_нові_таблиці - -Revision ID: 395793e08475 -Revises: 3191704e4d9a -Create Date: 2026-05-04 13:53:34.804438 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '395793e08475' -down_revision: Union[str, Sequence[str], None] = '3191704e4d9a' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('evaluations', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('evaluations_jury_id_fkey'), type_='foreignkey') - batch_op.drop_constraint(batch_op.f('evaluations_submission_id_fkey'), type_='foreignkey') - batch_op.create_foreign_key(None, 'submissions', ['submission_id'], ['team_id'], ondelete='CASCADE') - batch_op.create_foreign_key(None, 'users', ['jury_id'], ['id'], ondelete='CASCADE') - - 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) - - with op.batch_alter_table('notifications', schema=None) as batch_op: - batch_op.alter_column('user_id', - existing_type=sa.INTEGER(), - nullable=True) - batch_op.drop_constraint(batch_op.f('notifications_body_key'), type_='unique') - batch_op.drop_constraint(batch_op.f('notifications_user_id_fkey'), type_='foreignkey') - batch_op.create_foreign_key(None, 'users', ['user_id'], ['id'], ondelete='CASCADE') - - with op.batch_alter_table('requirement_evaluations', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('requirement_evaluations_evaluation_id_fkey'), type_='foreignkey') - batch_op.create_foreign_key(None, 'evaluations', ['evaluation_id'], ['id'], ondelete='CASCADE') - - with op.batch_alter_table('role_request_info', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('role_request_info_option_name_fkey'), type_='foreignkey') - batch_op.drop_constraint(batch_op.f('role_request_info_request_id_fkey'), type_='foreignkey') - batch_op.create_foreign_key(None, 'role_request_info_options', ['option_name'], ['name'], ondelete='CASCADE') - batch_op.create_foreign_key(None, 'role_requests', ['request_id'], ['id'], ondelete='CASCADE') - - with op.batch_alter_table('role_requests', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('role_requests_role_name_fkey'), type_='foreignkey') - batch_op.drop_constraint(batch_op.f('role_requests_user_id_fkey'), type_='foreignkey') - batch_op.create_foreign_key(None, 'users', ['user_id'], ['id'], ondelete='CASCADE') - batch_op.create_foreign_key(None, 'roles', ['role_name'], ['name'], ondelete='CASCADE') - - with op.batch_alter_table('submission_urls', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('submission_urls_url_id_fkey'), type_='foreignkey') - batch_op.drop_constraint(batch_op.f('submission_urls_submission_id_fkey'), type_='foreignkey') - batch_op.create_foreign_key(None, 'submission_url_options', ['url_id'], ['name'], ondelete='CASCADE') - batch_op.create_foreign_key(None, 'submissions', ['submission_id'], ['team_id'], ondelete='CASCADE') - - with op.batch_alter_table('submissions', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('submissions_team_id_fkey'), type_='foreignkey') - batch_op.create_foreign_key(None, 'teams', ['team_id'], ['id'], ondelete='CASCADE') - - with op.batch_alter_table('task_requirement_categories', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('task_requirement_categories_main_id_fkey'), type_='foreignkey') - batch_op.create_foreign_key(None, 'task_requirement_categories', ['main_id'], ['name'], ondelete='CASCADE') - - with op.batch_alter_table('task_requirement_options', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('task_requirement_options_category_id_fkey'), type_='foreignkey') - batch_op.create_foreign_key(None, 'task_requirement_categories', ['category_id'], ['name'], ondelete='CASCADE') - - with op.batch_alter_table('tasks', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('tasks_status_id_fkey'), type_='foreignkey') - batch_op.create_foreign_key(None, 'task_statuses', ['status_id'], ['name'], ondelete='CASCADE') - 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.drop_constraint(batch_op.f('team_members_tournament_id_fkey'), type_='foreignkey') - batch_op.create_foreign_key(None, 'tournaments', ['tournament_id'], ['id'], ondelete='CASCADE') - batch_op.create_foreign_key('fk_teammember_team', 'teams', ['team_id'], ['id'], ondelete='CASCADE', use_alter=True) - - with op.batch_alter_table('teams', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('teams_tournament_id_fkey'), type_='foreignkey') - batch_op.create_foreign_key(None, 'tournaments', ['tournament_id'], ['id'], ondelete='CASCADE') - - with op.batch_alter_table('tournaments', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('tournaments_status_id_fkey'), type_='foreignkey') - batch_op.drop_constraint(batch_op.f('tournaments_creator_id_fkey'), type_='foreignkey') - batch_op.create_foreign_key(None, 'tournament_status_options', ['status_id'], ['name'], ondelete='CASCADE') - batch_op.create_foreign_key(None, 'users', ['creator_id'], ['id'], ondelete='CASCADE') - - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('tournaments', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(batch_op.f('tournaments_creator_id_fkey'), 'users', ['creator_id'], ['id']) - batch_op.create_foreign_key(batch_op.f('tournaments_status_id_fkey'), 'tournament_status_options', ['status_id'], ['name']) - - with op.batch_alter_table('teams', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(batch_op.f('teams_tournament_id_fkey'), 'tournaments', ['tournament_id'], ['id']) - - with op.batch_alter_table('team_members', schema=None) as batch_op: - batch_op.drop_constraint('fk_teammember_team', type_='foreignkey') - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(batch_op.f('team_members_tournament_id_fkey'), 'tournaments', ['tournament_id'], ['id']) - - with op.batch_alter_table('tasks', schema=None) as batch_op: - batch_op.drop_constraint('fk_task_tournament', type_='foreignkey') - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(batch_op.f('tasks_status_id_fkey'), 'task_statuses', ['status_id'], ['name']) - - with op.batch_alter_table('task_requirement_options', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(batch_op.f('task_requirement_options_category_id_fkey'), 'task_requirement_categories', ['category_id'], ['name']) - - with op.batch_alter_table('task_requirement_categories', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(batch_op.f('task_requirement_categories_main_id_fkey'), 'task_requirement_categories', ['main_id'], ['name']) - - with op.batch_alter_table('submissions', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(batch_op.f('submissions_team_id_fkey'), 'teams', ['team_id'], ['id']) - - with op.batch_alter_table('submission_urls', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(batch_op.f('submission_urls_submission_id_fkey'), 'submissions', ['submission_id'], ['team_id']) - batch_op.create_foreign_key(batch_op.f('submission_urls_url_id_fkey'), 'submission_url_options', ['url_id'], ['name']) - - with op.batch_alter_table('role_requests', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(batch_op.f('role_requests_user_id_fkey'), 'users', ['user_id'], ['id']) - batch_op.create_foreign_key(batch_op.f('role_requests_role_name_fkey'), 'roles', ['role_name'], ['name']) - - with op.batch_alter_table('role_request_info', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(batch_op.f('role_request_info_request_id_fkey'), 'role_requests', ['request_id'], ['id']) - batch_op.create_foreign_key(batch_op.f('role_request_info_option_name_fkey'), 'role_request_info_options', ['option_name'], ['name']) - - with op.batch_alter_table('requirement_evaluations', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(batch_op.f('requirement_evaluations_evaluation_id_fkey'), 'evaluations', ['evaluation_id'], ['id']) - - with op.batch_alter_table('notifications', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(batch_op.f('notifications_user_id_fkey'), 'users', ['user_id'], ['id']) - batch_op.create_unique_constraint(batch_op.f('notifications_body_key'), ['body'], postgresql_nulls_not_distinct=False) - batch_op.alter_column('user_id', - existing_type=sa.INTEGER(), - nullable=False) - - with op.batch_alter_table('news_categories', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_news_categories_name')) - - with op.batch_alter_table('evaluations', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(batch_op.f('evaluations_submission_id_fkey'), 'submissions', ['submission_id'], ['team_id']) - batch_op.create_foreign_key(batch_op.f('evaluations_jury_id_fkey'), 'users', ['jury_id'], ['id']) - - # ### end Alembic commands ### diff --git a/backend/alembic/versions/39b777378bc8_.py b/backend/alembic/versions/39b777378bc8_.py deleted file mode 100644 index cf4958c..0000000 --- a/backend/alembic/versions/39b777378bc8_.py +++ /dev/null @@ -1,38 +0,0 @@ -"""empty message - -Revision ID: 39b777378bc8 -Revises: 3191704e4d9a -Create Date: 2026-05-04 18:14:02.964785 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '39b777378bc8' -down_revision: Union[str, Sequence[str], None] = '3191704e4d9a' -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('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') - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('tournament_juries') - # ### 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/6150229200ba_merge_two_base_branches.py b/backend/alembic/versions/6150229200ba_merge_two_base_branches.py deleted file mode 100644 index e399996..0000000 --- a/backend/alembic/versions/6150229200ba_merge_two_base_branches.py +++ /dev/null @@ -1,28 +0,0 @@ -"""merge two base branches - -Revision ID: 6150229200ba -Revises: cd57b38a80ee -Create Date: 2026-05-03 14:22:36.700041 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '6150229200ba' -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.""" - pass - - -def downgrade() -> None: - """Downgrade schema.""" - pass diff --git a/backend/alembic/versions/707211cdd0c9_fix_heads.py b/backend/alembic/versions/707211cdd0c9_fix_heads.py deleted file mode 100644 index 40c8adb..0000000 --- a/backend/alembic/versions/707211cdd0c9_fix_heads.py +++ /dev/null @@ -1,28 +0,0 @@ -"""fix_heads - -Revision ID: 707211cdd0c9 -Revises: 395793e08475, 39b777378bc8, d29a9dc3f541 -Create Date: 2026-05-06 18:49:40.087595 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '707211cdd0c9' -down_revision: Union[str, Sequence[str], None] = ('395793e08475', '39b777378bc8', 'd29a9dc3f541') -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/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/alembic/versions/d29a9dc3f541_.py b/backend/alembic/versions/d29a9dc3f541_.py deleted file mode 100644 index 08012dc..0000000 --- a/backend/alembic/versions/d29a9dc3f541_.py +++ /dev/null @@ -1,243 +0,0 @@ -"""empty message - -Revision ID: d29a9dc3f541 -Revises: -Create Date: 2026-04-29 18:07:35.581129 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'd29a9dc3f541' -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.""" - # ### commands auto generated by Alembic - please adjust! ### - 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(length=4096), 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('notifications', - sa.Column('body', sa.String(length=4096), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - 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('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('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('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') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/dfc9ca7d5a57_.py b/backend/alembic/versions/dfc9ca7d5a57_.py deleted file mode 100644 index 0bb9709..0000000 --- a/backend/alembic/versions/dfc9ca7d5a57_.py +++ /dev/null @@ -1,276 +0,0 @@ -"""empty message - -Revision ID: dfc9ca7d5a57 -Revises: 707211cdd0c9 -Create Date: 2026-05-06 20:38:14.616069 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'dfc9ca7d5a57' -down_revision: Union[str, Sequence[str], None] = '707211cdd0c9' -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/app/__init__.py b/backend/app/__init__.py index f904fb8..271253d 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -3,6 +3,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +import app.routes.jury as jury import app.routes.news as news import app.routes.profile as profile import app.routes.role_requests as role_requests @@ -38,6 +39,7 @@ async def lifespan(app: FastAPI): app.state.user_websocket_sessions = {} +app.include_router(jury.router) app.include_router(news.router) app.include_router(profile.router) app.include_router(role_requests.router) diff --git a/backend/app/admin/__init__.py b/backend/app/admin/__init__.py index fb8d850..04278e8 100644 --- a/backend/app/admin/__init__.py +++ b/backend/app/admin/__init__.py @@ -10,14 +10,17 @@ from app.db import engine, AsyncSessionLocal from app.utils import reject_role_request, approve_role_request from app.models import ( + JuryAssignment, Notification, - RequirementEvaluation, + CriterionScore, Role, RoleRequest, Submission, SubmissionEvaluation, SubmissionUrlOption, + SubmissionUrl, Task, + TaskEvaluationCriterion, TaskRequirementCategory, TaskRequirementOption, TaskStatusOption, @@ -197,11 +200,23 @@ class SubmissionUrlOptionAdmin(NamePrimaryKeyAdmin, model=SubmissionUrlOption): pass +class SubmissionUrlAdmin(BaseModelView, model=SubmissionUrl): + pass + + class SubmissionEvaluationAdmin(BaseModelView, model=SubmissionEvaluation): pass -class RequirementEvaluationAdmin(BaseModelView, model=RequirementEvaluation): +class JuryAssignmentAdmin(BaseModelView, model=JuryAssignment): + pass + + +class TaskEvaluationCriterionAdmin(BaseModelView, model=TaskEvaluationCriterion): + pass + + +class CriterionScoreAdmin(BaseModelView, model=CriterionScore): pass @@ -312,14 +327,17 @@ def setup_admin(app): admin.add_view(TournamentStatusOptionAdmin) admin.add_view(TaskAdmin) admin.add_view(TaskStatusOptionAdmin) + admin.add_view(TaskEvaluationCriterionAdmin) admin.add_view(TaskRequirementCategoryAdmin) admin.add_view(TaskRequirementOptionAdmin) admin.add_view(TeamAdmin) admin.add_view(TeamMemberAdmin) admin.add_view(SubmissionAdmin) admin.add_view(SubmissionUrlOptionAdmin) + admin.add_view(SubmissionUrlAdmin) + admin.add_view(JuryAssignmentAdmin) admin.add_view(SubmissionEvaluationAdmin) - admin.add_view(RequirementEvaluationAdmin) + admin.add_view(CriterionScoreAdmin) admin.add_view(NotificationAdmin) admin.add_view(NewsAdmin) admin.add_view(NewsCategoryAdmin) diff --git a/backend/app/config.py b/backend/app/config.py index 294e9b1..2b74e0d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -25,12 +25,6 @@ class OptionConfig(BaseModel): display_name: str -class RequirementOptionConfig(BaseModel): - name: str - display_name: str - category_id: str - - class CategoryConfig(BaseModel): name: str main_id: str | None = None @@ -44,10 +38,10 @@ class SharedAppConfig(BaseModel): roles: list[RoleConfig] tournament_statuses: list[OptionConfig] task_statuses: list[OptionConfig] + jury_assignment_statuses: list[OptionConfig] categories: list[CategoryConfig] role_request_options: list[OptionConfig] news_categories: list[NewsCategoryConfig] - requirement_options: list[RequirementOptionConfig] def load_shared_app_config() -> SharedAppConfig: @@ -127,6 +121,12 @@ def TOURNAMENT_STATUS_OPTIONS(self) -> list[dict[str, Any]]: def TASK_STATUS_OPTIONS(self) -> list[dict[str, Any]]: return [status.model_dump() for status in self.SHARED_APP_CONFIG.task_statuses] + @property + def JURY_ASSIGNMENT_STATUS_OPTIONS(self) -> list[dict[str, Any]]: + return [ + status.model_dump() for status in self.SHARED_APP_CONFIG.jury_assignment_statuses + ] + @property def NEWS_CATEGORY_OPTIONS(self) -> list[dict[str, Any]]: return [ @@ -149,6 +149,10 @@ def TOURNAMENT_STATUS_NAMES(self) -> SimpleNamespace: def TASK_STATUS_NAMES(self) -> SimpleNamespace: return option_names(self.SHARED_APP_CONFIG.task_statuses) + @property + def JURY_ASSIGNMENT_STATUS_NAMES(self) -> SimpleNamespace: + return option_names(self.SHARED_APP_CONFIG.jury_assignment_statuses) + @property def TOURNAMENT_CREATOR_ROLES(self) -> list[str]: return [self.ROLE_NAMES.ADMIN, self.ROLE_NAMES.ORGANIZER] @@ -161,10 +165,6 @@ def ROLE_REQUEST_OPTION_NAMES(self) -> SimpleNamespace: def ROLE_REQUEST_INFO_OPTIONS(self) -> list[dict[str, Any]]: return [opt.model_dump() for opt in self.SHARED_APP_CONFIG.role_request_options] - @property - def REQUIREMENT_OPTIONS(self) -> list[dict[str, Any]]: - return [opt.model_dump() for opt in self.SHARED_APP_CONFIG.requirement_options] - @property def TASK_CATEGORIES(self) -> SimpleNamespace: return option_names(self.SHARED_APP_CONFIG.categories) diff --git a/backend/app/core/seeds/__init__.py b/backend/app/core/seeds/__init__.py index fc90240..26d3d1f 100644 --- a/backend/app/core/seeds/__init__.py +++ b/backend/app/core/seeds/__init__.py @@ -16,4 +16,4 @@ async def init_static_data(session): await init_role_request_options(session) await init_task_requirement_options(session) await init_superuser(session) - await session.commit() + await session.commit() \ No newline at end of file diff --git a/backend/app/core/seeds/task_requirement_options.py b/backend/app/core/seeds/task_requirement_options.py index f861262..cc5962c 100644 --- a/backend/app/core/seeds/task_requirement_options.py +++ b/backend/app/core/seeds/task_requirement_options.py @@ -9,9 +9,7 @@ async def init_task_requirement_options(session: AsyncSession): options = settings.REQUIREMENT_OPTIONS for opt in options: - stmt = select(TaskRequirementOption).where( - TaskRequirementOption.name == opt["name"] - ) + stmt = select(TaskRequirementOption).where(TaskRequirementOption.name == opt["name"]) result = await session.execute(stmt) existing_option = result.scalar_one_or_none() diff --git a/backend/app/dependencies/__init__.py b/backend/app/dependencies/__init__.py index c97aedb..695fb4e 100644 --- a/backend/app/dependencies/__init__.py +++ b/backend/app/dependencies/__init__.py @@ -1,3 +1,4 @@ +from .session import SessionDep, get_session from .admin_user import AdminUserDep, get_admin_user from .current_user import ( CurrentUserDep, @@ -8,5 +9,6 @@ current_user_dependency, ) from .role_request import RoleRequestDep, get_role_request -from .session import SessionDep, get_session 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, 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/current_user.py b/backend/app/dependencies/current_user.py index 9579b8e..19a0085 100644 --- a/backend/app/dependencies/current_user.py +++ b/backend/app/dependencies/current_user.py @@ -2,7 +2,6 @@ from typing import Annotated from fastapi import Depends, HTTPException, status from sqlalchemy import select, or_ -from sqlalchemy.orm import selectinload from fastapi.security import HTTPBearer from firebase_admin import auth from firebase_admin.auth import ( @@ -14,15 +13,13 @@ ) from .session import SessionDep -from app.models import User, Tournament +from app.models import User from app.firebase import firebase async def get_or_create_user_from_token(token: dict, session: SessionDep) -> User: try: - u: auth.UserRecord = await asyncio.to_thread( - auth.get_user_by_email, token["email"] - ) + u: auth.UserRecord = await asyncio.to_thread(auth.get_user_by_email, token["email"]) except auth.UserNotFoundError as e: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Firebase user not found" @@ -45,7 +42,7 @@ async def get_or_create_user_from_token(token: dict, session: SessionDep) -> Use user = User(firebase_uid=u.uid, full_name=u.display_name, email=u.email) session.add(user) await session.commit() - user = await get_user(u.uid, session) + await session.refresh(user) return user @@ -60,7 +57,6 @@ async def get_current_user( else: token = auth.verify_id_token(token, firebase) user = await get_or_create_user_from_token(token, session) - user = await get_user(user.id, session) except ( ValueError, InvalidIdTokenError, @@ -86,15 +82,7 @@ async def get_user(identifier: str | int, session: SessionDep): statement = select(User).where(User.id == identifier) else: raise ValueError("Wrong user identifier type") - - statement = statement.options( - selectinload(User.roles), - selectinload(User.notifications), - selectinload(User.created_tournaments).selectinload(Tournament.juries), - selectinload(User.created_tournaments).selectinload(Tournament.status), - selectinload(User.evaluates_in), - ) - user = (await session.execute(statement)).scalar_one_or_none() + user = (await session.execute(statement)).scalar() if not user: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found!") diff --git a/backend/app/dependencies/jury.py b/backend/app/dependencies/jury.py new file mode 100644 index 0000000..867f93e --- /dev/null +++ b/backend/app/dependencies/jury.py @@ -0,0 +1,51 @@ +from .current_user import CurrentUserDep +from .session import SessionDep +from app.models import User, Task, Tournament +from fastapi import HTTPException, status, Depends +from app.config import settings +from app.utils import get_task, get_tournament + +# Permission dependencies +async def get_organizer_or_admin( + tournament_id: int, current_user: CurrentUserDep, session: SessionDep +) -> User: + from app.utils import get_tournament + + tournament = await get_tournament(tournament_id, session) + if tournament.creator_id != current_user.id and not current_user.is_admin: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Permission denied") + return current_user + + +async def get_assigned_jury( + assignment_id: int, current_user: CurrentUserDep, session: SessionDep +) -> User: + from app.utils import get_assignment + + assignment = await get_assignment(assignment_id, session) + if assignment.jury_id != current_user.id and not current_user.is_admin: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Permission denied") + return current_user + +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( + status.HTTP_400_BAD_REQUEST, + detail="The submissions are still open to be received!", + ) + 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) +closed_submission_task_dependency = Depends(get_closed_submission_task) +non_finished_tournament_dependency = Depends(get_non_finished_tournament) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ff90ef8..7923bb3 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,11 +1,22 @@ from .base import Base -from .evaluation import SubmissionEvaluation, RequirementEvaluation +from .evaluation import ( + JuryAssignment, + JuryAssignmentStatusOption, + CriterionScore, + SubmissionEvaluation, +) from .news import News, NewsCattegory from .notification import Notification from .role_request import RoleRequest, RoleRequestInfo, RoleRequestInfoOption from .role import Role from .submission import Submission, SubmissionUrl, SubmissionUrlOption -from .task import Task, TaskRequirementCategory, TaskRequirementOption, TaskStatusOption +from .task import ( + Task, + TaskEvaluationCriterion, + TaskRequirementCategory, + TaskRequirementOption, + TaskStatusOption, +) from .team import Team, TeamMember from .tournament import Tournament, TournamentStatusOption from .user import User diff --git a/backend/app/models/evaluation.py b/backend/app/models/evaluation.py index c0517f8..2b5158c 100644 --- a/backend/app/models/evaluation.py +++ b/backend/app/models/evaluation.py @@ -1,60 +1,102 @@ -from sqlalchemy import ForeignKey, Table, Column, UniqueConstraint -from sqlalchemy.orm import mapped_column, Mapped, relationship + +from sqlalchemy import ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship from .base import Base -from .mixin import PKMixin - -evaluation_requirements = Table( - "evaluation_requirements", - Base.metadata, - Column( - "requirement_evaluation_id", - ForeignKey("requirement_evaluations.id", ondelete="CASCADE"), - primary_key=True, - ), - Column( - "requirement_id", - ForeignKey("task_requirement_options.name", ondelete="CASCADE"), - primary_key=True, - ), -) - - -class SubmissionEvaluation(Base, PKMixin): - __tablename__ = "evaluations" +from .mixin import PKMixin, DatetimeMixin, OptionMixin + + +class JuryAssignmentStatusOption(Base, OptionMixin): + __tablename__ = "jury_assignment_statuses" + jury_assignments: Mapped[list["JuryAssignment"]] = relationship( + back_populates="status", lazy="selectin", cascade="all, delete-orphan" + ) + + def __repr__(self): + return ( + f"" + ) + + +class JuryAssignment(Base, PKMixin, DatetimeMixin): + __tablename__ = "jury_assignments" __table_args__ = (UniqueConstraint("submission_id", "jury_id"),) + + task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE")) submission_id: Mapped[int] = mapped_column( - ForeignKey("submissions.team_id", ondelete="CASCADE"), nullable=False + ForeignKey("submissions.id", ondelete="CASCADE"), nullable=False + ) + jury_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + status_id: Mapped[str] = mapped_column( + ForeignKey("jury_assignment_statuses.name", ondelete="CASCADE"), + default="assigned", + ) + status: Mapped["JuryAssignmentStatusOption"] = relationship( + back_populates="jury_assignments", lazy="selectin" + ) + + task: Mapped["Task"] = relationship(back_populates="jury_assignments", lazy="selectin") + submission: Mapped["Submission"] = relationship( + back_populates="assignments", lazy="selectin" + ) + jury: Mapped["User"] = relationship(lazy="selectin") + evaluation: Mapped["SubmissionEvaluation"] = relationship( + back_populates="assignment", + lazy="selectin", + cascade="all, delete-orphan", + uselist=False, + ) + + def __repr__(self): + return ( + f"" + ) + + +class SubmissionEvaluation(Base, PKMixin, DatetimeMixin): + __tablename__ = "evaluations" + __table_args__ = (UniqueConstraint("jury_id", "submission_id"),) + + assignment_id: Mapped[int] = mapped_column( + ForeignKey("jury_assignments.id", ondelete="CASCADE"), nullable=False + ) + submission_id: Mapped[int] = mapped_column( + ForeignKey("submissions.id", ondelete="CASCADE"), nullable=False ) jury_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) + comment: Mapped[str | None] = mapped_column(nullable=True) + assignment: Mapped["JuryAssignment"] = relationship( + back_populates="evaluation", lazy="selectin" + ) submission: Mapped["Submission"] = relationship( back_populates="evaluations", lazy="selectin" ) jury: Mapped["User"] = relationship(lazy="selectin") - requirement_evaluations: Mapped[list["RequirementEvaluation"]] = relationship( + criterion_scores: Mapped[list["CriterionScore"]] = relationship( back_populates="evaluation", lazy="selectin", cascade="all, delete-orphan" ) - def __repr__(self): - return f"" +class CriterionScore(Base, PKMixin): + __tablename__ = "criterion_scores" + __table_args__ = (UniqueConstraint("evaluation_id", "criterion_id"),) -class RequirementEvaluation(Base, PKMixin): - __tablename__ = "requirement_evaluations" evaluation_id: Mapped[int] = mapped_column( ForeignKey("evaluations.id", ondelete="CASCADE"), nullable=False ) - evaluation: Mapped["SubmissionEvaluation"] = relationship( - back_populates="requirement_evaluations", lazy="selectin" + criterion_id: Mapped[int] = mapped_column( + ForeignKey("task_evaluation_criteria.id", ondelete="CASCADE"), nullable=False ) score: Mapped[int] - requirement: Mapped[list["TaskRequirementOption"]] = relationship( - secondary=evaluation_requirements, lazy="selectin" - ) - def __repr__(self): - requirement_count = len(self.requirement) if "requirement" in self.__dict__ else "?" - return f"" + evaluation: Mapped["SubmissionEvaluation"] = relationship( + back_populates="criterion_scores", lazy="selectin" + ) + criterion: Mapped["TaskEvaluationCriterion"] = relationship( + back_populates="criterion_scores", lazy="selectin" + ) diff --git a/backend/app/models/submission.py b/backend/app/models/submission.py index 2cd73b7..56542bc 100644 --- a/backend/app/models/submission.py +++ b/backend/app/models/submission.py @@ -1,53 +1,58 @@ 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( back_populates="submission", lazy="selectin", cascade="all, delete-orphan" ) evaluations: Mapped[list["SubmissionEvaluation"]] = relationship( + back_populates="submission", cascade="all, delete-orphan" + ) + assignments: Mapped[list["JuryAssignment"]] = relationship( back_populates="submission", cascade="all, delete-orphan", lazy="selectin" ) 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] - submission: Mapped["Submission"] = relationship( - back_populates="urls", lazy="selectin" - ) + submission: Mapped["Submission"] = relationship(back_populates="urls", lazy="selectin") url: Mapped["SubmissionUrlOption"] = relationship(lazy="selectin") def __repr__(self): - return ( - f"" - ) + return f"" class SubmissionUrlOption(Base, OptionMixin): __tablename__ = "submission_url_options" def __repr__(self): - return ( - f"" - ) + return f"" diff --git a/backend/app/models/task.py b/backend/app/models/task.py index bad5c2a..17cd09e 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -3,6 +3,7 @@ from .base import Base from .mixin import PKMixin, OptionMixin +from .evaluation import JuryAssignment from datetime import datetime task_requirements = Table( @@ -24,6 +25,9 @@ class Task(Base, PKMixin): description: Mapped[str] = mapped_column(nullable=True) start_time: Mapped[datetime] end_time: Mapped[datetime] + min_reviews_per_submission: Mapped[int] = mapped_column(default=1) + max_score: Mapped[int] = mapped_column(default=100) + is_leaderboard_visible: Mapped[bool] = mapped_column(default=False) tournament_id: Mapped[int] = mapped_column( ForeignKey( "tournaments.id", @@ -45,6 +49,12 @@ class Task(Base, PKMixin): requirements: Mapped[list["TaskRequirementOption"]] = relationship( secondary=task_requirements, lazy="selectin" ) + criteria: Mapped[list["TaskEvaluationCriterion"]] = relationship( + back_populates="task", lazy="selectin", cascade="all, delete-orphan" + ) + jury_assignments: Mapped[list["JuryAssignment"]] = relationship( + back_populates="task", lazy="selectin", cascade="all, delete-orphan" + ) def __repr__(self): return f"" @@ -95,3 +105,20 @@ class TaskRequirementCategory(Base, OptionMixin): def __repr__(self): return f"" + + +class TaskEvaluationCriterion(Base, PKMixin): + __tablename__ = "task_evaluation_criteria" + + task_id: Mapped[int] = mapped_column( + ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] + description: Mapped[str | None] = mapped_column(nullable=True) + weight: Mapped[int] = mapped_column(default=1) + max_score: Mapped[int] = mapped_column(default=10) + + task: Mapped["Task"] = relationship(back_populates="criteria", lazy="selectin") + criterion_scores: Mapped[list["CriterionScore"]] = relationship( + back_populates="criterion", lazy="selectin" + ) diff --git a/backend/app/models/team.py b/backend/app/models/team.py index 196e653..01ef071 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -19,9 +19,7 @@ class Team(Base, PKMixin): ForeignKey("team_members.id", ondelete="CASCADE"), nullable=True ) - tournament: Mapped["Tournament"] = relationship( - back_populates="teams", lazy="selectin" - ) + tournament: Mapped["Tournament"] = relationship(back_populates="teams", lazy="selectin") members: Mapped[list["TeamMember"]] = relationship( back_populates="team", foreign_keys="TeamMember.team_id", @@ -29,10 +27,10 @@ class Team(Base, PKMixin): lazy="selectin", ) captain: Mapped["TeamMember"] = relationship( - "TeamMember", foreign_keys="Team.captain_id", lazy="selectin", post_update=True + "TeamMember", foreign_keys="Team.captain_id", post_update=True ) - submission: Mapped["Submission"] = relationship( + submissions: Mapped[list["Submission"]] = relationship( back_populates="team", cascade="all, delete-orphan" ) @@ -48,9 +46,7 @@ class TeamMember(Base, PKMixin): telegram: Mapped[str] = mapped_column(nullable=False) educational_institution: Mapped[Optional[str]] = mapped_column(nullable=True) team_id: Mapped[int] = mapped_column( - ForeignKey( - "teams.id", use_alter=True, name="fk_teammember_team", ondelete="CASCADE" - ) + ForeignKey("teams.id", use_alter=True, name="fk_teammember_team", ondelete="CASCADE") ) # This field exists so we can impose contraints related to it tournament_id: Mapped[int] = mapped_column( @@ -59,9 +55,7 @@ class TeamMember(Base, PKMixin): __table_args__ = ( UniqueConstraint("tournament_id", "email", name="uq_tournament_member_email"), - UniqueConstraint( - "tournament_id", "telegram", name="uq_tournament_member_telegram" - ), + UniqueConstraint("tournament_id", "telegram", name="uq_tournament_member_telegram"), UniqueConstraint("team_id", "email", name="uq_team_member_email"), UniqueConstraint("team_id", "telegram", name="uq_team_member_telegram"), ) diff --git a/backend/app/models/tournament.py b/backend/app/models/tournament.py index a0d5c30..033b7ba 100644 --- a/backend/app/models/tournament.py +++ b/backend/app/models/tournament.py @@ -6,6 +6,8 @@ from .base import Base from .mixin import PKMixin, OptionMixin from .task import Task +from .team import Team +from .submission import Submission tournament_juries = Table( "tournament_juries", @@ -40,6 +42,14 @@ class Tournament(Base, PKMixin): teams: Mapped[list["Team"]] = relationship( back_populates="tournament", lazy="selectin", cascade="all, delete-orphan" ) + submissions: Mapped[list["Submission"]] = relationship( + "Submission", + secondary=lambda: Team.__table__, + primaryjoin=lambda: Tournament.id == Team.tournament_id, + secondaryjoin=lambda: Team.id == Submission.team_id, + viewonly=True, + lazy="selectin", + ) tasks: Mapped[list["Task"]] = relationship( "Task", back_populates="tournament", diff --git a/backend/app/models/user.py b/backend/app/models/user.py index d30a3bf..2502ec8 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -64,15 +64,33 @@ class User(Base, PKMixin): lazy="selectin", ) - @property + @hybrid_property def is_admin(self) -> bool: - return any(role.name == settings.ROLE_NAMES.ADMIN for role in self.roles) + if "roles" in self.__dict__: + return any(role.name == settings.ROLE_NAMES.ADMIN for role in self.roles) + return False + + @is_admin.expression + def is_admin(cls): + return ( + select(user_roles.c.role_name) + .where(user_roles.c.user_id == cls.id, user_roles.c.role_name == settings.ROLE_NAMES.ADMIN) + ) is not None - @property + @hybrid_property def is_organizer(self) -> bool: if self.is_admin: return True - return any(role.name == settings.ROLE_NAMES.ORGANIZER for role in self.roles) + if "roles" in self.__dict__: + return any(role.name == settings.ROLE_NAMES.ORGANIZER for role in self.roles) + return False + + @is_organizer.expression + def is_organizer(cls): + return ( + select(user_roles.c.role_name) + .where(user_roles.c.user_id == cls.id, user_roles.c.role_name == settings.ROLE_NAMES.ORGANIZER) + ) is not None @hybrid_property def is_jury(self) -> bool: diff --git a/backend/app/routes/jury.py b/backend/app/routes/jury.py new file mode 100644 index 0000000..67e5255 --- /dev/null +++ b/backend/app/routes/jury.py @@ -0,0 +1,413 @@ +import random + +from fastapi import APIRouter, HTTPException, status +from sqlalchemy import select, update +from sqlalchemy.orm import selectinload +from app.config import settings + +from app.dependencies import ( + CurrentUserDep, + SessionDep, + current_user_dependency, + organizer_or_admin_dependency, + assigned_jury_dependency, + closed_submission_task_dependency, + get_organizer_or_admin, + get_non_finished_tournament, + non_finished_tournament_dependency +) +from app.models import ( + JuryAssignment, + CriterionScore, + Submission, + SubmissionEvaluation, + SubmissionUrl, + Task, + Team, +) +from app.schemas import ( + EvaluationLeaderboardEntry, + JuryAssignmentPublic, + JuryAssignmentPublic, + SubmissionEvaluationCreate, + SubmissionEvaluationUpdate, + SubmissionEvaluationPublic, + TaskPublic, + TournamentPublic +) +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"]) + +@router.get( + "/jury/tasks/", response_model=list[TaskPublic], dependencies=[current_user_dependency] +) +async def jury_tasks(current_user: CurrentUserDep, session: SessionDep): + statement = ( + select(Task) + .join(Task.jury_assignments) + .where( + JuryAssignment.jury_id == current_user.id, + Task.status_id == settings.TASK_STATUS_NAMES.SUBMISSION_CLOSED, + ) + .options(selectinload(Task.criteria)) + .distinct() + ) + return (await session.execute(statement)).scalars().unique().all() + + +@router.get( + "/jury/tasks/{task_id}/assignments/", + response_model=list[JuryAssignmentPublic], + dependencies=[closed_submission_task_dependency], +) +async def jury_task_assignments( + task_id: int, current_user: CurrentUserDep, session: SessionDep +): + statement = ( + select(JuryAssignment) + .join(JuryAssignment.task) + .where( + JuryAssignment.task_id == task_id, + JuryAssignment.jury_id == current_user.id, + Task.status_id == settings.TASK_STATUS_NAMES.SUBMISSION_CLOSED + ) + .options( + selectinload(JuryAssignment.status), + selectinload(JuryAssignment.task).selectinload(Task.criteria), + selectinload(JuryAssignment.submission) + .selectinload(Submission.team) + .selectinload(Team.members), + selectinload(JuryAssignment.submission) + .selectinload(Submission.team) + .selectinload(Team.tournament), + selectinload(JuryAssignment.submission) + .selectinload(Submission.urls) + .selectinload(SubmissionUrl.url), + selectinload(JuryAssignment.evaluation).selectinload( + SubmissionEvaluation.criterion_scores + ), + ) + ) + return (await session.execute(statement)).scalars().unique().all() + + +@router.get( + "/jury/assignments/{assignment_id}/", + response_model=JuryAssignmentPublic, + dependencies=[current_user_dependency, assigned_jury_dependency], +) +async def jury_assignment_detail(assignment_id: int, session: SessionDep): + assignment = await get_assignment(assignment_id, session) + if assignment.task.status_id != settings.TASK_STATUS_NAMES.SUBMISSION_CLOSED: + raise HTTPException(status.HTTP_400_NOT_FOUND, detail="Task of this assignment still accepts submissions") + return assignment + + +@router.post( + "/jury/assignments/{assignment_id}/evaluation/", + response_model=SubmissionEvaluationPublic, + status_code=status.HTTP_201_CREATED, + dependencies=[current_user_dependency, assigned_jury_dependency], +) +async def create_evaluation( + assignment_id: int, + payload: SubmissionEvaluationCreate, + current_user: CurrentUserDep, + session: SessionDep, +): + assignment = await get_assignment(assignment_id, session) + if assignment.evaluation is not None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Evaluation already exists") + + criteria = get_criterion_map(assignment.task) + evaluation = SubmissionEvaluation( + assignment_id=assignment.id, + submission_id=assignment.submission_id, + jury_id=current_user.id, + comment=payload.comment, + ) + for item in payload.criterion_scores: + criterion = criteria.get(item.criterion_id) + if criterion is None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Criterion not found") + if item.score > criterion.max_score: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Score for criterion {criterion.name} exceeds max score", + ) + evaluation.criterion_scores.append(CriterionScore(**item.model_dump())) + + assignment.status_id = settings.JURY_ASSIGNMENT_STATUS_NAMES.SUBMITTED + session.add(evaluation) + await session.commit() + await session.refresh(evaluation) + return evaluation + + +@router.patch( + "/jury/assignments/{assignment_id}/evaluation/", + response_model=SubmissionEvaluationPublic, + dependencies=[current_user_dependency, assigned_jury_dependency], +) +async def update_evaluation( + assignment_id: int, + update_data: SubmissionEvaluationUpdate, + session: SessionDep, +): + payload = update_data.model_dump(exclude_unset=True) + assignment = await get_assignment(assignment_id, session) + if assignment.evaluation is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Evaluation not found") + + criterions = 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: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Score for criterion {criterion.name} exceeds max score", + ) + criterion.score = item["score"] + + if payload: + result = await session.execute( + update(SubmissionEvaluation) + .where(SubmissionEvaluation.id == assignment.evaluation.id) + .values(**payload) + .returning(SubmissionEvaluation) + ) + evaluation = result.scalar_one_or_none() + if not evaluation: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Evaluation not found" + ) + await session.commit() + await session.refresh(assignment.evaluation) + return assignment.evaluation + + +@router.post( + "/tournaments/{tournament_id}/tasks/{task_id}/assignments/generate/", + response_model=list[JuryAssignmentPublic], + dependencies=[current_user_dependency, organizer_or_admin_dependency, closed_submission_task_dependency], +) +async def generate_jury_assignments( + tournament_id: int, + task_id: int, + session: SessionDep, +): + task = await get_task_by_tournament(tournament_id, task_id, session) + tournament = task.tournament + + juries = list(tournament.juries) + submissions = ( + ( + await session.execute( + select(Submission) + .join(Submission.team) + .where(Submission.task_id == task.id, Team.tournament_id == tournament.id) + .options(selectinload(Submission.team)) + ) + ) + .scalars() + .unique() + .all() + ) + if not submissions: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="No submissions to assign") + if len(juries) < task.min_reviews_per_submission: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="Not enough juries to satisfy minimum reviews per submission", + ) + + existing = ( + ( + await session.execute( + select(JuryAssignment).where(JuryAssignment.task_id == task.id) + ) + ) + .scalars() + .all() + ) + if existing: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="Assignments already generated for this task", + ) + + jury_loads = {jury.id: 0 for jury in juries} + created = [] + randomized_submissions = list(submissions) + random.shuffle(randomized_submissions) + for submission in randomized_submissions: + available = sorted( + juries, + key=lambda jury: (jury_loads[jury.id], random.random()), + ) + selected = available[:task.min_reviews_per_submission] + for jury in selected: + assignment = JuryAssignment( + task_id=task.id, + submission_id=submission.id, + jury_id=jury.id, + status_id=settings.JURY_ASSIGNMENT_STATUS_NAMES.ASSIGNED, + ) + session.add(assignment) + created.append(assignment) + jury_loads[jury.id] += 1 + + await session.commit() + + # Refresh created assignments with status and evaluation relationships + created_ids = [a.id for a in created] + statement = ( + select(JuryAssignment) + .where(JuryAssignment.id.in_(created_ids)) + .options(selectinload(JuryAssignment.status), selectinload(JuryAssignment.evaluation)) + ) + result = (await session.execute(statement)).scalars().unique().all() + return result + + +@router.get( + "/tournaments/{tournament_id}/tasks/{task_id}/assignments/", + response_model=list[JuryAssignmentPublic], + dependencies=[current_user_dependency, organizer_or_admin_dependency, closed_submission_task_dependency], +) +async def get_task_assignments( + tournament_id: int, + task_id: int, + session: SessionDep, +): + task = await get_task_by_tournament(tournament_id, task_id, session) + statement = ( + select(JuryAssignment) + .where(JuryAssignment.task_id == task.id) + .options( + selectinload(JuryAssignment.status), + selectinload(JuryAssignment.task).selectinload(Task.criteria), + selectinload(JuryAssignment.submission) + .selectinload(Submission.team) + .selectinload(Team.members), + selectinload(JuryAssignment.submission) + .selectinload(Submission.team) + .selectinload(Team.tournament), + selectinload(JuryAssignment.submission) + .selectinload(Submission.urls) + .selectinload(SubmissionUrl.url), + selectinload(JuryAssignment.evaluation).selectinload( + SubmissionEvaluation.criterion_scores + ), + ) + ) + return (await session.execute(statement)).scalars().unique().all() + + +@router.post( + "/tournaments/{tournament_id}/tasks/{task_id}/finish-evaluation/", + response_model=TaskPublic, + dependencies=[closed_submission_task_dependency], +) +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) + 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() + ) + 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() + ) + + return await finish_evaluation(task, jury_assignmnents, session) + + + +@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/", + response_model=list[EvaluationLeaderboardEntry], +) +async def task_leaderboard(tournament_id: int, task_id: int, session: SessionDep): + task = await get_task_by_tournament(tournament_id, task_id, session) + tournament = task.tournament + if ( + not task.is_leaderboard_visible + or tournament.status_id != settings.TOURNAMENT_STATUS_NAMES.FINISHED + ): + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Leaderboard is hidden") + return await get_task_leaderboard(task.id, session) + + +@router.get( + "/tournaments/{tournament_id}/leaderboard/", + response_model=list[EvaluationLeaderboardEntry], +) +async def tournament_leaderboard(tournament_id: int, session: SessionDep): + tournament = await get_tournament(tournament_id, session) + team_scores: dict[int, EvaluationLeaderboardEntry] = {} + for task in tournament.tasks: + if task.status_id != settings.TASK_STATUS_NAMES.EVALUATED: + continue + for entry in await 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.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 c79b49d..3a43531 100644 --- a/backend/app/routes/tasks.py +++ b/backend/app/routes/tasks.py @@ -3,11 +3,10 @@ from sqlalchemy import select from app.config import settings -from app.dependencies import SessionDep, TournamentOwnerDep, TaskOwnerDep -from app.models import Task, Tournament -from app.schemas import TaskCreate, TaskUpdate, TaskPublic -from app.utils import TaskStatus, update_tasks_status -from app.utils import get_requirements, get_task +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 router = APIRouter(prefix="/tournaments/{tournament_id}/tasks", tags=["tasks"]) @@ -43,16 +42,38 @@ async def task(tournament_id: int, task_id: int, session: SessionDep): status_code=status.HTTP_201_CREATED, ) async def create_task(tournament_id: int, task_data: TaskCreate, session: SessionDep): - task_dict = task_data.model_dump(exclude={"requirements"}) + task_dict = task_data.model_dump(exclude={"requirements", "criteria"}) + + if task_dict.get("start_time"): + task_dict["start_time"] = task_dict["start_time"].replace(tzinfo=None) + if task_dict.get("end_time"): + task_dict["end_time"] = task_dict["end_time"].replace(tzinfo=None) + new_task = Task( **task_dict, tournament_id=tournament_id, status_id=settings.TASK_STATUS_NAMES.DRAFT, ) + requirements = await get_requirements(task_data.requirements, session) new_task.requirements = requirements session.add(new_task) + await session.flush() + + await session.refresh(new_task, ["criteria"]) + + for crit_data in task_data.criteria: + new_task.criteria.append( + TaskEvaluationCriterion( + task_id=new_task.id, + name=crit_data.name, + description=crit_data.description, + weight=crit_data.weight, + max_score=crit_data.max_score, + ) + ) + await session.commit() await session.refresh(new_task) return new_task @@ -75,19 +96,43 @@ async def update_task( detail="Task does not belong to this tournament", ) - update_data = task_data.model_dump(exclude_unset=True, exclude={"requirements"}) + update_data = task_data.model_dump( + exclude_unset=True, exclude={"requirements", "criteria"} + ) - if not update_data and task_data.requirements is None: + if not update_data and task_data.requirements is None and task_data.criteria is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="No fields provided for update", ) + if update_data.get("start_time"): + update_data["start_time"] = update_data["start_time"].replace(tzinfo=None) + if update_data.get("end_time"): + update_data["end_time"] = update_data["end_time"].replace(tzinfo=None) + for key, value in update_data.items(): setattr(task, key, value) if task_data.requirements is not None: task.requirements = await get_requirements(task_data.requirements, session) + + if task_data.criteria is not None: + await session.refresh(task, ["criteria"]) + for crit in list(task.criteria): + await session.delete(crit) + + for crit_data in task_data.criteria: + task.criteria.append( + TaskEvaluationCriterion( + task_id=task.id, + name=crit_data.name, + description=crit_data.description, + weight=crit_data.weight, + max_score=crit_data.max_score, + ) + ) + TaskStatus(task).update_by_time() await session.commit() @@ -103,3 +148,30 @@ async def update_task( async def delete_task(session: SessionDep, task: TaskOwnerDep): await session.delete(task) await session.commit() + + +@router.post( + "/{task_id}/submissions/", + response_model=SubmissionModel, + status_code=status.HTTP_201_CREATED, +) +async def create_submission( + tournament_id: int, task_id: int, submission_data: SubmissionCreate, session: SessionDep +): + 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() + + for item in submission_data.urls: + session.add(SubmissionUrl( + submission_id=new_submission.id, + url_id=item.url_id, + value=item.value, + )) + + await session.commit() + await session.refresh(new_submission, ["urls", "team"]) + + return new_submission \ No newline at end of file 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/routes/tournaments.py b/backend/app/routes/tournaments.py index a908f2a..cd3c36d 100644 --- a/backend/app/routes/tournaments.py +++ b/backend/app/routes/tournaments.py @@ -15,8 +15,10 @@ get_user, ) from app.utils import get_tournament, tournament_load_options, get_status_by_name +from app.dependencies import get_user, current_user_dependency from app.schemas import SubmissionPublic + router = APIRouter(prefix="/tournaments", tags=["tournaments"]) @@ -33,7 +35,6 @@ async def tournaments(session: SessionDep): async def tournament(tournament_id: int, session: SessionDep): return await get_tournament(tournament_id, session) - @router.get( "/{tournament_id}/submissions", response_model=list[SubmissionPublic], diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 28ef2c6..9e9867a 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,12 +1,35 @@ -from .evaluation import RequirementEvaluationModel, SubmissionEvaluationModel +from .evaluation import ( + CriterionScoreCreate, + CriterionScorePublic, + EvaluationLeaderboardEntry, + FinishEvaluationResponse, + JuryAssignmentPublic, + JuryAssignmentPublic, + SubmissionEvaluationCreate, + SubmissionEvaluationPublic, + SubmissionEvaluationUpdate, +) from .news import NewsPublic from .notification import NotificationPublic, NotificationCreate from .option import OptionPublic, OptionUpdate from .role_request import RoleRequestPublic, RoleRequestCreate from .role import RoleCreate, RolePublic, RoleUpdate -from .submission import SubmissionUrlOptionModel, SubmissionUrlModel, SubmissionPublic +from .submission import ( + SubmissionCreateUrl, + SubmissionCreate, + SubmissionModel, + SubmissionPublic, + SubmissionUrlModel, +) from .task_options import TaskRequirementOptionCreate, TaskRequirementOptionPublic -from .task import TaskBase, TaskCreate, TaskUpdate, TaskPublic +from .task import ( + TaskBase, + TaskCreate, + TaskEvaluationCriterionCreate, + TaskEvaluationCriterionPublic, + TaskPublic, + TaskUpdate, +) from .team import ( TeamCreate, TeamPublic, @@ -23,6 +46,7 @@ TournamentStatusOptionModel, TournamentPublicMinimal, ) + from .user import ( UserModel, UserPublic, @@ -31,7 +55,6 @@ CurrentUser, UserMinimalPublic, ) - UserPublic.model_rebuild() UserMinimalPublic.model_rebuild() CurrentUser.model_rebuild() @@ -42,3 +65,4 @@ TournamentUpdate.model_rebuild() TournamentPublicMinimal.model_rebuild() TournamentPublic.model_rebuild() +TournamentPublicMinimal.model_rebuild() diff --git a/backend/app/schemas/evaluation.py b/backend/app/schemas/evaluation.py index cdfe16e..eb62996 100644 --- a/backend/app/schemas/evaluation.py +++ b/backend/app/schemas/evaluation.py @@ -1,14 +1,82 @@ -from pydantic import BaseModel, Field, ConfigDict +from datetime import datetime +from pydantic import BaseModel, ConfigDict, Field -class RequirementEvaluationModel(BaseModel): +from .submission import SubmissionPublic +from .task import TaskPublic +from .datetime import DatetimePublic + + +class CriterionScoreBase(BaseModel): model_config = ConfigDict(from_attributes=True) + criterion_id: int = Field(..., gt=0) score: int = Field(..., ge=0) -class SubmissionEvaluationModel(BaseModel): +class CriterionScoreCreate(CriterionScoreBase): + pass + + +class CriterionScorePublic(CriterionScoreBase): + id: int + + +class SubmissionEvaluationBase(BaseModel): + comment: str | None = None + + +class SubmissionEvaluationCreate(SubmissionEvaluationBase): + criterion_scores: list[CriterionScoreCreate] = Field([], min_length=1) + + +class SubmissionEvaluationUpdate(SubmissionEvaluationBase): + comment: str | None = None + criterion_scores: list[CriterionScoreCreate] = Field([], min_length=1) + + +class SubmissionEvaluationPublic(SubmissionEvaluationBase): + id: int + assignment_id: int + submission_id: int + jury_id: int + comment: str | None + created_at: datetime | None + criterion_scores: list[CriterionScorePublic] = Field(default_factory=list) + + +class JuryAssignmentStatusPublic(BaseModel): + model_config = ConfigDict(from_attributes=True) + + name: str + display_name: str + + +class JuryAssignmentBase(BaseModel): model_config = ConfigDict(from_attributes=True) - submission_id: int = Field(..., gt=0) - jury_id: int = Field(..., gt=0) + id: int + task_id: int + submission_id: int + jury_id: int + status_id: str + + +class JuryAssignmentPublic(JuryAssignmentBase): + status: JuryAssignmentStatusPublic + submission: SubmissionPublic + task: TaskPublic + evaluation: SubmissionEvaluationPublic | None = None + + +class EvaluationLeaderboardEntry(BaseModel): + submission_id: int + team_id: int + team_name: str + average_score: float + total_score: float + + +class FinishEvaluationResponse(BaseModel): + task_id: int + status_id: str diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index 1a88d66..7fd0b54 100644 --- a/backend/app/schemas/notification.py +++ b/backend/app/schemas/notification.py @@ -8,11 +8,11 @@ class NotificationBase(BaseModel): user_id: int | None = Field(None, description="Notification receiver id") -from .user import UserMinimalPublic +from .user import UserPublic class NotificationPublic(NotificationBase): - user: UserMinimalPublic | None = Field(None, description="Notification receiver") + user: UserPublic | None = Field(None, description="Notification receiver") class NotificationCreate(NotificationBase): diff --git a/backend/app/schemas/submission.py b/backend/app/schemas/submission.py index 618ea52..53856d4 100644 --- a/backend/app/schemas/submission.py +++ b/backend/app/schemas/submission.py @@ -12,15 +12,28 @@ class SubmissionUrlModel(BaseModel): model_config = ConfigDict(from_attributes=True) url_id: str = Field(..., description="ID of the URL option") - url: SubmissionUrlOptionModel | None + url: SubmissionUrlOptionModel | None = None + value: str | None = None -class SubmissionBase(BaseModel): +class SubmissionCreateUrl(BaseModel): + url_id: str + value: str + + +class SubmissionCreate(BaseModel): + team_id: int + urls: list[SubmissionCreateUrl] + + +class SubmissionModel(BaseModel): model_config = ConfigDict(from_attributes=True) + id: int = Field(...) team_id: int = Field(...) + task_id: int = Field(...) + team: TeamPublic | None = None + urls: list[SubmissionUrlModel] = Field(default_factory=list) -class SubmissionPublic(SubmissionBase): - team: TeamPublic - urls: list[SubmissionUrlModel] = Field(default_factory=list) \ No newline at end of file +SubmissionPublic = SubmissionModel diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index d6df787..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: @@ -33,7 +33,32 @@ class TaskBase(BaseModel): requirements: list[str] = Field(...) +class TaskEvaluationCriterionCreate(BaseModel): + name: str + description: str | None = None + weight: int = 1 + max_score: int = 10 + + class TaskCreate(TaskBase): + criteria: list[TaskEvaluationCriterionCreate] = [] + + @field_validator("start_time") + @classmethod + def start_not_past(cls, value: datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + + if value < datetime.now(timezone.utc): + raise ValueError("Task cannot start in the past") + return value + + @field_validator("end_time") + @classmethod + def end_make_aware(cls, value: datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value @model_validator(mode="after") def check_time_logic(self) -> Self: @@ -48,13 +73,18 @@ class TaskUpdate(BaseModel): start_time: NaiveDatetime | None = None end_time: NaiveDatetime | None = None requirements: list[str] | None = None + criteria: list[TaskEvaluationCriterionCreate] | None = None - @model_validator(mode="after") - def check_update_dates(self) -> Self: - if self.start_time and self.end_time: - if self.end_time <= self.start_time: - raise ValueError("end_time must be later than start_time") - return self + +class TaskEvaluationCriterionPublic(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + task_id: int + name: str + description: str | None + weight: int + max_score: int class TaskPublic(TaskBase): @@ -63,6 +93,8 @@ 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") @classmethod diff --git a/backend/app/schemas/team.py b/backend/app/schemas/team.py index 7211513..3a2c600 100644 --- a/backend/app/schemas/team.py +++ b/backend/app/schemas/team.py @@ -2,18 +2,22 @@ from typing import TYPE_CHECKING -from pydantic import BaseModel, Field, EmailStr, field_validator +from pydantic import BaseModel, Field, EmailStr, field_validator, ConfigDict from pydantic_extra_types.phone_numbers import PhoneNumber +from .tournament import TournamentPublicMinimal + if TYPE_CHECKING: - from .tournament import TournamentPublicMinimal + from .tournament import TournamentPublic class TeamMemberBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + full_name: str = Field(..., min_length=3) email: EmailStr = Field(..., description="Contact email") - telegram_username: str - educational_institution: str + telegram: str + educational_institution: str | None = None class TeamMemberCreate(TeamMemberBase): @@ -26,7 +30,7 @@ def normalize_email(cls, value: EmailStr): class TeamMemberUpdate(BaseModel): full_name: str | None = Field(None, min_length=3) email: EmailStr | None = Field(None, description="Contact email") - telegram_username: str | None = None + telegram: str | None = None educational_institution: str | None = None @field_validator("email") @@ -38,8 +42,8 @@ def normalize_email(cls, value: str | None): class TeamMemberPublic(TeamMemberBase): pass - class TeamBase(BaseModel): + model_config = ConfigDict(from_attributes=True) name: str = Field(..., description="Name of the team") team_email: EmailStr = Field(..., description="Contact email") contact_info: PhoneNumber = Field(..., description="Phone number") diff --git a/backend/app/schemas/tournament.py b/backend/app/schemas/tournament.py index 951cdbc..0be5d67 100644 --- a/backend/app/schemas/tournament.py +++ b/backend/app/schemas/tournament.py @@ -1,7 +1,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Annotated - from datetime import datetime, timezone from pydantic import ( AfterValidator, @@ -27,6 +26,7 @@ def make_naive(value: datetime) -> datetime: def drop_time(date: datetime) -> datetime: + """Обнуляє час до 00:00:00 для порівняння лише дат.""" return date.replace(hour=0, minute=0, second=0, microsecond=0) @@ -49,13 +49,12 @@ class TournamentCreate(TournamentBase): juries: list[int | str] = Field(..., description="Jury ids") @model_validator(mode="after") - def validate_dates(self) -> "TournamentBase": - + def validate_dates(self) -> "TournamentCreate": now = drop_time(datetime.now(timezone.utc).replace(tzinfo=None)) if drop_time(self.reg_start) < now: raise ValueError("Registration cannot start in the past") - + if drop_time(self.reg_end) <= drop_time(self.reg_start): raise ValueError( "Registration end time must be later than the registration start time." @@ -75,8 +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) - max_people_in_team: int | None = Field(None, gt=0) + 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") @@ -109,7 +108,6 @@ def validate_dates_update(self) -> "TournamentUpdate": @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( @@ -120,11 +118,10 @@ def validate_limits(self) -> "TournamentUpdate": class TournamentStatusOptionModel(BaseModel): model_config = ConfigDict(from_attributes=True) - name: StrippedStr = Field(..., min_length=3) -class TournamentPublicMinimal(TournamentBase): +class TournamentPublic(TournamentBase): model_config = ConfigDict(from_attributes=True) id: int @@ -133,11 +130,15 @@ class TournamentPublicMinimal(TournamentBase): status: OptionPublic tasks: list[TaskPublic] active_task: TaskPublic | None + teams: list[TeamPublic] juries: list["UserMinimalPublic"] status_name: str = Field(validation_alias=AliasPath("status", "display_name")) -class TournamentPublic(TournamentPublicMinimal): +class TournamentPublicMinimal(TournamentBase): model_config = ConfigDict(from_attributes=True) - teams: list[TeamPublic] + id: int + end_date: datetime | None + status: OptionPublic + status_name: str = Field(validation_alias=AliasPath("status", "display_name")) \ No newline at end of file diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index a20a9de..2666c77 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -4,10 +4,10 @@ from pydantic import BaseModel, Field, EmailStr, field_validator, ConfigDict from .role import RolePublic +from .tournament import TournamentPublicMinimal if TYPE_CHECKING: from .notification import NotificationPublic - from .tournament import TournamentPublic class UserBase(BaseModel): @@ -15,6 +15,11 @@ class UserBase(BaseModel): full_name: str = Field(..., description="Username") + +class UserCreate(UserBase): + firebase_uid: str = Field(..., description="Firebase user id") + email: EmailStr = Field(..., description="Email") + @field_validator("full_name") @classmethod def check_name(cls, value: str): @@ -23,11 +28,6 @@ def check_name(cls, value: str): return value -class UserCreate(UserBase): - firebase_uid: str = Field(..., description="Firebase user id") - email: EmailStr = Field(..., description="Email") - - class UserUpdate(UserBase): full_name: str | None = None email: str | None = None @@ -38,6 +38,7 @@ class UserUpdate(UserBase): class UserMinimalPublic(UserBase): """Minimal user schema without circular relationships for use in nested contexts""" + id: int email: EmailStr firebase_uid: str @@ -45,6 +46,7 @@ class UserMinimalPublic(UserBase): telegram: str | None github: str | None discord: str | None + is_admin: bool is_organizer: bool is_jury: bool @@ -61,7 +63,7 @@ class UserPublic(UserBase): is_admin: bool is_organizer: bool is_jury: bool - evaluates_in: list['TournamentPublic'] + evaluates_in: list["TournamentPublicMinimal"] class UserModel(UserBase): @@ -76,5 +78,5 @@ def check_email(cls, value: str): # Return notifications of current user only class CurrentUser(UserPublic): notifications: list["NotificationPublic"] - participates_in: list["TournamentPublic"] = Field(default_factory=list) - created_tournaments: list["TournamentPublic"] + participates_in: list["TournamentPublicMinimal"] = Field(default_factory=list) + created_tournaments: list["TournamentPublicMinimal"] diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py index b4b8072..c109532 100644 --- a/backend/app/utils/__init__.py +++ b/backend/app/utils/__init__.py @@ -2,6 +2,8 @@ from .fsm import * from .notifications import send_notification from .users import get_user_by_email, get_user_by_firebase_uid, get_user_by_id -from .tasks import get_requirements, get_task +from .tasks import get_requirements, get_task, get_task_by_tournament from .tournaments import get_tournament, tournament_load_options from .roles import get_role +from .assignments import get_assignment +from .criterions import get_criterion_score, get_task_evaluation_criterion, get_criterion_map diff --git a/backend/app/utils/assignments.py b/backend/app/utils/assignments.py new file mode 100644 index 0000000..10bc9cd --- /dev/null +++ b/backend/app/utils/assignments.py @@ -0,0 +1,39 @@ +from sqlalchemy import select +from sqlalchemy.orm import selectinload +from app.models import ( + JuryAssignment, + Task, + Submission, + Team, + SubmissionEvaluation, + SubmissionUrl, +) +from app.dependencies import SessionDep +from fastapi import HTTPException, status + + +async def get_assignment(assignment_id: int, session: SessionDep) -> JuryAssignment: + statement = ( + select(JuryAssignment) + .where(JuryAssignment.id == assignment_id) + .options( + selectinload(JuryAssignment.status), + selectinload(JuryAssignment.task).selectinload(Task.criteria), + selectinload(JuryAssignment.submission) + .selectinload(Submission.team) + .selectinload(Team.members), + selectinload(JuryAssignment.submission) + .selectinload(Submission.team) + .selectinload(Team.tournament), + selectinload(JuryAssignment.submission) + .selectinload(Submission.urls) + .selectinload(SubmissionUrl.url), + selectinload(JuryAssignment.evaluation).selectinload( + SubmissionEvaluation.criterion_scores + ), + ) + ) + assignment = (await session.execute(statement)).scalar_one_or_none() + if assignment is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Assignment not found") + return assignment diff --git a/backend/app/utils/criterions.py b/backend/app/utils/criterions.py new file mode 100644 index 0000000..cc40352 --- /dev/null +++ b/backend/app/utils/criterions.py @@ -0,0 +1,23 @@ +from sqlalchemy import select, or_ +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) + + criterion = (await session.execute(statement)).scalar() + if not criterion: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Criterion not found!") + return criterion + +async def get_task_evaluation_criterion(identifier: str | int, session: SessionDep) -> TaskEvaluationCriterion: + statement = select(TaskEvaluationCriterion).where(or_(TaskEvaluationCriterion.name == identifier, TaskEvaluationCriterion.id == identifier)) + + criterion = (await session.execute(statement)).scalar() + if not criterion: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Criterion not found!") + return criterion \ No newline at end of file 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/app/utils/tasks.py b/backend/app/utils/tasks.py index 8fc077b..eef001c 100644 --- a/backend/app/utils/tasks.py +++ b/backend/app/utils/tasks.py @@ -1,17 +1,31 @@ from fastapi import HTTPException, status from sqlalchemy import select +from sqlalchemy.orm import selectinload from app.dependencies.session import SessionDep from app.models import Task, TaskRequirementOption +from .tournaments import get_tournament async def get_task(task_id: int, session: SessionDep) -> Task: - statement = select(Task).where(Task.id == task_id) + statement = select(Task).where(Task.id == task_id).options(selectinload(Task.criteria)) task = (await session.execute(statement)).scalar() if not task: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Task not found!") return task +async def get_task_by_tournament( + tournament_id: int, task_id: int, session: SessionDep +) -> Task: + tournament = await get_tournament(tournament_id, session) + task = await get_task(task_id, session) + if task.tournament_id != tournament.id: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, detail="Task does not belong to this tournament" + ) + return task + + async def get_requirements( requirement_names: list[str], session: SessionDep ) -> list[TaskRequirementOption]: diff --git a/backend/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/factories.py b/backend/tests/factories.py index 662c21c..f247b86 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -20,10 +20,13 @@ Submission, SubmissionUrl, SubmissionUrlOption, + JuryAssignment, + JuryAssignmentStatusOption, SubmissionEvaluation, - RequirementEvaluation, Notification, RoleRequest, + TaskEvaluationCriterion, + CriterionScore, ) import datetime @@ -38,11 +41,20 @@ class Meta: class BaseOptionFactory(BaseFactory): class Meta: abstract = True + sqlalchemy_get_or_create = ('name',) name = Faker("name") display_name = factory.LazyAttribute(lambda f: f.name.upper()) +class BaseDatetimeFactory(BaseFactory): + class Meta: + abstract = True + + created_at = factory.Faker("date_time") + updated_at = factory.LazyAttribute(lambda o: o.created_at + datetime.timedelta(hours=1)) + + class UserFactory(BaseFactory): class Meta: model = User @@ -76,6 +88,7 @@ class Meta: class TournamentStatusOptionFactory(BaseOptionFactory): class Meta: model = TournamentStatusOption + sqlalchemy_get_or_create = ('name',) name = factory.Iterator(list(settings.TOURNAMENT_STATUS_NAMES.__dict__.values())) @@ -134,7 +147,7 @@ class Meta: name = Faker("name") team_email = Faker("email") - contact_info = Faker("phone_number") + contact_info = Faker("numerify", text="+38050#######") tournament = factory.SubFactory(TournamentFactory) captain = None @@ -173,6 +186,22 @@ class Meta: ) +class JuryAssignmentStatusOptionFactory(BaseOptionFactory): + class Meta: + model = JuryAssignmentStatusOption + + name = factory.Iterator( + [option["name"] for option in settings.JURY_ASSIGNMENT_STATUS_OPTIONS] + ) + display_name = factory.LazyAttribute( + lambda status: next( + option["display_name"] + for option in settings.JURY_ASSIGNMENT_STATUS_OPTIONS + if option["name"] == status.name + ) + ) + + class TaskFactory(BaseFactory): class Meta: model = Task @@ -183,6 +212,9 @@ class Meta: end_time = Faker("future_datetime") tournament = factory.SubFactory(TournamentFactory) status_id = factory.Iterator([option["name"] for option in settings.TASK_STATUS_OPTIONS]) + min_reviews_per_submission = 2 + max_score = 10 + is_leaderboard_visible = True @classmethod def _adjust_kwargs(cls, **kwargs): @@ -210,6 +242,7 @@ class Meta: model = Submission team = factory.SubFactory(TeamFactory) + task = factory.LazyAttribute(lambda obj: TaskFactory.build(tournament=obj.team.tournament)) class SubmissionUrlOptionFactory(BaseOptionFactory): @@ -225,21 +258,54 @@ class Meta: submission = factory.SubFactory(SubmissionFactory) url = factory.SubFactory(SubmissionUrlOptionFactory) + value = factory.Sequence(lambda n: f"https://example.com/submission/{n}") -class SubmissionEvaluationFactory(BaseFactory): +class TaskEvaluationCriterionFactory(BaseFactory): class Meta: - model = SubmissionEvaluation + model = TaskEvaluationCriterion + + task = factory.SubFactory(TaskFactory) + name = factory.Sequence(lambda n: f"Criterion {n}") + description = None + weight = 1 + max_score = 10 + + +class JuryAssignmentFactory(BaseDatetimeFactory): + class Meta: + model = JuryAssignment submission = factory.SubFactory(SubmissionFactory) + task = factory.SelfAttribute("submission.task") jury = factory.SubFactory(UserFactory) + status = factory.SubFactory(JuryAssignmentStatusOptionFactory) + + @classmethod + def _adjust_kwargs(cls, **kwargs): + status_id = kwargs.get("status_id") + if isinstance(status_id, JuryAssignmentStatusOption): + kwargs["status"] = status_id + kwargs.pop("status_id") + return super()._adjust_kwargs(**kwargs) + + +class SubmissionEvaluationFactory(BaseFactory): + class Meta: + model = SubmissionEvaluation + + assignment = factory.SubFactory(JuryAssignmentFactory) + submission = factory.SelfAttribute("assignment.submission") + jury = factory.SelfAttribute("assignment.jury") + comment = None -class RequirementEvaluationFactory(BaseFactory): +class CriterionScoreFactory(BaseFactory): class Meta: - model = RequirementEvaluation + model = CriterionScore evaluation = factory.SubFactory(SubmissionEvaluationFactory) + criterion = factory.SubFactory(TaskEvaluationCriterionFactory) score = factory.Faker("pyint", min_value=0, max_value=100) diff --git a/backend/tests/routes/test_jury.py b/backend/tests/routes/test_jury.py new file mode 100644 index 0000000..9cc0b6a --- /dev/null +++ b/backend/tests/routes/test_jury.py @@ -0,0 +1,362 @@ +import pytest +from app import app +from app.dependencies import get_current_user +from app.models import TaskEvaluationCriterion +from tests.factories import ( + JuryAssignmentStatusOptionFactory, + RoleFactory, + SubmissionUrlOptionFactory, + TaskFactory, + TaskStatusOptionFactory, + TeamFactory, + TeamMemberFactory, + TournamentFactory, + TournamentStatusOptionFactory, + UserFactory, + JuryAssignmentFactory, + JuryAssignmentStatusOptionFactory, + SubmissionFactory, + SubmissionEvaluationFactory, + TaskEvaluationCriterionFactory, + CriterionScoreFactory +) +from app.config import settings + +async def test_jury_tasks(create, client, db_session): + closed = await create(TaskStatusOptionFactory, name=settings.TASK_STATUS_NAMES.SUBMISSION_CLOSED) + jury = await create(UserFactory) + tournament = await create(TournamentFactory) + t1 = await create(TaskFactory, status=closed, tournament=tournament) + t2 = await create(TaskFactory, status=closed, tournament=tournament) + captain1 = await create(TeamMemberFactory, tournament=tournament) + captain2 = await create(TeamMemberFactory, tournament=tournament) + team1 = await create(TeamFactory, tournament=tournament, captain=captain1) + team2 = await create(TeamFactory, tournament=tournament, captain=captain2) + s1 = await create(SubmissionFactory, task=t1, team=team1) + s2 = await create(SubmissionFactory, task=t2, team=team2) + await create(JuryAssignmentFactory, jury=jury, task=t1, + submission=s1) + await create(JuryAssignmentFactory, jury=jury, task=t2, + submission=s2) + app.dependency_overrides[get_current_user] = lambda: jury + + resp = await client.get('/jury/tasks/') + assert resp.status_code == 200 + assert len(resp.json()) == 2 + t_ids = [t['id'] for t in resp.json()] + assert t1.id in t_ids + assert t2.id in t_ids + + app.dependency_overrides.pop(get_current_user) + +async def test_jury_tasks_draft(create, client, db_session): + draft = await create(TaskStatusOptionFactory, name=settings.TASK_STATUS_NAMES.DRAFT) + jury = await create(UserFactory) + tournament = await create(TournamentFactory) + t = await create(TaskFactory, status=draft, tournament=tournament) + captain = await create(TeamMemberFactory, tournament=tournament) + team = await create(TeamFactory, tournament=tournament, captain=captain) + s = await create(SubmissionFactory, task=t, team=team) + await create(JuryAssignmentFactory, jury=jury, task=t, + submission=s) + app.dependency_overrides[get_current_user] = lambda: jury + + resp = await client.get('/jury/tasks/') + assert resp.status_code == 200 + assert len(resp.json()) == 0 + + app.dependency_overrides.pop(get_current_user) + +async def test_jury_assignments(create, client, db_session): + closed = await create(TaskStatusOptionFactory, name=settings.TASK_STATUS_NAMES.SUBMISSION_CLOSED) + jury = await create(UserFactory) + tournament = await create(TournamentFactory) + t1 = await create(TaskFactory, status=closed, tournament=tournament) + captain1 = await create(TeamMemberFactory, tournament=tournament) + captain2 = await create(TeamMemberFactory, tournament=tournament) + team1 = await create(TeamFactory, tournament=tournament, captain=captain1) + team2 = await create(TeamFactory, tournament=tournament, captain=captain2) + s1 = await create(SubmissionFactory, task=t1, team=team1) + s2 = await create(SubmissionFactory, task=t1, team=team2) + assigned = await create(JuryAssignmentStatusOptionFactory, name=settings.JURY_ASSIGNMENT_STATUS_NAMES.ASSIGNED) + await create(JuryAssignmentFactory, jury=jury, task=t1, + submission=s1, status=assigned) + await create(JuryAssignmentFactory, jury=jury, task=t1, + submission=s2, status=assigned) + app.dependency_overrides[get_current_user] = lambda: jury + + resp = await client.get(f'/jury/tasks/{t1.id}/assignments/') + assert resp.status_code == 200 + assert len(resp.json()) == 2 + t_ids = [t['task_id'] for t in resp.json()] + assert t_ids[0] == t1.id + assert t_ids[1] == t1.id + + app.dependency_overrides.pop(get_current_user) + +async def test_jury_assignment_draft(create, client, db_session): + jury = await create(UserFactory) + a = await create(JuryAssignmentFactory, jury=jury) + app.dependency_overrides[get_current_user] = lambda: jury + + resp = await client.get(f'/jury/tasks/{a.task.id}/assignments/') + assert resp.status_code == 400 + assert resp.json()['detail'] == 'The submissions are still open to be received!' + + + 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) + cr1 = await create(TaskEvaluationCriterionFactory, task=t) + cr2 = await create(TaskEvaluationCriterionFactory, task=t) + s = await create(SubmissionFactory) + a = await create(JuryAssignmentFactory, jury=jury, task=t) + e = await create(SubmissionEvaluationFactory, assignment=a, submission=s) + + c1 = await create(CriterionScoreFactory, evaluation=e, criterion=cr1) + c2 = await create(CriterionScoreFactory, evaluation=e, criterion=cr2) + await db_session.refresh(e, ['criterion_scores']) + comment = e.comment + criteria = e.criterion_scores + + app.dependency_overrides[get_current_user] = lambda: jury + + resp = await client.patch(f'/jury/assignments/{a.id}/evaluation/', json={ + 'comment': 'A comment', + 'criterion_scores': [ + {'criterion_id': c1.id, 'score': 7}, + {'criterion_id': c2.id, 'score': 5} + ] + }) + assert resp.status_code == 200 + assert resp.json()['comment'] == 'A comment' + assert resp.json()['comment'] != comment + assert len(resp.json()['criterion_scores']) == 2 + assert len(resp.json()['criterion_scores']) != criteria + + + app.dependency_overrides.pop(get_current_user) + +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) + 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) + await create(SubmissionFactory, task=task, team=team) + + app.dependency_overrides[get_current_user] = lambda: creator + + resp = await client.post(f'/tournaments/{tournament.id}/tasks/{task.id}/assignments/generate/') + assert resp.status_code == 400 + assert resp.json()['detail'] == 'Not enough juries to satisfy minimum reviews per submission' + + 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) + 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) + 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}/assignments/generate/') + assert resp.status_code == 400 + assert resp.json()['detail'] == 'Assignments already generated for this task' + + app.dependency_overrides.pop(get_current_user) + +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=[]) + 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) + await create(SubmissionFactory, task=task, team=team) + app.dependency_overrides[get_current_user] = lambda: creator + + resp = await client.post(f'/tournaments/{tournament.id}/tasks/{task.id}/assignments/generate/') + assert resp.status_code == 400 + assert resp.json()['detail'] == 'Not enough juries to satisfy minimum reviews per submission' + + 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]) + jury1 = await create(UserFactory) + jury2 = await create(UserFactory) + participant = await create(UserFactory) + + tournament_status = await create( + TournamentStatusOptionFactory, name=settings.TOURNAMENT_STATUS_NAMES.DRAFT + ) + await create(TaskStatusOptionFactory, name=settings.TASK_STATUS_NAMES.DRAFT) + await create( + JuryAssignmentStatusOptionFactory, name=settings.JURY_ASSIGNMENT_STATUS_NAMES.ASSIGNED + ) + closed = await create(TaskStatusOptionFactory, name=settings.TASK_STATUS_NAMES.SUBMISSION_CLOSED) + tournament = await create(TournamentFactory, creator=organizer, status=tournament_status) + await db_session.refresh(tournament, ["juries"]) + tournament.juries.extend([jury1, jury2]) + + task = await create(TaskFactory, tournament=tournament, min_reviews_per_submission=2, status=closed) + criterion = TaskEvaluationCriterion( + task_id=task.id, + name="Completeness", + weight=1, + max_score=10, + ) + db_session.add(criterion) + + team = await create(TeamFactory, tournament=tournament) + await create(TeamMemberFactory, team=team, tournament=tournament, email=participant.email) + option = await create(SubmissionUrlOptionFactory, name="github") + await db_session.commit() + + app.dependency_overrides[get_current_user] = lambda: participant + submission_resp = await client.post( + f"/tournaments/{tournament.id}/tasks/{task.id}/submissions/", + json={ + "team_id": team.id, + "urls": [{"url_id": option.name, "value": "https://github.com/example/repo"}], + }, + ) + assert submission_resp.status_code == 201 + + app.dependency_overrides[get_current_user] = lambda: organizer + generate_resp = await client.post( + f"/tournaments/{tournament.id}/tasks/{task.id}/assignments/generate/" + ) + assert generate_resp.status_code == 200 + assert len(generate_resp.json()) == 2 + + app.dependency_overrides[get_current_user] = lambda: jury1 + assignments_resp = await client.get(f"/jury/tasks/{task.id}/assignments/") + assert assignments_resp.status_code == 200 + assignment = assignments_resp.json()[0] + + evaluation_resp = await client.post( + f"/jury/assignments/{assignment['id']}/evaluation/", + json={ + "comment": "Solid work", + "criterion_scores": [{"criterion_id": criterion.id, "score": 8}], + }, + ) + assert evaluation_resp.status_code == 201 + assert evaluation_resp.json()["comment"] == "Solid work" + + app.dependency_overrides.pop(get_current_user) 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/routes/test_tournaments.py b/backend/tests/routes/test_tournaments.py index 7b89252..94fbd43 100644 --- a/backend/tests/routes/test_tournaments.py +++ b/backend/tests/routes/test_tournaments.py @@ -44,7 +44,7 @@ async def test_update_tournament_juries(create, client): user = await create(UserFactory) u1 = await create(UserFactory) u2 = await create(UserFactory) - t = await create(TournamentFactory) + t = await create(TournamentFactory, creator=user) app.dependency_overrides[get_current_user] = lambda: user resp = await client.patch( f"/tournaments/{t.id}/", diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 4bc9670..3f91419 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -20,7 +20,7 @@ SubmissionUrl, SubmissionUrlOption, SubmissionEvaluation, - RequirementEvaluation, + CriterionScore, Notification, RoleRequest, ) @@ -41,8 +41,9 @@ SubmissionFactory, SubmissionUrlFactory, SubmissionUrlOptionFactory, + TaskEvaluationCriterionFactory, SubmissionEvaluationFactory, - RequirementEvaluationFactory, + CriterionScoreFactory, NotificationFactory, RoleRequestFactory, ) @@ -230,16 +231,16 @@ async def test_tournament_cascade_delete_children(db_session, create): submission = await create(SubmissionFactory, team=team) url = await create(SubmissionUrlFactory, submission=submission) evaluation = await create(SubmissionEvaluationFactory, submission=submission) - requirement_evaluation = await create(RequirementEvaluationFactory, evaluation=evaluation) + criterion_score = await create(CriterionScoreFactory, evaluation=evaluation) ids = { "task": task.id, "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, - "requirement_evaluation": requirement_evaluation.id, + "criterion_score": criterion_score.id, } await db_session.delete(tournament) @@ -251,7 +252,7 @@ async def test_tournament_cascade_delete_children(db_session, create): assert await db_session.get(Submission, ids["submission"]) is None assert await db_session.get(SubmissionUrl, ids["url"]) is None assert await db_session.get(SubmissionEvaluation, ids["evaluation"]) is None - assert await db_session.get(RequirementEvaluation, ids["requirement_evaluation"]) is None + assert await db_session.get(CriterionScore, ids["criterion_score"]) is None # TASK TESTS @@ -504,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): @@ -539,8 +540,8 @@ async def test_create_submission_evaluation(create): assert evaluation.jury is not None -async def test_create_requirement_evaluation(create): - evaluation = await create(RequirementEvaluationFactory) +async def test_create_criterion_score(create): + evaluation = await create(CriterionScoreFactory) assert evaluation.evaluation_id is not None assert evaluation.evaluation is not None @@ -550,8 +551,8 @@ async def test_create_requirement_evaluation(create): async def test_evaluation_multiple_requirements(create): evaluation = await create(SubmissionEvaluationFactory) - req1 = await create(RequirementEvaluationFactory, evaluation=evaluation) - req2 = await create(RequirementEvaluationFactory, evaluation=evaluation) + req1 = await create(CriterionScoreFactory, evaluation=evaluation) + req2 = await create(CriterionScoreFactory, evaluation=evaluation) assert req1.evaluation_id == evaluation.id assert req2.evaluation_id == evaluation.id @@ -601,24 +602,24 @@ async def test_evaluation_jury_relationship(db_session, create): assert db_eval.jury.id == evaluation.jury.id -async def test_requirement_evaluation_option_relationship(db_session, create): - option = await create(TaskRequirementOptionFactory) +async def test_criterion_score_option_relationship(db_session, create): + option = await create(TaskEvaluationCriterionFactory) req_eval = await create( - RequirementEvaluationFactory, - requirement=[option], + CriterionScoreFactory, + criterion=option, ) stmt = ( - select(RequirementEvaluation) - .where(RequirementEvaluation.id == req_eval.id) - .options(selectinload(RequirementEvaluation.requirement)) + select(CriterionScore) + .where(CriterionScore.id == req_eval.id) + .options(selectinload(CriterionScore.criterion)) ) result = await db_session.execute(stmt) db_req_eval = result.scalar_one() - assert db_req_eval.requirement[0].name == option.name + assert db_req_eval.criterion.name == option.name # NOTIFICATION TESTS diff --git a/frontend/src/api/requests/auth.ts b/frontend/src/api/requests/auth.ts new file mode 100644 index 0000000..3bdbc34 --- /dev/null +++ b/frontend/src/api/requests/auth.ts @@ -0,0 +1,8 @@ +import type { User } from "firebase/auth"; + +export const authHeaders = async (user: User) => { + const token = await user.getIdToken(); + return { + Authorization: `Bearer ${token}`, + }; +}; diff --git a/frontend/src/api/requests/createTask.ts b/frontend/src/api/requests/createTask.ts index 6e164ce..54f2783 100644 --- a/frontend/src/api/requests/createTask.ts +++ b/frontend/src/api/requests/createTask.ts @@ -1,38 +1,32 @@ +import type { User } from "firebase/auth"; import apiClient from "../client"; -import { getAuth } from "firebase/auth"; +import { authHeaders } from "./auth"; -export interface TaskData { +export interface CriterionCreateData { + name: string; + description?: string | null; + weight?: number; + max_score?: number; +} + +export interface TaskCreateData { title: string; - description?: string; + description?: string | null; start_time: string; end_time: string; requirements: string[]; + criteria?: CriterionCreateData[]; } -export const createTask = async (tournamentId: number, data: TaskData) => { - try { - const auth = getAuth(); - const user = auth.currentUser; - - if (!user) { - throw new Error("Користувач не авторизований"); - } - - const token = await user.getIdToken(); - - const resp = await apiClient.post( - `/tournaments/${tournamentId}/tasks`, - data, - { - headers: { - Authorization: `Bearer ${token}`, - }, - } - ); - - return resp.data; - } catch (e) { - console.error(`Error occurred:`, e); - throw e; - } +export const createTask = async ( + tournamentId: number, + data: TaskCreateData, + user: User, +) => { + const resp = await apiClient.post( + `/tournaments/${tournamentId}/tasks/`, + data, + { headers: await authHeaders(user) }, + ); + return resp.data; }; diff --git a/frontend/src/api/requests/createTournament.ts b/frontend/src/api/requests/createTournament.ts index d1c7ce6..f6ede5b 100644 --- a/frontend/src/api/requests/createTournament.ts +++ b/frontend/src/api/requests/createTournament.ts @@ -23,7 +23,7 @@ export const createTournament = async (data: TournamentData) => { const token = await user.getIdToken(); - const resp = await apiClient.post("/tournaments", data, { + const resp = await apiClient.post("/tournaments/", data, { headers: { Authorization: `Bearer ${token}`, }, diff --git a/frontend/src/api/requests/deleteTask.ts b/frontend/src/api/requests/deleteTask.ts index 272d567..b9f7a9f 100644 --- a/frontend/src/api/requests/deleteTask.ts +++ b/frontend/src/api/requests/deleteTask.ts @@ -1,27 +1,14 @@ +import type { User } from "firebase/auth"; import apiClient from "../client"; -import { getAuth } from "firebase/auth"; - -export const deleteTask = async (tournamentId: number, taskId: number) => { - try { - const auth = getAuth(); - const user = auth.currentUser; - - if (!user) { - throw new Error("Користувач не авторизований"); - } - - const token = await user.getIdToken(); - - await apiClient.delete( - `/tournaments/${tournamentId}/tasks/${taskId}/`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - } - ); - } catch (e) { - console.error(`Error occurred:`, e); - throw e; - } +import { authHeaders } from "./auth"; + +export const deleteTask = async ( + tournamentId: number, + taskId: number, + user: User, +) => { + await apiClient.delete( + `/tournaments/${tournamentId}/tasks/${taskId}/`, + { headers: await authHeaders(user) }, + ); }; diff --git a/frontend/src/api/requests/deleteTournament.ts b/frontend/src/api/requests/deleteTournament.ts index a308e68..9043e6a 100644 --- a/frontend/src/api/requests/deleteTournament.ts +++ b/frontend/src/api/requests/deleteTournament.ts @@ -11,7 +11,7 @@ export const deleteTournament = async (tournamentId: number) => { } const token = await user.getIdToken(); - const response = await apiClient.delete(`/tournaments/${tournamentId}`, { + const response = await apiClient.delete(`/tournaments/${tournamentId}/`, { headers: { Authorization: `Bearer ${token}`, } diff --git a/frontend/src/api/requests/finishEvaluation.ts b/frontend/src/api/requests/finishEvaluation.ts new file mode 100644 index 0000000..52ff889 --- /dev/null +++ b/frontend/src/api/requests/finishEvaluation.ts @@ -0,0 +1,18 @@ +import type { User } from "firebase/auth"; + +import apiClient from "../client"; +import { authHeaders } from "./auth"; +import type { JuryTask } from "./types"; + +export const finishEvaluation = async ( + tournamentId: number, + taskId: number, + user: User, +) => { + const resp = await apiClient.post( + `/tournaments/${tournamentId}/tasks/${taskId}/finish-evaluation/`, + {}, + { headers: await authHeaders(user) }, + ); + return resp.data; +}; diff --git a/frontend/src/api/requests/getAllTournaments.ts b/frontend/src/api/requests/getAllTournaments.ts index bdb0427..4473942 100644 --- a/frontend/src/api/requests/getAllTournaments.ts +++ b/frontend/src/api/requests/getAllTournaments.ts @@ -2,7 +2,7 @@ import apiClient from "../client"; export const getAllTournaments = async () => { try { - const resp = await apiClient.get("/tournaments"); + const resp = await apiClient.get("/tournaments/"); return resp.data; } catch (e) { console.error(`Error occurred:`, e); diff --git a/frontend/src/api/requests/getAllUsers.ts b/frontend/src/api/requests/getAllUsers.ts index 5eab682..852b87d 100644 --- a/frontend/src/api/requests/getAllUsers.ts +++ b/frontend/src/api/requests/getAllUsers.ts @@ -2,7 +2,7 @@ import apiClient from "../client"; export const getAllUsers = async () => { try { - const resp = await apiClient.get("/users"); + const resp = await apiClient.get("/users/"); return resp.data; } catch (e) { console.error(`Error occurred:`, e); diff --git a/frontend/src/api/requests/getJuryAssignments.ts b/frontend/src/api/requests/getJuryAssignments.ts new file mode 100644 index 0000000..7cfc72d --- /dev/null +++ b/frontend/src/api/requests/getJuryAssignments.ts @@ -0,0 +1,18 @@ +import type { User } from "firebase/auth"; + +import apiClient from "../client"; +import { authHeaders } from "./auth"; +import type { JuryAssignment } from "./types"; + +export const getJuryAssignments = async ( + taskId: string | number, + user: User, +) => { + const resp = await apiClient.get( + `/jury/tasks/${taskId}/assignments/`, + { + headers: await authHeaders(user), + }, + ); + return resp.data; +}; diff --git a/frontend/src/api/requests/getJuryTasks.ts b/frontend/src/api/requests/getJuryTasks.ts new file mode 100644 index 0000000..a62534e --- /dev/null +++ b/frontend/src/api/requests/getJuryTasks.ts @@ -0,0 +1,12 @@ +import type { User } from "firebase/auth"; + +import apiClient from "../client"; +import { authHeaders } from "./auth"; +import type { JuryTask } from "./types"; + +export const getJuryTasks = async (user: User) => { + const resp = await apiClient.get("/jury/tasks/", { + headers: await authHeaders(user), + }); + return resp.data; +}; diff --git a/frontend/src/api/requests/getTask.ts b/frontend/src/api/requests/getTask.ts new file mode 100644 index 0000000..ffd189f --- /dev/null +++ b/frontend/src/api/requests/getTask.ts @@ -0,0 +1,9 @@ +import apiClient from "../client"; +import type { JuryTask } from "./types"; + +export const getTask = async (tournamentId: number, taskId: number) => { + const resp = await apiClient.get( + `/tournaments/${tournamentId}/tasks/${taskId}/`, + ); + return resp.data; +}; diff --git a/frontend/src/api/requests/getTasks.ts b/frontend/src/api/requests/getTasks.ts index 61d3e40..7351d55 100644 --- a/frontend/src/api/requests/getTasks.ts +++ b/frontend/src/api/requests/getTasks.ts @@ -2,7 +2,7 @@ import apiClient from "../client"; export const getTasks = async (tournamentId: number) => { try { - const resp = await apiClient.get(`/tournaments/${tournamentId}/tasks`); + const resp = await apiClient.get(`/tournaments/${tournamentId}/tasks/`); return resp.data; } catch (e) { console.error(`Error occurred:`, e); diff --git a/frontend/src/api/requests/index.ts b/frontend/src/api/requests/index.ts index 5525e89..bdb5c81 100644 --- a/frontend/src/api/requests/index.ts +++ b/frontend/src/api/requests/index.ts @@ -1,3 +1,21 @@ +export * from "./createTask"; +export * from "./createTournament"; +export * from "./deleteTask"; +export * from "./deleteTournament"; export * from "./deleteUser"; +export * from "./finishEvaluation"; +export * from "./getAllTournaments"; +export * from "./getAllUsers"; +export * from "./getJuryAssignments"; +export * from "./getJuryTasks"; +export * from "./getNews"; +export * from "./getProfile"; +export { getSubmissions } from "./getSubmissions"; +export * from "./getTask"; export * from "./getUser"; -export * from './getSubmissions' \ No newline at end of file +export * from "./requestRole"; +export * from "./saveAssignmentEvaluation"; +export * from "./types"; +export * from "./updateProfile"; +export * from "./updateTask"; +export * from "./updateTournament"; diff --git a/frontend/src/api/requests/requests.test.ts b/frontend/src/api/requests/requests.test.ts index b50ff30..30fbe1a 100644 --- a/frontend/src/api/requests/requests.test.ts +++ b/frontend/src/api/requests/requests.test.ts @@ -35,7 +35,7 @@ describe("API request wrappers", () => { vi.mocked(apiClient.get).mockResolvedValue({ data: users }); await expect(getAllUsers()).resolves.toEqual(users); - expect(apiClient.get).toHaveBeenCalledWith("/users"); + expect(apiClient.get).toHaveBeenCalledWith("/users/"); }); it("rethrows and logs when request fails", async () => { @@ -70,7 +70,7 @@ describe("API request wrappers", () => { vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 44 } }); await expect(createTournament(payload)).resolves.toEqual({ id: 44 }); - expect(apiClient.post).toHaveBeenCalledWith("/tournaments", payload, { + expect(apiClient.post).toHaveBeenCalledWith("/tournaments/", payload, { headers: { Authorization: "Bearer token-123" }, }); }); @@ -100,7 +100,7 @@ describe("API request wrappers", () => { await expect(updateTask(10, 99, taskPayload)).resolves.toEqual({ ok: true }); expect(apiClient.patch).toHaveBeenCalledWith( - "/tournaments/10/tasks/99", + "/tournaments/10/tasks/99/", taskPayload, { headers: { Authorization: "Bearer task-token" }, diff --git a/frontend/src/api/requests/saveAssignmentEvaluation.ts b/frontend/src/api/requests/saveAssignmentEvaluation.ts new file mode 100644 index 0000000..f288708 --- /dev/null +++ b/frontend/src/api/requests/saveAssignmentEvaluation.ts @@ -0,0 +1,28 @@ +import type { User } from "firebase/auth"; + +import apiClient from "../client"; +import { authHeaders } from "./auth"; +import type { CriterionScore, SubmissionEvaluation } from "./types"; + +export const saveAssignmentEvaluation = async ( + assignmentId: number, + data: { comment?: string; criterion_scores: CriterionScore[] }, + user: User, + hasExistingEvaluation: boolean, +) => { + const config = { + headers: await authHeaders(user), + }; + const resp = hasExistingEvaluation + ? await apiClient.patch( + `/jury/assignments/${assignmentId}/evaluation/`, + data, + config, + ) + : await apiClient.post( + `/jury/assignments/${assignmentId}/evaluation/`, + data, + config, + ); + return resp.data; +}; diff --git a/frontend/src/api/requests/types.ts b/frontend/src/api/requests/types.ts new file mode 100644 index 0000000..355fe4c --- /dev/null +++ b/frontend/src/api/requests/types.ts @@ -0,0 +1,105 @@ +// TODO: Move the types to src/types +export interface TaskStatusOption { + name: string; + display_name: string; +} + +export interface TournamentStatusOption { + name: string; + display_name: string; +} + +export interface AppConfig { + task_statuses: TaskStatusOption[]; + tournament_statuses: TournamentStatusOption[]; +} + +export interface TeamMember { + id: number; + full_name: string; + email: string; + telegram: string; + educational_institution?: string | null; +} + +export interface TournamentSummary { + id: number; + title: string; + description: string; +} + +export interface Team { + id: number; + name: string; + team_email: string; + contact_info: string; + tournament: TournamentSummary; + members: TeamMember[]; +} + +export interface SubmissionUrl { + id: number; + url_id: string; + value: string; + url: { + name: string; + display_name: string; + }; +} + +export interface EvaluationCriterion { + id: number; + name: string; + description?: string | null; + weight: number; + max_score: number; +} + +export interface JuryTask { + id: number; + title: string; + description?: string | null; + status_id: string; + status: TaskStatusOption; + start_time: string; + end_time: string; + min_reviews_per_submission: number; + max_score: number; + is_leaderboard_visible: boolean; + criteria: EvaluationCriterion[]; +} + +export interface CriterionScore { + id?: number; + criterion_id: number; + score: number; +} + +export interface SubmissionEvaluation { + id: number; + assignment_id: number; + submission_id: number; + jury_id: number; + comment?: string | null; + created_at?: string | null; + criterion_scores: CriterionScore[]; +} + +export interface Submission { + id: number; + team_id: number; + task_id: number; + team: Team; + urls: SubmissionUrl[]; +} + +export interface JuryAssignment { + id: number; + task_id: number; + submission_id: number; + jury_id: number; + status: string; + submission: Submission; + task: JuryTask; + evaluation?: SubmissionEvaluation | null; +} diff --git a/frontend/src/api/requests/updateTask.ts b/frontend/src/api/requests/updateTask.ts index 9ba02ce..b1e6d24 100644 --- a/frontend/src/api/requests/updateTask.ts +++ b/frontend/src/api/requests/updateTask.ts @@ -1,42 +1,27 @@ +import type { User } from "firebase/auth"; import apiClient from "../client"; -import { getAuth } from "firebase/auth"; +import { authHeaders } from "./auth"; +import type { CriterionCreateData } from "./createTask"; export interface TaskUpdateData { title?: string; - description?: string; + description?: string | null; start_time?: string; end_time?: string; requirements?: string[]; + criteria?: CriterionCreateData[]; } export const updateTask = async ( tournamentId: number, taskId: number, - data: TaskUpdateData + data: TaskUpdateData, + user: User, ) => { - try { - const auth = getAuth(); - const user = auth.currentUser; - - if (!user) { - throw new Error("Користувач не авторизований"); - } - - const token = await user.getIdToken(); - - const resp = await apiClient.patch( - `/tournaments/${tournamentId}/tasks/${taskId}`, - data, - { - headers: { - Authorization: `Bearer ${token}`, - }, - } - ); - - return resp.data; - } catch (e) { - console.error(`Error occurred:`, e); - throw e; - } + const resp = await apiClient.patch( + `/tournaments/${tournamentId}/tasks/${taskId}/`, + data, + { headers: await authHeaders(user) }, + ); + return resp.data; }; diff --git a/frontend/src/api/requests/updateTournament.ts b/frontend/src/api/requests/updateTournament.ts index 3fcd680..9a1cfc6 100644 --- a/frontend/src/api/requests/updateTournament.ts +++ b/frontend/src/api/requests/updateTournament.ts @@ -25,7 +25,7 @@ export const updateTournament = async ( const token = await user.getIdToken(); - const response = await apiClient.patch(`/tournaments/${tournamentId}`,data,{ + const response = await apiClient.patch(`/tournaments/${tournamentId}/`,data,{ headers: { Authorization: `Bearer ${token}` } 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/Stars.tsx b/frontend/src/components/Stars.tsx new file mode 100644 index 0000000..998e296 --- /dev/null +++ b/frontend/src/components/Stars.tsx @@ -0,0 +1,92 @@ +import { useState, useEffect, useMemo, type FC } from "react"; + +const Stars: FC = () => { + const [mousePos, setMousePos] = useState({ x: -1000, y: -1000 }); + const [screenWidth, setScreenWidth] = useState(typeof window !== "undefined" ? window.innerWidth : 1000); + const [time, setTime] = useState(0); + + useEffect(() => { + if (typeof window === "undefined") return; + setScreenWidth(window.innerWidth); + const handleResize = () => setScreenWidth(window.innerWidth); + const handleMove = (e: MouseEvent) => setMousePos({ x: e.clientX, y: e.clientY }); + + let animationFrame: number; + const animate = () => { + setTime((t) => t + 1); + animationFrame = requestAnimationFrame(animate); + }; + animate(); + window.addEventListener("resize", handleResize); + window.addEventListener("mousemove", handleMove); + + return () => { + cancelAnimationFrame(animationFrame); + window.removeEventListener("resize", handleResize); + window.removeEventListener("mousemove", handleMove); + }; + }, []); + + const starsData = useMemo(() => { + return Array.from({ length: 100 }).map((_, i) => ({ + id: i, + leftPercent: (100 / 30) * i + Math.random() * 2, + stringHeight: 40 + Math.random() * 100, + size: 8 + Math.random() * 10, + sensitivity: 0.3 + Math.random() * 0.4, + phase: Math.random() * Math.PI * 2, + })); + }, []); + + return ( +
+ {starsData.map((star) => { + const starX = (star.leftPercent / 100) * screenWidth; + const starY = star.stringHeight; + const dx = mousePos.x - starX; + const dy = mousePos.y - starY; + const distance = Math.sqrt(dx * dx + dy * dy); + + const radius = 250; + let mouseTilt = 0; + + if (distance < radius) { + const force = (radius - distance) / radius; + mouseTilt = -(dx / radius) * force * 15 * star.sensitivity; + } + + const idleSwing = Math.sin(time * 0.02) * 5; + const isHovered = distance < 120; + + return ( +
+
+ + + + +
+ ); + })} +
+ ); +}; + +export { Stars }; \ 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 11383e4..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({ @@ -57,7 +69,13 @@ const DateTimePicker: React.FC = ({ }); const updateDateTime = (newDate: Date) => { - onChange(newDate.toISOString()); + const year = newDate.getFullYear(); + const month = String(newDate.getMonth() + 1).padStart(2, '0'); + const day = String(newDate.getDate()).padStart(2, '0'); + const hours = String(newDate.getHours()).padStart(2, '0'); + const minutes = String(newDate.getMinutes()).padStart(2, '0'); + + onChange(`${year}-${month}-${day}T${hours}:${minutes}`); }; const handleDateClick = (day: Date) => { @@ -101,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")} @@ -120,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")}
@@ -153,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}
@@ -190,9 +208,9 @@ const DateTimePicker: React.FC = ({ {renderCalendar()}
-
- - Час +
+ + {t("date_time_picker.time")}
@@ -200,39 +218,39 @@ const DateTimePicker: React.FC = ({ -
+
{format(dateValue, "HH")}
- : + :
-
+
{format(dateValue, "mm")}
@@ -249,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 @@ -263,4 +281,4 @@ const DateTimePicker: React.FC = ({ ); }; -export default DateTimePicker; +export default DateTimePicker; \ No newline at end of file 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 d9f2879..7a56998 100644 --- a/frontend/src/i18n/config.ts +++ b/frontend/src/i18n/config.ts @@ -2,7 +2,6 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import LanguageDetector from "i18next-browser-languagedetector"; -// UKRAINIAN import ukCommon from "../locales/uk/common.json"; import ukAuth from "../locales/uk/auth.json"; import ukRegistration from "../locales/uk/registration.json"; @@ -15,9 +14,10 @@ import ukTournaments from "../locales/uk/tournaments.json"; import ukRules from "../locales/uk/rules.json"; 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"; -// ENGLISH import enCommon from "../locales/en/common.json"; import enAuth from "../locales/en/auth.json"; import enRegistration from "../locales/en/registration.json"; @@ -30,6 +30,8 @@ import enTournaments from "../locales/en/tournaments.json"; 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"; const resources = { uk: { @@ -45,7 +47,9 @@ const resources = { rules: ukRules, profile: ukProfile, roleRequest: ukRoleRequest, + jury: ukJury, news: ukNews, + modals: ukModals, }, en: { common: enCommon, @@ -60,6 +64,8 @@ const resources = { rules: enRules, profile: enProfile, roleRequest: enRoleRequest, + jury: enJury, + modals: enModals, }, }; @@ -81,6 +87,10 @@ i18n "contact", "tournaments", "rules", + "profile", + "roleRequest", + "jury", + "modals", ], defaultNS: "common", debug: import.meta.env.DEV, diff --git a/frontend/src/locales/en/jury.json b/frontend/src/locales/en/jury.json new file mode 100644 index 0000000..3a5662a --- /dev/null +++ b/frontend/src/locales/en/jury.json @@ -0,0 +1,67 @@ +{ + "panel": { + "eyebrow": "Jury workspace", + "title": "Jury panel", + "description": "Review only the rounds assigned to you. Each task opens its own rubric and submission list so you can move quickly from one decision to the next.", + "assignments_label": "Active assignments", + "loading_aria": "Loading jury tasks", + "loading_hint": "Loading tasks…", + "error_title": "Could not load jury tasks", + "error_hint": "Please refresh the page or try again in a moment.", + "empty_title": "No jury assignments yet", + "empty_description": "When an organizer assigns you to a round, it will appear here with a clear path into the rubric.", + "rounds_ready_one": "{{count}} round ready to judge", + "rounds_ready_other": "{{count}} rounds ready to judge" + }, + "evaluate": { + "back": "All assignments", + "eyebrow": "Assigned round", + "loading_round": "Loading round", + "default_description": "Review every assigned submission using the round rubric. Save each evaluation before moving on.", + "progress_label": "Review progress", + "progress_aria": "{{percent}} percent of assigned submissions reviewed ({{reviewed}} of {{total}})", + "finalized_title": "This round has been finalized", + "finalized_hint": "Evaluations are read-only. You can still open cards to review scores.", + "status_label": "Round status", + "status_all_reviewed": "All assigned submissions are reviewed.", + "status_partial": "{{reviewed}} of {{total}} submissions reviewed.", + "status_hint_all_reviewed": "An organizer can finalize this round, or use the button below if finalization is enabled for jurors.", + "status_hint_partial": "Open each card, score against the rubric, and save. Pending items stay highlighted.", + "finalize": "Finalize evaluation", + "loading_aria": "Loading submissions", + "loading_hint": "Loading submissions…", + "error_title": "Could not load assigned submissions", + "error_hint": "Please check your connection and try again.", + "empty_title": "No submissions for this round", + "empty_description": "Nothing is assigned to you here yet. Return to the panel and pick another round when it appears.", + "back_to_panel": "Back to jury panel", + "toast_finalized": "Round finalized successfully", + "toast_finalize_failed": "Failed to finalize round" + }, + "task_card": { + "task_id": "Task #{{id}}", + "no_description": "No description provided for this round.", + "reviews_per_submission": "{{count}} reviews / submission", + "scale": "Scale 0–{{max}}", + "open_round": "Open round" + }, + "submission": { + "label": "Submission", + "reviewed": "Reviewed", + "pending": "Pending", + "no_links": "No submission links were attached.", + "cta_start": "Start evaluation", + "cta_update": "Update evaluation", + "cta_view": "View evaluation" + }, + "modal": { + "eyebrow": "Evaluation", + "close_aria": "Close", + "finalized_banner": "This round has been finalized. Evaluations cannot be modified.", + "comment": "Comment", + "comment_placeholder": "Optional feedback for the submission", + "cancel": "Cancel", + "save": "Save evaluation", + "criterion_meta": "Weight {{weight}} · Max {{max}}" + } +} 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/jury.json b/frontend/src/locales/uk/jury.json new file mode 100644 index 0000000..16aeb2a --- /dev/null +++ b/frontend/src/locales/uk/jury.json @@ -0,0 +1,69 @@ +{ + "panel": { + "eyebrow": "Робочий простір журі", + "title": "Панель журі", + "description": "Переглядайте лише призначені вам раунди. Кожне завдання відкриває власну шкалу та список робіт — щоб швидко переходити від одного рішення до наступного.", + "assignments_label": "Активні призначення", + "loading_aria": "Завантаження завдань журі", + "loading_hint": "Завантаження завдань…", + "error_title": "Не вдалося завантажити завдання журі", + "error_hint": "Оновіть сторінку або спробуйте ще раз за кілька хвилин.", + "empty_title": "Поки немає призначень журі", + "empty_description": "Коли організатор призначить вас на раунд, він з’явиться тут із зрозумілим переходом до шкали оцінювання.", + "rounds_ready_one": "{{count}} раунд готовий до оцінювання", + "rounds_ready_few": "{{count}} раунди готові до оцінювання", + "rounds_ready_many": "{{count}} раундів готові до оцінювання", + "rounds_ready_other": "{{count}} раундів готові до оцінювання" + }, + "evaluate": { + "back": "Усі призначення", + "eyebrow": "Призначений раунд", + "loading_round": "Завантаження раунду", + "default_description": "Перегляньте кожну призначену роботу за шкалою цього раунду. Збережіть оцінку перед переходом далі.", + "progress_label": "Прогрес перевірки", + "progress_aria": "Відсоток перевірених робіт: {{percent}}% ({{reviewed}} з {{total}})", + "finalized_title": "Цей раунд завершено", + "finalized_hint": "Оцінки лише для перегляду. Ви все ще можете відкрити картки та переглянути бали.", + "status_label": "Статус раунду", + "status_all_reviewed": "Усі призначені роботи перевірені.", + "status_partial": "Перевірено {{reviewed}} з {{total}} робіт.", + "status_hint_all_reviewed": "Організатор може завершити раунд, або скористайтеся кнопкою нижче, якщо завершення доступне журі.", + "status_hint_partial": "Відкрийте кожну картку, виставте бали за шкалою та збережіть. Неперевірені залишаються підсвіченими.", + "finalize": "Завершити оцінювання", + "loading_aria": "Завантаження робіт", + "loading_hint": "Завантаження робіт…", + "error_title": "Не вдалося завантажити призначені роботи", + "error_hint": "Перевірте з’єднання та спробуйте ще раз.", + "empty_title": "Немає робіт для цього раунду", + "empty_description": "Вам тут поки нічого не призначено. Поверніться до панелі та оберіть інший раунд, коли він з’явиться.", + "back_to_panel": "До панелі журі", + "toast_finalized": "Раунд успішно завершено", + "toast_finalize_failed": "Не вдалося завершити раунд" + }, + "task_card": { + "task_id": "Завдання №{{id}}", + "no_description": "Для цього раунду немає опису.", + "reviews_per_submission": "{{count}} перевірок / робота", + "scale": "Шкала 0–{{max}}", + "open_round": "Відкрити раунд" + }, + "submission": { + "label": "Робота", + "reviewed": "Перевірено", + "pending": "Очікує", + "no_links": "Посилання на роботу не додані.", + "cta_start": "Почати оцінювання", + "cta_update": "Оновити оцінку", + "cta_view": "Переглянути оцінку" + }, + "modal": { + "eyebrow": "Оцінювання", + "close_aria": "Закрити", + "finalized_banner": "Раунд завершено. Оцінки не можна змінювати.", + "comment": "Коментар", + "comment_placeholder": "Необов’язковий відгук для команди", + "cancel": "Скасувати", + "save": "Зберегти оцінку", + "criterion_meta": "Вага {{weight}} · Макс. {{max}}" + } +} 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 558f40d..ae0d135 100644 --- a/frontend/src/pages/JuryPanel/EvaluateTournamentPage.tsx +++ b/frontend/src/pages/JuryPanel/EvaluateTournamentPage.tsx @@ -1,32 +1,204 @@ -import { getSubmissions } from "@/api/requests"; +import { finishEvaluation, getJuryAssignments, type JuryAssignment } from "@/api/requests"; import { auth } from "@/firebase"; -import type { RootState } from "@/store"; -import { useQuery } from "@tanstack/react-query"; -import { useSelector } from "react-redux"; -import { useParams } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +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"; const EvaluateTournamentPage = () => { - const {id} = useParams(); + const { t } = useTranslation("jury"); + const { id } = useParams(); + const [selectedAssignment, setSelectedAssignment] = useState(null); + const queryClient = useQueryClient(); const user = auth.currentUser; + const { - data: submissions, + data: assignments = [], isLoading, - error, + isError, } = useQuery({ - queryKey: ["tournament", id], + queryKey: juryTaskQueryKey(id ?? ""), queryFn: async () => { - if (!id) return; - if (!user) return; - return await getSubmissions(id, user); + if (!id) throw new Error("Task id is missing"); + if (!user) throw new Error("User is not authenticated"); + return getJuryAssignments(id, user); + }, + enabled: !!id && !!user, + }); + + const task = assignments[0]?.task; + const tournamentId = assignments[0]?.submission.team.tournament.id; + const allReviewed = assignments.length > 0 && assignments.every((a) => a.evaluation); + const isFinalized = task?.status_id === taskStatusByName.evaluated.name; + const 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 () => { + if (!tournamentId || !id || !user) throw new Error("Missing context"); + return finishEvaluation(tournamentId, Number(id), user); + }, + onSuccess: async () => { + if (id) { + await queryClient.invalidateQueries({ queryKey: juryTaskQueryKey(id) }); + } + toast.success(t("evaluate.toast_finalized")); + }, + onError: (err) => { + toast.error((err as Error)?.message || t("evaluate.toast_finalize_failed")); }, - enabled: !!id, - retry: 1, }); - if (isLoading || !submissions) return <>Loading... + const backLink = ( + + + {t("evaluate.back")} + + ); + + return ( + <> + + {showRoundMeta && !isFinalized ? ( +
+
+ {t("evaluate.progress_label")} + + {reviewedCount}/{totalCount} + +
+
+
+
+
+ ) : null} + + {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_hint_all_reviewed") : t("evaluate.status_hint_partial")} +

+
+ {allReviewed ? ( + + ) : null} +
+ ) : null} + + {isLoading ? ( +
+
+
+
+
+
+
+
+
+

{t("evaluate.loading_hint")}

+
+ ) : null} + + {isError ? ( +
+

{t("evaluate.error_title")}

+

{t("evaluate.error_hint")}

+
+ ) : null} + + {!isLoading && !isError && assignments.length === 0 ? ( +
+

{t("evaluate.empty_title")}

+

{t("evaluate.empty_description")}

+ + {t("evaluate.back_to_panel")} + +
+ ) : null} + + {!isLoading && !isError && assignments.length > 0 ? ( +
+ {assignments.map((assignment) => ( + + ))} +
+ ) : null} + + + setSelectedAssignment(null)} /> + + ); +}; - return
{submissions.map((s) => )}
; -} - -export default EvaluateTournamentPage; \ No newline at end of file +export default EvaluateTournamentPage; diff --git a/frontend/src/pages/JuryPanel/JuryPanel.tsx b/frontend/src/pages/JuryPanel/JuryPanel.tsx index 3b68316..d9a6a5f 100644 --- a/frontend/src/pages/JuryPanel/JuryPanel.tsx +++ b/frontend/src/pages/JuryPanel/JuryPanel.tsx @@ -1,15 +1,94 @@ -import type { RootState } from "@/store"; -import { useSelector } from "react-redux"; +import { getJuryTasks } from "@/api/requests"; +import { auth } from "@/firebase"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + import TournamentCard from "./components/TournamentCard"; +import { juryTasksQueryKey } from "./juryQueryKeys"; +import { Hero } from "@/components/Hero"; +import { Stars } from "@/components/Stars" const JuryPanel = () => { - const user = useSelector((state: RootState) => state.user.user); - return
-

Jury panel

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

+ {t("panel.error_title")} +

+

{t("panel.error_hint")}

+
+ )} + + {!isLoading && !error && tasks.length === 0 && ( +
+
+ ☕ +
+

+ {t("panel.empty_title")} +

+

+ {t("panel.empty_description")} +

+
+ )} + + {!isLoading && !error && tasks.length > 0 && ( +
+ {tasks.map((task) => ( + + ))} +
+ )} +
+
+ ); +}; export default JuryPanel; \ No newline at end of file diff --git a/frontend/src/pages/JuryPanel/components/EvaluationsModal.tsx b/frontend/src/pages/JuryPanel/components/EvaluationsModal.tsx index e69de29..aced248 100644 --- a/frontend/src/pages/JuryPanel/components/EvaluationsModal.tsx +++ b/frontend/src/pages/JuryPanel/components/EvaluationsModal.tsx @@ -0,0 +1,192 @@ +import { saveAssignmentEvaluation, type JuryAssignment } from "@/api/requests"; +import { taskStatusByName } from "@/config/appConfig"; +import { auth } from "@/firebase"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +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; +} + +function initialRequirements(assignment: JuryAssignment): Record { + return Object.fromEntries( + (assignment.evaluation?.criterion_scores || []).map((item) => [item.criterion_id, item.score]), + ); +} + +function EvaluationsModalInner({ assignment, onClose }: { assignment: JuryAssignment; onClose: () => void }) { + const { t } = useTranslation("jury"); + const queryClient = useQueryClient(); + const [comment, setComment] = useState(() => assignment.evaluation?.comment || ""); + const [requirements, setRequirements] = useState(() => initialRequirements(assignment)); + + const mutation = useMutation({ + mutationFn: async () => { + if (!auth.currentUser) throw new Error("Missing assignment context"); + return saveAssignmentEvaluation( + assignment.id, + { + comment, + criterion_scores: assignment.task.criteria.map((criterion) => ({ + criterion_id: criterion.id, + score: requirements[criterion.id] ?? 0, + })), + }, + auth.currentUser, + !!assignment.evaluation, + ); + }, + 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(); + }, + }); + const isFinalized = assignment.task.status_id === taskStatusByName.evaluated.name; + + return ( +
+
+
+
+
+

{t("modal.eyebrow")}

+

+ {assignment.submission.team.name} +

+

{assignment.task.title}

+
+ +
+
+ +
+ {isFinalized ? ( +
+ {t("modal.finalized_banner")} +
+ ) : null} + +
+ {assignment.task.criteria.map((criterion) => ( + + ))} + +
+ +