feat: added review module

This commit is contained in:
Андрей Сумин
2025-03-01 23:58:14 +03:00
parent 27afa70207
commit cc2693986d
22 changed files with 283 additions and 431 deletions
+70 -11
View File
@@ -1,16 +1,14 @@
from datetime import datetime
from typing import Literal from typing import Literal
from uuid import UUID from uuid import UUID
from ninja import ModelSchema, Schema 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 from apps.task.models import CompetitionTaskSubmission
class PingOut(Schema):
status: str = "ok"
class ReviewerOut(ModelSchema): class ReviewerOut(ModelSchema):
id: UUID id: UUID
@@ -19,20 +17,81 @@ class ReviewerOut(ModelSchema):
exclude = ("token",) 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): class SubmissionOut(ModelSchema):
id: UUID 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: class Meta:
model = CompetitionTaskSubmission 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): class SubmissionsOut(Schema):
submissions: list = None submissions: list[SubmissionOut | None] = []
@staticmethod @staticmethod
def resolve_submissions(self, context) -> list[SubmissionOut]: def resolve_submissions(self, context) -> list[SubmissionOut | None]:
return list( submissions = list(
Review.objects.filter(reviewer=context.get("request").auth) CompetitionTaskSubmission.objects.filter(
reviews__reviewer=context.get("request").auth
)
) )
return submissions
+55 -14
View File
@@ -1,3 +1,4 @@
from datetime import datetime
from http import HTTPStatus as status from http import HTTPStatus as status
from uuid import UUID from uuid import UUID
@@ -7,31 +8,19 @@ from ninja import Router
from api.v1 import schemas as global_schemas from api.v1 import schemas as global_schemas
from api.v1.review import schemas from api.v1.review import schemas
from apps.review.models import Review, ReviewStatusChoices
from apps.task.models import CompetitionTaskSubmission from apps.task.models import CompetitionTaskSubmission
router = Router(tags=["review"]) 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( @router.get(
"{token}", "{token}",
response={ response={
status.OK: schemas.ReviewerOut, status.OK: schemas.ReviewerOut,
status.UNAUTHORIZED: global_schemas.UnauthorizedError, status.UNAUTHORIZED: global_schemas.UnauthorizedError,
}, },
description="token есть и в сваггер авторизации, но оно не работает, не верьте. подставляйте токен вручную в query", description="token есть и в сваггер авторизации, но оно не работает, не верьте. подставляйте токен вручную в path",
) )
def get_reviewer_profile(request: HttpRequest, token: str): def get_reviewer_profile(request: HttpRequest, token: str):
return status.OK, request.auth return status.OK, request.auth
@@ -47,4 +36,56 @@ def get_submission(
request: HttpRequest, token: str, submition_id: UUID request: HttpRequest, token: str, submition_id: UUID
) -> tuple[status, schemas.SubmissionsOut]: ) -> tuple[status, schemas.SubmissionsOut]:
submission = get_object_or_404(CompetitionTaskSubmission, id=submition_id) 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 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
+6 -1
View File
@@ -6,7 +6,12 @@ from ninja import Router
from ninja.errors import AuthenticationError from ninja.errors import AuthenticationError
from api.v1.auth import BearerAuth 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 ( from api.v1.user.schemas import (
LoginSchema, LoginSchema,
RegisterSchema, RegisterSchema,
@@ -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 datetime
import django.db.models.deletion import django.db.models.deletion
import uuid import uuid
@@ -11,7 +12,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('user', '0001_initial'), ('user', '0002_alter_user_email_alter_user_password_and_more'),
] ]
operations = [ operations = [
@@ -19,14 +20,14 @@ class Migration(migrations.Migration):
name='Competition', name='Competition',
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=100, verbose_name='Название')), ('title', models.CharField(max_length=100, verbose_name='название')),
('description', models.TextField(verbose_name='Описание')), ('description', models.TextField(verbose_name='описание')),
('image_url', models.FileField(blank=True, null=True, upload_to='', 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='Дедлайн участия')), ('end_date', models.DateTimeField(blank=True, null=True, verbose_name='дедлайн участия')),
('start_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='Тип участия')), ('type', models.CharField(choices=[('solo', 'Индивидуальный')], max_length=10, verbose_name='тип участия')),
('participation_type', models.CharField(choices=[('edu', 'Edu'), ('competitive', 'Competitive')], max_length=11, verbose_name='Тип соревнования')), ('participation_type', models.CharField(choices=[('edu', 'Образовательный'), ('competitive', 'Соревновательный')], max_length=11, verbose_name='тип соревнования')),
('participants', models.ManyToManyField(related_name='participants', to='user.user')), ('participants', models.ManyToManyField(blank=True, editable=False, related_name='participants', to='user.user')),
], ],
options={ options={
'verbose_name': 'соревнование', 'verbose_name': 'соревнование',
@@ -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='Тип участия'),
),
]
@@ -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',
),
]
@@ -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='тип участия'),
),
]
+8 -13
View File
@@ -12,14 +12,14 @@ class CompetitionEndpointTests(TestCase):
self.user = User.objects.create( self.user = User.objects.create(
email="user@example.com", email="user@example.com",
password=make_password("password123"), password=make_password("password123"),
username="t1wk4" username="t1wk4",
) )
self.competition = Competition.objects.create( self.competition = Competition.objects.create(
title="AI Challenge", title="AI Challenge",
description="Machine Learning Competition", description="Machine Learning Competition",
type="solo", type="solo",
participation_type="edu" participation_type="edu",
) )
resp = self.client.post( resp = self.client.post(
@@ -29,9 +29,7 @@ class CompetitionEndpointTests(TestCase):
).json() ).json()
token = resp["token"] token = resp["token"]
self.valid_headers = { self.valid_headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"}
"HTTP_AUTHORIZATION": f"Bearer {token}"
}
# --- Helper methods --- # --- Helper methods ---
def get_url(self, competition_id): def get_url(self, competition_id):
@@ -41,8 +39,7 @@ class CompetitionEndpointTests(TestCase):
def test_get_competition_success(self): def test_get_competition_success(self):
"""Authenticated user gets competition details (200 OK)""" """Authenticated user gets competition details (200 OK)"""
response = self.client.get( response = self.client.get(
self.get_url(self.competition.id), self.get_url(self.competition.id), **self.valid_headers
**self.valid_headers
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@@ -61,8 +58,7 @@ class CompetitionEndpointTests(TestCase):
def test_invalid_uuid_format(self): def test_invalid_uuid_format(self):
"""Invalid UUID format returns 400 Bad Request""" """Invalid UUID format returns 400 Bad Request"""
response = self.client.get( response = self.client.get(
self.get_url("invalid-id"), self.get_url("invalid-id"), **self.valid_headers
**self.valid_headers
) )
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
@@ -76,8 +72,7 @@ class CompetitionEndpointTests(TestCase):
"""Valid UUID but missing competition returns 404""" """Valid UUID but missing competition returns 404"""
new_uuid = uuid.uuid4() new_uuid = uuid.uuid4()
response = self.client.get( response = self.client.get(
self.get_url(new_uuid), self.get_url(new_uuid), **self.valid_headers
**self.valid_headers
) )
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["detail"], "Not Found") self.assertEqual(response.json()["detail"], "Not Found")
@@ -86,7 +81,7 @@ class CompetitionEndpointTests(TestCase):
"""Invalid token returns 401 Unauthorized""" """Invalid token returns 401 Unauthorized"""
response = self.client.get( response = self.client.get(
self.get_url(self.competition.id), 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.status_code, 401)
self.assertEqual(response.json()["detail"], "Unauthorized") self.assertEqual(response.json()["detail"], "Unauthorized")
@@ -103,6 +98,6 @@ class CompetitionEndpointTests(TestCase):
with self.subTest(header=header): with self.subTest(header=header):
response = self.client.get( response = self.client.get(
self.get_url(self.competition.id), self.get_url(self.competition.id),
HTTP_AUTHORIZATION=header HTTP_AUTHORIZATION=header,
) )
self.assertEqual(response.status_code, expected_status) self.assertEqual(response.status_code, expected_status)
@@ -8,6 +8,7 @@ from django.core.management.base import BaseCommand
from django.utils import timezone from django.utils import timezone
from apps.competition.models import Competition, State from apps.competition.models import Competition, State
from apps.review.models import Review, Reviewer
from apps.task.models import CompetitionTask, CompetitionTaskSubmission from apps.task.models import CompetitionTask, CompetitionTaskSubmission
from apps.user.models import User, UserRole from apps.user.models import User, UserRole
@@ -20,10 +21,22 @@ class Command(BaseCommand):
users = self.create_users(5) users = self.create_users(5)
competitions = self.create_competitions(2, users) competitions = self.create_competitions(2, users)
tasks = self.create_tasks(competitions) tasks = self.create_tasks(competitions)
self.reviewers = self.create_reviewers(1)
self.create_submissions(tasks, users) self.create_submissions(tasks, users)
self.create_states(competitions, users) self.create_states(competitions, users)
self.stdout.write("Data generation completed.") 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): def create_users(self, count):
users = [] users = []
for i in range(1, count + 1): for i in range(1, count + 1):
@@ -89,6 +102,7 @@ class Command(BaseCommand):
description=description, description=description,
type=task_type, type=task_type,
points=random.randint(1, 10), points=random.randint(1, 10),
max_attempts=random.randint(1, 10),
) )
tasks.append(task) tasks.append(task)
self.stdout.write(f"Created task: {title} (type: {task_type})") self.stdout.write(f"Created task: {title} (type: {task_type})")
@@ -117,6 +131,15 @@ class Command(BaseCommand):
self.stdout.write( self.stdout.write(
f"Created submission for task '{task.title}' by user '{user.username}'" 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): def create_states(self, competitions, users):
# For each competition, create a State for some of its participants # For each competition, create a State for some of its participants
@@ -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 import uuid
from django.db import migrations, models from django.db import migrations, models
@@ -12,6 +12,17 @@ class Migration(migrations.Migration):
] ]
operations = [ 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( migrations.CreateModel(
name='Reviewer', name='Reviewer',
fields=[ fields=[
@@ -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'),
),
]
@@ -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,
},
),
]
@@ -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'),
),
]
+13 -7
View File
@@ -11,17 +11,23 @@ class Reviewer(BaseModel):
token = models.CharField(max_length=100) token = models.CharField(max_length=100)
class Review(BaseModel): class ReviewStatusChoices(models.TextChoices):
class ReviewStatusChoices(models.TextChoices): NOT_CHECKED = "not_checked"
NOT_CHECKED = "not_checked" CHECKING = "checking"
CHECKING = "checking" CHECKED = "checked"
CHECKED = "checked"
class Review(BaseModel):
reviewer = models.ForeignKey(Reviewer, on_delete=models.CASCADE) reviewer = models.ForeignKey(Reviewer, on_delete=models.CASCADE)
submission = models.ForeignKey( 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( state = models.CharField(
choices=ReviewStatusChoices.choices, max_length=11 choices=ReviewStatusChoices.choices,
default=ReviewStatusChoices.NOT_CHECKED.value,
max_length=11,
) )
@@ -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 apps.task.models
import django.db.models.deletion import django.db.models.deletion
import tinymce.models
import uuid import uuid
from django.db import migrations, models from django.db import migrations, models
@@ -12,7 +13,7 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('competition', '0001_initial'), ('competition', '0001_initial'),
('user', '0001_initial'), ('user', '0002_alter_user_email_alter_user_password_and_more'),
] ]
operations = [ operations = [
@@ -20,21 +21,35 @@ class Migration(migrations.Migration):
name='CompetitionTask', name='CompetitionTask',
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.TextField(max_length=50, verbose_name='заголовок')), ('title', models.CharField(max_length=50, verbose_name='заголовок')),
('description', models.TextField(max_length=300, verbose_name='описание')), ('description', tinymce.models.HTMLField(max_length=300, verbose_name='описание')),
('type', models.CharField(choices=[('input', 'Input'), ('checker', 'Checker'), ('review', 'Review')], max_length=8)), ('max_attempts', models.PositiveSmallIntegerField(blank=True, null=True)),
('correct_answer_file', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to)), ('type', models.CharField(choices=[('input', 'Ввод правильного ответа'), ('checker', 'Вывод кода'), ('review', 'Ручная')], max_length=8, verbose_name='тип проверки')),
('points', models.IntegerField(blank=True, null=True)), ('correct_answer_file', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to, verbose_name='файл с правильным ответом')),
('answer_file_path', models.TextField(blank=True, null=True)), ('points', models.IntegerField(blank=True, null=True, verbose_name='баллы за задание')),
('criteries', models.JSONField(blank=True, null=True)), ('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')), ('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={ options={
'abstract': False, 'abstract': False,
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='CompetetionTaskSumbission', name='CompetitionTaskSubmission',
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('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)), ('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)), ('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)), ('result', models.JSONField(blank=True, default=None, null=True)),
('earned_points', models.IntegerField()), ('earned_points', models.IntegerField()),
('reviewed_at', models.DateTimeField(blank=True, null=True)),
('timestamp', models.DateTimeField(auto_now_add=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')), ('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={ options={
'abstract': False, 'abstract': False,
@@ -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='тип задания'),
),
]
@@ -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'),
),
]
@@ -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='тип проверки'),
),
]
@@ -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 = [
]
@@ -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',
),
]
+2 -1
View File
@@ -21,7 +21,7 @@ class CompetitionTask(BaseModel):
competition = models.ForeignKey(Competition, on_delete=models.CASCADE) competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
title = models.CharField(verbose_name="заголовок", max_length=50) title = models.CharField(verbose_name="заголовок", max_length=50)
description = HTMLField(verbose_name="описание", max_length=300) description = HTMLField(verbose_name="описание", max_length=300)
max_attemps = models.PositiveSmallIntegerField() max_attempts = models.PositiveSmallIntegerField(null=True, blank=True)
type = models.CharField( type = models.CharField(
choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки" 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 # just more readable result representation, maybe will be calcuated somehow more complex depends on criteria
earned_points = models.IntegerField() earned_points = models.IntegerField()
reviewed_at = models.DateTimeField(null=True, blank=True)
timestamp = models.DateTimeField(auto_now_add=True) timestamp = models.DateTimeField(auto_now_add=True)
+29 -38
View File
@@ -1,8 +1,8 @@
import json import json
import uuid import uuid
from django.test import TestCase
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.test import TestCase
from apps.user.models import User from apps.user.models import User
@@ -47,7 +47,9 @@ class SignUpAPITestCase(TestCase):
def test_existing_user_conflict(self): def test_existing_user_conflict(self):
User.objects.create( User.objects.create(
email="existing@example.com", password="existingpass123", username="testing" email="existing@example.com",
password="existingpass123",
username="testing",
) )
payload = { payload = {
"email": "existing@example.com", "email": "existing@example.com",
@@ -62,23 +64,24 @@ class SignUpAPITestCase(TestCase):
self.assertEqual(response.status_code, 409) self.assertEqual(response.status_code, 409)
self.assertIn("detail", response.json()) self.assertIn("detail", response.json())
class SignInAPITestCase(TestCase): class SignInAPITestCase(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create( self.user = User.objects.create(
email="valid@example.com", email="valid@example.com",
password=make_password("securepassword123"), password=make_password("securepassword123"),
username="testuser" username="testuser",
) )
self.valid_payload = { self.valid_payload = {
"email": "valid@example.com", "email": "valid@example.com",
"password": "securepassword123" "password": "securepassword123",
} }
def test_successful_sign_in(self): def test_successful_sign_in(self):
response = self.client.post( response = self.client.post(
"/api/v1/sign-in", "/api/v1/sign-in",
data=json.dumps(self.valid_payload), data=json.dumps(self.valid_payload),
content_type="application/json" content_type="application/json",
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertIn("token", response.json()) self.assertIn("token", response.json())
@@ -88,7 +91,7 @@ class SignInAPITestCase(TestCase):
response = self.client.post( response = self.client.post(
"/api/v1/sign-in", "/api/v1/sign-in",
data=json.dumps({"password": "pass"}), data=json.dumps({"password": "pass"}),
content_type="application/json" content_type="application/json",
) )
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
@@ -96,44 +99,35 @@ class SignInAPITestCase(TestCase):
response = self.client.post( response = self.client.post(
"/api/v1/sign-in", "/api/v1/sign-in",
data=json.dumps({"email": "test@example.com"}), data=json.dumps({"email": "test@example.com"}),
content_type="application/json" content_type="application/json",
) )
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_invalid_email_format(self): def test_invalid_email_format(self):
payload = { payload = {"email": "invalid-email", "password": "password123"}
"email": "invalid-email",
"password": "password123"
}
response = self.client.post( response = self.client.post(
"/api/v1/sign-in", "/api/v1/sign-in",
data=json.dumps(payload), data=json.dumps(payload),
content_type="application/json" content_type="application/json",
) )
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
def test_incorrect_password(self): def test_incorrect_password(self):
payload = { payload = {"email": "valid@example.com", "password": "wrongpassword"}
"email": "valid@example.com",
"password": "wrongpassword"
}
response = self.client.post( response = self.client.post(
"/api/v1/sign-in", "/api/v1/sign-in",
data=json.dumps(payload), data=json.dumps(payload),
content_type="application/json" content_type="application/json",
) )
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Unauthorized") self.assertEqual(response.json()["detail"], "Unauthorized")
def test_nonexistent_user(self): def test_nonexistent_user(self):
payload = { payload = {"email": "notexist@example.com", "password": "password123"}
"email": "notexist@example.com",
"password": "password123"
}
response = self.client.post( response = self.client.post(
"/api/v1/sign-in", "/api/v1/sign-in",
data=json.dumps(payload), data=json.dumps(payload),
content_type="application/json" content_type="application/json",
) )
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Unauthorized") self.assertEqual(response.json()["detail"], "Unauthorized")
@@ -145,21 +139,25 @@ class UserMeEndpointTestCase(TestCase):
self.user = User.objects.create( self.user = User.objects.create(
email="johndoe@example.com", email="johndoe@example.com",
username="johndoe", username="johndoe",
password=make_password("securepassword123") password=make_password("securepassword123"),
) )
resp = self.client.post( resp = self.client.post(
"/api/v1/sign-in", "/api/v1/sign-in",
data=json.dumps({"email": "johndoe@example.com", "password": "securepassword123"}), data=json.dumps(
content_type="application/json" {
"email": "johndoe@example.com",
"password": "securepassword123",
}
),
content_type="application/json",
).json() ).json()
self.token = resp['token'] self.token = resp["token"]
self.url = "/api/v1/me" self.url = "/api/v1/me"
def test_get_authenticated_user_data(self): def test_get_authenticated_user_data(self):
"""Test authenticated user can retrieve their profile (200 OK)""" """Test authenticated user can retrieve their profile (200 OK)"""
response = self.client.get( response = self.client.get(
self.url, self.url, HTTP_AUTHORIZATION=f"Bearer {self.token}"
HTTP_AUTHORIZATION=f"Bearer {self.token}"
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@@ -191,8 +189,7 @@ class UserMeEndpointTestCase(TestCase):
def test_invalid_auth_scheme(self): def test_invalid_auth_scheme(self):
"""Test invalid authentication scheme returns 401""" """Test invalid authentication scheme returns 401"""
response = self.client.get( response = self.client.get(
self.url, self.url, HTTP_AUTHORIZATION=f"InvalidScheme {self.token}"
HTTP_AUTHORIZATION=f"InvalidScheme {self.token}"
) )
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
@@ -200,18 +197,12 @@ class UserMeEndpointTestCase(TestCase):
def test_malformed_token(self): def test_malformed_token(self):
"""Test malformed token returns 401""" """Test malformed token returns 401"""
test_cases = [ test_cases = ["invalid.token.123", "Bearer", "", "123456"]
"invalid.token.123",
"Bearer",
"",
"123456"
]
for token in test_cases: for token in test_cases:
with self.subTest(token=token): with self.subTest(token=token):
response = self.client.get( response = self.client.get(
self.url, self.url, HTTP_AUTHORIZATION=f"Bearer {token}"
HTTP_AUTHORIZATION=f"Bearer {token}"
) )
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Unauthorized") self.assertEqual(response.json()["detail"], "Unauthorized")