diff --git a/api/app/controllers/lessons_router.py b/api/app/controllers/lessons_router.py index 9ed81a9..974159a 100644 --- a/api/app/controllers/lessons_router.py +++ b/api/app/controllers/lessons_router.py @@ -95,7 +95,7 @@ async def delete_lesson( @lessons_router.get( '/files/{lesson_id}/', - response_model=Optional[ReadLessonFile], + response_model=Optional[List[ReadLessonFile]], summary='Get a files list by lesson ID', description='Get a files list by lesson ID', ) @@ -111,8 +111,6 @@ async def get_files( @lessons_router.get( '/file/{file_id}/', response_class=FileResponse, - summary='Get a file by ID', - description='Get a file by ID', ) async def get_file( file_id: int, diff --git a/api/app/infrastructure/lesson_files_service.py b/api/app/infrastructure/lesson_files_service.py index 76d590a..2069ed2 100644 --- a/api/app/infrastructure/lesson_files_service.py +++ b/api/app/infrastructure/lesson_files_service.py @@ -20,15 +20,15 @@ class LessonFilesService: self.lessons_repository = LessonsRepository(db) async def get_file_by_id(self, file_id: int) -> FileResponse: - lesson_file = self.lesson_files_repository.get_by_id(file_id) + lesson_file = await self.lesson_files_repository.get_by_id(file_id) if not lesson_file: raise HTTPException(404, "Файл с таким ID не найден") return FileResponse( - lesson_file.filename, + lesson_file.file_path, media_type=self.get_media_type(lesson_file.filename), - filename=os.path.basename(lesson_file.file_path), + filename=os.path.basename(lesson_file.filename), ) async def get_files_list_by_lesson(self, lesson_id: int) -> List[ReadLessonFile]: @@ -58,8 +58,8 @@ class LessonFilesService: file_path = await self.save_file(file, f'uploads/lessons/{lesson.id}') lesson_file_model = LessonFile( - filename=file_path, - file_path=file.filename, + filename=file.filename, + file_path=file_path, lesson_id=lesson.id, ) diff --git a/web/src/Api/lessonsApi.js b/web/src/Api/lessonsApi.js index 6b3558e..da8d82f 100644 --- a/web/src/Api/lessonsApi.js +++ b/web/src/Api/lessonsApi.js @@ -93,4 +93,5 @@ export const { useGetLessonFilesListQuery, useGetDownloadFileQuery, useUploadFileMutation, + useDeleteFileMutation, } = lessonsApi; diff --git a/web/src/Components/Pages/CourseDetailPage/Components/UpdateLessonModalForm/UpdateLessonModalForm.jsx b/web/src/Components/Pages/CourseDetailPage/Components/UpdateLessonModalForm/UpdateLessonModalForm.jsx index f7b4984..210a953 100644 --- a/web/src/Components/Pages/CourseDetailPage/Components/UpdateLessonModalForm/UpdateLessonModalForm.jsx +++ b/web/src/Components/Pages/CourseDetailPage/Components/UpdateLessonModalForm/UpdateLessonModalForm.jsx @@ -1,7 +1,8 @@ import useUpdateLessonModalForm from "./useUpdateLessonModalForm.js"; -import {Button, Form, Input, InputNumber, Modal, Upload} from "antd"; +import {Button, Divider, Form, Input, InputNumber, Modal, Popconfirm, Row, Spin, Upload} from "antd"; import JoditEditor from "jodit-react"; import LoadingIndicator from "../../../../Widgets/LoadingIndicator/LoadingIndicator.jsx"; +import {UploadOutlined} from "@ant-design/icons"; const {TextArea} = Input; @@ -16,6 +17,15 @@ const UpdateLessonModalForm = ({courseId}) => { isLoading, initialContent, currentLesson, + isFilesLoading, + downloadFile, + files, + downloadingFiles, + deletingFiles, + deleteFile, + draftFiles, + handleAddFile, + handleRemoveFile, } = useUpdateLessonModalForm({courseId}); if (isLoading) { @@ -71,6 +81,57 @@ const UpdateLessonModalForm = ({courseId}) => { /> + + {isFilesLoading ? ( + + ) : files.length > 0 ? ( + files.map((file) => ( + + {file.filename || "Не указан"} +
+ + deleteFile(file.id, file.filename)} + > + + +
+ +
+ )) + ) : ( +

Файлы отсутствуют

+ )} + + { + handleAddFile(file); + return false; + }} + onRemove={(file) => handleRemoveFile(file)} + accept=".pdf,.doc,.docx,.jpg,.jpeg,.png" + multiple + > + + + ); diff --git a/web/src/Components/Pages/CourseDetailPage/Components/UpdateLessonModalForm/useUpdateLessonModalForm.js b/web/src/Components/Pages/CourseDetailPage/Components/UpdateLessonModalForm/useUpdateLessonModalForm.js index 137ff3c..ac7f3f6 100644 --- a/web/src/Components/Pages/CourseDetailPage/Components/UpdateLessonModalForm/useUpdateLessonModalForm.js +++ b/web/src/Components/Pages/CourseDetailPage/Components/UpdateLessonModalForm/useUpdateLessonModalForm.js @@ -1,8 +1,14 @@ import {useDispatch, useSelector} from "react-redux"; import {Form, notification} from "antd"; -import {useEffect, useMemo, useRef} from "react"; -import {useUpdateLessonMutation} from "../../../../../Api/lessonsApi.js"; +import {useEffect, useMemo, useRef, useState} from "react"; +import { + useDeleteFileMutation, + useGetLessonFilesListQuery, + useUpdateLessonMutation, + useUploadFileMutation +} from "../../../../../Api/lessonsApi.js"; import {setSelectedLessonToUpdate} from "../../../../../Redux/Slices/lessonsSlice.js"; +import CONFIG from "../../../../../Core/сonfig.js"; const useUpdateLessonModalForm = () => { @@ -11,9 +17,119 @@ const useUpdateLessonModalForm = () => { const editorRef = useRef(null); const {selectedLessonToUpdate} = useSelector((state) => state.lessons); - const [updateLesson, {isLoading}] = useUpdateLessonMutation(); + const [updateLesson, {isLoading: isLoadingUpdateLesson}] = useUpdateLessonMutation(); const isModalOpen = selectedLessonToUpdate !== null; + const [draftFiles, setDraftFiles] = useState([]); + const [uploadFile] = useUploadFileMutation(); + const [deleteFileMut] = useDeleteFileMutation(); + const [downloadingFiles, setDownloadingFiles] = useState({}); + const [deletingFiles, setDeletingFiles] = useState({}); + + const { + data: currentLessonFiles = [], + isLoading: isCurrentLessonFilesLoading, + isError: isCurrentLessonFilesError + } = useGetLessonFilesListQuery(selectedLessonToUpdate?.id, { + skip: !selectedLessonToUpdate?.id + }); + + const downloadFile = async (fileId, fileName) => { + try { + setDownloadingFiles((prev) => ({ ...prev, [fileId]: true })); + + const token = localStorage.getItem('access_token'); + if (!token) { + notification.error({ + title: "Ошибка", + description: "Токен не найден", + placement: "topRight", + }); + return; + } + + const response = await fetch(`${CONFIG.BASE_URL}/lessons/file/${fileId}/`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + + if (!response.ok) { + const errorText = await response.text(); + notification.error({ + title: "Ошибка", + description: errorText || "Не удалось скачать файл", + placement: "topRight", + }); + return; + } + + const contentType = response.headers.get('content-type'); + if (!contentType || contentType.includes('text/html')) { + const errorText = await response.text(); + notification.error({ + title: "Ошибка", + description: errorText || "Не удалось скачать файл", + placement: "topRight", + }); + return; + } + + let safeFileName = fileName || "file"; + if (!safeFileName.match(/\.[a-zA-Z0-9]+$/)) { + if (contentType.includes('application/pdf')) { + safeFileName += '.pdf'; + } else if (contentType.includes('image/jpeg')) { + safeFileName += '.jpg'; + } else if (contentType.includes('image/png')) { + safeFileName += '.png'; + } else { + safeFileName += '.bin'; + } + } + + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = downloadUrl; + link.setAttribute("download", safeFileName); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(downloadUrl); + } catch (error) { + notification.error({ + title: "Ошибка", + description: error.message || "Не удалось скачать файл", + placement: "topRight", + }); + } finally { + setDownloadingFiles((prev) => ({...prev, [fileId]: false})); + } + }; + + const deleteFile = async (fileId, fileName) => { + try { + setDeletingFiles((prev) => ({...prev, [fileId]: true})); + await deleteFileMut(fileId).unwrap(); + notification.success({ + title: "Файл удален", + description: `Файл ${fileName || "неизвестный"} успешно удален.`, + placement: "topRight", + }); + } catch (error) { + console.error("Error deleting file:", error); + notification.error({ + title: "Ошибка при удалении файла", + description: `Не удалось удалить файл ${fileName || "неизвестный"}: ${error.data?.detail || error.message}`, + placement: "topRight", + }); + } finally { + setDeletingFiles((prev) => ({...prev, [fileId]: false})); + } + }; useEffect(() => { if (selectedLessonToUpdate && isModalOpen) { @@ -56,6 +172,27 @@ const useUpdateLessonModalForm = () => { lessonData, }).unwrap(); + for (const file of draftFiles) { + try { + await uploadFile({ + lesson_id: selectedLessonToUpdate.id, + fileData: file, + }).unwrap(); + } catch (error) { + console.error(`Error uploading file ${file.name}:`, error); + const errorMessage = error.data?.detail + ? JSON.stringify(error.data.detail, null, 2) + : JSON.stringify(error.data || error.message || "Неизвестная ошибка", null, 2); + notification.error({ + title: "Ошибка загрузки файла", + description: `Не удалось загрузить файл ${file.name}: ${errorMessage}`, + placement: "topRight", + }); + } + } + + setDraftFiles([]); + notification.success({ title: "Успех", description: "Лекция успешно обновлена!", @@ -135,6 +272,24 @@ const useUpdateLessonModalForm = () => { [] ); + const handleAddFile = (file) => { + const maxSize = 50 * 1024 * 1024; // 50 мегабайт + if (file.size > maxSize) { + notification.error({ + message: "Ошибка вставки", + description: "Файл слишком большой.", + placement: "topRight", + }); + return false; + } + setDraftFiles((prev) => [...prev, file]); + return false; + }; + + const handleRemoveFile = (file) => { + setDraftFiles((prev) => prev.filter((f) => f.uid !== file.uid)); + }; + const initialContent = selectedLessonToUpdate?.text || ""; return { @@ -144,9 +299,19 @@ const useUpdateLessonModalForm = () => { form, joditConfig, editorRef, - isLoading, + isLoading: isLoadingUpdateLesson, initialContent, currentLesson: selectedLessonToUpdate, + currentLessonFiles, + isFilesLoading: isCurrentLessonFilesLoading, + downloadFile, + files: currentLessonFiles, + downloadingFiles, + deletingFiles, + deleteFile, + draftFiles, + handleAddFile, + handleRemoveFile, }; }; diff --git a/web/src/Components/Pages/CourseDetailPage/Components/ViewLessonModalForm/ViewLessonModal.jsx b/web/src/Components/Pages/CourseDetailPage/Components/ViewLessonModalForm/ViewLessonModal.jsx index 82fccc1..fab86ba 100644 --- a/web/src/Components/Pages/CourseDetailPage/Components/ViewLessonModalForm/ViewLessonModal.jsx +++ b/web/src/Components/Pages/CourseDetailPage/Components/ViewLessonModalForm/ViewLessonModal.jsx @@ -1,5 +1,5 @@ import useViewLessonModal from "./useViewLessonModal.js"; -import {Avatar, Button, Col, Divider, Modal, Space, Typography} from "antd"; +import {Avatar, Button, Col, Divider, Modal, Popconfirm, Row, Space, Spin, Typography} from "antd"; import {CloseOutlined, UserOutlined} from "@ant-design/icons"; @@ -10,6 +10,11 @@ const ViewLessonModal = () => { selectedLessonToView, modalIsOpen, handleClose, + currentLessonFiles, + isCurrentLessonFilesLoading, + isCurrentLessonFilesError, + downloadFile, + downloadingFiles } = useViewLessonModal(); return ( @@ -72,6 +77,30 @@ const ViewLessonModal = () => { )} + Прикрепленные файлы + {isCurrentLessonFilesLoading ? ( + + ) : currentLessonFiles.length > 0 ? ( + currentLessonFiles.map((file) => ( + + {file.filename || "Не указан"} +
+ +
+ +
+ )) + ) : ( +

Файлы отсутствуют

+ )}