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:
- 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
+13 -1
View File
@@ -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:
+1 -1
View File
@@ -1,2 +1,2 @@
REGISTRY_LOGIN=devitq
REGISTRY_PASSWORD=14zQrbzDTM0WXK@CogMQikAvP74Rj4
REGISTRY_PASSWORD=prod-zAoUDyHwkgRfQPyVvskH
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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;
+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):
@@ -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",)
+3 -2
View File
@@ -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()
@@ -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:
+6
View File
@@ -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:
+1
View File
@@ -33,6 +33,7 @@ class TaskOutSchema(ModelSchema):
"description",
"in_competition_position",
"points",
"max_attempts"
]
+16 -1
View File
@@ -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(
+11 -1
View File
@@ -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):
+13 -13
View File
@@ -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
)
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 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,
},
),
]
@@ -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 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)
@@ -5,3 +5,6 @@ class CompetitionsConfig(AppConfig):
name = "apps.competition"
label = "competition"
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 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.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
@@ -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 = "Проверка"
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
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
from django.db import migrations, models
+1 -2
View File
@@ -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()
+12 -4
View File
@@ -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
@@ -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': 'посылки',
},
),
]
@@ -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.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):
+21 -12
View File
@@ -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}
@@ -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
+3
View File
@@ -6,3 +6,6 @@ class UsersConfig(AppConfig):
name = "apps.user"
label = "user"
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
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': 'пользователь',
@@ -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
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:
+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 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 = {
+2 -4
View File
@@ -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
+1 -2
View File
@@ -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",
+2
View File
@@ -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
+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 \
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
+1 -7
View File
@@ -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"
)
+3 -12
View File
@@ -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,
@@ -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<CompetitionHeaderProps> = ({
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 (
<header className="bg-white shadow-sm sticky top-0 z-30 w-full">
<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"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Обратно
</Link>
<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">
{tasks.map((task) => (
<Link
key={task.id}
to={`/competition/${competitionId}/tasks/${task.id}`}
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
transition-all hover:brightness-95 flex-shrink-0
`}
>
{task.in_competition_position}
</Link>
<button
key={task.id}
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
transition-all hover:brightness-95 flex-shrink-0
`}
onClick={() => handleTaskSelect(task.id)}
>
{task.in_competition_position}
</button>
))}
</div>
</div>
@@ -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 (
<div className="flex min-h-screen flex-col">
<CompetitionHeader
title="Олимпиада DANO 2025. Индивидуальный этап"
title={competitionTitle}
tasks={tasks}
competitionId={competitionId}
setAnswer={setAnswer}
setSelectedFile={setSelectedFile}
/>
<main className="flex-1 bg-[#F8F8F8] pb-8">
@@ -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<ActionButtonsProps> = ({
onSubmit,
onHistoryClick
onHistoryClick,
isSubmitting = false,
hasSubmissionsLeft = true
}) => {
return (
<div className="flex gap-8">
@@ -16,15 +21,31 @@ const ActionButtons: React.FC<ActionButtonsProps> = ({
variant="ghost"
className="font-hse-sans bg-white hover:bg-gray-100"
onClick={onHistoryClick}
disabled={isSubmitting}
>
История
</Button>
<Button
onClick={onSubmit}
className="font-hse-sans flex-grow"
>
Отправить решение
</Button>
{hasSubmissionsLeft ? (
<Button
onClick={onSubmit}
className="font-hse-sans flex-grow"
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>
);
};
@@ -8,6 +8,7 @@ interface FileSolutionProps {
fileInputRef: React.RefObject<HTMLInputElement>;
existingFileUrl?: string | null;
onClearExistingFile?: () => void; // New prop to clear existing file URL
firstSolution: boolean
}
const FileSolution: React.FC<FileSolutionProps> = ({
@@ -15,7 +16,8 @@ const FileSolution: React.FC<FileSolutionProps> = ({
setSelectedFile,
fileInputRef,
existingFileUrl = null,
onClearExistingFile
onClearExistingFile,
firstSolution
}) => {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
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
? selectedFile.name
@@ -69,7 +68,7 @@ const FileSolution: React.FC<FileSolutionProps> = ({
? existingFileUrl.split('/').pop() || 'file'
: '';
const hasFile = !!selectedFile || !!existingFileUrl;
const hasFile = !!selectedFile || (!!existingFileUrl && !firstSolution);
return (
<>
@@ -87,7 +86,7 @@ const FileSolution: React.FC<FileSolutionProps> = ({
<FileIcon size={28} className="text-black mb-2" />
<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 && (
<a
href={existingFileUrl}
@@ -99,7 +98,7 @@ const FileSolution: React.FC<FileSolutionProps> = ({
</a>
)}
{selectedFile ? (
{selectedFile || existingFileUrl ? (
<Button
variant="ghost"
className="text-sm p-0 h-auto hover:bg-transparent font-hse-sans"
@@ -107,23 +106,6 @@ const FileSolution: React.FC<FileSolutionProps> = ({
>
Очистить
</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}
</div>
</div>
@@ -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<SolutionHistorySheetProps> = ({
onOpenChange,
solutions,
maxPoints,
onSolutionSelect
onSolutionSelect,
}) => {
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
@@ -39,7 +39,7 @@ const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
solutions.map((solution, index) => (
<div
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={() => {
onSolutionSelect(solution);
onOpenChange(false);
@@ -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<TaskSolutionProps> = ({
@@ -26,11 +27,14 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
selectedFile,
setSelectedFile,
onSubmit,
isSubmitting = false
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [selectedSolutionUrl, setSelectedSolutionUrl] = useState<string | null>(null);
const [displayedSolution, setDisplayedSolution] = useState<Solution | null>(null);
const { id: competitionId } = useParams<{ id: string }>();
const prevTaskIdRef = useRef<string | null>(null);
const solutionsQuery = useQuery({
queryKey: ['solutionHistory', competitionId, task.id],
@@ -40,43 +44,86 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
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 (
<div className="md:w-[500px] flex flex-col gap-4">
{latestSolution ? (
<SolutionStatus solution={latestSolution} maxPoints={task.points}/>
{displayedSolution ? (
<>
<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">
Решение еще не отправлено
@@ -84,7 +131,10 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
)}
{task.type === TaskType.INPUT && (
<InputSolution answer={answer} setAnswer={setAnswer} />
<InputSolution
answer={answer}
setAnswer={setAnswer}
/>
)}
{task.type === TaskType.FILE && (
@@ -94,16 +144,45 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
fileInputRef={fileInputRef}
existingFileUrl={selectedSolutionUrl}
onClearExistingFile={handleClearExistingFile}
firstSolution={solutionHistory.length > 0}
/>
)}
{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
onSubmit={onSubmit}
onHistoryClick={handleOpenHistory}
isSubmitting={isSubmitting}
hasSubmissionsLeft={hasSubmissionsLeft}
/>
<SolutionHistorySheet
+1 -1
View File
@@ -24,7 +24,7 @@ export const submitTaskSolution = async (
// туповатый костыль но для мвп сойдет
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);
} else {
formData.append('content', solution);
@@ -5,6 +5,7 @@ interface Task {
type: TaskType;
in_competition_position: number;
points: number;
max_attempts: number;
}
export interface TaskAttachment {