diff --git a/services/backend/api/v1/competition/schemas.py b/services/backend/api/v1/competition/schemas.py index c1252c2..9a1116f 100644 --- a/services/backend/api/v1/competition/schemas.py +++ b/services/backend/api/v1/competition/schemas.py @@ -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",) diff --git a/services/backend/api/v1/router.py b/services/backend/api/v1/router.py index a9ae8f6..4388666 100644 --- a/services/backend/api/v1/router.py +++ b/services/backend/api/v1/router.py @@ -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", diff --git a/services/backend/api/v1/task/schemas.py b/services/backend/api/v1/task/schemas.py index e5a4046..9546379 100644 --- a/services/backend/api/v1/task/schemas.py +++ b/services/backend/api/v1/task/schemas.py @@ -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") diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py index cc1f9fe..79e9859 100644 --- a/services/backend/api/v1/task/views.py +++ b/services/backend/api/v1/task/views.py @@ -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 diff --git a/services/backend/api/v1/team/schemas.py b/services/backend/api/v1/team/schemas.py index defafdf..567ca23 100644 --- a/services/backend/api/v1/team/schemas.py +++ b/services/backend/api/v1/team/schemas.py @@ -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", + ) diff --git a/services/backend/api/v1/team/views.py b/services/backend/api/v1/team/views.py index 9b13f81..178992b 100644 --- a/services/backend/api/v1/team/views.py +++ b/services/backend/api/v1/team/views.py @@ -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) diff --git a/services/backend/apps/competition/migrations/0001_initial.py b/services/backend/apps/competition/migrations/0001_initial.py index d2222af..7a7cdfe 100644 --- a/services/backend/apps/competition/migrations/0001_initial.py +++ b/services/backend/apps/competition/migrations/0001_initial.py @@ -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')), diff --git a/services/backend/apps/competition/models.py b/services/backend/apps/competition/models.py index 9c37c79..3cb9b5b 100644 --- a/services/backend/apps/competition/models.py +++ b/services/backend/apps/competition/models.py @@ -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) diff --git a/services/backend/apps/competition/tests.py b/services/backend/apps/competition/tests.py index 3884d70..37dfcc0 100644 --- a/services/backend/apps/competition/tests.py +++ b/services/backend/apps/competition/tests.py @@ -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() diff --git a/services/backend/apps/core/management/commands/generate_data.py b/services/backend/apps/core/management/commands/generate_data.py index b6eaf20..bc3b9de 100644 --- a/services/backend/apps/core/management/commands/generate_data.py +++ b/services/backend/apps/core/management/commands/generate_data.py @@ -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 diff --git a/services/backend/apps/review/migrations/0001_initial.py b/services/backend/apps/review/migrations/0001_initial.py index d0d2eeb..c12c510 100644 --- a/services/backend/apps/review/migrations/0001_initial.py +++ b/services/backend/apps/review/migrations/0001_initial.py @@ -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 diff --git a/services/backend/apps/task/migrations/0001_initial.py b/services/backend/apps/task/migrations/0001_initial.py index 9060ff3..b1a0a2e 100644 --- a/services/backend/apps/task/migrations/0001_initial.py +++ b/services/backend/apps/task/migrations/0001_initial.py @@ -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')), diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index 83bcfc3..66d4ca3 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -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) diff --git a/services/backend/apps/team/admin.py b/services/backend/apps/team/admin.py index 764c42e..37cb70e 100644 --- a/services/backend/apps/team/admin.py +++ b/services/backend/apps/team/admin.py @@ -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", + ) diff --git a/services/backend/apps/team/apps.py b/services/backend/apps/team/apps.py index 5a9c7c0..47024a6 100644 --- a/services/backend/apps/team/apps.py +++ b/services/backend/apps/team/apps.py @@ -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 = "Команды" diff --git a/services/backend/apps/team/migrations/0001_initial.py b/services/backend/apps/team/migrations/0001_initial.py index 70b13e9..81d686b 100644 --- a/services/backend/apps/team/migrations/0001_initial.py +++ b/services/backend/apps/team/migrations/0001_initial.py @@ -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 diff --git a/services/backend/apps/team/models.py b/services/backend/apps/team/models.py index 9cedc3c..8dd5b0c 100644 --- a/services/backend/apps/team/models.py +++ b/services/backend/apps/team/models.py @@ -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: diff --git a/services/backend/apps/user/migrations/0001_initial.py b/services/backend/apps/user/migrations/0001_initial.py index b4b83de..12a0407 100644 --- a/services/backend/apps/user/migrations/0001_initial.py +++ b/services/backend/apps/user/migrations/0001_initial.py @@ -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