Merge branch 'master' of gitlab.prodcontest.ru:team-15/project

This commit is contained in:
ITQ
2025-03-02 00:06:00 +03:00
23 changed files with 440 additions and 431 deletions
+3 -1
View File
@@ -2,4 +2,6 @@
sidebar_position: 1
---
# Начала!
# Начало!
Выбирай интересующий раздел слева и просвещайся!
+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
@@ -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='тип участия'),
),
]
+166 -13
View File
@@ -1,5 +1,8 @@
import uuid
from datetime import timedelta, datetime, tzinfo
from dateutil.parser import isoparse
import pytz
from django.contrib.auth.hashers import make_password
from django.test import TestCase
@@ -12,14 +15,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 +32,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 +42,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 +61,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 +75,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 +84,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 +101,161 @@ 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)
class CompetitionsEndpointTests(TestCase):
def setUp(self):
self.user = User.objects.create(
email="user@example.com",
password=make_password("password123"),
username="t1wk4"
)
resp = self.client.post(
"/api/v1/sign-in",
data={"email": self.user.email, "password": "password123"},
content_type="application/json",
).json()
token = resp["token"]
# Create test competitions
now = datetime.now(tz=pytz.utc)
self.competitions = []
for i in range(1, 6):
competition = Competition.objects.create(
title=f"Competition {i}",
description=f"Description {i}",
type=Competition.CompetitionType.SOLO,
participation_type=(
Competition.CompetitionParticipationType.EDU if i % 2 == 0
else Competition.CompetitionParticipationType.COMPETITIVE
),
start_date=(now + timedelta(days=i)).isoformat(),
end_date=(now + timedelta(days=i + 7)).isoformat(),
)
if i <= 2:
competition.participants.add(self.user)
self.competitions.append(competition)
self.valid_headers = {
"HTTP_AUTHORIZATION": f"Bearer {token}"
}
def get_url(self, params=None):
base_url = "/api/v1/competitions"
return f"{base_url}?{params}" if params else base_url
def test_get_participating_competitions(self):
"""Test filtering competitions where user is participating"""
response = self.client.get(
self.get_url("is_participating=true"),
**self.valid_headers
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(len(data), 2)
self.assertEqual(
{item["id"] for item in data},
{str(self.competitions[0].id), str(self.competitions[1].id)}
)
def test_competition_type_values(self):
"""Test competition type choices are respected"""
response = self.client.get(
self.get_url("is_participating=true"),
**self.valid_headers
)
for item in response.json():
self.assertEqual(item["type"], "solo")
def test_participation_type_values(self):
"""Test participation type alternates between edu/competitive"""
response = self.client.get(
self.get_url("is_participating=false"),
**self.valid_headers
)
types = [item["participation_type"] for item in response.json()]
self.assertCountEqual(
types,
["competitive", "edu", "competitive"]
)
def test_datetime_formatting(self):
"""Test start/end date ISO formatting"""
response = self.client.get(
self.get_url("is_participating=true"),
**self.valid_headers
)
for item in response.json():
if item["start_date"]:
try:
isoparse(item["start_date"])
except ValueError:
self.fail("Invalid start_date format")
if item["end_date"]:
try:
isoparse(item["end_date"])
except ValueError:
self.fail("Invalid end_date format")
def test_competition_metadata(self):
"""Test competition metadata fields"""
response = self.client.get(
self.get_url("is_participating=true"),
**self.valid_headers
)
item = response.json()[0]
self.assertEqual(item["title"], "Competition 1")
self.assertEqual(item["description"], "Description 1")
self.assertEqual(item["type"], "solo")
self.assertEqual(item["participation_type"], "competitive")
def test_verbose_name_consistency(self):
"""Test model verbose names don't affect API schema"""
response = self.client.get(
self.get_url("is_participating=true"),
**self.valid_headers
)
item = response.json()[0]
self.assertNotIn("название", item) # Russian verbose name
self.assertIn("title", item) # Actual API field name
def test_null_dates_handling(self):
"""Test competitions with null dates"""
competition = Competition.objects.create(
title="No Dates Competition",
description="Test competition",
type=Competition.CompetitionType.SOLO,
participation_type=Competition.CompetitionParticipationType.EDU
)
response = self.client.get(
self.get_url("is_participating=false"),
**self.valid_headers
)
test_item = next(
item for item in response.json()
if item["id"] == str(competition.id)
)
self.assertIsNone(test_item["start_date"])
self.assertIsNone(test_item["end_date"])
def test_participation_status_filtering(self):
"""Test filtering by participation_type"""
response = self.client.get(
self.get_url("is_participating=false"),
**self.valid_headers
)
data = response.json()
self.assertEqual(len(data), 3)
@@ -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'),
),
]
+10 -4
View File
@@ -11,17 +11,23 @@ class Reviewer(BaseModel):
token = models.CharField(max_length=100)
class Review(BaseModel):
class ReviewStatusChoices(models.TextChoices):
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',
),
]
+3 -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="тип проверки"
)
@@ -60,6 +60,7 @@ class CompetitionTask(BaseModel):
return self.title
class Meta:
verbose_name = "задание"
verbose_name_plural = "задания"
@@ -110,4 +111,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")
+1
View File
@@ -23,6 +23,7 @@ dependencies = [
"psycopg2-binary>=2.9.10",
"pydantic>=2.10.5",
"pyjwt>=2.10.1",
"python-dateutil>=2.9.0.post0",
"python-gettext>=5.0",
"python-json-logger>=3.2.1",
"pytz>=2024.2",