сделал комментарии к решению

This commit is contained in:
Андрей Дувакин 2025-11-29 19:26:25 +05:00
parent 9195f4eacc
commit be01b5fc22
12 changed files with 455 additions and 20 deletions

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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')

View File

@ -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)

View File

@ -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'])

View File

@ -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;

View File

@ -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"],
}),
}),
});

View File

@ -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 = () => {
) : (
<Text type="secondary">Файлы не прикреплены</Text>
)}
<div style={{ marginTop: 32 }}>
<Title level={4}>Комментарии к решению</Title>
<div
style={{
maxHeight: 400,
overflowY: "auto",
padding: "8px 0",
border: "1px solid #f0f0f0",
borderRadius: 8,
background: "#fafafa",
}}
>
{solution.solution_comments && solution.solution_comments.length > 0 ? (
<List
dataSource={solution.solution_comments}
renderItem={(comment) => (
<List.Item style={{ padding: "12px 16px", borderBottom: "1px solid #f0f0f0" }}>
<List.Item.Meta
avatar={
<Avatar style={{ backgroundColor: "#1890ff" }}>
{comment.comment_autor.first_name[0]}
{comment.comment_autor.last_name[0]}
</Avatar>
}
title={
<Space>
<Text strong>
{comment.comment_autor.first_name} {comment.comment_autor.last_name}
</Text>
{comment.comment_autor.role?.title === "teacher" && (
<Tag color="gold" size="small">Преподаватель</Tag>
)}
</Space>
}
description={
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(comment.created_at || Date.now()).toLocaleString("ru-RU")}
</Text>
}
/>
<div
style={{
marginTop: 8,
paddingLeft: 56,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
dangerouslySetInnerHTML={{ __html: comment.comment_text}}
/>
</List.Item>
)}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="Пока нет комментариев"
style={{ margin: "20px 0" }}
/>
)}
</div>
<Form
onFinish={(values) => onCommentSubmit(solution.id, values.comment)}
style={{ marginTop: 16 }}
form={commentForm}
>
<Form.Item
name="comment"
rules={[{ required: true, message: "Напишите комментарий" }]}
>
<Input.TextArea
rows={3}
placeholder="Напишите комментарий к решению..."
allowClear
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit">
Отправить комментарий
</Button>
</Form.Item>
</Form>
</div>
</Panel>
))}
</Collapse>
@ -336,7 +423,7 @@ const ViewTaskModal = () => {
<Text type="secondary">Файлы не прикреплены</Text>
)}
<Title level={3}>Оценка</Title>
<Form form={assessmentForm} name={"assessmentForm"} onFinish={() => {
<Form form={assessmentForm} onFinish={() => {
onAssessmentFinish(solution.id)
}}>
<Form.Item
@ -360,6 +447,91 @@ const ViewTaskModal = () => {
</Button>
</Form.Item>
</Form>
<div style={{ marginTop: 32 }}>
<Title level={4}>Комментарии к решению</Title>
<div
style={{
maxHeight: 400,
overflowY: "auto",
padding: "8px 0",
border: "1px solid #f0f0f0",
borderRadius: 8,
background: "#fafafa",
}}
>
{solution.solution_comments && solution.solution_comments.length > 0 ? (
<List
dataSource={solution.solution_comments}
renderItem={(comment) => (
<List.Item style={{ padding: "12px 16px", borderBottom: "1px solid #f0f0f0" }}>
<List.Item.Meta
avatar={
<Avatar style={{ backgroundColor: "#1890ff" }}>
{comment.comment_autor.first_name[0]}
{comment.comment_autor.last_name[0]}
</Avatar>
}
title={
<Space>
<Text strong>
{comment.comment_autor.first_name} {comment.comment_autor.last_name}
</Text>
{comment.comment_autor.role?.title === "teacher" && (
<Tag color="gold" size="small">Преподаватель</Tag>
)}
</Space>
}
description={
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(comment.created_at || Date.now()).toLocaleString("ru-RU")}
</Text>
}
/>
<div
style={{
marginTop: 8,
paddingLeft: 56,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
dangerouslySetInnerHTML={{ __html: comment.comment_text}}
/>
</List.Item>
)}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="Пока нет комментариев"
style={{ margin: "20px 0" }}
/>
)}
</div>
<Form
onFinish={(values) => onCommentSubmit(solution.id, values.comment)}
style={{ marginTop: 16 }}
form={commentForm}
>
<Form.Item
name="comment"
rules={[{ required: true, message: "Напишите комментарий" }]}
>
<Input.TextArea
rows={3}
placeholder="Напишите комментарий к решению..."
allowClear
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit">
Отправить комментарий
</Button>
</Form.Item>
</Form>
</div>
</Panel>
))}
</Collapse>

View File

@ -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,
}
};

View File

@ -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,
)
),
});