diff --git a/services/backend/api/v1/task/schemas.py b/services/backend/api/v1/task/schemas.py index 3e07b92..a203f22 100644 --- a/services/backend/api/v1/task/schemas.py +++ b/services/backend/api/v1/task/schemas.py @@ -62,3 +62,8 @@ class TaskAttachmentSchema(ModelSchema): "file", "public", ) + + +class TaskStatusSchema(Schema): + task_name: str + result: int diff --git a/services/backend/api/v1/task/views.py b/services/backend/api/v1/task/views.py index f2e93c9..137e73e 100644 --- a/services/backend/api/v1/task/views.py +++ b/services/backend/api/v1/task/views.py @@ -1,7 +1,7 @@ from http import HTTPStatus as status from uuid import UUID -from django.shortcuts import get_object_or_404 +from django.shortcuts import get_object_or_404, get_list_or_404 from ninja import File, Router, UploadedFile from api.v1.ping.schemas import PingOut @@ -11,6 +11,7 @@ from api.v1.task.schemas import ( TaskAttachmentSchema, TaskOutSchema, TaskSubmissionOut, + TaskStatusSchema, ) from apps.achievement.models import Achievement, UserAchievement from apps.competition.models import State @@ -116,14 +117,16 @@ def submit_task( return status.FORBIDDEN, ForbiddenError() if task.type == CompetitionTask.CompetitionTaskType.INPUT: + verdict = content.read() == task.correct_answer_file.read() submission = CompetitionTaskSubmission.objects.create( user=user, task=task, status=CompetitionTaskSubmission.StatusChoices.CHECKED, content=content, result={ - "correct": content.read() == task.correct_answer_file.read() + "correct": verdict }, + earned_points=task.points ) if task.type == CompetitionTask.CompetitionTaskType.REVIEW: submission = CompetitionTaskSubmission.objects.create( @@ -173,3 +176,31 @@ def get_submissions_history(request, competition_id: UUID, task_id: UUID): 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(task=task).all() + + +@router.get( + "competitions/{competition_id}/results", + response={ + status.OK: list[TaskStatusSchema], + status.UNAUTHORIZED: UnauthorizedError + }, +) +def get_competition_results(request, competition_id: UUID): + tasks = get_list_or_404(CompetitionTask, competition_id=competition_id) + + data = [] + + for task in tasks: + submissions = CompetitionTaskSubmission.objects.filter( + user=request.auth, task=task + ).filter(status="checked").all() + if not submissions: + result = 0 + else: + result = submissions[0].earned_points + data.append(TaskStatusSchema( + task_name=task.title, + result=result + )) + + return status.OK, data diff --git a/services/backend/apps/task/admin.py b/services/backend/apps/task/admin.py index ca097c0..af2477b 100644 --- a/services/backend/apps/task/admin.py +++ b/services/backend/apps/task/admin.py @@ -42,7 +42,7 @@ class CompetitionTaskSubmissionAdmin(admin.ModelAdmin): "user__username", "user__email", ) - list_filter = ("plagiarism_checked", "status") + list_filter = ("plagiarism_detected", "status") ordering = ["-timestamp"] def has_add_permission(self, request, obj=None): diff --git a/services/backend/apps/task/migrations/0002_remove_competitiontasksubmission_plagiarism_checked_and_more.py b/services/backend/apps/task/migrations/0002_remove_competitiontasksubmission_plagiarism_checked_and_more.py new file mode 100644 index 0000000..01c63d2 --- /dev/null +++ b/services/backend/apps/task/migrations/0002_remove_competitiontasksubmission_plagiarism_checked_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.6 on 2025-03-03 13:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('task', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='competitiontasksubmission', + name='plagiarism_checked', + ), + migrations.AddField( + model_name='competitiontasksubmission', + name='plagiarism_detected', + field=models.BooleanField(default=False, verbose_name='обнаружен плагиат'), + ), + ] diff --git a/services/backend/apps/task/models.py b/services/backend/apps/task/models.py index 8f6936e..77123b2 100644 --- a/services/backend/apps/task/models.py +++ b/services/backend/apps/task/models.py @@ -178,8 +178,8 @@ class CompetitionTaskSubmission(BaseModel): checked_at = models.DateTimeField( null=True, blank=True, verbose_name="дата проверки" ) - plagiarism_checked = models.BooleanField( - default=False, verbose_name="проверено на плагиат" + plagiarism_detected = models.BooleanField( + default=False, verbose_name="обнаружен плагиат" ) timestamp = models.DateTimeField( auto_now_add=True, verbose_name="дата отправки" diff --git a/services/backend/apps/task/tasks.py b/services/backend/apps/task/tasks.py index 86588f4..b68ecf2 100644 --- a/services/backend/apps/task/tasks.py +++ b/services/backend/apps/task/tasks.py @@ -4,6 +4,7 @@ import httpx from celery import shared_task from django.conf import settings from django.core.files.base import ContentFile +from urllib.parse import urlparse from apps.task.models import CompetitionTaskSubmission @@ -12,10 +13,16 @@ from apps.task.models import CompetitionTaskSubmission def analyze_data_task(self, submission_id): submission = CompetitionTaskSubmission.objects.get(id=submission_id) try: - code_url = f"{settings.MINIO_DEFAULT_CUSTOM_ENDPOINT_URL}{submission.content.path}" + code_url = ( + f"{settings.MINIO_DEFAULT_CUSTOM_ENDPOINT_URL}/" + f"{urlparse(submission.content.url).path}" + ) files = [ { - "url": f"{settings.MINIO_DEFAULT_CUSTOM_ENDPOINT_URL}{attachment.path}", + "url": ( + f"{settings.MINIO_DEFAULT_CUSTOM_ENDPOINT_URL}/" + f"{urlparse(submission.content.url).path}" + ), "bind_path": attachment.bind_at, } for attachment in submission.task.attachments.filter( @@ -40,12 +47,12 @@ def analyze_data_task(self, submission_id): submission.stdout.save("output.txt", ContentFile(result["output"])) submission.result = { - "correct": result["hash_match"], - "result_hash": result["result_hash"], + "correct": result["correct"], + "hash_match": result["hash_match"], "error": result.get("error"), } submission.earned_points = ( - submission.task.points if result["hash_match"] else 0 + submission.task.points if result["correct"] else 0 ) submission.status = CompetitionTaskSubmission.StatusChoices.CHECKED diff --git a/services/frontend/bun.lock b/services/frontend/bun.lock index c5f880c..3448773 100644 --- a/services/frontend/bun.lock +++ b/services/frontend/bun.lock @@ -30,6 +30,7 @@ "react-router": "^7.2.0", "react-router-dom": "^7.2.0", "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.9", @@ -605,8 +606,24 @@ "lucide-react": ["lucide-react@0.476.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-x6cLTk8gahdUPje0hSgLN1/MgiJH+Xl90Xoxy9bkPAsMPOUiyRSKR4JCDPGVCEpyqnZXH3exFWNItcvra9WzUQ=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + "mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="], "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], @@ -629,6 +646,20 @@ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + "micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="], "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], @@ -743,12 +774,16 @@ "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + "remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="], "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], "remark-rehype": ["remark-rehype@11.1.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ=="], + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], @@ -873,6 +908,8 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], diff --git a/services/frontend/package.json b/services/frontend/package.json index 27b9a85..98bd7bf 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -36,6 +36,7 @@ "react-router": "^7.2.0", "react-router-dom": "^7.2.0", "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.9", diff --git a/services/frontend/src/pages/Competition/index.tsx b/services/frontend/src/pages/Competition/index.tsx index e472ddb..ed1320c 100644 --- a/services/frontend/src/pages/Competition/index.tsx +++ b/services/frontend/src/pages/Competition/index.tsx @@ -1,12 +1,15 @@ import { useParams, Link, useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, Clock, Trophy, BookOpen } from "lucide-react"; +import { ArrowLeft, Clock, Trophy, BookOpen, BarChart2, AlertCircle } from "lucide-react"; import ReactMarkdown from "react-markdown"; import { useQuery, useMutation } from "@tanstack/react-query"; import { getCompetition, startCompetition } from "@/shared/api/competitions"; import { getCompetitionTasks } from "@/shared/api/session"; import { Loading } from "@/components/ui/loading"; import { CompetitionType } from "@/shared/types/competition"; +import remarkMath from "remark-math"; +import remarkGfm from "remark-gfm"; +import rehypeKatex from "rehype-katex"; const CompetitionPage = () => { const { id } = useParams<{ id: string }>(); @@ -39,6 +42,7 @@ const CompetitionPage = () => { console.error("Failed to start competition:", error); } }); + const formatDate = (date?: Date | string) => { if (!date) return ""; @@ -56,6 +60,10 @@ const CompetitionPage = () => { const handleStart = () => { startMutation.mutate(); }; + + const handleViewResults = () => { + console.log("sorryan"); + }; if (competitionQuery.isLoading) { return ; @@ -66,6 +74,27 @@ const CompetitionPage = () => { } const competition = competitionQuery.data; + + const isCompetitionEnded = () => { + if (!competition?.end_date) return false; + + const endDate = new Date(competition.end_date); + const now = new Date(); + + return now > endDate; + }; + + const isCompetitionNotStarted = () => { + if (!competition?.start_date) return false; + + const startDate = new Date(competition.start_date); + const now = new Date(); + + return now < startDate; + }; + + const competitionEnded = isCompetitionEnded(); + const competitionNotStarted = isCompetitionNotStarted(); return (
@@ -103,6 +132,18 @@ const CompetitionPage = () => { )}
+ + {competitionEnded && competition.type === CompetitionType.COMPETITIVE && ( +
+ Завершено +
+ )} + + {competitionNotStarted && competition.type === CompetitionType.COMPETITIVE && ( +
+ Скоро начнется +
+ )}

@@ -128,18 +169,43 @@ const CompetitionPage = () => {
- {competition.description || ""} + + {competition.description || ""} +
- + {competitionEnded && competition.type === CompetitionType.COMPETITIVE ? ( + + ) : competitionNotStarted && competition.type === CompetitionType.COMPETITIVE ? ( + + ) : ( + + )}
diff --git a/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx b/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx index d4bab74..d912f1a 100644 --- a/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/components/CompetitionHeader/index.tsx @@ -1,8 +1,8 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; +import React, { useState, useEffect } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; import { Task } from '@/shared/types/task'; -import { ArrowLeft } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; +import { ArrowLeft, Clock } from 'lucide-react'; +import { CompetitionType } from '@/shared/types/competition'; interface CompetitionHeaderProps { title: string; @@ -10,6 +10,9 @@ interface CompetitionHeaderProps { competitionId: string; setAnswer: (value: string) => void; setSelectedFile: (file: File | null) => void; + competitionType?: CompetitionType; + startDate?: Date; + endDate?: Date; } const CompetitionHeader: React.FC = ({ @@ -17,33 +20,103 @@ const CompetitionHeader: React.FC = ({ tasks, competitionId, setAnswer, - setSelectedFile + setSelectedFile, + competitionType, + startDate, + endDate }) => { const navigate = useNavigate(); - + const [timeLeft, setTimeLeft] = useState(''); + const handleTaskSelect = (taskId: string) => { setAnswer(""); setSelectedFile(null); - console.log("SETTER ERROR") navigate(`/competition/${competitionId}/tasks/${taskId}`); } - + + const formatDate = (date?: Date) => { + if (!date) return ''; + + const dateObj = typeof date === 'string' ? new Date(date) : date; + return dateObj.toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + useEffect(() => { + if (!endDate || competitionType !== CompetitionType.COMPETITIVE) return; + + const endDateObj = typeof endDate === 'string' ? new Date(endDate) : endDate; + + const updateTimer = () => { + const now = new Date(); + const diff = endDateObj.getTime() - now.getTime(); + + if (diff <= 0) { + navigate(`/competition/${competitionId}`); + return; + } + + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + + setTimeLeft(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`); + }; + + updateTimer(); + const timerInterval = setInterval(updateTimer, 1000); + + return () => clearInterval(timerInterval); + }, [endDate, competitionId, navigate, competitionType]); + + const showTimeSection = competitionType === CompetitionType.COMPETITIVE && (startDate || endDate); + return (
- - - +
+ + + + +

+ {title} +

+
+ -

- {title} -

- -
+ {showTimeSection ? ( +
+
+ {startDate && ( + + Начало: {formatDate(startDate)} + + )} + {endDate && ( + + Конец: {formatDate(endDate)} + + )} + {timeLeft && ( + + Осталось: {timeLeft} + + )} +
+
+ ) : ( +
+ )}
diff --git a/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx b/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx index 0c49ca3..634a7a0 100644 --- a/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/components/TaskContent/index.tsx @@ -2,6 +2,7 @@ import React from 'react'; import ReactMarkdown from 'react-markdown'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; +import remarkGfm from 'remark-gfm'; import 'katex/dist/katex.min.css'; import { Task } from '@/shared/types/task'; import { useQuery } from '@tanstack/react-query'; @@ -24,18 +25,27 @@ const TaskContent: React.FC = ({ task }) => { const attachments = attachmentsQuery.data || []; + const convertToMarkdown = (text: string): string => { + if (!text) return ''; + + let markdown = text.replace(/\n/g, '\n\n'); + return markdown; + }; + + const markdownText = convertToMarkdown(task.description); + return (

- Задача {task.in_competition_position} + {task.title}

- {task.description} + {markdownText}
diff --git a/services/frontend/src/pages/CompetitionSession/index.tsx b/services/frontend/src/pages/CompetitionSession/index.tsx index eb80913..fd8ec50 100644 --- a/services/frontend/src/pages/CompetitionSession/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/index.tsx @@ -97,6 +97,9 @@ const CompetitionSession = () => { competitionId={competitionId} setAnswer={setAnswer} setSelectedFile={setSelectedFile} + competitionType={competition?.type} + startDate={competition?.start_date} + endDate={competition?.end_date} />
@@ -120,6 +123,7 @@ const CompetitionSession = () => { selectedFile={selectedFile} setSelectedFile={setSelectedFile} onSubmit={handleSubmit} + isSubmitting={submitMutation.isPending} />
) : ( diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx index a843612..992f117 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/components/FileSolution/index.tsx @@ -8,7 +8,6 @@ interface FileSolutionProps { fileInputRef: React.RefObject; existingFileUrl?: string | null; onClearExistingFile?: () => void; // New prop to clear existing file URL - firstSolution: boolean } const FileSolution: React.FC = ({ @@ -17,7 +16,6 @@ const FileSolution: React.FC = ({ fileInputRef, existingFileUrl = null, onClearExistingFile, - firstSolution }) => { const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files && event.target.files[0]) { @@ -68,7 +66,7 @@ const FileSolution: React.FC = ({ ? existingFileUrl.split('/').pop() || 'file' : ''; - const hasFile = !!selectedFile || (!!existingFileUrl && !firstSolution); + const hasFile = !!selectedFile || !!existingFileUrl; return ( <> diff --git a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx index 5267dbb..cd7906b 100644 --- a/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx +++ b/services/frontend/src/pages/CompetitionSession/modules/TaskSolution/index.tsx @@ -54,7 +54,7 @@ const TaskSolution: React.FC = ({ const latestSolution = solutionHistory[solutionHistory.length - 1]; setDisplayedSolution(latestSolution); } - }, [solutionHistory, displayedSolution]); + }, [solutionHistory]); useEffect(() => { if (prevTaskIdRef.current !== task.id) { @@ -70,38 +70,41 @@ const TaskSolution: React.FC = ({ } }, [task.id, solutionHistory]); - useEffect(() => { - if (solutionHistory.length > 0 && - (!displayedSolution || - (solutionHistory[solutionHistory.length - 1].id !== displayedSolution.id && - displayedSolution.id === solutionHistory[solutionHistory.length - 2]?.id))) { - setDisplayedSolution(solutionHistory[solutionHistory.length - 1]); - } - }, [solutionHistory, displayedSolution]); + // useEffect(() => { + // if (solutionHistory.length > 0 && + // (!displayedSolution || + // (solutionHistory[solutionHistory.length - 1].id !== displayedSolution.id))) { + // setDisplayedSolution(solutionHistory[solutionHistory.length - 1]); + // } + // }, [solutionHistory, displayedSolution]); useEffect(() => { const loadSolutionContent = async () => { if (!displayedSolution || !displayedSolution.content) return; - try { if (task.type === TaskType.FILE) { + setAnswer(""); setSelectedFile(null); setSelectedSolutionUrl(displayedSolution.content); - } else { + } + else { + setSelectedFile(null); + setSelectedSolutionUrl(null); const response = await fetch(displayedSolution.content); if (!response.ok) { throw new Error(`Failed to fetch solution content: ${response.status}`); } const text = await response.text(); + setAnswer(text); } } catch (error) { console.error('Error loading solution content:', error); } }; - + loadSolutionContent(); - }, [displayedSolution, task.type, setAnswer, setSelectedFile]); + }, [displayedSolution, setAnswer, setSelectedFile]); const handleOpenHistory = () => { setIsHistoryOpen(true); @@ -144,7 +147,6 @@ const TaskSolution: React.FC = ({ fileInputRef={fileInputRef} existingFileUrl={selectedSolutionUrl} onClearExistingFile={handleClearExistingFile} - firstSolution={solutionHistory.length > 0} /> )} diff --git a/services/frontend/src/shared/stores/user.ts b/services/frontend/src/shared/stores/user.ts index 6e7509d..86bdc9b 100644 --- a/services/frontend/src/shared/stores/user.ts +++ b/services/frontend/src/shared/stores/user.ts @@ -1,12 +1,14 @@ import { create } from "zustand"; import { User } from "../types/user"; import { getCurrentUser } from "../api/user"; +import Cookies from "js-cookie"; interface UserState { user: User | null; loading: boolean; fetchUser: () => Promise; + clearUser: () => void; } const useUserStore = create((set) => ({ @@ -18,6 +20,16 @@ const useUserStore = create((set) => ({ const user = await getCurrentUser(); set({ user, loading: false }); }, + + clearUser: () => { + set({ user: null }); + + const cookies = Cookies.get(); + Object.keys(cookies).forEach(cookieName => { + Cookies.remove(cookieName, { path: '/' }); + Cookies.remove(cookieName); + }); + }, })); -export { useUserStore }; +export { useUserStore }; \ No newline at end of file