diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fde8bb7..929f711 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,7 @@ stages: - build - deploy + - utils variables: DOCKER_TLS_CERTDIR: /certs @@ -106,10 +107,42 @@ 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 + + +reset-compose: + image: kroniak/ssh-client:3.19 + stage: utils + when: manual + rules: + - if: '$CI_COMMIT_REF_NAME == "master"' + variables: + SSH_HOST: "158.160.172.23" + SSH_USER: "ubuntu" + SSH_ADDRESS: "$SSH_USER@$SSH_HOST" + SSH_PRIVATE_KEY: SSH_PRIVATE_KEY + script: + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + - echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config + - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - ssh-keyscan -H "$SSH_HOST" > /dev/null 2>&1 + + - AUTH_COMMAND="echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY --username $CI_REGISTRY_USER --password-stdin"; + - ssh $SSH_ADDRESS "$AUTH_COMMAND" + - scp -C -r infrastructure/ compose.yaml $SSH_ADDRESS:~/deploy/ + - ssh $SSH_ADDRESS "docker -v" + - | + ssh $SSH_ADDRESS <<'EOF' + cd ~/deploy + docker compose down -v > deploy.log 2>&1 + docker compose up -d --remove-orphans --force-recreate >> deploy.log 2>&1 + docker compose ps >> deploy.log 2>&1 + EOF retry: 2 diff --git a/compose.yaml b/compose.yaml index eabd04b..a6f946f 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 @@ -391,6 +400,9 @@ services: - type: bind source: /var/run/docker.sock target: /var/run/docker.sock + - type: bind + source: /tmp + target: /tmp proxy: image: docker.io/nginx:1.27-alpine3.21 @@ -401,7 +413,7 @@ services: test: ["CMD", "service", "nginx", "status", "||", " exit 1"] interval: 1m30s timeout: 5s - start_period: 5s + start_period: 15s start_interval: 2s retries: 5 ports: diff --git a/infrastructure/checker/.env.template b/infrastructure/checker/.env.template index 7c6f9d3..696fced 100644 --- a/infrastructure/checker/.env.template +++ b/infrastructure/checker/.env.template @@ -1,2 +1,2 @@ REGISTRY_LOGIN=devitq -REGISTRY_PASSWORD=14zQrbzDTM0WXK@CogMQikAvP74Rj4 +REGISTRY_PASSWORD=prod-zAoUDyHwkgRfQPyVvskH diff --git a/infrastructure/grafana/grafana.ini b/infrastructure/grafana/grafana.ini index 86cb5f8..9c79e84 100644 --- a/infrastructure/grafana/grafana.ini +++ b/infrastructure/grafana/grafana.ini @@ -48,13 +48,13 @@ domain = localhost enforce_domain = false # The full public facing url -root_url = https://prod-team-15-2pc0i3lc.final.prodcontest.ru/admin/grafana/ +root_url = https://prod-team-15-2pc0i3lc.final.prodcontest.ru/admin/grafana # Serve Grafana from subpath specified in `root_url` setting. By default it is set to `false` for compatibility reasons. serve_from_sub_path = true # Log web requests -router_logging = false +router_logging = true # the path relative working path static_root_path = public diff --git a/infrastructure/nginx/nginx.conf b/infrastructure/nginx/nginx.conf index dff0f61..ffb5e55 100644 --- a/infrastructure/nginx/nginx.conf +++ b/infrastructure/nginx/nginx.conf @@ -183,8 +183,8 @@ http { client_max_body_size 100M; } - location /admin/grafana/ { - proxy_pass http://grafana:3000/; + location /admin/grafana { + proxy_pass http://grafana:3000/admin/grafana; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; diff --git a/services/backend/api/v1/achievement/schemas.py b/services/backend/api/v1/achievement/schemas.py index ecc1c5d..74feaf8 100644 --- a/services/backend/api/v1/achievement/schemas.py +++ b/services/backend/api/v1/achievement/schemas.py @@ -1,6 +1,9 @@ -from ninja import ModelSchema +from datetime import datetime -from apps.achievement.models import Achievement +from ninja import ModelSchema, Schema +from pydantic import Field + +from apps.achievement.models import Achievement, UserAchievement class AchievementSchema(ModelSchema): @@ -12,3 +15,14 @@ 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") + received_at: datetime + + 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/review/schemas.py b/services/backend/api/v1/review/schemas.py index c623e2f..dd09d5c 100644 --- a/services/backend/api/v1/review/schemas.py +++ b/services/backend/api/v1/review/schemas.py @@ -38,6 +38,7 @@ class SubmissionOut(ModelSchema): competition_name: str = Field(..., alias="task.competition.title") task_position: int = Field(..., alias="task.in_competition_position") task_title: str = Field(..., alias="task.title") + description: str = Field(..., alias="task.description") @staticmethod def resolve_criteries(self, context) -> list[CriteriaOut] | None: 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/schemas.py b/services/backend/api/v1/task/schemas.py index 9e73c37..3e07b92 100644 --- a/services/backend/api/v1/task/schemas.py +++ b/services/backend/api/v1/task/schemas.py @@ -33,6 +33,7 @@ class TaskOutSchema(ModelSchema): "description", "in_competition_position", "points", + "max_attempts" ] diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py index 1b86af1..f2e93c9 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,13 +103,27 @@ 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 + ) + + total_attempts = CompetitionTaskSubmission.objects.filter( + user=user, task=task + ).count() + if task.max_attempts == total_attempts: + return status.FORBIDDEN, ForbiddenError() + if task.type == CompetitionTask.CompetitionTaskType.INPUT: submission = CompetitionTaskSubmission.objects.create( user=user, task=task, status=CompetitionTaskSubmission.StatusChoices.CHECKED, - result={"correct": content == task.answer_file_path}, content=content, + result={ + "correct": content.read() == task.correct_answer_file.read() + }, ) if task.type == CompetitionTask.CompetitionTaskType.REVIEW: submission = CompetitionTaskSubmission.objects.create( diff --git a/services/backend/api/v1/user/schemas.py b/services/backend/api/v1/user/schemas.py index b97f6ac..2109f8e 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", "avatar", "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..1344e8f 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, CompetitionTask from apps.user.models import User -from apps.task.models import CompetitionTaskSubmission, ReviewStatusChoices router = Router(tags=["user"]) @@ -91,27 +92,26 @@ 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: - is_correct = attempt.result.get("correct", None) - if is_correct is None: - is_correct = attempt.result.get("total_points", 0) > 0 + if attempt.task.type == CompetitionTask.CompetitionTaskType.REVIEW: + is_correct = attempt.earned_points > 0 + else: + is_correct = attempt.result.get("correct", None) if is_correct: 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..709bfbd Binary files /dev/null and b/services/backend/apps/achievement/icons/first_steps.png differ diff --git a/services/backend/apps/achievement/icons/start_competition.png b/services/backend/apps/achievement/icons/start_competition.png new file mode 100644 index 0000000..d8a3519 Binary files /dev/null and b/services/backend/apps/achievement/icons/start_competition.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..879f9a4 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..bb9f2fb --- /dev/null +++ b/services/backend/apps/achievement/management/commands/init_achievments.py @@ -0,0 +1,42 @@ +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, + ) + + if not Achievement.objects.filter(slug="start_competition").exists(): + with open(f"{icons_dir}/start_competition.png", "rb") as f: + start_competition = File(f, name="start_competition.png") + Achievement.objects.get_or_create( + name="Да начнётся битва!", + description="Начните соревнование", + slug="start_competition", + icon=start_competition, + ) diff --git a/services/backend/apps/achievement/migrations/0001_initial.py b/services/backend/apps/achievement/migrations/0001_initial.py index b20fb21..89ba1fc 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 12:09 +# Generated by Django 5.1.6 on 2025-03-03 09:46 import apps.achievement.models +import django.db.models.deletion import uuid from django.db import migrations, models @@ -19,11 +20,23 @@ class Migration(migrations.Migration): ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('name', models.CharField(max_length=30, unique=True, verbose_name='название')), ('description', models.TextField(verbose_name='описание')), - ('icon', models.FileField(upload_to=apps.achievement.models.Achievement.image_url_upload_to, verbose_name='иконка достижения')), + ('icon', models.ImageField(upload_to=apps.achievement.models.Achievement.image_url_upload_to, verbose_name='иконка достижения')), + ('slug', models.SlugField(unique=True, verbose_name='слаг')), ], options={ 'verbose_name': 'ачивка', '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_achievement_need_count_achievement_type.py b/services/backend/apps/achievement/migrations/0002_achievement_need_count_achievement_type.py deleted file mode 100644 index e16f3b6..0000000 --- a/services/backend/apps/achievement/migrations/0002_achievement_need_count_achievement_type.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-02 12:56 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('achievement', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='achievement', - name='need_count', - field=models.IntegerField(default=5, help_text='Здесь нужно указать количество действий, необходимое для получения ачивок. Например, если вы указали в предыдущем пункте "Выполненные задания" а тут 5, то ачивка будет выдаваться за 5 решенных заданий', verbose_name='кол-во того, что нужно для получения ачивки'), - ), - migrations.AddField( - model_name='achievement', - name='type', - field=models.CharField(choices=[('correct_tasks', 'Выполненные задания')], default='correct_tasks', help_text='За какой тип достижений будет выдаваться ачивка', max_length=20, verbose_name='тип'), - ), - ] 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..23da213 --- /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 09:46 + +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/migrations/0003_remove_achievement_need_count_and_more_squashed_0004_alter_achievement_slug.py b/services/backend/apps/achievement/migrations/0003_remove_achievement_need_count_and_more_squashed_0004_alter_achievement_slug.py deleted file mode 100644 index 682a718..0000000 --- a/services/backend/apps/achievement/migrations/0003_remove_achievement_need_count_and_more_squashed_0004_alter_achievement_slug.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-02 13:36 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - replaces = [('achievement', '0003_remove_achievement_need_count_and_more'), ('achievement', '0004_alter_achievement_slug')] - - dependencies = [ - ('achievement', '0002_achievement_need_count_achievement_type'), - ] - - operations = [ - migrations.RemoveField( - model_name='achievement', - name='need_count', - ), - migrations.RemoveField( - model_name='achievement', - name='type', - ), - migrations.AddField( - model_name='achievement', - name='slug', - field=models.SlugField(unique=True, verbose_name='слаг'), - ), - ] diff --git a/services/backend/apps/achievement/migrations/0005_alter_achievement_icon.py b/services/backend/apps/achievement/migrations/0005_alter_achievement_icon.py deleted file mode 100644 index 7ed0851..0000000 --- a/services/backend/apps/achievement/migrations/0005_alter_achievement_icon.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-02 14:03 - -import apps.achievement.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('achievement', '0003_remove_achievement_need_count_and_more_squashed_0004_alter_achievement_slug'), - ] - - operations = [ - migrations.AlterField( - model_name='achievement', - name='icon', - field=models.ImageField(upload_to=apps.achievement.models.Achievement.image_url_upload_to, verbose_name='иконка достижения'), - ), - ] 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/apps.py b/services/backend/apps/competition/apps.py index d343cd0..f10ee38 100644 --- a/services/backend/apps/competition/apps.py +++ b/services/backend/apps/competition/apps.py @@ -5,3 +5,6 @@ class CompetitionsConfig(AppConfig): name = "apps.competition" label = "competition" verbose_name = "Соревнование" + + def ready(self): + import apps.competition.signals diff --git a/services/backend/apps/competition/migrations/0001_initial.py b/services/backend/apps/competition/migrations/0001_initial.py index 1ada8da..b11390f 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 10:28 +# Generated by Django 5.1.6 on 2025-03-03 09:46 import apps.competition.models import datetime diff --git a/services/backend/apps/competition/signals.py b/services/backend/apps/competition/signals.py new file mode 100644 index 0000000..9a832bd --- /dev/null +++ b/services/backend/apps/competition/signals.py @@ -0,0 +1,17 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from apps.achievement.models import Achievement, UserAchievement +from apps.competition.models import State +from apps.user.models import User + + +@receiver(post_save, sender=State) +def assign_start_competition_achievement(sender, instance, created, **kwargs): + if created: + if State.objects.filter(user=instance.user, state=State.StateChoices.STARTED.value).count() == 1 \ + and not State.objects.filter(user=instance.user, state=State.StateChoices.FINISHED.value).exists(): + start_competition_achievement = Achievement.objects.get(slug="start_competition") + UserAchievement.objects.create( + user=instance.user, achievement=start_competition_achievement + ) diff --git a/services/backend/apps/core/contents/presentation.pptx b/services/backend/apps/core/contents/presentation.pptx new file mode 100644 index 0000000..566b2db Binary files /dev/null and b/services/backend/apps/core/contents/presentation.pptx differ diff --git a/services/backend/apps/core/management/commands/generate_data.py b/services/backend/apps/core/management/commands/generate_data.py index 4d2a916..855b4d3 100644 --- a/services/backend/apps/core/management/commands/generate_data.py +++ b/services/backend/apps/core/management/commands/generate_data.py @@ -6,12 +6,18 @@ from django.contrib.auth.hashers import make_password from django.core.files.base import ContentFile from django.core.management.base import BaseCommand from django.utils import timezone +from faker import Faker 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 +faker = Faker("ru_RU") class Command(BaseCommand): help = "Generate sample data for Users, Competitions, Tasks, Submissions, and States." @@ -40,11 +46,10 @@ class Command(BaseCommand): def create_users(self, count): users = [] for i in range(1, count + 1): - email = f"user{i}@example.com" - username = f"user{i}" - password = ( - "password123" # In production, use proper password handling. - ) + fake_profile = faker.profile() + email = fake_profile["email"] + username = fake_profile["username"] + password = faker.password() role = random.choice( [UserRole.STUDENT.value, UserRole.METODIST.value] ) @@ -64,7 +69,7 @@ class Command(BaseCommand): competitions = [] now = timezone.now() for i in range(1, count + 1): - title = f"Competition {i}" + title = faker.sentence() description = f"Description for competition {i}" start_date = now - timedelta(days=random.randint(1, 10)) end_date = now + timedelta(days=random.randint(1, 10)) @@ -92,7 +97,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 +115,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, @@ -132,28 +140,29 @@ class Command(BaseCommand): def create_submissions(self, tasks, users): for task in tasks: - # Each task will get between 1 and 3 submissions - num_submissions = random.randint(1, 3) - for _ in range(num_submissions): - user = random.choice(users) - # Create a dummy content file - dummy_content = ContentFile( - b"Submission content", - name=f"submission_{uuid.uuid4().hex}.txt", - ) - submission = CompetitionTaskSubmission.objects.create( - user=user, - task=task, - earned_points=random.randint( - 0, task.points if task.points else 10 - ), - content=dummy_content, - ) - submission.save() - submission.send_on_review() - self.stdout.write( - f"Created submission for task '{task.title}' by user '{user.username}'" - ) + if task.type == CompetitionTask.CompetitionTaskType.REVIEW.value: + # Each task will get between 1 and 3 submissions + num_submissions = random.randint(1, 3) + for _ in range(num_submissions): + user = random.choice(users) + # Create a dummy content file + dummy_content = ContentFile( + b"Submission content", + name=f"submission_{uuid.uuid4().hex}.txt", + ) + submission = CompetitionTaskSubmission.objects.create( + user=user, + task=task, + earned_points=random.randint( + 0, task.points if task.points else 10 + ), + content=dummy_content, + ) + submission.save() + submission.send_on_review() + self.stdout.write( + f"Created submission for task '{task.title}' by user '{user.username}'" + ) def create_states(self, competitions, users): # For each competition, create a State for some of its participants diff --git a/services/backend/apps/core/management/commands/generate_pretty_data.py b/services/backend/apps/core/management/commands/generate_pretty_data.py new file mode 100644 index 0000000..2a9da0a --- /dev/null +++ b/services/backend/apps/core/management/commands/generate_pretty_data.py @@ -0,0 +1,386 @@ +import random +import uuid +from datetime import timedelta, datetime + +from django.conf import settings +from django.contrib.auth.hashers import make_password +from django.core.files.base import ContentFile, File +from django.core.management.base import BaseCommand +from django.utils import timezone + +from apps.competition.models import Competition, State +from apps.review.models import Reviewer +from apps.task.models import ( + CompetitionTask, + CompetitionTaskCriteria, + CompetitionTaskSubmission, +) +from apps.user.models import User, UserRole + +ans1 = ContentFile( + b"1984", + name=f"submission_{uuid.uuid4().hex}.txt", +) +ans2 = ContentFile( + b"3", + name=f"submission_{uuid.uuid4().hex}.txt", +) + +now = timezone.now() +competitions = [ + { + "obj": None, # докидывает в процессе + "title": "DANO. Финал", + "description": "Олимпиада по анализу данных от Т-Банка и ВШЭ", + "start_date": now - timedelta(days=2), + "end_date": now + timedelta(days=5), + "type": "competitive", + "participation_type": "solo", + "tasks": [ + { + "obj": None, + "title": "Задача 1", + "description": """На маркетплейсе «Е-шопинг» продаются различные товары. Одна из задач аналитика — +прогнозировать, сколько товаров будет продаваться при определенной цене. В ходе +исследований и экспериментов был выявлен вид зависимости: +$Q(P) = Q_0 \times e^{E \times \frac{P_0 - P}{P_0}}$ +где Q — это количество проданных единиц товара при цене P, +Q 0 — количество проданных единиц товара при цене P0 , +E — коэффициент чувствительности количества проданных единиц товара к изменению +цены. + +Найдите, сколько заработает продавец при цене по 3 000 ₽ за нож и сковороду +при условии, что себестоимость ножа — 1 000 ₽, а сковородки — 2 000 ₽.Ответ +округлите до целых.""".strip(), + "type": CompetitionTask.CompetitionTaskType.INPUT.value, + "points": 3, + "submission_reviewers_count": 2, + "max_attempts": 20, + "correct_answer_file": ans1 + }, + { + "obj": None, + "title": "Задача 2", + "description": """ +Напишите "hello_dano" на питоне +""".strip(), + "type": CompetitionTask.CompetitionTaskType.CHECKER.value, + "points": 25, + "submission_reviewers_count": 2, + "max_attempts": 50, + }, + { + "obj": None, + "title": "Задача 3", + "description": """Небольшой интернет-магазин собрал данные о действиях пользователей на своем сайте +за последние несколько месяцев. +ecommerce_logs.csv — журнал действий пользователей: +• user_id — идентификатор пользователя. +• action — тип действия пользователя: +— visit — посещение сайта; +— click — клик на карточку товара; +— cart — добавление товара в корзину; +— delete — удаление товара из корзины; +— purchase — покупка товаров. +• date_time — время совершения действия. +• product_id — идентификатор товара. +• quantity — количество добавленного в корзину товара. +• delivery_price — стоимость доставки. +• sex — пол пользователя. +• region — регион пользователя. +• price — цена товара. + +Вам нужно изучить воронку конверсии, которая показывает, как пользователи переходят +от одного шага к другому на сайте. В нашем случае воронка состоит из следующих шагов: +1. Посещение сайта. +2. Просмотр карточки товара. +3. Добавление товара в корзину. +4. Покупка. + +1. Посещение сайта. +2. Просмотр карточки товара. +3. Добавление товара в корзину. +4. Покупка. +3 / 11 +1.) Посчитайте конверсию (округлите ответ до 3 знаков после запятой): +• Из визита на сайт в клик на карточку товара. +• Из клика в добавление в корзину. +• Из добавления в корзину в покупку. +• Из визита на сайт в добавление в корзину. +• Из визита на сайт в покупку. +2. Постройте воронку конверсии с помощью столбчатой диаграммы: +• По оси X — шаги воронки. +• По оси Y — количество уникальных пользователей на каждом шаге. +3. Определите, на каком этапе конверсия из предыдущего шага ниже всего. +Сформулируйте одну гипотезу, связанную с поведением пользователей, которая +может объяснить падение конверсии именно на этом этапе. Обоснуйте механизм +работы приведенной гипотезы. +4. Постройте график динамики (по оси X — дни) для каждой из конверсий: +• Конверсия из визита в клик. +• Конверсия из визита в добавление в корзину. +• Конверсия из визита в покупку. +5. На графике найдите просадку конверсии: укажите, какая конверсия просела +и в какой примерно период это произошло (допустимая погрешность — 1–3 +дня). +6. Чем вызвано снижение конверсии в этот период? Какие изменения в бизнесе +или поведении пользователей могли бы объяснить это? Ответьте на оба +вопроса, опираясь на данные. +""".strip(), + "type": CompetitionTask.CompetitionTaskType.REVIEW.value, + "points": 10, + "submission_reviewers_count": 2, + "max_attempts": 1, + "criteries": [ + { + "obj": None, + "name": "Обоснованность решения", + "slug": "validity", + "description": "Аргументация", + "max_value": 5 + }, + { + "obj": None, + "name": "Правильность", + "slug": "correctness", + "description": "Насколько точные и верные ответы были представлены.", + "max_value": 5 + } + ] + }, + ] + }, + { + "obj": None, + "title": "PRODANO. Тур 5", + "description": "Олимпиада по олимпиаде DANO", + "start_date": now - timedelta(days=10), + "end_date": now + timedelta(days=50), + "type": "edu", + "participation_type": "solo", + "tasks": [ + { + "obj": None, + "title": "Задача 1", + "description": """Сколько этапов в DANO?""".strip(), + "type": CompetitionTask.CompetitionTaskType.INPUT.value, + "points": 3, + "submission_reviewers_count": 2, + "max_attempts": 20, + "correct_answer_file": ans2 + }, + { + "obj": None, + "title": "Задача 2", + "description": """ +Напишите отзыв про DANO(Хороший) +""".strip(), + "type": CompetitionTask.CompetitionTaskType.REVIEW.value, + "points": 15, + "submission_reviewers_count": 2, + "max_attempts": 1, + "criteries": [ + { + "obj": None, + "name": "Хорошесть отзыва", + "slug": "validity", + "description": "Хорошесть", + "max_value": 10 + }, + { + "obj": None, + "name": "Подробность", + "slug": "detail", + "description": "Насколько подробно расписан ответ.", + "max_value": 5 + } + ] + }, + { + "obj": None, + "title": "Задача 3", + "description": """ +Напишите выведите 1+3 на питоне +""".strip(), + "type": CompetitionTask.CompetitionTaskType.CHECKER.value, + "points": 30, + "submission_reviewers_count": 2, + "max_attempts": 100, + } + ] + } +] + +users = [ + { + "email": "germanivanov1984@gmail.com", + "username": "germanivanov", + "password": "password123!", + "role": UserRole.STUDENT.value, + }, + { + "email": "dreamonovich@gmail.com", + "username": "dreamonovich", + "password": "password123!", + "role": UserRole.STUDENT.value, + } +] + +reviewers = [ + { + "name": "Владислав", + "surname": "Пикиневич", + "token": "aa443163-9861-4b8d-b8f7-81ecd25f6088" + }, + { + "name": "Александр", + "surname": "Шахов", + "token": "d2e8904a-01dd-4f84-a8b0-8a60f1a3b6c0" + } +] + +class Command(BaseCommand): + help = "Generate sample data for Users, Competitions, Tasks, Submissions, and States." + + def handle(self, *args, **options): + self.stdout.write("Starting data generation...") + users = self.create_users(5) + competitions = self.create_competitions(2, users) + self.reviewers = self.create_reviewers(2) + tasks = self.create_tasks() + self.create_incorrect_submissions(tasks, users) + self.create_states(competitions, users) + self.stdout.write("Data generation completed.") + + def create_reviewers(self, count): + reviewers_objs = [] + for reviewer in reviewers: + name = reviewer['name'] + surname = reviewer['surname'] + token = reviewer['token'] + reviewer_obj = Reviewer(name=name, surname=surname, token=token) + reviewer_obj.save() + reviewers_objs.append(reviewer_obj) + return reviewers_objs + + def create_users(self, count): + users_objs = [] + for user in users: + user_obj, created = User.objects.get_or_create( + email=user['email'], + defaults={ + "username": user['username'], + "password": make_password(user['password']), + "status": user['role'], + }, + ) + users_objs.append(user_obj) + self.stdout.write(f"Created user: {user['username']}") + return users_objs + + def create_competitions(self, count, users): + competitions_objs = [] + + for i, competition in enumerate(competitions): + competition_obj = Competition.objects.create( + title=competition['title'], + description=competition['description'], + start_date=competition['start_date'], + end_date=competition['end_date'], + type=competition['type'], + participation_type=competition['participation_type'], + ) + + competitions[i]['obj'] = competition_obj + competition_obj.participants.add(*users) + competitions_objs.append(competition_obj) + self.stdout.write(f"Created competition: {competition['title']}") + return competitions_objs + + def create_tasks(self): + tasks_objs = [] + task_types = [ + CompetitionTask.CompetitionTaskType.INPUT.value, + CompetitionTask.CompetitionTaskType.REVIEW.value, + CompetitionTask.CompetitionTaskType.INPUT.value, + ] + for i, competition in enumerate(competitions): + for j, task in enumerate(competition['tasks']): + task_obj = CompetitionTask.objects.create( + in_competition_position=j, + competition=competition['obj'], + title=task['title'], + description=task['description'], + type=task['type'], + points=task['points'], + submission_reviewers_count=task['submission_reviewers_count'], + max_attempts=task['max_attempts'], + ) + competitions[i]['tasks'][j]['obj'] = task_obj + + if task['type'] == CompetitionTask.CompetitionTaskType.INPUT.value: + task_obj.correct_answer_file = task['correct_answer_file'] + + if ( + task['type'] + == CompetitionTask.CompetitionTaskType.REVIEW.value + ): + for k, criteria in enumerate(task['criteries']): + criteria_obj = CompetitionTaskCriteria.objects.create( + task=task_obj, + name=criteria['name'], + slug=criteria['slug'], + description=criteria['description'], + max_value=criteria['max_value'], + ) + competitions[i]['tasks'][j]['criteries'][k]['obj'] = criteria_obj + self.stdout.write(f"Created criteria: {criteria['slug']}") + tasks_objs.append(task_obj) + self.stdout.write(f"Created task: {task['title']} (type: {task['type']})") + self.add_reviewers_to_task(tasks_objs) + return tasks_objs + + def add_reviewers_to_task(self, tasks): + for task in tasks: + task.reviewers.set(self.reviewers) + task.save() + + def create_incorrect_submissions(self, tasks, users): + for user in users: + for task in tasks: + if task.type == CompetitionTask.CompetitionTaskType.REVIEW.value: + num_submissions = random.randint(1, 3) + for m in range(num_submissions): + dummy_content_txt = ContentFile( + b"otvet: 112 sto proc" , + name=f"submission_{uuid.uuid4().hex}.txt", + ) + + content_dir = f"{settings.BASE_DIR}/apps/core/contents" + with open(f"{content_dir}/presentation.pptx", "rb") as f: + pptx = File(f, name="presentation.pptx") + files = [pptx, pptx, dummy_content_txt] + submission = CompetitionTaskSubmission.objects.create( + user=user, + task=task, + content=random.choice(files), + ) + submission.save() + submission.send_on_review() + self.stdout.write( + f"Created submission for task '{task.title}' by user '{user.username}'" + ) + + def create_states(self, competitions, users): + for comp in competitions: + for user in comp.participants.all(): + state_obj, created = State.objects.get_or_create( + user=user, + competition=comp, + defaults={ + "state": "started", + "changed_at": timezone.now() - timedelta(days=random.randint(1, 30)), + }, + ) + self.stdout.write( + f"Created state '{state_obj.state}' for user '{user.username}' in competition '{comp.title}'" + ) 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 1d0ac7b..c69fdd1 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 10:28 +# Generated by Django 5.1.6 on 2025-03-03 09:46 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 2d6ee5c..d830b54 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 10:28 +# Generated by Django 5.1.6 on 2025-03-03 09:46 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 4a8ff15..ca097c0 100644 --- a/services/backend/apps/task/admin.py +++ b/services/backend/apps/task/admin.py @@ -3,6 +3,7 @@ from django.contrib import admin from apps.task.models import ( CompetitionTask, CompetitionTaskAttachment, + CompetitionTaskCriteria, CompetitionTaskSubmission, ) @@ -12,10 +13,20 @@ class CompletionAttachmentInline(admin.StackedInline): extra = 0 +class CompetitionCriteriaInline(admin.StackedInline): + model = CompetitionTaskCriteria + extra = 0 + + @admin.register(CompetitionTask) class CompetitionTaskAdmin(admin.ModelAdmin): list_display = ("title", "type", "points") filter_horizontal = ("reviewers",) + list_filter = ("type",) + inlines = ( + CompletionAttachmentInline, + CompetitionCriteriaInline, + ) @admin.register(CompetitionTaskSubmission) @@ -31,15 +42,12 @@ 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): return False - def has_delete_permission(self, request, obj=None): - return False - class CompetitionTaskInline(admin.StackedInline): model = CompetitionTask diff --git a/services/backend/apps/task/migrations/0001_initial.py b/services/backend/apps/task/migrations/0001_initial.py index c2cbaa8..75345bc 100644 --- a/services/backend/apps/task/migrations/0001_initial.py +++ b/services/backend/apps/task/migrations/0001_initial.py @@ -1,8 +1,8 @@ -# Generated by Django 5.1.6 on 2025-03-02 10:28 +# Generated by Django 5.1.6 on 2025-03-03 09:46 import apps.task.models import django.db.models.deletion -import tinymce.models +import mdeditor.fields import uuid from django.db import migrations, models @@ -22,16 +22,17 @@ 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)), + ('in_competition_position', models.PositiveSmallIntegerField(verbose_name='позиция в соревновании')), ('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)), + ('description', mdeditor.fields.MDTextField(verbose_name='описание')), + ('max_attempts', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='максимальное кол-во попыток')), ('type', models.CharField(choices=[('input', 'Ввод правильного ответа'), ('checker', 'Ввод кода'), ('review', 'Ручная')], max_length=8, verbose_name='тип проверки')), - ('correct_answer_file', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to, verbose_name='файл с правильным ответом')), - ('points', models.IntegerField(blank=True, null=True, verbose_name='баллы за задание')), - ('answer_file_path', models.TextField(blank=True, default='stdout', help_text='Путь до файла в котором ожидается результат. Пример: stdout или ./output.txt', null=True, verbose_name='куда сделать вывод программы участнику')), - ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')), - ('reviewers', models.ManyToManyField(blank=True, to='review.reviewer')), + ('correct_answer_file', models.FileField(blank=True, help_text='Имеет смысл только при автоматической (ввод ответа или кода) проверке.', null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to, verbose_name='файл с правильным ответом')), + ('points', models.IntegerField(blank=True, null=True, verbose_name='общий балл за задание')), + ('answer_file_path', models.TextField(blank=True, default='stdout', help_text='Путь до файла в котором ожидается результат. Пример: stdout или ./output.txt. Имеет смысл только при автоматическом типе проверки.', null=True, verbose_name='куда сделать вывод программы участнику')), + ('submission_reviewers_count', models.PositiveSmallIntegerField(blank=True, default=1, null=True, verbose_name='кол-во проверяющих для зачета задачи')), + ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition', verbose_name='привязанное соревнование')), + ('reviewers', models.ManyToManyField(blank=True, help_text='Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему. Имеет смысл только при ручном типе проверки.', to='review.reviewer', verbose_name='ревьюверы')), ], options={ 'verbose_name': 'задание', @@ -43,45 +44,48 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('file', models.FileField(upload_to=apps.task.models.CompetitionTaskAttachment.file_upload_at, verbose_name='файл')), - ('bind_at', models.FilePathField(verbose_name='путь сохранения')), + ('bind_at', models.CharField(max_length=255, verbose_name='путь сохранения')), ('public', models.BooleanField(default=False, verbose_name='публичный')), ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask', verbose_name='задание')), ], options={ - 'abstract': False, + 'verbose_name': 'вложение', + 'verbose_name_plural': 'вложения', }, ), migrations.CreateModel( name='CompetitionTaskCriteria', fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('name', models.TextField()), - ('slug', models.SlugField()), - ('description', models.TextField()), - ('max_value', models.PositiveSmallIntegerField()), + ('name', models.TextField(verbose_name='название')), + ('slug', models.SlugField(verbose_name='техническое название')), + ('description', models.TextField(verbose_name='описание критерии')), + ('max_value', models.PositiveSmallIntegerField(verbose_name='максимальное кол-во баллов')), ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='criteries', to='task.competitiontask')), ], options={ - 'abstract': False, + 'verbose_name': 'критерий', + 'verbose_name_plural': 'критерии', }, ), migrations.CreateModel( name='CompetitionTaskSubmission', fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('status', models.CharField(choices=[('sent', 'Sent'), ('checking', 'Checking'), ('checked', 'Checked')], default='sent', max_length=8, verbose_name='статус')), - ('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(blank=True, null=True)), - ('checked_at', models.DateTimeField(blank=True, null=True)), - ('plagiarism_checked', models.BooleanField(default=False)), - ('timestamp', models.DateTimeField(auto_now_add=True)), - ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')), + ('status', models.CharField(choices=[('sent', 'Отправлено на проверку'), ('checking', 'Проверка'), ('checked', 'Проверено')], default='sent', max_length=8, verbose_name='статус')), + ('content', models.FileField(upload_to=apps.task.models.CompetitionTaskSubmission.submission_content_upload_to, verbose_name='содержание посылки')), + ('stdout', models.FileField(blank=True, help_text='Используется только при проверке чекером', null=True, upload_to=apps.task.models.CompetitionTaskSubmission.submission_stdout_upload_to, verbose_name='вывод программы')), + ('result', models.JSONField(blank=True, default=None, null=True, verbose_name='результат проверки')), + ('earned_points', models.IntegerField(blank=True, null=True, verbose_name='баллы за задание')), + ('checked_at', models.DateTimeField(blank=True, null=True, verbose_name='дата проверки')), + ('plagiarism_checked', models.BooleanField(default=False, verbose_name='проверено на плагиат')), + ('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='дата отправки')), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask', verbose_name='задание')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user', verbose_name='пользователь')), ], options={ - 'abstract': False, + 'verbose_name': 'посылка', + 'verbose_name_plural': 'посылки', }, ), ] diff --git a/services/backend/apps/task/migrations/0002_alter_competitiontasksubmission_options_and_more.py b/services/backend/apps/task/migrations/0002_alter_competitiontasksubmission_options_and_more.py deleted file mode 100644 index 9cc1672..0000000 --- a/services/backend/apps/task/migrations/0002_alter_competitiontasksubmission_options_and_more.py +++ /dev/null @@ -1,71 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-02 12:09 - -import apps.task.models -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('review', '0002_initial'), - ('task', '0001_initial'), - ('user', '0001_initial'), - ] - - operations = [ - migrations.AlterModelOptions( - name='competitiontasksubmission', - options={'verbose_name': 'посылка', 'verbose_name_plural': 'посылки'}, - ), - migrations.AlterField( - model_name='competitiontask', - name='reviewers', - field=models.ManyToManyField(blank=True, help_text='Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему', to='review.reviewer', verbose_name='ревьюверы'), - ), - migrations.AlterField( - model_name='competitiontasksubmission', - name='checked_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='дата проверки'), - ), - migrations.AlterField( - model_name='competitiontasksubmission', - name='content', - field=models.FileField(upload_to=apps.task.models.CompetitionTaskSubmission.submission_content_upload_to, verbose_name='содержание посылки'), - ), - migrations.AlterField( - model_name='competitiontasksubmission', - name='earned_points', - field=models.IntegerField(blank=True, null=True, verbose_name='баллы за задание'), - ), - migrations.AlterField( - model_name='competitiontasksubmission', - name='plagiarism_checked', - field=models.BooleanField(default=False, verbose_name='проверено на плагиат'), - ), - migrations.AlterField( - model_name='competitiontasksubmission', - name='result', - field=models.JSONField(blank=True, default=None, null=True, verbose_name='результат проверки'), - ), - migrations.AlterField( - model_name='competitiontasksubmission', - name='stdout', - field=models.FileField(blank=True, help_text='Используется только при проверке чекером', null=True, upload_to=apps.task.models.CompetitionTaskSubmission.submission_stdout_upload_to, verbose_name='вывод программы'), - ), - migrations.AlterField( - model_name='competitiontasksubmission', - name='task', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask', verbose_name='задание'), - ), - migrations.AlterField( - model_name='competitiontasksubmission', - name='timestamp', - field=models.DateTimeField(auto_now_add=True, verbose_name='дата отправки'), - ), - migrations.AlterField( - model_name='competitiontasksubmission', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user', verbose_name='пользователь'), - ), - ] diff --git a/services/backend/apps/task/migrations/0003_alter_competitiontask_description.py b/services/backend/apps/task/migrations/0003_alter_competitiontask_description.py deleted file mode 100644 index 2dfa914..0000000 --- a/services/backend/apps/task/migrations/0003_alter_competitiontask_description.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-02 12:23 - -import tinymce.models -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('task', '0002_alter_competitiontasksubmission_options_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='competitiontask', - name='description', - field=tinymce.models.HTMLField(verbose_name='описание'), - ), - ] diff --git a/services/backend/apps/task/migrations/0004_competitiontask_submission_reviewers_count_and_more.py b/services/backend/apps/task/migrations/0004_competitiontask_submission_reviewers_count_and_more.py deleted file mode 100644 index 400255c..0000000 --- a/services/backend/apps/task/migrations/0004_competitiontask_submission_reviewers_count_and_more.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-02 12:49 - -import martor.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('task', '0003_alter_competitiontask_description'), - ] - - operations = [ - migrations.AddField( - model_name='competitiontask', - name='submission_reviewers_count', - field=models.PositiveSmallIntegerField(blank=True, default=1, null=True), - ), - migrations.AlterField( - model_name='competitiontask', - name='description', - field=martor.models.MartorField(verbose_name='описание'), - ), - ] diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index c0a4ea5..8f6936e 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -2,7 +2,7 @@ from uuid import uuid4 from django.db import models from django.db.models import Count, Q -from martor.models import MartorField +from mdeditor.fields import MDTextField from apps.competition.models import Competition from apps.core.models import BaseModel @@ -19,11 +19,19 @@ class CompetitionTask(BaseModel): def answer_file_upload_to(instance, filename) -> str: return f"tasks/{instance.id}/answer/{uuid4()}/{filename}" - in_competition_position = models.PositiveSmallIntegerField() - competition = models.ForeignKey(Competition, on_delete=models.CASCADE) + in_competition_position = models.PositiveSmallIntegerField( + verbose_name="позиция в соревновании" + ) + competition = models.ForeignKey( + Competition, + on_delete=models.CASCADE, + verbose_name="привязанное соревнование", + ) title = models.CharField(verbose_name="заголовок", max_length=50) - description = MartorField(verbose_name="описание") - max_attempts = models.PositiveSmallIntegerField(null=True, blank=True) + description = MDTextField(verbose_name="описание") + max_attempts = models.PositiveSmallIntegerField( + null=True, blank=True, verbose_name="максимальное кол-во попыток" + ) type = models.CharField( choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки" ) @@ -34,9 +42,10 @@ class CompetitionTask(BaseModel): null=True, blank=True, verbose_name="файл с правильным ответом", + help_text="Имеет смысл только при автоматической (ввод ответа или кода) проверке.", ) points = models.IntegerField( - null=True, blank=True, verbose_name="баллы за задание" + null=True, blank=True, verbose_name="общий балл за задание" ) # only when "checker" type @@ -44,7 +53,10 @@ class CompetitionTask(BaseModel): null=True, blank=True, verbose_name="куда сделать вывод программы участнику", - help_text="Путь до файла в котором ожидается результат. Пример: stdout или ./output.txt", + help_text=( + "Путь до файла в котором ожидается результат. " + "Пример: stdout или ./output.txt. Имеет смысл только при автоматическом типе проверки." + ), default="stdout", ) @@ -53,10 +65,17 @@ class CompetitionTask(BaseModel): Reviewer, blank=True, verbose_name="ревьюверы", - help_text="Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему", + help_text=( + "Справа отображаются действующие проверяющие, слева - доступные для выбора. " + "Для перемещения можно кликнуть 2 раза по проверяющему. Имеет смысл только" + " при ручном типе проверки." + ), ) submission_reviewers_count = models.PositiveSmallIntegerField( - default=1, null=True, blank=True + default=1, + null=True, + blank=True, + verbose_name="кол-во проверяющих для зачета задачи", ) def __str__(self): @@ -72,23 +91,36 @@ class CompetitionTaskCriteria(BaseModel): CompetitionTask, on_delete=models.CASCADE, related_name="criteries" ) - name = models.TextField() - slug = models.SlugField() - description = models.TextField() - max_value = models.PositiveSmallIntegerField() + name = models.TextField(verbose_name="название") + slug = models.SlugField(verbose_name="техническое название") + description = models.TextField(verbose_name="описание критерии") + max_value = models.PositiveSmallIntegerField( + verbose_name="максимальное кол-во баллов" + ) + + def __str__(self): + return self.name + + class Meta: + verbose_name = "критерий" + verbose_name_plural = "критерии" class CompetitionTaskAttachment(BaseModel): - def file_upload_at(instance, filename): - return f"attachment/{instance.id}/file/{filename}" + def file_upload_at(instance, filename) -> str: + return f"attachments/{instance.id}/file/{filename}" task = models.ForeignKey( CompetitionTask, on_delete=models.CASCADE, verbose_name="задание" ) file = models.FileField(upload_to=file_upload_at, verbose_name="файл") - bind_at = models.FilePathField(verbose_name="путь сохранения") + bind_at = models.CharField(verbose_name="путь сохранения", max_length=255) public = models.BooleanField(default=False, verbose_name="публичный") + class Meta: + verbose_name = "вложение" + verbose_name_plural = "вложения" + class CompetitionTaskSubmission(BaseModel): class StatusChoices(models.TextChoices): diff --git a/services/backend/apps/task/tasks.py b/services/backend/apps/task/tasks.py index 2f3d50c..86588f4 100644 --- a/services/backend/apps/task/tasks.py +++ b/services/backend/apps/task/tasks.py @@ -1,29 +1,38 @@ +import hashlib + import httpx from celery import shared_task +from django.conf import settings from django.core.files.base import ContentFile -from django.conf import settings +from apps.task.models import CompetitionTaskSubmission @shared_task(bind=True, max_retries=3) def analyze_data_task(self, submission_id): - from .models import CompetitionTaskSubmission - submission = CompetitionTaskSubmission.objects.get(id=submission_id) try: - code = submission.content.read().decode() + code_url = f"{settings.MINIO_DEFAULT_CUSTOM_ENDPOINT_URL}{submission.content.path}" files = [ - (f.name, f.file.open("rb")) - for f in submission.task.attachments.filter(public=True) + { + "url": f"{settings.MINIO_DEFAULT_CUSTOM_ENDPOINT_URL}{attachment.path}", + "bind_path": attachment.bind_at, + } + for attachment in submission.task.attachments.filter( + bind_path__isnull=False + ) ] response = httpx.post( f"{settings.CHECKER_API_ENDPOINT}/execute", - files=[("files", (f.name, f)) for f in files] - + [ - ("code", code), - ("expected_hash", submission.task.correct_answer_hash), - ], + json={ + "files": files, + "code_url": code_url, + "answer_file_path": submission.task.answer_file_path, + "expected_hash": hashlib.sha256( + submission.task.correct_answer_file.read().encode() + ).hexdigest(), + }, timeout=30, ) response.raise_for_status() @@ -40,7 +49,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 7e4fca4..02c49b1 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 10:28 +# Generated by Django 5.1.6 on 2025-03-03 09:46 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..21a3488 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): + import apps.user.signals diff --git a/services/backend/apps/user/migrations/0001_initial.py b/services/backend/apps/user/migrations/0001_initial.py index fe09ceb..6b7f152 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 10:28 +# Generated by Django 5.1.6 on 2025-03-03 09:46 import uuid from django.db import migrations, models @@ -9,6 +9,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('achievement', '0001_initial'), ] operations = [ @@ -16,11 +17,13 @@ class Migration(migrations.Migration): name='User', fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('avatar', models.ImageField(blank=True, null=True, upload_to='', verbose_name='аватар')), ('email', models.EmailField(max_length=254, unique=True, verbose_name='почта')), ('username', models.SlugField(unique=True, verbose_name='юзернейм')), ('password', models.TextField(verbose_name='пароль')), - ('created_at', models.DateTimeField(auto_now=True)), - ('status', models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10)), + ('created_at', models.DateTimeField(auto_now=True, verbose_name='дата создания')), + ('status', models.CharField(choices=[('student', 'Участник соревнований'), ('metodist', 'Методист (составитель заданий)')], default='student', max_length=10, verbose_name='роль участника')), + ('achievements', models.ManyToManyField(blank=True, to='achievement.achievement', verbose_name='ачивки пользователя')), ], options={ 'verbose_name': 'пользователь', diff --git a/services/backend/apps/user/migrations/0002_user_achievements.py b/services/backend/apps/user/migrations/0002_user_achievements.py deleted file mode 100644 index 33adefa..0000000 --- a/services/backend/apps/user/migrations/0002_user_achievements.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-02 12:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('achievement', '0001_initial'), - ('user', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='achievements', - field=models.ManyToManyField(blank=True, to='achievement.achievement', verbose_name='ачивки пользователя'), - ), - ] diff --git a/services/backend/apps/user/models.py b/services/backend/apps/user/models.py index aaa0ec0..6a7a0c5 100644 --- a/services/backend/apps/user/models.py +++ b/services/backend/apps/user/models.py @@ -5,17 +5,20 @@ from apps.achievement.models import Achievement from apps.core.models import BaseModel -class UserRole(models.Choices): - STUDENT = "student" - METODIST = "metodist" +class UserRole(models.TextChoices): + STUDENT = "student", "Участник соревнований" + METODIST = "metodist", "Методист (составитель заданий)" class User(BaseModel): + avatar = models.ImageField(verbose_name="аватар", null=True, blank=True) email = models.EmailField(unique=True, verbose_name="почта") username = models.SlugField(unique=True, verbose_name="юзернейм") password = models.TextField(verbose_name="пароль") - created_at = models.DateTimeField(auto_now=True) + created_at = models.DateTimeField( + auto_now=True, verbose_name="дата создания" + ) achievements = models.ManyToManyField( Achievement, blank=True, verbose_name="ачивки пользователя" @@ -29,7 +32,10 @@ class User(BaseModel): return check_password(self.password, password) status = models.CharField( - max_length=10, choices=UserRole, default="student" + max_length=10, + choices=UserRole.choices, + default="student", + verbose_name="роль участника", ) def __str__(self) -> str: 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..cb0d18e 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 @@ -270,6 +271,8 @@ DEFAULT_CHARSET = "utf-8" FORCE_SCRIPT_NAME = None +X_FRAME_OPTIONS = "SAMEORIGIN" + INTERNAL_IPS = env( "DJANGO_INTERNAL_IPS", list, @@ -437,8 +440,7 @@ INSTALLED_APPS = [ "django_guid", "ninja", "minio_storage", - "tinymce", - "martor", + "mdeditor", # Internal apps "apps.core", "apps.user", @@ -449,65 +451,6 @@ INSTALLED_APPS = [ "apps.achievement", ] -# tinymce -TINYMCE_DEFAULT_CONFIG = { - "theme": "silver", - "height": 500, - "menubar": False, - "plugins": "advlist,autolink,lists,link,image,charmap,print,preview,anchor," - "searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,paste," - "code,help,wordcount,markdown", - "toolbar": "undo redo | formatselect | " - "bold italic backcolor | alignleft aligncenter " - "alignright alignjustify | bullist numlist outdent indent | " - "removeformat | help", - "skin": "oxide-dark", - "content_css": "dark", - "textpattern_patterns": [ - {"start": "*", "end": "*", "format": "italic"}, - {"start": "**", "end": "**", "format": "bold"}, - {"start": "#", "format": "h1"}, - {"start": "##", "format": "h2"}, - {"start": "###", "format": "h3"}, - {"start": "####", "format": "h4"}, - {"start": "#####", "format": "h5"}, - {"start": "######", "format": "h6"}, - {"start": "1. ", "cmd": "InsertOrderedList"}, - {"start": "* ", "cmd": "InsertUnorderedList"}, - {"start": "- ", "cmd": "InsertUnorderedList"}, - ], -} - -# martor - -MARTOR_THEME = "bootstrap" - -MARTOR_ENABLE_CONFIGS = { - "emoji": "true", # to enable/disable emoji icons. - "imgur": "true", # to enable/disable imgur/custom uploader. - "mention": "false", # to enable/disable mention - "jquery": "true", # to include/revoke jquery (require for admin default django) - "living": "false", # to enable/disable live updates in preview - "spellcheck": "false", # to enable/disable spellcheck in form textareas - "hljs": "true", # to enable/disable hljs highlighting in preview -} - -MARTOR_TOOLBAR_BUTTONS = [ - "bold", - "italic", - "horizontal", - "heading", - "pre-code", - "blockquote", - "unordered-list", - "ordered-list", - "link", - "emoji", - "direct-mention", - "toggle-maximize", - "help", -] - # GUID DJANGO_GUID = { diff --git a/services/backend/config/urls.py b/services/backend/config/urls.py index 6fe96c2..73e9ec2 100644 --- a/services/backend/config/urls.py +++ b/services/backend/config/urls.py @@ -12,10 +12,8 @@ admin.site.index_title = "DataRush" urlpatterns = [ - # tinymce - path("tinymce/", include("tinymce.urls")), - # martor - path("martor/", include("martor.urls")), + # mdeditor + path(r"mdeditor/", include("mdeditor.urls")), # Admin urls path("admin/", admin.site.urls), # API urls diff --git a/services/backend/pyproject.toml b/services/backend/pyproject.toml index d3b2371..23fefd0 100644 --- a/services/backend/pyproject.toml +++ b/services/backend/pyproject.toml @@ -12,14 +12,13 @@ dependencies = [ "django-extensions>=3.2.3", "django-guid>=3.5.0", "django-health-check>=3.18.3", + "django-mdeditor>=0.1.20", "django-minio-storage>=0.5.7", "django-ninja>=1.3.0", "django-pagedown>=2.2.1", "django-stubs-ext>=5.1.3", - "django-tinymce>=4.1.0", "gunicorn>=23.0.0", "httpx>=0.28.1", - "martor>=1.6.45", "pillow>=11.1.0", "psycopg2-binary>=2.9.10", "pydantic>=2.10.5", 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/backend/scripts/initdb_advanced b/services/backend/scripts/initdb_advanced new file mode 100755 index 0000000..fe030f2 --- /dev/null +++ b/services/backend/scripts/initdb_advanced @@ -0,0 +1,14 @@ +#!/bin/sh + +python manage.py migrate +if [ $? -ne 0 ]; then + echo "Migration failed" + exit 1 +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 +python manage.py generate_pretty_data \ No newline at end of file diff --git a/services/checker/.env.template b/services/checker/.env.template new file mode 100644 index 0000000..9dac72a --- /dev/null +++ b/services/checker/.env.template @@ -0,0 +1,4 @@ +# Change all vars before going to production and remove all comments (!) +# Below all environment variables and default values + +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 b297cc8..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( - "IMAGE", default="gitlab.prodcontest.ru:5050/team-15/project/custom-python" + "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 bd10819..a1d05fa 100644 --- a/services/checker/main.py +++ b/services/checker/main.py @@ -1,5 +1,5 @@ from fastapi import FastAPI, HTTPException, status -from pydantic import BaseModel, Field, HttpUrl, constr +from pydantic import BaseModel, Field, HttpUrl import aiohttp import asyncio import docker @@ -22,11 +22,6 @@ app = FastAPI() docker_client = docker.from_env() logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) -docker_client.login( - username=config.REGISTRY_LOGIN, - password=config.REGISTRY_PASSWORD, - registry=config.REGISTRY_URL, -) class FileDetails(BaseModel): @@ -93,9 +88,7 @@ async def download_file( session: aiohttp.ClientSession, url: str, dest_path: str ) -> None: try: - async with session.get( - url, timeout=aiohttp.ClientTimeout(total=30) - ) as resp: + async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp: if resp.status != 200: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -276,9 +269,7 @@ async def execute_code(request: ExecutionRequest) -> ExecutionResponse: return ExecutionResponse( success=success, hash_match=( - result_hash == request.expected_hash - if request.expected_hash - else None + result_hash == request.expected_hash if request.expected_hash else None ), output=output[:5000], result_hash=result_hash, diff --git a/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx b/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx index 96f990b..d4bab74 100644 --- a/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx @@ -2,18 +2,32 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { Task } from '@/shared/types/task'; import { ArrowLeft } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; interface CompetitionHeaderProps { title: string; tasks: Task[]; competitionId: string; + setAnswer: (value: string) => void; + setSelectedFile: (file: File | null) => void; } const CompetitionHeader: React.FC = ({ title, tasks, - competitionId + competitionId, + setAnswer, + setSelectedFile }) => { + const navigate = useNavigate(); + + const handleTaskSelect = (taskId: string) => { + setAnswer(""); + setSelectedFile(null); + console.log("SETTER ERROR") + navigate(`/competition/${competitionId}/tasks/${taskId}`); + } + return (
@@ -23,7 +37,6 @@ const CompetitionHeader: React.FC = ({ className="flex items-center text-gray-600 hover:text-gray-900 transition-colors font-hse-sans text-sm" > - Обратно

@@ -35,16 +48,16 @@ const CompetitionHeader: React.FC = ({
{tasks.map((task) => ( - - {task.in_competition_position} - + ))}

diff --git a/services/frontend/src/pages/CompetitionSession/index.tsx b/services/frontend/src/pages/CompetitionSession/index.tsx index 09fc40c..eb80913 100644 --- a/services/frontend/src/pages/CompetitionSession/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/index.tsx @@ -4,6 +4,7 @@ import CompetitionHeader from "./components/CompetitionHeader"; import TaskContent from "./components/TaskContent"; import TaskSolution from "./modules/TaskSolution"; import { getCompetitionTasks, submitTaskSolution } from "@/shared/api/session"; +import { getCompetition } from "@/shared/api/competitions"; import { Loader2 } from "lucide-react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { TaskType } from "@/shared/types/task"; @@ -15,6 +16,12 @@ const CompetitionSession = () => { const competitionId = id || ""; const queryClient = useQueryClient(); + const competitionQuery = useQuery({ + queryKey: ["competition", competitionId], + queryFn: () => getCompetition(competitionId), + enabled: !!competitionId, + }); + const tasksQuery = useQuery({ queryKey: ["competitionTasks", competitionId], queryFn: () => getCompetitionTasks(competitionId), @@ -46,9 +53,12 @@ const CompetitionSession = () => { } }); + const competition = competitionQuery.data; const tasks = tasksQuery.data || []; - const isLoading = tasksQuery.isLoading; - const error = tasksQuery.error ? "Не удалось загрузить задания. Пожалуйста, попробуйте позже." : null; + const isLoading = tasksQuery.isLoading || competitionQuery.isLoading; + const error = tasksQuery.error || competitionQuery.error + ? "Не удалось загрузить данные. Пожалуйста, попробуйте позже." + : null; const currentTask = tasks.find((t) => t.id === taskId) || null; @@ -77,12 +87,16 @@ const CompetitionSession = () => { submitMutation.mutate(); }; + const competitionTitle = competition?.title || "Загрузка соревнования..."; + return (
diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/ActionButtons/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/ActionButtons/index.tsx index ca1953e..cf02e94 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/ActionButtons/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/ActionButtons/index.tsx @@ -1,14 +1,19 @@ import React from 'react'; import { Button } from "@/components/ui/button"; +import { Loader2 } from "lucide-react"; interface ActionButtonsProps { onSubmit: () => void; onHistoryClick: () => void; + isSubmitting?: boolean; + hasSubmissionsLeft?: boolean; } const ActionButtons: React.FC = ({ onSubmit, - onHistoryClick + onHistoryClick, + isSubmitting = false, + hasSubmissionsLeft = true }) => { return (
@@ -16,15 +21,31 @@ const ActionButtons: React.FC = ({ variant="ghost" className="font-hse-sans bg-white hover:bg-gray-100" onClick={onHistoryClick} + disabled={isSubmitting} > История - + + {hasSubmissionsLeft ? ( + + ) : ( +
+ Лимит посылок исчерпан +
+ )}
); }; diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx index 8f97216..a843612 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx @@ -8,6 +8,7 @@ interface FileSolutionProps { fileInputRef: React.RefObject; existingFileUrl?: string | null; onClearExistingFile?: () => void; // New prop to clear existing file URL + firstSolution: boolean } const FileSolution: React.FC = ({ @@ -15,7 +16,8 @@ const FileSolution: React.FC = ({ setSelectedFile, fileInputRef, existingFileUrl = null, - onClearExistingFile + onClearExistingFile, + firstSolution }) => { const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files && event.target.files[0]) { @@ -59,9 +61,6 @@ const FileSolution: React.FC = ({ } }; - const handleSelectNewFile = () => { - fileInputRef.current?.click(); - }; const fileName = selectedFile ? selectedFile.name @@ -69,7 +68,7 @@ const FileSolution: React.FC = ({ ? existingFileUrl.split('/').pop() || 'file' : ''; - const hasFile = !!selectedFile || !!existingFileUrl; + const hasFile = !!selectedFile || (!!existingFileUrl && !firstSolution); return ( <> @@ -87,7 +86,7 @@ const FileSolution: React.FC = ({ {fileName} -
+
{existingFileUrl && !selectedFile && ( = ({ )} - {selectedFile ? ( + {selectedFile || existingFileUrl ? ( - ) : existingFileUrl ? ( -
- - -
) : null}
diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionHistorySheet/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionHistorySheet/index.tsx index 870d511..ed88c68 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionHistorySheet/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/SolutionHistorySheet/index.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; -import { X } from "lucide-react"; +import { X, Check } from "lucide-react"; import SolutionStatus from '../SolutionStatus'; -import { Solution, TaskType } from '@/shared/types/task'; +import { Solution } from '@/shared/types/task'; interface SolutionHistorySheetProps { isOpen: boolean; @@ -18,7 +18,7 @@ const SolutionHistorySheet: React.FC = ({ onOpenChange, solutions, maxPoints, - onSolutionSelect + onSolutionSelect, }) => { return ( @@ -39,10 +39,10 @@ const SolutionHistorySheet: React.FC = ({ solutions.map((solution, index) => (
{ onSolutionSelect(solution); - onOpenChange(false); + onOpenChange(false); }} > diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx index 833e1d2..5267dbb 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { Task, TaskType, Solution } from '@/shared/types/task'; import { useQuery } from '@tanstack/react-query'; @@ -17,6 +17,7 @@ interface TaskSolutionProps { selectedFile: File | null; setSelectedFile: (file: File | null) => void; onSubmit: () => void; + isSubmitting?: boolean; } const TaskSolution: React.FC = ({ @@ -26,11 +27,14 @@ const TaskSolution: React.FC = ({ selectedFile, setSelectedFile, onSubmit, + isSubmitting = false }) => { const fileInputRef = useRef(null); const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [selectedSolutionUrl, setSelectedSolutionUrl] = useState(null); + const [displayedSolution, setDisplayedSolution] = useState(null); const { id: competitionId } = useParams<{ id: string }>(); + const prevTaskIdRef = useRef(null); const solutionsQuery = useQuery({ queryKey: ['solutionHistory', competitionId, task.id], @@ -39,44 +43,87 @@ const TaskSolution: React.FC = ({ }); const solutionHistory = solutionsQuery.data || []; + + const maxAttempts = task.max_attempts || -1; + const submissionsUsed = solutionHistory.length; + const submissionsLeft = Math.max(0, maxAttempts - submissionsUsed); + const hasSubmissionsLeft = submissionsLeft > 0; + + useEffect(() => { + if (solutionHistory.length > 0 && !displayedSolution) { + const latestSolution = solutionHistory[solutionHistory.length - 1]; + setDisplayedSolution(latestSolution); + } + }, [solutionHistory, displayedSolution]); + + useEffect(() => { + if (prevTaskIdRef.current !== task.id) { + setDisplayedSolution(null); + setSelectedSolutionUrl(null); + + if (solutionHistory.length > 0) { + const latestSolution = solutionHistory[solutionHistory.length - 1]; + setDisplayedSolution(latestSolution); + } + + prevTaskIdRef.current = task.id; + } + }, [task.id, solutionHistory]); + + useEffect(() => { + if (solutionHistory.length > 0 && + (!displayedSolution || + (solutionHistory[solutionHistory.length - 1].id !== displayedSolution.id && + displayedSolution.id === solutionHistory[solutionHistory.length - 2]?.id))) { + setDisplayedSolution(solutionHistory[solutionHistory.length - 1]); + } + }, [solutionHistory, displayedSolution]); + + useEffect(() => { + const loadSolutionContent = async () => { + if (!displayedSolution || !displayedSolution.content) return; + + try { + if (task.type === TaskType.FILE) { + setSelectedFile(null); + setSelectedSolutionUrl(displayedSolution.content); + } else { + const response = await fetch(displayedSolution.content); + if (!response.ok) { + throw new Error(`Failed to fetch solution content: ${response.status}`); + } + const text = await response.text(); + setAnswer(text); + } + } catch (error) { + console.error('Error loading solution content:', error); + } + }; + + loadSolutionContent(); + }, [displayedSolution, task.type, setAnswer, setSelectedFile]); const handleOpenHistory = () => { setIsHistoryOpen(true); }; - const latestSolution = solutionHistory && solutionHistory.length > 0 ? solutionHistory[0] : null; - - const handleSolutionSelect = async (solution: Solution) => { - if (!solution.content) return; - - try { - if (task.type === TaskType.FILE) { - // For file tasks, just store the URL - setSelectedFile(null); // Clear any selected file first - setSelectedSolutionUrl(solution.content); - } else { - // For INPUT and CODE tasks, fetch the content and set as answer - const response = await fetch(solution.content); - if (!response.ok) { - throw new Error(`Failed to fetch solution content: ${response.status}`); - } - const text = await response.text(); - setAnswer(text); - } - } catch (error) { - console.error('Error loading solution content:', error); - } + const handleSolutionSelect = (solution: Solution) => { + setDisplayedSolution(solution); }; - // Function to clear the existing file URL const handleClearExistingFile = () => { setSelectedSolutionUrl(null); }; return (
- {latestSolution ? ( - + {displayedSolution ? ( + <> +
+ Результат последней посылки: +
+ + ) : (
Решение еще не отправлено @@ -84,7 +131,10 @@ const TaskSolution: React.FC = ({ )} {task.type === TaskType.INPUT && ( - + )} {task.type === TaskType.FILE && ( @@ -94,16 +144,45 @@ const TaskSolution: React.FC = ({ fileInputRef={fileInputRef} existingFileUrl={selectedSolutionUrl} onClearExistingFile={handleClearExistingFile} + firstSolution={solutionHistory.length > 0} /> )} {task.type === TaskType.CODE && ( - + )} - + +
+ {hasSubmissionsLeft ? ( + <> + + Осталось посылок: {submissionsLeft === Infinity ? '∞' : submissionsLeft} + + {maxAttempts !== -1 && ( + + (из {maxAttempts}) + + )} + + ) : ( + + Вы использовали все посылки + + )} +
+