From 999f5b069016bc08c492cba6fece03deb1dfe823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=A1=D1=83=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sat, 1 Mar 2025 02:33:06 +0300 Subject: [PATCH 1/4] feat: added competition logic --- .../backend/api/v1/competition/schemas.py | 24 ++++++++++- services/backend/api/v1/competition/views.py | 43 ++++++++++++++++--- services/backend/api/v1/router.py | 2 + .../0002_competition_participants.py | 19 ++++++++ .../apps/competition/migrations/0003_state.py | 28 ++++++++++++ services/backend/apps/competition/models.py | 32 +++++++++----- .../apps/user/migrations/0002_user_status.py | 18 ++++++++ .../user/migrations/0003_alter_user_status.py | 18 ++++++++ services/backend/apps/user/models.py | 2 +- 9 files changed, 167 insertions(+), 19 deletions(-) create mode 100644 services/backend/apps/competition/migrations/0002_competition_participants.py create mode 100644 services/backend/apps/competition/migrations/0003_state.py create mode 100644 services/backend/apps/user/migrations/0002_user_status.py create mode 100644 services/backend/apps/user/migrations/0003_alter_user_status.py diff --git a/services/backend/api/v1/competition/schemas.py b/services/backend/api/v1/competition/schemas.py index efd667a..47c86c1 100644 --- a/services/backend/api/v1/competition/schemas.py +++ b/services/backend/api/v1/competition/schemas.py @@ -1,8 +1,9 @@ +from typing import Literal from uuid import UUID -from ninja import ModelSchema +from ninja import ModelSchema, Schema -from apps.competition.models import Competition +from apps.competition.models import Competition, State class CompetitionOut(ModelSchema): @@ -12,12 +13,31 @@ class CompetitionOut(ModelSchema): model = Competition fields = "__all__" +class StateOut(ModelSchema): + class Meta: + model = State + fields = ( + "state", + ) + +class StateIn(Schema): + state: Literal["started", "not_started", "finished"] class CompetitionListInstanceOut(ModelSchema): id: UUID is_participating: bool completed: bool + @staticmethod + def resolve_is_participating(self, context): + user = context["request"].auth + return self.participants.filter(id=user.id).exists() + + @staticmethod + def resolve_completed(self, context): + user = context["request"].auth + return State.objects.filter(competition=self, user=user, state="finished").exists() + class Meta: model = Competition fields = ( diff --git a/services/backend/api/v1/competition/views.py b/services/backend/api/v1/competition/views.py index 95a70d5..81ebdaf 100644 --- a/services/backend/api/v1/competition/views.py +++ b/services/backend/api/v1/competition/views.py @@ -1,12 +1,14 @@ from http import HTTPStatus as status +from typing import Literal from uuid import UUID - -from django.http import HttpRequest +from django.shortcuts import get_object_or_404 +from django.http import HttpRequest, Http404 from ninja import Router import api.v1.schemas as global_schemas from api.v1.auth import BearerAuth from api.v1.competition import schemas +from apps.competition.models import Competition, State router = Router(tags=["competition"]) @@ -18,11 +20,12 @@ router = Router(tags=["competition"]) status.BAD_REQUEST: global_schemas.BadRequestError, status.UNAUTHORIZED: global_schemas.UnauthorizedError, }, - auth=BearerAuth(), ) def get_competition( request: HttpRequest, competition_id: UUID -) -> tuple[status, schemas.CompetitionOut]: ... +) -> tuple[status, schemas.CompetitionOut]: + competition = get_object_or_404(Competition, id=competition_id) + return status.OK, competition @router.get( @@ -32,8 +35,36 @@ def get_competition( status.BAD_REQUEST: global_schemas.BadRequestError, status.UNAUTHORIZED: global_schemas.UnauthorizedError, }, - auth=BearerAuth(), ) def list_competitions( request: HttpRequest, is_participating: bool -) -> tuple[status, list[schemas.CompetitionListInstanceOut]]: ... +) -> tuple[status, list[schemas.CompetitionListInstanceOut]]: + user = request.auth + if is_participating: + competitions = Competition.objects.filter(participants=user) + else: + competitions = Competition.objects.exclude(participants=user) + return status.OK, competitions + +@router.post( + "competitions/{competition_id}/state", + response={ + status.OK: schemas.StateOut, + status.BAD_REQUEST: global_schemas.BadRequestError, + status.UNAUTHORIZED: global_schemas.UnauthorizedError, + } +) +def change_competition_state( + request: HttpRequest, + competition_id: UUID, + state: schemas.StateIn, +) -> tuple[status, schemas.StateOut]: + user = request.auth + competition = get_object_or_404(Competition, id=competition_id) + + state_obj, _ = State.objects.update_or_create( + user=user, + competition=competition, + state=state.state + ) + return status.OK, schemas.StateOut.from_orm(state_obj) diff --git a/services/backend/api/v1/router.py b/services/backend/api/v1/router.py index 62d8ca4..848448f 100644 --- a/services/backend/api/v1/router.py +++ b/services/backend/api/v1/router.py @@ -3,6 +3,7 @@ from functools import partial from ninja import NinjaAPI from api.v1 import handlers +from api.v1.auth import BearerAuth from api.v1.competition.views import router as competition_router from api.v1.ping.views import router as ping_router from api.v1.user.views import router as user_router @@ -12,6 +13,7 @@ router = NinjaAPI( version="1", description="API docs for DataRush", openapi_url="/docs/openapi.json", + auth=BearerAuth() ) diff --git a/services/backend/apps/competition/migrations/0002_competition_participants.py b/services/backend/apps/competition/migrations/0002_competition_participants.py new file mode 100644 index 0000000..a15dafd --- /dev/null +++ b/services/backend/apps/competition/migrations/0002_competition_participants.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.6 on 2025-02-28 22:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competition', '0001_initial'), + ('user', '0002_user_status'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='participants', + field=models.ManyToManyField(related_name='participants', to='user.user'), + ), + ] diff --git a/services/backend/apps/competition/migrations/0003_state.py b/services/backend/apps/competition/migrations/0003_state.py new file mode 100644 index 0000000..2212552 --- /dev/null +++ b/services/backend/apps/competition/migrations/0003_state.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.6 on 2025-02-28 23:26 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competition', '0002_competition_participants'), + ('user', '0003_alter_user_status'), + ] + + operations = [ + migrations.CreateModel( + 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)), + ('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')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/services/backend/apps/competition/models.py b/services/backend/apps/competition/models.py index 952058b..08538bd 100644 --- a/services/backend/apps/competition/models.py +++ b/services/backend/apps/competition/models.py @@ -1,18 +1,18 @@ from django.db import models from apps.core.models import BaseModel - - -class CompetitionType(models.TextChoices): - SOLO = "solo" - - -class CompetitionParticipationType(models.TextChoices): - EDU = "edu" - COMPETITIVE = "competitive" - +from apps.user.models import User class Competition(BaseModel): + class CompetitionType(models.TextChoices): + SOLO = "solo" + + + class CompetitionParticipationType(models.TextChoices): + EDU = "edu" + COMPETITIVE = "competitive" + + title = models.CharField(max_length=100, verbose_name="Название") description = models.TextField(verbose_name="Описание") image_url = models.FileField( @@ -34,7 +34,19 @@ class Competition(BaseModel): choices=CompetitionParticipationType.choices, verbose_name="Тип соревнования", ) + participants = models.ManyToManyField(User, related_name="participants") class Meta: verbose_name = "соревнование" verbose_name_plural = "соревнования" + + +class State(BaseModel): + class StateChoices(models.TextChoices): + NOT_STARTED = 'not_started' + STARTED = 'started' + FINISHED = 'finished' + + 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) \ No newline at end of file diff --git a/services/backend/apps/user/migrations/0002_user_status.py b/services/backend/apps/user/migrations/0002_user_status.py new file mode 100644 index 0000000..281d8fd --- /dev/null +++ b/services/backend/apps/user/migrations/0002_user_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-02-28 22:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='status', + field=models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10), + ), + ] diff --git a/services/backend/apps/user/migrations/0003_alter_user_status.py b/services/backend/apps/user/migrations/0003_alter_user_status.py new file mode 100644 index 0000000..a7c766f --- /dev/null +++ b/services/backend/apps/user/migrations/0003_alter_user_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-02-28 22:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0002_user_status'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='status', + field=models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10), + ), + ] diff --git a/services/backend/apps/user/models.py b/services/backend/apps/user/models.py index ab52004..be30d58 100644 --- a/services/backend/apps/user/models.py +++ b/services/backend/apps/user/models.py @@ -13,7 +13,7 @@ class User(BaseModel): username = models.SlugField(unique=True, verbose_name="Юзернейм") password = models.TextField(verbose_name="Пароль") - status = models.CharField(max_length=10, choices=UserRole.choices, default=UserRole.STUDENT) + status = models.CharField(max_length=10, choices=UserRole, default="student") def __str__(self): return self.username From 33b081e433683d21806dc1f2306a12a4aa552755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=A1=D1=83=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sat, 1 Mar 2025 02:35:09 +0300 Subject: [PATCH 2/4] lint: linted --- services/backend/api/v1/competition/schemas.py | 11 +++++++---- services/backend/api/v1/competition/views.py | 12 +++++------- services/backend/api/v1/router.py | 2 +- services/backend/api/v1/user/views.py | 10 +++++++--- services/backend/apps/competition/models.py | 11 +++++------ services/backend/apps/core/apps.py | 3 --- services/backend/apps/user/models.py | 4 +++- 7 files changed, 28 insertions(+), 25 deletions(-) diff --git a/services/backend/api/v1/competition/schemas.py b/services/backend/api/v1/competition/schemas.py index 47c86c1..1c5aff1 100644 --- a/services/backend/api/v1/competition/schemas.py +++ b/services/backend/api/v1/competition/schemas.py @@ -13,16 +13,17 @@ class CompetitionOut(ModelSchema): model = Competition fields = "__all__" + class StateOut(ModelSchema): class Meta: model = State - fields = ( - "state", - ) + fields = ("state",) + class StateIn(Schema): state: Literal["started", "not_started", "finished"] + class CompetitionListInstanceOut(ModelSchema): id: UUID is_participating: bool @@ -36,7 +37,9 @@ class CompetitionListInstanceOut(ModelSchema): @staticmethod def resolve_completed(self, context): user = context["request"].auth - return State.objects.filter(competition=self, user=user, state="finished").exists() + return State.objects.filter( + competition=self, user=user, state="finished" + ).exists() class Meta: model = Competition diff --git a/services/backend/api/v1/competition/views.py b/services/backend/api/v1/competition/views.py index 81ebdaf..095549c 100644 --- a/services/backend/api/v1/competition/views.py +++ b/services/backend/api/v1/competition/views.py @@ -1,12 +1,11 @@ from http import HTTPStatus as status -from typing import Literal from uuid import UUID + +from django.http import HttpRequest from django.shortcuts import get_object_or_404 -from django.http import HttpRequest, Http404 from ninja import Router import api.v1.schemas as global_schemas -from api.v1.auth import BearerAuth from api.v1.competition import schemas from apps.competition.models import Competition, State @@ -46,13 +45,14 @@ def list_competitions( competitions = Competition.objects.exclude(participants=user) return status.OK, competitions + @router.post( "competitions/{competition_id}/state", response={ status.OK: schemas.StateOut, status.BAD_REQUEST: global_schemas.BadRequestError, status.UNAUTHORIZED: global_schemas.UnauthorizedError, - } + }, ) def change_competition_state( request: HttpRequest, @@ -63,8 +63,6 @@ def change_competition_state( competition = get_object_or_404(Competition, id=competition_id) state_obj, _ = State.objects.update_or_create( - user=user, - competition=competition, - state=state.state + user=user, competition=competition, state=state.state ) return status.OK, schemas.StateOut.from_orm(state_obj) diff --git a/services/backend/api/v1/router.py b/services/backend/api/v1/router.py index 848448f..241af1e 100644 --- a/services/backend/api/v1/router.py +++ b/services/backend/api/v1/router.py @@ -13,7 +13,7 @@ router = NinjaAPI( version="1", description="API docs for DataRush", openapi_url="/docs/openapi.json", - auth=BearerAuth() + auth=BearerAuth(), ) diff --git a/services/backend/api/v1/user/views.py b/services/backend/api/v1/user/views.py index b253db9..29b27bb 100644 --- a/services/backend/api/v1/user/views.py +++ b/services/backend/api/v1/user/views.py @@ -3,9 +3,14 @@ from http import HTTPStatus as status from ninja import Router from ninja.errors import AuthenticationError -from api.v1.user.schemas import LoginSchema, RegisterSchema, TokenSchema, UserSchema from api.v1.auth import BearerAuth from api.v1.schemas import BadRequestError, ForbiddenError, NotFoundError +from api.v1.user.schemas import ( + LoginSchema, + RegisterSchema, + TokenSchema, + UserSchema, +) from apps.user.models import User router = Router(tags=["user"]) @@ -56,5 +61,4 @@ def sign_in(request, data: LoginSchema): status.NOT_FOUND: NotFoundError, }, ) -def get_user(request, user_id: str): - ... +def get_user(request, user_id: str): ... diff --git a/services/backend/apps/competition/models.py b/services/backend/apps/competition/models.py index 08538bd..644f733 100644 --- a/services/backend/apps/competition/models.py +++ b/services/backend/apps/competition/models.py @@ -3,16 +3,15 @@ from django.db import models from apps.core.models import BaseModel from apps.user.models import User + class Competition(BaseModel): class CompetitionType(models.TextChoices): SOLO = "solo" - class CompetitionParticipationType(models.TextChoices): EDU = "edu" COMPETITIVE = "competitive" - title = models.CharField(max_length=100, verbose_name="Название") description = models.TextField(verbose_name="Описание") image_url = models.FileField( @@ -43,10 +42,10 @@ class Competition(BaseModel): class State(BaseModel): class StateChoices(models.TextChoices): - NOT_STARTED = 'not_started' - STARTED = 'started' - FINISHED = 'finished' + NOT_STARTED = "not_started" + STARTED = "started" + FINISHED = "finished" 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) \ No newline at end of file + state = models.CharField(choices=StateChoices.choices, max_length=11) diff --git a/services/backend/apps/core/apps.py b/services/backend/apps/core/apps.py index a37dd7d..3a9b191 100644 --- a/services/backend/apps/core/apps.py +++ b/services/backend/apps/core/apps.py @@ -1,7 +1,4 @@ -import contextlib - from django.apps import AppConfig -from django.core.cache import cache class CoreConfig(AppConfig): diff --git a/services/backend/apps/user/models.py b/services/backend/apps/user/models.py index be30d58..a84403b 100644 --- a/services/backend/apps/user/models.py +++ b/services/backend/apps/user/models.py @@ -13,7 +13,9 @@ class User(BaseModel): username = models.SlugField(unique=True, verbose_name="Юзернейм") password = models.TextField(verbose_name="Пароль") - status = models.CharField(max_length=10, choices=UserRole, default="student") + status = models.CharField( + max_length=10, choices=UserRole, default="student" + ) def __str__(self): return self.username From 2bf38523a867b9f50022f0fb6439114f816f0a35 Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 02:35:56 +0300 Subject: [PATCH 3/4] add user status and reg tests --- .../apps/user/migrations/0002_user_status.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 services/backend/apps/user/migrations/0002_user_status.py diff --git a/services/backend/apps/user/migrations/0002_user_status.py b/services/backend/apps/user/migrations/0002_user_status.py new file mode 100644 index 0000000..5fe4011 --- /dev/null +++ b/services/backend/apps/user/migrations/0002_user_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-02-28 21:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='status', + field=models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10), + ), + ] From 2ccfb62f4e79441cad380ad195ee5d99485e2d8e Mon Sep 17 00:00:00 2001 From: Timur Date: Sat, 1 Mar 2025 02:42:30 +0300 Subject: [PATCH 4/4] add user model tests --- services/backend/apps/user/test.py | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 services/backend/apps/user/test.py diff --git a/services/backend/apps/user/test.py b/services/backend/apps/user/test.py new file mode 100644 index 0000000..437da0d --- /dev/null +++ b/services/backend/apps/user/test.py @@ -0,0 +1,32 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase + +from apps.user.models import User + + +class TestSignUp(TestCase): + def test_correct_signup(self): + user = User( + email="123123@timka.su", + password="1321312", + username="123123", + ) + user.full_clean() + user.save() + + def test_incorrect_mail(self): + user = User( + email="123123", + password="1321312", + username="123123123", + ) + with self.assertRaises(ValidationError): + user.full_clean() + + def test_missing_params(self): + user = User( + password="123123", + username="132131232131" + ) + with self.assertRaises(ValidationError): + user.full_clean()