mirror of
https://gitlab.com/megazordpobeda/DataRush.git
synced 2026-05-23 20:17:10 +00:00
Merge branch 'master' of gitlab.prodcontest.ru:team-15/project
This commit is contained in:
+35
-2
@@ -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
@@ -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,2 +1,2 @@
|
||||
REGISTRY_LOGIN=devitq
|
||||
REGISTRY_PASSWORD=14zQrbzDTM0WXK@CogMQikAvP74Rj4
|
||||
REGISTRY_PASSWORD=prod-zAoUDyHwkgRfQPyVvskH
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -33,6 +33,7 @@ class TaskOutSchema(ModelSchema):
|
||||
"description",
|
||||
"in_competition_position",
|
||||
"points",
|
||||
"max_attempts"
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
-23
@@ -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'),
|
||||
),
|
||||
]
|
||||
-28
@@ -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='иконка достижения'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
)
|
||||
Binary file not shown.
@@ -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}'"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
submission.send_on_review()
|
||||
|
||||
@@ -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': 'посылки',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
-71
@@ -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='описание'),
|
||||
),
|
||||
]
|
||||
-24
@@ -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='описание'),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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='ачивки пользователя'),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
Executable
+14
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+25
-12
@@ -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">
|
||||
|
||||
+28
-7
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+6
-24
@@ -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>
|
||||
|
||||
+5
-5
@@ -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,10 +39,10 @@ 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);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<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 { 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],
|
||||
@@ -39,44 +43,87 @@ 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user