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 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
+55 -14
View File
@@ -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
+6 -1
View File
@@ -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,
@@ -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': 'соревнование',
@@ -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(
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)
@@ -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
@@ -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=[
@@ -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'),
),
]
+9 -3
View File
@@ -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 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,
)
@@ -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,
@@ -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)
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)
+29 -38
View File
@@ -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")