feat: Добавлена работа с файлами приемов
Добавлены API для загрузки, скачивания и удаления файлов приемов. Обновлен UI страницы приемов.
This commit is contained in:
parent
17d877111c
commit
eb4890e97b
32
api/app/application/appointment_files_repository.py
Normal file
32
api/app/application/appointment_files_repository.py
Normal file
@ -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
|
||||
71
api/app/controllers/appointment_files_router.py
Normal file
71
api/app/controllers/appointment_files_router.py
Normal file
@ -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)
|
||||
11
api/app/domain/entities/appointment_file.py
Normal file
11
api/app/domain/entities/appointment_file.py
Normal file
@ -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
|
||||
149
api/app/infrastructure/appointment_files_service.py
Normal file
149
api/app/infrastructure/appointment_files_service.py
Normal file
@ -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"
|
||||
@ -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'])
|
||||
|
||||
@ -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
|
||||
pyjwt==2.10.1
|
||||
python-magic==0.4.27
|
||||
@ -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 = () => {
|
||||
<LoadingIndicator/>
|
||||
) : (
|
||||
<>
|
||||
<Row justify="end" style={{marginBottom: 10, marginRight: "2.4rem"}}>
|
||||
<Space direction={"vertical"}>
|
||||
<Tag color={"blue"} style={{width: "100%"}}>
|
||||
<Badge status={"processing"}
|
||||
text={
|
||||
<span style={appointmentsPageUI.badgeTextStyle}>
|
||||
Запланированный прием
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</Tag>
|
||||
<Tag color={"green"} style={{width: "100%"}}>
|
||||
<Badge status={"success"}
|
||||
text={
|
||||
<span style={appointmentsPageUI.badgeTextStyle}>
|
||||
Прошедший прием
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</Tag>
|
||||
</Space>
|
||||
</Row>
|
||||
<Splitter
|
||||
style={appointmentsPageUI.splitterStyle}
|
||||
min={200}
|
||||
@ -94,19 +116,19 @@ 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")}
|
||||
>
|
||||
<Space direction="vertical" size={2}>
|
||||
<Space>
|
||||
{item.appointment_datetime ? (
|
||||
<ClockCircleOutlined style={{color: "#1890ff"}}/>
|
||||
<ClockCircleOutlined style={{color: "#52c41a"}}/>
|
||||
) : (
|
||||
<CalendarOutlined style={{color: "#52c41a"}}/>
|
||||
<CalendarOutlined style={{color: "#1890ff"}}/>
|
||||
)}
|
||||
<Typography.Text strong>
|
||||
{dayjs(item.appointment_datetime || item.scheduled_datetime).format('DD.MM.YYYY HH:mm')}
|
||||
|
||||
@ -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={
|
||||
<span style={badgeTextStyle}>
|
||||
{getBadgeText(app)}
|
||||
</span>
|
||||
{getBadgeText(app)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</Tag>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user