feat: Добавлена загрузка файлов к приему

Добавлена возможность прикреплять файлы к записи на прием.
Исправлены ошибки и добавлены логи.
This commit is contained in:
Андрей Дувакин 2025-06-04 13:04:24 +05:00
parent 4d174f5021
commit 4954bd0a1c
6 changed files with 174 additions and 21 deletions

View File

@ -5,7 +5,7 @@ from starlette.responses import FileResponse
from app.database.session import get_db from app.database.session import get_db
from app.domain.entities.appointment_file import AppointmentFileEntity from app.domain.entities.appointment_file import AppointmentFileEntity
from app.infrastructure.appointment_files_service import AppointmentFilesService from app.infrastructure.appointment_files_service import AppointmentFilesService
from app.infrastructure.dependencies import require_admin, get_current_user from app.infrastructure.dependencies import get_current_user
router = APIRouter() router = APIRouter()
@ -26,7 +26,7 @@ async def get_files_by_project_id(
@router.get( @router.get(
'/{file_id}/file', '/{file_id}/file/',
response_class=FileResponse, response_class=FileResponse,
summary='Download appointment file by ID', summary='Download appointment file by ID',
description='Returns the file for the specified file ID.' description='Returns the file for the specified file ID.'
@ -50,7 +50,7 @@ async def upload_project_file(
appointment_id: int, appointment_id: int,
file: UploadFile = File(...), file: UploadFile = File(...),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user=Depends(require_admin), user=Depends(get_current_user),
): ):
appointment_files_service = AppointmentFilesService(db) appointment_files_service = AppointmentFilesService(db)
return await appointment_files_service.upload_file(appointment_id, file, user) return await appointment_files_service.upload_file(appointment_id, file, user)
@ -65,7 +65,7 @@ async def upload_project_file(
async def delete_project_file( async def delete_project_file(
file_id: int, file_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user=Depends(require_admin), user=Depends(get_current_user),
): ):
appointment_files_service = AppointmentFilesService(db) appointment_files_service = AppointmentFilesService(db)
return await appointment_files_service.delete_file(file_id, user) return await appointment_files_service.delete_file(file_id, user)

View File

@ -1,5 +1,5 @@
import { createApi } from "@reduxjs/toolkit/query/react"; import {createApi} from "@reduxjs/toolkit/query/react";
import { baseQueryWithAuth } from "./baseQuery.js"; import {baseQueryWithAuth} from "./baseQuery.js";
export const appointmentFilesApi = createApi({ export const appointmentFilesApi = createApi({
reducerPath: 'appointmentFilesApi', reducerPath: 'appointmentFilesApi',
@ -7,30 +7,46 @@ export const appointmentFilesApi = createApi({
tagTypes: ['AppointmentFile'], tagTypes: ['AppointmentFile'],
endpoints: (builder) => ({ endpoints: (builder) => ({
getAppointmentFiles: builder.query({ getAppointmentFiles: builder.query({
query: (appointmentId) => `/appointment-files/${appointmentId}/`, query: (appointmentId) => {
console.log(`Fetching files for appointment ID: ${appointmentId}`);
return `/appointment_files/${appointmentId}/`;
},
providesTags: ['AppointmentFile'], providesTags: ['AppointmentFile'],
refetchOnMountOrArgChange: 5, refetchOnMountOrArgChange: 5,
}), }),
downloadAppointmentFile: builder.query({ downloadAppointmentFile: builder.query({
query: (fileId) => ({ query: (fileId) => ({
url: `/appointment-files/${fileId}/file`, url: `/appointment_files/${fileId}/file/`,
responseHandler: async (response) => { responseHandler: async (response) => {
const blob = await response.blob(); const blob = await response.blob();
const contentDisposition = response.headers.get('content-disposition'); const contentDisposition = response.headers.get('content-disposition');
const filename = contentDisposition const filename = contentDisposition
? contentDisposition.match(/filename="(.+)"/)?.[1] || `file_${fileId}` ? contentDisposition.match(/filename="(.+)"/)?.[1] || `file_${fileId}`
: `file_${fileId}`; : `file_${fileId}`;
return { blob, filename }; return {blob, filename};
}, },
cache: 'no-cache', cache: 'no-cache',
}), }),
}), }),
uploadAppointmentFile: builder.mutation({ uploadAppointmentFile: builder.mutation({
query: ({ appointmentId, file }) => { query: ({appointmentId, file}) => {
console.log(`File details:`, {
name: file.name,
size: file.size,
type: file.type,
isFile: file instanceof File,
});
if (!(file instanceof File)) {
console.error('Expected a File object, received:', file);
throw new Error('Invalid file object');
}
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
for (let [key, value] of formData.entries()) {
console.log(`FormData entry: ${key}=${value.name || value}`);
}
return { return {
url: `/appointment-files/${appointmentId}/upload`, url: `/appointment_files/${appointmentId}/upload/`,
method: 'POST', method: 'POST',
body: formData, body: formData,
}; };
@ -39,7 +55,7 @@ export const appointmentFilesApi = createApi({
}), }),
deleteAppointmentFile: builder.mutation({ deleteAppointmentFile: builder.mutation({
query: (fileId) => ({ query: (fileId) => ({
url: `/appointment-files/${fileId}/`, url: `/appointment_files/${fileId}/`,
method: 'DELETE', method: 'DELETE',
}), }),
invalidatesTags: ['AppointmentFile'], invalidatesTags: ['AppointmentFile'],

View File

@ -1,15 +1,24 @@
import { fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import {fetchBaseQuery} from '@reduxjs/toolkit/query/react';
import { logout } from '../Redux/Slices/authSlice.js'; import {logout} from '../Redux/Slices/authSlice.js';
import CONFIG from "../Core/сonfig.js"; import CONFIG from "../Core/сonfig.js";
export const baseQuery = fetchBaseQuery({ export const baseQuery = fetchBaseQuery({
baseUrl: CONFIG.BASE_URL, baseUrl: CONFIG.BASE_URL,
prepareHeaders: (headers) => { prepareHeaders: (headers, {getState}) => {
const token = localStorage.getItem('access_token'); const token = localStorage.getItem('access_token');
if (token) { if (token) {
headers.set('Authorization', `Bearer ${token}`); headers.set('Authorization', `Bearer ${token}`);
} }
headers.set('Content-Type', 'application/json');
const body = getState()?.api?.mutations?.[Object.keys(getState()?.api?.mutations || {})[0]]?.body;
if (!(body instanceof FormData)) {
headers.set('Content-Type', 'application/json');
} else {
console.log('FormData request headers:', headers);
for (let [key, value] of body.entries()) {
console.log(`FormData entry: ${key}=${value.name || value}`);
}
}
return headers; return headers;
}, },
}); });

View File

@ -15,7 +15,10 @@ import {
Steps, Steps,
Typography, Typography,
Drawer, Drawer,
Upload,
List,
} from "antd"; } from "antd";
import { UploadOutlined } from '@ant-design/icons';
import useAppointmentFormModal from "./useAppointmentFormModal.js"; import useAppointmentFormModal from "./useAppointmentFormModal.js";
import useAppointmentFormModalUI from "./useAppointmentFormModalUI.js"; import useAppointmentFormModalUI from "./useAppointmentFormModalUI.js";
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx"; import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
@ -194,6 +197,42 @@ const AppointmentFormModal = () => {
/> />
</div> </div>
</Form.Item> </Form.Item>
<Form.Item name="files" label="Прикрепить файлы">
<Upload
fileList={appointmentFormModalUI.draftFiles}
beforeUpload={(file) => {
appointmentFormModalUI.handleAddFile(file);
return false; // Prevent auto-upload
}}
onRemove={(file) => appointmentFormModalUI.handleRemoveFile(file)}
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"
multiple
>
<Button icon={<UploadOutlined />}>Выбрать файлы</Button>
</Upload>
{appointmentFormModalUI.draftFiles.length > 0 && (
<List
style={{ marginTop: 16 }}
dataSource={appointmentFormModalUI.draftFiles}
renderItem={(file) => (
<List.Item
actions={[
<Button
key={file.uid}
type="link"
danger
onClick={() => appointmentFormModalUI.handleRemoveFile(file)}
>
Удалить
</Button>
]}
>
{file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)
</List.Item>
)}
/>
)}
</Form.Item>
</Form> </Form>
</div> </div>
); );
@ -202,6 +241,9 @@ const AppointmentFormModal = () => {
appointmentFormModalUI.selectedPatient, appointmentFormModalUI.selectedPatient,
appointmentFormModalUI.showDrawer, appointmentFormModalUI.showDrawer,
appointmentFormModalUI.editor, appointmentFormModalUI.editor,
appointmentFormModalUI.draftFiles,
appointmentFormModalUI.handleAddFile,
appointmentFormModalUI.handleRemoveFile,
appointmentFormModalData.appointmentTypes, appointmentFormModalData.appointmentTypes,
appointmentFormModalUI.joditConfig, appointmentFormModalUI.joditConfig,
handleEditorBlur, handleEditorBlur,
@ -232,9 +274,24 @@ const AppointmentFormModal = () => {
<b>Результаты приема:</b> <b>Результаты приема:</b>
</p> </p>
<div dangerouslySetInnerHTML={{__html: values.results || "Не указаны"}}/> <div dangerouslySetInnerHTML={{__html: values.results || "Не указаны"}}/>
<p>
<b>Прикрепленные файлы:</b>
</p>
{appointmentFormModalUI.draftFiles.length > 0 ? (
<List
dataSource={appointmentFormModalUI.draftFiles}
renderItem={(file) => (
<List.Item>
{file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)
</List.Item>
)}
/>
) : (
<p>Файлы не прикреплены</p>
)}
</div> </div>
); );
}, [appointmentFormModalUI.form, appointmentFormModalData.patients, appointmentFormModalData.appointmentTypes, appointmentFormModalUI.blockStepStyle]); }, [appointmentFormModalUI.form, appointmentFormModalData.patients, appointmentFormModalData.appointmentTypes, appointmentFormModalUI.draftFiles, appointmentFormModalUI.blockStepStyle]);
const steps = useMemo(() => [ const steps = useMemo(() => [
{ {

View File

@ -6,7 +6,9 @@ import dayjs from "dayjs";
import {useGetAppointmentsQuery} from "../../../Api/appointmentsApi.js"; import {useGetAppointmentsQuery} from "../../../Api/appointmentsApi.js";
import {Grid} from "antd"; import {Grid} from "antd";
import {useUploadAppointmentFileMutation} from "../../../Api/appointmentFilesApi.js"; import {useUploadAppointmentFileMutation} from "../../../Api/appointmentFilesApi.js";
import Compressor from 'compressorjs'; // Импортируем Compressor import Compressor from 'compressorjs';
import CONFIG from "../../../Core/сonfig.js";
import axios from "axios";
const {useBreakpoint} = Grid; const {useBreakpoint} = Grid;
@ -140,6 +142,24 @@ const useAppointmentFormModalUI = (createAppointment, patients, cancelAppointmen
[previousAppointments, searchPreviousAppointments] [previousAppointments, searchPreviousAppointments]
); );
const handleAddFile = (file) => {
const maxSize = 10 * 1024 * 1024; // 10MB
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));
};
useEffect(() => { useEffect(() => {
if (modalVisible) { if (modalVisible) {
form.resetFields(); form.resetFields();
@ -149,6 +169,7 @@ const useAppointmentFormModalUI = (createAppointment, patients, cancelAppointmen
setFormValues({}); setFormValues({});
setIsDrawerVisible(false); setIsDrawerVisible(false);
setSearchPreviousAppointments(""); setSearchPreviousAppointments("");
setDraftFiles([]);
if (scheduledData) { if (scheduledData) {
const patient = patients.find((p) => p.id === scheduledData.patient_id); const patient = patients.find((p) => p.id === scheduledData.patient_id);
@ -199,6 +220,7 @@ const useAppointmentFormModalUI = (createAppointment, patients, cancelAppointmen
setSearchPatientString(""); setSearchPatientString("");
setFormValues({}); setFormValues({});
setIsDrawerVisible(false); setIsDrawerVisible(false);
setDraftFiles([]);
}; };
const handleSetAppointmentDate = (date) => setAppointmentDate(date); const handleSetAppointmentDate = (date) => setAppointmentDate(date);
@ -273,7 +295,47 @@ const useAppointmentFormModalUI = (createAppointment, patients, cancelAppointmen
results: values.results || "", results: values.results || "",
}; };
await createAppointment(data).unwrap(); const response = await createAppointment(data).unwrap();
for (const file of draftFiles) {
try {
console.log(`Uploading file: ${file.name}, size: ${file.size}, type: ${file.type}`);
// Формируем FormData
const formData = new FormData();
formData.append("file", file);
// Логируем содержимое FormData
for (let [key, value] of formData.entries()) {
console.log(`FormData entry: ${key}=${value.name || value}`);
}
// Отправляем запрос через Axios
const uploadResponse = await axios.post(
`${CONFIG.BASE_URL}/appointment_files/${response.id}/upload/`,
formData,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
// Content-Type не указываем, Axios автоматически установит multipart/form-data
},
}
);
console.log(`Successfully uploaded file: ${file.name}`, uploadResponse.data);
} catch (error) {
console.error(`Error uploading file ${file.name}:`, error);
// Улучшенная обработка ошибок
const errorMessage = error.response?.data?.detail
? JSON.stringify(error.response.data.detail, null, 2)
: error.message || "Неизвестная ошибка";
notification.error({
message: "Ошибка загрузки файла",
description: `Не удалось загрузить файл ${file.name}: ${errorMessage}`,
placement: "topRight",
});
}
}
if (scheduledData) { if (scheduledData) {
await cancelScheduledAppointment(scheduledData.id); await cancelScheduledAppointment(scheduledData.id);
@ -289,9 +351,10 @@ const useAppointmentFormModalUI = (createAppointment, patients, cancelAppointmen
dispatch(closeModal()); dispatch(closeModal());
resetForm(); resetForm();
} catch (error) { } catch (error) {
console.error("Error creating appointment:", error);
notification.error({ notification.error({
message: "Ошибка", message: "Ошибка",
description: error.data?.message || "Не удалось сохранить прием.", description: error.data?.message || "Не удалось создать прием.",
placement: "topRight", placement: "topRight",
}); });
} }
@ -301,6 +364,7 @@ const useAppointmentFormModalUI = (createAppointment, patients, cancelAppointmen
try { try {
await cancelAppointment(selectedScheduledAppointmentId); await cancelAppointment(selectedScheduledAppointmentId);
} catch (error) { } catch (error) {
console.error("Error cancelling appointment:", error);
notification.error({ notification.error({
message: "Ошибка", message: "Ошибка",
description: error.data?.message || "Не удалось отменить прием.", description: error.data?.message || "Не удалось отменить прием.",
@ -359,6 +423,9 @@ const useAppointmentFormModalUI = (createAppointment, patients, cancelAppointmen
isLoadingPreviousAppointments, isLoadingPreviousAppointments,
isErrorPreviousAppointments, isErrorPreviousAppointments,
joditConfig, joditConfig,
draftFiles,
handleAddFile,
handleRemoveFile,
}; };
}; };

View File

@ -20,6 +20,7 @@ import authReducer from "./Slices/authSlice.js";
import {rolesApi} from "../Api/rolesApi.js"; import {rolesApi} from "../Api/rolesApi.js";
import adminReducer from "./Slices/adminSlice.js"; import adminReducer from "./Slices/adminSlice.js";
import {registerApi} from "../Api/registerApi.js"; import {registerApi} from "../Api/registerApi.js";
import {appointmentFilesApi} from "../Api/appointmentFilesApi.js";
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
@ -56,7 +57,9 @@ export const store = configureStore({
[rolesApi.reducerPath]: rolesApi.reducer, [rolesApi.reducerPath]: rolesApi.reducer,
[registerApi.reducerPath]: registerApi.reducer [registerApi.reducerPath]: registerApi.reducer,
[appointmentFilesApi.reducerPath]: appointmentFilesApi.reducer,
}, },
middleware: (getDefaultMiddleware) => ( middleware: (getDefaultMiddleware) => (
getDefaultMiddleware().concat( getDefaultMiddleware().concat(
@ -73,6 +76,7 @@ export const store = configureStore({
authApi.middleware, authApi.middleware,
rolesApi.middleware, rolesApi.middleware,
registerApi.middleware, registerApi.middleware,
appointmentFilesApi.middleware,
) )
), ),
}); });