Merge remote-tracking branch 'origin/master'

This commit is contained in:
Андрей Сумин
2025-03-03 12:28:17 +03:00
22 changed files with 129 additions and 142 deletions
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-02 22:53
# Generated by Django 5.1.6 on 2025-03-03 07:20
import apps.achievement.models
import django.db.models.deletion
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-02 22:53
# Generated by Django 5.1.6 on 2025-03-03 07:20
import django.db.models.deletion
from django.db import migrations, models
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-02 22:53
# Generated by Django 5.1.6 on 2025-03-03 07:20
import apps.competition.models
import datetime
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-02 22:53
# Generated by Django 5.1.6 on 2025-03-03 07:20
import uuid
from django.db import migrations, models
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-02 22:53
# Generated by Django 5.1.6 on 2025-03-03 07:20
import django.db.models.deletion
from django.db import migrations, models
+7 -4
View File
@@ -4,6 +4,7 @@ from apps.task.models import (
CompetitionTask,
CompetitionTaskAttachment,
CompetitionTaskSubmission,
CompetitionTaskCriteria
)
@@ -12,12 +13,17 @@ 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,)
inlines = (CompletionAttachmentInline, CompetitionCriteriaInline,)
@admin.register(CompetitionTaskSubmission)
@@ -39,9 +45,6 @@ class CompetitionTaskSubmissionAdmin(admin.ModelAdmin):
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,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-02 22:53
# Generated by Django 5.1.6 on 2025-03-03 07:20
import apps.task.models
import django.db.models.deletion
+40 -13
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,15 @@ 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 +38,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 +49,10 @@ class CompetitionTask(BaseModel):
null=True,
blank=True,
verbose_name="куда сделать вывод программы участнику",
help_text="Путь до файла в котором ожидается результат. Пример: stdout или ./output.txt",
help_text=(
"Путь до файла в котором ожидается результат. "
"Пример: stdout или ./output.txt. Имеет смысл только при автоматическом типе проверки."
),
default="stdout",
)
@@ -53,10 +61,14 @@ 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,10 +84,25 @@ 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):
+21 -10
View File
@@ -2,27 +2,38 @@ import httpx
from celery import shared_task
from django.conf import settings
from django.core.files.base import ContentFile
import hashlib
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.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()
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-02 22:53
# Generated by Django 5.1.6 on 2025-03-03 07:20
import django.db.models.deletion
import uuid
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-02 22:53
# Generated by Django 5.1.6 on 2025-03-03 07:20
import uuid
from django.db import migrations, models
+6 -5
View File
@@ -5,9 +5,9 @@ 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):
@@ -15,7 +15,7 @@ class User(BaseModel):
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 +29,8 @@ 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:
+3 -61
View File
@@ -271,6 +271,8 @@ DEFAULT_CHARSET = "utf-8"
FORCE_SCRIPT_NAME = None
X_FRAME_OPTIONS = "SAMEORIGIN"
INTERNAL_IPS = env(
"DJANGO_INTERNAL_IPS",
list,
@@ -438,8 +440,7 @@ INSTALLED_APPS = [
"django_guid",
"ninja",
"minio_storage",
"tinymce",
"martor",
"mdeditor",
# Internal apps
"apps.core",
"apps.user",
@@ -450,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
+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
+6 -7
View File
@@ -1,4 +1,3 @@
import docker.errors
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field, HttpUrl
import aiohttp
@@ -158,12 +157,12 @@ def run_container_safely(
"stderr": f"Container error: {str(e)}",
"status": -1,
}
finally:
if container:
try:
container.remove(force=True)
except docker.errors.DockerException:
pass
# finally:
# if container:
# try:
# container.remove(force=True)
# except docker.errors.DockerException:
# pass
def validate_file_path(path: str) -> bool:
@@ -22,6 +22,7 @@ const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
onSolutionSelect,
currentSolutionId
}) => {
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent className="w-[350px] sm:w-[450px] p-0">
@@ -32,7 +32,7 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
const [selectedSolutionUrl, setSelectedSolutionUrl] = useState<string | null>(null);
const [currentSolution, setCurrentSolution] = useState<Solution | null>(null);
const { id: competitionId } = useParams<{ id: string }>();
const previousTaskIdRef = useRef<string | null>(null);
const taskIdRef = useRef<string | null>(null);
const solutionsQuery = useQuery({
queryKey: ['solutionHistory', competitionId, task.id],
@@ -41,36 +41,38 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
});
const solutionHistory = solutionsQuery.data || [];
// Handle task changes
useEffect(() => {
if (solutionHistory.length > 0 && !currentSolution) {
setCurrentSolution(solutionHistory[solutionHistory.length - 1]);
}
}, [solutionHistory, currentSolution]);
useEffect(() => {
if (solutionHistory.length > 0 && currentSolution &&
solutionHistory[0].id !== currentSolution.id) {
setCurrentSolution(solutionHistory[solutionHistory.length - 1]);
}
}, [solutionHistory, currentSolution]);
useEffect(() => {
if (previousTaskIdRef.current !== task.id) {
if (taskIdRef.current !== task.id) {
setCurrentSolution(null);
setSelectedSolutionUrl(null);
setAnswer("");
setSelectedFile(null);
taskIdRef.current = task.id;
if (solutionHistory.length > 0 && !solutionsQuery.isLoading) {
setCurrentSolution(solutionHistory[solutionHistory.length - 1]);
// Wait for the query to complete
if (!solutionsQuery.isLoading && solutionHistory.length > 0) {
// Get the most recent solution (last in the array)
const latestSolution = solutionHistory[solutionHistory.length - 1];
setCurrentSolution(latestSolution);
}
previousTaskIdRef.current = task.id;
}
}, [task.id, solutionHistory, solutionsQuery.isLoading, setAnswer, setSelectedFile]);
// Refresh current solution when the solution history changes (after a new submission)
useEffect(() => {
if (!solutionsQuery.isLoading && solutionHistory.length > 0) {
// If we don't have a current solution or there's a new submission
// (which would be the last item in the array)
if (!currentSolution ||
currentSolution.id !== solutionHistory[solutionHistory.length - 1].id) {
// Set to the latest solution (last in the array)
setCurrentSolution(solutionHistory[solutionHistory.length - 1]);
}
}
}, [solutionHistory, currentSolution, solutionsQuery.isLoading]);
// Load solution content when current solution changes
useEffect(() => {
const loadSolutionContent = async () => {
if (!currentSolution || !currentSolution.content) return;
@@ -108,6 +110,10 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
setSelectedSolutionUrl(null);
};
const handleSubmitWrapper = () => {
onSubmit();
};
return (
<div className="md:w-[500px] flex flex-col gap-4">
{currentSolution ? (
@@ -143,7 +149,7 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
)}
<ActionButtons
onSubmit={onSubmit}
onSubmit={handleSubmitWrapper}
onHistoryClick={handleOpenHistory}
/>