feat: Добавлена блокировка пользователя.

This commit is contained in:
Андрей Дувакин 2025-06-28 18:05:21 +05:00
parent 7574b08b25
commit ceee769100
19 changed files with 268 additions and 254 deletions

View File

@ -0,0 +1,30 @@
"""0005_добавил блокировку пользователя
Revision ID: 9e7ab1a46b64
Revises: 69fee5fc14c8
Create Date: 2025-06-15 13:41:20.591874
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9e7ab1a46b64'
down_revision: Union[str, None] = '69fee5fc14c8'
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.add_column('users', sa.Column('is_blocked', sa.Boolean(), server_default='false', nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'is_blocked')
# ### end Alembic commands ###

View File

@ -11,6 +11,7 @@ class UserEntity(BaseModel):
last_name: str
patronymic: Optional[str] = None
login: str
is_blocked: bool
role_id: Optional[int] = None

View File

@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, VARCHAR, ForeignKey, String
from sqlalchemy import Column, Integer, VARCHAR, ForeignKey, String, Boolean
from sqlalchemy.orm import relationship
from werkzeug.security import check_password_hash, generate_password_hash
@ -13,6 +13,7 @@ class User(BaseModel):
patronymic = Column(VARCHAR(200))
login = Column(String, nullable=False, unique=True)
password = Column(String, nullable=False)
is_blocked = Column(Boolean, nullable=False, default=False, server_default='false')
role_id = Column(Integer, ForeignKey('roles.id'), nullable=False)

View File

@ -180,6 +180,7 @@ class UsersService:
last_name=user.last_name,
patronymic=user.patronymic,
login=user.login,
is_blocked=user.is_blocked,
)
if user.id is not None:
@ -196,4 +197,5 @@ class UsersService:
patronymic=user.patronymic,
login=user.login,
role_id=user.role_id,
is_blocked=user.is_blocked,
)

View File

@ -11,8 +11,8 @@ service:
resources:
limits:
memory: 512Mi
cpu: 500m
memory: 128Mi
cpu: 200m
persistence:
path: /mnt/k8s_storage/visus-api

View File

@ -11,8 +11,8 @@ service:
resources:
limits:
memory: 512Mi
cpu: 500m
memory: 128Mi
cpu: 200m
ingress:
secretTLSName: visus-web-tls-secret

View File

@ -1,4 +1,4 @@
import {Button, Form, Input, Modal, Select, Space, Typography} from "antd";
import {Button, Form, Input, Modal, Select, Space, Switch, Typography} from "antd";
import useUpdateUserModalFormUI from "./useUpdateUserModalFormUI.js";
import useUpdateUserModalForm from "./useUpdateUserModalForm.js";

View File

@ -1,4 +1,4 @@
import {Table, Button, Result, Typography} from "antd";
import {Table, Button, Result, Typography, Switch} from "antd";
import {ControlOutlined} from "@ant-design/icons";
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import useAdminPage from "./useAdminPage.js";
@ -41,6 +41,18 @@ const AdminPage = () => {
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
value={value}
checkedChildren={"Заблокирован"}
unCheckedChildren={"Разблокирован"}
/>
)
},
{
title: "Действия",
key: "actions",

View File

@ -1,11 +1,9 @@
import {Form, Input, Button, Row, Col, Typography, Image, Space} from "antd";
import { Form, Input, Button, Row, Col, Typography, Image, Space } from "antd";
import useLoginPage from "./useLoginPage.js";
import useLoginPageUI from "./useLoginPageUI.js";
const {Title} = Typography;
const { Title } = Typography;
const LoginPage = () => {
const {onFinish, isLoading} = useLoginPage();
const {
containerStyle,
formContainerStyle,
@ -13,8 +11,10 @@ const LoginPage = () => {
logoBlockStyle,
logoStyle,
appNameStyle,
labels
} = useLoginPageUI();
labels,
isLoading,
onFinish,
} = useLoginPage();
return (
<Row justify="center" align="middle" style={containerStyle}>
@ -35,19 +35,19 @@ const LoginPage = () => {
{labels.title}
</Title>
<Form name="login" initialValues={{remember: true}} onFinish={onFinish}>
<Form name="login" initialValues={{ remember: true }} onFinish={onFinish}>
<Form.Item
name="login"
rules={[{required: true, message: labels.loginRequired}]}
rules={[{ required: true, message: labels.loginRequired }]}
>
<Input placeholder={labels.loginPlaceholder}/>
<Input placeholder={labels.loginPlaceholder} />
</Form.Item>
<Form.Item
name="password"
rules={[{required: true, message: labels.passwordRequired}]}
rules={[{ required: true, message: labels.passwordRequired }]}
>
<Input.Password placeholder={labels.passwordPlaceholder}/>
<Input.Password placeholder={labels.passwordPlaceholder} />
</Form.Item>
<Form.Item>

View File

@ -1,12 +1,73 @@
import { useDispatch } from "react-redux";
import { notification } from "antd";
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import { Grid, notification } from "antd";
import { setError, setUser } from "../../../Redux/Slices/authSlice.js";
import { useLoginMutation } from "../../../Api/authApi.js";
import { checkAuth } from "../../../Redux/Slices/authSlice.js";
const { useBreakpoint } = Grid;
const useLoginPage = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const [loginUser, { isLoading }] = useLoginMutation();
const { user, userData } = useSelector((state) => state.auth);
const screens = useBreakpoint();
const hasRedirected = useRef(false);
const containerStyle = {
minHeight: "100vh",
};
const formContainerStyle = {
padding: screens.xs ? 10 : 20,
border: "1px solid #ddd",
borderRadius: 8,
textAlign: "center",
};
const titleStyle = {
textAlign: "center",
marginBottom: 20,
};
const logoStyle = {
width: 80,
marginBottom: 10,
borderRadius: 20,
border: "1px solid #ddd",
};
const appNameStyle = {
textAlign: "center",
color: "#1890ff",
marginBottom: 40,
};
const logoBlockStyle = {
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
};
const labels = {
title: "Авторизация",
loginPlaceholder: "Логин",
passwordPlaceholder: "Пароль",
submitButton: "Войти",
loginRequired: "Пожалуйста, введите логин",
passwordRequired: "Пожалуйста, введите пароль",
};
useEffect(() => {
if (user && userData && !isLoading && !hasRedirected.current) {
hasRedirected.current = true;
navigate("/");
}
document.title = labels.title;
}, [user, userData, isLoading, navigate]);
const onFinish = async (loginData) => {
try {
@ -32,6 +93,13 @@ const useLoginPage = () => {
};
return {
containerStyle,
formContainerStyle,
titleStyle,
logoStyle,
appNameStyle,
labels,
logoBlockStyle,
onFinish,
isLoading,
};

View File

@ -1,78 +0,0 @@
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import { Grid } from "antd";
const { useBreakpoint } = Grid;
const useLoginPageUI = () => {
const navigate = useNavigate();
const { user, userData, isLoading } = useSelector((state) => state.auth);
const screens = useBreakpoint();
const hasRedirected = useRef(false);
const containerStyle = {
minHeight: "100vh",
};
const formContainerStyle = {
padding: screens.xs ? 10 : 20,
border: "1px solid #ddd",
borderRadius: 8,
textAlign: "center",
};
const titleStyle = {
textAlign: "center",
marginBottom: 20,
};
const logoStyle = {
width: 80,
marginBottom: 10,
borderRadius: 20,
border: "1px solid #ddd",
};
const appNameStyle = {
textAlign: "center",
color: "#1890ff",
marginBottom: 40,
};
const logoBlockStyle = {
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
};
const labels = {
title: "Авторизация",
loginPlaceholder: "Логин",
passwordPlaceholder: "Пароль",
submitButton: "Войти",
loginRequired: "Пожалуйста, введите логин",
passwordRequired: "Пожалуйста, введите пароль",
};
useEffect(() => {
if (user && userData && !isLoading && !hasRedirected.current) {
hasRedirected.current = true;
navigate("/");
}
document.title = labels.title;
}, [user, userData, isLoading, navigate]);
return {
containerStyle,
formContainerStyle,
titleStyle,
logoStyle,
appNameStyle,
labels,
logoBlockStyle,
};
};
export default useLoginPageUI;

View File

@ -2,22 +2,19 @@ import {Button, Card, Col, Row, Typography, Result} from "antd";
import {EditOutlined, UserOutlined} from "@ant-design/icons";
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import useProfilePage from "./useProfilePage.js";
import useProfilePageUI from "./useProfilePageUI.js";
import UpdateUserModalForm from "../../Dummies/UpdateUserModalForm/UpdateUserModalForm.jsx";
const ProfilePage = () => {
const {
userData,
isLoading,
isError,
} = useProfilePage();
const {
containerStyle,
cardStyle,
buttonStyle,
isLoading,
isError,
userData,
handleEditUser,
} = useProfilePageUI(userData);
} = useProfilePage();
if (isError) {
return (

View File

@ -1,8 +1,14 @@
import {
useGetAuthenticatedUserDataQuery,
} from "../../../Api/usersApi.js";
import {Grid} from "antd";
import {useDispatch} from "react-redux";
import {setSelectedUser} from "../../../Redux/Slices/usersSlice.js";
import {useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js";
const {useBreakpoint} = Grid;
const useProfilePage = () => {
const dispatch = useDispatch();
const screens = useBreakpoint();
const {
data: userData = {},
isLoading: isLoadingUserData,
@ -11,10 +17,23 @@ const useProfilePage = () => {
pollingInterval: 20000,
});
const containerStyle = {padding: screens.xs ? 16 : 24};
const cardStyle = {marginBottom: 24};
const buttonStyle = {width: screens.xs ? "100%" : "auto"};
const handleEditUser = () => {
dispatch(setSelectedUser(userData))
};
return {
userData,
containerStyle,
cardStyle,
buttonStyle,
isMobile: screens.xs,
isLoading: isLoadingUserData,
isError: isErrorUserData,
handleEditUser,
};
};

View File

@ -1,29 +0,0 @@
import {Grid} from "antd";
import {useDispatch} from "react-redux";
import {setSelectedUser} from "../../../Redux/Slices/usersSlice.js";
const { useBreakpoint } = Grid;
const useProfilePageUI = (userData) => {
const dispatch = useDispatch();
const screens = useBreakpoint();
const containerStyle = { padding: screens.xs ? 16 : 24 };
const cardStyle = { marginBottom: 24 };
const buttonStyle = { width: screens.xs ? "100%" : "auto" };
const handleEditUser = () => {
dispatch(setSelectedUser(userData))
};
return {
containerStyle,
cardStyle,
buttonStyle,
isMobile: screens.xs,
handleEditUser,
};
};
export default useProfilePageUI;

View File

@ -1,9 +1,7 @@
import {Button, Modal, Popconfirm, Row, Typography} from "antd";
import useScheduledAppointmentsViewModal from "./useScheduledAppointmentsViewModal.js";
import useScheduledAppointmentsViewModalUI from "./useScheduledAppointmentsViewModalUI.js";
const ScheduledAppointmentsViewModal = () => {
const scheduledAppointmentsViewModalData = useScheduledAppointmentsViewModal();
const {
selectedScheduledAppointment,
modalWidth,
@ -19,7 +17,7 @@ const ScheduledAppointmentsViewModal = () => {
onCancel,
cancelScheduledAppointment,
handleConvertToAppointment,
} = useScheduledAppointmentsViewModalUI(scheduledAppointmentsViewModalData.cancelAppointment);
} = useScheduledAppointmentsViewModal();
if (!selectedScheduledAppointment) {
return null;

View File

@ -1,10 +1,110 @@
import { useDispatch, useSelector } from "react-redux";
import { setSelectedScheduledAppointment, openModalWithScheduledData } from "../../../Redux/Slices/appointmentsSlice.js";
import { notification } from "antd";
import dayjs from "dayjs";
import {useCancelScheduledAppointmentMutation} from "../../../Api/scheduledAppointmentsApi.js";
const useScheduledAppointmentsViewModal = () => {
const dispatch = useDispatch();
const { selectedScheduledAppointment } = useSelector((state) => state.appointmentsUI);
const [cancelAppointment] = useCancelScheduledAppointmentMutation();
return {cancelAppointment};
const modalWidth = 700;
const blockStyle = { marginBottom: 16 };
const footerRowStyle = { marginTop: 16, gap: 8 };
const footerButtonStyle = { marginRight: 8 };
const labels = {
title: "Просмотр запланированного приема",
patient: "Пациент:",
birthday: "Дата рождения:",
email: "Email:",
phone: "Телефон:",
type: "Тип приема:",
appointmentTime: "Время приема:",
closeButton: "Закрыть",
convertButton: "Конвертировать в прием",
cancelButton: "Отмена приема",
popconfirmTitle: "Вы уверены, что хотите отменить прием?",
popconfirmOk: "Да, отменить",
popconfirmCancel: "Отмена",
notSpecified: "Не указан",
};
const visible = !!selectedScheduledAppointment;
const getDateString = (date) => {
return date ? dayjs(date).format("DD.MM.YYYY") : labels.notSpecified;
};
const getAppointmentTime = (datetime) => {
return datetime
? dayjs(datetime).format("DD.MM.YYYY HH:mm")
: labels.notSpecified;
};
const getPatientName = (patient) => {
return patient
? `${patient.last_name} ${patient.first_name}`
: labels.notSpecified;
};
const getPatientField = (field) => {
return field || labels.notSpecified;
};
const onCancel = () => {
dispatch(setSelectedScheduledAppointment(null));
};
const cancelScheduledAppointment = async () => {
try {
await cancelAppointment(selectedScheduledAppointment.id);
notification.success({
message: "Прием отменен",
placement: "topRight",
description: "Прием успешно отменен.",
});
onCancel();
} catch (error) {
notification.error({
message: "Ошибка",
description: error?.data?.detail || "Не удалось отменить прием.",
placement: "topRight",
});
}
};
const handleConvertToAppointment = () => {
if (selectedScheduledAppointment) {
dispatch(
openModalWithScheduledData({
id: selectedScheduledAppointment.id,
patient_id: selectedScheduledAppointment.patient?.id,
type_id: selectedScheduledAppointment.type?.id,
appointment_datetime: selectedScheduledAppointment.scheduled_datetime,
})
);
}
};
return {
selectedScheduledAppointment,
modalWidth,
blockStyle,
footerRowStyle,
footerButtonStyle,
labels,
visible,
getDateString,
getAppointmentTime,
getPatientName,
getPatientField,
onCancel,
cancelScheduledAppointment,
handleConvertToAppointment,
};
};
export default useScheduledAppointmentsViewModal;

View File

@ -1,107 +0,0 @@
import { useDispatch, useSelector } from "react-redux";
import { setSelectedScheduledAppointment, openModalWithScheduledData } from "../../../Redux/Slices/appointmentsSlice.js";
import { notification } from "antd";
import dayjs from "dayjs";
const useScheduledAppointmentsViewModalUI = (cancelAppointment) => {
const dispatch = useDispatch();
const { selectedScheduledAppointment } = useSelector((state) => state.appointmentsUI);
const modalWidth = 700;
const blockStyle = { marginBottom: 16 };
const footerRowStyle = { marginTop: 16, gap: 8 };
const footerButtonStyle = { marginRight: 8 };
const labels = {
title: "Просмотр запланированного приема",
patient: "Пациент:",
birthday: "Дата рождения:",
email: "Email:",
phone: "Телефон:",
type: "Тип приема:",
appointmentTime: "Время приема:",
closeButton: "Закрыть",
convertButton: "Конвертировать в прием",
cancelButton: "Отмена приема",
popconfirmTitle: "Вы уверены, что хотите отменить прием?",
popconfirmOk: "Да, отменить",
popconfirmCancel: "Отмена",
notSpecified: "Не указан",
};
const visible = !!selectedScheduledAppointment;
const getDateString = (date) => {
return date ? dayjs(date).format("DD.MM.YYYY") : labels.notSpecified;
};
const getAppointmentTime = (datetime) => {
return datetime
? dayjs(datetime).format("DD.MM.YYYY HH:mm")
: labels.notSpecified;
};
const getPatientName = (patient) => {
return patient
? `${patient.last_name} ${patient.first_name}`
: labels.notSpecified;
};
const getPatientField = (field) => {
return field || labels.notSpecified;
};
const onCancel = () => {
dispatch(setSelectedScheduledAppointment(null));
};
const cancelScheduledAppointment = async () => {
try {
await cancelAppointment(selectedScheduledAppointment.id);
notification.success({
message: "Прием отменен",
placement: "topRight",
description: "Прием успешно отменен.",
});
onCancel();
} catch (error) {
notification.error({
message: "Ошибка",
description: error?.data?.detail || "Не удалось отменить прием.",
placement: "topRight",
});
}
};
const handleConvertToAppointment = () => {
if (selectedScheduledAppointment) {
dispatch(
openModalWithScheduledData({
id: selectedScheduledAppointment.id,
patient_id: selectedScheduledAppointment.patient?.id,
type_id: selectedScheduledAppointment.type?.id,
appointment_datetime: selectedScheduledAppointment.scheduled_datetime,
})
);
}
};
return {
selectedScheduledAppointment,
modalWidth,
blockStyle,
footerRowStyle,
footerButtonStyle,
labels,
visible,
getDateString,
getAppointmentTime,
getPatientName,
getPatientField,
onCancel,
cancelScheduledAppointment,
handleConvertToAppointment,
};
};
export default useScheduledAppointmentsViewModalUI;

View File

@ -1,12 +1,12 @@
import { Select, Tooltip } from "antd";
import PropTypes from "prop-types";
import { ViewModPropType } from "../../../Types/viewModPropType.js";
import useSelectViewModeUI from "./useSelectViewModeUI.js";
import useSelectViewMode from "./useSelectViewMode.js";
const { Option } = Select;
const SelectViewMode = ({ viewMode, setViewMode, localStorageKey, toolTipText, viewModes }) => {
const { selectStyle, handleChange } = useSelectViewModeUI({ setViewMode, localStorageKey });
const { selectStyle, handleChange } = useSelectViewMode({ setViewMode, localStorageKey });
return (
<Tooltip title={toolTipText}>

View File

@ -1,6 +1,6 @@
import { cacheInfo } from "../../../Utils/cachedInfoUtils.js";
const useSelectViewModeUI = ({ setViewMode, localStorageKey }) => {
const useSelectViewMode = ({ setViewMode, localStorageKey }) => {
const selectStyle = {
width: "100%",
};
@ -16,4 +16,4 @@ const useSelectViewModeUI = ({ setViewMode, localStorageKey }) => {
};
};
export default useSelectViewModeUI;
export default useSelectViewMode;