сделал управление файлами

This commit is contained in:
Андрей Дувакин 2025-11-29 08:48:25 +05:00
parent 79e343374e
commit eb498051a1
7 changed files with 364 additions and 14 deletions

View File

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

View File

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

View File

@ -93,4 +93,5 @@ export const {
useGetLessonFilesListQuery,
useGetDownloadFileQuery,
useUploadFileMutation,
useDeleteFileMutation,
} = lessonsApi;

View File

@ -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}) => {
/>
</div>
</Form.Item>
{isFilesLoading ? (
<Spin/>
) : files.length > 0 ? (
files.map((file) => (
<Row key={file.id} align="middle" justify="space-between">
<span>{file.filename || "Не указан"}</span>
<div>
<Button
onClick={() => downloadFile(file.id, file.filename)}
loading={downloadingFiles[file.id] || false}
disabled={downloadingFiles[file.id] || deletingFiles[file.id] || false}
type={"dashed"}
style={{marginRight: 8}}
>
{downloadingFiles[file.id] ? "Загрузка..." : "Скачать"}
</Button>
<Popconfirm
title={"Вы уверены, что хотите удалить файл?"}
onConfirm={() => deleteFile(file.id, file.filename)}
>
<Button
loading={deletingFiles[file.id] || false}
disabled={deletingFiles[file.id] || downloadingFiles[file.id] || false}
type={"dashed"}
danger
>
{deletingFiles[file.id] ? "Удаление..." : "Удалить"}
</Button>
</Popconfirm>
</div>
<Divider/>
</Row>
))
) : (
<p>Файлы отсутствуют</p>
)}
<Form.Item name="files" label="Прикрепить файлы">
<Upload
fileList={draftFiles}
beforeUpload={(file) => {
handleAddFile(file);
return false;
}}
onRemove={(file) => handleRemoveFile(file)}
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"
multiple
>
<Button icon={<UploadOutlined/>}>Выбрать файлы</Button>
</Upload>
</Form.Item>
</Form>
</Modal>
);

View File

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

View File

@ -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 = () => {
)}
<Divider/>
<Title level={3}>Прикрепленные файлы</Title>
{isCurrentLessonFilesLoading ? (
<Spin/>
) : currentLessonFiles.length > 0 ? (
currentLessonFiles.map((file) => (
<Row key={file.id} align="middle" justify="space-between">
<span>{file.filename || "Не указан"}</span>
<div>
<Button
onClick={() => downloadFile(file.id, file.filename)}
loading={downloadingFiles[file.id] || false}
disabled={downloadingFiles[file.id] || false}
type={"dashed"}
style={{marginRight: 8}}
>
{downloadingFiles[file.id] ? "Загрузка..." : "Скачать"}
</Button>
</div>
<Divider/>
</Row>
))
) : (
<p>Файлы отсутствуют</p>
)}
<div style={{textAlign: "right"}}>
<Button onClick={handleClose}>

View File

@ -1,5 +1,9 @@
import {useDispatch, useSelector} from "react-redux";
import {setSelectedLessonToView} from "../../../../../Redux/Slices/lessonsSlice.js";
import {useGetLessonFilesListQuery} from "../../../../../Api/lessonsApi.js";
import {notification} from "antd";
import CONFIG from "../../../../../Core/сonfig.js";
import {useState} from "react";
const useViewLessonModal = () => {
@ -15,10 +19,102 @@ const useViewLessonModal = () => {
dispatch(setSelectedLessonToView(null));
};
const {
data: currentLessonFiles = [],
isLoading: isCurrentLessonFilesLoading,
isError: isCurrentLessonFilesError
} = useGetLessonFilesListQuery(selectedLessonToView?.id, {
skip: !selectedLessonToView?.id
});
const [downloadingFiles, setDownloadingFiles] = useState({});
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}));
}
};
return {
selectedLessonToView,
modalIsOpen,
handleClose,
currentLessonFiles,
isCurrentLessonFilesLoading,
isCurrentLessonFilesError,
downloadFile,
downloadingFiles
}
};