diff --git a/api/app/application/lesson_files_repository.py b/api/app/application/lesson_files_repository.py new file mode 100644 index 0000000..8891559 --- /dev/null +++ b/api/app/application/lesson_files_repository.py @@ -0,0 +1,38 @@ +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.models import LessonFile + + +class LessonFilesRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_by_id(self, file_id: int) -> Optional[LessonFile]: + query = ( + select(LessonFile) + .filter_by(id=file_id) + ) + result = await self.db.execute(query) + return result.scalars().first() + + async def get_by_lesson_id(self, lesson_id: int) -> Optional[LessonFile]: + query = ( + select(LessonFile) + .filter_by(lesson_id=lesson_id) + ) + result = await self.db.execute(query) + return result.scalars().all() + + async def create(self, lesson_file: LessonFile) -> LessonFile: + self.db.add(lesson_file) + await self.db.commit() + await self.db.refresh(lesson_file) + return lesson_file + + async def delete(self, lesson_file: LessonFile) -> LessonFile: + await self.db.delete(lesson_file) + await self.db.commit() + return lesson_file diff --git a/api/app/controllers/lessons_router.py b/api/app/controllers/lessons_router.py index 882a0e4..9ed81a9 100644 --- a/api/app/controllers/lessons_router.py +++ b/api/app/controllers/lessons_router.py @@ -1,12 +1,15 @@ from typing import List, Optional -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, status, File, UploadFile from sqlalchemy.ext.asyncio import AsyncSession +from starlette.responses import FileResponse from app.database.session import get_db +from app.domain.entities.lesson_files import ReadLessonFile from app.domain.entities.lessons import LessonCreate, LessonUpdate, LessonRead from app.domain.models import User from app.infrastructure.dependencies import require_auth_user, require_teacher, require_admin +from app.infrastructure.lesson_files_service import LessonFilesService from app.infrastructure.lessons_service import LessonsService lessons_router = APIRouter() @@ -88,3 +91,64 @@ async def delete_lesson( lessons_service = LessonsService(db) await lessons_service.delete(lesson_id, current_user) return None + + +@lessons_router.get( + '/files/{lesson_id}/', + response_model=Optional[ReadLessonFile], + summary='Get a files list by lesson ID', + description='Get a files list by lesson ID', +) +async def get_files( + lesson_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + lesson_files_service = LessonFilesService(db) + return await lesson_files_service.get_files_list_by_lesson(lesson_id) + + +@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, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_auth_user), +): + lesson_files_service = LessonFilesService(db) + return await lesson_files_service.get_file_by_id(file_id) + + +@lessons_router.post( + '/files/{lesson_id}/upload/', + response_model=ReadLessonFile, + summary='Upload a file', + description='Upload a file', +) +async def upload_file( + lesson_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + user: User = Depends(require_teacher), +): + lesson_files_service = LessonFilesService(db) + return await lesson_files_service.upload_file(lesson_id, file) + + +@lessons_router.delete( + '/files/{file_id}/', + response_model=Optional[ReadLessonFile], + summary='Delete a file', + description='Delete a file', +) +async def delete_file( + file_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_teacher), +): + lesson_files_service = LessonFilesService(db) + return await lesson_files_service.delete_file(file_id) diff --git a/api/app/domain/entities/lesson_files.py b/api/app/domain/entities/lesson_files.py new file mode 100644 index 0000000..e563be4 --- /dev/null +++ b/api/app/domain/entities/lesson_files.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class ReadLessonFile(BaseModel): + id: int + filename: str + file_path: str + lesson_id: int + + class Config: + from_attributes = True diff --git a/api/app/infrastructure/lesson_files_service.py b/api/app/infrastructure/lesson_files_service.py new file mode 100644 index 0000000..76d590a --- /dev/null +++ b/api/app/infrastructure/lesson_files_service.py @@ -0,0 +1,118 @@ +import os +import uuid +from typing import List + +import aiofiles +from fastapi import UploadFile, HTTPException +from starlette.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession +from werkzeug.utils import secure_filename + +from app.application.lesson_files_repository import LessonFilesRepository +from app.application.lessons_repository import LessonsRepository +from app.domain.entities.lesson_files import ReadLessonFile +from app.domain.models import LessonFile + + +class LessonFilesService: + def __init__(self, db: AsyncSession): + self.lesson_files_repository = LessonFilesRepository(db) + 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) + + if not lesson_file: + raise HTTPException(404, "Файл с таким ID не найден") + + return FileResponse( + lesson_file.filename, + media_type=self.get_media_type(lesson_file.filename), + filename=os.path.basename(lesson_file.file_path), + ) + + async def get_files_list_by_lesson(self, lesson_id: int) -> List[ReadLessonFile]: + lesson = await self.lessons_repository.get_by_id(lesson_id) + + if lesson is None: + raise HTTPException(404, "Лекционный материал не найден") + + lesson_files = await self.lesson_files_repository.get_by_lesson_id(lesson_id) + + response = [] + for lesson_file in lesson_files: + response.append( + ReadLessonFile.model_validate( + lesson_file + ) + ) + + return response + + async def upload_file(self, lesson_id: int, file: UploadFile) -> ReadLessonFile: + lesson = await self.lessons_repository.get_by_id(lesson_id) + + if lesson is None: + raise HTTPException(404, "Лекционный материал не найден") + + file_path = await self.save_file(file, f'uploads/lessons/{lesson.id}') + + lesson_file_model = LessonFile( + filename=file_path, + file_path=file.filename, + lesson_id=lesson.id, + ) + + lesson_file_model = await self.lesson_files_repository.create(lesson_file_model) + + return ReadLessonFile.model_validate(lesson_file_model) + + async def delete_file(self, file_id: int) -> ReadLessonFile: + lesson_file = await self.lesson_files_repository.get_by_id(file_id) + + if lesson_file is None: + raise HTTPException(404, "Файл не найден") + + if not os.path.exists(lesson_file.file_path): + raise HTTPException(404, "Файл не найден на диске") + + if os.path.exists(lesson_file.file_path): + os.remove(lesson_file.file_path) + + lesson_file = await self.lesson_files_repository.delete(lesson_file) + + return ReadLessonFile.model_validate(lesson_file) + + async def save_file(self, file: UploadFile, upload_dir: str = 'uploads/lessons') -> str: + os.makedirs(upload_dir, exist_ok=True) + filename = self.generate_filename(file) + file_path = os.path.join(upload_dir, filename) + + async with aiofiles.open(file_path, 'wb') as out_file: + content = await file.read() + await out_file.write(content) + return file_path + + @staticmethod + def generate_filename(file: UploadFile) -> str: + return secure_filename(f"{uuid.uuid4()}_{file.filename}") + + @staticmethod + def get_media_type(filename: str) -> str: + extension = filename.split('.')[-1].lower() + if extension in ['jpeg', 'jpg', 'png']: + return f"image/{extension}" + if extension == 'pdf': + return "application/pdf" + if extension in ['zip']: + return "application/zip" + if extension in ['doc', 'docx']: + return "application/msword" + if extension in ['xls', 'xlsx']: + return "application/vnd.ms-excel" + if extension in ['ppt', 'pptx']: + return "application/vnd.ms-powerpoint" + if extension in ['txt']: + return "text/plain" + + return "application/octet-stream" diff --git a/api/req.txt b/api/req.txt index 1529607..d3a8463 100644 --- a/api/req.txt +++ b/api/req.txt @@ -6,4 +6,5 @@ greenlet==3.2.4 werkzeug==3.1.3 pyjwt==2.9.0 fastapi==0.115.0 -pydantic[email]==2.11.4 \ No newline at end of file +pydantic[email]==2.11.4 +aiofiles==25.1.0 \ No newline at end of file diff --git a/web/src/Api/lessonsApi.js b/web/src/Api/lessonsApi.js index 6cd47d4..6b3558e 100644 --- a/web/src/Api/lessonsApi.js +++ b/web/src/Api/lessonsApi.js @@ -44,6 +44,43 @@ export const lessonsApi = createApi({ }), invalidatesTags: ["lesson"], }), + getLessonFilesList: builder.query({ + query: (lessonId) => ({ + url: `/lessons/files/${lessonId}/`, + method: "GET", + }), + providesTags: ["lesson"], + }), + getDownloadFile: builder.query({ + query: (fileId) => ({ + url: `/lessons/file/${fileId}/`, + method: "GET", + }), + providesTags: ["lesson"], + }), + uploadFile: builder.mutation({ + query: ({lesson_id, fileData}) => { + if (!(fileData instanceof File)) { + throw new Error('Invalid file object'); + } + const formData = new FormData(); + formData.append('file', fileData); + return { + url: `/lessons/files/${lesson_id}/upload/`, + method: 'POST', + formData: true, + body: formData, + }; + }, + invalidatesTags: ["lesson"], + }), + deleteFile: builder.mutation({ + query: (fileId) => ({ + url: `/lessons/files/${fileId}/`, + method: "DELETE", + }), + invalidatesTags: ["lesson"], + }), }), }); @@ -53,4 +90,7 @@ export const { useCreateLessonMutation, useUpdateLessonMutation, useDeleteLessonMutation, + useGetLessonFilesListQuery, + useGetDownloadFileQuery, + useUploadFileMutation, } = lessonsApi; diff --git a/web/src/Components/Pages/CourseDetailPage/Components/CreateLessonModalForm/CreateLessonModalForm.jsx b/web/src/Components/Pages/CourseDetailPage/Components/CreateLessonModalForm/CreateLessonModalForm.jsx index d67f996..9c41b85 100644 --- a/web/src/Components/Pages/CourseDetailPage/Components/CreateLessonModalForm/CreateLessonModalForm.jsx +++ b/web/src/Components/Pages/CourseDetailPage/Components/CreateLessonModalForm/CreateLessonModalForm.jsx @@ -1,6 +1,7 @@ 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; @@ -13,6 +14,9 @@ const CreateLessonModalForm = ({courseId}) => { joditConfig, editorRef, isLoading, + handleAddFile, + handleRemoveFile, + draftFiles, } = useCreateLessonModalForm({courseId}); return ( @@ -61,6 +65,21 @@ const CreateLessonModalForm = ({courseId}) => { /> + +