mirror of
https://gitlab.com/megazordpobeda/DataRush.git
synced 2026-05-23 14:27:10 +00:00
Merge branch 'master' of gitlab.prodcontest.ru:team-15/project
This commit is contained in:
@@ -4,6 +4,7 @@ from apps.task.models import (
|
|||||||
CompetitionTask,
|
CompetitionTask,
|
||||||
CompetitionTaskAttachment,
|
CompetitionTaskAttachment,
|
||||||
CompetitionTaskSubmission,
|
CompetitionTaskSubmission,
|
||||||
|
CompetitionTaskCriteria
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -12,12 +13,17 @@ class CompletionAttachmentInline(admin.StackedInline):
|
|||||||
extra = 0
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
class CompetitionCriteriaInline(admin.StackedInline):
|
||||||
|
model = CompetitionTaskCriteria
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CompetitionTask)
|
@admin.register(CompetitionTask)
|
||||||
class CompetitionTaskAdmin(admin.ModelAdmin):
|
class CompetitionTaskAdmin(admin.ModelAdmin):
|
||||||
list_display = ("title", "type", "points")
|
list_display = ("title", "type", "points")
|
||||||
filter_horizontal = ("reviewers",)
|
filter_horizontal = ("reviewers",)
|
||||||
list_filter = ("type",)
|
list_filter = ("type",)
|
||||||
inlines = (CompletionAttachmentInline,)
|
inlines = (CompletionAttachmentInline, CompetitionCriteriaInline,)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CompetitionTaskSubmission)
|
@admin.register(CompetitionTaskSubmission)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Q
|
||||||
from martor.models import MartorField
|
from mdeditor.fields import MDTextField
|
||||||
|
|
||||||
from apps.competition.models import Competition
|
from apps.competition.models import Competition
|
||||||
from apps.core.models import BaseModel
|
from apps.core.models import BaseModel
|
||||||
@@ -19,11 +19,15 @@ class CompetitionTask(BaseModel):
|
|||||||
def answer_file_upload_to(instance, filename) -> str:
|
def answer_file_upload_to(instance, filename) -> str:
|
||||||
return f"tasks/{instance.id}/answer/{uuid4()}/{filename}"
|
return f"tasks/{instance.id}/answer/{uuid4()}/{filename}"
|
||||||
|
|
||||||
in_competition_position = models.PositiveSmallIntegerField()
|
in_competition_position = models.PositiveSmallIntegerField(
|
||||||
competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
|
verbose_name="позиция в соревновании"
|
||||||
|
)
|
||||||
|
competition = models.ForeignKey(Competition, on_delete=models.CASCADE,
|
||||||
|
verbose_name="привязанное соревнование")
|
||||||
title = models.CharField(verbose_name="заголовок", max_length=50)
|
title = models.CharField(verbose_name="заголовок", max_length=50)
|
||||||
description = MartorField(verbose_name="описание")
|
description = MDTextField(verbose_name="описание")
|
||||||
max_attempts = models.PositiveSmallIntegerField(null=True, blank=True)
|
max_attempts = models.PositiveSmallIntegerField(null=True, blank=True,
|
||||||
|
verbose_name="максимальное кол-во попыток")
|
||||||
type = models.CharField(
|
type = models.CharField(
|
||||||
choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки"
|
choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки"
|
||||||
)
|
)
|
||||||
@@ -64,7 +68,7 @@ class CompetitionTask(BaseModel):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
submission_reviewers_count = models.PositiveSmallIntegerField(
|
submission_reviewers_count = models.PositiveSmallIntegerField(
|
||||||
default=1, null=True, blank=True
|
default=1, null=True, blank=True, verbose_name="кол-во проверяющих для зачета задачи"
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -80,10 +84,25 @@ class CompetitionTaskCriteria(BaseModel):
|
|||||||
CompetitionTask, on_delete=models.CASCADE, related_name="criteries"
|
CompetitionTask, on_delete=models.CASCADE, related_name="criteries"
|
||||||
)
|
)
|
||||||
|
|
||||||
name = models.TextField()
|
name = models.TextField(
|
||||||
slug = models.SlugField()
|
verbose_name="название"
|
||||||
description = models.TextField()
|
)
|
||||||
max_value = models.PositiveSmallIntegerField()
|
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):
|
class CompetitionTaskAttachment(BaseModel):
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ from apps.achievement.models import Achievement
|
|||||||
from apps.core.models import BaseModel
|
from apps.core.models import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class UserRole(models.Choices):
|
class UserRole(models.TextChoices):
|
||||||
STUDENT = "student"
|
STUDENT = "student", "Участник соревнований"
|
||||||
METODIST = "metodist"
|
METODIST = "metodist", "Методист (составитель заданий)"
|
||||||
|
|
||||||
|
|
||||||
class User(BaseModel):
|
class User(BaseModel):
|
||||||
@@ -15,7 +15,7 @@ class User(BaseModel):
|
|||||||
username = models.SlugField(unique=True, verbose_name="юзернейм")
|
username = models.SlugField(unique=True, verbose_name="юзернейм")
|
||||||
password = models.TextField(verbose_name="пароль")
|
password = models.TextField(verbose_name="пароль")
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now=True)
|
created_at = models.DateTimeField(auto_now=True, verbose_name="дата создания")
|
||||||
|
|
||||||
achievements = models.ManyToManyField(
|
achievements = models.ManyToManyField(
|
||||||
Achievement, blank=True, verbose_name="ачивки пользователя"
|
Achievement, blank=True, verbose_name="ачивки пользователя"
|
||||||
@@ -29,7 +29,8 @@ class User(BaseModel):
|
|||||||
return check_password(self.password, password)
|
return check_password(self.password, password)
|
||||||
|
|
||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
max_length=10, choices=UserRole, default="student"
|
max_length=10, choices=UserRole.choices, default="student",
|
||||||
|
verbose_name="роль участника"
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
|
|||||||
@@ -271,6 +271,8 @@ DEFAULT_CHARSET = "utf-8"
|
|||||||
|
|
||||||
FORCE_SCRIPT_NAME = None
|
FORCE_SCRIPT_NAME = None
|
||||||
|
|
||||||
|
X_FRAME_OPTIONS = "SAMEORIGIN"
|
||||||
|
|
||||||
INTERNAL_IPS = env(
|
INTERNAL_IPS = env(
|
||||||
"DJANGO_INTERNAL_IPS",
|
"DJANGO_INTERNAL_IPS",
|
||||||
list,
|
list,
|
||||||
@@ -438,8 +440,7 @@ INSTALLED_APPS = [
|
|||||||
"django_guid",
|
"django_guid",
|
||||||
"ninja",
|
"ninja",
|
||||||
"minio_storage",
|
"minio_storage",
|
||||||
"tinymce",
|
"mdeditor",
|
||||||
"martor",
|
|
||||||
# Internal apps
|
# Internal apps
|
||||||
"apps.core",
|
"apps.core",
|
||||||
"apps.user",
|
"apps.user",
|
||||||
@@ -450,65 +451,6 @@ INSTALLED_APPS = [
|
|||||||
"apps.achievement",
|
"apps.achievement",
|
||||||
]
|
]
|
||||||
|
|
||||||
# tinymce
|
|
||||||
TINYMCE_DEFAULT_CONFIG = {
|
|
||||||
"theme": "silver",
|
|
||||||
"height": 500,
|
|
||||||
"menubar": False,
|
|
||||||
"plugins": "advlist,autolink,lists,link,image,charmap,print,preview,anchor,"
|
|
||||||
"searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,paste,"
|
|
||||||
"code,help,wordcount,markdown",
|
|
||||||
"toolbar": "undo redo | formatselect | "
|
|
||||||
"bold italic backcolor | alignleft aligncenter "
|
|
||||||
"alignright alignjustify | bullist numlist outdent indent | "
|
|
||||||
"removeformat | help",
|
|
||||||
"skin": "oxide-dark",
|
|
||||||
"content_css": "dark",
|
|
||||||
"textpattern_patterns": [
|
|
||||||
{"start": "*", "end": "*", "format": "italic"},
|
|
||||||
{"start": "**", "end": "**", "format": "bold"},
|
|
||||||
{"start": "#", "format": "h1"},
|
|
||||||
{"start": "##", "format": "h2"},
|
|
||||||
{"start": "###", "format": "h3"},
|
|
||||||
{"start": "####", "format": "h4"},
|
|
||||||
{"start": "#####", "format": "h5"},
|
|
||||||
{"start": "######", "format": "h6"},
|
|
||||||
{"start": "1. ", "cmd": "InsertOrderedList"},
|
|
||||||
{"start": "* ", "cmd": "InsertUnorderedList"},
|
|
||||||
{"start": "- ", "cmd": "InsertUnorderedList"},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# martor
|
|
||||||
|
|
||||||
MARTOR_THEME = "bootstrap"
|
|
||||||
|
|
||||||
MARTOR_ENABLE_CONFIGS = {
|
|
||||||
"emoji": "true", # to enable/disable emoji icons.
|
|
||||||
"imgur": "true", # to enable/disable imgur/custom uploader.
|
|
||||||
"mention": "false", # to enable/disable mention
|
|
||||||
"jquery": "true", # to include/revoke jquery (require for admin default django)
|
|
||||||
"living": "false", # to enable/disable live updates in preview
|
|
||||||
"spellcheck": "false", # to enable/disable spellcheck in form textareas
|
|
||||||
"hljs": "true", # to enable/disable hljs highlighting in preview
|
|
||||||
}
|
|
||||||
|
|
||||||
MARTOR_TOOLBAR_BUTTONS = [
|
|
||||||
"bold",
|
|
||||||
"italic",
|
|
||||||
"horizontal",
|
|
||||||
"heading",
|
|
||||||
"pre-code",
|
|
||||||
"blockquote",
|
|
||||||
"unordered-list",
|
|
||||||
"ordered-list",
|
|
||||||
"link",
|
|
||||||
"emoji",
|
|
||||||
"direct-mention",
|
|
||||||
"toggle-maximize",
|
|
||||||
"help",
|
|
||||||
]
|
|
||||||
|
|
||||||
# GUID
|
# GUID
|
||||||
|
|
||||||
DJANGO_GUID = {
|
DJANGO_GUID = {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ urlpatterns = [
|
|||||||
path("tinymce/", include("tinymce.urls")),
|
path("tinymce/", include("tinymce.urls")),
|
||||||
# martor
|
# martor
|
||||||
path("martor/", include("martor.urls")),
|
path("martor/", include("martor.urls")),
|
||||||
|
# mdeditor
|
||||||
|
path(r'mdeditor/', include('mdeditor.urls')),
|
||||||
# Admin urls
|
# Admin urls
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
# API urls
|
# API urls
|
||||||
|
|||||||
@@ -12,14 +12,13 @@ dependencies = [
|
|||||||
"django-extensions>=3.2.3",
|
"django-extensions>=3.2.3",
|
||||||
"django-guid>=3.5.0",
|
"django-guid>=3.5.0",
|
||||||
"django-health-check>=3.18.3",
|
"django-health-check>=3.18.3",
|
||||||
|
"django-mdeditor>=0.1.20",
|
||||||
"django-minio-storage>=0.5.7",
|
"django-minio-storage>=0.5.7",
|
||||||
"django-ninja>=1.3.0",
|
"django-ninja>=1.3.0",
|
||||||
"django-pagedown>=2.2.1",
|
"django-pagedown>=2.2.1",
|
||||||
"django-stubs-ext>=5.1.3",
|
"django-stubs-ext>=5.1.3",
|
||||||
"django-tinymce>=4.1.0",
|
|
||||||
"gunicorn>=23.0.0",
|
"gunicorn>=23.0.0",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"martor>=1.6.45",
|
|
||||||
"pillow>=11.1.0",
|
"pillow>=11.1.0",
|
||||||
"psycopg2-binary>=2.9.10",
|
"psycopg2-binary>=2.9.10",
|
||||||
"pydantic>=2.10.5",
|
"pydantic>=2.10.5",
|
||||||
|
|||||||
+1
@@ -22,6 +22,7 @@ const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
|
|||||||
onSolutionSelect,
|
onSolutionSelect,
|
||||||
currentSolutionId
|
currentSolutionId
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<SheetContent className="w-[350px] sm:w-[450px] p-0">
|
<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 [selectedSolutionUrl, setSelectedSolutionUrl] = useState<string | null>(null);
|
||||||
const [currentSolution, setCurrentSolution] = useState<Solution | null>(null);
|
const [currentSolution, setCurrentSolution] = useState<Solution | null>(null);
|
||||||
const { id: competitionId } = useParams<{ id: string }>();
|
const { id: competitionId } = useParams<{ id: string }>();
|
||||||
const previousTaskIdRef = useRef<string | null>(null);
|
const taskIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const solutionsQuery = useQuery({
|
const solutionsQuery = useQuery({
|
||||||
queryKey: ['solutionHistory', competitionId, task.id],
|
queryKey: ['solutionHistory', competitionId, task.id],
|
||||||
@@ -41,36 +41,38 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const solutionHistory = solutionsQuery.data || [];
|
const solutionHistory = solutionsQuery.data || [];
|
||||||
|
// Handle task changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (solutionHistory.length > 0 && !currentSolution) {
|
if (taskIdRef.current !== task.id) {
|
||||||
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) {
|
|
||||||
setCurrentSolution(null);
|
setCurrentSolution(null);
|
||||||
setSelectedSolutionUrl(null);
|
setSelectedSolutionUrl(null);
|
||||||
|
|
||||||
setAnswer("");
|
setAnswer("");
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
|
taskIdRef.current = task.id;
|
||||||
|
|
||||||
if (solutionHistory.length > 0 && !solutionsQuery.isLoading) {
|
// Wait for the query to complete
|
||||||
setCurrentSolution(solutionHistory[solutionHistory.length - 1]);
|
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]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const loadSolutionContent = async () => {
|
const loadSolutionContent = async () => {
|
||||||
if (!currentSolution || !currentSolution.content) return;
|
if (!currentSolution || !currentSolution.content) return;
|
||||||
@@ -108,6 +110,10 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
|
|||||||
setSelectedSolutionUrl(null);
|
setSelectedSolutionUrl(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmitWrapper = () => {
|
||||||
|
onSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md:w-[500px] flex flex-col gap-4">
|
<div className="md:w-[500px] flex flex-col gap-4">
|
||||||
{currentSolution ? (
|
{currentSolution ? (
|
||||||
@@ -143,7 +149,7 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ActionButtons
|
<ActionButtons
|
||||||
onSubmit={onSubmit}
|
onSubmit={handleSubmitWrapper}
|
||||||
onHistoryClick={handleOpenHistory}
|
onHistoryClick={handleOpenHistory}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user