сделал создание и просмотр лекции

This commit is contained in:
Андрей Дувакин 2025-11-28 22:56:51 +05:00
parent 87a14f0eb1
commit 4ec6e96525
10 changed files with 352 additions and 59 deletions

View File

@ -1,6 +1,8 @@
from typing import Optional
from pydantic import BaseModel, Field
from app.domain.entities.users import UserRead
class LessonBase(BaseModel):
title: str = Field(..., max_length=250)
@ -10,7 +12,7 @@ class LessonBase(BaseModel):
class LessonCreate(LessonBase):
course_id: int
pass
class LessonUpdate(BaseModel):
@ -25,5 +27,7 @@ class LessonRead(LessonBase):
course_id: int
creator_id: int
creator: UserRead
class Config:
from_attributes = True

View File

@ -18,6 +18,6 @@ class Lesson(RootTable):
creator_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False)
course: Mapped['Course'] = relationship('Course', back_populates='lessons')
creator: Mapped['User'] = relationship('User', back_populates='created_lessons')
creator: Mapped['User'] = relationship('User', back_populates='created_lessons', lazy='joined')
files: Mapped[List['LessonFile']] = relationship('LessonFile', back_populates='lesson')

View File

@ -48,7 +48,6 @@ def require_admin(user: User = Depends(require_auth_user)):
def require_teacher(user: User = Depends(require_auth_user)):
print(user.role.title, user.role.title not in [UserRoles.TEACHER, Settings().root_role_name], [UserRoles.TEACHER, Settings().root_role_name])
if user.role.title not in [UserRoles.TEACHER, Settings().root_role_name]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Ошибка доступа')

View File

@ -1,77 +1,68 @@
import useCreateLessonModalForm from "./useCreateLessonModalForm.js";
import {Button, Form, Input, InputNumber, Modal, Upload} from "antd";
import JoditEditor from "jodit-react";
import {UploadOutlined} from "@ant-design/icons";
const {TextArea} = Input;
const CreateLessonModalForm = () => {
const CreateLessonModalForm = ({courseId}) => {
const {
isModalOpen,
handleCancel,
handleOk,
form,
joditConfig,
editorRef,
} = useCreateLessonModalForm();
isLoading,
} = useCreateLessonModalForm({courseId});
return (
<Modal
title="Создание лекционного материала"
open={isModalOpen}
onCancel={handleCancel}
footer={null}
title={"Создание лекционного материала"}
style={{
minWidth: "70%",
minHeight: "80%",
}}
width={1000}
footer={[
<Button key="cancel" onClick={handleCancel}>
Отмена
</Button>,
<Button
key="submit"
type="primary"
loading={isLoading}
onClick={handleOk}
>
Создать лекцию
</Button>,
]}
destroyOnHidden
>
<Form
name={"lesson"}
form={form}
layout={"vertical"}
>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item
name="title"
label="Название"
rules={[{required: true, message: "Пожалуйста, введите название"}]}
label="Название лекции"
rules={[{required: true, message: "Введите название лекции"}]}
>
<Input/>
<Input size="large" placeholder="Введение в JavaScript"/>
</Form.Item>
<Form.Item
name="description"
label="Описание"
>
<Input/>
<Form.Item name="description" label="Краткое описание">
<TextArea rows={2} placeholder="О чём эта лекция..."/>
</Form.Item>
<Form.Item
name="text"
label="Текстовый материал"
>
<div className="jodit-container">
<Form.Item name="number" label="Порядковый номер" initialValue={1}>
<InputNumber min={1} style={{width: "100%"}}/>
</Form.Item>
<Form.Item label="Содержание лекции">
<div style={{border: "1px solid #d9d9d9", borderRadius: 6}}>
<JoditEditor
ref={editorRef}
config={joditConfig}
/>
</div>
</Form.Item>
<Form.Item
name="number"
label="Порядковый номер отображения"
>
<InputNumber min={1} defaultValue={1}/>
</Form.Item>
<Form.Item name="files" label="Прикрепить файлы">
<Upload
multiple
>
<Button icon={<UploadOutlined/>}>Выбрать файлы</Button>
</Upload>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">Сохранить</Button>
</Form.Item>
</Form>
</Modal>
)
);
};
export default CreateLessonModalForm;

View File

@ -2,22 +2,62 @@ import {useDispatch, useSelector} from "react-redux";
import {setOpenModalCreateLesson} from "../../../../../Redux/Slices/lessonsSlice.js";
import {Form, notification} from "antd";
import {useMemo, useRef} from "react";
import {useCreateLessonMutation} from "../../../../../Api/lessonsApi.js";
const useCreateLessonModalForm = () => {
const useCreateLessonModalForm = ({courseId}) => {
const dispatch = useDispatch();
const {
openModalCreateLesson
} = useSelector((state) => state.lessons);
const [form] = Form.useForm();
const [createLesson, {isLoading}] = useCreateLessonMutation();
const isModalOpen = openModalCreateLesson;
const editorRef = useRef(null);
const handleCancel = () => {
form.resetFields();
if (editorRef.current) {
editorRef.current.value = "";
}
dispatch(setOpenModalCreateLesson(false));
};
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 || 1,
};
await createLesson({
courseId,
lessonData,
}).unwrap();
notification.success({
title: "Успех",
description: "Лекция успешно создана!",
placement: "topRight",
});
handleCancel();
} catch (error) {
notification.error({
title: "Ошибка",
description: error?.data?.detail || "Не удалось создать лекцию",
placement: "topRight",
});
}
};
const joditConfig = useMemo(
() => ({
readonly: false,
@ -89,6 +129,7 @@ const useCreateLessonModalForm = () => {
form,
joditConfig,
editorRef,
handleOk,
}
};

View File

@ -0,0 +1,85 @@
import useViewLessonModal from "./useViewLessonModal.js";
import {Avatar, Button, Col, Divider, Modal, Space, Typography} from "antd";
import {CloseOutlined, UserOutlined} from "@ant-design/icons";
const {Title, Text, Paragraph} = Typography;
const ViewLessonModal = () => {
const {
selectedLessonToView,
modalIsOpen,
handleClose,
} = useViewLessonModal();
return (
<Modal
open={modalIsOpen}
onCancel={handleClose}
footer={null}
width={1000}
closeIcon={<CloseOutlined style={{fontSize: 20}}/>}
title={null}
centered
destroyOnHidden
>
<Space align="start" style={{marginBottom: 16}}>
<Col>
<Title level={2} style={{margin: 0, flex: 1}}>
{selectedLessonToView?.title}
</Title>
<Avatar
size="small"
icon={<UserOutlined/>}
style={{backgroundColor: "#1890ff"}}
>
{selectedLessonToView?.creator?.first_name?.[0] || "У"}
</Avatar>
<Text type="secondary">
Создал: <strong>{selectedLessonToView?.creator?.first_name} {selectedLessonToView?.creator?.last_name}</strong>
{selectedLessonToView?.creator?.patronymic && ` ${selectedLessonToView?.creator.patronymic}`}
</Text>
</Col>
</Space>
{selectedLessonToView?.description && (
<>
<Title level={4} style={{margin: "16px 0 8px"}}>
Описание
</Title>
<Paragraph type="secondary" style={{fontSize: 16, marginBottom: 24}}>
{selectedLessonToView?.description}
</Paragraph>
<Divider style={{margin: "24px 0"}}/>
</>
)}
{selectedLessonToView?.text ? (
<div
className="lesson-content"
dangerouslySetInnerHTML={{__html: selectedLessonToView?.text}}
style={{
fontSize: "16px",
lineHeight: "1.7",
color: "#333",
}}
/>
) : (
<Paragraph italic type="secondary">
Текстовый материал отсутствует
</Paragraph>
)}
<Divider/>
<div style={{textAlign: "right"}}>
<Button onClick={handleClose}>
Закрыть
</Button>
</div>
</Modal>
);
};
export default ViewLessonModal;

View File

@ -0,0 +1,25 @@
import {useDispatch, useSelector} from "react-redux";
import {setSelectedLessonToView} from "../../../../../Redux/Slices/lessonsSlice.js";
const useViewLessonModal = () => {
const dispatch = useDispatch();
const {
selectedLessonToView
} = useSelector((state) => state.lessons);
const modalIsOpen = selectedLessonToView !== null;
const handleClose = () => {
dispatch(setSelectedLessonToView(null));
};
return {
selectedLessonToView,
modalIsOpen,
handleClose,
}
};
export default useViewLessonModal;

View File

@ -1,24 +1,50 @@
import useCourseDetailPage from "./useCourseDetailPage.js";
import {Button, Col, FloatButton, Result, Row, Tooltip, Typography} from "antd";
import {ArrowLeftOutlined, BookOutlined, FormOutlined, PlusOutlined} from "@ant-design/icons";
import {
Avatar,
Button,
Card,
Col,
Empty,
FloatButton,
Popconfirm,
Result,
Row,
Space,
Tag,
Tooltip,
Typography
} from "antd";
import {
ArrowLeftOutlined,
BookOutlined,
DeleteOutlined,
EditOutlined,
FormOutlined,
PlusOutlined
} from "@ant-design/icons";
import {useNavigate, useParams} from "react-router-dom";
import {ROLES} from "../../../Core/constants.js";
import CONFIG from "../../../Core/сonfig.js";
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import CreateLessonModalForm from "./Components/CreateLessonModalForm/CreateLessonModalForm.jsx";
import ViewLessonModal from "./Components/ViewLessonModalForm/ViewLessonModal.jsx";
const {Title} = Typography;
const {Title, Text} = Typography;
const CourseDetailPage = () => {
const navigate = useNavigate();
const {courseId} = useParams();
const {
isTeacherOrAdmin,
lessonsData,
userData,
courseData,
isLoading,
isError,
handleCreateLesson,
handleOpenLesson,
handleEditLesson,
} = useCourseDetailPage(courseId);
if (isLoading) {
@ -46,7 +72,92 @@ const CourseDetailPage = () => {
</Col>
</Row>
<CreateLessonModalForm/>
{lessonsData.length === 0 ? (
<Empty
description="Пока нет лекций"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ margin: "60px 0" }}
>
{isTeacherOrAdmin && (
<Button type="primary" size="large" onClick={handleCreateLesson}>
Добавить первую лекцию
</Button>
)}
</Empty>
) : (
<Row gutter={[24, 24]}>
{lessonsData.map((lesson, index) => (
<Col xs={24} sm={12} lg={8} xl={6} key={lesson.id}>
<Card
hoverable
style={{ height: "100%", cursor: "pointer" }}
onClick={() => handleOpenLesson(lesson)}
title={
<Space>
<Text strong>{lesson.number}. {lesson.title}</Text>
{lesson.text && lesson.text.length > 100 && (
<Tag color="blue">Есть текст</Tag>
)}
</Space>
}
extra={
isTeacherOrAdmin && (
<Space onClick={(e) => e.stopPropagation()}>
<Button
type="text"
icon={<EditOutlined />}
onClick={(e) => {
e.stopPropagation();
handleEditLesson(lesson);
}}
/>
<Popconfirm
title="Удалить лекцию?"
description="Это действие нельзя отменить"
// onConfirm={(e) => {
// e?.stopPropagation();
// handleDeleteLesson(lesson.id);
// }}
okText="Удалить"
cancelText="Отмена"
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
/>
</Popconfirm>
</Space>
)
}
>
<div style={{ marginBottom: 16 }}>
{lesson.description ? (
<Text type="secondary">{lesson.description}</Text>
) : (
<Text type="secondary" italic>Описание отсутствует</Text>
)}
</div>
<div style={{ marginTop: 16, display: "flex", alignItems: "center", gap: 8 }}>
<Avatar size="small" style={{ backgroundColor: "#1890ff" }}>
{userData?.first_name?.[0] || "У"}
</Avatar>
<Text type="secondary" style={{ fontSize: 12 }}>
Создал: {lesson.creator?.first_name} {lesson.creator?.last_name}
</Text>
</div>
</Card>
</Col>
))}
</Row>
)}
<CreateLessonModalForm
courseId={courseId}
/>
<ViewLessonModal/>
{[CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData.role.title) && (
<FloatButton.Group
placement={"left"}

View File

@ -2,7 +2,14 @@ import {useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js";
import {useGetCourseByIdQuery} from "../../../Api/coursesApi.js";
import {useEffect} from "react";
import {useDispatch} from "react-redux";
import {setOpenModalCreateLesson} from "../../../Redux/Slices/lessonsSlice.js";
import {
setOpenModalCreateLesson,
setSelectedLessonToUpdate,
setSelectedLessonToView
} from "../../../Redux/Slices/lessonsSlice.js";
import {useGetLessonsByCourseIdQuery} from "../../../Api/lessonsApi.js";
import {ROLES} from "../../../Core/constants.js";
import CONFIG from "../../../Core/сonfig.js";
const useCourseDetailPage = (courseId) => {
@ -13,7 +20,7 @@ const useCourseDetailPage = (courseId) => {
isLoading: isUserLoading,
isError: isUserError,
} = useGetAuthenticatedUserDataQuery(undefined, {
pollingInterval: 60000,
pollingInterval: 10000,
});
const {
@ -21,7 +28,15 @@ const useCourseDetailPage = (courseId) => {
isLoading: isCourseLoading,
isError: isCourseError,
} = useGetCourseByIdQuery(courseId, {
pollingInterval: 60000,
pollingInterval: 10000,
});
const {
data: lessonsData,
isLoading: isLessonsLoading,
isError: isLessonsError,
} = useGetLessonsByCourseIdQuery(courseId, {
pollingInterval: 10000,
});
useEffect(() => {
@ -32,12 +47,26 @@ const useCourseDetailPage = (courseId) => {
dispatch(setOpenModalCreateLesson(true))
};
const isTeacherOrAdmin = [CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData?.role?.title);
const handleOpenLesson = (lesson) => {
dispatch(setSelectedLessonToView(lesson))
};
const handleEditLesson = (lesson) => {
dispatch(setSelectedLessonToUpdate(lesson))
};
return {
isTeacherOrAdmin,
lessonsData,
userData,
courseData,
isLoading: isUserLoading || isCourseLoading,
isError: isUserError || isCourseError,
isLoading: isUserLoading || isCourseLoading || isLessonsLoading,
isError: isUserError || isCourseError || isLessonsError,
handleCreateLesson,
handleOpenLesson,
handleEditLesson,
}
};

View File

@ -4,6 +4,7 @@ import {createSlice} from "@reduxjs/toolkit";
const initialState = {
selectedLessonToUpdate: null,
openModalCreateLesson: false,
selectedLessonToView: null,
};
const lessonSlice = createSlice({
@ -16,9 +17,16 @@ const lessonSlice = createSlice({
setOpenModalCreateLesson(state, action) {
state.openModalCreateLesson = action.payload;
},
setSelectedLessonToView(state, action) {
state.selectedLessonToView = action.payload;
}
},
});
export const {setSelectedLessonToUpdate, setOpenModalCreateLesson} = lessonSlice.actions;
export const {
setSelectedLessonToUpdate,
setOpenModalCreateLesson,
setSelectedLessonToView,
} = lessonSlice.actions;
export default lessonSlice.reducer;