diff --git a/api/app/application/appointment_files_repository.py b/api/app/application/appointment_files_repository.py new file mode 100644 index 0000000..5a4b516 --- /dev/null +++ b/api/app/application/appointment_files_repository.py @@ -0,0 +1,32 @@ +from typing import Optional, Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.models import AppointmentFile + + +class AppointmentFilesRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_by_id(self, file_id: int) -> Optional[AppointmentFile]: + stmt = select(AppointmentFile).filter_by(id=file_id) + result = await self.db.execute(stmt) + return result.scalars().first() + + async def get_by_appointment_id(self, appointment_id: int) -> Sequence[AppointmentFile]: + stmt = select(AppointmentFile).filter_by(appointment_id=appointment_id) + result = await self.db.execute(stmt) + return result.scalars().all() + + async def create(self, appointment_file: AppointmentFile) -> AppointmentFile: + self.db.add(appointment_file) + await self.db.commit() + await self.db.refresh(appointment_file) + return appointment_file + + async def delete(self, appointment_file: AppointmentFile) -> AppointmentFile: + await self.db.delete(appointment_file) + await self.db.commit() + return appointment_file diff --git a/api/app/controllers/appointment_files_router.py b/api/app/controllers/appointment_files_router.py new file mode 100644 index 0000000..b51fe58 --- /dev/null +++ b/api/app/controllers/appointment_files_router.py @@ -0,0 +1,71 @@ +from fastapi import Depends, File, UploadFile, APIRouter +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.responses import FileResponse + +from app.database.session import get_db +from app.domain.entities.appointment_file import AppointmentFileEntity +from app.infrastructure.appointment_files_service import AppointmentFilesService +from app.infrastructure.dependencies import require_admin, get_current_user + +router = APIRouter() + + +@router.get( + '/{appointment_id}/', + response_model=list[AppointmentFileEntity], + summary='Get all appointment files', + description='Returns metadata of all files uploaded for the specified appointment.' +) +async def get_files_by_project_id( + appointment_id: int, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), +): + appointment_files_service = AppointmentFilesService(db) + return await appointment_files_service.get_files_by_appointment_id(appointment_id, user) + + +@router.get( + '/{file_id}/file', + response_class=FileResponse, + summary='Download appointment file by ID', + description='Returns the file for the specified file ID.' +) +async def download_project_file( + file_id: int, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), +): + appointment_files_service = AppointmentFilesService(db) + return await appointment_files_service.get_file_by_id(file_id, user) + + +@router.post( + '/{appointment_id}/upload', + response_model=AppointmentFileEntity, + summary='Upload a new file for the appointment', + description='Uploads a new file and associates it with the specified appointment.' +) +async def upload_project_file( + appointment_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + user=Depends(require_admin), +): + appointment_files_service = AppointmentFilesService(db) + return await appointment_files_service.upload_file(appointment_id, file, user) + + +@router.delete( + '/{file_id}/', + response_model=AppointmentFileEntity, + summary='Delete an appointment file by ID', + description='Deletes the file and its database entry.' +) +async def delete_project_file( + file_id: int, + db: AsyncSession = Depends(get_db), + user=Depends(require_admin), +): + appointment_files_service = AppointmentFilesService(db) + return await appointment_files_service.delete_file(file_id, user) diff --git a/api/app/domain/entities/appointment_file.py b/api/app/domain/entities/appointment_file.py new file mode 100644 index 0000000..cd44407 --- /dev/null +++ b/api/app/domain/entities/appointment_file.py @@ -0,0 +1,11 @@ +from typing import Optional + +from pydantic import BaseModel + + +class AppointmentFileEntity(BaseModel): + id: Optional[int] = None + file_path: str + file_title: str + + appointment_id: int diff --git a/api/app/infrastructure/appointment_files_service.py b/api/app/infrastructure/appointment_files_service.py new file mode 100644 index 0000000..0a3d75c --- /dev/null +++ b/api/app/infrastructure/appointment_files_service.py @@ -0,0 +1,149 @@ +import os +import uuid +from typing import Optional + +import aiofiles +import magic +from fastapi import UploadFile, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.responses import FileResponse +from werkzeug.utils import secure_filename + +from app.application.appointment_files_repository import AppointmentFilesRepository +from app.application.appointments_repository import AppointmentsRepository +from app.domain.entities.appointment_file import AppointmentFileEntity +from app.domain.models import AppointmentFile, User + + +class AppointmentFilesService: + def __init__(self, db: AsyncSession): + self.appointment_files_repository = AppointmentFilesRepository(db) + self.appointments_repository = AppointmentsRepository(db) + + async def get_file_by_id(self, file_id: int, user: User) -> FileResponse: + appointment_file = await self.appointment_files_repository.get_by_id(file_id) + + if not appointment_file: + raise HTTPException(404, "Файл с таким ID не найден") + + appointment = await self.appointments_repository.get_by_id(appointment_file.appointment_id) + + if not appointment: + raise HTTPException(404, "Прием с таким ID не найден") + + if appointment.doctor_id != user.id and user.role.title != 'Администратор': + raise HTTPException(403, 'Доступ запрещен') + + if not os.path.exists(appointment_file.file_path): + raise HTTPException(404, "Файл не найден на диске") + + return FileResponse( + appointment_file.file_path, + media_type=self.get_media_type(appointment_file.file_path), + filename=os.path.basename(appointment_file.file_title), + ) + + async def get_files_by_appointment_id(self, appointment_id: int, user: User) -> Optional[ + list[AppointmentFileEntity] + ]: + appointment = await self.appointments_repository.get_by_id(appointment_id) + + if not appointment: + raise HTTPException(404, "Прием с таким ID не найден") + + if appointment.doctor_id != user.id and user.role.title != 'Администратор': + raise HTTPException(403, 'Доступ запрещен') + + appointment_files = await self.appointment_files_repository.get_by_appointment_id(appointment_id) + + return [ + self.model_to_entity(appointment_file) + for appointment_file in appointment_files + ] + + async def upload_file(self, appointment_id: int, file: UploadFile, user: User) -> AppointmentFileEntity: + appointment = await self.appointments_repository.get_by_id(appointment_id) + + if not appointment: + raise HTTPException(404, "Прием с таким ID не найден") + + if appointment.doctor_id != user.id and user.role.title != 'Администратор': + raise HTTPException(403, 'Доступ запрещен') + + file_path = await self.save_file(file, f'uploads/appointment_files/{appointment.id}') + + appointment_file = AppointmentFile( + file_title=file.filename, + file_path=file_path, + appointment_id=appointment_id, + ) + + return self.model_to_entity( + await self.appointment_files_repository.create(appointment_file) + ) + + async def delete_file(self, file_id: int, user: User) -> AppointmentFileEntity: + appointment_file = await self.appointment_files_repository.get_by_id(file_id) + + if not appointment_file: + raise HTTPException(404, "Файл с таким ID не найден") + + appointment = await self.appointments_repository.get_by_id(appointment_file.appointment_id) + + if not appointment: + raise HTTPException(404, "Прием с таким ID не найден") + + if appointment.doctor_id != user.id and user.role.title != 'Администратор': + raise HTTPException(403, 'Доступ запрещен') + + if not os.path.exists(appointment_file.file_path): + raise HTTPException(404, "Файл не найден на диске") + + if os.path.exists(appointment_file.file_path): + os.remove(appointment_file.file_path) + + return self.model_to_entity( + await self.appointment_files_repository.delete(appointment_file) + ) + + async def save_file(self, file: UploadFile, upload_dir: str = 'uploads/appointment_files') -> 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 model_to_entity(appointment_file_model: AppointmentFile) -> AppointmentFileEntity: + return AppointmentFileEntity( + id=appointment_file_model.id, + file_path=appointment_file_model.file_path, + file_title=appointment_file_model.file_title, + appointment_id=appointment_file_model.appointment_id, + ) + + @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/app/main.py b/api/app/main.py index 4168876..df4b959 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -1,6 +1,7 @@ from fastapi import FastAPI from starlette.middleware.cors import CORSMiddleware +from app.controllers.appointment_files_router import router as appointment_files_router from app.controllers.appointment_types_router import router as appointments_types_router from app.controllers.appointments_router import router as appointment_router from app.controllers.auth_router import router as auth_router @@ -28,6 +29,7 @@ def start_app(): allow_headers=['*'], ) + api_app.include_router(appointment_files_router, prefix=f'{settings.APP_PREFIX}/appointment_files', tags=['appointment_files']) api_app.include_router(appointments_types_router, prefix=f'{settings.APP_PREFIX}/appointment_types', tags=['appointment_types']) api_app.include_router(appointment_router, prefix=f'{settings.APP_PREFIX}/appointments', tags=['appointments']) api_app.include_router(auth_router, prefix=settings.APP_PREFIX, tags=['auth']) diff --git a/api/req.txt b/api/req.txt index 98d82b2..c7cfdbd 100644 --- a/api/req.txt +++ b/api/req.txt @@ -8,4 +8,5 @@ python-dotenv==1.0.1 SQLAlchemy==2.0.37 uvicorn==0.34.0 Werkzeug==3.1.3 -pyjwt==2.10.1 \ No newline at end of file +pyjwt==2.10.1 +python-magic==0.4.27 \ No newline at end of file diff --git a/web-app/src/Components/Pages/AppointmentsPage/AppointmentsPage.jsx b/web-app/src/Components/Pages/AppointmentsPage/AppointmentsPage.jsx index 22abf85..411a03d 100644 --- a/web-app/src/Components/Pages/AppointmentsPage/AppointmentsPage.jsx +++ b/web-app/src/Components/Pages/AppointmentsPage/AppointmentsPage.jsx @@ -1,11 +1,11 @@ -import {Button, FloatButton, List, Result, Space, Typography} from "antd"; +import {Badge, Button, Col, FloatButton, List, Result, Row, Space, Tag, Typography} from "antd"; import {Splitter} from "antd"; import { CalendarOutlined, MenuFoldOutlined, MenuUnfoldOutlined, PlusOutlined, - ClockCircleOutlined, DatabaseOutlined + ClockCircleOutlined, } from "@ant-design/icons"; import AppointmentsCalendarTab from "./Components/AppointmentCalendarTab/AppointmentsCalendarTab.jsx"; import useAppointmentsUI from "./useAppointmentsUI.js"; @@ -15,13 +15,13 @@ import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.js import AppointmentFormModal from "../../Dummies/AppointmentFormModal/AppointmentFormModal.jsx"; import {useDispatch} from "react-redux"; import { - closeModal, openModal, setSelectedAppointment, setSelectedScheduledAppointment } from "../../../Redux/Slices/appointmentsSlice.js"; import AppointmentViewModal from "../../Widgets/AppointmentViewModal/AppointmentViewModal.jsx"; -import ScheduledAppointmentFormModal from "../../Dummies/ScheduledAppintmentFormModal/ScheduledAppointmentFormModal.jsx"; +import ScheduledAppointmentFormModal + from "../../Dummies/ScheduledAppintmentFormModal/ScheduledAppointmentFormModal.jsx"; import ScheduledAppointmentsViewModal from "../../Widgets/ScheduledAppointmentsViewModal/ScheduledAppointmentsViewModal.jsx"; import AppointmentsListModal from "./Components/AppointmentsListModal/AppointmentsListModal.jsx"; @@ -54,6 +54,28 @@ const AppointmentsPage = () => { ) : ( <> + + + + + Запланированный прием + + } + /> + + + + Прошедший прием + + } + /> + + + { padding: "12px", marginBottom: "8px", borderRadius: "4px", - background: item.appointment_datetime ? "#e6f7ff" : "#f6ffed", + background: item.appointment_datetime ? "#f6ffed" : "#e6f7ff", cursor: "pointer", transition: "background 0.3s", }} - onMouseEnter={(e) => (e.currentTarget.style.background = item.appointment_datetime ? "#d9efff" : "#efffdb")} - onMouseLeave={(e) => (e.currentTarget.style.background = item.appointment_datetime ? "#e6f7ff" : "#f6ffed")} + onMouseEnter={(e) => (e.currentTarget.style.background = item.appointment_datetime ? "#efffdb" : "#d9efff")} + onMouseLeave={(e) => (e.currentTarget.style.background = item.appointment_datetime ? "#f6ffed" : "#e6f7ff")} > {item.appointment_datetime ? ( - + ) : ( - + )} {dayjs(item.appointment_datetime || item.scheduled_datetime).format('DD.MM.YYYY HH:mm')} diff --git a/web-app/src/Components/Pages/AppointmentsPage/Components/CalendarCell/CalendarCell.jsx b/web-app/src/Components/Pages/AppointmentsPage/Components/CalendarCell/CalendarCell.jsx index c42b6e8..a31bfa3 100644 --- a/web-app/src/Components/Pages/AppointmentsPage/Components/CalendarCell/CalendarCell.jsx +++ b/web-app/src/Components/Pages/AppointmentsPage/Components/CalendarCell/CalendarCell.jsx @@ -1,10 +1,10 @@ -import { Badge, Col, Tag, Tooltip } from "antd"; +import {Badge, Col, Tag, Tooltip} from "antd"; import PropTypes from "prop-types"; -import { AppointmentPropType } from "../../../../../Types/appointmentPropType.js"; -import { ScheduledAppointmentPropType } from "../../../../../Types/scheduledAppointmentPropType.js"; +import {AppointmentPropType} from "../../../../../Types/appointmentPropType.js"; +import {ScheduledAppointmentPropType} from "../../../../../Types/scheduledAppointmentPropType.js"; import useCalendarCellUI from "./useCalendarCellUI.js"; -const CalendarCell = ({ allAppointments, onCellClick, onItemClick }) => { +const CalendarCell = ({allAppointments, onCellClick, onItemClick}) => { const { containerRef, isCompressed, @@ -43,8 +43,8 @@ const CalendarCell = ({ allAppointments, onCellClick, onItemClick }) => { status={getBadgeStatus(!!app.appointment_datetime)} text={ - {getBadgeText(app)} - + {getBadgeText(app)} + } />