Merge branch 'master' of gitlab.prodcontest.ru:team-15/project

This commit is contained in:
moolcoov
2025-03-03 15:56:53 +03:00
63 changed files with 1030 additions and 511 deletions
+35 -2
View File
@@ -1,6 +1,7 @@
stages: stages:
- build - build
- deploy - deploy
- utils
variables: variables:
DOCKER_TLS_CERTDIR: /certs DOCKER_TLS_CERTDIR: /certs
@@ -106,10 +107,42 @@ deploy:
- | - |
ssh $SSH_ADDRESS <<'EOF' ssh $SSH_ADDRESS <<'EOF'
cd ~/deploy cd ~/deploy
docker system prune -a --force
docker compose pull > deploy.log 2>&1 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 up -d --remove-orphans --force-recreate >> deploy.log 2>&1
docker compose ps >> deploy.log 2>&1 docker compose ps >> deploy.log 2>&1
EOF 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 retry: 2
+13 -1
View File
@@ -370,11 +370,20 @@ services:
restart: unless-stopped restart: unless-stopped
shm_size: 4mb shm_size: 4mb
custom_python:
image: gitlab.prodcontest.ru:5050/team-15/project/custom-python:latest
entrypoint: ["sh", "-c", "exit 0"]
checker: checker:
image: gitlab.prodcontest.ru:5050/team-15/project/checker:latest image: gitlab.prodcontest.ru:5050/team-15/project/checker:latest
build: build:
context: ./services/checker context: ./services/checker
dockerfile: Dockerfile dockerfile: Dockerfile
depends_on:
custom_python:
restart: false
condition: service_completed_successfully
required: true
env_file: env_file:
- path: ./infrastructure/checker/.env.template - path: ./infrastructure/checker/.env.template
required: true required: true
@@ -391,6 +400,9 @@ services:
- type: bind - type: bind
source: /var/run/docker.sock source: /var/run/docker.sock
target: /var/run/docker.sock target: /var/run/docker.sock
- type: bind
source: /tmp
target: /tmp
proxy: proxy:
image: docker.io/nginx:1.27-alpine3.21 image: docker.io/nginx:1.27-alpine3.21
@@ -401,7 +413,7 @@ services:
test: ["CMD", "service", "nginx", "status", "||", " exit 1"] test: ["CMD", "service", "nginx", "status", "||", " exit 1"]
interval: 1m30s interval: 1m30s
timeout: 5s timeout: 5s
start_period: 5s start_period: 15s
start_interval: 2s start_interval: 2s
retries: 5 retries: 5
ports: ports:
+1 -1
View File
@@ -1,2 +1,2 @@
REGISTRY_LOGIN=devitq REGISTRY_LOGIN=devitq
REGISTRY_PASSWORD=14zQrbzDTM0WXK@CogMQikAvP74Rj4 REGISTRY_PASSWORD=prod-zAoUDyHwkgRfQPyVvskH
+2 -2
View File
@@ -48,13 +48,13 @@ domain = localhost
enforce_domain = false enforce_domain = false
# The full public facing url # 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 Grafana from subpath specified in `root_url` setting. By default it is set to `false` for compatibility reasons.
serve_from_sub_path = true serve_from_sub_path = true
# Log web requests # Log web requests
router_logging = false router_logging = true
# the path relative working path # the path relative working path
static_root_path = public static_root_path = public
+2 -2
View File
@@ -183,8 +183,8 @@ http {
client_max_body_size 100M; client_max_body_size 100M;
} }
location /admin/grafana/ { location /admin/grafana {
proxy_pass http://grafana:3000/; proxy_pass http://grafana:3000/admin/grafana;
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
+16 -2
View File
@@ -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): class AchievementSchema(ModelSchema):
@@ -12,3 +15,14 @@ class AchievementSchema(ModelSchema):
"description", "description",
"icon", "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",)
+3 -2
View File
@@ -6,15 +6,16 @@ from api.v1.achievement.schemas import AchievementSchema
from api.v1.schemas import UnauthorizedError from api.v1.schemas import UnauthorizedError
from apps.achievement.models import Achievement from apps.achievement.models import Achievement
router = Router() router = Router(tags=["achievement"])
@router.get( @router.get(
"", "all",
response={ response={
status.OK: list[AchievementSchema], status.OK: list[AchievementSchema],
status.UNAUTHORIZED: UnauthorizedError, status.UNAUTHORIZED: UnauthorizedError,
}, },
auth=None,
) )
def get_all_achievements(request): def get_all_achievements(request):
return Achievement.objects.all() return Achievement.objects.all()
@@ -38,6 +38,7 @@ class SubmissionOut(ModelSchema):
competition_name: str = Field(..., alias="task.competition.title") competition_name: str = Field(..., alias="task.competition.title")
task_position: int = Field(..., alias="task.in_competition_position") task_position: int = Field(..., alias="task.in_competition_position")
task_title: str = Field(..., alias="task.title") task_title: str = Field(..., alias="task.title")
description: str = Field(..., alias="task.description")
@staticmethod @staticmethod
def resolve_criteries(self, context) -> list[CriteriaOut] | None: def resolve_criteries(self, context) -> list[CriteriaOut] | None:
+6
View File
@@ -3,6 +3,7 @@ from functools import partial
from ninja import NinjaAPI from ninja import NinjaAPI
from api.v1 import handlers from api.v1 import handlers
from api.v1.achievement.views import router as achievement_router
from api.v1.auth import BearerAuth from api.v1.auth import BearerAuth
from api.v1.competition.views import router as competition_router from api.v1.competition.views import router as competition_router
from api.v1.ping.views import router as ping_router from api.v1.ping.views import router as ping_router
@@ -49,6 +50,11 @@ router.add_router(
team_router, team_router,
auth=BearerAuth(), auth=BearerAuth(),
) )
router.add_router(
"achievements",
achievement_router,
auth=BearerAuth(),
)
for exception, handler in handlers.exception_handlers: for exception, handler in handlers.exception_handlers:
+1
View File
@@ -33,6 +33,7 @@ class TaskOutSchema(ModelSchema):
"description", "description",
"in_competition_position", "in_competition_position",
"points", "points",
"max_attempts"
] ]
+16 -1
View File
@@ -12,6 +12,7 @@ from api.v1.task.schemas import (
TaskOutSchema, TaskOutSchema,
TaskSubmissionOut, TaskSubmissionOut,
) )
from apps.achievement.models import Achievement, UserAchievement
from apps.competition.models import State from apps.competition.models import State
from apps.task.models import ( from apps.task.models import (
Competition, Competition,
@@ -102,13 +103,27 @@ def submit_task(
CompetitionTask, competition=competition, id=task_id 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: if task.type == CompetitionTask.CompetitionTaskType.INPUT:
submission = CompetitionTaskSubmission.objects.create( submission = CompetitionTaskSubmission.objects.create(
user=user, user=user,
task=task, task=task,
status=CompetitionTaskSubmission.StatusChoices.CHECKED, status=CompetitionTaskSubmission.StatusChoices.CHECKED,
result={"correct": content == task.answer_file_path},
content=content, content=content,
result={
"correct": content.read() == task.correct_answer_file.read()
},
) )
if task.type == CompetitionTask.CompetitionTaskType.REVIEW: if task.type == CompetitionTask.CompetitionTaskType.REVIEW:
submission = CompetitionTaskSubmission.objects.create( submission = CompetitionTaskSubmission.objects.create(
+11 -1
View File
@@ -1,5 +1,7 @@
from ninja import ModelSchema, Schema from ninja import ModelSchema, Schema
from api.v1.achievement.schemas import UserAchievementSchema
from apps.achievement.models import UserAchievement
from apps.user.models import User from apps.user.models import User
@@ -20,9 +22,17 @@ class LoginSchema(ModelSchema):
class UserSchema(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: class Meta:
model = User model = User
fields = ["id", "email", "username", "created_at", "achievements"] fields = ["id", "avatar", "email", "username", "created_at"]
class StatSchema(Schema): class StatSchema(Schema):
+13 -13
View File
@@ -11,17 +11,18 @@ from api.v1.schemas import (
BadRequestError, BadRequestError,
ConflictError, ConflictError,
ForbiddenError, ForbiddenError,
NotFoundError, UnauthorizedError, NotFoundError,
UnauthorizedError,
) )
from api.v1.user.schemas import ( from api.v1.user.schemas import (
LoginSchema, LoginSchema,
RegisterSchema, RegisterSchema,
StatSchema,
TokenSchema, TokenSchema,
UserSchema, UserSchema,
StatSchema
) )
from apps.task.models import CompetitionTaskSubmission, CompetitionTask
from apps.user.models import User from apps.user.models import User
from apps.task.models import CompetitionTaskSubmission, ReviewStatusChoices
router = Router(tags=["user"]) router = Router(tags=["user"])
@@ -91,27 +92,26 @@ def get_user(request, user_id: str):
@router.get( @router.get(
"/me/stat", "/me/stat",
response={ response={status.OK: StatSchema, status.UNAUTHORIZED: UnauthorizedError},
status.OK: StatSchema,
status.UNAUTHORIZED: UnauthorizedError
},
) )
def get_my_stat(request): def get_my_stat(request):
user_submissions = CompetitionTaskSubmission.objects.filter( user_submissions = CompetitionTaskSubmission.objects.filter(
user=request.auth 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 success_attempts_cnt = 0
for attempt in checked_attempts: for attempt in checked_attempts:
is_correct = attempt.result.get("correct", None) if attempt.task.type == CompetitionTask.CompetitionTaskType.REVIEW:
if is_correct is None: is_correct = attempt.earned_points > 0
is_correct = attempt.result.get("total_points", 0) > 0 else:
is_correct = attempt.result.get("correct", None)
if is_correct: if is_correct:
success_attempts_cnt += 1 success_attempts_cnt += 1
return StatSchema( return StatSchema(
total_attempts=len(user_submissions), total_attempts=len(user_submissions), solved_tasks=success_attempts_cnt
solved_tasks=success_attempts_cnt
) )
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -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,
)
@@ -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 apps.achievement.models
import django.db.models.deletion
import uuid import uuid
from django.db import migrations, models 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)), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=30, unique=True, verbose_name='название')), ('name', models.CharField(max_length=30, unique=True, verbose_name='название')),
('description', models.TextField(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={ options={
'verbose_name': 'ачивка', 'verbose_name': 'ачивка',
'verbose_name_plural': 'ачивки', '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,
},
),
] ]
@@ -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='тип'),
),
]
@@ -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'),
),
]
@@ -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='слаг'),
),
]
@@ -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='иконка достижения'),
),
]
+7 -3
View File
@@ -4,9 +4,6 @@ from apps.core.models import BaseModel
class Achievement(BaseModel): class Achievement(BaseModel):
class AchievementType(models.TextChoices):
CORRECT_TASKS = "correct_tasks", "Выполненные задания"
def image_url_upload_to(instance, filename): def image_url_upload_to(instance, filename):
return f"achievements/{instance.id}/icon/{filename}" return f"achievements/{instance.id}/icon/{filename}"
@@ -27,3 +24,10 @@ class Achievement(BaseModel):
class Meta: class Meta:
verbose_name = "ачивка" verbose_name = "ачивка"
verbose_name_plural = "ачивки" 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)
@@ -5,3 +5,6 @@ class CompetitionsConfig(AppConfig):
name = "apps.competition" name = "apps.competition"
label = "competition" label = "competition"
verbose_name = "Соревнование" verbose_name = "Соревнование"
def ready(self):
import apps.competition.signals
@@ -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 apps.competition.models
import datetime import datetime
@@ -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
)
@@ -6,12 +6,18 @@ from django.contrib.auth.hashers import make_password
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils import timezone from django.utils import timezone
from faker import Faker
from apps.competition.models import Competition, State from apps.competition.models import Competition, State
from apps.review.models import Reviewer 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 from apps.user.models import User, UserRole
faker = Faker("ru_RU")
class Command(BaseCommand): class Command(BaseCommand):
help = "Generate sample data for Users, Competitions, Tasks, Submissions, and States." help = "Generate sample data for Users, Competitions, Tasks, Submissions, and States."
@@ -40,11 +46,10 @@ class Command(BaseCommand):
def create_users(self, count): def create_users(self, count):
users = [] users = []
for i in range(1, count + 1): for i in range(1, count + 1):
email = f"user{i}@example.com" fake_profile = faker.profile()
username = f"user{i}" email = fake_profile["email"]
password = ( username = fake_profile["username"]
"password123" # In production, use proper password handling. password = faker.password()
)
role = random.choice( role = random.choice(
[UserRole.STUDENT.value, UserRole.METODIST.value] [UserRole.STUDENT.value, UserRole.METODIST.value]
) )
@@ -64,7 +69,7 @@ class Command(BaseCommand):
competitions = [] competitions = []
now = timezone.now() now = timezone.now()
for i in range(1, count + 1): for i in range(1, count + 1):
title = f"Competition {i}" title = faker.sentence()
description = f"Description for competition {i}" description = f"Description for competition {i}"
start_date = now - timedelta(days=random.randint(1, 10)) start_date = now - timedelta(days=random.randint(1, 10))
end_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 = [ task_types = [
CompetitionTask.CompetitionTaskType.INPUT.value, CompetitionTask.CompetitionTaskType.INPUT.value,
CompetitionTask.CompetitionTaskType.REVIEW.value, CompetitionTask.CompetitionTaskType.REVIEW.value,
CompetitionTask.CompetitionTaskType.INPUT.value CompetitionTask.CompetitionTaskType.INPUT.value,
] ]
for comp in competitions: for comp in competitions:
# Create 3 tasks per competition # Create 3 tasks per competition
@@ -110,7 +115,10 @@ class Command(BaseCommand):
submission_reviewers_count=random.randint(2, 10), submission_reviewers_count=random.randint(2, 10),
max_attempts=random.randint(1, 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): for j in range(5):
CompetitionTaskCriteria.objects.create( CompetitionTaskCriteria.objects.create(
task=task, task=task,
@@ -132,28 +140,29 @@ class Command(BaseCommand):
def create_submissions(self, tasks, users): def create_submissions(self, tasks, users):
for task in tasks: for task in tasks:
# Each task will get between 1 and 3 submissions if task.type == CompetitionTask.CompetitionTaskType.REVIEW.value:
num_submissions = random.randint(1, 3) # Each task will get between 1 and 3 submissions
for _ in range(num_submissions): num_submissions = random.randint(1, 3)
user = random.choice(users) for _ in range(num_submissions):
# Create a dummy content file user = random.choice(users)
dummy_content = ContentFile( # Create a dummy content file
b"Submission content", dummy_content = ContentFile(
name=f"submission_{uuid.uuid4().hex}.txt", b"Submission content",
) name=f"submission_{uuid.uuid4().hex}.txt",
submission = CompetitionTaskSubmission.objects.create( )
user=user, submission = CompetitionTaskSubmission.objects.create(
task=task, user=user,
earned_points=random.randint( task=task,
0, task.points if task.points else 10 earned_points=random.randint(
), 0, task.points if task.points else 10
content=dummy_content, ),
) content=dummy_content,
submission.save() )
submission.send_on_review() submission.save()
self.stdout.write( submission.send_on_review()
f"Created submission for task '{task.title}' by user '{user.username}'" self.stdout.write(
) f"Created submission for task '{task.title}' by user '{user.username}'"
)
def create_states(self, competitions, users): def create_states(self, competitions, users):
# For each competition, create a State for some of its participants # For each competition, create a State for some of its participants
@@ -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}'"
)
+1 -1
View File
@@ -7,4 +7,4 @@ class CoreConfig(AppConfig):
verbose_name = "Проверка" verbose_name = "Проверка"
def ready(self): def ready(self):
import apps.review.signals pass
@@ -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 import uuid
from django.db import migrations, models from django.db import migrations, models
@@ -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 django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
+2 -3
View File
@@ -2,13 +2,12 @@
from django.db.models.signals import m2m_changed from django.db.models.signals import m2m_changed
from django.dispatch import receiver from django.dispatch import receiver
from apps.review.models import Review
from apps.task.models import CompetitionTask, CompetitionTaskSubmission from apps.task.models import CompetitionTask, CompetitionTaskSubmission
@receiver(m2m_changed, sender=CompetitionTask.reviewers.through) @receiver(m2m_changed, sender=CompetitionTask.reviewers.through)
def print_reviewers(sender, instance, action, **kwargs): 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) submissions = CompetitionTaskSubmission.objects.filter(task=instance)
for submission in submissions: for submission in submissions:
submission.send_on_review() submission.send_on_review()
+12 -4
View File
@@ -3,6 +3,7 @@ from django.contrib import admin
from apps.task.models import ( from apps.task.models import (
CompetitionTask, CompetitionTask,
CompetitionTaskAttachment, CompetitionTaskAttachment,
CompetitionTaskCriteria,
CompetitionTaskSubmission, CompetitionTaskSubmission,
) )
@@ -12,10 +13,20 @@ class CompletionAttachmentInline(admin.StackedInline):
extra = 0 extra = 0
class CompetitionCriteriaInline(admin.StackedInline):
model = CompetitionTaskCriteria
extra = 0
@admin.register(CompetitionTask) @admin.register(CompetitionTask)
class CompetitionTaskAdmin(admin.ModelAdmin): class CompetitionTaskAdmin(admin.ModelAdmin):
list_display = ("title", "type", "points") list_display = ("title", "type", "points")
filter_horizontal = ("reviewers",) filter_horizontal = ("reviewers",)
list_filter = ("type",)
inlines = (
CompletionAttachmentInline,
CompetitionCriteriaInline,
)
@admin.register(CompetitionTaskSubmission) @admin.register(CompetitionTaskSubmission)
@@ -31,15 +42,12 @@ class CompetitionTaskSubmissionAdmin(admin.ModelAdmin):
"user__username", "user__username",
"user__email", "user__email",
) )
list_filter = ("plagiarism_checked", "status",) list_filter = ("plagiarism_checked", "status")
ordering = ["-timestamp"] ordering = ["-timestamp"]
def has_add_permission(self, request, obj=None): def has_add_permission(self, request, obj=None):
return False return False
def has_delete_permission(self, request, obj=None):
return False
class CompetitionTaskInline(admin.StackedInline): class CompetitionTaskInline(admin.StackedInline):
model = CompetitionTask model = CompetitionTask
@@ -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 apps.task.models
import django.db.models.deletion import django.db.models.deletion
import tinymce.models import mdeditor.fields
import uuid import uuid
from django.db import migrations, models from django.db import migrations, models
@@ -22,16 +22,17 @@ class Migration(migrations.Migration):
name='CompetitionTask', name='CompetitionTask',
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('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='заголовок')), ('title', models.CharField(max_length=50, verbose_name='заголовок')),
('description', tinymce.models.HTMLField(max_length=300, verbose_name='описание')), ('description', mdeditor.fields.MDTextField(verbose_name='описание')),
('max_attempts', models.PositiveSmallIntegerField(blank=True, null=True)), ('max_attempts', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='максимальное кол-во попыток')),
('type', models.CharField(choices=[('input', 'Ввод правильного ответа'), ('checker', 'Ввод кода'), ('review', 'Ручная')], max_length=8, 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='файл с правильным ответом')), ('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='баллы за задание')), ('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='куда сделать вывод программы участнику')), ('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')), ('submission_reviewers_count', models.PositiveSmallIntegerField(blank=True, default=1, null=True, verbose_name='кол-во проверяющих для зачета задачи')),
('reviewers', models.ManyToManyField(blank=True, to='review.reviewer')), ('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={ options={
'verbose_name': 'задание', 'verbose_name': 'задание',
@@ -43,45 +44,48 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('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='файл')), ('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='публичный')), ('public', models.BooleanField(default=False, verbose_name='публичный')),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask', verbose_name='задание')), ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask', verbose_name='задание')),
], ],
options={ options={
'abstract': False, 'verbose_name': 'вложение',
'verbose_name_plural': 'вложения',
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='CompetitionTaskCriteria', name='CompetitionTaskCriteria',
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.TextField()), ('name', models.TextField(verbose_name='название')),
('slug', models.SlugField()), ('slug', models.SlugField(verbose_name='техническое название')),
('description', models.TextField()), ('description', models.TextField(verbose_name='описание критерии')),
('max_value', models.PositiveSmallIntegerField()), ('max_value', models.PositiveSmallIntegerField(verbose_name='максимальное кол-во баллов')),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='criteries', to='task.competitiontask')), ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='criteries', to='task.competitiontask')),
], ],
options={ options={
'abstract': False, 'verbose_name': 'критерий',
'verbose_name_plural': 'критерии',
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='CompetitionTaskSubmission', name='CompetitionTaskSubmission',
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('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='статус')), ('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)), ('content', models.FileField(upload_to=apps.task.models.CompetitionTaskSubmission.submission_content_upload_to, verbose_name='содержание посылки')),
('stdout', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTaskSubmission.submission_stdout_upload_to)), ('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)), ('result', models.JSONField(blank=True, default=None, null=True, verbose_name='результат проверки')),
('earned_points', models.IntegerField(blank=True, null=True)), ('earned_points', models.IntegerField(blank=True, null=True, verbose_name='баллы за задание')),
('checked_at', models.DateTimeField(blank=True, null=True)), ('checked_at', models.DateTimeField(blank=True, null=True, verbose_name='дата проверки')),
('plagiarism_checked', models.BooleanField(default=False)), ('plagiarism_checked', models.BooleanField(default=False, verbose_name='проверено на плагиат')),
('timestamp', models.DateTimeField(auto_now_add=True)), ('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='дата отправки')),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask')), ('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')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user', verbose_name='пользователь')),
], ],
options={ options={
'abstract': False, 'verbose_name': 'посылка',
'verbose_name_plural': 'посылки',
}, },
), ),
] ]
@@ -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='пользователь'),
),
]
@@ -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='описание'),
),
]
@@ -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='описание'),
),
]
+48 -16
View File
@@ -2,7 +2,7 @@ from uuid import uuid4
from django.db import models from django.db import models
from django.db.models import Count, Q 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.competition.models import Competition
from apps.core.models import BaseModel from apps.core.models import BaseModel
@@ -19,11 +19,19 @@ class CompetitionTask(BaseModel):
def answer_file_upload_to(instance, filename) -> str: def answer_file_upload_to(instance, filename) -> str:
return f"tasks/{instance.id}/answer/{uuid4()}/{filename}" return f"tasks/{instance.id}/answer/{uuid4()}/{filename}"
in_competition_position = models.PositiveSmallIntegerField() in_competition_position = models.PositiveSmallIntegerField(
competition = models.ForeignKey(Competition, on_delete=models.CASCADE) verbose_name="позиция в соревновании"
)
competition = models.ForeignKey(
Competition,
on_delete=models.CASCADE,
verbose_name="привязанное соревнование",
)
title = models.CharField(verbose_name="заголовок", max_length=50) title = models.CharField(verbose_name="заголовок", max_length=50)
description = MartorField(verbose_name="описание") description = MDTextField(verbose_name="описание")
max_attempts = models.PositiveSmallIntegerField(null=True, blank=True) max_attempts = models.PositiveSmallIntegerField(
null=True, blank=True, verbose_name="максимальное кол-во попыток"
)
type = models.CharField( type = models.CharField(
choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки" choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки"
) )
@@ -34,9 +42,10 @@ class CompetitionTask(BaseModel):
null=True, null=True,
blank=True, blank=True,
verbose_name="файл с правильным ответом", verbose_name="файл с правильным ответом",
help_text="Имеет смысл только при автоматической (ввод ответа или кода) проверке.",
) )
points = models.IntegerField( points = models.IntegerField(
null=True, blank=True, verbose_name="баллы за задание" null=True, blank=True, verbose_name="общий балл за задание"
) )
# only when "checker" type # only when "checker" type
@@ -44,7 +53,10 @@ class CompetitionTask(BaseModel):
null=True, null=True,
blank=True, blank=True,
verbose_name="куда сделать вывод программы участнику", verbose_name="куда сделать вывод программы участнику",
help_text="Путь до файла в котором ожидается результат. Пример: stdout или ./output.txt", help_text=(
"Путь до файла в котором ожидается результат. "
"Пример: stdout или ./output.txt. Имеет смысл только при автоматическом типе проверки."
),
default="stdout", default="stdout",
) )
@@ -53,10 +65,17 @@ class CompetitionTask(BaseModel):
Reviewer, Reviewer,
blank=True, blank=True,
verbose_name="ревьюверы", verbose_name="ревьюверы",
help_text="Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему", help_text=(
"Справа отображаются действующие проверяющие, слева - доступные для выбора. "
"Для перемещения можно кликнуть 2 раза по проверяющему. Имеет смысл только"
" при ручном типе проверки."
),
) )
submission_reviewers_count = models.PositiveSmallIntegerField( submission_reviewers_count = models.PositiveSmallIntegerField(
default=1, null=True, blank=True default=1,
null=True,
blank=True,
verbose_name="кол-во проверяющих для зачета задачи",
) )
def __str__(self): def __str__(self):
@@ -72,23 +91,36 @@ class CompetitionTaskCriteria(BaseModel):
CompetitionTask, on_delete=models.CASCADE, related_name="criteries" CompetitionTask, on_delete=models.CASCADE, related_name="criteries"
) )
name = models.TextField() name = models.TextField(verbose_name="название")
slug = models.SlugField() slug = models.SlugField(verbose_name="техническое название")
description = models.TextField() description = models.TextField(verbose_name="описание критерии")
max_value = models.PositiveSmallIntegerField() max_value = models.PositiveSmallIntegerField(
verbose_name="максимальное кол-во баллов"
)
def __str__(self):
return self.name
class Meta:
verbose_name = "критерий"
verbose_name_plural = "критерии"
class CompetitionTaskAttachment(BaseModel): class CompetitionTaskAttachment(BaseModel):
def file_upload_at(instance, filename): def file_upload_at(instance, filename) -> str:
return f"attachment/{instance.id}/file/{filename}" return f"attachments/{instance.id}/file/{filename}"
task = models.ForeignKey( task = models.ForeignKey(
CompetitionTask, on_delete=models.CASCADE, verbose_name="задание" CompetitionTask, on_delete=models.CASCADE, verbose_name="задание"
) )
file = models.FileField(upload_to=file_upload_at, 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="публичный") public = models.BooleanField(default=False, verbose_name="публичный")
class Meta:
verbose_name = "вложение"
verbose_name_plural = "вложения"
class CompetitionTaskSubmission(BaseModel): class CompetitionTaskSubmission(BaseModel):
class StatusChoices(models.TextChoices): class StatusChoices(models.TextChoices):
+21 -12
View File
@@ -1,29 +1,38 @@
import hashlib
import httpx import httpx
from celery import shared_task from celery import shared_task
from django.conf import settings
from django.core.files.base import ContentFile 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) @shared_task(bind=True, max_retries=3)
def analyze_data_task(self, submission_id): def analyze_data_task(self, submission_id):
from .models import CompetitionTaskSubmission
submission = CompetitionTaskSubmission.objects.get(id=submission_id) submission = CompetitionTaskSubmission.objects.get(id=submission_id)
try: try:
code = submission.content.read().decode() code_url = f"{settings.MINIO_DEFAULT_CUSTOM_ENDPOINT_URL}{submission.content.path}"
files = [ 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( response = httpx.post(
f"{settings.CHECKER_API_ENDPOINT}/execute", f"{settings.CHECKER_API_ENDPOINT}/execute",
files=[("files", (f.name, f)) for f in files] json={
+ [ "files": files,
("code", code), "code_url": code_url,
("expected_hash", submission.task.correct_answer_hash), "answer_file_path": submission.task.answer_file_path,
], "expected_hash": hashlib.sha256(
submission.task.correct_answer_file.read().encode()
).hexdigest(),
},
timeout=30, timeout=30,
) )
response.raise_for_status() response.raise_for_status()
@@ -40,7 +49,7 @@ def analyze_data_task(self, submission_id):
) )
submission.status = CompetitionTaskSubmission.StatusChoices.CHECKED submission.status = CompetitionTaskSubmission.StatusChoices.CHECKED
except httpx.RequestError as e: except httpx.RequestError:
self.retry(countdown=2**self.request.retries) self.retry(countdown=2**self.request.retries)
except Exception as e: except Exception as e:
submission.result = {"error": str(e), "success": False} submission.result = {"error": str(e), "success": False}
@@ -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 django.db.models.deletion
import uuid import uuid
+3
View File
@@ -6,3 +6,6 @@ class UsersConfig(AppConfig):
name = "apps.user" name = "apps.user"
label = "user" label = "user"
verbose_name = "Пользователи (веб)" verbose_name = "Пользователи (веб)"
def ready(self):
import apps.user.signals
@@ -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 import uuid
from django.db import migrations, models from django.db import migrations, models
@@ -9,6 +9,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('achievement', '0001_initial'),
] ]
operations = [ operations = [
@@ -16,11 +17,13 @@ class Migration(migrations.Migration):
name='User', name='User',
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('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='почта')), ('email', models.EmailField(max_length=254, unique=True, verbose_name='почта')),
('username', models.SlugField(unique=True, verbose_name='юзернейм')), ('username', models.SlugField(unique=True, verbose_name='юзернейм')),
('password', models.TextField(verbose_name='пароль')), ('password', models.TextField(verbose_name='пароль')),
('created_at', models.DateTimeField(auto_now=True)), ('created_at', models.DateTimeField(auto_now=True, verbose_name='дата создания')),
('status', models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10)), ('status', models.CharField(choices=[('student', 'Участник соревнований'), ('metodist', 'Методист (составитель заданий)')], default='student', max_length=10, verbose_name='роль участника')),
('achievements', models.ManyToManyField(blank=True, to='achievement.achievement', verbose_name='ачивки пользователя')),
], ],
options={ options={
'verbose_name': 'пользователь', 'verbose_name': 'пользователь',
@@ -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='ачивки пользователя'),
),
]
+11 -5
View File
@@ -5,17 +5,20 @@ from apps.achievement.models import Achievement
from apps.core.models import BaseModel from apps.core.models import BaseModel
class UserRole(models.Choices): class UserRole(models.TextChoices):
STUDENT = "student" STUDENT = "student", "Участник соревнований"
METODIST = "metodist" METODIST = "metodist", "Методист (составитель заданий)"
class User(BaseModel): class User(BaseModel):
avatar = models.ImageField(verbose_name="аватар", null=True, blank=True)
email = models.EmailField(unique=True, verbose_name="почта") email = models.EmailField(unique=True, verbose_name="почта")
username = models.SlugField(unique=True, verbose_name="юзернейм") username = models.SlugField(unique=True, verbose_name="юзернейм")
password = models.TextField(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( achievements = models.ManyToManyField(
Achievement, blank=True, verbose_name="ачивки пользователя" Achievement, blank=True, verbose_name="ачивки пользователя"
@@ -29,7 +32,10 @@ class User(BaseModel):
return check_password(self.password, password) return check_password(self.password, password)
status = models.CharField( status = models.CharField(
max_length=10, choices=UserRole, default="student" max_length=10,
choices=UserRole.choices,
default="student",
verbose_name="роль участника",
) )
def __str__(self) -> str: def __str__(self) -> str:
+14
View File
@@ -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
)
+5 -62
View File
@@ -7,8 +7,9 @@ from pathlib import Path
import django_stubs_ext import django_stubs_ext
import environ import environ
from health_check.plugins import plugin_dir
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from health_check.plugins import plugin_dir
from integrations.checker.healthcheck import CheckerHealthCheck from integrations.checker.healthcheck import CheckerHealthCheck
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@@ -270,6 +271,8 @@ DEFAULT_CHARSET = "utf-8"
FORCE_SCRIPT_NAME = None FORCE_SCRIPT_NAME = None
X_FRAME_OPTIONS = "SAMEORIGIN"
INTERNAL_IPS = env( INTERNAL_IPS = env(
"DJANGO_INTERNAL_IPS", "DJANGO_INTERNAL_IPS",
list, list,
@@ -437,8 +440,7 @@ INSTALLED_APPS = [
"django_guid", "django_guid",
"ninja", "ninja",
"minio_storage", "minio_storage",
"tinymce", "mdeditor",
"martor",
# Internal apps # Internal apps
"apps.core", "apps.core",
"apps.user", "apps.user",
@@ -449,65 +451,6 @@ INSTALLED_APPS = [
"apps.achievement", "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 # GUID
DJANGO_GUID = { DJANGO_GUID = {
+2 -4
View File
@@ -12,10 +12,8 @@ admin.site.index_title = "DataRush"
urlpatterns = [ urlpatterns = [
# tinymce # mdeditor
path("tinymce/", include("tinymce.urls")), path(r"mdeditor/", include("mdeditor.urls")),
# martor
path("martor/", include("martor.urls")),
# Admin urls # Admin urls
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
# API urls # API urls
+1 -2
View File
@@ -12,14 +12,13 @@ dependencies = [
"django-extensions>=3.2.3", "django-extensions>=3.2.3",
"django-guid>=3.5.0", "django-guid>=3.5.0",
"django-health-check>=3.18.3", "django-health-check>=3.18.3",
"django-mdeditor>=0.1.20",
"django-minio-storage>=0.5.7", "django-minio-storage>=0.5.7",
"django-ninja>=1.3.0", "django-ninja>=1.3.0",
"django-pagedown>=2.2.1", "django-pagedown>=2.2.1",
"django-stubs-ext>=5.1.3", "django-stubs-ext>=5.1.3",
"django-tinymce>=4.1.0",
"gunicorn>=23.0.0", "gunicorn>=23.0.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"martor>=1.6.45",
"pillow>=11.1.0", "pillow>=11.1.0",
"psycopg2-binary>=2.9.10", "psycopg2-binary>=2.9.10",
"pydantic>=2.10.5", "pydantic>=2.10.5",
+2
View File
@@ -9,3 +9,5 @@ fi
if [ "$DJANGO_CREATE_SUPERUSER" = "True" ]; then if [ "$DJANGO_CREATE_SUPERUSER" = "True" ]; then
python manage.py createsuperuser --noinput --username "$DJANGO_SUPERUSER_USERNAME" --email "$DJANGO_SUPERUSER_EMAIL" || true python manage.py createsuperuser --noinput --username "$DJANGO_SUPERUSER_USERNAME" --email "$DJANGO_SUPERUSER_EMAIL" || true
fi fi
python manage.py init_achievments
+14
View File
@@ -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
+4
View File
@@ -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
+1 -1
View File
@@ -30,7 +30,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONOPTIMIZE=2 \ PYTHONOPTIMIZE=2 \
PATH="/opt/venv/bin:$PATH" PATH="/opt/venv/bin:$PATH"
EXPOSE 8080 EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --start-interval=2s --retries=3 \ 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 CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8000/health || exit 1
+1 -7
View File
@@ -7,12 +7,6 @@ BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env") 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 = 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"
) )
+3 -12
View File
@@ -1,5 +1,5 @@
from fastapi import FastAPI, HTTPException, status from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field, HttpUrl, constr from pydantic import BaseModel, Field, HttpUrl
import aiohttp import aiohttp
import asyncio import asyncio
import docker import docker
@@ -22,11 +22,6 @@ app = FastAPI()
docker_client = docker.from_env() docker_client = docker.from_env()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
docker_client.login(
username=config.REGISTRY_LOGIN,
password=config.REGISTRY_PASSWORD,
registry=config.REGISTRY_URL,
)
class FileDetails(BaseModel): class FileDetails(BaseModel):
@@ -93,9 +88,7 @@ async def download_file(
session: aiohttp.ClientSession, url: str, dest_path: str session: aiohttp.ClientSession, url: str, dest_path: str
) -> None: ) -> None:
try: try:
async with session.get( async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
url, timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status != 200: if resp.status != 200:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -276,9 +269,7 @@ async def execute_code(request: ExecutionRequest) -> ExecutionResponse:
return ExecutionResponse( return ExecutionResponse(
success=success, success=success,
hash_match=( hash_match=(
result_hash == request.expected_hash result_hash == request.expected_hash if request.expected_hash else None
if request.expected_hash
else None
), ),
output=output[:5000], output=output[:5000],
result_hash=result_hash, result_hash=result_hash,
@@ -2,18 +2,32 @@ import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Task } from '@/shared/types/task'; import { Task } from '@/shared/types/task';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
interface CompetitionHeaderProps { interface CompetitionHeaderProps {
title: string; title: string;
tasks: Task[]; tasks: Task[];
competitionId: string; competitionId: string;
setAnswer: (value: string) => void;
setSelectedFile: (file: File | null) => void;
} }
const CompetitionHeader: React.FC<CompetitionHeaderProps> = ({ const CompetitionHeader: React.FC<CompetitionHeaderProps> = ({
title, title,
tasks, 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 ( return (
<header className="bg-white shadow-sm sticky top-0 z-30 w-full"> <header className="bg-white shadow-sm sticky top-0 z-30 w-full">
<div className="mx-auto max-w-6xl px-4"> <div className="mx-auto max-w-6xl px-4">
@@ -23,7 +37,6 @@ const CompetitionHeader: React.FC<CompetitionHeaderProps> = ({
className="flex items-center text-gray-600 hover:text-gray-900 transition-colors font-hse-sans text-sm" className="flex items-center text-gray-600 hover:text-gray-900 transition-colors font-hse-sans text-sm"
> >
<ArrowLeft className="h-4 w-4 mr-1" /> <ArrowLeft className="h-4 w-4 mr-1" />
Обратно
</Link> </Link>
<h1 className="font-hse-sans text-xl font-semibold text-center flex-1"> <h1 className="font-hse-sans text-xl font-semibold text-center flex-1">
@@ -35,16 +48,16 @@ const CompetitionHeader: React.FC<CompetitionHeaderProps> = ({
<div className="flex items-center justify-center gap-4 pb-4 overflow-x-auto no-scrollbar"> <div className="flex items-center justify-center gap-4 pb-4 overflow-x-auto no-scrollbar">
{tasks.map((task) => ( {tasks.map((task) => (
<Link <button
key={task.id} key={task.id}
to={`/competition/${competitionId}/tasks/${task.id}`} className={`text-[var(--color-task-text-uncleared)] bg-[var(--color-task-uncleared)]
className={`text-[var(--color-task-text-uncleared)] bg-[var(--color-task-uncleared)] rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer
rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer transition-all hover:brightness-95 flex-shrink-0
transition-all hover:brightness-95 flex-shrink-0 `}
`} onClick={() => handleTaskSelect(task.id)}
> >
{task.in_competition_position} {task.in_competition_position}
</Link> </button>
))} ))}
</div> </div>
</div> </div>
@@ -4,6 +4,7 @@ import CompetitionHeader from "./components/CompetitionHeader";
import TaskContent from "./components/TaskContent"; import TaskContent from "./components/TaskContent";
import TaskSolution from "./modules/TaskSolution"; import TaskSolution from "./modules/TaskSolution";
import { getCompetitionTasks, submitTaskSolution } from "@/shared/api/session"; import { getCompetitionTasks, submitTaskSolution } from "@/shared/api/session";
import { getCompetition } from "@/shared/api/competitions";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { TaskType } from "@/shared/types/task"; import { TaskType } from "@/shared/types/task";
@@ -15,6 +16,12 @@ const CompetitionSession = () => {
const competitionId = id || ""; const competitionId = id || "";
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const competitionQuery = useQuery({
queryKey: ["competition", competitionId],
queryFn: () => getCompetition(competitionId),
enabled: !!competitionId,
});
const tasksQuery = useQuery({ const tasksQuery = useQuery({
queryKey: ["competitionTasks", competitionId], queryKey: ["competitionTasks", competitionId],
queryFn: () => getCompetitionTasks(competitionId), queryFn: () => getCompetitionTasks(competitionId),
@@ -46,9 +53,12 @@ const CompetitionSession = () => {
} }
}); });
const competition = competitionQuery.data;
const tasks = tasksQuery.data || []; const tasks = tasksQuery.data || [];
const isLoading = tasksQuery.isLoading; const isLoading = tasksQuery.isLoading || competitionQuery.isLoading;
const error = tasksQuery.error ? "Не удалось загрузить задания. Пожалуйста, попробуйте позже." : null; const error = tasksQuery.error || competitionQuery.error
? "Не удалось загрузить данные. Пожалуйста, попробуйте позже."
: null;
const currentTask = tasks.find((t) => t.id === taskId) || null; const currentTask = tasks.find((t) => t.id === taskId) || null;
@@ -77,12 +87,16 @@ const CompetitionSession = () => {
submitMutation.mutate(); submitMutation.mutate();
}; };
const competitionTitle = competition?.title || "Загрузка соревнования...";
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
<CompetitionHeader <CompetitionHeader
title="Олимпиада DANO 2025. Индивидуальный этап" title={competitionTitle}
tasks={tasks} tasks={tasks}
competitionId={competitionId} competitionId={competitionId}
setAnswer={setAnswer}
setSelectedFile={setSelectedFile}
/> />
<main className="flex-1 bg-[#F8F8F8] pb-8"> <main className="flex-1 bg-[#F8F8F8] pb-8">
@@ -1,14 +1,19 @@
import React from 'react'; import React from 'react';
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
interface ActionButtonsProps { interface ActionButtonsProps {
onSubmit: () => void; onSubmit: () => void;
onHistoryClick: () => void; onHistoryClick: () => void;
isSubmitting?: boolean;
hasSubmissionsLeft?: boolean;
} }
const ActionButtons: React.FC<ActionButtonsProps> = ({ const ActionButtons: React.FC<ActionButtonsProps> = ({
onSubmit, onSubmit,
onHistoryClick onHistoryClick,
isSubmitting = false,
hasSubmissionsLeft = true
}) => { }) => {
return ( return (
<div className="flex gap-8"> <div className="flex gap-8">
@@ -16,15 +21,31 @@ const ActionButtons: React.FC<ActionButtonsProps> = ({
variant="ghost" variant="ghost"
className="font-hse-sans bg-white hover:bg-gray-100" className="font-hse-sans bg-white hover:bg-gray-100"
onClick={onHistoryClick} onClick={onHistoryClick}
disabled={isSubmitting}
> >
История История
</Button> </Button>
<Button
onClick={onSubmit} {hasSubmissionsLeft ? (
className="font-hse-sans flex-grow" <Button
> onClick={onSubmit}
Отправить решение className="font-hse-sans flex-grow"
</Button> disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Отправка...
</>
) : (
"Отправить решение"
)}
</Button>
) : (
<div className="flex-grow text-right text-gray-500 flex items-center justify-end font-hse-sans">
Лимит посылок исчерпан
</div>
)}
</div> </div>
); );
}; };
@@ -8,6 +8,7 @@ interface FileSolutionProps {
fileInputRef: React.RefObject<HTMLInputElement>; fileInputRef: React.RefObject<HTMLInputElement>;
existingFileUrl?: string | null; existingFileUrl?: string | null;
onClearExistingFile?: () => void; // New prop to clear existing file URL onClearExistingFile?: () => void; // New prop to clear existing file URL
firstSolution: boolean
} }
const FileSolution: React.FC<FileSolutionProps> = ({ const FileSolution: React.FC<FileSolutionProps> = ({
@@ -15,7 +16,8 @@ const FileSolution: React.FC<FileSolutionProps> = ({
setSelectedFile, setSelectedFile,
fileInputRef, fileInputRef,
existingFileUrl = null, existingFileUrl = null,
onClearExistingFile onClearExistingFile,
firstSolution
}) => { }) => {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files[0]) { if (event.target.files && event.target.files[0]) {
@@ -59,9 +61,6 @@ const FileSolution: React.FC<FileSolutionProps> = ({
} }
}; };
const handleSelectNewFile = () => {
fileInputRef.current?.click();
};
const fileName = selectedFile const fileName = selectedFile
? selectedFile.name ? selectedFile.name
@@ -69,7 +68,7 @@ const FileSolution: React.FC<FileSolutionProps> = ({
? existingFileUrl.split('/').pop() || 'file' ? existingFileUrl.split('/').pop() || 'file'
: ''; : '';
const hasFile = !!selectedFile || !!existingFileUrl; const hasFile = !!selectedFile || (!!existingFileUrl && !firstSolution);
return ( return (
<> <>
@@ -87,7 +86,7 @@ const FileSolution: React.FC<FileSolutionProps> = ({
<FileIcon size={28} className="text-black mb-2" /> <FileIcon size={28} className="text-black mb-2" />
<span className="text-sm text-gray-700 font-medium mb-1 font-hse-sans">{fileName}</span> <span className="text-sm text-gray-700 font-medium mb-1 font-hse-sans">{fileName}</span>
<div className="flex items-center mt-2"> <div className="flex flex-col justify-center mt-2">
{existingFileUrl && !selectedFile && ( {existingFileUrl && !selectedFile && (
<a <a
href={existingFileUrl} href={existingFileUrl}
@@ -99,7 +98,7 @@ const FileSolution: React.FC<FileSolutionProps> = ({
</a> </a>
)} )}
{selectedFile ? ( {selectedFile || existingFileUrl ? (
<Button <Button
variant="ghost" variant="ghost"
className="text-sm p-0 h-auto hover:bg-transparent font-hse-sans" className="text-sm p-0 h-auto hover:bg-transparent font-hse-sans"
@@ -107,23 +106,6 @@ const FileSolution: React.FC<FileSolutionProps> = ({
> >
Очистить Очистить
</Button> </Button>
) : existingFileUrl ? (
<div className="flex gap-3">
<Button
variant="ghost"
className="text-sm p-0 h-auto hover:bg-transparent font-hse-sans"
onClick={handleSelectNewFile}
>
Выбрать другой файл
</Button>
<Button
variant="ghost"
className="text-sm p-0 h-auto hover:bg-transparent font-hse-sans"
onClick={handleClearFile}
>
Очистить
</Button>
</div>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { X } from "lucide-react"; import { X, Check } from "lucide-react";
import SolutionStatus from '../SolutionStatus'; import SolutionStatus from '../SolutionStatus';
import { Solution, TaskType } from '@/shared/types/task'; import { Solution } from '@/shared/types/task';
interface SolutionHistorySheetProps { interface SolutionHistorySheetProps {
isOpen: boolean; isOpen: boolean;
@@ -18,7 +18,7 @@ const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
onOpenChange, onOpenChange,
solutions, solutions,
maxPoints, maxPoints,
onSolutionSelect onSolutionSelect,
}) => { }) => {
return ( return (
<Sheet open={isOpen} onOpenChange={onOpenChange}> <Sheet open={isOpen} onOpenChange={onOpenChange}>
@@ -39,10 +39,10 @@ const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
solutions.map((solution, index) => ( solutions.map((solution, index) => (
<div <div
key={solution.id || index} key={solution.id || index}
className="w-full cursor-pointer transition-transform hover:scale-[1.01]" className={`w-full cursor-pointer transition-transform hover:scale-[1.01] relative`}
onClick={() => { onClick={() => {
onSolutionSelect(solution); onSolutionSelect(solution);
onOpenChange(false); onOpenChange(false);
}} }}
> >
<SolutionStatus solution={solution} maxPoints={maxPoints} /> <SolutionStatus solution={solution} maxPoints={maxPoints} />
@@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Task, TaskType, Solution } from '@/shared/types/task'; import { Task, TaskType, Solution } from '@/shared/types/task';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@@ -17,6 +17,7 @@ interface TaskSolutionProps {
selectedFile: File | null; selectedFile: File | null;
setSelectedFile: (file: File | null) => void; setSelectedFile: (file: File | null) => void;
onSubmit: () => void; onSubmit: () => void;
isSubmitting?: boolean;
} }
const TaskSolution: React.FC<TaskSolutionProps> = ({ const TaskSolution: React.FC<TaskSolutionProps> = ({
@@ -26,11 +27,14 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
selectedFile, selectedFile,
setSelectedFile, setSelectedFile,
onSubmit, onSubmit,
isSubmitting = false
}) => { }) => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [selectedSolutionUrl, setSelectedSolutionUrl] = useState<string | null>(null); const [selectedSolutionUrl, setSelectedSolutionUrl] = useState<string | null>(null);
const [displayedSolution, setDisplayedSolution] = useState<Solution | null>(null);
const { id: competitionId } = useParams<{ id: string }>(); const { id: competitionId } = useParams<{ id: string }>();
const prevTaskIdRef = useRef<string | null>(null);
const solutionsQuery = useQuery({ const solutionsQuery = useQuery({
queryKey: ['solutionHistory', competitionId, task.id], queryKey: ['solutionHistory', competitionId, task.id],
@@ -39,44 +43,87 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
}); });
const solutionHistory = solutionsQuery.data || []; 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 = () => { const handleOpenHistory = () => {
setIsHistoryOpen(true); setIsHistoryOpen(true);
}; };
const latestSolution = solutionHistory && solutionHistory.length > 0 ? solutionHistory[0] : null; const handleSolutionSelect = (solution: Solution) => {
setDisplayedSolution(solution);
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);
}
}; };
// Function to clear the existing file URL
const handleClearExistingFile = () => { const handleClearExistingFile = () => {
setSelectedSolutionUrl(null); setSelectedSolutionUrl(null);
}; };
return ( return (
<div className="md:w-[500px] flex flex-col gap-4"> <div className="md:w-[500px] flex flex-col gap-4">
{latestSolution ? ( {displayedSolution ? (
<SolutionStatus solution={latestSolution} maxPoints={task.points}/> <>
<div className="bg-gray-100 rounded-lg p-4 text-gray-600 font-hse-sans">
Результат последней посылки:
</div>
<SolutionStatus key={displayedSolution.id} solution={displayedSolution} maxPoints={task.points}/>
</>
) : ( ) : (
<div className="bg-gray-100 rounded-lg p-4 text-gray-600 font-hse-sans"> <div className="bg-gray-100 rounded-lg p-4 text-gray-600 font-hse-sans">
Решение еще не отправлено Решение еще не отправлено
@@ -84,7 +131,10 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
)} )}
{task.type === TaskType.INPUT && ( {task.type === TaskType.INPUT && (
<InputSolution answer={answer} setAnswer={setAnswer} /> <InputSolution
answer={answer}
setAnswer={setAnswer}
/>
)} )}
{task.type === TaskType.FILE && ( {task.type === TaskType.FILE && (
@@ -94,16 +144,45 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
fileInputRef={fileInputRef} fileInputRef={fileInputRef}
existingFileUrl={selectedSolutionUrl} existingFileUrl={selectedSolutionUrl}
onClearExistingFile={handleClearExistingFile} onClearExistingFile={handleClearExistingFile}
firstSolution={solutionHistory.length > 0}
/> />
)} )}
{task.type === TaskType.CODE && ( {task.type === TaskType.CODE && (
<CodeSolution answer={answer} setAnswer={setAnswer} /> <CodeSolution
answer={answer}
setAnswer={setAnswer}
/>
)} )}
<div className={`rounded-lg p-3 font-hse-sans text-sm flex items-center
${hasSubmissionsLeft
? 'bg-blue-50 text-blue-700'
: 'bg-red-50 text-red-700'}`}
>
{hasSubmissionsLeft ? (
<>
<span className="font-medium">
Осталось посылок: {submissionsLeft === Infinity ? '∞' : submissionsLeft}
</span>
{maxAttempts !== -1 && (
<span className="text-blue-500 ml-1">
(из {maxAttempts})
</span>
)}
</>
) : (
<span className="font-medium">
Вы использовали все посылки
</span>
)}
</div>
<ActionButtons <ActionButtons
onSubmit={onSubmit} onSubmit={onSubmit}
onHistoryClick={handleOpenHistory} onHistoryClick={handleOpenHistory}
isSubmitting={isSubmitting}
hasSubmissionsLeft={hasSubmissionsLeft}
/> />
<SolutionHistorySheet <SolutionHistorySheet
+1 -1
View File
@@ -24,7 +24,7 @@ export const submitTaskSolution = async (
// туповатый костыль но для мвп сойдет // туповатый костыль но для мвп сойдет
if (typeof solution === 'string') { if (typeof solution === 'string') {
const textFile = new File([solution], 'solution.txt', { type: 'text/plain' }); const textFile = new File([solution], 'solution_example.txt', { type: 'text/plain' });
formData.append('content', textFile); formData.append('content', textFile);
} else { } else {
formData.append('content', solution); formData.append('content', solution);
@@ -5,6 +5,7 @@ interface Task {
type: TaskType; type: TaskType;
in_competition_position: number; in_competition_position: number;
points: number; points: number;
max_attempts: number;
} }
export interface TaskAttachment { export interface TaskAttachment {