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)}
+
}
/>