починил отображение курсов

This commit is contained in:
Андрей Дувакин 2025-11-28 23:51:42 +05:00
parent 4ec6e96525
commit 2dd0419726
9 changed files with 321 additions and 18 deletions

View File

@ -1,10 +1,10 @@
from typing import List, Optional from typing import List, Optional, Sequence
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.domain.models import Course from app.domain.models import Course, CourseTeacher, Enrollment
class CoursesRepository: class CoursesRepository:
@ -22,6 +22,32 @@ class CoursesRepository:
result = await self.db.execute(query) result = await self.db.execute(query)
return result.scalars().all() return result.scalars().all()
async def get_all_for_teacher(self, user_id: int) -> Optional[Sequence[Course]]:
query = (
select(Course)
.options(
selectinload(Course.teachers),
selectinload(Course.enrollments)
)
.join(Course.teachers)
.filter(CourseTeacher.teacher_id == user_id)
)
result = await self.db.execute(query)
return result.scalars().all()
async def get_all_for_enrollment(self, user_id: int) -> Optional[Sequence[Course]]:
query = (
select(Course)
.options(
selectinload(Course.teachers),
selectinload(Course.enrollments)
)
.join(Course.enrollments)
.filter(Enrollment.user_id == user_id)
)
result = await self.db.execute(query)
return result.scalars().all()
async def get_by_id(self, course_id: int) -> Optional[Course]: async def get_by_id(self, course_id: int) -> Optional[Course]:
query = ( query = (
select(Course) select(Course)

View File

@ -30,6 +30,20 @@ async def get_all_courses(
return await courses_service.get_all() return await courses_service.get_all()
@courses_router.get(
'/for-me/',
response_model=Optional[List[CourseRead]],
summary='Return all for current user',
description='Return all current user',
)
async def get_fors_for_teacher(
db: AsyncSession = Depends(get_db),
user: User = Depends(require_auth_user),
):
courses_service = CoursesService(db)
return await courses_service.get_all_for_me(user)
@courses_router.get( @courses_router.get(
'/{course_id}/', '/{course_id}/',
response_model=Optional[CourseRead], response_model=Optional[CourseRead],

View File

@ -4,13 +4,16 @@ from fastapi import HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.application.courses_repository import CoursesRepository from app.application.courses_repository import CoursesRepository
from app.core.constants import UserRoles
from app.domain.entities.courses import CourseRead, CourseCreate, CourseCreated from app.domain.entities.courses import CourseRead, CourseCreate, CourseCreated
from app.domain.models import Course from app.domain.models import Course, User
from app.settings import Settings
class CoursesService: class CoursesService:
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
self.courses_repository = CoursesRepository(db) self.courses_repository = CoursesRepository(db)
self.settings = Settings()
async def get_all(self) -> List[CourseRead]: async def get_all(self) -> List[CourseRead]:
courses = await self.courses_repository.get_all() courses = await self.courses_repository.get_all()
@ -22,6 +25,27 @@ class CoursesService:
return response return response
async def get_all_for_me(self, user: User) -> Optional[List[CourseRead]]:
if user.role.title == self.settings.root_role_name:
courses = await self.courses_repository.get_all()
elif user.role.title == UserRoles.TEACHER:
courses = await self.courses_repository.get_all_for_teacher(user.id)
elif user.role.title == UserRoles.STUDENT:
courses = await self.courses_repository.get_all_for_enrollment(user.id)
else:
courses = []
response = []
for course in courses:
response.append(
CourseRead.model_validate(course)
)
return response
async def get_by_id(self, course_id: int) -> Optional[CourseRead]: async def get_by_id(self, course_id: int) -> Optional[CourseRead]:
course = await self.courses_repository.get_by_id(course_id) course = await self.courses_repository.get_by_id(course_id)
if course is None: if course is None:

View File

@ -14,6 +14,13 @@ export const coursesApi = createApi({
}), }),
providesTags: ['course'], providesTags: ['course'],
}), }),
getAllMyCourses: builder.query({
query: () => ({
url: "/courses/for-me/",
method: "GET",
}),
providesTags: ['course'],
}),
getCourseById: builder.query({ getCourseById: builder.query({
query: (courseId) => ({ query: (courseId) => ({
url: `/courses/${courseId}/`, url: `/courses/${courseId}/`,
@ -72,6 +79,7 @@ export const coursesApi = createApi({
export const { export const {
useGetAllCoursesQuery, useGetAllCoursesQuery,
useGetAllMyCoursesQuery,
useGetCourseByIdQuery, useGetCourseByIdQuery,
useCreateCourseMutation, useCreateCourseMutation,
useUpdateCourseMutation, useUpdateCourseMutation,

View File

@ -0,0 +1,79 @@
import useUpdateLessonModalForm from "./useUpdateLessonModalForm.js";
import {Button, Form, Input, InputNumber, Modal, Upload} from "antd";
import JoditEditor from "jodit-react";
import LoadingIndicator from "../../../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
const {TextArea} = Input;
const UpdateLessonModalForm = ({courseId}) => {
const {
isModalOpen,
handleCancel,
handleOk,
form,
joditConfig,
editorRef,
isLoading,
initialContent,
currentLesson,
} = useUpdateLessonModalForm({courseId});
if (isLoading) {
return <Modal>
<LoadingIndicator/>
</Modal>
}
return (
<Modal
title="Редактирование лекции"
open={isModalOpen}
onCancel={handleCancel}
width={1000}
footer={[
<Button key="cancel" onClick={handleCancel}>
Отмена
</Button>,
<Button
key="submit"
type="primary"
loading={isLoading}
onClick={handleOk}
>
Сохранить изменения
</Button>,
]}
maskClosable={false}
keyboard={false}
>
<Form form={form} layout="vertical">
<Form.Item
name="title"
label="Название лекции"
rules={[{ required: true, message: "Введите название лекции" }]}
>
<Input size="large" />
</Form.Item>
<Form.Item name="description" label="Краткое описание">
<TextArea rows={2} placeholder="О чём эта лекция..." />
</Form.Item>
<Form.Item name="number" label="Порядковый номер">
<InputNumber min={1} style={{ width: "100%" }} />
</Form.Item>
<Form.Item label="Содержание лекции">
<div style={{ border: "1px solid #d9d9d9", borderRadius: 8, overflow: "hidden" }}>
<JoditEditor
ref={editorRef}
config={joditConfig}
/>
</div>
</Form.Item>
</Form>
</Modal>
);
};
export default UpdateLessonModalForm;

View File

@ -0,0 +1,153 @@
import {useDispatch, useSelector} from "react-redux";
import {Form, notification} from "antd";
import {useEffect, useMemo, useRef} from "react";
import {useUpdateLessonMutation} from "../../../../../Api/lessonsApi.js";
import {setSelectedLessonToUpdate} from "../../../../../Redux/Slices/lessonsSlice.js";
const useUpdateLessonModalForm = () => {
const dispatch = useDispatch();
const [form] = Form.useForm();
const editorRef = useRef(null);
const {selectedLessonToUpdate} = useSelector((state) => state.lessons);
const [updateLesson, {isLoading}] = useUpdateLessonMutation();
const isModalOpen = selectedLessonToUpdate !== null;
useEffect(() => {
if (selectedLessonToUpdate && isModalOpen) {
form.setFieldsValue({
title: selectedLessonToUpdate.title || "",
description: selectedLessonToUpdate.description || "",
number: selectedLessonToUpdate.number || 1,
});
setTimeout(() => {
if (editorRef.current) {
editorRef.current.value = selectedLessonToUpdate.text || "";
}
}, 0);
}
}, [selectedLessonToUpdate, isModalOpen, form]);
const handleCancel = () => {
form.resetFields();
if (editorRef.current) {
editorRef.current.value = "";
}
dispatch(setSelectedLessonToUpdate(null));
};
const handleOk = async () => {
try {
const values = await form.validateFields();
const content = editorRef.current?.value || "";
const lessonData = {
title: values.title,
description: values.description || null,
text: content,
number: values.number,
};
await updateLesson({
lessonId: selectedLessonToUpdate.id,
lessonData,
}).unwrap();
notification.success({
title: "Успех",
description: "Лекция успешно обновлена!",
});
handleCancel();
} catch (error) {
notification.error({
title: "Ошибка",
description: error?.data?.detail || "Не удалось обновить лекцию",
});
}
};
const joditConfig = useMemo(
() => ({
readonly: false,
height: 150,
toolbarAdaptive: false,
buttons: [
"bold", "italic", "underline", "strikethrough", "|",
"superscript", "subscript", "|",
"ul", "ol", "outdent", "indent", "|",
"font", "fontsize", "brush", "paragraph", "|",
"align", "hr", "|",
"table", "link", "image", "video", "symbols", "|",
"undo", "redo", "cut", "copy", "paste", "selectall", "eraser", "|",
"find", "source", "fullsize", "print", "preview",
],
autofocus: false,
preserveSelection: true,
askBeforePasteHTML: false,
askBeforePasteFromWord: false,
defaultActionOnPaste: "insert_clear_html",
spellcheck: true,
placeholder: "Введите результаты приёма...",
showCharsCounter: true,
showWordsCounter: true,
showXPathInStatusbar: false,
toolbarSticky: true,
toolbarButtonSize: "middle",
cleanHTML: {
removeEmptyElements: true,
replaceNBSP: false,
},
hotkeys: {
"ctrl + shift + f": "find",
"ctrl + b": "bold",
"ctrl + i": "italic",
"ctrl + u": "underline",
},
image: {
editSrc: true,
editTitle: true,
editAlt: true,
openOnDblClick: false,
},
video: {
allowedSources: ["youtube", "vimeo"],
},
uploader: {
insertImageAsBase64URI: true,
},
paste: {
insertAsBase64: true,
mimeTypes: ["image/png", "image/jpeg", "image/gif"],
maxFileSize: 5 * 1024 * 1024,
error: () => {
notification.error({
title: "Ошибка вставки",
description: "Файл слишком большой или неподдерживаемый формат.",
placement: "topRight",
});
},
},
}),
[]
);
const initialContent = selectedLessonToUpdate?.text || "";
return {
isModalOpen,
handleCancel,
handleOk,
form,
joditConfig,
editorRef,
isLoading,
initialContent,
currentLesson: selectedLessonToUpdate,
};
};
export default useUpdateLessonModalForm;

View File

@ -28,6 +28,7 @@ import CONFIG from "../../../Core/сonfig.js";
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx"; import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import CreateLessonModalForm from "./Components/CreateLessonModalForm/CreateLessonModalForm.jsx"; import CreateLessonModalForm from "./Components/CreateLessonModalForm/CreateLessonModalForm.jsx";
import ViewLessonModal from "./Components/ViewLessonModalForm/ViewLessonModal.jsx"; import ViewLessonModal from "./Components/ViewLessonModalForm/ViewLessonModal.jsx";
import UpdateLessonModalForm from "./Components/UpdateLessonModalForm/UpdateLessonModalForm.jsx";
const {Title, Text} = Typography; const {Title, Text} = Typography;
@ -76,7 +77,7 @@ const CourseDetailPage = () => {
<Empty <Empty
description="Пока нет лекций" description="Пока нет лекций"
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ margin: "60px 0" }} style={{margin: "60px 0"}}
> >
{isTeacherOrAdmin && ( {isTeacherOrAdmin && (
<Button type="primary" size="large" onClick={handleCreateLesson}> <Button type="primary" size="large" onClick={handleCreateLesson}>
@ -90,14 +91,11 @@ const CourseDetailPage = () => {
<Col xs={24} sm={12} lg={8} xl={6} key={lesson.id}> <Col xs={24} sm={12} lg={8} xl={6} key={lesson.id}>
<Card <Card
hoverable hoverable
style={{ height: "100%", cursor: "pointer" }} style={{height: "100%", cursor: "pointer"}}
onClick={() => handleOpenLesson(lesson)} onClick={() => handleOpenLesson(lesson)}
title={ title={
<Space> <Space>
<Text strong>{lesson.number}. {lesson.title}</Text> <Text strong>{lesson.number}. {lesson.title}</Text>
{lesson.text && lesson.text.length > 100 && (
<Tag color="blue">Есть текст</Tag>
)}
</Space> </Space>
} }
extra={ extra={
@ -105,7 +103,7 @@ const CourseDetailPage = () => {
<Space onClick={(e) => e.stopPropagation()}> <Space onClick={(e) => e.stopPropagation()}>
<Button <Button
type="text" type="text"
icon={<EditOutlined />} icon={<EditOutlined/>}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleEditLesson(lesson); handleEditLesson(lesson);
@ -124,7 +122,7 @@ const CourseDetailPage = () => {
<Button <Button
type="text" type="text"
danger danger
icon={<DeleteOutlined />} icon={<DeleteOutlined/>}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
</Popconfirm> </Popconfirm>
@ -132,7 +130,7 @@ const CourseDetailPage = () => {
) )
} }
> >
<div style={{ marginBottom: 16 }}> <div style={{marginBottom: 16}}>
{lesson.description ? ( {lesson.description ? (
<Text type="secondary">{lesson.description}</Text> <Text type="secondary">{lesson.description}</Text>
) : ( ) : (
@ -140,11 +138,11 @@ const CourseDetailPage = () => {
)} )}
</div> </div>
<div style={{ marginTop: 16, display: "flex", alignItems: "center", gap: 8 }}> <div style={{marginTop: 16, display: "flex", alignItems: "center", gap: 8}}>
<Avatar size="small" style={{ backgroundColor: "#1890ff" }}> <Avatar size="small" style={{backgroundColor: "#1890ff"}}>
{userData?.first_name?.[0] || "У"} {userData?.first_name?.[0] || "У"}
</Avatar> </Avatar>
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{fontSize: 12}}>
Создал: {lesson.creator?.first_name} {lesson.creator?.last_name} Создал: {lesson.creator?.first_name} {lesson.creator?.last_name}
</Text> </Text>
</div> </div>
@ -158,6 +156,7 @@ const CourseDetailPage = () => {
courseId={courseId} courseId={courseId}
/> />
<ViewLessonModal/> <ViewLessonModal/>
<UpdateLessonModalForm/>
{[CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData.role.title) && ( {[CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData.role.title) && (
<FloatButton.Group <FloatButton.Group
placement={"left"} placement={"left"}

View File

@ -71,8 +71,6 @@ const CoursesPage = () => {
{courses.map((course) => ( {courses.map((course) => (
<Col xs={24} sm={12} lg={8} xl={6} key={course.id}> <Col xs={24} sm={12} lg={8} xl={6} key={course.id}>
<Card <Card
onClick={() => navigate(`/courses/${course.id}`)}
hoverable hoverable
cover={ cover={
<div <div
@ -85,6 +83,7 @@ const CoursesPage = () => {
color: "white", color: "white",
fontSize: 48, fontSize: 48,
}} }}
onClick={() => navigate(`/courses/${course.id}`)}
> >
{course.title[0].toUpperCase()} {course.title[0].toUpperCase()}
</div> </div>
@ -103,6 +102,7 @@ const CoursesPage = () => {
} }
> >
<Card.Meta <Card.Meta
onClick={() => navigate(`/courses/${course.id}`)}
title={<Title level={4}>{course.title}</Title>} title={<Title level={4}>{course.title}</Title>}
description={ description={
course.description || <Text type="secondary">Без описания</Text> course.description || <Text type="secondary">Без описания</Text>

View File

@ -1,5 +1,5 @@
import {useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js"; import {useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js";
import {useGetAllCoursesQuery} from "../../../Api/coursesApi.js"; import {useGetAllCoursesQuery, useGetAllMyCoursesQuery} from "../../../Api/coursesApi.js";
import CONFIG from "../../../Core/сonfig.js"; import CONFIG from "../../../Core/сonfig.js";
import {ROLES} from "../../../Core/constants.js"; import {ROLES} from "../../../Core/constants.js";
import {useDispatch} from "react-redux"; import {useDispatch} from "react-redux";
@ -11,7 +11,7 @@ const useCoursesPage = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const {data: userData, isLoading: isUserLoading} = useGetAuthenticatedUserDataQuery(); const {data: userData, isLoading: isUserLoading} = useGetAuthenticatedUserDataQuery();
const {data: courses = [], isLoading, isCoursesLoading, isError} = useGetAllCoursesQuery(undefined, { const {data: courses = [], isLoading, isCoursesLoading, isError} = useGetAllMyCoursesQuery(undefined, {
pollingInterval: 20000, pollingInterval: 20000,
}); });