diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5cbbefe..f454300 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -61,10 +61,13 @@ deploy: before_script: - apk add --no-cache openssh-client script: + - echo $SSH_PRIVATE_KEY_BASE64 - mkdir -p ~/.ssh && chmod 700 ~/.ssh - - printf "%s" "$SSH_PRIVATE_KEY_BASE64" | base64 -d -i > ~/.ssh/id_rsa - - chmod 600 ~/.ssh/id_rsa - - ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts + - echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config && chmod 600 ~/.ssh/config + - echo "$SSH_PRIVATE_KEY_BASE64" | base64 -d > ~/.ssh/id_rsa && chmod 400 ~/.ssh/id_rsa + - cat ~/.ssh/id_rsa + - ssh-agent sh -c "ssh-add ~/.ssh/id_rsa" + - ssh-keyscan -H "$SSH_HOST" - scp -C -r infrastructure/ compose.yaml "$SSH_ADDRESS":~/deploy/ - ssh "$SSH_ADDRESS" << 'EOF' set -e diff --git a/services/backend/api/v1/competition/schemas.py b/services/backend/api/v1/competition/schemas.py index efd667a..1c5aff1 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): @@ -13,11 +14,33 @@ class CompetitionOut(ModelSchema): 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..095549c 100644 --- a/services/backend/api/v1/competition/views.py +++ b/services/backend/api/v1/competition/views.py @@ -2,11 +2,12 @@ from http import HTTPStatus as status from uuid import UUID from django.http import HttpRequest +from django.shortcuts import get_object_or_404 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 +19,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 +34,35 @@ 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..241af1e 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/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/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..644f733 100644 --- a/services/backend/apps/competition/models.py +++ b/services/backend/apps/competition/models.py @@ -1,18 +1,17 @@ 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 +33,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) 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/migrations/0002_user_status.py b/services/backend/apps/user/migrations/0002_user_status.py index 5fe4011..281d8fd 100644 --- a/services/backend/apps/user/migrations/0002_user_status.py +++ b/services/backend/apps/user/migrations/0002_user_status.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-02-28 21:40 +# Generated by Django 5.1.6 on 2025-02-28 22:40 from django.db import migrations, models 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..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.choices, default=UserRole.STUDENT) + status = models.CharField( + max_length=10, choices=UserRole, default="student" + ) def __str__(self): return self.username