From be01b5fc228e41991a7e851959b61c0edf44efc4 Mon Sep 17 00:00:00 2001 From: andrei Date: Sat, 29 Nov 2025 19:26:25 +0500 Subject: [PATCH] =?UTF-8?q?=D1=81=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=BA=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solution_comments_repository.py | 39 ++++ api/app/application/solutions_repository.py | 2 + .../controllers/solution_comments_router.py | 55 ++++++ api/app/domain/entities/solutions.py | 29 ++- api/app/domain/models/solution_comments.py | 7 +- .../solution_comments_service.py | 75 ++++++++ api/app/main.py | 2 + web/src/Api/commentsApi.js | 39 ++++ web/src/Api/solutionsApi.js | 16 +- .../ViewTaskModalForm/ViewTaskModal.jsx | 176 +++++++++++++++++- .../ViewTaskModalForm/useTaskLessonModal.js | 31 +++ web/src/Redux/store.js | 4 + 12 files changed, 455 insertions(+), 20 deletions(-) create mode 100644 api/app/application/solution_comments_repository.py create mode 100644 api/app/controllers/solution_comments_router.py create mode 100644 api/app/infrastructure/solution_comments_service.py create mode 100644 web/src/Api/commentsApi.js diff --git a/api/app/application/solution_comments_repository.py b/api/app/application/solution_comments_repository.py new file mode 100644 index 0000000..ad9d408 --- /dev/null +++ b/api/app/application/solution_comments_repository.py @@ -0,0 +1,39 @@ +from typing import Optional, List + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.models import SolutionComment + + +class SolutionCommentsRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_by_id(self, comment_id: int) -> Optional[SolutionComment]: + query = ( + select(SolutionComment) + .filter_by(id=comment_id) + ) + result = await self.db.execute(query) + return result.scalars().first() + + async def get_by_solution_id(self, solution_id: int) -> Optional[List[SolutionComment]]: + query = ( + select(SolutionComment) + .filter_by(solution_id=solution_id) + .order_by(SolutionComment.created_at) + ) + result = await self.db.execute(query) + return result.scalars().all() + + async def create(self, comment: SolutionComment) -> SolutionComment: + self.db.add(comment) + await self.db.commit() + await self.db.refresh(comment) + return comment + + async def delete(self, comment: SolutionComment) -> SolutionComment: + await self.db.delete(comment) + await self.db.commit() + return comment diff --git a/api/app/application/solutions_repository.py b/api/app/application/solutions_repository.py index f5779a8..37b79fb 100644 --- a/api/app/application/solutions_repository.py +++ b/api/app/application/solutions_repository.py @@ -25,6 +25,7 @@ class SolutionsRepository: .filter_by(task_id=task_id) .options( selectinload(Solution.files), + selectinload(Solution.solution_comments), ) ) result = await self.db.execute(query) @@ -36,6 +37,7 @@ class SolutionsRepository: .filter_by(task_id=task_id, student_id=student_id) .options( selectinload(Solution.files), + selectinload(Solution.solution_comments), ) ) result = await self.db.execute(query) diff --git a/api/app/controllers/solution_comments_router.py b/api/app/controllers/solution_comments_router.py new file mode 100644 index 0000000..7d2feb5 --- /dev/null +++ b/api/app/controllers/solution_comments_router.py @@ -0,0 +1,55 @@ +from typing import List, Optional + +from fastapi import APIRouter, Depends, status, Response +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_db +from app.domain.entities.solutions import SolutionCommentRead, SolutionCommentCreate +from app.domain.models import SolutionComment, User +from app.infrastructure.dependencies import require_auth_user +from app.infrastructure.solution_comments_service import SolutionCommentsService + +solution_comments_router = APIRouter() + + +@solution_comments_router.get( + '/solution/{solution_id}/', + response_model=List[SolutionCommentRead], + summary='Returns all comments for solution', + description='Returns all comments for solution', +) +async def get_solution_comments( + solution_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth_user), +): + solution_comments_service = SolutionCommentsService(db) + return await solution_comments_service.get_by_solution_id(solution_id) + +@solution_comments_router.post( + '/solution/{solution_id}/', + response_model=SolutionCommentRead, + summary='Creates a new solution comment', + description='Creates a new solution comment', +) +async def create_solution_comment( + solution_id: int, + solution_comment: SolutionCommentCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth_user), +): + solution_comments_service = SolutionCommentsService(db) + return await solution_comments_service.create(solution_comment, user, solution_id) + +@solution_comments_router.delete( + '/{comment_id}/', + summary='Deletes a solution comment', + description='Deletes a solution comment', +) +async def delete_solution_comment( + comment_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth_user), +): + solution_comments_service = SolutionCommentsService(db) + return await solution_comments_service.delete(comment_id) diff --git a/api/app/domain/entities/solutions.py b/api/app/domain/entities/solutions.py index 803daa7..e85e7e0 100644 --- a/api/app/domain/entities/solutions.py +++ b/api/app/domain/entities/solutions.py @@ -27,14 +27,33 @@ class SolutionAfterCreate(SolutionBase): from_attributes = True -class SolutionRead(SolutionAfterCreate): - created_at: datetime +class AssessmentCreate(BaseModel): + assessment: int = Field(...) - files: Optional[List[ReadSolutionFile]] = [] + +class SolutionCommentBase(BaseModel): + comment_text: str = Field(...) + + +class SolutionCommentCreate(SolutionCommentBase): + pass + + +class SolutionCommentRead(SolutionCommentBase): + comment_autor_id: int + solution_id: int + + comment_autor: UserRead class Config: from_attributes = True -class AssessmentCreate(BaseModel): - assessment: int = Field(...) +class SolutionRead(SolutionAfterCreate): + created_at: datetime + + files: Optional[List[ReadSolutionFile]] = [] + solution_comments: Optional[List[SolutionCommentRead]] = [] + + class Config: + from_attributes = True diff --git a/api/app/domain/models/solution_comments.py b/api/app/domain/models/solution_comments.py index 9bec13e..377cd9f 100644 --- a/api/app/domain/models/solution_comments.py +++ b/api/app/domain/models/solution_comments.py @@ -1,9 +1,6 @@ -from typing import List - -from sqlalchemy import String, ForeignKey +from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship - from app.domain.models.base import RootTable @@ -15,5 +12,5 @@ class SolutionComment(RootTable): comment_autor_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False) solution_id: Mapped[int] = mapped_column(ForeignKey('solutions.id'), nullable=False) - comment_autor: Mapped['User'] = relationship('User', back_populates='solution_comments') + comment_autor: Mapped['User'] = relationship('User', back_populates='solution_comments', lazy='joined') solution: Mapped['Solution'] = relationship('Solution', back_populates='solution_comments') \ No newline at end of file diff --git a/api/app/infrastructure/solution_comments_service.py b/api/app/infrastructure/solution_comments_service.py new file mode 100644 index 0000000..85fb08f --- /dev/null +++ b/api/app/infrastructure/solution_comments_service.py @@ -0,0 +1,75 @@ +from http.client import HTTPException +from typing import Optional, List + +from fastapi import HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.application.solution_comments_repository import SolutionCommentsRepository +from app.application.solutions_repository import SolutionsRepository +from app.domain.entities.solutions import SolutionCommentRead, SolutionCommentCreate +from app.domain.models import User, SolutionComment + + +class SolutionCommentsService: + def __init__(self, db: AsyncSession): + self.solution_comments_repository = SolutionCommentsRepository(db) + self.solutions_repository = SolutionsRepository(db) + + async def get_by_id(self, comment_id) -> Optional[SolutionCommentRead]: + comment_model = await self.solution_comments_repository.get_by_id(comment_id) + + if comment_model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Комментарий не найден" + ) + + return SolutionCommentRead.model_validate(comment_model) + + async def get_by_solution_id(self, solution_id) -> Optional[List[SolutionCommentRead]]: + solution_model = await self.solutions_repository.get_by_id(solution_id) + + if solution_model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Решение не найдено" + ) + + comments = await self.solution_comments_repository.get_by_solution_id(solution_id) + response = [] + for comment in comments: + response.append(SolutionCommentRead.model_validate(comment)) + + return response + + async def create( + self, comment: SolutionCommentCreate, user: User, solution_id: int + ) -> Optional[SolutionCommentRead]: + solution_model = await self.solutions_repository.get_by_id(solution_id) + + if solution_model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Решение не найдено" + ) + + comment_model = SolutionComment( + comment_text=comment.comment_text, + comment_autor_id=user.id, + solution_id=solution_model.id, + ) + + comment_model = await self.solution_comments_repository.create(comment_model) + + return SolutionCommentRead.model_validate(comment_model) + + async def delete(self, comment_id: int) -> None: + comment_model = await self.solution_comments_repository.get_by_id(comment_id) + + if comment_model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Комментарий не найден" + ) + + await self.solution_comments_repository.delete(comment_model) diff --git a/api/app/main.py b/api/app/main.py index ec663bb..57b5862 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -6,6 +6,7 @@ from app.controllers.courses_router import courses_router from app.controllers.lessons_router import lessons_router from app.controllers.register_router import register_router from app.controllers.roles_router import roles_router +from app.controllers.solution_comments_router import solution_comments_router from app.controllers.solutions_router import solution_router from app.controllers.statuses_router import statuses_router from app.controllers.tasks_router import tasks_router @@ -30,6 +31,7 @@ def start_app(): api_app.include_router(lessons_router, prefix=f'{settings.prefix}/lessons', tags=['lessons']) api_app.include_router(register_router, prefix=f'{settings.prefix}/register', tags=['register']) api_app.include_router(roles_router, prefix=f'{settings.prefix}/roles', tags=['roles']) + api_app.include_router(solution_comments_router, prefix=f'{settings.prefix}/comments', tags=['comments']) api_app.include_router(solution_router, prefix=f'{settings.prefix}/solutions', tags=['solutions']) api_app.include_router(statuses_router, prefix=f'{settings.prefix}/statuses', tags=['statuses']) api_app.include_router(tasks_router, prefix=f'{settings.prefix}/tasks', tags=['tasks']) diff --git a/web/src/Api/commentsApi.js b/web/src/Api/commentsApi.js new file mode 100644 index 0000000..8e27398 --- /dev/null +++ b/web/src/Api/commentsApi.js @@ -0,0 +1,39 @@ +import {createApi} from "@reduxjs/toolkit/query/react"; +import {baseQueryWithAuth} from "./baseQuery.js"; + + +export const commentsApi = createApi({ + reducerPath: "commentsApi", + baseQuery: baseQueryWithAuth, + tagTypes: ["comments"], + endpoints: (builder) => ({ + getAllCommentsBySolutionId: builder.query({ + query: (solutionId) => ({ + url: `/comments/solution/${solutionId}/`, + method: "GET" + }), + providesTags: ["comments"], + }), + createComment: builder.mutation({ + query: ({solutionId, comment}) => ({ + url: `/comments/solution/${solutionId}/`, + method: "POST", + body: comment, + }), + invalidatesTags: ["comments"], + }), + deleteComment: builder.mutation({ + query: (commentId) => ({ + url: `/comments/${commentId}/`, + method: "DELETE", + }), + invalidatesTags: ["comments"], + }), + }) +}); + +export const { + useGetAllCommentsBySolutionIdQuery, + useCreateCommentMutation, + useDeleteCommentMutation, +} = commentsApi; \ No newline at end of file diff --git a/web/src/Api/solutionsApi.js b/web/src/Api/solutionsApi.js index 9731783..93cdc95 100644 --- a/web/src/Api/solutionsApi.js +++ b/web/src/Api/solutionsApi.js @@ -5,21 +5,21 @@ import {baseQueryWithAuth} from "./baseQuery.js"; export const solutionsApi = createApi({ reducerPath: "solutionsApi", baseQuery: baseQueryWithAuth, - tagTypes: ["lesson"], + tagTypes: ["solution"], endpoints: (builder) => ({ getTaskSolutions: builder.query({ query: (taskId) => ({ url: `/solutions/task/${taskId}/`, method: "GET" }), - providesTags: ["lesson"], + providesTags: ["solution"], }), getTaskStudentSolutions: builder.query({ query: ({taskId, studentId}) => ({ url: `/solutions/task/${taskId}/student/${studentId}/`, method: "GET" }), - providesTags: ["lesson"], + providesTags: ["solution"], }), createSolution: builder.mutation({ query: ({taskId, solution}) => ({ @@ -27,21 +27,21 @@ export const solutionsApi = createApi({ method: "POST", body: solution, }), - invalidatesTags: ["lesson"], + invalidatesTags: ["solution"], }), deleteSolution: builder.mutation({ query: (solutionId) => ({ url: `/solutions/${solutionId}/`, method: "DELETE", }), - invalidatesTags: ["lesson"], + invalidatesTags: ["solution"], }), getSolutionFilesList: builder.query({ query: (solutionId) => ({ url: `/solutions/files/${solutionId}/`, method: "GET" }), - providesTags: ["lesson"], + providesTags: ["solution"], }), uploadFile: builder.mutation({ query: ({solutionId, fileData}) => { @@ -57,7 +57,7 @@ export const solutionsApi = createApi({ body: formData, }; }, - invalidatesTags: ["task"], + invalidatesTags: ["solution"], }), createAssessment: builder.mutation({ query: ({solutionId, assessment}) => ({ @@ -65,7 +65,7 @@ export const solutionsApi = createApi({ method: "POST", body: assessment, }), - invalidatesTags: ["lesson"], + invalidatesTags: ["solution"], }), }), }); diff --git a/web/src/Components/Pages/CourseDetailPage/Components/ViewTaskModalForm/ViewTaskModal.jsx b/web/src/Components/Pages/CourseDetailPage/Components/ViewTaskModalForm/ViewTaskModal.jsx index 7979806..545949c 100644 --- a/web/src/Components/Pages/CourseDetailPage/Components/ViewTaskModalForm/ViewTaskModal.jsx +++ b/web/src/Components/Pages/CourseDetailPage/Components/ViewTaskModalForm/ViewTaskModal.jsx @@ -4,7 +4,7 @@ import { Col, Collapse, Divider, Empty, Flex, - Form, Input, InputNumber, + Form, Input, InputNumber, List, Modal, Popconfirm, Row, @@ -51,6 +51,8 @@ const ViewTaskModal = () => { allSolutions, onAssessmentFinish, assessmentForm, + onCommentSubmit, + commentForm } = useViewTaskModal(); return ( @@ -228,6 +230,91 @@ const ViewTaskModal = () => { ) : ( Файлы не прикреплены )} +
+ Комментарии к решению + +
+ {solution.solution_comments && solution.solution_comments.length > 0 ? ( + ( + + + {comment.comment_autor.first_name[0]} + {comment.comment_autor.last_name[0]} + + } + title={ + + + {comment.comment_autor.first_name} {comment.comment_autor.last_name} + + {comment.comment_autor.role?.title === "teacher" && ( + Преподаватель + )} + + } + description={ + + {new Date(comment.created_at || Date.now()).toLocaleString("ru-RU")} + + } + /> +
+ + )} + /> + ) : ( + + )} +
+ +
onCommentSubmit(solution.id, values.comment)} + style={{ marginTop: 16 }} + form={commentForm} + > + + + + + + + +
+
))} @@ -336,7 +423,7 @@ const ViewTaskModal = () => { Файлы не прикреплены )} Оценка -
{ + { onAssessmentFinish(solution.id) }}> {
+
+ Комментарии к решению + +
+ {solution.solution_comments && solution.solution_comments.length > 0 ? ( + ( + + + {comment.comment_autor.first_name[0]} + {comment.comment_autor.last_name[0]} + + } + title={ + + + {comment.comment_autor.first_name} {comment.comment_autor.last_name} + + {comment.comment_autor.role?.title === "teacher" && ( + Преподаватель + )} + + } + description={ + + {new Date(comment.created_at || Date.now()).toLocaleString("ru-RU")} + + } + /> +
+ + )} + /> + ) : ( + + )} +
+ +
onCommentSubmit(solution.id, values.comment)} + style={{ marginTop: 16 }} + form={commentForm} + > + + + + + + + +
+
))} diff --git a/web/src/Components/Pages/CourseDetailPage/Components/ViewTaskModalForm/useTaskLessonModal.js b/web/src/Components/Pages/CourseDetailPage/Components/ViewTaskModalForm/useTaskLessonModal.js index 96a5c8e..40e18a2 100644 --- a/web/src/Components/Pages/CourseDetailPage/Components/ViewTaskModalForm/useTaskLessonModal.js +++ b/web/src/Components/Pages/CourseDetailPage/Components/ViewTaskModalForm/useTaskLessonModal.js @@ -12,6 +12,7 @@ import { useUploadFileMutation } from "../../../../../Api/solutionsApi.js"; import {ROLES} from "../../../../../Core/constants.js"; +import {useCreateCommentMutation} from "../../../../../Api/commentsApi.js"; const useViewTaskModal = () => { @@ -37,6 +38,34 @@ const useViewTaskModal = () => { const [uploadFile] = useUploadFileMutation(); const [deleteSolution] = useDeleteSolutionMutation(); + const [createComment, { + isLoading: isCreatingComment, + isError: isErrorCreatingComment + }] = useCreateCommentMutation(); + + const onCommentSubmit = async (solutionId, commentText) => { + if (!commentText?.trim()) return; + + try { + await createComment({ + solutionId: solutionId, + comment: { + comment_text: commentText.trim() + } + }).unwrap(); + commentForm.resetFields(); + notification.success({ + message: "Комментарий отправлен", + description: "Ваш комментарий успешно добавлен", + }); + } catch (error) { + notification.error({ + message: "Ошибка", + description: error?.data?.detail || "Не удалось отправить комментарий", + }); + } + }; + const handleAddFile = (file) => { const maxSize = 50 * 1024 * 1024; // 50 мегабайт if (file.size > maxSize) { @@ -165,6 +194,7 @@ const useViewTaskModal = () => { pollingInterval: 5000, }) + const [commentForm] = Form.useForm(); const [downloadingFiles, setDownloadingFiles] = useState({}); const downloadFile = async (fileId, fileName) => { @@ -356,6 +386,7 @@ const useViewTaskModal = () => { allSolutions, onAssessmentFinish, assessmentForm, + onCommentSubmit, } }; diff --git a/web/src/Redux/store.js b/web/src/Redux/store.js index 93ebfd3..b3c023a 100644 --- a/web/src/Redux/store.js +++ b/web/src/Redux/store.js @@ -12,6 +12,7 @@ import {coursesApi} from "../Api/coursesApi.js"; import {lessonsApi} from "../Api/lessonsApi.js"; import {tasksApi} from "../Api/tasksApi.js"; import {solutionsApi} from "../Api/solutionsApi.js"; +import {commentsApi} from "../Api/commentsApi.js"; export const store = configureStore({ reducer: { @@ -35,6 +36,8 @@ export const store = configureStore({ [tasksApi.reducerPath]: tasksApi.reducer, [solutionsApi.reducerPath]: solutionsApi.reducer, + + [commentsApi.reducerPath]: commentsApi.reducer, }, middleware: (getDefaultMiddleware) => ( getDefaultMiddleware().concat( @@ -46,6 +49,7 @@ export const store = configureStore({ lessonsApi.middleware, tasksApi.middleware, solutionsApi.middleware, + commentsApi.middleware, ) ), });