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 sidebar_position: 1
--- ---
# Начала! # Начало!
Выбирай интересующий раздел слева и просвещайся!
+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
@@ -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='тип участия'),
),
]
+166 -13
View File
@@ -1,5 +1,8 @@
import uuid 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.contrib.auth.hashers import make_password
from django.test import TestCase from django.test import TestCase
@@ -12,14 +15,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 +32,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 +42,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 +61,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 +75,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 +84,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 +101,161 @@ 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)
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 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',
),
]
+3 -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="тип проверки"
) )
@@ -60,6 +60,7 @@ class CompetitionTask(BaseModel):
return self.title return self.title
class Meta: class Meta:
verbose_name = "задание"
verbose_name_plural = "задания" 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 # 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")
+1
View File
@@ -23,6 +23,7 @@ dependencies = [
"psycopg2-binary>=2.9.10", "psycopg2-binary>=2.9.10",
"pydantic>=2.10.5", "pydantic>=2.10.5",
"pyjwt>=2.10.1", "pyjwt>=2.10.1",
"python-dateutil>=2.9.0.post0",
"python-gettext>=5.0", "python-gettext>=5.0",
"python-json-logger>=3.2.1", "python-json-logger>=3.2.1",
"pytz>=2024.2", "pytz>=2024.2",