diff --git a/services/backend/api/v1/review/schemas.py b/services/backend/api/v1/review/schemas.py index 1b79004..97a9fd1 100644 --- a/services/backend/api/v1/review/schemas.py +++ b/services/backend/api/v1/review/schemas.py @@ -38,6 +38,7 @@ class SubmissionOut(ModelSchema): competition: UUID = Field(..., alias="task.competition.id") competition_name: str = Field(..., alias="task.competition.title") task_position: int = Field(..., alias="task.in_competition_position") + task_title: str = Field(..., alias="task.title") @staticmethod def resolve_criteries(self, context) -> list[CriteriaOut] | None: diff --git a/services/backend/api/v1/review/views.py b/services/backend/api/v1/review/views.py index 281358b..772ade8 100644 --- a/services/backend/api/v1/review/views.py +++ b/services/backend/api/v1/review/views.py @@ -1,5 +1,6 @@ from datetime import datetime from http import HTTPStatus as status +from statistics import median from uuid import UUID from django.http import HttpRequest @@ -84,15 +85,22 @@ def evaluate_submission( review.evaluation = evaluation review.state = ReviewStatusChoices.CHECKED.value review.submission.checked_at = datetime.now() - - points = 0 - for criterea in evaluation: - points += criterea["mark"] - review.submission.earned_points = ( - points # TODO: оценка не от последнего проверяющего а средняя по всем - ) review.save() + submission_evaluations = Review.objects.filter(submission=submission).values_list('evaluation', flat=True) + + marks = [] + for evaluation in submission_evaluations: + mark = 0 + for criterea in evaluation: + mark += criterea["mark"] + marks.append(mark) + earned_points = median(marks) + + review.submission.earned_points = ( + earned_points + ) + all_checked = not submission.reviews.exclude( state=ReviewStatusChoices.CHECKED ).exists() @@ -100,5 +108,6 @@ def evaluate_submission( review.submission.status = ( CompetitionTaskSubmission.StatusChoices.CHECKED.value ) - review.submission.save() + review.submission.save() + return status.OK, review.submission diff --git a/services/backend/api/v1/task/schemas.py b/services/backend/api/v1/task/schemas.py index 836643d..054a9b5 100644 --- a/services/backend/api/v1/task/schemas.py +++ b/services/backend/api/v1/task/schemas.py @@ -2,11 +2,20 @@ from typing import Literal from uuid import UUID from ninja import ModelSchema, Schema +from pydantic import Field from apps.task.models import CompetitionTask, CompetitionTaskSubmission, CompetitionTaskAttachment class TaskOutSchema(ModelSchema): + status: Literal["sent", "checked", "checking", "not_submitted"] = None + + @staticmethod + def resolve_status(self, context) -> Literal["sent", "checked", "checking", "not_submitted"]: + if submission := CompetitionTaskSubmission.objects.filter(task=self, user=context.get("request").auth).first(): + return submission.status + return "not_submitted" + class Meta: model = CompetitionTask fields = [ diff --git a/services/backend/apps/achievement/migrations/0002_achievement_need_count_achievement_type.py b/services/backend/apps/achievement/migrations/0002_achievement_need_count_achievement_type.py new file mode 100644 index 0000000..e16f3b6 --- /dev/null +++ b/services/backend/apps/achievement/migrations/0002_achievement_need_count_achievement_type.py @@ -0,0 +1,23 @@ +# 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='тип'), + ), + ] diff --git a/services/backend/apps/achievement/models.py b/services/backend/apps/achievement/models.py index 1e0460a..7bb796d 100644 --- a/services/backend/apps/achievement/models.py +++ b/services/backend/apps/achievement/models.py @@ -3,6 +3,9 @@ from django.db import models 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" @@ -14,6 +17,19 @@ class Achievement(BaseModel): upload_to=image_url_upload_to, ) + type = models.CharField( + max_length=20, + choices=AchievementType.choices, + verbose_name="тип", + help_text="За какой тип достижений будет выдаваться ачивка", + default=AchievementType.CORRECT_TASKS, + ) + need_count = models.IntegerField( + verbose_name="кол-во того, что нужно для получения ачивки", + help_text="Здесь нужно указать количество действий, необходимое для получения ачивок. Например, если вы указали в предыдущем пункте \"Выполненные задания\" а тут 5, то ачивка будет выдаваться за 5 решенных заданий", + default=5 + ) + def __str__(self): return self.name diff --git a/services/backend/apps/core/management/commands/generate_data.py b/services/backend/apps/core/management/commands/generate_data.py index 8781590..754f5e0 100644 --- a/services/backend/apps/core/management/commands/generate_data.py +++ b/services/backend/apps/core/management/commands/generate_data.py @@ -105,6 +105,7 @@ class Command(BaseCommand): description=description, type=task_type, points=random.randint(1, 10), + submission_reviewers_count=random.randint(2, 10), max_attempts=random.randint(1, 10), ) tasks.append(task) diff --git a/services/backend/apps/task/migrations/0004_competitiontask_submission_reviewers_count_and_more.py b/services/backend/apps/task/migrations/0004_competitiontask_submission_reviewers_count_and_more.py new file mode 100644 index 0000000..400255c --- /dev/null +++ b/services/backend/apps/task/migrations/0004_competitiontask_submission_reviewers_count_and_more.py @@ -0,0 +1,24 @@ +# 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='описание'), + ), + ] diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index 28cf455..58d26bc 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -3,6 +3,7 @@ from uuid import uuid4 from django.db import models from django.db.models import Count, Q from tinymce.models import HTMLField +from martor.models import MartorField from apps.competition.models import Competition from apps.core.models import BaseModel @@ -24,7 +25,7 @@ class CompetitionTask(BaseModel): ) competition = models.ForeignKey(Competition, on_delete=models.CASCADE) title = models.CharField(verbose_name="заголовок", max_length=50) - description = HTMLField(verbose_name="описание") + description = MartorField(verbose_name="описание") max_attempts = models.PositiveSmallIntegerField(null=True, blank=True) type = models.CharField( choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки" @@ -57,6 +58,7 @@ class CompetitionTask(BaseModel): verbose_name="ревьюверы", help_text="Справа отображаются действующие проверяющие, слева - доступные для выбора. Для перемещения можно кликнуть 2 раза по проверяющему" ) + submission_reviewers_count = models.PositiveSmallIntegerField(default=1, null=True, blank=True) def __str__(self): return self.title @@ -152,8 +154,8 @@ class CompetitionTaskSubmission(BaseModel): if not self.task.reviewers.exists(): return - reviewer = ( - self.task.reviewers.annotate( + reviewers_count = self.task.submission_reviewers_count + reviewers = self.task.reviewers.annotate( pending_count=Count( "review", filter=Q( @@ -163,11 +165,10 @@ class CompetitionTaskSubmission(BaseModel): ] ), ) + ).order_by("pending_count")[:reviewers_count] # да это медленно работает и чо + + for reviewer in reviewers: + Review.objects.create( + reviewer=reviewer, + submission=self, ) - .order_by("pending_count") - .first() - ) - review = Review.objects.create( - reviewer=reviewer, - submission=self, - ) diff --git a/services/backend/config/settings.py b/services/backend/config/settings.py index 63bc06a..266990b 100644 --- a/services/backend/config/settings.py +++ b/services/backend/config/settings.py @@ -442,6 +442,7 @@ INSTALLED_APPS = [ "ninja", "minio_storage", "tinymce", + "martor", # Internal apps "apps.core", "apps.user", @@ -459,15 +460,49 @@ TINYMCE_DEFAULT_CONFIG = { "menubar": False, "plugins": "advlist,autolink,lists,link,image,charmap,print,preview,anchor," "searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,paste," - "code,help,wordcount", + "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 = { diff --git a/services/backend/config/urls.py b/services/backend/config/urls.py index 27b279a..4e50a9a 100644 --- a/services/backend/config/urls.py +++ b/services/backend/config/urls.py @@ -14,6 +14,8 @@ admin.site.index_title = "DataRush" urlpatterns = [ # tinymce path("tinymce/", include("tinymce.urls")), + # martor + path('martor/', include('martor.urls')), # Admin urls path("admin/", admin.site.urls), # API urls diff --git a/services/backend/pyproject.toml b/services/backend/pyproject.toml index 20b218e..d3b2371 100644 --- a/services/backend/pyproject.toml +++ b/services/backend/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "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",