Merge branch 'master' of gitlab.prodcontest.ru:team-15/project

This commit is contained in:
moolcoov
2025-03-02 13:55:59 +03:00
79 changed files with 8311 additions and 661 deletions
+1 -1
View File
@@ -253,7 +253,7 @@ services:
- name: web - name: web
target: 80 target: 80
published: 8003 published: 8003
host_ip: 127.0.0.1 host_ip: 0.0.0.0
protocol: tcp protocol: tcp
restart: unless-stopped restart: unless-stopped
secrets: secrets:
+3 -1
View File
@@ -2,4 +2,6 @@
sidebar_position: 1 sidebar_position: 1
--- ---
# Начала! # Начало!
Выбирай интересующий раздел слева и просвещайся!
+66 -62
View File
@@ -1,77 +1,81 @@
import {themes as prismThemes} from 'prism-react-renderer'; import { themes as prismThemes } from "prism-react-renderer";
import type {Config} from '@docusaurus/types'; import type { Config } from "@docusaurus/types";
import type * as Preset from '@docusaurus/preset-classic'; import type * as Preset from "@docusaurus/preset-classic";
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
const config: Config = { const config: Config = {
title: 'DataRush', title: "DataRush",
tagline: 'Изучите основы анализа данных здесь!', tagline: "Изучите основы анализа данных здесь!",
favicon: 'https://prod-team-15-2pc0i3lc.final.prodcontest.ru/dr.svg', favicon: "https://prod-team-15-2pc0i3lc.final.prodcontest.ru/dr.svg",
url: 'https://prod-team-15-2pc0i3lc.final.prodcontest.ru', url: "https://prod-team-15-2pc0i3lc.final.prodcontest.ru",
baseUrl: '/docs/', baseUrl: "/docs/",
organizationName: 'megazord', organizationName: "megazord",
projectName: 'megazord', projectName: "megazord",
onBrokenLinks: 'throw', onBrokenLinks: "throw",
onBrokenMarkdownLinks: 'warn', onBrokenMarkdownLinks: "warn",
i18n: { i18n: {
defaultLocale: 'ru', defaultLocale: "ru",
locales: ['ru'], locales: ["ru"],
},
presets: [
[
'classic',
{
docs: {},
theme: {
customCss: './src/css/custom.css',
},
} satisfies Preset.Options,
],
],
themeConfig: {
image: 'https://prod-team-15-2pc0i3lc.final.prodcontest.ru/dr.svg',
navbar: {
title: 'DataRush',
logo: {
alt: 'DataRush',
src: 'https://prod-team-15-2pc0i3lc.final.prodcontest.ru/dr.svg',
},
items: [
{
type: 'docSidebar',
sidebarId: 'defaultSidebar',
position: 'left',
label: 'Документация',
},
],
}, },
footer: {
style: 'dark', presets: [
links: [ [
{ "classic",
title: 'Документация',
items: [
{ {
label: 'Начало', docs: {
to: '/docs/docs/intro', routeBasePath: "/",
},
theme: {
customCss: "./src/css/custom.css",
},
} satisfies Preset.Options,
],
],
themeConfig: {
image: "https://prod-team-15-2pc0i3lc.final.prodcontest.ru/dr.svg",
navbar: {
title: "DataRush",
logo: {
alt: "DataRush",
src: "https://prod-team-15-2pc0i3lc.final.prodcontest.ru/dr.svg",
}, },
], items: [
{
type: "docSidebar",
sidebarId: "defaultSidebar",
position: "left",
label: "Документация",
},
],
}, },
], footer: {
copyright: `Создано для Megazord ♥`, style: "dark",
}, links: [
prism: { {
theme: prismThemes.github, title: "Документация",
darkTheme: prismThemes.dracula, items: [
}, {
} satisfies Preset.ThemeConfig, label: "Начало",
to: "/intro",
},
],
},
],
copyright: `Создано для Megazord ♥`,
},
prism: {
theme: prismThemes.github,
darkTheme: prismThemes.dracula,
},
} satisfies Preset.ThemeConfig,
staticDirectories: ["static"],
}; };
export default config; export default config;
+1 -1
View File
@@ -12,7 +12,7 @@ DJANGO_DB_URI=postgresql://postgres:postgres@postgres/postgres
DJANGO_CREATE_SUPERUSER=True DJANGO_CREATE_SUPERUSER=True
DJANGO_SUPERUSER_USERNAME=admin DJANGO_SUPERUSER_USERNAME=admin
DJANGO_SUPERUSER_EMAIL=admin@mail.com DJANGO_SUPERUSER_EMAIL=admin@mail.com
DJANGO_SUPERUSER_PASSWORD=admin DJANGO_SUPERUSER_PASSWORD=prooooooood
MINIO_ENDPOINT=minio:9000 MINIO_ENDPOINT=minio:9000
MINIO_CUSTOM_ENDPOINT_URL=https://prod-team-15-minio-2pc0i3lc.final.prodcontest.ru MINIO_CUSTOM_ENDPOINT_URL=https://prod-team-15-minio-2pc0i3lc.final.prodcontest.ru
+1 -1
View File
@@ -1 +1 @@
admin J2NofXLJa57mpHVQVdNFaltSmg9gjI
@@ -8,6 +8,19 @@ from apps.competition.models import Competition, State
class CompetitionOut(ModelSchema): class CompetitionOut(ModelSchema):
id: UUID id: UUID
state: Literal["not_started", "started", "finished"]
@staticmethod
def resolve_state(
self, context
) -> Literal["not_started", "started", "finished"]:
if not (
state := State.objects.filter(
user=context.get("request").auth, competition=self
).first()
):
return "not_started"
return state.state
class Meta: class Meta:
model = Competition model = Competition
+72 -11
View File
@@ -1,16 +1,14 @@
from datetime import datetime
from typing import Literal from typing import Literal
from uuid import UUID from uuid import UUID
from ninja import ModelSchema, Schema from ninja import ModelSchema, Schema
from pydantic import Field
from apps.review.models import Review, Reviewer from apps.review.models import Review, Reviewer, ReviewStatusChoices
from apps.task.models import CompetitionTaskSubmission from apps.task.models import CompetitionTaskSubmission
class PingOut(Schema):
status: str = "ok"
class ReviewerOut(ModelSchema): class ReviewerOut(ModelSchema):
id: UUID id: UUID
@@ -19,20 +17,83 @@ class ReviewerOut(ModelSchema):
exclude = ("token",) exclude = ("token",)
class CriteriaMarkOut(Schema):
slug: str
mark: float
class CriteriaOut(Schema):
name: str
slug: str
max_value: int
min_value: int
class SubmissionOut(ModelSchema): class SubmissionOut(ModelSchema):
id: UUID id: UUID
status: Literal["sent", "checking", "checked"] review_status: Literal["not_checked", "checked", "checking"]
evaluation: list[CriteriaMarkOut] | None = None
criteries: list[CriteriaOut] | None = None
submitted_at: datetime = Field(..., alias="timestamp")
competition: UUID = Field(..., alias="task.competition.id")
competition_name: str = Field(..., alias="task.competition.title")
@staticmethod
def resolve_criteries(self, context) -> list[CriteriaOut] | None:
criteries = self.task.criteries
return criteries
@staticmethod
def resolve_evaluation(self, context) -> list[CriteriaMarkOut] | None:
if not (
review := Review.objects.filter(
reviewer=context.get("request").auth, submission=self
).first()
):
return None
return review.evaluation
@staticmethod
def resolve_review_status(self, context):
reviewer = context.get("request").auth
if not (
review := Review.objects.filter(
reviewer=reviewer, submission=self
).first()
):
return ReviewStatusChoices.NOT_CHECKED.value
return review.state
class Meta: class Meta:
model = CompetitionTaskSubmission model = CompetitionTaskSubmission
exclude = ("user",) fields = (
"id",
"task",
"content",
"stdout",
"result",
"earned_points",
"checked_at",
)
class CriteriaMarkIn(Schema):
slug: str
mark: float
class EvaluationIn(Schema):
evaluation: list[CriteriaMarkIn]
class SubmissionsOut(Schema): class SubmissionsOut(Schema):
submissions: list = None submissions: list[SubmissionOut | None] = []
@staticmethod @staticmethod
def resolve_submissions(self, context) -> list[SubmissionOut]: def resolve_submissions(self, context) -> list[SubmissionOut | None]:
return list( submissions = list(
Review.objects.filter(reviewer=context.get("request").auth) CompetitionTaskSubmission.objects.filter(
reviews__reviewer=context.get("request").auth
)
) )
return submissions
+68 -14
View File
@@ -1,3 +1,4 @@
from datetime import datetime
from http import HTTPStatus as status from http import HTTPStatus as status
from uuid import UUID from uuid import UUID
@@ -7,31 +8,19 @@ from ninja import Router
from api.v1 import schemas as global_schemas from api.v1 import schemas as global_schemas
from api.v1.review import schemas from api.v1.review import schemas
from apps.review.models import Review, ReviewStatusChoices
from apps.task.models import CompetitionTaskSubmission from apps.task.models import CompetitionTaskSubmission
router = Router(tags=["review"]) router = Router(tags=["review"])
@router.get(
"{token}/submissions",
response={
status.OK: schemas.SubmissionsOut,
},
description="Список отправок, на проверку которых назначен ревьюер",
)
def get_submissions(
request: HttpRequest, token: str
) -> tuple[status, schemas.SubmissionsOut]:
return status.OK, schemas.SubmissionsOut()
@router.get( @router.get(
"{token}", "{token}",
response={ response={
status.OK: schemas.ReviewerOut, status.OK: schemas.ReviewerOut,
status.UNAUTHORIZED: global_schemas.UnauthorizedError, status.UNAUTHORIZED: global_schemas.UnauthorizedError,
}, },
description="token есть и в сваггер авторизации, но оно не работает, не верьте. подставляйте токен вручную в query", description="token есть и в сваггер авторизации, но оно не работает, не верьте. подставляйте токен вручную в path",
) )
def get_reviewer_profile(request: HttpRequest, token: str): def get_reviewer_profile(request: HttpRequest, token: str):
return status.OK, request.auth return status.OK, request.auth
@@ -47,4 +36,69 @@ def get_submission(
request: HttpRequest, token: str, submition_id: UUID request: HttpRequest, token: str, submition_id: UUID
) -> tuple[status, schemas.SubmissionsOut]: ) -> tuple[status, schemas.SubmissionsOut]:
submission = get_object_or_404(CompetitionTaskSubmission, id=submition_id) submission = get_object_or_404(CompetitionTaskSubmission, id=submition_id)
reviewer = request.auth
review = Review.objects.get(reviewer=reviewer, submission=submission)
if review.state == ReviewStatusChoices.NOT_CHECKED.value:
review.state = ReviewStatusChoices.CHECKING.value
review.submission.state = (
CompetitionTaskSubmission.StatusChoices.CHECKING.value
)
review.save()
review.submission.save()
return status.OK, submission return status.OK, submission
@router.get(
"{token}/submissions",
response={
status.OK: schemas.SubmissionsOut,
},
description="Список отправок, на проверку которых назначен ревьюер",
)
def get_submissions(
request: HttpRequest, token: str
) -> tuple[status, schemas.SubmissionsOut]:
return status.OK, schemas.SubmissionsOut()
@router.post(
"{token}/submissions/{submition_id}/evaluate",
response={
status.OK: schemas.SubmissionOut,
},
description="Оценка посылки. В body отправляется список с slug критерия и оценкой по этому критерию",
)
def evaluate_submission(
request: HttpRequest,
token: str,
submition_id: UUID,
evaluation_info: schemas.EvaluationIn,
) -> tuple[status, schemas.SubmissionsOut]:
submission = get_object_or_404(CompetitionTaskSubmission, id=submition_id)
reviewer = request.auth
review = Review.objects.get(reviewer=reviewer, submission=submission)
evaluation = evaluation_info.dict()["evaluation"]
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()
all_checked = not submission.reviews.exclude(
state=ReviewStatusChoices.CHECKED
).exists()
if all_checked:
review.submission.status = (
CompetitionTaskSubmission.StatusChoices.CHECKED.value
)
review.submission.save()
return status.OK, review.submission
+12
View File
@@ -8,6 +8,8 @@ from api.v1.competition.views import router as competition_router
from api.v1.ping.views import router as ping_router from api.v1.ping.views import router as ping_router
from api.v1.review.auth import ReviewerAuth from api.v1.review.auth import ReviewerAuth
from api.v1.review.views import router as review_router from api.v1.review.views import router as review_router
from api.v1.task.views import router as task_router
from api.v1.team.views import router as team_router
from api.v1.user.views import router as user_router from api.v1.user.views import router as user_router
router = NinjaAPI( router = NinjaAPI(
@@ -37,6 +39,16 @@ router.add_router(
review_router, review_router,
auth=ReviewerAuth(), auth=ReviewerAuth(),
) )
router.add_router(
"",
task_router,
auth=BearerAuth(),
)
router.add_router(
"team",
team_router,
auth=BearerAuth(),
)
for exception, handler in handlers.exception_handlers: for exception, handler in handlers.exception_handlers:
+24 -7
View File
@@ -3,19 +3,36 @@ from uuid import UUID
from ninja import ModelSchema, Schema from ninja import ModelSchema, Schema
from apps.task.models import CompetitionTask from apps.task.models import CompetitionTask, CompetitionTaskSubmission, CompetitionTaskAttachment
class TaskOutSchema(ModelSchema): class TaskOutSchema(ModelSchema):
class Meta: class Meta:
model = CompetitionTask model = CompetitionTask
fields = ["id", "competition", "title", "description", "type"] fields = [
"id",
"competition",
class TaskSubmissionIn(Schema): "title",
type: Literal["input", "file", "code"] "description",
content: str "type",
"in_competition_position",
"points",
]
class TaskSubmissionOut(Schema): class TaskSubmissionOut(Schema):
submission_id: UUID submission_id: UUID
class HistorySubmissionOut(ModelSchema):
status: Literal["sent", "checked", "checking"]
class Meta:
model = CompetitionTaskSubmission
fields = ("id", "earned_points", "timestamp", "content",)
class TaskAttachmentSchema(ModelSchema):
class Meta:
model = CompetitionTaskAttachment
fields = ("id", "file", "public",)
+52 -11
View File
@@ -2,19 +2,21 @@ from http import HTTPStatus as status
from uuid import UUID from uuid import UUID
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from ninja import Router from ninja import File, Router, UploadedFile
from api.v1.ping.schemas import PingOut from api.v1.ping.schemas import PingOut
from api.v1.schemas import ForbiddenError, NotFoundError, UnauthorizedError from api.v1.schemas import ForbiddenError, NotFoundError, UnauthorizedError
from api.v1.task.schemas import ( from api.v1.task.schemas import (
HistorySubmissionOut,
TaskAttachmentSchema,
TaskOutSchema, TaskOutSchema,
TaskSubmissionIn,
TaskSubmissionOut, TaskSubmissionOut,
) )
from apps.competition.models import State from apps.competition.models import State
from apps.task.models import ( from apps.task.models import (
Competition, Competition,
CompetitionTask, CompetitionTask,
CompetitionTaskAttachment,
CompetitionTaskSubmission, CompetitionTaskSubmission,
) )
@@ -87,32 +89,71 @@ def get_task(request, competition_id: str, task_id: str) -> TaskOutSchema: ...
}, },
) )
def submit_task( def submit_task(
request, competition_id: str, task_id: str, submission: TaskSubmissionIn request,
) -> PingOut: competition_id: str,
task_id: str,
content: UploadedFile = File(...), # TODO: вот это надо переделать
) -> TaskSubmissionOut:
user = request.auth user = request.auth
competetion = get_object_or_404(Competition, id=competition_id) competition = get_object_or_404(Competition, id=competition_id)
task = get_object_or_404( task = get_object_or_404(
CompetitionTask, competetion=competetion, id=task_id CompetitionTask, competition=competition, id=task_id
) )
if task.type == CompetitionTask.CompetitionTaskType.INPUT: if task.type == CompetitionTask.CompetitionTaskType.INPUT:
CompetitionTaskSubmission.objects.create( submission = CompetitionTaskSubmission.objects.create(
user=user, user=user,
task=task, task=task,
status=CompetitionTaskSubmission.StatusChoices.CHECKED, status=CompetitionTaskSubmission.StatusChoices.CHECKED,
result={"correct": submission.content == task.answer_file_path}, result={"correct": content == task.answer_file_path},
content=content,
) )
if task.type == CompetitionTask.CompetitionTaskType.REVIEW: if task.type == CompetitionTask.CompetitionTaskType.REVIEW:
CompetitionTaskSubmission.objects.create( submission = CompetitionTaskSubmission.objects.create(
user=user, user=user,
task=task, task=task,
status=CompetitionTaskSubmission.StatusChoices.SENT, status=CompetitionTaskSubmission.StatusChoices.SENT,
content=content,
) )
submission.send_on_review()
if task.type == CompetitionTask.CompetitionTaskType.CHECKER: if task.type == CompetitionTask.CompetitionTaskType.CHECKER:
CompetitionTaskSubmission.objects.create( submission = CompetitionTaskSubmission.objects.create(
user=user, user=user,
task=task, task=task,
status=CompetitionTaskSubmission.StatusChoices.CHECKING, status=CompetitionTaskSubmission.StatusChoices.CHECKING,
content=content,
) )
return TaskSubmissionOut(id=CompetitionTaskSubmission.id) return TaskSubmissionOut(submission_id=submission.id)
@router.get(
"competitions/{competition_id}/tasks/{task_id}/history",
response={
status.OK: list[HistorySubmissionOut],
status.UNAUTHORIZED: UnauthorizedError,
},
)
def get_submissions_history(request, competition_id: UUID, task_id: UUID):
task = get_object_or_404(
CompetitionTask, competition_id=competition_id, id=task_id
)
submissions_history = CompetitionTaskSubmission.objects.filter(
task=task, user=request.auth
)
return status.OK, submissions_history
@router.get(
"competitions/{competition_id}/tasks/{task_id}/attachments",
response={
status.OK: list[TaskAttachmentSchema],
status.UNAUTHORIZED: UnauthorizedError,
},
)
def get_task_attachments(request, competition_id: UUID, task_id: UUID):
task = get_object_or_404(CompetitionTask, id=task_id)
return status.OK, CompetitionTaskAttachment.objects.filter(
competition_id=competition_id, task=task, user=request.auth
)
+23
View File
@@ -0,0 +1,23 @@
from ninja import ModelSchema
from apps.team.models import Team
class CreateTeamSchema(ModelSchema):
class Meta:
model = Team
fields = (
"name",
"members",
)
class TeamSchemaOut(ModelSchema):
class Meta:
model = Team
fields = (
"id",
"name",
"owner",
"members",
)
+38
View File
@@ -0,0 +1,38 @@
from uuid import UUID
from django.shortcuts import get_object_or_404
from ninja import Router
from api.v1.schemas import BadRequestError, NotFoundError, UnauthorizedError
from api.v1.team.schemas import CreateTeamSchema, TeamSchemaOut
from apps.team.models import Team
router = Router()
@router.post(
"",
response={
201: TeamSchemaOut,
400: BadRequestError,
401: UnauthorizedError,
},
description="Create team. Note: members array must have team members uuid, default can be empty",
)
def create_team(request, team_data: CreateTeamSchema) -> (int, TeamSchemaOut):
team = Team(name=team_data.name, owner=request.auth)
team.members.add(request.auth)
team.save()
return 201, team
@router.get(
"{team_id}",
response={
200: TeamSchemaOut,
401: UnauthorizedError,
404: NotFoundError,
},
)
def get_team(request, team_id: UUID) -> (int, TeamSchemaOut):
return get_object_or_404(Team, pk=team_id)
+1 -1
View File
@@ -22,4 +22,4 @@ class LoginSchema(ModelSchema):
class UserSchema(ModelSchema): class UserSchema(ModelSchema):
class Meta: class Meta:
model = User model = User
fields = ["id", "email", "username"] fields = ["id", "email", "username", "created_at",]
+11 -3
View File
@@ -1,12 +1,18 @@
from datetime import datetime
from http import HTTPStatus as status from http import HTTPStatus as status
from django.contrib.auth.hashers import check_password from django.contrib.auth.hashers import check_password, make_password
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from ninja import Router from ninja import Router
from ninja.errors import AuthenticationError from ninja.errors import AuthenticationError
from api.v1.auth import BearerAuth from api.v1.auth import BearerAuth
from api.v1.schemas import BadRequestError, ForbiddenError, NotFoundError, ConflictError from api.v1.schemas import (
BadRequestError,
ConflictError,
ForbiddenError,
NotFoundError,
)
from api.v1.user.schemas import ( from api.v1.user.schemas import (
LoginSchema, LoginSchema,
RegisterSchema, RegisterSchema,
@@ -28,7 +34,9 @@ router = Router(tags=["user"])
auth=None, auth=None,
) )
def sign_up(request, data: RegisterSchema): def sign_up(request, data: RegisterSchema):
user = User(**data.dict()) user = User(**data.dict(exclude={"password"}))
user.password = make_password(data.password)
user.created_at = datetime.now()
user.save() user.save()
token = BearerAuth.generate_jwt(user) token = BearerAuth.generate_jwt(user)
@@ -1,5 +1,6 @@
# Generated by Django 5.1.6 on 2025-03-01 10:26 # Generated by Django 5.1.6 on 2025-03-02 10:28
import apps.competition.models
import datetime import datetime
import django.db.models.deletion import django.db.models.deletion
import uuid import uuid
@@ -19,14 +20,14 @@ class Migration(migrations.Migration):
name='Competition', name='Competition',
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=100, verbose_name='Название')), ('title', models.CharField(max_length=100, verbose_name='название')),
('description', models.TextField(verbose_name='Описание')), ('description', models.TextField(verbose_name='описание')),
('image_url', models.FileField(blank=True, null=True, upload_to='', verbose_name='Изображение соревнования')), ('image_url', models.ImageField(blank=True, null=True, upload_to=apps.competition.models.Competition.image_url_upload_to, verbose_name='изображение соревнования')),
('end_date', models.DateTimeField(blank=True, null=True, verbose_name='Дедлайн участия')), ('end_date', models.DateTimeField(blank=True, null=True, verbose_name='окончание соревнования')),
('start_date', models.DateTimeField(blank=True, null=True, verbose_name='Дедлайн участия')), ('start_date', models.DateTimeField(blank=True, null=True, verbose_name='начало соревнования')),
('type', models.CharField(choices=[('solo', 'Solo')], max_length=10, verbose_name='Тип участия')), ('type', models.CharField(choices=[('edu', 'Образовательный'), ('competitive', 'Соревновательный')], max_length=11, verbose_name='тип участия')),
('participation_type', models.CharField(choices=[('edu', 'Edu'), ('competitive', 'Competitive')], max_length=11, verbose_name='Тип соревнования')), ('participation_type', models.CharField(choices=[('solo', 'Индивидуальный')], max_length=11, verbose_name='тип соревнования')),
('participants', models.ManyToManyField(related_name='participants', to='user.user')), ('participants', models.ManyToManyField(blank=True, editable=False, related_name='participants', to='user.user')),
], ],
options={ options={
'verbose_name': 'соревнование', 'verbose_name': 'соревнование',
@@ -37,7 +38,7 @@ class Migration(migrations.Migration):
name='State', name='State',
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('state', models.CharField(choices=[('not_started', 'Not Started'), ('started', 'Started'), ('finished', 'Finished')], max_length=11)), ('state', models.CharField(choices=[('not_started', 'Not Started'), ('started', 'Started'), ('finished', 'Finished')], default='not_started', max_length=11)),
('changed_at', models.DateTimeField(default=datetime.datetime.now)), ('changed_at', models.DateTimeField(default=datetime.datetime.now)),
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')), ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')),
@@ -1,35 +0,0 @@
# Generated by Django 5.1.6 on 2025-03-01 12:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('competition', '0001_initial'),
('task', '0001_initial'),
('user', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='competition',
name='tasks',
field=models.ManyToManyField(blank=True, related_name='tasks', to='task.competitiontask'),
),
migrations.AlterField(
model_name='competition',
name='participants',
field=models.ManyToManyField(blank=True, editable=False, related_name='participants', to='user.user'),
),
migrations.AlterField(
model_name='competition',
name='participation_type',
field=models.CharField(choices=[('edu', 'Образовательный'), ('competitive', 'Соревновательный')], max_length=11, verbose_name='Тип соревнования'),
),
migrations.AlterField(
model_name='competition',
name='type',
field=models.CharField(choices=[('solo', 'Индивидуальный')], max_length=10, verbose_name='Тип участия'),
),
]
@@ -1,17 +0,0 @@
# Generated by Django 5.1.6 on 2025-03-01 13:49
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('competition', '0002_competition_tasks_alter_competition_participants_and_more'),
]
operations = [
migrations.RemoveField(
model_name='competition',
name='tasks',
),
]
@@ -1,49 +0,0 @@
# Generated by Django 5.1.6 on 2025-03-01 14:46
import tinymce.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('competition', '0003_remove_competition_tasks'),
]
operations = [
migrations.AlterField(
model_name='competition',
name='description',
field=tinymce.models.HTMLField(verbose_name='описание'),
),
migrations.AlterField(
model_name='competition',
name='end_date',
field=models.DateTimeField(blank=True, null=True, verbose_name='дедлайн участия'),
),
migrations.AlterField(
model_name='competition',
name='image_url',
field=models.FileField(blank=True, null=True, upload_to='', verbose_name='изображение соревнования'),
),
migrations.AlterField(
model_name='competition',
name='participation_type',
field=models.CharField(choices=[('edu', 'Образовательный'), ('competitive', 'Соревновательный')], max_length=11, verbose_name='тип соревнования'),
),
migrations.AlterField(
model_name='competition',
name='start_date',
field=models.DateTimeField(blank=True, null=True, verbose_name='дедлайн участия'),
),
migrations.AlterField(
model_name='competition',
name='title',
field=models.CharField(max_length=100, verbose_name='аазвание'),
),
migrations.AlterField(
model_name='competition',
name='type',
field=models.CharField(choices=[('solo', 'Индивидуальный')], max_length=10, verbose_name='тип участия'),
),
]
+12 -8
View File
@@ -8,31 +8,31 @@ from apps.user.models import User
class Competition(BaseModel): class Competition(BaseModel):
class CompetitionType(models.TextChoices): class CompetitionType(models.TextChoices):
SOLO = "solo", "Индивидуальный"
class CompetitionParticipationType(models.TextChoices):
EDU = "edu", "Образовательный" EDU = "edu", "Образовательный"
COMPETITIVE = "competitive", "Соревновательный" COMPETITIVE = "competitive", "Соревновательный"
class CompetitionParticipationType(models.TextChoices):
SOLO = "solo", "Индивидуальный"
def image_url_upload_to(instance, filename): def image_url_upload_to(instance, filename):
return f"/competitions/{instance.id}/image" return f"/competitions/{instance.id}/image"
title = models.CharField(max_length=100, verbose_name="название") title = models.CharField(max_length=100, verbose_name="название")
description = models.TextField(verbose_name="описание") description = models.TextField(verbose_name="описание")
image_url = models.FileField( image_url = models.ImageField(
verbose_name="изображение соревнования", verbose_name="изображение соревнования",
null=True, null=True,
blank=True, blank=True,
upload_to=image_url_upload_to, upload_to=image_url_upload_to,
) )
end_date = models.DateTimeField( end_date = models.DateTimeField(
verbose_name="дедлайн участия", null=True, blank=True verbose_name="окончание соревнования", null=True, blank=True
) )
start_date = models.DateTimeField( start_date = models.DateTimeField(
verbose_name="дедлайн участия", null=True, blank=True verbose_name="начало соревнования", null=True, blank=True
) )
type = models.CharField( type = models.CharField(
max_length=10, max_length=11,
choices=CompetitionType.choices, choices=CompetitionType.choices,
verbose_name="тип участия", verbose_name="тип участия",
) )
@@ -61,5 +61,9 @@ class State(BaseModel):
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
competition = models.ForeignKey(Competition, on_delete=models.CASCADE) competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
state = models.CharField(choices=StateChoices.choices, max_length=11) state = models.CharField(
choices=StateChoices.choices,
max_length=11,
default=StateChoices.NOT_STARTED.value,
)
changed_at = models.DateTimeField(default=datetime.now) changed_at = models.DateTimeField(default=datetime.now)
+154 -27
View File
@@ -1,5 +1,8 @@
import uuid import uuid
from datetime import timedelta, datetime, tzinfo
from dateutil.parser import isoparse
import pytz
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.test import TestCase from django.test import TestCase
@@ -12,14 +15,14 @@ class CompetitionEndpointTests(TestCase):
self.user = User.objects.create( self.user = User.objects.create(
email="user@example.com", email="user@example.com",
password=make_password("password123"), password=make_password("password123"),
username="t1wk4" username="t1wk4",
) )
self.competition = Competition.objects.create( self.competition = Competition.objects.create(
title="AI Challenge", title="AI Challenge",
description="Machine Learning Competition", description="Machine Learning Competition",
type="solo", type="edu",
participation_type="edu" participation_type="solo",
) )
resp = self.client.post( resp = self.client.post(
@@ -29,80 +32,204 @@ class CompetitionEndpointTests(TestCase):
).json() ).json()
token = resp["token"] token = resp["token"]
self.valid_headers = { self.valid_headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"}
"HTTP_AUTHORIZATION": f"Bearer {token}"
}
# --- Helper methods ---
def get_url(self, competition_id): def get_url(self, competition_id):
return f"/api/v1/competition/{competition_id}" return f"/api/v1/competition/{competition_id}"
# --- Test Cases ---
def test_get_competition_success(self): def test_get_competition_success(self):
"""Authenticated user gets competition details (200 OK)"""
response = self.client.get( response = self.client.get(
self.get_url(self.competition.id), self.get_url(self.competition.id), **self.valid_headers
**self.valid_headers
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = response.json() data = response.json()
# Validate required fields
self.assertEqual(data["id"], str(self.competition.id)) self.assertEqual(data["id"], str(self.competition.id))
self.assertEqual(data["title"], "AI Challenge") self.assertEqual(data["title"], "AI Challenge")
self.assertEqual(data["type"], "solo") self.assertEqual(data["type"], "edu")
# Validate optional null fields
self.assertIsNone(data["image_url"]) self.assertIsNone(data["image_url"])
self.assertIsNone(data["start_date"]) self.assertIsNone(data["start_date"])
self.assertIsNone(data["end_date"]) self.assertIsNone(data["end_date"])
def test_invalid_uuid_format(self): def test_invalid_uuid_format(self):
"""Invalid UUID format returns 400 Bad Request"""
response = self.client.get( response = self.client.get(
self.get_url("invalid-id"), self.get_url("invalid-id"), **self.valid_headers
**self.valid_headers
) )
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_unauthenticated_access(self): def test_unauthenticated_access(self):
"""Missing auth token returns 401 Unauthorized"""
response = self.client.get(self.get_url(self.competition.id)) response = self.client.get(self.get_url(self.competition.id))
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Unauthorized") self.assertEqual(response.json()["detail"], "Unauthorized")
def test_nonexistent_competition(self): def test_nonexistent_competition(self):
"""Valid UUID but missing competition returns 404"""
new_uuid = uuid.uuid4() new_uuid = uuid.uuid4()
response = self.client.get( response = self.client.get(
self.get_url(new_uuid), self.get_url(new_uuid), **self.valid_headers
**self.valid_headers
) )
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["detail"], "Not Found") self.assertEqual(response.json()["detail"], "Not Found")
def test_invalid_auth_token(self): def test_invalid_auth_token(self):
"""Invalid token returns 401 Unauthorized"""
response = self.client.get( response = self.client.get(
self.get_url(self.competition.id), self.get_url(self.competition.id),
HTTP_AUTHORIZATION="Bearer invalid_token" HTTP_AUTHORIZATION="Bearer invalid_token",
) )
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Unauthorized") self.assertEqual(response.json()["detail"], "Unauthorized")
def test_malformed_auth_header(self): def test_malformed_auth_header(self):
"""Malformed Authorization header returns 401"""
cases = [ cases = [
("InvalidScheme valid_token_123", 401), ("InvalidScheme valid_token_123", 401),
("Bearer", 401), # Missing token ("Bearer", 401),
("", 401), # No header ("", 401),
] ]
for header, expected_status in cases: for header, expected_status in cases:
with self.subTest(header=header): with self.subTest(header=header):
response = self.client.get( response = self.client.get(
self.get_url(self.competition.id), self.get_url(self.competition.id),
HTTP_AUTHORIZATION=header HTTP_AUTHORIZATION=header,
) )
self.assertEqual(response.status_code, expected_status) self.assertEqual(response.status_code, expected_status)
class CompetitionsEndpointTests(TestCase):
def setUp(self):
self.user = User.objects.create(
email="user@example.com",
password=make_password("password123"),
username="t1wk4",
)
resp = self.client.post(
"/api/v1/sign-in",
data={"email": self.user.email, "password": "password123"},
content_type="application/json",
).json()
token = resp["token"]
now = datetime.now(tz=pytz.utc)
self.competitions = []
for i in range(1, 6):
competition = Competition.objects.create(
title=f"Competition {i}",
description=f"Description {i}",
type=(
Competition.CompetitionType.EDU
if i % 2 == 0
else Competition.CompetitionType.COMPETITIVE
),
participation_type=Competition.CompetitionParticipationType.SOLO,
start_date=(now + timedelta(days=i)).isoformat(),
end_date=(now + timedelta(days=i + 7)).isoformat(),
)
if i <= 2:
competition.participants.add(self.user)
self.competitions.append(competition)
self.valid_headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"}
def get_url(self, params=None):
base_url = "/api/v1/competitions"
return f"{base_url}?{params}" if params else base_url
def test_get_participating_competitions(self):
response = self.client.get(
self.get_url("is_participating=true"), **self.valid_headers
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(len(data), 2)
self.assertEqual(
{item["id"] for item in data},
{str(self.competitions[0].id), str(self.competitions[1].id)},
)
def test_competition_type_values(self):
response = self.client.get(
self.get_url("is_participating=true"), **self.valid_headers
)
for i in range(len(response.json())):
item = response.json()[i]
if (i + 1) % 2 == 0:
self.assertEqual(item["type"], "edu")
else:
self.assertEqual(item["type"], "competitive")
def test_participation_type_values(self):
response = self.client.get(
self.get_url("is_participating=false"), **self.valid_headers
)
types = [item["participation_type"] for item in response.json()]
self.assertCountEqual(types, ["solo", "solo", "solo"])
def test_datetime_formatting(self):
response = self.client.get(
self.get_url("is_participating=true"), **self.valid_headers
)
for item in response.json():
if item["start_date"]:
try:
isoparse(item["start_date"])
except ValueError:
self.fail("Invalid start_date format")
if item["end_date"]:
try:
isoparse(item["end_date"])
except ValueError:
self.fail("Invalid end_date format")
def test_competition_metadata(self):
response = self.client.get(
self.get_url("is_participating=true"), **self.valid_headers
)
item = response.json()[0]
self.assertEqual(item["title"], "Competition 1")
self.assertEqual(item["description"], "Description 1")
self.assertEqual(item["type"], "competitive")
self.assertEqual(item["participation_type"], "solo")
def test_verbose_name_consistency(self):
response = self.client.get(
self.get_url("is_participating=true"), **self.valid_headers
)
item = response.json()[0]
self.assertNotIn("название", item)
self.assertIn("title", item)
def test_null_dates_handling(self):
competition = Competition.objects.create(
title="No Dates Competition",
description="Test competition",
type=Competition.CompetitionType.EDU,
participation_type=Competition.CompetitionParticipationType.SOLO,
)
response = self.client.get(
self.get_url("is_participating=false"), **self.valid_headers
)
test_item = next(
item
for item in response.json()
if item["id"] == str(competition.id)
)
self.assertIsNone(test_item["start_date"])
self.assertIsNone(test_item["end_date"])
def test_participation_status_filtering(self):
response = self.client.get(
self.get_url("is_participating=false"), **self.valid_headers
)
data = response.json()
self.assertEqual(len(data), 3)
-1
View File
@@ -2,4 +2,3 @@ from django.contrib import admin
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
admin.site.unregister(Group) admin.site.unregister(Group)
admin.site.unregister(User)
@@ -8,6 +8,7 @@ from django.core.management.base import BaseCommand
from django.utils import timezone from django.utils import timezone
from apps.competition.models import Competition, State from apps.competition.models import Competition, State
from apps.review.models import Review, Reviewer
from apps.task.models import CompetitionTask, CompetitionTaskSubmission from apps.task.models import CompetitionTask, CompetitionTaskSubmission
from apps.user.models import User, UserRole from apps.user.models import User, UserRole
@@ -19,11 +20,23 @@ class Command(BaseCommand):
self.stdout.write("Starting data generation...") self.stdout.write("Starting data generation...")
users = self.create_users(5) users = self.create_users(5)
competitions = self.create_competitions(2, users) competitions = self.create_competitions(2, users)
self.reviewers = self.create_reviewers(2)
tasks = self.create_tasks(competitions) tasks = self.create_tasks(competitions)
self.create_submissions(tasks, users) self.create_submissions(tasks, users)
self.create_states(competitions, users) self.create_states(competitions, users)
self.stdout.write("Data generation completed.") self.stdout.write("Data generation completed.")
def create_reviewers(self, count):
reviewers = []
for i in range(count):
name = f"John_{i}"
surname = f"Smith_{i}"
token = random.randint(100000, 999999)
reviewer = Reviewer(name=name, surname=surname, token=token)
reviewer.save()
reviewers.append(reviewer)
return reviewers
def create_users(self, count): def create_users(self, count):
users = [] users = []
for i in range(1, count + 1): for i in range(1, count + 1):
@@ -60,8 +73,10 @@ class Command(BaseCommand):
description=description, description=description,
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
type="solo", # assuming only one type for now type=random.choice(
participation_type=random.choice(["edu", "competitive"]), ["edu", "competitive"]
), # assuming only one type for now
participation_type="solo",
) )
# Add random participants # Add random participants
selected_users = random.sample( selected_users = random.sample(
@@ -89,11 +104,18 @@ class Command(BaseCommand):
description=description, description=description,
type=task_type, type=task_type,
points=random.randint(1, 10), points=random.randint(1, 10),
max_attempts=random.randint(1, 10),
) )
tasks.append(task) tasks.append(task)
self.stdout.write(f"Created task: {title} (type: {task_type})") self.stdout.write(f"Created task: {title} (type: {task_type})")
self.add_reviewers_to_task(tasks)
return tasks return tasks
def add_reviewers_to_task(self, tasks):
for task in tasks:
task.reviewers.set(self.reviewers)
task.save()
def create_submissions(self, tasks, users): def create_submissions(self, tasks, users):
for task in tasks: for task in tasks:
# Each task will get between 1 and 3 submissions # Each task will get between 1 and 3 submissions
+17
View File
@@ -0,0 +1,17 @@
from django.contrib import admin
from apps.review.models import Review, Reviewer
@admin.register(Reviewer)
class ReviewAdmin(admin.ModelAdmin):
list_display = ("name", "surname",)
search_fields = ("name", "surname",)
@admin.register(Review)
class ReviewAdmin(admin.ModelAdmin):
list_display = ("id", "reviewer", "submission",)
search_fields = ("id", "reviewer__id", "reviewer__name", "reviewer__surname",
"submission__id", "submission__content")
list_filter = ("submission__plagiarism_checked", "submission__status",)
+1
View File
@@ -4,3 +4,4 @@ from django.apps import AppConfig
class CoreConfig(AppConfig): class CoreConfig(AppConfig):
name = "apps.review" name = "apps.review"
label = "review" label = "review"
verbose_name = "Проверка"
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-01 08:47 # Generated by Django 5.1.6 on 2025-03-02 10:28
import uuid import uuid
from django.db import migrations, models from django.db import migrations, models
@@ -12,16 +12,29 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.CreateModel(
name='Review',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('evaluation', models.JSONField(blank=True, default=list, null=True, verbose_name='выполнение')),
('state', models.CharField(choices=[('not_checked', 'Not Checked'), ('checking', 'Checking'), ('checked', 'Checked')], default='not_checked', max_length=11, verbose_name='состояние')),
],
options={
'verbose_name': 'проверка',
'verbose_name_plural': 'проверки',
},
),
migrations.CreateModel( migrations.CreateModel(
name='Reviewer', name='Reviewer',
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)), ('name', models.CharField(max_length=100, verbose_name='имя')),
('surname', models.CharField(max_length=100)), ('surname', models.CharField(max_length=100, verbose_name='фамилия')),
('token', models.CharField(max_length=100)), ('token', models.CharField(max_length=100, verbose_name='токен для входа')),
], ],
options={ options={
'abstract': False, 'verbose_name': 'проверяющий',
'verbose_name_plural': 'проверяющие',
}, },
), ),
] ]
@@ -0,0 +1,27 @@
# Generated by Django 5.1.6 on 2025-03-02 10:28
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('review', '0001_initial'),
('task', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='review',
name='submission',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='task.competitiontasksubmission', verbose_name='посылка'),
),
migrations.AddField(
model_name='review',
name='reviewer',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='review.reviewer', verbose_name='проверяющий'),
),
]
@@ -1,26 +0,0 @@
# Generated by Django 5.1.6 on 2025-03-01 14:47
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('review', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Review',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('state', models.CharField(choices=[('not_checked', 'Not Checked'), ('checking', 'Checking'), ('checked', 'Checked')], max_length=11)),
('reviewer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='review.reviewer')),
],
options={
'abstract': False,
},
),
]
@@ -1,20 +0,0 @@
# Generated by Django 5.1.6 on 2025-03-01 14:47
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('review', '0002_review'),
('task', '0005_alter_competitiontask_description_and_more'),
]
operations = [
migrations.AddField(
model_name='review',
name='submission',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontasksubmission'),
),
]
+35 -12
View File
@@ -1,27 +1,50 @@
from django.db import models from django.db import models
from apps.core.models import BaseModel from apps.core.models import BaseModel
from apps.task.models import CompetitionTaskSubmission
class Reviewer(BaseModel): class Reviewer(BaseModel):
name = models.CharField(max_length=100) name = models.CharField(max_length=100, verbose_name="имя")
surname = models.CharField(max_length=100) surname = models.CharField(max_length=100, verbose_name="фамилия")
token = models.CharField(max_length=100) token = models.CharField(max_length=100, verbose_name="токен для входа")
def __str__(self):
return self.name + " " + self.surname
class Meta:
verbose_name = "проверяющий"
verbose_name_plural = "проверяющие"
class ReviewStatusChoices(models.TextChoices):
NOT_CHECKED = "not_checked"
CHECKING = "checking"
CHECKED = "checked"
class Review(BaseModel): class Review(BaseModel):
class ReviewStatusChoices(models.TextChoices): reviewer = models.ForeignKey(Reviewer, on_delete=models.CASCADE,
NOT_CHECKED = "not_checked" verbose_name="проверяющий")
CHECKING = "checking"
CHECKED = "checked"
reviewer = models.ForeignKey(Reviewer, on_delete=models.CASCADE)
submission = models.ForeignKey( submission = models.ForeignKey(
CompetitionTaskSubmission, on_delete=models.CASCADE "task.CompetitionTaskSubmission",
on_delete=models.CASCADE,
related_name="reviews",
verbose_name="посылка"
) )
evaluation = models.JSONField(default=list, null=True, blank=True,
verbose_name="выполнение")
state = models.CharField( state = models.CharField(
choices=ReviewStatusChoices.choices, max_length=11 choices=ReviewStatusChoices.choices,
default=ReviewStatusChoices.NOT_CHECKED.value,
max_length=11,
verbose_name="состояние"
) )
def __str__(self):
return str(self.id)
class Meta:
verbose_name = "проверка"
verbose_name_plural = "проверки"
+21 -1
View File
@@ -1,6 +1,12 @@
from django.contrib import admin from django.contrib import admin
from apps.task.models import CompetitionTask from apps.task.models import CompetitionTask, CompetitionTaskAttachment, \
CompetitionTaskSubmission
class CompletionAttachmentInline(admin.StackedInline):
model = CompetitionTaskAttachment
extra = 0
@admin.register(CompetitionTask) @admin.register(CompetitionTask)
@@ -8,6 +14,20 @@ class CompetitionTaskAdmin(admin.ModelAdmin):
list_display = ("title", "type", "points") list_display = ("title", "type", "points")
@admin.register(CompetitionTaskSubmission)
class CompetitionTaskSubmissionAdmin(admin.ModelAdmin):
list_display = ("task", "user", "status",)
search_fields = ("task__id", "task__title", "user__username", "user__email")
filter = ("plagiarism_checked",)
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): class CompetitionTaskInline(admin.StackedInline):
model = CompetitionTask model = CompetitionTask
extra = 0 extra = 0
@@ -1,7 +1,8 @@
# Generated by Django 5.1.6 on 2025-03-01 10:26 # Generated by Django 5.1.6 on 2025-03-02 10:28
import apps.task.models import apps.task.models
import django.db.models.deletion import django.db.models.deletion
import tinymce.models
import uuid import uuid
from django.db import migrations, models from django.db import migrations, models
@@ -12,6 +13,7 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('competition', '0001_initial'), ('competition', '0001_initial'),
('review', '0001_initial'),
('user', '0001_initial'), ('user', '0001_initial'),
] ]
@@ -20,31 +22,63 @@ class Migration(migrations.Migration):
name='CompetitionTask', name='CompetitionTask',
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.TextField(max_length=50, verbose_name='заголовок')), ('in_competition_position', models.PositiveSmallIntegerField(blank=True, null=True)),
('description', models.TextField(max_length=300, verbose_name='описание')), ('title', models.CharField(max_length=50, verbose_name='заголовок')),
('type', models.CharField(choices=[('input', 'Input'), ('checker', 'Checker'), ('review', 'Review')], max_length=8)), ('description', tinymce.models.HTMLField(max_length=300, verbose_name='описание')),
('correct_answer_file', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to)), ('max_attempts', models.PositiveSmallIntegerField(blank=True, null=True)),
('points', models.IntegerField(blank=True, null=True)), ('type', models.CharField(choices=[('input', 'Ввод правильного ответа'), ('checker', 'Ввод кода'), ('review', 'Ручная')], max_length=8, verbose_name='тип проверки')),
('answer_file_path', models.TextField(blank=True, null=True)), ('correct_answer_file', models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to, verbose_name='файл с правильным ответом')),
('criteries', models.JSONField(blank=True, null=True)), ('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')), ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition')),
('reviewers', models.ManyToManyField(blank=True, to='review.reviewer')),
],
options={
'verbose_name': 'задание',
'verbose_name_plural': 'задания',
},
),
migrations.CreateModel(
name='CompetitionTaskAttachment',
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='путь сохранения')),
('public', models.BooleanField(default=False, verbose_name='публичный')),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask', verbose_name='задание')),
], ],
options={ options={
'abstract': False, 'abstract': False,
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='CompetetionTaskSumbission', name='CompetitionTaskCriteria',
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('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)), ('name', models.TextField()),
('slug', models.SlugField()),
('description', models.TextField()),
('max_value', models.PositiveSmallIntegerField()),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='criteries', to='task.competitiontask')),
],
options={
'abstract': False,
},
),
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)), ('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)), ('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)), ('result', models.JSONField(blank=True, default=None, null=True)),
('earned_points', models.IntegerField()), ('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)), ('timestamp', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user')),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='task.competitiontask')), ('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')),
], ],
options={ options={
'abstract': False, 'abstract': False,
@@ -1,45 +0,0 @@
# Generated by Django 5.1.6 on 2025-03-01 12:21
import apps.task.models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('competition', '0002_competition_tasks_alter_competition_participants_and_more'),
('task', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='competitiontask',
options={'verbose_name': 'задание', 'verbose_name_plural': 'задания'},
),
migrations.AlterField(
model_name='competitiontask',
name='competition',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition', verbose_name='соревнование'),
),
migrations.AlterField(
model_name='competitiontask',
name='correct_answer_file',
field=models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to, verbose_name='правильный ответ'),
),
migrations.AlterField(
model_name='competitiontask',
name='criteries',
field=models.JSONField(blank=True, null=True, verbose_name='критерии проверки'),
),
migrations.AlterField(
model_name='competitiontask',
name='points',
field=models.IntegerField(blank=True, null=True, verbose_name='баллы за задание'),
),
migrations.AlterField(
model_name='competitiontask',
name='type',
field=models.CharField(choices=[('input', 'Input'), ('checker', 'Checker'), ('review', 'Review')], max_length=8, verbose_name='тип задания'),
),
]
@@ -1,19 +0,0 @@
# Generated by Django 5.1.6 on 2025-03-01 12:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('review', '0001_initial'),
('task', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='competetiontasksumbission',
name='reviewers',
field=models.ManyToManyField(blank=True, related_name='reviewers', to='review.reviewer'),
),
]
@@ -1,51 +0,0 @@
# Generated by Django 5.1.6 on 2025-03-01 13:49
import apps.task.models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('competition', '0003_remove_competition_tasks'),
('task', '0002_alter_competitiontask_options_and_more'),
]
operations = [
migrations.AddField(
model_name='competitiontask',
name='max_attemps',
field=models.PositiveSmallIntegerField(default=0),
),
migrations.AlterField(
model_name='competitiontask',
name='answer_file_path',
field=models.TextField(blank=True, default='stdout', null=True, verbose_name='куда сохранять решения'),
),
migrations.AlterField(
model_name='competitiontask',
name='competition',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.competition'),
),
migrations.AlterField(
model_name='competitiontask',
name='correct_answer_file',
field=models.FileField(blank=True, null=True, upload_to=apps.task.models.CompetitionTask.answer_file_upload_to, verbose_name='файл с правильным ответом'),
),
migrations.AlterField(
model_name='competitiontask',
name='criteries',
field=models.JSONField(blank=True, null=True, verbose_name='критерии'),
),
migrations.AlterField(
model_name='competitiontask',
name='title',
field=models.CharField(max_length=50, verbose_name='заголовок'),
),
migrations.AlterField(
model_name='competitiontask',
name='type',
field=models.CharField(choices=[('input', 'Ввод правильного ответа'), ('checker', 'Вывод кода'), ('review', 'Ручная')], max_length=8, verbose_name='тип проверки'),
),
]
@@ -1,14 +0,0 @@
# Generated by Django 5.1.6 on 2025-03-01 14:39
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('task', '0002_competetiontasksumbission_reviewers'),
('task', '0003_competitiontask_max_attemps_and_more'),
]
operations = [
]
@@ -1,48 +0,0 @@
# Generated by Django 5.1.6 on 2025-03-01 14:47
import apps.task.models
import django.db.models.deletion
import tinymce.models
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task', '0004_merge_20250301_1739'),
('user', '0002_alter_user_email_alter_user_password_and_more'),
]
operations = [
migrations.AlterField(
model_name='competitiontask',
name='description',
field=tinymce.models.HTMLField(max_length=300, verbose_name='описание'),
),
migrations.AlterField(
model_name='competitiontask',
name='max_attemps',
field=models.PositiveSmallIntegerField(),
),
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)),
('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()),
('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')),
],
options={
'abstract': False,
},
),
migrations.DeleteModel(
name='CompetetionTaskSumbission',
),
]
+56 -18
View File
@@ -1,27 +1,31 @@
from uuid import uuid4 from uuid import uuid4
from django.db import models from django.db import models
from django.db.models import Count, Q
from tinymce.models import HTMLField from tinymce.models import HTMLField
from apps.competition.models import Competition from apps.competition.models import Competition
from apps.core.models import BaseModel from apps.core.models import BaseModel
from apps.task.validators import ContestTaskCriteriesValidator from apps.review.models import Review, ReviewStatusChoices, Reviewer
from apps.user.models import User from apps.user.models import User
class CompetitionTask(BaseModel): class CompetitionTask(BaseModel):
class CompetitionTaskType(models.TextChoices): class CompetitionTaskType(models.TextChoices):
INPUT = "input", "Ввод правильного ответа" INPUT = "input", "Ввод правильного ответа"
CHECKER = "checker", "Вывод кода" CHECKER = "checker", "Ввод кода"
REVIEW = "review", "Ручная" REVIEW = "review", "Ручная"
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(
null=True, blank=True
)
competition = models.ForeignKey(Competition, on_delete=models.CASCADE) competition = models.ForeignKey(Competition, on_delete=models.CASCADE)
title = models.CharField(verbose_name="заголовок", max_length=50) title = models.CharField(verbose_name="заголовок", max_length=50)
description = HTMLField(verbose_name="описание", max_length=300) description = HTMLField(verbose_name="описание", max_length=300)
max_attemps = models.PositiveSmallIntegerField() max_attempts = models.PositiveSmallIntegerField(null=True, blank=True)
type = models.CharField( type = models.CharField(
choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки" choices=CompetitionTaskType, max_length=8, verbose_name="тип проверки"
) )
@@ -41,36 +45,43 @@ class CompetitionTask(BaseModel):
answer_file_path = models.TextField( answer_file_path = models.TextField(
null=True, null=True,
blank=True, blank=True,
verbose_name="куда сохранять решения", verbose_name="куда сделать вывод программы участнику",
help_text="Путь до файла в котором ожидается результат. Пример: stdout или ./output.txt",
default="stdout", default="stdout",
) )
# only when "review" type # only when "review" type
# TODO make it more humanize reviewers = models.ManyToManyField(Reviewer, blank=True)
criteries = models.JSONField(
blank=True,
null=True,
verbose_name="критерии",
)
def clean(self):
ContestTaskCriteriesValidator()(self)
def __str__(self): def __str__(self):
return self.title return self.title
class Meta: class Meta:
verbose_name = "задание"
verbose_name_plural = "задания" verbose_name_plural = "задания"
class CompetitionTaskCriteria(BaseModel):
task = models.ForeignKey(
CompetitionTask, on_delete=models.CASCADE, related_name="criteries"
)
name = models.TextField()
slug = models.SlugField()
description = models.TextField()
max_value = models.PositiveSmallIntegerField()
class CompetitionTaskAttachment(BaseModel): class CompetitionTaskAttachment(BaseModel):
def file_upload_at(instance, filename): def file_upload_at(instance, filename):
return f"/attachment/{instance.id}/file" return f"/attachment/{instance.id}/file"
task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE) task = models.ForeignKey(CompetitionTask, on_delete=models.CASCADE,
file = models.FileField(upload_to=file_upload_at) verbose_name="задание")
bind_at = models.FilePathField() file = models.FileField(upload_to=file_upload_at,
public = models.BooleanField(default=False) verbose_name="файл")
bind_at = models.FilePathField(verbose_name="путь сохранения")
public = models.BooleanField(default=False, verbose_name="публичный")
class CompetitionTaskSubmission(BaseModel): class CompetitionTaskSubmission(BaseModel):
@@ -92,6 +103,7 @@ class CompetitionTaskSubmission(BaseModel):
choices=StatusChoices.choices, choices=StatusChoices.choices,
default=StatusChoices.SENT, default=StatusChoices.SENT,
max_length=8, max_length=8,
verbose_name="статус"
) )
# code or text or file # code or text or file
@@ -108,6 +120,32 @@ class CompetitionTaskSubmission(BaseModel):
# - code: {"correct": boolean} # - code: {"correct": boolean}
result = models.JSONField(default=None, null=True, blank=True) result = models.JSONField(default=None, null=True, blank=True)
# just more readable result representation, maybe will be calcuated somehow more complex depends on criteria # just more readable result representation, maybe will be calcuated somehow more complex depends on criteria
earned_points = models.IntegerField() earned_points = models.IntegerField(null=True, blank=True)
checked_at = models.DateTimeField(null=True, blank=True)
plagiarism_checked = models.BooleanField(default=False)
timestamp = models.DateTimeField(auto_now_add=True) timestamp = models.DateTimeField(auto_now_add=True)
def send_on_review(self):
if not self.task.reviewers.exists():
return
reviewer = (
self.task.reviewers.annotate(
pending_count=Count(
"review",
filter=Q(
review__state__in=[
ReviewStatusChoices.NOT_CHECKED,
ReviewStatusChoices.CHECKING,
]
),
)
)
.order_by("pending_count")
.first()
)
review = Review.objects.create(
reviewer=reviewer,
submission=self,
)
+23 -4
View File
@@ -1,4 +1,5 @@
import ast import ast
import contextlib
import hashlib import hashlib
import os import os
import sys import sys
@@ -6,6 +7,7 @@ import tempfile
from io import StringIO from io import StringIO
from config.celery import app from config.celery import app
from apps.task.models import CompetitionTaskSubmission
ALLOWED_MODULES = { ALLOWED_MODULES = {
"pandas", "pandas",
@@ -19,6 +21,7 @@ ALLOWED_MODULES = {
"csv", "csv",
"math", "math",
"statistics", "statistics",
"statsmodels",
} }
@@ -63,18 +66,28 @@ def validate_code(code_str):
raise SecurityException(f"Security check failed: {e!s}") raise SecurityException(f"Security check failed: {e!s}")
def secure_exec(code_str, result_path): def secure_exec(code_str, result_path, input_files=None):
original_dir = os.getcwd() original_dir = os.getcwd()
original_stdout = sys.stdout original_stdout = sys.stdout
sys.stdout = captured_stdout = StringIO() sys.stdout = captured_stdout = StringIO()
result_content = None result_content = None
if input_files is None:
input_files = []
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
try: try:
os.chdir(temp_dir) os.chdir(temp_dir)
for file in input_files:
file_path = os.path.join(temp_dir, file["bind_at"])
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "wb") as f:
f.write(file["content"])
restricted_globals = { restricted_globals = {
"__builtins__": { "__builtins__": {
"open": lambda f, *a, **kw: open(f, *a, **kw), "open": open,
"print": print, "print": print,
"str": str, "str": str,
"int": int, "int": int,
@@ -105,15 +118,21 @@ def secure_exec(code_str, result_path):
@app.task(bind=True) @app.task(bind=True)
def analyze_data_task(self, code_str, result_path, expected_bytes): def analyze_data_task(
self, code_str, result_path, expected_file_link, submission_id, input_files=[]
):
try: try:
validate_code(code_str) validate_code(code_str)
result_content = secure_exec(code_str, result_path) result_content = secure_exec(code_str, result_path, input_files)
result_hash = hashlib.sha256(result_content).hexdigest() result_hash = hashlib.sha256(result_content).hexdigest()
expected_hash = hashlib.sha256(expected_bytes).hexdigest() expected_hash = hashlib.sha256(expected_bytes).hexdigest()
with contextlib.suppress(CompetitionTaskSubmission.DoesNotExist):
submission = CompetitionTaskSubmission.objects.get(id=submission_id)
submission.result = {"correct": True}
return { return {
"success": True, "success": True,
"match": result_hash == expected_hash, "match": result_hash == expected_hash,
@@ -0,0 +1,32 @@
import unittest
from apps.task.tasks import analyze_data_task
class TestAnalyzeDataTask(unittest.TestCase):
def test_task_execution_basic(self):
code_str = 'print("Hello, World!")'
result_path = "stdout"
expected_bytes = b"Hello, World!\n"
result = analyze_data_task(code_str, result_path, expected_bytes)
self.assertTrue(result["success"])
self.assertTrue(result["match"])
def test_task_execution_with_files(self):
code_str = """
with open("file.txt") as f:
print(f.read())
"""
result_path = "stdout"
expected_bytes = b"some_content\n"
result = analyze_data_task(
code_str,
result_path,
expected_bytes,
input_files=[{"bind_at": "file.txt", "content": b"some_content"}],
)
print(result)
self.assertTrue(result["success"])
self.assertTrue(result["match"])
-24
View File
@@ -1,24 +0,0 @@
from django.core.exceptions import ValidationError
from pydantic import BaseModel
from pydantic import ValidationError as PydanticValidationError
class Criteria(BaseModel):
name: str
slug: str
max_value: int
min_value: int
class ContestTaskCriteriesValidator:
def __call__(self, instance):
if instance.criteries and not isinstance(instance.criteries, list):
err = "criteries must be a valid dictionary"
raise ValidationError(err)
try:
for criteria in instance.criteries if instance.criteries else []:
Criteria(**criteria)
except PydanticValidationError:
err = "invalid criteries data"
raise ValidationError(err)
+13
View File
@@ -0,0 +1,13 @@
from django.contrib import admin
from apps.team.models import Team
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ("name", "owner")
search_fields = (
"name",
"owner",
"members",
)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class TeamConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.team"
verbose_name = "Команды"
@@ -0,0 +1,42 @@
# Generated by Django 5.1.6 on 2025-03-02 10:28
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('user', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Team',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=50, verbose_name='название')),
('members', models.ManyToManyField(related_name='team_members', to='user.user', verbose_name='участники')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user.user', verbose_name='владелец')),
],
options={
'verbose_name': 'команда',
'verbose_name_plural': 'команды',
},
),
migrations.CreateModel(
name='TeamInvite',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('link', models.UUIDField(verbose_name='инвайт')),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='team.team', verbose_name='команда')),
],
options={
'verbose_name': 'приглашение',
'verbose_name_plural': 'приглашения',
},
),
]
+32
View File
@@ -0,0 +1,32 @@
from django.db import models
from apps.core.models import BaseModel
from apps.user.models import User
class Team(BaseModel):
name = models.CharField(max_length=50, verbose_name="название")
owner = models.ForeignKey(
User, on_delete=models.CASCADE, verbose_name="владелец"
)
members = models.ManyToManyField(
User, related_name="team_members", verbose_name="участники"
)
def __str__(self):
return self.name
class Meta:
verbose_name = "команда"
verbose_name_plural = "команды"
class TeamInvite(BaseModel):
team = models.ForeignKey(
Team, on_delete=models.CASCADE, verbose_name="команда"
)
link = models.UUIDField(verbose_name="инвайт")
class Meta:
verbose_name = "приглашение"
verbose_name_plural = "приглашения"
@@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-03-01 08:47 # Generated by Django 5.1.6 on 2025-03-02 10:28
import uuid import uuid
from django.db import migrations, models from django.db import migrations, models
@@ -16,9 +16,10 @@ class Migration(migrations.Migration):
name='User', name='User',
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('email', models.EmailField(max_length=254, unique=True, verbose_name='Почта')), ('email', models.EmailField(max_length=254, unique=True, verbose_name='почта')),
('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)),
('status', models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10)), ('status', models.CharField(choices=[('student', 'Student'), ('metodist', 'Metodist')], default='student', max_length=10)),
], ],
options={ options={
@@ -1,28 +0,0 @@
# Generated by Django 5.1.6 on 2025-03-01 14:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('user', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(max_length=254, unique=True, verbose_name='почта'),
),
migrations.AlterField(
model_name='user',
name='password',
field=models.TextField(verbose_name='пароль'),
),
migrations.AlterField(
model_name='user',
name='username',
field=models.SlugField(unique=True, verbose_name='юзернейм'),
),
]
+2
View File
@@ -14,6 +14,8 @@ 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)
@staticmethod @staticmethod
def make_password(password: str): def make_password(password: str):
return make_password(password) return make_password(password)
+29 -38
View File
@@ -1,8 +1,8 @@
import json import json
import uuid import uuid
from django.test import TestCase
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.test import TestCase
from apps.user.models import User from apps.user.models import User
@@ -47,7 +47,9 @@ class SignUpAPITestCase(TestCase):
def test_existing_user_conflict(self): def test_existing_user_conflict(self):
User.objects.create( User.objects.create(
email="existing@example.com", password="existingpass123", username="testing" email="existing@example.com",
password="existingpass123",
username="testing",
) )
payload = { payload = {
"email": "existing@example.com", "email": "existing@example.com",
@@ -62,23 +64,24 @@ class SignUpAPITestCase(TestCase):
self.assertEqual(response.status_code, 409) self.assertEqual(response.status_code, 409)
self.assertIn("detail", response.json()) self.assertIn("detail", response.json())
class SignInAPITestCase(TestCase): class SignInAPITestCase(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create( self.user = User.objects.create(
email="valid@example.com", email="valid@example.com",
password=make_password("securepassword123"), password=make_password("securepassword123"),
username="testuser" username="testuser",
) )
self.valid_payload = { self.valid_payload = {
"email": "valid@example.com", "email": "valid@example.com",
"password": "securepassword123" "password": "securepassword123",
} }
def test_successful_sign_in(self): def test_successful_sign_in(self):
response = self.client.post( response = self.client.post(
"/api/v1/sign-in", "/api/v1/sign-in",
data=json.dumps(self.valid_payload), data=json.dumps(self.valid_payload),
content_type="application/json" content_type="application/json",
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertIn("token", response.json()) self.assertIn("token", response.json())
@@ -88,7 +91,7 @@ class SignInAPITestCase(TestCase):
response = self.client.post( response = self.client.post(
"/api/v1/sign-in", "/api/v1/sign-in",
data=json.dumps({"password": "pass"}), data=json.dumps({"password": "pass"}),
content_type="application/json" content_type="application/json",
) )
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
@@ -96,44 +99,35 @@ class SignInAPITestCase(TestCase):
response = self.client.post( response = self.client.post(
"/api/v1/sign-in", "/api/v1/sign-in",
data=json.dumps({"email": "test@example.com"}), data=json.dumps({"email": "test@example.com"}),
content_type="application/json" content_type="application/json",
) )
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_invalid_email_format(self): def test_invalid_email_format(self):
payload = { payload = {"email": "invalid-email", "password": "password123"}
"email": "invalid-email",
"password": "password123"
}
response = self.client.post( response = self.client.post(
"/api/v1/sign-in", "/api/v1/sign-in",
data=json.dumps(payload), data=json.dumps(payload),
content_type="application/json" content_type="application/json",
) )
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
def test_incorrect_password(self): def test_incorrect_password(self):
payload = { payload = {"email": "valid@example.com", "password": "wrongpassword"}
"email": "valid@example.com",
"password": "wrongpassword"
}
response = self.client.post( response = self.client.post(
"/api/v1/sign-in", "/api/v1/sign-in",
data=json.dumps(payload), data=json.dumps(payload),
content_type="application/json" content_type="application/json",
) )
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Unauthorized") self.assertEqual(response.json()["detail"], "Unauthorized")
def test_nonexistent_user(self): def test_nonexistent_user(self):
payload = { payload = {"email": "notexist@example.com", "password": "password123"}
"email": "notexist@example.com",
"password": "password123"
}
response = self.client.post( response = self.client.post(
"/api/v1/sign-in", "/api/v1/sign-in",
data=json.dumps(payload), data=json.dumps(payload),
content_type="application/json" content_type="application/json",
) )
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Unauthorized") self.assertEqual(response.json()["detail"], "Unauthorized")
@@ -145,21 +139,25 @@ class UserMeEndpointTestCase(TestCase):
self.user = User.objects.create( self.user = User.objects.create(
email="johndoe@example.com", email="johndoe@example.com",
username="johndoe", username="johndoe",
password=make_password("securepassword123") password=make_password("securepassword123"),
) )
resp = self.client.post( resp = self.client.post(
"/api/v1/sign-in", "/api/v1/sign-in",
data=json.dumps({"email": "johndoe@example.com", "password": "securepassword123"}), data=json.dumps(
content_type="application/json" {
"email": "johndoe@example.com",
"password": "securepassword123",
}
),
content_type="application/json",
).json() ).json()
self.token = resp['token'] self.token = resp["token"]
self.url = "/api/v1/me" self.url = "/api/v1/me"
def test_get_authenticated_user_data(self): def test_get_authenticated_user_data(self):
"""Test authenticated user can retrieve their profile (200 OK)""" """Test authenticated user can retrieve their profile (200 OK)"""
response = self.client.get( response = self.client.get(
self.url, self.url, HTTP_AUTHORIZATION=f"Bearer {self.token}"
HTTP_AUTHORIZATION=f"Bearer {self.token}"
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@@ -191,8 +189,7 @@ class UserMeEndpointTestCase(TestCase):
def test_invalid_auth_scheme(self): def test_invalid_auth_scheme(self):
"""Test invalid authentication scheme returns 401""" """Test invalid authentication scheme returns 401"""
response = self.client.get( response = self.client.get(
self.url, self.url, HTTP_AUTHORIZATION=f"InvalidScheme {self.token}"
HTTP_AUTHORIZATION=f"InvalidScheme {self.token}"
) )
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
@@ -200,18 +197,12 @@ class UserMeEndpointTestCase(TestCase):
def test_malformed_token(self): def test_malformed_token(self):
"""Test malformed token returns 401""" """Test malformed token returns 401"""
test_cases = [ test_cases = ["invalid.token.123", "Bearer", "", "123456"]
"invalid.token.123",
"Bearer",
"",
"123456"
]
for token in test_cases: for token in test_cases:
with self.subTest(token=token): with self.subTest(token=token):
response = self.client.get( response = self.client.get(
self.url, self.url, HTTP_AUTHORIZATION=f"Bearer {token}"
HTTP_AUTHORIZATION=f"Bearer {token}"
) )
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Unauthorized") self.assertEqual(response.json()["detail"], "Unauthorized")
+1
View File
@@ -448,6 +448,7 @@ INSTALLED_APPS = [
"apps.competition", "apps.competition",
"apps.review", "apps.review",
"apps.task", "apps.task",
"apps.team",
] ]
# tinymce # tinymce
+6 -5
View File
@@ -23,6 +23,7 @@ dependencies = [
"psycopg2-binary>=2.9.10", "psycopg2-binary>=2.9.10",
"pydantic>=2.10.5", "pydantic>=2.10.5",
"pyjwt>=2.10.1", "pyjwt>=2.10.1",
"python-dateutil>=2.9.0.post0",
"python-gettext>=5.0", "python-gettext>=5.0",
"python-json-logger>=3.2.1", "python-json-logger>=3.2.1",
"pytz>=2024.2", "pytz>=2024.2",
@@ -31,11 +32,11 @@ dependencies = [
[dependency-groups] [dependency-groups]
dev = [ dev = [
"coverage>=7.6.12", "coverage>=7.6.12",
"django-debug-toolbar>=4.4.6", "django-debug-toolbar>=4.4.6",
"django-stubs[compatible-mypy]>=5.1.3", "django-stubs[compatible-mypy]>=5.1.3",
"mypy>=1.15.0", "mypy>=1.15.0",
"ruff>=0.9.3", "ruff>=0.9.3",
] ]
[tool.ruff] [tool.ruff]
+10
View File
@@ -6,6 +6,8 @@
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",
"@tailwindcss/vite": "^4.0.9", "@tailwindcss/vite": "^4.0.9",
@@ -158,12 +160,16 @@
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA=="], "@radix-ui/react-id": ["@radix-ui/react-id@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA=="], "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg=="], "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.2", "", { "dependencies": { "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w=="], "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.2", "", { "dependencies": { "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w=="],
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.2.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-roving-focus": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw=="], "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="],
@@ -178,6 +184,10 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w=="], "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.34.9", "", { "os": "android", "cpu": "arm" }, "sha512-qZdlImWXur0CFakn2BJ2znJOdqYZKiedEPEVNTBrpfPjc/YuTGcaYZcdmNFTkUj3DU0ZM/AElcM8Ybww3xVLzA=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.34.9", "", { "os": "android", "cpu": "arm" }, "sha512-qZdlImWXur0CFakn2BJ2znJOdqYZKiedEPEVNTBrpfPjc/YuTGcaYZcdmNFTkUj3DU0ZM/AElcM8Ybww3xVLzA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.34.9", "", { "os": "android", "cpu": "arm64" }, "sha512-4KW7P53h6HtJf5Y608T1ISKvNIYLWRKMvfnG0c44M6In4DQVU58HZFEVhWINDZKp7FZps98G3gxwC1sb0wXUUg=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.34.9", "", { "os": "android", "cpu": "arm64" }, "sha512-4KW7P53h6HtJf5Y608T1ISKvNIYLWRKMvfnG0c44M6In4DQVU58HZFEVhWINDZKp7FZps98G3gxwC1sb0wXUUg=="],
+5921
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -12,6 +12,8 @@
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",
"@tailwindcss/vite": "^4.0.9", "@tailwindcss/vite": "^4.0.9",
+14 -1
View File
@@ -1,5 +1,5 @@
import { Routes, Route } from "react-router";
import "./styles/globals.css"; import "./styles/globals.css";
import { Routes, Route } from "react-router";
import { NavbarLayout } from "./widgets/navbar-layout"; import { NavbarLayout } from "./widgets/navbar-layout";
@@ -10,6 +10,8 @@ import LoginPage from "./pages/Login";
import { AuthLayout } from "./widgets/auth-layout"; import { AuthLayout } from "./widgets/auth-layout";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ReviewPage from "./pages/Review"; import ReviewPage from "./pages/Review";
import CompetitionConstructor from "./pages/CompetitionConstructor";
import UserProfile from "./pages/UserProfile";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -30,6 +32,17 @@ const App = () => {
element={<CompetitionSession />} element={<CompetitionSession />}
/> />
<Route path="/constructor/:id" element={<CompetitionConstructor />} />
<Route path="/constructor/new" element={<CompetitionConstructor />} />
<Route
path="/constructor/:id/tasks/:taskId"
element={<CompetitionConstructor />}
/>
<Route path="/profile" element={<UserProfile />} />
<Route path="/review/:token" element={<ReviewPage />} /> <Route path="/review/:token" element={<ReviewPage />} />
</Route> </Route>
</Routes> </Routes>
@@ -0,0 +1,133 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/shared/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/shared/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,43 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/shared/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
@@ -0,0 +1,63 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Task } from "@/shared/types";
import { Settings, Plus } from 'lucide-react';
import { Button } from "@/components/ui/button";
interface ConstructorHeaderProps {
title: string;
tasks: Task[];
competitionId: string;
onAddTaskClick: () => void;
}
const ConstructorHeader: React.FC<ConstructorHeaderProps> = ({
title,
tasks,
competitionId,
onAddTaskClick
}) => {
return (
<header className="bg-white shadow-sm sticky top-0 z-30 w-full">
<div className="mx-auto max-w-6xl px-4">
<div className="py-4 text-center">
<h1 className="font-hse-sans text-xl font-semibold">
{title}
</h1>
</div>
<div className="flex items-center justify-center gap-4 pb-4 overflow-x-auto no-scrollbar">
<Link
to={`/constructor/${competitionId}/tasks/settings`}
className="bg-gray-100 text-gray-700 rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer
transition-all hover:bg-gray-200 flex-shrink-0 flex items-center"
>
<Settings size={16} className="mr-1" />
</Link>
{tasks.map((task) => (
<Link
key={task.id}
to={`/constructor/${competitionId}/tasks/${task.id}`}
className="bg-blue-100 text-blue-700 rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer
transition-all hover:bg-blue-200 flex-shrink-0"
>
{task.number}
</Link>
))}
<Button
variant="ghost"
size="sm"
className="rounded-lg flex items-center px-2 h-8"
onClick={onAddTaskClick}
>
<Plus size={18} />
</Button>
</div>
</div>
</header>
);
};
export default ConstructorHeader;
@@ -0,0 +1,89 @@
import { useState } from "react";
import { useParams, Navigate, useNavigate } from "react-router-dom";
import { Task, TaskStatus } from "@/shared/types";
import ConstructorHeader from "./components/ConstructorHeader";
import TaskCreationModal from "./modules/TaskCreationModal";
const CompetitionConstructor = () => {
const { id, taskId } = useParams<{ id: string; taskId?: string }>();
const navigate = useNavigate();
const [competitionTitle, setCompetitionTitle] = useState("Новая олимпиада");
const [tasks, setTasks] = useState<Task[]>([]);
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const isSettings = taskId === "settings";
const handleOpenTaskModal = () => {
setIsTaskModalOpen(true);
};
const handleCloseTaskModal = () => {
setIsTaskModalOpen(false);
};
const handleCreateTask = (taskData: Partial<Task>) => {
const newTask: Task = {
id: `task-${Date.now()}`,
number: taskData.number || `${tasks.length + 1}`,
status: TaskStatus.Uncleared,
solutionType: taskData.solutionType || "input",
description: taskData.description || "",
requirements: taskData.requirements,
attachments: taskData.attachments || []
};
setTasks([...tasks, newTask]);
setIsTaskModalOpen(false);
navigate(`/constructor/${id}/tasks/${newTask.id}`);
};
if (!taskId) {
if (tasks.length > 0) {
return <Navigate to={`/constructor/${id}/tasks/${tasks[0].id}`} replace />;
} else {
return <Navigate to={`/constructor/${id}/tasks/settings`} replace />;
}
}
return (
<div className="flex flex-col min-h-screen">
<ConstructorHeader
title={competitionTitle}
tasks={tasks}
competitionId={id || ""}
onAddTaskClick={handleOpenTaskModal}
/>
<TaskCreationModal
isOpen={isTaskModalOpen}
onClose={handleCloseTaskModal}
onCreateTask={handleCreateTask}
taskCount={tasks.length}
/>
<main className="flex-1 bg-[#F8F8F8] pb-8">
<div className="max-w-6xl mx-auto px-4 py-6">
{isSettings ? (
<div className="bg-white rounded-lg p-6 shadow-sm">
<h2 className="text-2xl font-semibold mb-6 font-hse-sans">Настройки олимпиады</h2>
<p className="text-gray-500 font-hse-sans">
Здесь будет форма настроек олимпиады
</p>
</div>
) : (
<div className="bg-white rounded-lg p-6 shadow-sm">
<h2 className="text-2xl font-semibold mb-6 font-hse-sans">
{`Редактирование задачи ${tasks.find(t => t.id === taskId)?.number || ""}`}
</h2>
<p className="text-gray-500 font-hse-sans">
Здесь будет форма редактирования задачи
</p>
</div>
)}
</div>
</main>
</div>
);
};
export default CompetitionConstructor;
@@ -0,0 +1,27 @@
import React from 'react';
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
interface TaskDescriptionFieldProps {
description: string;
onChange: (value: string) => void;
}
const TaskDescriptionField: React.FC<TaskDescriptionFieldProps> = ({ description, onChange }) => {
return (
<div className="grid grid-cols-4 items-start gap-4">
<Label htmlFor="description" className="text-right pt-2">
Описание
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => onChange(e.target.value)}
className="col-span-3 min-h-[100px]"
placeholder="Введите описание задачи"
/>
</div>
);
};
export default TaskDescriptionField;
@@ -0,0 +1,92 @@
import React from 'react';
import { FileIcon, X, Upload } from 'lucide-react';
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
interface TaskFileAttachmentsProps {
files: File[];
onChange: (files: File[]) => void;
}
const TaskFileAttachments: React.FC<TaskFileAttachmentsProps> = ({ files, onChange }) => {
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const newFiles = Array.from(e.target.files);
onChange([...files, ...newFiles]);
}
};
const removeFile = (index: number) => {
onChange(files.filter((_, i) => i !== index));
};
return (
<div className="grid grid-cols-4 items-start gap-4">
<Label className="text-right pt-2">
Файлы
</Label>
<div className="col-span-3">
<div className="flex flex-col gap-2">
{files.map((file, index) => (
<FileListItem
key={index}
file={file}
onRemove={() => removeFile(index)}
/>
))}
<FileUploadButton onChange={handleFileChange} />
</div>
</div>
</div>
);
};
interface FileListItemProps {
file: File;
onRemove: () => void;
}
const FileListItem: React.FC<FileListItemProps> = ({ file, onRemove }) => {
return (
<div className="flex items-center justify-between p-2 border rounded-md">
<div className="flex items-center">
<FileIcon size={16} className="mr-2 text-gray-500" />
<span className="text-sm">{file.name}</span>
<span className="text-xs text-gray-500 ml-2">
({Math.round(file.size / 1024)} KB)
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={onRemove}
>
<X size={16} />
</Button>
</div>
);
};
interface FileUploadButtonProps {
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const FileUploadButton: React.FC<FileUploadButtonProps> = ({ onChange }) => {
return (
<label className="cursor-pointer">
<div className="flex items-center gap-2 p-2 border border-dashed rounded-md hover:bg-gray-50 transition-colors">
<Upload size={16} className="text-gray-500" />
<span className="text-sm text-gray-700">Добавить файлы</span>
</div>
<input
type="file"
className="hidden"
onChange={onChange}
multiple
/>
</label>
);
};
export default TaskFileAttachments;
@@ -0,0 +1,26 @@
import React from 'react';
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface TaskNumberFieldProps {
number: string;
onChange: (value: string) => void;
}
const TaskNumberField: React.FC<TaskNumberFieldProps> = ({ number, onChange }) => {
return (
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="task-number" className="text-right">
Номер задачи
</Label>
<Input
id="task-number"
value={number}
onChange={(e) => onChange(e.target.value)}
className="col-span-3"
/>
</div>
);
};
export default TaskNumberField;
@@ -0,0 +1,27 @@
import React from 'react';
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
interface TaskRequirementsFieldProps {
requirements: string;
onChange: (value: string) => void;
}
const TaskRequirementsField: React.FC<TaskRequirementsFieldProps> = ({ requirements, onChange }) => {
return (
<div className="grid grid-cols-4 items-start gap-4">
<Label htmlFor="requirements" className="text-right pt-2">
Требования
</Label>
<Textarea
id="requirements"
value={requirements}
onChange={(e) => onChange(e.target.value)}
className="col-span-3"
placeholder="Введите требования к решению (необязательно)"
/>
</div>
);
};
export default TaskRequirementsField;
@@ -0,0 +1,41 @@
import React from 'react';
import {
RadioGroup,
RadioGroupItem
} from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
interface TaskSolutionTypeSelectorProps {
solutionType: 'input' | 'file' | 'code';
onChange: (value: 'input' | 'file' | 'code') => void;
}
const TaskSolutionTypeSelector: React.FC<TaskSolutionTypeSelectorProps> = ({ solutionType, onChange }) => {
return (
<div className="grid grid-cols-4 items-start gap-4">
<Label className="text-right pt-2">
Тип решения
</Label>
<RadioGroup
className="col-span-3"
value={solutionType}
onValueChange={(value) => onChange(value as 'input' | 'file' | 'code')}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="input" id="input" />
<Label htmlFor="input">Ввод ответа</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="file" id="file" />
<Label htmlFor="file">Загрузка файла</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="code" id="code" />
<Label htmlFor="code">Программный код</Label>
</div>
</RadioGroup>
</div>
);
};
export default TaskSolutionTypeSelector;
@@ -0,0 +1,101 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Task } from "@/shared/types";
import TaskNumberField from './components/TaskNumberField';
import TaskDescriptionField from './components/TaskDescriptionField';
import TaskRequirementsField from './components/TaskRequirementsField';
import TaskSolutionTypeSelector from './components/TaskSolutionTypeSelector';
import TaskFileAttachments from './components/TaskFileAttachments';
interface TaskCreationModalProps {
isOpen: boolean;
onClose: () => void;
onCreateTask: (task: Partial<Task>) => void;
taskCount: number;
}
const TaskCreationModal: React.FC<TaskCreationModalProps> = ({
isOpen,
onClose,
onCreateTask,
taskCount
}) => {
const [number, setNumber] = useState(`${taskCount + 1}`);
const [description, setDescription] = useState('');
const [requirements, setRequirements] = useState('');
const [solutionType, setSolutionType] = useState<'input' | 'file' | 'code'>('input');
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
const handleSubmit = () => {
const newTask: Partial<Task> = {
number,
description,
requirements: requirements || undefined,
solutionType,
attachments: attachedFiles.map(file => file.name)
};
onCreateTask(newTask);
setNumber(`${taskCount + 1}`);
setDescription('');
setRequirements('');
setSolutionType('input');
setAttachedFiles([]);
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px] font-hse-sans">
<DialogHeader>
<DialogTitle className="text-xl">Создание новой задачи</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<TaskNumberField
number={number}
onChange={setNumber}
/>
<TaskDescriptionField
description={description}
onChange={setDescription}
/>
<TaskRequirementsField
requirements={requirements}
onChange={setRequirements}
/>
<TaskSolutionTypeSelector
solutionType={solutionType}
onChange={setSolutionType}
/>
<TaskFileAttachments
files={attachedFiles}
onChange={setAttachedFiles}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Отмена
</Button>
<Button type="button" onClick={handleSubmit}>
Создать задачу
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default TaskCreationModal;
@@ -15,7 +15,7 @@ const CompetitionHeader: React.FC<CompetitionHeaderProps> = ({
competitionId competitionId
}) => { }) => {
return ( return (
<header className="bg-white shadow-sm"> <header className="bg-white shadow-sm sticky top-0 z-30 w-full">
<div className="mx-auto max-w-6xl px-4"> <div className="mx-auto max-w-6xl px-4">
<div className="py-4 text-center"> <div className="py-4 text-center">
<h1 className="font-hse-sans text-xl font-semibold"> <h1 className="font-hse-sans text-xl font-semibold">
@@ -1,24 +1,61 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { useParams, Navigate } from "react-router-dom"; import { useParams, Navigate } from "react-router-dom";
import { Task } from "@/shared/types"; import { Task, TaskStatus } from "@/shared/types";
import { mockSolutions, mockTasks } from "@/shared/mocks/mocks"; import { mockSolutions } from "@/shared/mocks/mocks"; // Keep mocks for solutions for now
import CompetitionHeader from "./components/CompetitionHeader"; import CompetitionHeader from "./components/CompetitionHeader";
import TaskContent from "./components/TaskContent"; import TaskContent from "./components/TaskContent";
import TaskSolution from "./modules/TaskSolution"; import TaskSolution from "./modules/TaskSolution";
import { getCompetitionTasks } from "@/shared/api/session";
import { Loader2 } from "lucide-react";
const CompetitionSession = () => { const CompetitionSession = () => {
const { id, taskId } = useParams<{ id: string; taskId?: string }>(); const { id, taskId } = useParams<{ id: string; taskId?: string }>();
const [tasks] = useState<Task[]>(mockTasks); const [tasks, setTasks] = useState<Task[]>([]);
const [answer, setAnswer] = useState(""); const [answer, setAnswer] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const currentTask = tasks.find((t) => t.id === taskId) || tasks.at(0); const competitionId = id || "";
if (!taskId && tasks.length > 0) { useEffect(() => {
return <Navigate to={`/competition/${id}/tasks/${tasks[0].id}`} replace />; const fetchTasks = async () => {
try {
setLoading(true);
const fetchedTasks = await getCompetitionTasks(competitionId);
setTasks(fetchedTasks);
setError(null);
} catch (err) {
console.error("Failed to fetch tasks:", err);
setError("Не удалось загрузить задания. Пожалуйста, попробуйте позже.");
} finally {
setLoading(false);
}
};
if (competitionId) {
fetchTasks();
}
}, [competitionId]);
const currentTask = tasks.find((t) => t.id === taskId) || null;
if (!taskId && tasks.length > 0 && !loading) {
return (
<Navigate
to={`/competition/${competitionId}/tasks/${tasks[0].id}`}
replace
/>
);
} }
const handleSubmit = () => { const handleSubmit = async () => {
console.log("Submitting answer:", answer); if (!currentTask || !competitionId) return;
try {
console.log("Solution submitted successfully");
} catch (err) {
console.error("Failed to submit solution:", err);
}
}; };
return ( return (
@@ -26,17 +63,26 @@ const CompetitionSession = () => {
<CompetitionHeader <CompetitionHeader
title="Олимпиада DANO 2025. Индивидуальный этап" title="Олимпиада DANO 2025. Индивидуальный этап"
tasks={tasks} tasks={tasks}
competitionId={id || ""} competitionId={competitionId}
/> />
<main className="flex-1 bg-[#F8F8F8] pb-8"> <main className="flex-1 bg-[#F8F8F8] pb-8">
<div className="mx-auto max-w-6xl px-4 py-6"> <div className="mx-auto max-w-6xl px-4 py-6">
{currentTask ? ( {loading ? (
<div className="flex h-40 flex-col items-center justify-center rounded-lg bg-white">
<Loader2 className="mb-2 h-8 w-8 animate-spin text-gray-400" />
<p className="font-hse-sans text-gray-500">Загрузка заданий...</p>
</div>
) : error ? (
<div className="flex h-40 items-center justify-center rounded-lg bg-white">
<p className="font-hse-sans text-red-500">{error}</p>
</div>
) : currentTask ? (
<div className="font-hse-sans flex flex-col gap-6 md:flex-row"> <div className="font-hse-sans flex flex-col gap-6 md:flex-row">
<TaskContent task={currentTask} /> <TaskContent task={currentTask} />
<TaskSolution <TaskSolution
task={currentTask} task={currentTask}
solutions={mockSolutions} solutions={mockSolutions} // Still using mock solutions
answer={answer} answer={answer}
setAnswer={setAnswer} setAnswer={setAnswer}
onSubmit={handleSubmit} onSubmit={handleSubmit}
@@ -44,7 +90,7 @@ const CompetitionSession = () => {
</div> </div>
) : ( ) : (
<div className="flex h-40 items-center justify-center rounded-lg bg-white"> <div className="flex h-40 items-center justify-center rounded-lg bg-white">
<p className="font-hse-sans text-gray-500">Загрузка задания...</p> <p className="font-hse-sans text-gray-500">Задание не найдено</p>
</div> </div>
)} )}
</div> </div>
@@ -1,4 +1,4 @@
import { Button, buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { DataRushReview } from "@/components/ui/icons/datarush-review"; import { DataRushReview } from "@/components/ui/icons/datarush-review";
import { Reviewer } from "@/shared/types/review"; import { Reviewer } from "@/shared/types/review";
import { Link } from "react-router"; import { Link } from "react-router";
@@ -0,0 +1,398 @@
import React from "react";
import { User } from "lucide-react";
import { useUserStore } from "@/shared/stores/user";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const UserProfile = () => {
const user = useUserStore((state) => state.user);
return (
<div className="container mx-auto max-w-5xl px-4 py-8">
<div className="mb-8 flex items-center gap-6">
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-blue-100">
{user?.avatar ? (
<img
src={user.avatar}
alt={user.username}
className="h-24 w-24 rounded-full object-cover"
/>
) : (
<User size={40} className="text-blue-500" />
)}
</div>
<div>
<h1 className="font-hse-sans text-3xl font-bold">{user?.username}</h1>
<p className="font-hse-sans text-gray-500">
{user?.role || "Участник"} На платформе с{" "}
{new Date(user?.createdAt || Date.now()).toLocaleDateString("ru-RU", {
year: "numeric",
month: "long",
})}
</p>
</div>
</div>
<Tabs defaultValue="info" className="w-full">
<TabsList className="mb-6 w-full justify-start">
<TabsTrigger value="info" className="font-hse-sans">
Информация
</TabsTrigger>
<TabsTrigger value="statistics" className="font-hse-sans">
Статистика
</TabsTrigger>
<TabsTrigger value="achievements" className="font-hse-sans">
Достижения
</TabsTrigger>
</TabsList>
<TabsContent value="info">
<UserInfo />
</TabsContent>
<TabsContent value="statistics">
<UserStatistics />
</TabsContent>
<TabsContent value="achievements">
<UserAchievements />
</TabsContent>
</Tabs>
</div>
);
};
const UserInfo = () => {
const user = useUserStore((state) => state.user);
return (
<Card>
<CardHeader>
<CardTitle className="font-hse-sans">Личная информация</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<h3 className="font-hse-sans text-sm font-medium text-gray-500">
Полное имя
</h3>
<p className="font-hse-sans mt-1">
{user?.fullName || "Не указано"}
</p>
</div>
<div>
<h3 className="font-hse-sans text-sm font-medium text-gray-500">
Email
</h3>
<p className="font-hse-sans mt-1">{user?.email || "Не указано"}</p>
</div>
<div>
<h3 className="font-hse-sans text-sm font-medium text-gray-500">
Учебное заведение
</h3>
<p className="font-hse-sans mt-1">
{user?.university || "Не указано"}
</p>
</div>
<div>
<h3 className="font-hse-sans text-sm font-medium text-gray-500">
Специализация
</h3>
<p className="font-hse-sans mt-1">
{user?.specialization || "Не указано"}
</p>
</div>
</div>
<div>
<h3 className="font-hse-sans text-sm font-medium text-gray-500">
О себе
</h3>
<p className="font-hse-sans mt-1">
{user?.bio || "Пользователь пока не добавил информацию о себе."}
</p>
</div>
</CardContent>
</Card>
);
};
const UserStatistics = () => {
// Mock statistics data
const statistics = {
totalCompetitions: 12,
completedCompetitions: 8,
totalScore: 756,
averageScore: 94.5,
bestResult: {
competition: "Олимпиада DANO 2024",
place: 3,
score: 97,
},
totalTasks: 86,
solvedTasks: 72,
tasksByStatus: {
correct: 58,
partial: 14,
wrong: 9,
unattempted: 5,
},
};
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Всего соревнований"
value={statistics.totalCompetitions}
/>
<StatCard
title="Завершено соревнований"
value={statistics.completedCompetitions}
/>
<StatCard title="Всего баллов" value={statistics.totalScore} />
<StatCard
title="Средний балл"
value={statistics.averageScore.toFixed(1)}
/>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="font-hse-sans">Лучший результат</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<p className="font-hse-sans text-lg font-medium">
{statistics.bestResult.competition}
</p>
<div className="flex items-center justify-between">
<span className="font-hse-sans text-gray-500">Место</span>
<span className="font-hse-sans font-medium">
{statistics.bestResult.place}
</span>
</div>
<div className="flex items-center justify-between">
<span className="font-hse-sans text-gray-500">Баллы</span>
<span className="font-hse-sans font-medium">
{statistics.bestResult.score}
</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="font-hse-sans">Решение задач</CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<span className="font-hse-sans">Всего задач</span>
<span className="font-hse-sans font-medium">
{statistics.totalTasks}
</span>
</div>
<div className="flex items-center justify-between">
<span className="font-hse-sans">Решено задач</span>
<span className="font-hse-sans font-medium">
{statistics.solvedTasks}
</span>
</div>
</div>
<div className="space-y-2">
<h4 className="font-hse-sans text-sm font-medium">
Статусы решений
</h4>
<div className="h-6 w-full overflow-hidden rounded-full bg-gray-200">
<div className="flex h-full">
<div
className="bg-green-500"
style={{
width: `${
(statistics.tasksByStatus.correct /
statistics.totalTasks) *
100
}%`,
}}
></div>
<div
className="bg-yellow-500"
style={{
width: `${
(statistics.tasksByStatus.partial /
statistics.totalTasks) *
100
}%`,
}}
></div>
<div
className="bg-red-500"
style={{
width: `${
(statistics.tasksByStatus.wrong /
statistics.totalTasks) *
100
}%`,
}}
></div>
<div
className="bg-gray-300"
style={{
width: `${
(statistics.tasksByStatus.unattempted /
statistics.totalTasks) *
100
}%`,
}}
></div>
</div>
</div>
<div className="flex justify-between text-xs">
<div className="flex items-center">
<div className="mr-1 h-3 w-3 rounded-full bg-green-500"></div>
<span className="font-hse-sans">
Верно ({statistics.tasksByStatus.correct})
</span>
</div>
<div className="flex items-center">
<div className="mr-1 h-3 w-3 rounded-full bg-yellow-500"></div>
<span className="font-hse-sans">
Частично ({statistics.tasksByStatus.partial})
</span>
</div>
<div className="flex items-center">
<div className="mr-1 h-3 w-3 rounded-full bg-red-500"></div>
<span className="font-hse-sans">
Неверно ({statistics.tasksByStatus.wrong})
</span>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
};
const StatCard = ({ title, value }: { title: string; value: number | string }) => (
<Card>
<CardContent className="pt-6">
<p className="font-hse-sans text-sm text-gray-500">{title}</p>
<p className="font-hse-sans mt-2 text-3xl font-bold">{value}</p>
</CardContent>
</Card>
);
const UserAchievements = () => {
const achievements = [
{
id: 1,
name: "Первые шаги",
description: "Участие в первом соревновании",
imageUrl: "/achievements/first-steps.png",
unlocked: true,
},
{
id: 2,
name: "Восходящая звезда",
description: "Победа в соревновании",
imageUrl: "/achievements/rising-star.png",
unlocked: true,
},
{
id: 3,
name: "Мастер кода",
description: "Решите 50 задач на программирование",
imageUrl: "/achievements/code-master.png",
unlocked: true,
},
{
id: 4,
name: "Бронзовый призер",
description: "Займите 3 место в соревновании",
imageUrl: "/achievements/bronze.png",
unlocked: true,
},
{
id: 5,
name: "Серебряный призер",
description: "Займите 2 место в соревновании",
imageUrl: "/achievements/silver.png",
unlocked: false,
},
{
id: 6,
name: "Золотой призер",
description: "Займите 1 место в соревновании",
imageUrl: "/achievements/gold.png",
unlocked: false,
},
{
id: 7,
name: "Марафонец",
description: "Участвуйте в 10 соревнованиях",
imageUrl: "/achievements/marathon.png",
unlocked: false,
},
{
id: 8,
name: "Идеальное решение",
description: "Получите максимальные баллы за все задачи в соревновании",
imageUrl: "/achievements/perfect.png",
unlocked: false,
},
];
return (
<div className="space-y-6">
<h2 className="font-hse-sans text-xl font-semibold">
Разблокировано {achievements.filter(a => a.unlocked).length} из {achievements.length}
</h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{achievements.map((achievement) => (
<div
key={achievement.id}
className={`flex flex-col items-center justify-center rounded-lg p-4 text-center ${
achievement.unlocked ? "" : "opacity-40"
}`}
>
<div className="mb-3 flex h-20 w-20 items-center justify-center rounded-full bg-blue-100">
{achievement.imageUrl ? (
<div className="relative h-16 w-16 overflow-hidden rounded-full">
<div
className="h-full w-full bg-cover bg-center"
style={{ backgroundImage: `url(${achievement.imageUrl})` }}
></div>
</div>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-blue-200 text-blue-700">
<span className="font-hse-sans text-xl font-bold">
{achievement.name.substring(0, 1)}
</span>
</div>
)}
</div>
<h3 className="font-hse-sans text-sm font-medium">
{achievement.name}
</h3>
<p className="font-hse-sans mt-1 text-xs text-gray-500">
{achievement.description}
</p>
</div>
))}
</div>
</div>
);
};
export default UserProfile;
@@ -0,0 +1,45 @@
const UserAchievements = () => {
return (
<div className="space-y-6">
<h2 className="font-hse-sans text-xl font-semibold">
Разблокировано {achievements.filter(a => a.unlocked).length} из {achievements.length}
</h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{achievements.map((achievement) => (
<div
key={achievement.id}
className={`flex flex-col items-center justify-center rounded-lg p-4 text-center ${
achievement.unlocked ? "" : "opacity-40"
}`}
>
<div className="mb-3 flex h-20 w-20 items-center justify-center rounded-full bg-blue-100">
{achievement.imageUrl ? (
<div className="relative h-16 w-16 overflow-hidden rounded-full">
<div
className="h-full w-full bg-cover bg-center"
style={{ backgroundImage: `url(${achievement.imageUrl})` }}
></div>
</div>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-blue-200 text-blue-700">
<span className="font-hse-sans text-xl font-bold">
{achievement.name.substring(0, 1)}
</span>
</div>
)}
</div>
<h3 className="font-hse-sans text-sm font-medium">
{achievement.name}
</h3>
<p className="font-hse-sans mt-1 text-xs text-gray-500">
{achievement.description}
</p>
</div>
))}
</div>
</div>
);
};
export default UserAchievements
@@ -0,0 +1,82 @@
import { apiFetch } from './index';
import { Task, TaskStatus } from '@/shared/types';
interface ApiTask {
id: string;
title: string;
description: string;
type: 'input' | 'file' | 'code';
in_competition_position: number;
points: number;
status?: TaskStatus;
}
/**
* Fetches tasks for a specific competition
* @param competitionId - The ID of the competition
* @returns Promise with an array of tasks in the application's format
*/
export const getCompetitionTasks = async (competitionId: string): Promise<Task[]> => {
try {
const apiTasks: ApiTask[] = await apiFetch(`/api/v1/competitions/${competitionId}/tasks`);
// Transform API tasks to application Task format
return apiTasks.map(apiTask => transformApiTask(apiTask));
} catch (error) {
console.error('Failed to fetch competition tasks:', error);
throw error;
}
};
/**
* Transforms an API task to the application's Task format
*/
const transformApiTask = (apiTask: ApiTask): Task => {
return {
id: apiTask.id,
number: String(apiTask.in_competition_position),
status: apiTask.status || TaskStatus.Uncleared,
solutionType: apiTask.type,
description: apiTask.description,
maxScore: apiTask.points
};
};
// export const submitTaskSolution = async (
// competitionId: string,
// taskId: string,
// solution: string | File
// ): Promise<void> => {
// const endpoint = `/api/v1/competitions/${competitionId}/tasks/${taskId}/submit`;
// // Handle different solution types
// if (typeof solution === 'string') {
// // Text or code solution
// await apiFetch(endpoint, {
// method: 'POST',
// body: { answer: solution }
// });
// } else {
// // File solution
// const formData = new FormData();
// formData.append('file', solution);
// await apiFetch(endpoint, {
// method: 'POST',
// body: formData
// });
// }
// };
/**
* Gets the status of a task submission
* This would be used to poll for updates after submission
*/
// export const getTaskSubmissionStatus = async (
// competitionId: string,
// taskId: string
// ): Promise<TaskStatus> => {
// const response = await apiFetch(`/api/v1/competitions/${competitionId}/tasks/${taskId}/status`);
// return response.status;
// };
+109 -9
View File
@@ -57,49 +57,70 @@ const mockTasks: Task[] = [
id: "1", id: "1",
number: "1.1", number: "1.1",
status: TaskStatus.Uncleared, status: TaskStatus.Uncleared,
solutionType: "input" solutionType: "input",
description: "123",
maxScore: 10,
}, },
{ {
id: "2", id: "2",
number: "1.2", number: "1.2",
status: TaskStatus.Checking, status: TaskStatus.Checking,
solutionType: "file" solutionType: "file",
description: "123",
maxScore: 20,
}, },
{ {
id: "3", id: "3",
number: "1.3", number: "1.3",
status: TaskStatus.Correct, status: TaskStatus.Correct,
solutionType: "code" solutionType: "code",
description: "123",
maxScore: 20,
}, },
{ {
id: "4", id: "4",
number: "2.1", number: "2.1",
status: TaskStatus.Partial, status: TaskStatus.Partial,
solutionType: "input" solutionType: "input",
description: "123",
maxScore: 20,
}, },
{ {
id: "5", id: "5",
number: "2.2", number: "2.2",
status: TaskStatus.Wrong, status: TaskStatus.Wrong,
solutionType: "file" solutionType: "file",
description: "123",
maxScore: 20,
}, },
{ {
id: "6", id: "6",
number: "2.3", number: "2.3",
status: TaskStatus.Uncleared, status: TaskStatus.Uncleared,
solutionType: "code" solutionType: "code",
description: "123",
maxScore: 20,
}, },
{ {
id: "7", id: "7",
number: "3.1", number: "3.1",
status: TaskStatus.Checking, status: TaskStatus.Checking,
solutionType: "file" solutionType: "file",
description: "123",
maxScore: 20,
}, },
{ {
id: "8", id: "8",
number: "3.2", number: "3.2",
status: TaskStatus.Correct, status: TaskStatus.Correct,
solutionType: "input" solutionType: "input",
description: "123",
maxScore: 20,
}, },
]; ];
@@ -132,5 +153,84 @@ const mockSolutions: Solution[] = [
]; ];
const mockAchievements = [
{
id: 1,
name: "Первые шаги",
description: "Участие в первом соревновании",
imageUrl: "/achievements/first-steps.png",
unlocked: true,
},
{
id: 2,
name: "Восходящая звезда",
description: "Победа в соревновании",
imageUrl: "/achievements/rising-star.png",
unlocked: true,
},
{
id: 3,
name: "Мастер кода",
description: "Решите 50 задач на программирование",
imageUrl: "/achievements/code-master.png",
unlocked: true,
},
{
id: 4,
name: "Бронзовый призер",
description: "Займите 3 место в соревновании",
imageUrl: "/achievements/bronze.png",
unlocked: true,
},
{
id: 5,
name: "Серебряный призер",
description: "Займите 2 место в соревновании",
imageUrl: "/achievements/silver.png",
unlocked: false,
},
{
id: 6,
name: "Золотой призер",
description: "Займите 1 место в соревновании",
imageUrl: "/achievements/gold.png",
unlocked: false,
},
{
id: 7,
name: "Марафонец",
description: "Участвуйте в 10 соревнованиях",
imageUrl: "/achievements/marathon.png",
unlocked: false,
},
{
id: 8,
name: "Идеальное решение",
description: "Получите максимальные баллы за все задачи в соревновании",
imageUrl: "/achievements/perfect.png",
unlocked: false,
},
];
export { mockCompetitions, mockTasks, mockSolutions };
const mockStatistics = {
totalCompetitions: 12,
completedCompetitions: 8,
totalScore: 756,
averageScore: 94.5,
bestResult: {
competition: "Олимпиада DANO 2024",
place: 3,
score: 97,
},
totalTasks: 86,
solvedTasks: 72,
tasksByStatus: {
correct: 58,
partial: 14,
wrong: 9,
unattempted: 5,
},
};
export { mockCompetitions, mockTasks, mockSolutions, mockAchievements, mockStatistics };
+5
View File
@@ -30,11 +30,16 @@ interface Solution {
score?: number, score?: number,
maxScore?: number, maxScore?: number,
} }
interface Task { interface Task {
id: string; id: string;
number: string; number: string;
description: string;
maxScore: number;
status: TaskStatus; status: TaskStatus;
solutionType: SolutionType; solutionType: SolutionType;
requirements?: string;
attachments?: string[];
} }
export { CompetitionStatus, TaskStatus }; export { CompetitionStatus, TaskStatus };