feat: added submissions history, formatted

This commit is contained in:
Андрей Сумин
2025-03-02 03:17:18 +03:00
parent cb541b3a2a
commit 696fc8e58b
18 changed files with 130 additions and 78 deletions
@@ -11,12 +11,17 @@ class CompetitionOut(ModelSchema):
state: Literal["not_started", "started", "finished"]
@staticmethod
def resolve_state(self, context) -> Literal["not_started", "started", "finished"]:
if not (state := State.objects.filter(user=context.get("request").auth, competition=self).first()):
def resolve_state(
self, context
) -> Literal["not_started", "started", "finished"]:
if not (
state := State.objects.filter(
user=context.get("request").auth, competition=self
).first()
):
return "not_started"
return state.state
class Meta:
model = Competition
exclude = ("participants",)
+1 -1
View File
@@ -8,9 +8,9 @@ from api.v1.competition.views import router as competition_router
from api.v1.ping.views import router as ping_router
from api.v1.review.auth import ReviewerAuth
from api.v1.review.views import router as review_router
from api.v1.user.views import router as user_router
from api.v1.task.views import router as task_router
from api.v1.team.views import router as team_router
from api.v1.user.views import router as user_router
router = NinjaAPI(
title="DataRush API",
+17 -7
View File
@@ -3,19 +3,29 @@ from uuid import UUID
from ninja import ModelSchema, Schema
from apps.task.models import CompetitionTask
from apps.task.models import CompetitionTask, CompetitionTaskSubmission
class TaskOutSchema(ModelSchema):
class Meta:
model = CompetitionTask
fields = ["id", "competition", "title", "description", "type"]
class TaskSubmissionIn(Schema):
type: Literal["input", "file", "code"]
content: str
fields = [
"id",
"competition",
"title",
"description",
"type",
"in_competition_position",
]
class TaskSubmissionOut(Schema):
submission_id: UUID
class HistorySubmissionOut(ModelSchema):
status: Literal["sent", "checked", "checking"]
class Meta:
model = CompetitionTaskSubmission
fields = ("id", "earned_points", "timestamp")
+35 -11
View File
@@ -2,13 +2,13 @@ from http import HTTPStatus as status
from uuid import UUID
from django.shortcuts import get_object_or_404
from ninja import Router
from ninja import File, Router, UploadedFile
from api.v1.ping.schemas import PingOut
from api.v1.schemas import ForbiddenError, NotFoundError, UnauthorizedError
from api.v1.task.schemas import (
HistorySubmissionOut,
TaskOutSchema,
TaskSubmissionIn,
TaskSubmissionOut,
)
from apps.competition.models import State
@@ -87,32 +87,56 @@ def get_task(request, competition_id: str, task_id: str) -> TaskOutSchema: ...
},
)
def submit_task(
request, competition_id: str, task_id: str, submission: TaskSubmissionIn
) -> PingOut:
request,
competition_id: str,
task_id: str,
content: UploadedFile = File(...), # TODO: вот это надо переделать
) -> TaskSubmissionOut:
user = request.auth
competetion = get_object_or_404(Competition, id=competition_id)
competition = get_object_or_404(Competition, id=competition_id)
task = get_object_or_404(
CompetitionTask, competetion=competetion, id=task_id
CompetitionTask, competition=competition, id=task_id
)
if task.type == CompetitionTask.CompetitionTaskType.INPUT:
CompetitionTaskSubmission.objects.create(
submission = CompetitionTaskSubmission.objects.create(
user=user,
task=task,
status=CompetitionTaskSubmission.StatusChoices.CHECKED,
result={"correct": submission.content == task.answer_file_path},
result={"correct": content == task.answer_file_path},
content=content,
)
if task.type == CompetitionTask.CompetitionTaskType.REVIEW:
CompetitionTaskSubmission.objects.create(
submission = CompetitionTaskSubmission.objects.create(
user=user,
task=task,
status=CompetitionTaskSubmission.StatusChoices.SENT,
content=content,
)
if task.type == CompetitionTask.CompetitionTaskType.CHECKER:
CompetitionTaskSubmission.objects.create(
submission = CompetitionTaskSubmission.objects.create(
user=user,
task=task,
status=CompetitionTaskSubmission.StatusChoices.CHECKING,
content=content,
)
return TaskSubmissionOut(id=CompetitionTaskSubmission.id)
return TaskSubmissionOut(submission_id=submission.id)
@router.get(
"competitions/{competition_id}/tasks/{task_id}/history",
response={
status.OK: list[HistorySubmissionOut],
status.UNAUTHORIZED: UnauthorizedError,
},
)
def get_submissions_history(request, competition_id: UUID, task_id: UUID):
task = get_object_or_404(
CompetitionTask, competition_id=competition_id, id=task_id
)
submissions_history = CompetitionTaskSubmission.objects.filter(
task=task, user=request.auth
)
return status.OK, submissions_history
+11 -3
View File
@@ -1,4 +1,4 @@
from ninja import ModelSchema, Schema
from ninja import ModelSchema
from apps.team.models import Team
@@ -6,10 +6,18 @@ from apps.team.models import Team
class CreateTeamSchema(ModelSchema):
class Meta:
model = Team
fields = ("name", "members",)
fields = (
"name",
"members",
)
class TeamSchemaOut(ModelSchema):
class Meta:
model = Team
fields = ("id", "name", "owner", "members", )
fields = (
"id",
"name",
"owner",
"members",
)
+4 -5
View File
@@ -1,12 +1,11 @@
from http import HTTPStatus as status
from uuid import UUID
from django.shortcuts import get_object_or_404
from ninja import Router
from api.v1.schemas import BadRequestError, NotFoundError, UnauthorizedError
from api.v1.team.schemas import CreateTeamSchema, TeamSchemaOut
from apps.team.models import Team
from api.v1.team.schemas import TeamSchemaOut, CreateTeamSchema
from api.v1.schemas import UnauthorizedError, BadRequestError, NotFoundError
router = Router()
@@ -18,7 +17,7 @@ router = Router()
400: BadRequestError,
401: UnauthorizedError,
},
description="Create team. Note: members array must have team members uuid, default can be empty"
description="Create team. Note: members array must have team members uuid, default can be empty",
)
def create_team(request, team_data: CreateTeamSchema) -> (int, TeamSchemaOut):
team = Team(name=team_data.name, owner=request.auth)
@@ -33,7 +32,7 @@ def create_team(request, team_data: CreateTeamSchema) -> (int, TeamSchemaOut):
200: TeamSchemaOut,
401: UnauthorizedError,
404: NotFoundError,
}
},
)
def get_team(request, team_id: UUID) -> (int, TeamSchemaOut):
return get_object_or_404(Team, pk=team_id)
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-01 23:48
# Generated by Django 5.1.6 on 2025-03-02 00:16
import apps.competition.models
import datetime
@@ -38,7 +38,7 @@ class Migration(migrations.Migration):
name='State',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('state', models.CharField(choices=[('not_started', 'Not Started'), ('started', 'Started'), ('finished', 'Finished')], max_length=11)),
('state', models.CharField(choices=[('not_started', 'Not Started'), ('started', 'Started'), ('finished', 'Finished')], default='not_started', max_length=11)),
('changed_at', models.DateTimeField(default=datetime.datetime.now)),
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')),
+6 -1
View File
@@ -10,6 +10,7 @@ class Competition(BaseModel):
class CompetitionType(models.TextChoices):
EDU = "edu", "Образовательный"
COMPETITIVE = "competitive", "Соревновательный"
class CompetitionParticipationType(models.TextChoices):
SOLO = "solo", "Индивидуальный"
@@ -60,5 +61,9 @@ class State(BaseModel):
user = models.ForeignKey(User, on_delete=models.CASCADE)
competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
state = models.CharField(choices=StateChoices.choices, max_length=11, default=StateChoices.NOT_STARTED.value)
state = models.CharField(
choices=StateChoices.choices,
max_length=11,
default=StateChoices.NOT_STARTED.value,
)
changed_at = models.DateTimeField(default=datetime.now)
+17 -28
View File
@@ -103,7 +103,7 @@ class CompetitionsEndpointTests(TestCase):
self.user = User.objects.create(
email="user@example.com",
password=make_password("password123"),
username="t1wk4"
username="t1wk4",
)
resp = self.client.post(
@@ -121,7 +121,8 @@ class CompetitionsEndpointTests(TestCase):
title=f"Competition {i}",
description=f"Description {i}",
type=(
Competition.CompetitionType.EDU if i % 2 == 0
Competition.CompetitionType.EDU
if i % 2 == 0
else Competition.CompetitionType.COMPETITIVE
),
participation_type=Competition.CompetitionParticipationType.SOLO,
@@ -132,9 +133,7 @@ class CompetitionsEndpointTests(TestCase):
competition.participants.add(self.user)
self.competitions.append(competition)
self.valid_headers = {
"HTTP_AUTHORIZATION": f"Bearer {token}"
}
self.valid_headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"}
def get_url(self, params=None):
base_url = "/api/v1/competitions"
@@ -142,8 +141,7 @@ class CompetitionsEndpointTests(TestCase):
def test_get_participating_competitions(self):
response = self.client.get(
self.get_url("is_participating=true"),
**self.valid_headers
self.get_url("is_participating=true"), **self.valid_headers
)
self.assertEqual(response.status_code, 200)
@@ -151,13 +149,12 @@ class CompetitionsEndpointTests(TestCase):
self.assertEqual(len(data), 2)
self.assertEqual(
{item["id"] for item in data},
{str(self.competitions[0].id), str(self.competitions[1].id)}
{str(self.competitions[0].id), str(self.competitions[1].id)},
)
def test_competition_type_values(self):
response = self.client.get(
self.get_url("is_participating=true"),
**self.valid_headers
self.get_url("is_participating=true"), **self.valid_headers
)
for item in response.json():
@@ -165,20 +162,15 @@ class CompetitionsEndpointTests(TestCase):
def test_participation_type_values(self):
response = self.client.get(
self.get_url("is_participating=false"),
**self.valid_headers
self.get_url("is_participating=false"), **self.valid_headers
)
types = [item["participation_type"] for item in response.json()]
self.assertCountEqual(
types,
["solo", "solo", "solo"]
)
self.assertCountEqual(types, ["solo", "solo", "solo"])
def test_datetime_formatting(self):
response = self.client.get(
self.get_url("is_participating=true"),
**self.valid_headers
self.get_url("is_participating=true"), **self.valid_headers
)
for item in response.json():
@@ -195,8 +187,7 @@ class CompetitionsEndpointTests(TestCase):
def test_competition_metadata(self):
response = self.client.get(
self.get_url("is_participating=true"),
**self.valid_headers
self.get_url("is_participating=true"), **self.valid_headers
)
item = response.json()[0]
@@ -207,8 +198,7 @@ class CompetitionsEndpointTests(TestCase):
def test_verbose_name_consistency(self):
response = self.client.get(
self.get_url("is_participating=true"),
**self.valid_headers
self.get_url("is_participating=true"), **self.valid_headers
)
item = response.json()[0]
@@ -220,16 +210,16 @@ class CompetitionsEndpointTests(TestCase):
title="No Dates Competition",
description="Test competition",
type=Competition.CompetitionType.EDU,
participation_type=Competition.CompetitionParticipationType.SOLO
participation_type=Competition.CompetitionParticipationType.SOLO,
)
response = self.client.get(
self.get_url("is_participating=false"),
**self.valid_headers
self.get_url("is_participating=false"), **self.valid_headers
)
test_item = next(
item for item in response.json()
item
for item in response.json()
if item["id"] == str(competition.id)
)
self.assertIsNone(test_item["start_date"])
@@ -237,8 +227,7 @@ class CompetitionsEndpointTests(TestCase):
def test_participation_status_filtering(self):
response = self.client.get(
self.get_url("is_participating=false"),
**self.valid_headers
self.get_url("is_participating=false"), **self.valid_headers
)
data = response.json()
@@ -73,7 +73,9 @@ class Command(BaseCommand):
description=description,
start_date=start_date,
end_date=end_date,
type=random.choice(["edu", "competitive"]), # assuming only one type for now
type=random.choice(
["edu", "competitive"]
), # assuming only one type for now
participation_type="solo",
)
# Add random participants
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-01 23:48
# Generated by Django 5.1.6 on 2025-03-02 00:16
import django.db.models.deletion
import uuid
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-01 23:48
# Generated by Django 5.1.6 on 2025-03-02 00:16
import apps.task.models
import django.db.models.deletion
@@ -21,6 +21,7 @@ class Migration(migrations.Migration):
name='CompetitionTask',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('in_competition_position', models.PositiveSmallIntegerField(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)),
@@ -57,7 +58,7 @@ class Migration(migrations.Migration):
('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()),
('earned_points', models.IntegerField(blank=True, null=True)),
('reviewed_at', models.DateTimeField(blank=True, null=True)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask')),
+4 -1
View File
@@ -18,6 +18,9 @@ class CompetitionTask(BaseModel):
def answer_file_upload_to(instance, filename) -> str:
return f"/tasks/{instance.id}/answer/{uuid4()}/filename"
in_competition_position = models.PositiveSmallIntegerField(
null=True, blank=True
)
competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
title = models.CharField(verbose_name="заголовок", max_length=50)
description = HTMLField(verbose_name="описание", max_length=300)
@@ -109,7 +112,7 @@ class CompetitionTaskSubmission(BaseModel):
# - code: {"correct": boolean}
result = models.JSONField(default=None, null=True, blank=True)
# just more readable result representation, maybe will be calcuated somehow more complex depends on criteria
earned_points = models.IntegerField()
earned_points = models.IntegerField(null=True, blank=True)
reviewed_at = models.DateTimeField(null=True, blank=True)
timestamp = models.DateTimeField(auto_now_add=True)
+5 -1
View File
@@ -6,4 +6,8 @@ from apps.team.models import Team
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ("name", "owner")
search_fields = ("name", "owner", "members",)
search_fields = (
"name",
"owner",
"members",
)
+2 -2
View File
@@ -2,6 +2,6 @@ from django.apps import AppConfig
class TeamConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.team'
default_auto_field = "django.db.models.BigAutoField"
name = "apps.team"
verbose_name = "Команды"
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-01 23:48
# Generated by Django 5.1.6 on 2025-03-02 00:16
import django.db.models.deletion
import uuid
+9 -7
View File
@@ -1,4 +1,3 @@
from uuid import uuid4
from django.db import models
@@ -8,10 +7,12 @@ from apps.user.models import User
class Team(BaseModel):
name = models.CharField(max_length=50, verbose_name="название")
owner = models.ForeignKey(User, on_delete=models.CASCADE,
verbose_name="владелец")
members = models.ManyToManyField(User, related_name="team_members",
verbose_name="участники")
owner = models.ForeignKey(
User, on_delete=models.CASCADE, verbose_name="владелец"
)
members = models.ManyToManyField(
User, related_name="team_members", verbose_name="участники"
)
def __str__(self):
return self.name
@@ -22,8 +23,9 @@ class Team(BaseModel):
class TeamInvite(BaseModel):
team = models.ForeignKey(Team, on_delete=models.CASCADE,
verbose_name="команда")
team = models.ForeignKey(
Team, on_delete=models.CASCADE, verbose_name="команда"
)
link = models.UUIDField(verbose_name="инвайт")
class Meta:
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-01 23:48
# Generated by Django 5.1.6 on 2025-03-02 00:16
import uuid
from django.db import migrations, models