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"] state: Literal["not_started", "started", "finished"]
@staticmethod @staticmethod
def resolve_state(self, context) -> Literal["not_started", "started", "finished"]: def resolve_state(
if not (state := State.objects.filter(user=context.get("request").auth, competition=self).first()): 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 "not_started"
return state.state return state.state
class Meta: class Meta:
model = Competition model = Competition
exclude = ("participants",) 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.ping.views import router as ping_router
from api.v1.review.auth import ReviewerAuth from api.v1.review.auth import ReviewerAuth
from api.v1.review.views import router as review_router 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.task.views import router as task_router
from api.v1.team.views import router as team_router from api.v1.team.views import router as team_router
from api.v1.user.views import router as user_router
router = NinjaAPI( router = NinjaAPI(
title="DataRush API", title="DataRush API",
+17 -7
View File
@@ -3,19 +3,29 @@ from uuid import UUID
from ninja import ModelSchema, Schema from ninja import ModelSchema, Schema
from apps.task.models import CompetitionTask from apps.task.models import CompetitionTask, CompetitionTaskSubmission
class TaskOutSchema(ModelSchema): class TaskOutSchema(ModelSchema):
class Meta: class Meta:
model = CompetitionTask model = CompetitionTask
fields = ["id", "competition", "title", "description", "type"] fields = [
"id",
"competition",
class TaskSubmissionIn(Schema): "title",
type: Literal["input", "file", "code"] "description",
content: str "type",
"in_competition_position",
]
class TaskSubmissionOut(Schema): class TaskSubmissionOut(Schema):
submission_id: UUID 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 uuid import UUID
from django.shortcuts import get_object_or_404 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.ping.schemas import PingOut
from api.v1.schemas import ForbiddenError, NotFoundError, UnauthorizedError from api.v1.schemas import ForbiddenError, NotFoundError, UnauthorizedError
from api.v1.task.schemas import ( from api.v1.task.schemas import (
HistorySubmissionOut,
TaskOutSchema, TaskOutSchema,
TaskSubmissionIn,
TaskSubmissionOut, TaskSubmissionOut,
) )
from apps.competition.models import State from apps.competition.models import State
@@ -87,32 +87,56 @@ def get_task(request, competition_id: str, task_id: str) -> TaskOutSchema: ...
}, },
) )
def submit_task( def submit_task(
request, competition_id: str, task_id: str, submission: TaskSubmissionIn request,
) -> PingOut: competition_id: str,
task_id: str,
content: UploadedFile = File(...), # TODO: вот это надо переделать
) -> TaskSubmissionOut:
user = request.auth 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( task = get_object_or_404(
CompetitionTask, competetion=competetion, id=task_id CompetitionTask, competition=competition, id=task_id
) )
if task.type == CompetitionTask.CompetitionTaskType.INPUT: if task.type == CompetitionTask.CompetitionTaskType.INPUT:
CompetitionTaskSubmission.objects.create( submission = CompetitionTaskSubmission.objects.create(
user=user, user=user,
task=task, task=task,
status=CompetitionTaskSubmission.StatusChoices.CHECKED, 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: if task.type == CompetitionTask.CompetitionTaskType.REVIEW:
CompetitionTaskSubmission.objects.create( submission = CompetitionTaskSubmission.objects.create(
user=user, user=user,
task=task, task=task,
status=CompetitionTaskSubmission.StatusChoices.SENT, status=CompetitionTaskSubmission.StatusChoices.SENT,
content=content,
) )
if task.type == CompetitionTask.CompetitionTaskType.CHECKER: if task.type == CompetitionTask.CompetitionTaskType.CHECKER:
CompetitionTaskSubmission.objects.create( submission = CompetitionTaskSubmission.objects.create(
user=user, user=user,
task=task, task=task,
status=CompetitionTaskSubmission.StatusChoices.CHECKING, 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 from apps.team.models import Team
@@ -6,10 +6,18 @@ from apps.team.models import Team
class CreateTeamSchema(ModelSchema): class CreateTeamSchema(ModelSchema):
class Meta: class Meta:
model = Team model = Team
fields = ("name", "members",) fields = (
"name",
"members",
)
class TeamSchemaOut(ModelSchema): class TeamSchemaOut(ModelSchema):
class Meta: class Meta:
model = Team 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 uuid import UUID
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from ninja import Router 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 apps.team.models import Team
from api.v1.team.schemas import TeamSchemaOut, CreateTeamSchema
from api.v1.schemas import UnauthorizedError, BadRequestError, NotFoundError
router = Router() router = Router()
@@ -18,7 +17,7 @@ router = Router()
400: BadRequestError, 400: BadRequestError,
401: UnauthorizedError, 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): def create_team(request, team_data: CreateTeamSchema) -> (int, TeamSchemaOut):
team = Team(name=team_data.name, owner=request.auth) team = Team(name=team_data.name, owner=request.auth)
@@ -33,7 +32,7 @@ def create_team(request, team_data: CreateTeamSchema) -> (int, TeamSchemaOut):
200: TeamSchemaOut, 200: TeamSchemaOut,
401: UnauthorizedError, 401: UnauthorizedError,
404: NotFoundError, 404: NotFoundError,
} },
) )
def get_team(request, team_id: UUID) -> (int, TeamSchemaOut): def get_team(request, team_id: UUID) -> (int, TeamSchemaOut):
return get_object_or_404(Team, pk=team_id) 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 apps.competition.models
import datetime import datetime
@@ -38,7 +38,7 @@ class Migration(migrations.Migration):
name='State', name='State',
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)),
('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)), ('changed_at', models.DateTimeField(default=datetime.datetime.now)),
('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')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')), ('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): class CompetitionType(models.TextChoices):
EDU = "edu", "Образовательный" EDU = "edu", "Образовательный"
COMPETITIVE = "competitive", "Соревновательный" COMPETITIVE = "competitive", "Соревновательный"
class CompetitionParticipationType(models.TextChoices): class CompetitionParticipationType(models.TextChoices):
SOLO = "solo", "Индивидуальный" SOLO = "solo", "Индивидуальный"
@@ -60,5 +61,9 @@ class State(BaseModel):
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
competition = models.ForeignKey(Competition, 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) changed_at = models.DateTimeField(default=datetime.now)
+17 -28
View File
@@ -103,7 +103,7 @@ class CompetitionsEndpointTests(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",
) )
resp = self.client.post( resp = self.client.post(
@@ -121,7 +121,8 @@ class CompetitionsEndpointTests(TestCase):
title=f"Competition {i}", title=f"Competition {i}",
description=f"Description {i}", description=f"Description {i}",
type=( type=(
Competition.CompetitionType.EDU if i % 2 == 0 Competition.CompetitionType.EDU
if i % 2 == 0
else Competition.CompetitionType.COMPETITIVE else Competition.CompetitionType.COMPETITIVE
), ),
participation_type=Competition.CompetitionParticipationType.SOLO, participation_type=Competition.CompetitionParticipationType.SOLO,
@@ -132,9 +133,7 @@ class CompetitionsEndpointTests(TestCase):
competition.participants.add(self.user) competition.participants.add(self.user)
self.competitions.append(competition) self.competitions.append(competition)
self.valid_headers = { self.valid_headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"}
"HTTP_AUTHORIZATION": f"Bearer {token}"
}
def get_url(self, params=None): def get_url(self, params=None):
base_url = "/api/v1/competitions" base_url = "/api/v1/competitions"
@@ -142,8 +141,7 @@ class CompetitionsEndpointTests(TestCase):
def test_get_participating_competitions(self): def test_get_participating_competitions(self):
response = self.client.get( response = self.client.get(
self.get_url("is_participating=true"), self.get_url("is_participating=true"), **self.valid_headers
**self.valid_headers
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@@ -151,13 +149,12 @@ class CompetitionsEndpointTests(TestCase):
self.assertEqual(len(data), 2) self.assertEqual(len(data), 2)
self.assertEqual( self.assertEqual(
{item["id"] for item in data}, {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): def test_competition_type_values(self):
response = self.client.get( response = self.client.get(
self.get_url("is_participating=true"), self.get_url("is_participating=true"), **self.valid_headers
**self.valid_headers
) )
for item in response.json(): for item in response.json():
@@ -165,20 +162,15 @@ class CompetitionsEndpointTests(TestCase):
def test_participation_type_values(self): def test_participation_type_values(self):
response = self.client.get( response = self.client.get(
self.get_url("is_participating=false"), self.get_url("is_participating=false"), **self.valid_headers
**self.valid_headers
) )
types = [item["participation_type"] for item in response.json()] types = [item["participation_type"] for item in response.json()]
self.assertCountEqual( self.assertCountEqual(types, ["solo", "solo", "solo"])
types,
["solo", "solo", "solo"]
)
def test_datetime_formatting(self): def test_datetime_formatting(self):
response = self.client.get( response = self.client.get(
self.get_url("is_participating=true"), self.get_url("is_participating=true"), **self.valid_headers
**self.valid_headers
) )
for item in response.json(): for item in response.json():
@@ -195,8 +187,7 @@ class CompetitionsEndpointTests(TestCase):
def test_competition_metadata(self): def test_competition_metadata(self):
response = self.client.get( response = self.client.get(
self.get_url("is_participating=true"), self.get_url("is_participating=true"), **self.valid_headers
**self.valid_headers
) )
item = response.json()[0] item = response.json()[0]
@@ -207,8 +198,7 @@ class CompetitionsEndpointTests(TestCase):
def test_verbose_name_consistency(self): def test_verbose_name_consistency(self):
response = self.client.get( response = self.client.get(
self.get_url("is_participating=true"), self.get_url("is_participating=true"), **self.valid_headers
**self.valid_headers
) )
item = response.json()[0] item = response.json()[0]
@@ -220,16 +210,16 @@ class CompetitionsEndpointTests(TestCase):
title="No Dates Competition", title="No Dates Competition",
description="Test competition", description="Test competition",
type=Competition.CompetitionType.EDU, type=Competition.CompetitionType.EDU,
participation_type=Competition.CompetitionParticipationType.SOLO participation_type=Competition.CompetitionParticipationType.SOLO,
) )
response = self.client.get( response = self.client.get(
self.get_url("is_participating=false"), self.get_url("is_participating=false"), **self.valid_headers
**self.valid_headers
) )
test_item = next( test_item = next(
item for item in response.json() item
for item in response.json()
if item["id"] == str(competition.id) if item["id"] == str(competition.id)
) )
self.assertIsNone(test_item["start_date"]) self.assertIsNone(test_item["start_date"])
@@ -237,8 +227,7 @@ class CompetitionsEndpointTests(TestCase):
def test_participation_status_filtering(self): def test_participation_status_filtering(self):
response = self.client.get( response = self.client.get(
self.get_url("is_participating=false"), self.get_url("is_participating=false"), **self.valid_headers
**self.valid_headers
) )
data = response.json() data = response.json()
@@ -73,7 +73,9 @@ class Command(BaseCommand):
description=description, description=description,
start_date=start_date, start_date=start_date,
end_date=end_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", participation_type="solo",
) )
# Add random participants # 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 django.db.models.deletion
import uuid 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 apps.task.models
import django.db.models.deletion import django.db.models.deletion
@@ -21,6 +21,7 @@ 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)),
('in_competition_position', models.PositiveSmallIntegerField(blank=True, null=True)),
('title', models.CharField(max_length=50, verbose_name='заголовок')), ('title', models.CharField(max_length=50, verbose_name='заголовок')),
('description', tinymce.models.HTMLField(max_length=300, verbose_name='описание')), ('description', tinymce.models.HTMLField(max_length=300, verbose_name='описание')),
('max_attempts', models.PositiveSmallIntegerField(blank=True, null=True)), ('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)), ('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)), ('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(blank=True, null=True)),
('reviewed_at', models.DateTimeField(blank=True, null=True)), ('reviewed_at', models.DateTimeField(blank=True, null=True)),
('timestamp', models.DateTimeField(auto_now_add=True)), ('timestamp', models.DateTimeField(auto_now_add=True)),
('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')),
+4 -1
View File
@@ -18,6 +18,9 @@ class CompetitionTask(BaseModel):
def answer_file_upload_to(instance, filename) -> str: def answer_file_upload_to(instance, filename) -> str:
return f"/tasks/{instance.id}/answer/{uuid4()}/filename" 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) 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)
@@ -109,7 +112,7 @@ class CompetitionTaskSubmission(BaseModel):
# - code: {"correct": boolean} # - code: {"correct": boolean}
result = models.JSONField(default=None, null=True, blank=True) result = models.JSONField(default=None, null=True, blank=True)
# 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(null=True, blank=True)
reviewed_at = models.DateTimeField(null=True, blank=True) reviewed_at = models.DateTimeField(null=True, blank=True)
timestamp = models.DateTimeField(auto_now_add=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) @admin.register(Team)
class TeamAdmin(admin.ModelAdmin): class TeamAdmin(admin.ModelAdmin):
list_display = ("name", "owner") 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): class TeamConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = "django.db.models.BigAutoField"
name = 'apps.team' name = "apps.team"
verbose_name = "Команды" 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 django.db.models.deletion
import uuid import uuid
+9 -7
View File
@@ -1,4 +1,3 @@
from uuid import uuid4
from django.db import models from django.db import models
@@ -8,10 +7,12 @@ from apps.user.models import User
class Team(BaseModel): class Team(BaseModel):
name = models.CharField(max_length=50, verbose_name="название") name = models.CharField(max_length=50, verbose_name="название")
owner = models.ForeignKey(User, on_delete=models.CASCADE, owner = models.ForeignKey(
verbose_name="владелец") User, on_delete=models.CASCADE, verbose_name="владелец"
members = models.ManyToManyField(User, related_name="team_members", )
verbose_name="участники") members = models.ManyToManyField(
User, related_name="team_members", verbose_name="участники"
)
def __str__(self): def __str__(self):
return self.name return self.name
@@ -22,8 +23,9 @@ class Team(BaseModel):
class TeamInvite(BaseModel): class TeamInvite(BaseModel):
team = models.ForeignKey(Team, on_delete=models.CASCADE, team = models.ForeignKey(
verbose_name="команда") Team, on_delete=models.CASCADE, verbose_name="команда"
)
link = models.UUIDField(verbose_name="инвайт") link = models.UUIDField(verbose_name="инвайт")
class Meta: 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 import uuid
from django.db import migrations, models from django.db import migrations, models