diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8897e31..457741d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -107,12 +107,12 @@ deploy: - | ssh $SSH_ADDRESS <<'EOF' cd ~/deploy + docker system prune -a --force docker compose pull > deploy.log 2>&1 docker compose down >> deploy.log 2>&1 docker compose up -d --remove-orphans --force-recreate >> deploy.log 2>&1 docker compose ps >> deploy.log 2>&1 EOF - - ssh $SSH_ADDRESS "docker system prune -a --force" retry: 2 @@ -146,5 +146,4 @@ reset-compose: docker compose up -d --remove-orphans --force-recreate >> deploy.log 2>&1 docker compose ps >> deploy.log 2>&1 EOF - - ssh $SSH_ADDRESS "docker system prune -a --force" retry: 2 diff --git a/compose.yaml b/compose.yaml index eabd04b..53771a3 100644 --- a/compose.yaml +++ b/compose.yaml @@ -370,11 +370,20 @@ services: restart: unless-stopped shm_size: 4mb + custom_python: + image: gitlab.prodcontest.ru:5050/team-15/project/custom-python:latest + entrypoint: ["sh", "-c", "exit 0"] + checker: image: gitlab.prodcontest.ru:5050/team-15/project/checker:latest build: context: ./services/checker dockerfile: Dockerfile + depends_on: + custom_python: + restart: false + condition: service_completed_successfully + required: true env_file: - path: ./infrastructure/checker/.env.template required: true diff --git a/services/backend/api/v1/achievement/schemas.py b/services/backend/api/v1/achievement/schemas.py index ecc1c5d..0017a16 100644 --- a/services/backend/api/v1/achievement/schemas.py +++ b/services/backend/api/v1/achievement/schemas.py @@ -1,6 +1,7 @@ -from ninja import ModelSchema +from ninja import ModelSchema, Schema +from pydantic import Field -from apps.achievement.models import Achievement +from apps.achievement.models import Achievement, UserAchievement class AchievementSchema(ModelSchema): @@ -12,3 +13,13 @@ class AchievementSchema(ModelSchema): "description", "icon", ) + + +class UserAchievementSchema(Schema): + name: str = Field(..., alias="achievement.name") + description: str = Field(..., alias="achievement.description") + icon: str = Field(..., alias="achievement.icon") + + class Meta: + model = UserAchievement + fields = ("received_at",) diff --git a/services/backend/api/v1/achievement/views.py b/services/backend/api/v1/achievement/views.py index 804348a..44a6eff 100644 --- a/services/backend/api/v1/achievement/views.py +++ b/services/backend/api/v1/achievement/views.py @@ -6,15 +6,16 @@ from api.v1.achievement.schemas import AchievementSchema from api.v1.schemas import UnauthorizedError from apps.achievement.models import Achievement -router = Router() +router = Router(tags=["achievement"]) @router.get( - "", + "all", response={ status.OK: list[AchievementSchema], status.UNAUTHORIZED: UnauthorizedError, }, + auth=None, ) def get_all_achievements(request): return Achievement.objects.all() diff --git a/services/backend/api/v1/router.py b/services/backend/api/v1/router.py index 4388666..4bcb70d 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.achievement.views import router as achievement_router 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 @@ -49,6 +50,11 @@ router.add_router( team_router, auth=BearerAuth(), ) +router.add_router( + "achievements", + achievement_router, + auth=BearerAuth(), +) for exception, handler in handlers.exception_handlers: diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py index 1b86af1..9a57a7f 100644 --- a/services/backend/api/v1/task/views.py +++ b/services/backend/api/v1/task/views.py @@ -12,6 +12,7 @@ from api.v1.task.schemas import ( TaskOutSchema, TaskSubmissionOut, ) +from apps.achievement.models import Achievement, UserAchievement from apps.competition.models import State from apps.task.models import ( Competition, @@ -102,6 +103,11 @@ def submit_task( CompetitionTask, competition=competition, id=task_id ) + if not CompetitionTaskSubmission.objects.filter(user=user).exists(): + first_steps_achievement = Achievement.objects.get(slug="first_steps") + UserAchievement.objects.create( + user=user, achievement=first_steps_achievement + ) if task.type == CompetitionTask.CompetitionTaskType.INPUT: submission = CompetitionTaskSubmission.objects.create( user=user, diff --git a/services/backend/api/v1/user/schemas.py b/services/backend/api/v1/user/schemas.py index b97f6ac..2788913 100644 --- a/services/backend/api/v1/user/schemas.py +++ b/services/backend/api/v1/user/schemas.py @@ -1,5 +1,7 @@ from ninja import ModelSchema, Schema +from api.v1.achievement.schemas import UserAchievementSchema +from apps.achievement.models import UserAchievement from apps.user.models import User @@ -20,9 +22,17 @@ class LoginSchema(ModelSchema): class UserSchema(ModelSchema): + achievements: list[UserAchievementSchema] = None + + @staticmethod + def resolve_achievements(self, context): + return UserAchievement.objects.filter( + user=context.get("request").auth + ).order_by("-received_at") + class Meta: model = User - fields = ["id", "email", "username", "created_at", "achievements"] + fields = ["id", "email", "username", "created_at"] class StatSchema(Schema): diff --git a/services/backend/api/v1/user/views.py b/services/backend/api/v1/user/views.py index d7a3dfb..5028e87 100644 --- a/services/backend/api/v1/user/views.py +++ b/services/backend/api/v1/user/views.py @@ -11,17 +11,18 @@ from api.v1.schemas import ( BadRequestError, ConflictError, ForbiddenError, - NotFoundError, UnauthorizedError, + NotFoundError, + UnauthorizedError, ) from api.v1.user.schemas import ( LoginSchema, RegisterSchema, + StatSchema, TokenSchema, UserSchema, - StatSchema ) +from apps.task.models import CompetitionTaskSubmission from apps.user.models import User -from apps.task.models import CompetitionTaskSubmission, ReviewStatusChoices router = Router(tags=["user"]) @@ -91,16 +92,15 @@ def get_user(request, user_id: str): @router.get( "/me/stat", - response={ - status.OK: StatSchema, - status.UNAUTHORIZED: UnauthorizedError - }, + response={status.OK: StatSchema, status.UNAUTHORIZED: UnauthorizedError}, ) def get_my_stat(request): user_submissions = CompetitionTaskSubmission.objects.filter( user=request.auth ) - checked_attempts = user_submissions.filter(status=CompetitionTaskSubmission.StatusChoices.CHECKED).all() + checked_attempts = user_submissions.filter( + status=CompetitionTaskSubmission.StatusChoices.CHECKED + ).all() success_attempts_cnt = 0 for attempt in checked_attempts: @@ -112,6 +112,5 @@ def get_my_stat(request): success_attempts_cnt += 1 return StatSchema( - total_attempts=len(user_submissions), - solved_tasks=success_attempts_cnt + total_attempts=len(user_submissions), solved_tasks=success_attempts_cnt ) diff --git a/services/backend/apps/achievement/icons/first_steps.png b/services/backend/apps/achievement/icons/first_steps.png new file mode 100644 index 0000000..9a29700 Binary files /dev/null and b/services/backend/apps/achievement/icons/first_steps.png differ diff --git a/services/backend/apps/achievement/icons/welcome.png b/services/backend/apps/achievement/icons/welcome.png new file mode 100644 index 0000000..c0f4f18 Binary files /dev/null and b/services/backend/apps/achievement/icons/welcome.png differ diff --git a/services/backend/apps/achievement/management/commands/init_achievments.py b/services/backend/apps/achievement/management/commands/init_achievments.py new file mode 100644 index 0000000..9619b58 --- /dev/null +++ b/services/backend/apps/achievement/management/commands/init_achievments.py @@ -0,0 +1,32 @@ +from django.conf import settings +from django.core.files import File +from django.core.management import BaseCommand + +from apps.achievement.models import Achievement + +icons_dir = f"{settings.BASE_DIR}/apps/achievement/icons" + + +class Command(BaseCommand): + help = "Create achievement fixtures." + + def handle(self, *args, **options): + if not Achievement.objects.filter(slug="first_steps").exists(): + with open(f"{icons_dir}/first_steps.png", "rb") as f: + first_steps_icon = File(f, name="first_steps.png") + Achievement.objects.get_or_create( + name="Первые шаги", + description="Отправьте свое первое решение на задачу!", + slug="first_steps", + icon=first_steps_icon, + ) + + if not Achievement.objects.filter(slug="welcome").exists(): + with open(f"{icons_dir}/welcome.png", "rb") as f: + welcome_icon = File(f, name="welcome.png") + Achievement.objects.get_or_create( + name="Добро пожаловать!", + description="Зарегистрируйтесь на платформе", + slug="welcome", + icon=welcome_icon, + ) diff --git a/services/backend/apps/achievement/migrations/0001_initial.py b/services/backend/apps/achievement/migrations/0001_initial.py index b6ad6c1..03300ea 100644 --- a/services/backend/apps/achievement/migrations/0001_initial.py +++ b/services/backend/apps/achievement/migrations/0001_initial.py @@ -1,6 +1,7 @@ -# Generated by Django 5.1.6 on 2025-03-02 21:24 +# Generated by Django 5.1.6 on 2025-03-03 07:20 import apps.achievement.models +import django.db.models.deletion import uuid from django.db import migrations, models @@ -27,4 +28,15 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'ачивки', }, ), + migrations.CreateModel( + name='UserAchievement', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('received_at', models.DateTimeField(auto_now_add=True)), + ('achievement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='achievement.achievement')), + ], + options={ + 'abstract': False, + }, + ), ] diff --git a/services/backend/apps/achievement/migrations/0002_initial.py b/services/backend/apps/achievement/migrations/0002_initial.py new file mode 100644 index 0000000..1d03338 --- /dev/null +++ b/services/backend/apps/achievement/migrations/0002_initial.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.6 on 2025-03-03 07:20 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('achievement', '0001_initial'), + ('user', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='userachievement', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user'), + ), + ] diff --git a/services/backend/apps/achievement/models.py b/services/backend/apps/achievement/models.py index 292598f..8116ff3 100644 --- a/services/backend/apps/achievement/models.py +++ b/services/backend/apps/achievement/models.py @@ -4,9 +4,6 @@ from apps.core.models import BaseModel class Achievement(BaseModel): - class AchievementType(models.TextChoices): - CORRECT_TASKS = "correct_tasks", "Выполненные задания" - def image_url_upload_to(instance, filename): return f"achievements/{instance.id}/icon/{filename}" @@ -27,3 +24,10 @@ class Achievement(BaseModel): class Meta: verbose_name = "ачивка" verbose_name_plural = "ачивки" + + +class UserAchievement(BaseModel): + achievement = models.ForeignKey(Achievement, on_delete=models.CASCADE) + user = models.ForeignKey("user.User", on_delete=models.CASCADE) + + received_at = models.DateTimeField(auto_now_add=True) diff --git a/services/backend/apps/competition/migrations/0001_initial.py b/services/backend/apps/competition/migrations/0001_initial.py index c13f62b..428a78a 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-02 21:24 +# Generated by Django 5.1.6 on 2025-03-03 07:20 import apps.competition.models import datetime diff --git a/services/backend/apps/core/management/commands/generate_data.py b/services/backend/apps/core/management/commands/generate_data.py index d6e6157..63645f3 100644 --- a/services/backend/apps/core/management/commands/generate_data.py +++ b/services/backend/apps/core/management/commands/generate_data.py @@ -9,7 +9,11 @@ from django.utils import timezone from apps.competition.models import Competition, State from apps.review.models import Reviewer -from apps.task.models import CompetitionTask, CompetitionTaskSubmission, CompetitionTaskCriteria +from apps.task.models import ( + CompetitionTask, + CompetitionTaskCriteria, + CompetitionTaskSubmission, +) from apps.user.models import User, UserRole @@ -92,7 +96,7 @@ class Command(BaseCommand): task_types = [ CompetitionTask.CompetitionTaskType.INPUT.value, CompetitionTask.CompetitionTaskType.REVIEW.value, - CompetitionTask.CompetitionTaskType.INPUT.value + CompetitionTask.CompetitionTaskType.INPUT.value, ] for comp in competitions: # Create 3 tasks per competition @@ -110,7 +114,10 @@ class Command(BaseCommand): submission_reviewers_count=random.randint(2, 10), max_attempts=random.randint(1, 10), ) - if task_type == CompetitionTask.CompetitionTaskType.REVIEW.value: + if ( + task_type + == CompetitionTask.CompetitionTaskType.REVIEW.value + ): for j in range(5): CompetitionTaskCriteria.objects.create( task=task, diff --git a/services/backend/apps/review/apps.py b/services/backend/apps/review/apps.py index 138bf7f..b144226 100644 --- a/services/backend/apps/review/apps.py +++ b/services/backend/apps/review/apps.py @@ -7,4 +7,4 @@ class CoreConfig(AppConfig): verbose_name = "Проверка" def ready(self): - import apps.review.signals + pass diff --git a/services/backend/apps/review/migrations/0001_initial.py b/services/backend/apps/review/migrations/0001_initial.py index 0ec3a21..afe281e 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-02 21:24 +# Generated by Django 5.1.6 on 2025-03-03 07:20 import uuid from django.db import migrations, models diff --git a/services/backend/apps/review/migrations/0002_initial.py b/services/backend/apps/review/migrations/0002_initial.py index 426c065..5a5d3cc 100644 --- a/services/backend/apps/review/migrations/0002_initial.py +++ b/services/backend/apps/review/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 21:24 +# Generated by Django 5.1.6 on 2025-03-03 07:20 import django.db.models.deletion from django.db import migrations, models diff --git a/services/backend/apps/review/signals.py b/services/backend/apps/review/signals.py index 44da6c2..2386300 100644 --- a/services/backend/apps/review/signals.py +++ b/services/backend/apps/review/signals.py @@ -2,13 +2,12 @@ from django.db.models.signals import m2m_changed from django.dispatch import receiver -from apps.review.models import Review from apps.task.models import CompetitionTask, CompetitionTaskSubmission @receiver(m2m_changed, sender=CompetitionTask.reviewers.through) def print_reviewers(sender, instance, action, **kwargs): - if action in ['post_add', 'post_remove', 'post_clear']: + if action in ["post_add", "post_remove", "post_clear"]: submissions = CompetitionTaskSubmission.objects.filter(task=instance) for submission in submissions: - submission.send_on_review() \ No newline at end of file + submission.send_on_review() diff --git a/services/backend/apps/task/admin.py b/services/backend/apps/task/admin.py index 6feaced..7b6fbfa 100644 --- a/services/backend/apps/task/admin.py +++ b/services/backend/apps/task/admin.py @@ -33,7 +33,7 @@ class CompetitionTaskSubmissionAdmin(admin.ModelAdmin): "user__username", "user__email", ) - list_filter = ("plagiarism_checked", "status",) + list_filter = ("plagiarism_checked", "status") ordering = ["-timestamp"] def has_add_permission(self, request, obj=None): diff --git a/services/backend/apps/task/migrations/0001_initial.py b/services/backend/apps/task/migrations/0001_initial.py index 8a2fa34..9cfeeae 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-02 21:24 +# Generated by Django 5.1.6 on 2025-03-03 07:20 import apps.task.models import django.db.models.deletion diff --git a/services/backend/apps/task/tasks.py b/services/backend/apps/task/tasks.py index 2f3d50c..9001a6d 100644 --- a/services/backend/apps/task/tasks.py +++ b/services/backend/apps/task/tasks.py @@ -1,8 +1,7 @@ import httpx from celery import shared_task -from django.core.files.base import ContentFile - from django.conf import settings +from django.core.files.base import ContentFile @shared_task(bind=True, max_retries=3) @@ -40,7 +39,7 @@ def analyze_data_task(self, submission_id): ) submission.status = CompetitionTaskSubmission.StatusChoices.CHECKED - except httpx.RequestError as e: + except httpx.RequestError: self.retry(countdown=2**self.request.retries) except Exception as e: submission.result = {"error": str(e), "success": False} diff --git a/services/backend/apps/team/migrations/0001_initial.py b/services/backend/apps/team/migrations/0001_initial.py index a2eca7f..1924dc3 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-02 21:24 +# Generated by Django 5.1.6 on 2025-03-03 07:20 import django.db.models.deletion import uuid diff --git a/services/backend/apps/user/apps.py b/services/backend/apps/user/apps.py index e38650e..c804c8f 100644 --- a/services/backend/apps/user/apps.py +++ b/services/backend/apps/user/apps.py @@ -6,3 +6,6 @@ class UsersConfig(AppConfig): name = "apps.user" label = "user" verbose_name = "Пользователи (веб)" + + def ready(self): + pass diff --git a/services/backend/apps/user/migrations/0001_initial.py b/services/backend/apps/user/migrations/0001_initial.py index c1fa8be..4ade297 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-02 21:24 +# Generated by Django 5.1.6 on 2025-03-03 07:20 import uuid from django.db import migrations, models diff --git a/services/backend/apps/user/signals.py b/services/backend/apps/user/signals.py new file mode 100644 index 0000000..a6f5ff6 --- /dev/null +++ b/services/backend/apps/user/signals.py @@ -0,0 +1,14 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from apps.achievement.models import Achievement, UserAchievement +from apps.user.models import User + + +@receiver(post_save, sender=User) +def assign_welcome_achievement(sender, instance, created, **kwargs): + if created: + welcome_achievement = Achievement.objects.get(slug="welcome") + UserAchievement.objects.create( + user=instance, achievement=welcome_achievement + ) diff --git a/services/backend/config/settings.py b/services/backend/config/settings.py index 243d5ee..3987361 100644 --- a/services/backend/config/settings.py +++ b/services/backend/config/settings.py @@ -7,8 +7,9 @@ from pathlib import Path import django_stubs_ext import environ -from health_check.plugins import plugin_dir from django.utils.translation import gettext_lazy as _ +from health_check.plugins import plugin_dir + from integrations.checker.healthcheck import CheckerHealthCheck BASE_DIR = Path(__file__).resolve().parent.parent diff --git a/services/backend/scripts/initdb b/services/backend/scripts/initdb index f2d64eb..146732a 100755 --- a/services/backend/scripts/initdb +++ b/services/backend/scripts/initdb @@ -9,3 +9,5 @@ fi if [ "$DJANGO_CREATE_SUPERUSER" = "True" ]; then python manage.py createsuperuser --noinput --username "$DJANGO_SUPERUSER_USERNAME" --email "$DJANGO_SUPERUSER_EMAIL" || true fi + +python manage.py init_achievments \ No newline at end of file diff --git a/services/checker/.env.template b/services/checker/.env.template index 8bea4d3..9dac72a 100644 --- a/services/checker/.env.template +++ b/services/checker/.env.template @@ -1,10 +1,4 @@ # Change all vars before going to production and remove all comments (!) # Below all environment variables and default values -REGISTRY_USERNAME= - -REGISTRY_PASSWORD= - -REGISTRY_URL=gitlab.prodcontest.ru:5050 - DOCKER_IMAGE=gitlab.prodcontest.ru:5050/team-15/project/custom-python diff --git a/services/checker/Dockerfile b/services/checker/Dockerfile index d4266d1..d0f7ea4 100644 --- a/services/checker/Dockerfile +++ b/services/checker/Dockerfile @@ -30,7 +30,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONOPTIMIZE=2 \ PATH="/opt/venv/bin:$PATH" -EXPOSE 8080 +EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --start-interval=2s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8000/health || exit 1 diff --git a/services/checker/config.py b/services/checker/config.py index 7c49c2f..5f88007 100644 --- a/services/checker/config.py +++ b/services/checker/config.py @@ -7,12 +7,6 @@ BASE_DIR = Path(__file__).resolve().parent load_dotenv(BASE_DIR / ".env") -REGISTRY_LOGIN = os.getenv("REGISTRY_USERNAME", None) - -REGISTRY_PASSWORD = os.getenv("REGISTRY_USERNAME", None) - -REGISTRY_URL = os.getenv("REGISTRY_URL", "gitlab.prodcontest.ru:5050") - DOCKER_IMAGE = os.getenv( "DOCKER_IMAGE", default="gitlab.prodcontest.ru:5050/team-15/project/custom-python" ) diff --git a/services/checker/main.py b/services/checker/main.py index f7b4b13..a1d05fa 100644 --- a/services/checker/main.py +++ b/services/checker/main.py @@ -1,4 +1,3 @@ -import docker.errors from fastapi import FastAPI, HTTPException, status from pydantic import BaseModel, Field, HttpUrl import aiohttp @@ -21,26 +20,10 @@ ALLOWED_FILENAME_CHARS = r"[^a-zA-Z0-9_\-.]" app = FastAPI() docker_client = docker.from_env() -print(docker_client.login( - username=config.REGISTRY_LOGIN, - password=config.REGISTRY_PASSWORD, - registry=config.REGISTRY_URL, -)) logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) -@app.on_event("startup") -async def pull_docker_image(): - client = docker.from_env() - image_name = config.DOCKER_IMAGE - try: - client.images.pull(image_name) - print(f"Successfully pulled {image_name}") - except docker.errors.DockerException as e: - print(f"Error pulling {image_name}: {e}") - - class FileDetails(BaseModel): url: HttpUrl = Field( ..., description="URL to download the file from (supports HTTP/HTTPS)"