feat: Админ-панель. Добавлены вкладки пользователей и бэкапов.

This commit is contained in:
Андрей Дувакин 2025-06-29 16:55:11 +05:00
parent 67963bd395
commit 2447bc53af
14 changed files with 398 additions and 155 deletions

View File

@ -0,0 +1,26 @@
from fastapi import APIRouter, Depends
from starlette.responses import FileResponse
from app.infrastructure.backup_service import BackupService
from app.infrastructure.dependencies import require_admin
from app.settings import settings
router = APIRouter()
@router.post(
"/create/",
response_class=FileResponse,
summary="Create backup",
description="Create backup",
)
async def create_backup(
user=Depends(require_admin),
):
backup_service = BackupService(
db_url=settings.BACKUP_DB_URL,
app_files_dir=settings.FILE_UPLOAD_DIR,
backup_dir=settings.BACKUP_DIR,
pg_dump_path=settings.PG_DUMP_PATH,
)
return await backup_service.create_backup()

View File

@ -0,0 +1,40 @@
"""0006 добавил таблицу backups
Revision ID: b58238896c0f
Revises: 9e7ab1a46b64
Create Date: 2025-06-29 16:50:16.638528
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b58238896c0f'
down_revision: Union[str, None] = '9e7ab1a46b64'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('backups',
sa.Column('timestamp', sa.DateTime(), nullable=False),
sa.Column('path', sa.String(), nullable=False),
sa.Column('filename', sa.String(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('backups')
# ### end Alembic commands ###

View File

@ -4,6 +4,7 @@ Base = declarative_base()
from app.domain.models.appointment_files import AppointmentFile
from app.domain.models.appointments import Appointment
from app.domain.models.backups import Backup
from app.domain.models.appointment_types import AppointmentType
from app.domain.models.lens_types import LensType
from app.domain.models.lens_issues import LensIssue

View File

@ -0,0 +1,17 @@
import datetime
from sqlalchemy import Column, DateTime, String, Integer, ForeignKey
from app.domain.models.base import BaseModel
class Backup(BaseModel):
__tablename__ = 'backups'
timestamp = Column(DateTime, nullable=False, default=datetime.datetime.now)
path = Column(String, nullable=False)
filename = Column(String, nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)

View File

@ -0,0 +1,40 @@
import subprocess
import os
import tarfile
import datetime
from fastapi import HTTPException
from starlette.responses import FileResponse
class BackupService:
def __init__(self, db_url: str, app_files_dir: str, backup_dir: str, pg_dump_path: str):
self.db_url = db_url
self.app_files_dir = app_files_dir
self.backup_dir = backup_dir
self.pg_dump_path = pg_dump_path
os.makedirs(backup_dir, exist_ok=True)
async def create_backup(self) -> FileResponse:
try:
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_name = f"backup_{timestamp}"
backup_path = os.path.join(self.backup_dir, backup_name)
db_dump_path = f"{os.getcwd()}/{backup_path}.sql"
dump_cmd = f'"{self.pg_dump_path}" -Fc -d {self.db_url} -f "{db_dump_path}"'
subprocess.run(dump_cmd, shell=True, check=True)
with tarfile.open(f"{backup_path}.tar.gz", "w:gz") as tar:
tar.add(self.app_files_dir, arcname=self.app_files_dir)
tar.add(db_dump_path, arcname="db_dump.sql")
os.remove(db_dump_path)
return FileResponse(
f"{backup_path}.tar.gz",
media_type="application/gzip",
filename=backup_name,
)
except subprocess.CalledProcessError as e:
print(e)
raise HTTPException(500, f"Ошибка создания бэкапа: {e}")

View File

@ -5,6 +5,7 @@ from app.controllers.appointment_files_router import router as appointment_files
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
from app.controllers.backup_router import router as backups_router
from app.controllers.lens_issues_router import router as lens_issues_router
from app.controllers.lens_types_router import router as lens_types_router
from app.controllers.lenses_router import router as lenses_router
@ -35,6 +36,7 @@ def start_app():
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'])
api_app.include_router(backups_router, prefix=f'{settings.APP_PREFIX}/backups', tags=['backups'])
api_app.include_router(lens_issues_router, prefix=f'{settings.APP_PREFIX}/lens_issues', tags=['lens_issue'])
api_app.include_router(lens_types_router, prefix=f'{settings.APP_PREFIX}/lens_types', tags=['lens_types'])
api_app.include_router(lenses_router, prefix=f'{settings.APP_PREFIX}/lenses', tags=['lenses'])

View File

@ -8,6 +8,10 @@ class Settings(BaseSettings):
SECRET_KEY: str
ALGORITHM: str
APP_PREFIX: str = '/api/v1'
FILE_UPLOAD_DIR: str = 'uploads'
BACKUP_DIR: str = 'backups'
BACKUP_DB_URL: str
PG_DUMP_PATH: str
class Config:
env_file = '.env'

View File

@ -1,10 +1,10 @@
import { fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { logout } from '../Redux/Slices/authSlice.js';
import {fetchBaseQuery} from '@reduxjs/toolkit/query/react';
import {logout} from '../Redux/Slices/authSlice.js';
import CONFIG from "../Core/сonfig.js";
export const baseQuery = fetchBaseQuery({
baseUrl: CONFIG.BASE_URL,
prepareHeaders: (headers, { getState, endpoint }) => {
prepareHeaders: (headers, {getState, endpoint}) => {
const token = localStorage.getItem('access_token');
if (token) {
headers.set('Authorization', `Bearer ${token}`);
@ -25,7 +25,7 @@ export const baseQuery = fetchBaseQuery({
export const baseQueryWithAuth = async (args, api, extraOptions) => {
const result = await baseQuery(args, api, extraOptions);
if (result.error && result.error.status === 401) {
if (result.error && [401, 403].includes(result.error.status)) {
localStorage.removeItem('access_token');
api.dispatch(logout());
window.location.href = '/login';

View File

@ -1,101 +1,43 @@
import {Table, Button, Result, Typography, Switch, Input, Tooltip, FloatButton} from "antd";
import {ControlOutlined, PlusOutlined} from "@ant-design/icons";
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import {Tabs, Typography} from "antd";
import {
ContainerOutlined,
ControlOutlined,
UserOutlined
} from "@ant-design/icons";
import useAdminPage from "./useAdminPage.js";
import CreateUserModalForm from "./Components/CreateUserModalForm/CreateUserModalForm.jsx";
import UpdateUserModalForm from "../../Dummies/UpdateUserModalForm/UpdateUserModalForm.jsx";
import UsersManageTab from "./Components/UsersManageTab/UsersManageTab.jsx";
import BackupManageTab from "./Components/BackupManageTab/BackupManageTab.jsx";
const items = [
{
key: '1',
label: 'Пользователи',
children: <UsersManageTab/>,
icon: <UserOutlined/>
},
{
key: '2',
label: 'Резервное копирование и восстановление',
children: <BackupManageTab/>,
icon: <ContainerOutlined/>
}
]
const AdminPage = () => {
const adminPageData = useAdminPage();
const columns = [
{
title: "ID",
dataIndex: "id",
key: "id",
},
{
title: "Фамилия",
dataIndex: "last_name",
key: "lastName",
sorter: (a, b) => a.last_name.localeCompare(b.last_name),
},
{
title: "Имя",
dataIndex: "first_name",
key: "firstName",
sorter: (a, b) => a.first_name.localeCompare(b.first_name),
},
{
title: "Отчество",
dataIndex: "patronymic",
key: "patronymic",
},
{
title: "Роль",
dataIndex: ["role", "title"],
key: "role",
filters: adminPageData.roles.map(role => ({text: role.title, value: role.title})),
onFilter: (value, record) => record.role.title === value,
},
{
title: "Заблокирован",
dataIndex: "is_blocked",
key: "is_blocked",
render: (value, record) => (
<Switch
loading={adminPageData.isBlocking}
checked={value}
checkedChildren="Заблокирован"
unCheckedChildren="Не заблокирован"
onChange={(isBlocked) => adminPageData.setIsBlockUser(record.id, isBlocked)}
/>
),
},
{
title: "Действия",
key: "actions",
render: (_, record) => (
<Button type="link" onClick={() => adminPageData.openEditModal(record)}>
Редактировать
</Button>
),
},
];
if (adminPageData.isError) {
return <Result status="500" title="500" subTitle="Произошла ошибка при загрузке данных"/>;
}
return (
<div style={adminPageData.containerStyle}>
{adminPageData.isLoading ? (
<LoadingIndicator/>
) : (
<>
<Typography.Title level={1}>
<ControlOutlined/> Панель администратора
</Typography.Title>
<Input
placeholder="Введите фамилию, имя или отчество"
style={{marginBottom: 12}}
/>
<Table
columns={columns}
dataSource={adminPageData.users}
rowKey="id"
pagination={{pageSize: 10}}
loading={adminPageData.isLoading}
/>
<Tooltip title="Добавить пользователя">
<FloatButton onClick={adminPageData.openCreateModal} icon={<PlusOutlined/>} type={"primary"}/>
</Tooltip>
<CreateUserModalForm/>
<UpdateUserModalForm/>
</>
)}
<>
<Typography.Title level={1}>
<ControlOutlined/> Панель администратора
</Typography.Title>
<Tabs
defaultActiveKey="1"
items={items}
/>
</>
</div>
);
};

View File

@ -0,0 +1,38 @@
import {Button, Space, Spin, Typography, Upload} from "antd";
import {CloudDownloadOutlined, UploadOutlined} from "@ant-design/icons";
const BackupManageTab = () => {
return (
<Spin spinning={false}>
<Typography>
<Typography.Title level={4}>Управление резервными копиями</Typography.Title>
<Typography.Paragraph>
Здесь вы можете создать резервную копию системы и восстановить её из архива.
</Typography.Paragraph>
</Typography>
<Space direction="vertical" size="large" style={{width: "100%"}}>
<Button
type="primary"
icon={<CloudDownloadOutlined/>}
// onClick={handleCreateBackup}
// disabled={loading}
block
>
Создать и скачать бэкап
</Button>
<Upload
// beforeUpload={handleUpload}
showUploadList={false}
accept=".tar.gz,.zip"
// disabled={loading}
>
<Button icon={<UploadOutlined/>} block>
Загрузить бэкап для восстановления
</Button>
</Upload>
</Space>
</Spin>
);
};
export default BackupManageTab;

View File

@ -0,0 +1,99 @@
import {Button, FloatButton, Input, Result, Switch, Table, Tooltip} from "antd";
import CreateUserModalForm from "../CreateUserModalForm/CreateUserModalForm.jsx";
import UpdateUserModalForm from "../../../../Dummies/UpdateUserModalForm/UpdateUserModalForm.jsx";
import useUsersManageTab from "./useUsersManageTab.js";
import {PlusOutlined} from "@ant-design/icons";
import LoadingIndicator from "../../../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
const UsersManageTab = () => {
const usersManageTabData = useUsersManageTab();
const columns = [
{
title: "ID",
dataIndex: "id",
key: "id",
},
{
title: "Фамилия",
dataIndex: "last_name",
key: "lastName",
sorter: (a, b) => a.last_name.localeCompare(b.last_name),
},
{
title: "Имя",
dataIndex: "first_name",
key: "firstName",
sorter: (a, b) => a.first_name.localeCompare(b.first_name),
},
{
title: "Отчество",
dataIndex: "patronymic",
key: "patronymic",
},
{
title: "Роль",
dataIndex: ["role", "title"],
key: "role",
filters: usersManageTabData.roles.map(role => ({text: role.title, value: role.title})),
onFilter: (value, record) => record.role.title === value,
},
{
title: "Заблокирован",
dataIndex: "is_blocked",
key: "is_blocked",
render: (value, record) => (
<Switch
loading={usersManageTabData.isBlocking}
checked={value}
checkedChildren="Заблокирован"
unCheckedChildren="Не заблокирован"
onChange={(isBlocked) => usersManageTabData.setIsBlockUser(record.id, isBlocked)}
/>
),
},
{
title: "Действия",
key: "actions",
render: (_, record) => (
<Button type="link" onClick={() => usersManageTabData.openEditModal(record)}>
Редактировать
</Button>
),
},
];
if (usersManageTabData.isError) {
return <Result status="500" title="500" subTitle="Произошла ошибка при загрузке данных"/>;
}
if (usersManageTabData.isLoading) {
return <LoadingIndicator/>;
}
return (
<>
<Input
placeholder="Введите фамилию, имя или отчество"
style={{marginBottom: 12}}
allowClear
onChange={usersManageTabData.handleSearch}
/>
<Table
columns={columns}
dataSource={usersManageTabData.filteredUsers}
rowKey="id"
pagination={{pageSize: 10}}
/>
<Tooltip title="Добавить пользователя">
<FloatButton onClick={usersManageTabData.openCreateModal} icon={<PlusOutlined/>} type={"primary"}/>
</Tooltip>
<CreateUserModalForm/>
<UpdateUserModalForm/>
</>
)
};
export default UsersManageTab;

View File

@ -0,0 +1,93 @@
import {useGetAllUsersQuery, useSetIsBlockedMutation} from "../../../../../Api/usersApi.js";
import {useGetRolesQuery} from "../../../../../Api/rolesApi.js";
import {Grid, notification} from "antd";
import {useDispatch} from "react-redux";
import {setIsCurrentUser, setSelectedUser} from "../../../../../Redux/Slices/usersSlice.js";
import {openModal} from "../../../../../Redux/Slices/adminSlice.js";
import {useMemo, useState} from "react";
const {useBreakpoint} = Grid;
const useUsersManageTab = () => {
const dispatch = useDispatch();
const screens = useBreakpoint();
const [searchString, setSearchString] = useState("");
const {
data: users = [], isLoading, isError,
} = useGetAllUsersQuery(undefined, {
pollingInterval: 10000,
});
const {data: roles = [], isLoading: isLoadingRoles, isError: isErrorRoles} = useGetRolesQuery(undefined, {
pollingInterval: 60000,
});
const [setIsBlocked, {isLoading: isBlocking, isError: isBlockError}] = useSetIsBlockedMutation();
const containerStyle = {padding: screens.xs ? 16 : 24};
const openEditModal = (user) => {
dispatch(setSelectedUser(user));
dispatch(setIsCurrentUser(true));
};
const openCreateModal = () => {
dispatch(openModal());
};
const handleSearch = (e) => {
setSearchString(e.target.value);
};
const setIsBlockUser = async (user, isBlock) => {
try {
await setIsBlocked({userId: user, isBlocked: isBlock}).unwrap();
notification.success({
message: "Успех",
description: isBlock
? "Пользователь успешно заблокирован"
: "Пользователь успешно разблокирован",
placement: "topRight",
})
} catch (error) {
notification.error({
message: "Ошибка",
description: error?.data?.detail ? error?.data?.detail : isBlock
? "Не удалось заблокировать пользователя"
: "Не удалось разблокировать пользователя",
placement: "topRight",
})
}
};
const filteredUsers = useMemo(() => {
return users.filter((user) => {
return Object.entries(user).some(([key, value]) => {
if (typeof value === "string") {
return value.toString().toLowerCase().includes(searchString.toLowerCase());
}
});
});
}, [users, searchString]);
return {
roles,
isBlocking,
filteredUsers,
isLoading: isLoading || isLoadingRoles,
isError: isError || isErrorRoles,
isUpdating: false,
isUpdateError: false,
isCreating: false,
isCreateError: false,
containerStyle,
openEditModal,
openCreateModal,
setIsBlockUser,
handleSearch,
};
};
export default useUsersManageTab;

View File

@ -1,74 +1,15 @@
import {Grid, notification} from "antd";
import {useDispatch} from "react-redux";
import {openModal} from "../../../Redux/Slices/adminSlice.js";
import {setIsCurrentUser, setSelectedUser} from "../../../Redux/Slices/usersSlice.js";
import {useGetAllUsersQuery, useSetIsBlockedMutation} from "../../../Api/usersApi.js";
import {useGetRolesQuery} from "../../../Api/rolesApi.js";
import {Grid} from "antd";
const {useBreakpoint} = Grid;
const useAdminPage = () => {
const dispatch = useDispatch();
const screens = useBreakpoint();
const {
data: users = [], isLoading, isError,
} = useGetAllUsersQuery(undefined, {
pollingInterval: 10000,
});
const {data: roles = [], isLoading: isLoadingRoles, isError: isErrorRoles} = useGetRolesQuery(undefined, {
pollingInterval: 60000,
});
const [setIsBlocked, {isLoading: isBlocking, isError: isBlockError}] = useSetIsBlockedMutation();
const containerStyle = {padding: screens.xs ? 16 : 24};
const openEditModal = (user) => {
dispatch(setSelectedUser(user));
dispatch(setIsCurrentUser(true));
};
const openCreateModal = () => {
dispatch(openModal());
};
const setIsBlockUser = async (user, isBlock) => {
try {
await setIsBlocked({userId: user, isBlocked: isBlock}).unwrap();
notification.success({
message: "Успех",
description: isBlock
? "Пользователь успешно заблокирован"
: "Пользователь успешно разблокирован",
placement: "topRight",
})
} catch (error) {
notification.error({
message: "Ошибка",
description: error?.data?.detail ? error?.data?.detail : isBlock
? "Не удалось заблокировать пользователя"
: "Не удалось разблокировать пользователя",
placement: "topRight",
})
}
};
return {
users,
roles,
isBlocking,
isLoading: isLoading || isLoadingRoles,
isError: isError || isErrorRoles,
isUpdating: false,
isUpdateError: false,
isCreating: false,
isCreateError: false,
containerStyle,
openEditModal,
openCreateModal,
setIsBlockUser,
containerStyle
};
};