diff --git a/services/backend/api/v1/review/schemas.py b/services/backend/api/v1/review/schemas.py index 8bd5ca9..65412d8 100644 --- a/services/backend/api/v1/review/schemas.py +++ b/services/backend/api/v1/review/schemas.py @@ -1,16 +1,14 @@ +from datetime import datetime from typing import Literal from uuid import UUID from ninja import ModelSchema, Schema +from pydantic import Field -from apps.review.models import Review, Reviewer +from apps.review.models import Review, Reviewer, ReviewStatusChoices from apps.task.models import CompetitionTaskSubmission -class PingOut(Schema): - status: str = "ok" - - class ReviewerOut(ModelSchema): id: UUID @@ -19,20 +17,81 @@ class ReviewerOut(ModelSchema): exclude = ("token",) +class CriteriaMarkOut(Schema): + slug: str + mark: float + + +class CriteriaOut(Schema): + name: str + slug: str + max_value: int + min_value: int + + class SubmissionOut(ModelSchema): id: UUID - status: Literal["sent", "checking", "checked"] + review_status: Literal["not_checked", "checked", "checking"] + evaluation: list[CriteriaMarkOut] | None = None + criteries: list[CriteriaOut] | None = None + submitted_at: datetime = Field(..., alias="timestamp") + + @staticmethod + def resolve_criteries(self, context) -> list[CriteriaOut] | None: + criteries = self.task.criteries + return criteries + + @staticmethod + def resolve_evaluation(self, context) -> list[CriteriaMarkOut] | None: + if not ( + review := Review.objects.filter( + reviewer=context.get("request").auth, submission=self + ).first() + ): + return None + return review.evaluation + + @staticmethod + def resolve_review_status(self, context): + reviewer = context.get("request").auth + if not ( + review := Review.objects.filter( + reviewer=reviewer, submission=self + ).first() + ): + return ReviewStatusChoices.NOT_CHECKED.value + return review.state class Meta: model = CompetitionTaskSubmission - exclude = ("user",) + fields = ( + "id", + "task", + "content", + "stdout", + "result", + "earned_points", + "reviewed_at", + ) + + +class CriteriaMarkIn(Schema): + slug: str + mark: float + + +class EvaluationIn(Schema): + evaluation: list[CriteriaMarkIn] class SubmissionsOut(Schema): - submissions: list = None + submissions: list[SubmissionOut | None] = [] @staticmethod - def resolve_submissions(self, context) -> list[SubmissionOut]: - return list( - Review.objects.filter(reviewer=context.get("request").auth) + def resolve_submissions(self, context) -> list[SubmissionOut | None]: + submissions = list( + CompetitionTaskSubmission.objects.filter( + reviews__reviewer=context.get("request").auth + ) ) + return submissions diff --git a/services/backend/api/v1/review/views.py b/services/backend/api/v1/review/views.py index dbe0833..f8a83e8 100644 --- a/services/backend/api/v1/review/views.py +++ b/services/backend/api/v1/review/views.py @@ -1,3 +1,4 @@ +from datetime import datetime from http import HTTPStatus as status from uuid import UUID @@ -7,31 +8,19 @@ from ninja import Router from api.v1 import schemas as global_schemas from api.v1.review import schemas +from apps.review.models import Review, ReviewStatusChoices from apps.task.models import CompetitionTaskSubmission router = Router(tags=["review"]) -@router.get( - "{token}/submissions", - response={ - status.OK: schemas.SubmissionsOut, - }, - description="Список отправок, на проверку которых назначен ревьюер", -) -def get_submissions( - request: HttpRequest, token: str -) -> tuple[status, schemas.SubmissionsOut]: - return status.OK, schemas.SubmissionsOut() - - @router.get( "{token}", response={ status.OK: schemas.ReviewerOut, status.UNAUTHORIZED: global_schemas.UnauthorizedError, }, - description="token есть и в сваггер авторизации, но оно не работает, не верьте. подставляйте токен вручную в query", + description="token есть и в сваггер авторизации, но оно не работает, не верьте. подставляйте токен вручную в path", ) def get_reviewer_profile(request: HttpRequest, token: str): return status.OK, request.auth @@ -47,4 +36,56 @@ def get_submission( request: HttpRequest, token: str, submition_id: UUID ) -> tuple[status, schemas.SubmissionsOut]: submission = get_object_or_404(CompetitionTaskSubmission, id=submition_id) + reviewer = request.auth + + review = Review.objects.get(reviewer=reviewer, submission=submission) + if review.state == ReviewStatusChoices.NOT_CHECKED.value: + review.state = ReviewStatusChoices.CHECKING.value + review.save() + return status.OK, submission + + +@router.get( + "{token}/submissions", + response={ + status.OK: schemas.SubmissionsOut, + }, + description="Список отправок, на проверку которых назначен ревьюер", +) +def get_submissions( + request: HttpRequest, token: str +) -> tuple[status, schemas.SubmissionsOut]: + return status.OK, schemas.SubmissionsOut() + + +@router.post( + "{token}/submissions/{submition_id}/evaluate", + response={ + status.OK: schemas.SubmissionOut, + }, + description="Оценка посылки. В body отправляется список с slug критерия и оценкой по этому критерию", +) +def evaluate_submission( + request: HttpRequest, + token: str, + submition_id: UUID, + evaluation_info: schemas.EvaluationIn, +) -> tuple[status, schemas.SubmissionsOut]: + submission = get_object_or_404(CompetitionTaskSubmission, id=submition_id) + reviewer = request.auth + + review = Review.objects.get(reviewer=reviewer, submission=submission) + evaluation = evaluation_info.dict()["evaluation"] + review.evaluation = evaluation + review.state = ReviewStatusChoices.CHECKED.value + review.submission.reviewed_at = datetime.now() + + points = 0 + for criterea in evaluation: + points += criterea["mark"] + review.submission.earned_points = points + + review.save() + + return status.OK, review.submission diff --git a/services/backend/api/v1/user/views.py b/services/backend/api/v1/user/views.py index ff24ded..4bf0a2d 100644 --- a/services/backend/api/v1/user/views.py +++ b/services/backend/api/v1/user/views.py @@ -6,7 +6,12 @@ from ninja import Router from ninja.errors import AuthenticationError from api.v1.auth import BearerAuth -from api.v1.schemas import BadRequestError, ForbiddenError, NotFoundError, ConflictError +from api.v1.schemas import ( + BadRequestError, + ConflictError, + ForbiddenError, + NotFoundError, +) from api.v1.user.schemas import ( LoginSchema, RegisterSchema, diff --git a/services/backend/apps/competition/migrations/0001_initial.py b/services/backend/apps/competition/migrations/0001_initial.py index bb60448..944d20b 100644 --- a/services/backend/apps/competition/migrations/0001_initial.py +++ b/services/backend/apps/competition/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 5.1.6 on 2025-03-01 10:26 +# Generated by Django 5.1.6 on 2025-03-01 20:35 +import apps.competition.models import datetime import django.db.models.deletion import uuid @@ -11,7 +12,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('user', '0001_initial'), + ('user', '0002_alter_user_email_alter_user_password_and_more'), ] operations = [ @@ -19,14 +20,14 @@ class Migration(migrations.Migration): name='Competition', fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('title', models.CharField(max_length=100, verbose_name='Название')), - ('description', models.TextField(verbose_name='Описание')), - ('image_url', models.FileField(blank=True, null=True, upload_to='', verbose_name='Изображение соревнования')), - ('end_date', models.DateTimeField(blank=True, null=True, verbose_name='Дедлайн участия')), - ('start_date', models.DateTimeField(blank=True, null=True, verbose_name='Дедлайн участия')), - ('type', models.CharField(choices=[('solo', 'Solo')], max_length=10, verbose_name='Тип участия')), - ('participation_type', models.CharField(choices=[('edu', 'Edu'), ('competitive', 'Competitive')], max_length=11, verbose_name='Тип соревнования')), - ('participants', models.ManyToManyField(related_name='participants', to='user.user')), + ('title', models.CharField(max_length=100, verbose_name='название')), + ('description', models.TextField(verbose_name='описание')), + ('image_url', models.FileField(blank=True, null=True, upload_to=apps.competition.models.Competition.image_url_upload_to, verbose_name='изображение соревнования')), + ('end_date', models.DateTimeField(blank=True, null=True, verbose_name='дедлайн участия')), + ('start_date', models.DateTimeField(blank=True, null=True, verbose_name='дедлайн участия')), + ('type', models.CharField(choices=[('solo', 'Индивидуальный')], max_length=10, verbose_name='тип участия')), + ('participation_type', models.CharField(choices=[('edu', 'Образовательный'), ('competitive', 'Соревновательный')], max_length=11, verbose_name='тип соревнования')), + ('participants', models.ManyToManyField(blank=True, editable=False, related_name='participants', to='user.user')), ], options={ 'verbose_name': 'соревнование', diff --git a/services/backend/apps/competition/migrations/0002_competition_tasks_alter_competition_participants_and_more.py b/services/backend/apps/competition/migrations/0002_competition_tasks_alter_competition_participants_and_more.py deleted file mode 100644 index 5cfcfdf..0000000 --- a/services/backend/apps/competition/migrations/0002_competition_tasks_alter_competition_participants_and_more.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-01 12:21 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('competition', '0001_initial'), - ('task', '0001_initial'), - ('user', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='competition', - name='tasks', - field=models.ManyToManyField(blank=True, related_name='tasks', to='task.competitiontask'), - ), - migrations.AlterField( - model_name='competition', - name='participants', - field=models.ManyToManyField(blank=True, editable=False, related_name='participants', to='user.user'), - ), - migrations.AlterField( - model_name='competition', - name='participation_type', - field=models.CharField(choices=[('edu', 'Образовательный'), ('competitive', 'Соревновательный')], max_length=11, verbose_name='Тип соревнования'), - ), - migrations.AlterField( - model_name='competition', - name='type', - field=models.CharField(choices=[('solo', 'Индивидуальный')], max_length=10, verbose_name='Тип участия'), - ), - ] diff --git a/services/backend/apps/competition/migrations/0003_remove_competition_tasks.py b/services/backend/apps/competition/migrations/0003_remove_competition_tasks.py deleted file mode 100644 index b03500b..0000000 --- a/services/backend/apps/competition/migrations/0003_remove_competition_tasks.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-01 13:49 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('competition', '0002_competition_tasks_alter_competition_participants_and_more'), - ] - - operations = [ - migrations.RemoveField( - model_name='competition', - name='tasks', - ), - ] diff --git a/services/backend/apps/competition/migrations/0004_alter_competition_description_and_more.py b/services/backend/apps/competition/migrations/0004_alter_competition_description_and_more.py deleted file mode 100644 index d5f462e..0000000 --- a/services/backend/apps/competition/migrations/0004_alter_competition_description_and_more.py +++ /dev/null @@ -1,49 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-01 14:46 - -import tinymce.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('competition', '0003_remove_competition_tasks'), - ] - - operations = [ - migrations.AlterField( - model_name='competition', - name='description', - field=tinymce.models.HTMLField(verbose_name='описание'), - ), - migrations.AlterField( - model_name='competition', - name='end_date', - field=models.DateTimeField(blank=True, null=True, verbose_name='дедлайн участия'), - ), - migrations.AlterField( - model_name='competition', - name='image_url', - field=models.FileField(blank=True, null=True, upload_to='', verbose_name='изображение соревнования'), - ), - migrations.AlterField( - model_name='competition', - name='participation_type', - field=models.CharField(choices=[('edu', 'Образовательный'), ('competitive', 'Соревновательный')], max_length=11, verbose_name='тип соревнования'), - ), - migrations.AlterField( - model_name='competition', - name='start_date', - field=models.DateTimeField(blank=True, null=True, verbose_name='дедлайн участия'), - ), - migrations.AlterField( - model_name='competition', - name='title', - field=models.CharField(max_length=100, verbose_name='аазвание'), - ), - migrations.AlterField( - model_name='competition', - name='type', - field=models.CharField(choices=[('solo', 'Индивидуальный')], max_length=10, verbose_name='тип участия'), - ), - ] diff --git a/services/backend/apps/competition/tests.py b/services/backend/apps/competition/tests.py index f9508a2..8c33351 100644 --- a/services/backend/apps/competition/tests.py +++ b/services/backend/apps/competition/tests.py @@ -12,14 +12,14 @@ class CompetitionEndpointTests(TestCase): self.user = User.objects.create( email="user@example.com", password=make_password("password123"), - username="t1wk4" + username="t1wk4", ) self.competition = Competition.objects.create( title="AI Challenge", description="Machine Learning Competition", type="solo", - participation_type="edu" + participation_type="edu", ) resp = self.client.post( @@ -29,9 +29,7 @@ class CompetitionEndpointTests(TestCase): ).json() token = resp["token"] - self.valid_headers = { - "HTTP_AUTHORIZATION": f"Bearer {token}" - } + self.valid_headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"} # --- Helper methods --- def get_url(self, competition_id): @@ -41,8 +39,7 @@ class CompetitionEndpointTests(TestCase): def test_get_competition_success(self): """Authenticated user gets competition details (200 OK)""" response = self.client.get( - self.get_url(self.competition.id), - **self.valid_headers + self.get_url(self.competition.id), **self.valid_headers ) self.assertEqual(response.status_code, 200) @@ -61,8 +58,7 @@ class CompetitionEndpointTests(TestCase): def test_invalid_uuid_format(self): """Invalid UUID format returns 400 Bad Request""" response = self.client.get( - self.get_url("invalid-id"), - **self.valid_headers + self.get_url("invalid-id"), **self.valid_headers ) self.assertEqual(response.status_code, 400) @@ -76,8 +72,7 @@ class CompetitionEndpointTests(TestCase): """Valid UUID but missing competition returns 404""" new_uuid = uuid.uuid4() response = self.client.get( - self.get_url(new_uuid), - **self.valid_headers + self.get_url(new_uuid), **self.valid_headers ) self.assertEqual(response.status_code, 404) self.assertEqual(response.json()["detail"], "Not Found") @@ -86,7 +81,7 @@ class CompetitionEndpointTests(TestCase): """Invalid token returns 401 Unauthorized""" response = self.client.get( self.get_url(self.competition.id), - HTTP_AUTHORIZATION="Bearer invalid_token" + HTTP_AUTHORIZATION="Bearer invalid_token", ) self.assertEqual(response.status_code, 401) self.assertEqual(response.json()["detail"], "Unauthorized") @@ -103,6 +98,6 @@ class CompetitionEndpointTests(TestCase): with self.subTest(header=header): response = self.client.get( self.get_url(self.competition.id), - HTTP_AUTHORIZATION=header + HTTP_AUTHORIZATION=header, ) self.assertEqual(response.status_code, expected_status) diff --git a/services/backend/apps/core/management/commands/generate_data.py b/services/backend/apps/core/management/commands/generate_data.py index e68394f..04b8c73 100644 --- a/services/backend/apps/core/management/commands/generate_data.py +++ b/services/backend/apps/core/management/commands/generate_data.py @@ -8,6 +8,7 @@ from django.core.management.base import BaseCommand from django.utils import timezone from apps.competition.models import Competition, State +from apps.review.models import Review, Reviewer from apps.task.models import CompetitionTask, CompetitionTaskSubmission from apps.user.models import User, UserRole @@ -20,10 +21,22 @@ class Command(BaseCommand): users = self.create_users(5) competitions = self.create_competitions(2, users) tasks = self.create_tasks(competitions) + self.reviewers = self.create_reviewers(1) self.create_submissions(tasks, users) self.create_states(competitions, users) self.stdout.write("Data generation completed.") + def create_reviewers(self, count): + reviewers = [] + for i in range(count): + name = f"John_{i}" + surname = f"Smith_{i}" + token = random.randint(100000, 999999) + reviewer = Reviewer(name=name, surname=surname, token=token) + reviewer.save() + reviewers.append(reviewer) + return reviewers + def create_users(self, count): users = [] for i in range(1, count + 1): @@ -89,6 +102,7 @@ class Command(BaseCommand): description=description, type=task_type, points=random.randint(1, 10), + max_attempts=random.randint(1, 10), ) tasks.append(task) self.stdout.write(f"Created task: {title} (type: {task_type})") @@ -117,6 +131,15 @@ class Command(BaseCommand): self.stdout.write( f"Created submission for task '{task.title}' by user '{user.username}'" ) + self.add_reviewers(submission) + + def add_reviewers(self, submission): + for reviewer in self.reviewers: + if random.choice([True, False]): + Review.objects.create( + submission=submission, + reviewer=reviewer, + ) def create_states(self, competitions, users): # For each competition, create a State for some of its participants diff --git a/services/backend/apps/review/migrations/0001_initial.py b/services/backend/apps/review/migrations/0001_initial.py index ceed39d..3a62d5a 100644 --- a/services/backend/apps/review/migrations/0001_initial.py +++ b/services/backend/apps/review/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-01 08:47 +# Generated by Django 5.1.6 on 2025-03-01 20:35 import uuid from django.db import migrations, models @@ -12,6 +12,17 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='Review', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('evaluation', models.JSONField(blank=True, default=list, null=True)), + ('state', models.CharField(choices=[('not_checked', 'Not Checked'), ('checking', 'Checking'), ('checked', 'Checked')], default='not_checked', max_length=11)), + ], + options={ + 'abstract': False, + }, + ), migrations.CreateModel( name='Reviewer', fields=[ diff --git a/services/backend/apps/review/migrations/0002_initial.py b/services/backend/apps/review/migrations/0002_initial.py new file mode 100644 index 0000000..8b2836c --- /dev/null +++ b/services/backend/apps/review/migrations/0002_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.6 on 2025-03-01 20:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('review', '0001_initial'), + ('task', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='review', + name='submission', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='task.competitiontasksubmission'), + ), + migrations.AddField( + model_name='review', + name='reviewer', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='review.reviewer'), + ), + ] diff --git a/services/backend/apps/review/migrations/0002_review.py b/services/backend/apps/review/migrations/0002_review.py deleted file mode 100644 index c9ded38..0000000 --- a/services/backend/apps/review/migrations/0002_review.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-01 14:47 - -import django.db.models.deletion -import uuid -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('review', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='Review', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('state', models.CharField(choices=[('not_checked', 'Not Checked'), ('checking', 'Checking'), ('checked', 'Checked')], max_length=11)), - ('reviewer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='review.reviewer')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/services/backend/apps/review/migrations/0003_review_submission.py b/services/backend/apps/review/migrations/0003_review_submission.py deleted file mode 100644 index fd976b0..0000000 --- a/services/backend/apps/review/migrations/0003_review_submission.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-01 14:47 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('review', '0002_review'), - ('task', '0005_alter_competitiontask_description_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='review', - name='submission', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontasksubmission'), - ), - ] diff --git a/services/backend/apps/review/models.py b/services/backend/apps/review/models.py index ee84c9f..633c6fd 100644 --- a/services/backend/apps/review/models.py +++ b/services/backend/apps/review/models.py @@ -11,17 +11,23 @@ class Reviewer(BaseModel): token = models.CharField(max_length=100) -class Review(BaseModel): - class ReviewStatusChoices(models.TextChoices): - NOT_CHECKED = "not_checked" - CHECKING = "checking" - CHECKED = "checked" +class ReviewStatusChoices(models.TextChoices): + NOT_CHECKED = "not_checked" + CHECKING = "checking" + CHECKED = "checked" + +class Review(BaseModel): reviewer = models.ForeignKey(Reviewer, on_delete=models.CASCADE) submission = models.ForeignKey( - CompetitionTaskSubmission, on_delete=models.CASCADE + CompetitionTaskSubmission, + on_delete=models.CASCADE, + related_name="reviews", ) + evaluation = models.JSONField(default=list, null=True, blank=True) state = models.CharField( - choices=ReviewStatusChoices.choices, max_length=11 + choices=ReviewStatusChoices.choices, + default=ReviewStatusChoices.NOT_CHECKED.value, + max_length=11, ) diff --git a/services/backend/apps/task/migrations/0001_initial.py b/services/backend/apps/task/migrations/0001_initial.py index e65c59e..7f649f3 100644 --- a/services/backend/apps/task/migrations/0001_initial.py +++ b/services/backend/apps/task/migrations/0001_initial.py @@ -1,7 +1,8 @@ -# Generated by Django 5.1.6 on 2025-03-01 10:26 +# Generated by Django 5.1.6 on 2025-03-01 20:35 import apps.task.models import django.db.models.deletion +import tinymce.models import uuid from django.db import migrations, models @@ -12,7 +13,7 @@ class Migration(migrations.Migration): dependencies = [ ('competition', '0001_initial'), - ('user', '0001_initial'), + ('user', '0002_alter_user_email_alter_user_password_and_more'), ] operations = [ @@ -20,21 +21,35 @@ class Migration(migrations.Migration): name='CompetitionTask', fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('title', models.TextField(max_length=50, verbose_name='заголовок')), - ('description', models.TextField(max_length=300, verbose_name='описание')), - ('type', models.CharField(choices=[('input', 'Input'), ('checker', 'Checker'), ('review', 'Review')], max_length=8)), - ('correct_answer_file', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to)), - ('points', models.IntegerField(blank=True, null=True)), - ('answer_file_path', models.TextField(blank=True, null=True)), - ('criteries', models.JSONField(blank=True, null=True)), + ('title', models.CharField(max_length=50, verbose_name='заголовок')), + ('description', tinymce.models.HTMLField(max_length=300, verbose_name='описание')), + ('max_attempts', models.PositiveSmallIntegerField(blank=True, null=True)), + ('type', models.CharField(choices=[('input', 'Ввод правильного ответа'), ('checker', 'Вывод кода'), ('review', 'Ручная')], max_length=8, verbose_name='тип проверки')), + ('correct_answer_file', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to, verbose_name='файл с правильным ответом')), + ('points', models.IntegerField(blank=True, null=True, verbose_name='баллы за задание')), + ('answer_file_path', models.TextField(blank=True, default='stdout', null=True, verbose_name='куда сохранять решения')), + ('criteries', models.JSONField(blank=True, null=True, verbose_name='критерии')), ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')), ], + options={ + 'verbose_name_plural': 'задания', + }, + ), + migrations.CreateModel( + name='CompetitionTaskAttachment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file', models.FileField(upload_to=apps.task.models.CompetitionTaskAttachment.file_upload_at)), + ('bind_at', models.FilePathField()), + ('public', models.BooleanField(default=False)), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask')), + ], options={ 'abstract': False, }, ), migrations.CreateModel( - name='CompetetionTaskSumbission', + name='CompetitionTaskSubmission', fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('status', models.CharField(choices=[('sent', 'Sent'), ('checking', 'Checking'), ('checked', 'Checked')], default='sent', max_length=8)), @@ -42,9 +57,10 @@ class Migration(migrations.Migration): ('stdout', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTaskSubmission.submission_stdout_upload_to)), ('result', models.JSONField(blank=True, default=None, null=True)), ('earned_points', models.IntegerField()), + ('reviewed_at', models.DateTimeField(blank=True, null=True)), ('timestamp', models.DateTimeField(auto_now_add=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')), ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')), ], options={ 'abstract': False, diff --git a/services/backend/apps/task/migrations/0002_alter_competitiontask_options_and_more.py b/services/backend/apps/task/migrations/0002_alter_competitiontask_options_and_more.py deleted file mode 100644 index 4a14698..0000000 --- a/services/backend/apps/task/migrations/0002_alter_competitiontask_options_and_more.py +++ /dev/null @@ -1,45 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-01 12:21 - -import apps.task.models -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('competition', '0002_competition_tasks_alter_competition_participants_and_more'), - ('task', '0001_initial'), - ] - - operations = [ - migrations.AlterModelOptions( - name='competitiontask', - options={'verbose_name': 'задание', 'verbose_name_plural': 'задания'}, - ), - migrations.AlterField( - model_name='competitiontask', - name='competition', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition', verbose_name='соревнование'), - ), - migrations.AlterField( - model_name='competitiontask', - name='correct_answer_file', - field=models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to, verbose_name='правильный ответ'), - ), - migrations.AlterField( - model_name='competitiontask', - name='criteries', - field=models.JSONField(blank=True, null=True, verbose_name='критерии проверки'), - ), - migrations.AlterField( - model_name='competitiontask', - name='points', - field=models.IntegerField(blank=True, null=True, verbose_name='баллы за задание'), - ), - migrations.AlterField( - model_name='competitiontask', - name='type', - field=models.CharField(choices=[('input', 'Input'), ('checker', 'Checker'), ('review', 'Review')], max_length=8, verbose_name='тип задания'), - ), - ] diff --git a/services/backend/apps/task/migrations/0002_competetiontasksumbission_reviewers.py b/services/backend/apps/task/migrations/0002_competetiontasksumbission_reviewers.py deleted file mode 100644 index bda15a2..0000000 --- a/services/backend/apps/task/migrations/0002_competetiontasksumbission_reviewers.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-01 12:54 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('review', '0001_initial'), - ('task', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='competetiontasksumbission', - name='reviewers', - field=models.ManyToManyField(blank=True, related_name='reviewers', to='review.reviewer'), - ), - ] diff --git a/services/backend/apps/task/migrations/0003_competitiontask_max_attemps_and_more.py b/services/backend/apps/task/migrations/0003_competitiontask_max_attemps_and_more.py deleted file mode 100644 index 039cbdf..0000000 --- a/services/backend/apps/task/migrations/0003_competitiontask_max_attemps_and_more.py +++ /dev/null @@ -1,51 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-01 13:49 - -import apps.task.models -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('competition', '0003_remove_competition_tasks'), - ('task', '0002_alter_competitiontask_options_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='competitiontask', - name='max_attemps', - field=models.PositiveSmallIntegerField(default=0), - ), - migrations.AlterField( - model_name='competitiontask', - name='answer_file_path', - field=models.TextField(blank=True, default='stdout', null=True, verbose_name='куда сохранять решения'), - ), - migrations.AlterField( - model_name='competitiontask', - name='competition', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition'), - ), - migrations.AlterField( - model_name='competitiontask', - name='correct_answer_file', - field=models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to, verbose_name='файл с правильным ответом'), - ), - migrations.AlterField( - model_name='competitiontask', - name='criteries', - field=models.JSONField(blank=True, null=True, verbose_name='критерии'), - ), - migrations.AlterField( - model_name='competitiontask', - name='title', - field=models.CharField(max_length=50, verbose_name='заголовок'), - ), - migrations.AlterField( - model_name='competitiontask', - name='type', - field=models.CharField(choices=[('input', 'Ввод правильного ответа'), ('checker', 'Вывод кода'), ('review', 'Ручная')], max_length=8, verbose_name='тип проверки'), - ), - ] diff --git a/services/backend/apps/task/migrations/0004_merge_20250301_1739.py b/services/backend/apps/task/migrations/0004_merge_20250301_1739.py deleted file mode 100644 index 8d06cf8..0000000 --- a/services/backend/apps/task/migrations/0004_merge_20250301_1739.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-01 14:39 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('task', '0002_competetiontasksumbission_reviewers'), - ('task', '0003_competitiontask_max_attemps_and_more'), - ] - - operations = [ - ] diff --git a/services/backend/apps/task/migrations/0005_alter_competitiontask_description_and_more.py b/services/backend/apps/task/migrations/0005_alter_competitiontask_description_and_more.py deleted file mode 100644 index fb0d89d..0000000 --- a/services/backend/apps/task/migrations/0005_alter_competitiontask_description_and_more.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-01 14:47 - -import apps.task.models -import django.db.models.deletion -import tinymce.models -import uuid -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('task', '0004_merge_20250301_1739'), - ('user', '0002_alter_user_email_alter_user_password_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='competitiontask', - name='description', - field=tinymce.models.HTMLField(max_length=300, verbose_name='описание'), - ), - migrations.AlterField( - model_name='competitiontask', - name='max_attemps', - field=models.PositiveSmallIntegerField(), - ), - migrations.CreateModel( - name='CompetitionTaskSubmission', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('status', models.CharField(choices=[('sent', 'Sent'), ('checking', 'Checking'), ('checked', 'Checked')], default='sent', max_length=8)), - ('content', models.FileField(upload_to=apps.task.models.CompetitionTaskSubmission.submission_content_upload_to)), - ('stdout', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTaskSubmission.submission_stdout_upload_to)), - ('result', models.JSONField(blank=True, default=None, null=True)), - ('earned_points', models.IntegerField()), - ('timestamp', models.DateTimeField(auto_now_add=True)), - ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')), - ], - options={ - 'abstract': False, - }, - ), - migrations.DeleteModel( - name='CompetetionTaskSumbission', - ), - ] diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index dbee8da..2b3f33c 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -21,7 +21,7 @@ class CompetitionTask(BaseModel): competition = models.ForeignKey(Competition, on_delete=models.CASCADE) title = models.CharField(verbose_name="заголовок", max_length=50) description = HTMLField(verbose_name="описание", max_length=300) - max_attemps = models.PositiveSmallIntegerField() + max_attempts = models.PositiveSmallIntegerField(null=True, blank=True) type = models.CharField( choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки" ) @@ -110,4 +110,5 @@ class CompetitionTaskSubmission(BaseModel): # just more readable result representation, maybe will be calcuated somehow more complex depends on criteria earned_points = models.IntegerField() + reviewed_at = models.DateTimeField(null=True, blank=True) timestamp = models.DateTimeField(auto_now_add=True) diff --git a/services/backend/apps/user/test.py b/services/backend/apps/user/test.py index 33bbc41..929a4e7 100644 --- a/services/backend/apps/user/test.py +++ b/services/backend/apps/user/test.py @@ -1,8 +1,8 @@ import json import uuid -from django.test import TestCase from django.contrib.auth.hashers import make_password +from django.test import TestCase from apps.user.models import User @@ -47,7 +47,9 @@ class SignUpAPITestCase(TestCase): def test_existing_user_conflict(self): User.objects.create( - email="existing@example.com", password="existingpass123", username="testing" + email="existing@example.com", + password="existingpass123", + username="testing", ) payload = { "email": "existing@example.com", @@ -62,23 +64,24 @@ class SignUpAPITestCase(TestCase): self.assertEqual(response.status_code, 409) self.assertIn("detail", response.json()) + class SignInAPITestCase(TestCase): def setUp(self): self.user = User.objects.create( email="valid@example.com", password=make_password("securepassword123"), - username="testuser" + username="testuser", ) self.valid_payload = { "email": "valid@example.com", - "password": "securepassword123" + "password": "securepassword123", } def test_successful_sign_in(self): response = self.client.post( "/api/v1/sign-in", data=json.dumps(self.valid_payload), - content_type="application/json" + content_type="application/json", ) self.assertEqual(response.status_code, 200) self.assertIn("token", response.json()) @@ -88,7 +91,7 @@ class SignInAPITestCase(TestCase): response = self.client.post( "/api/v1/sign-in", data=json.dumps({"password": "pass"}), - content_type="application/json" + content_type="application/json", ) self.assertEqual(response.status_code, 400) @@ -96,44 +99,35 @@ class SignInAPITestCase(TestCase): response = self.client.post( "/api/v1/sign-in", data=json.dumps({"email": "test@example.com"}), - content_type="application/json" + content_type="application/json", ) self.assertEqual(response.status_code, 400) def test_invalid_email_format(self): - payload = { - "email": "invalid-email", - "password": "password123" - } + payload = {"email": "invalid-email", "password": "password123"} response = self.client.post( "/api/v1/sign-in", data=json.dumps(payload), - content_type="application/json" + content_type="application/json", ) self.assertEqual(response.status_code, 401) def test_incorrect_password(self): - payload = { - "email": "valid@example.com", - "password": "wrongpassword" - } + payload = {"email": "valid@example.com", "password": "wrongpassword"} response = self.client.post( "/api/v1/sign-in", data=json.dumps(payload), - content_type="application/json" + content_type="application/json", ) self.assertEqual(response.status_code, 401) self.assertEqual(response.json()["detail"], "Unauthorized") def test_nonexistent_user(self): - payload = { - "email": "notexist@example.com", - "password": "password123" - } + payload = {"email": "notexist@example.com", "password": "password123"} response = self.client.post( "/api/v1/sign-in", data=json.dumps(payload), - content_type="application/json" + content_type="application/json", ) self.assertEqual(response.status_code, 401) self.assertEqual(response.json()["detail"], "Unauthorized") @@ -145,21 +139,25 @@ class UserMeEndpointTestCase(TestCase): self.user = User.objects.create( email="johndoe@example.com", username="johndoe", - password=make_password("securepassword123") + password=make_password("securepassword123"), ) resp = self.client.post( "/api/v1/sign-in", - data=json.dumps({"email": "johndoe@example.com", "password": "securepassword123"}), - content_type="application/json" + data=json.dumps( + { + "email": "johndoe@example.com", + "password": "securepassword123", + } + ), + content_type="application/json", ).json() - self.token = resp['token'] + self.token = resp["token"] self.url = "/api/v1/me" def test_get_authenticated_user_data(self): """Test authenticated user can retrieve their profile (200 OK)""" response = self.client.get( - self.url, - HTTP_AUTHORIZATION=f"Bearer {self.token}" + self.url, HTTP_AUTHORIZATION=f"Bearer {self.token}" ) self.assertEqual(response.status_code, 200) @@ -191,8 +189,7 @@ class UserMeEndpointTestCase(TestCase): def test_invalid_auth_scheme(self): """Test invalid authentication scheme returns 401""" response = self.client.get( - self.url, - HTTP_AUTHORIZATION=f"InvalidScheme {self.token}" + self.url, HTTP_AUTHORIZATION=f"InvalidScheme {self.token}" ) self.assertEqual(response.status_code, 401) @@ -200,18 +197,12 @@ class UserMeEndpointTestCase(TestCase): def test_malformed_token(self): """Test malformed token returns 401""" - test_cases = [ - "invalid.token.123", - "Bearer", - "", - "123456" - ] + test_cases = ["invalid.token.123", "Bearer", "", "123456"] for token in test_cases: with self.subTest(token=token): response = self.client.get( - self.url, - HTTP_AUTHORIZATION=f"Bearer {token}" + self.url, HTTP_AUTHORIZATION=f"Bearer {token}" ) self.assertEqual(response.status_code, 401) self.assertEqual(response.json()["detail"], "Unauthorized")