diff --git a/api/app/application/statuses_repository.py b/api/app/application/statuses_repository.py index f189d03..38c82fa 100644 --- a/api/app/application/statuses_repository.py +++ b/api/app/application/statuses_repository.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, List, Sequence from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -10,6 +10,11 @@ class StatusesRepository: def __init__(self, db: AsyncSession) -> None: self.db = db + async def get_all(self) -> Sequence[Status]: + query = select(Status) + results = await self.db.execute(query) + return results.scalars().all() + async def get_by_id(self, status_id: int) -> Optional[Status]: query = ( select(Status) diff --git a/api/app/controllers/statuses_router.py b/api/app/controllers/statuses_router.py new file mode 100644 index 0000000..6fdb1e0 --- /dev/null +++ b/api/app/controllers/statuses_router.py @@ -0,0 +1,26 @@ +from typing import List, Optional + +from fastapi import APIRouter, Depends, Response +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_db +from app.domain.entities.statuses import StatusRead +from app.domain.models import User +from app.infrastructure.dependencies import require_auth_user +from app.infrastructure.statuses_servise import StatusesService + +statuses_router = APIRouter() + + +@statuses_router.get( + '/', + response_model=List[StatusRead], + summary='Return all statuses', + description='Return all statuses', +) +async def get_statuses( + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth_user), +): + statuses_service = StatusesService(db) + return await statuses_service.get_all() diff --git a/api/app/controllers/users_router.py b/api/app/controllers/users_router.py index 224e5a5..b4d59ad 100644 --- a/api/app/controllers/users_router.py +++ b/api/app/controllers/users_router.py @@ -72,6 +72,7 @@ async def change_password( users_service = UsersService(db) return await users_service.change_password(user_id, password_data, user) + @users_router.post( '/create/', response_model=Optional[UserRead], diff --git a/api/app/domain/entities/users.py b/api/app/domain/entities/users.py index df2d91c..5e5d910 100644 --- a/api/app/domain/entities/users.py +++ b/api/app/domain/entities/users.py @@ -34,8 +34,11 @@ class UserUpdate(BaseModel): first_name: str = Field(max_length=250) last_name: str = Field(max_length=250) patronymic: Optional[str] = Field(default=None, max_length=250) + login: str = Field(max_length=250) email: Optional[EmailStr] = None birthdate: date + role_id: Optional[int] = Field(default=None) + status_id: Optional[int] = Field(default=None) class PasswordChangeRequest(BaseModel): diff --git a/api/app/infrastructure/statuses_servise.py b/api/app/infrastructure/statuses_servise.py new file mode 100644 index 0000000..28ec290 --- /dev/null +++ b/api/app/infrastructure/statuses_servise.py @@ -0,0 +1,21 @@ +from typing import List + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.application.statuses_repository import StatusesRepository +from app.domain.entities.statuses import StatusRead + + +class StatusesService: + def __init__(self, db: AsyncSession): + self.statuses_repository = StatusesRepository(db) + + async def get_all(self) -> List[StatusRead]: + statuses = await self.statuses_repository.get_all() + response = [] + for status in statuses: + response.append( + StatusRead.model_validate(status) + ) + + return response diff --git a/api/app/infrastructure/users_service.py b/api/app/infrastructure/users_service.py index 634da3b..3a110ee 100644 --- a/api/app/infrastructure/users_service.py +++ b/api/app/infrastructure/users_service.py @@ -42,7 +42,7 @@ class UsersService: detail='Пользователь не найден', ) - if current_user.id != user_model.id and not current_user.role.title != self.settings.root_role_name: + if current_user.id != user_model.id and current_user.role.title != self.settings.root_role_name: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail='Доступ запрещен', @@ -54,6 +54,10 @@ class UsersService: user_model.email = user.email user_model.birthdate = user.birthdate + if current_user.role.title == self.settings.root_role_name and user.role_id is not None: + user_model.role_id = user.role_id + user_model.status_id = user.status_id + user_model = await self.users_repository.update(user_model) return UserRead.model_validate(user_model) @@ -68,7 +72,7 @@ class UsersService: detail='Пользователь не найден', ) - if current_user.id != user_model.id and not current_user.role.title != self.settings.root_role_name: + if current_user.id != user_model.id and current_user.role.title != self.settings.root_role_name: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail='Доступ запрещен', diff --git a/api/app/main.py b/api/app/main.py index c4669ec..3c4f2d4 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -4,6 +4,7 @@ from starlette.middleware.cors import CORSMiddleware from app.controllers.auth_router import auth_router from app.controllers.register_router import register_router from app.controllers.roles_router import roles_router +from app.controllers.statuses_router import statuses_router from app.controllers.users_router import users_router from app.settings import Settings @@ -23,6 +24,7 @@ def start_app(): api_app.include_router(auth_router, prefix=f'{settings.prefix}/auth', tags=['auth']) api_app.include_router(register_router, prefix=f'{settings.prefix}/register', tags=['register']) api_app.include_router(roles_router, prefix=f'{settings.prefix}/roles', tags=['roles']) + api_app.include_router(statuses_router, prefix=f'{settings.prefix}/statuses', tags=['statuses']) api_app.include_router(users_router, prefix=f'{settings.prefix}/users', tags=['users']) return api_app diff --git a/web/src/Api/statusesApi.js b/web/src/Api/statusesApi.js new file mode 100644 index 0000000..75fede2 --- /dev/null +++ b/web/src/Api/statusesApi.js @@ -0,0 +1,20 @@ +import {createApi, fetchBaseQuery} from "@reduxjs/toolkit/query/react"; +import CONFIG from "../Core/сonfig.js"; +import {baseQueryWithAuth} from "./baseQuery.js"; + + +export const statusesApi = createApi({ + reducerPath: 'statusesApi', + baseQuery: baseQueryWithAuth, + tagTypes: ['Statuses'], + endpoints: (builder) => ({ + getStatuses: builder.query({ + query: () => '/statuses/', + providesTags: ['Statuses'], + }), + }), +}); + +export const { + useGetStatusesQuery, +} = statusesApi; \ No newline at end of file diff --git a/web/src/Api/usersApi.js b/web/src/Api/usersApi.js index 4328428..280b620 100644 --- a/web/src/Api/usersApi.js +++ b/web/src/Api/usersApi.js @@ -13,6 +13,7 @@ export const usersApi = createApi({ }), getAuthenticatedUserData: builder.query({ query: () => "/users/me/", + providesTags: ["user"], }), updateUser: builder.mutation({ query: ({userId, ...data}) => ({ diff --git a/web/src/Components/Pages/AdminPage/AdminPage.jsx b/web/src/Components/Pages/AdminPage/AdminPage.jsx index 05f117d..33d3290 100644 --- a/web/src/Components/Pages/AdminPage/AdminPage.jsx +++ b/web/src/Components/Pages/AdminPage/AdminPage.jsx @@ -3,6 +3,7 @@ import {ControlOutlined, PlusOutlined} from "@ant-design/icons"; import useAdminPage from "./useAdminPage.js"; import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx"; import CreateUserModalForm from "./CreateUserModalForm/CreateUserModalForm.jsx"; +import UpdateUserModalForm from "./UpdateUserModalForm/UpdateUserModalForm.jsx"; const {Title} = Typography; @@ -15,6 +16,7 @@ const AdminPage = () => { isLoading, isError, openCreateModal, + currentUser, } = useAdminPage(); const columns = [ @@ -47,6 +49,13 @@ const AdminPage = () => { filters: rolesData.map(role => ({text: role.title, value: role.title})), onFilter: (value, record) => record.role.title === value, }, + { + title: "Статус", + dataIndex: ["status", "title"], + key: "status", + filters: rolesData.map(status => ({text: status.title, value: status.title})), + onFilter: (value, record) => record.status.title === value, + }, { title: "Статус", dataIndex: "status", @@ -56,9 +65,9 @@ const AdminPage = () => { title: "Действия", key: "actions", render: (_, record) => ( - + ) : null) ), }, ]; @@ -97,6 +106,7 @@ const AdminPage = () => { + ) }; diff --git a/web/src/Components/Pages/AdminPage/CreateUserModalForm/useCreateUserModalForm.js b/web/src/Components/Pages/AdminPage/CreateUserModalForm/useCreateUserModalForm.js index bd5a68f..559bfdd 100644 --- a/web/src/Components/Pages/AdminPage/CreateUserModalForm/useCreateUserModalForm.js +++ b/web/src/Components/Pages/AdminPage/CreateUserModalForm/useCreateUserModalForm.js @@ -36,8 +36,6 @@ const useCreateUserModalForm = () => { : null, }; - console.log(payload); - try { await registerUser(payload).unwrap(); notification.success({ diff --git a/web/src/Components/Pages/AdminPage/UpdateUserModalForm/UpdateUserModalForm.jsx b/web/src/Components/Pages/AdminPage/UpdateUserModalForm/UpdateUserModalForm.jsx new file mode 100644 index 0000000..21966db --- /dev/null +++ b/web/src/Components/Pages/AdminPage/UpdateUserModalForm/UpdateUserModalForm.jsx @@ -0,0 +1,147 @@ +import {Button, DatePicker, Form, Input, Modal, Result, Select, Tooltip, Typography} from "antd"; +import useUpdateUserModalForm from "./useUpdateUserModalForm.js"; +import {CalendarOutlined, InfoCircleOutlined} from "@ant-design/icons"; +import dayjs from "dayjs"; +import LoadingIndicator from "../../../Widgets/LoadingIndicator/LoadingIndicator.jsx"; + + +const UpdateUserModalForm = () => { + const { + modalVisible, + handleCancel, + handleFinish, + userForm, + passwordForm, + roles, + isLoading, + isError, + isLoadingUpdate, + isErrorUpdate, + handlePasswordFinish, + statusesData, + } = useUpdateUserModalForm(); + + if (isLoading) { + return + } + + if (isError) { + return + } + + return ( + +
+ + + + + + + + + + + + + + + + + } + format="DD.MM.YYYY" + style={{width: "100%"}} + size="large" + maxDate={dayjs()} + /> + + + + + + + + + + +
+
+ Изменение пароля + + + + ({ + validator(_, value) { + if (!value || getFieldValue("password") === value) { + return Promise.resolve(); + } + return Promise.reject(new Error("Пароли не совпадают")); + }, + }), + ]} + > + + + + + + +
+
+ ) +}; + +export default UpdateUserModalForm; \ No newline at end of file diff --git a/web/src/Components/Pages/AdminPage/UpdateUserModalForm/useUpdateUserModalForm.js b/web/src/Components/Pages/AdminPage/UpdateUserModalForm/useUpdateUserModalForm.js new file mode 100644 index 0000000..d749512 --- /dev/null +++ b/web/src/Components/Pages/AdminPage/UpdateUserModalForm/useUpdateUserModalForm.js @@ -0,0 +1,137 @@ +import {useDispatch, useSelector} from "react-redux"; +import {Form, notification} from "antd"; +import {setSelectedUserToUpdate} from "../../../../Redux/Slices/usersSlice.js"; +import {useGetAllRolesQuery} from "../../../../Api/rolesApi.js"; +import {useEffect} from "react"; +import dayjs from "dayjs"; +import {useUpdateUserMutation, useUpdateUserPasswordMutation} from "../../../../Api/usersApi.js"; +import {useGetStatusesQuery} from "../../../../Api/statusesApi.js"; + + +const useUpdateUserModalForm = () => { + const dispatch = useDispatch(); + const [userForm] = Form.useForm(); + const [passwordForm] = Form.useForm(); + + const { + selectedUserToUpdate + } = useSelector(state => state.users); + + const modalVisible = selectedUserToUpdate !== null; + + const handleCancel = () => { + dispatch(setSelectedUserToUpdate(null)); + }; + + const [ + updateUser, + { + isLoading: isLoadingUpdate, + isError: isErrorUpdate, + } + ] = useUpdateUserMutation(); + + const [ + changeUserPassword, + { + isLoading: isLoadingChangePassword, + isError: isErrorChangePassword, + } + ] = useUpdateUserPasswordMutation(); + + const { + data: statusesData = [], + isLoading: statusesIsLoading, + isError: statusesIsError, + } = useGetStatusesQuery(undefined); + + useEffect(() => { + if (selectedUserToUpdate) { + const { role, ...userWithoutRole } = selectedUserToUpdate; + + userForm.setFieldsValue({ + ...userWithoutRole, + role_id: role?.id, + birthdate: selectedUserToUpdate.birthdate + ? dayjs(selectedUserToUpdate.birthdate) + : null, + }); + } + }, [selectedUserToUpdate, userForm]); + + const handlePasswordFinish = async () => { + const values = passwordForm.getFieldsValue(); + const payload = { + ...values, + }; + + try { + await changeUserPassword({userId: selectedUserToUpdate.id, ...payload}).unwrap(); + notification.success({ + title: "Пароль изменен", + description: "Пароль успешно изменен", + placement: "topRight", + }); + passwordForm.resetFields(); + handleCancel(); + } catch (error) { + notification.error({ + title: "Ошибка изменения пароля", + description: error?.data?.detail || "Не удалось изменить пароль", + placement: "topRight", + }); + } + }; + + const handleFinish = async () => { + const values = userForm.getFieldsValue(); + const payload = { + ...values, + birthdate: values.birthdate + ? values.birthdate.format("YYYY-MM-DD") + : null, + }; + + try { + await updateUser({userId: selectedUserToUpdate.id, ...payload}).unwrap(); + notification.success({ + title: "Пользователь изменен", + description: "Пользователь успешно изменен", + placement: "topRight", + }); + userForm.resetFields(); + handleCancel(); + } catch (error) { + notification.error({ + title: "Ошибка изменения пользователя", + description: error?.data?.detail || "Не удалось изменить пользователя", + placement: "topRight", + }); + } + }; + + const { + data: roles = [], + isLoading: isLoadingRoles, + isError: isErrorRoles, + } = useGetAllRolesQuery(undefined, { + pollingInterval: 60000, + }); + + return { + modalVisible, + handleCancel, + handleFinish, + userForm, + passwordForm, + roles, + isLoading: isLoadingRoles | statusesIsLoading, + isError: isErrorRoles | statusesIsError, + isLoadingUpdate, + isErrorUpdate, + handlePasswordFinish, + statusesData, + }; +}; + +export default useUpdateUserModalForm; \ No newline at end of file diff --git a/web/src/Components/Pages/AdminPage/useAdminPage.js b/web/src/Components/Pages/AdminPage/useAdminPage.js index e9ae146..c84335c 100644 --- a/web/src/Components/Pages/AdminPage/useAdminPage.js +++ b/web/src/Components/Pages/AdminPage/useAdminPage.js @@ -1,4 +1,4 @@ -import {useGetAllUsersQuery} from "../../../Api/usersApi.js"; +import {useGetAllUsersQuery, useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js"; import {useGetAllRolesQuery} from "../../../Api/rolesApi.js"; import {useMemo, useState} from "react"; import {useDispatch} from "react-redux"; @@ -7,6 +7,11 @@ import {setOpenModalCreateUser, setSelectedUserToUpdate} from "../../../Redux/Sl const useAdminPage = () => { const dispatch = useDispatch(); + const { + data: currentUser = {}, + isLoading: currentUserIsLoading, + isError: currentUserIsError, + } = useGetAuthenticatedUserDataQuery(undefined); const [ searchString, @@ -56,9 +61,10 @@ const useAdminPage = () => { rolesData, filteredUsers, handleSearch, - isLoading: usersIsLoading | rolesIsLoading, - isError: usersIsError | rolesIsError, + isLoading: usersIsLoading | rolesIsLoading | currentUserIsLoading, + isError: usersIsError | rolesIsError | currentUserIsError, openCreateModal, + currentUser, }; }; diff --git a/web/src/Components/Pages/ProfilePage/ProfilePage.jsx b/web/src/Components/Pages/ProfilePage/ProfilePage.jsx index e7bbc50..dfed89c 100644 --- a/web/src/Components/Pages/ProfilePage/ProfilePage.jsx +++ b/web/src/Components/Pages/ProfilePage/ProfilePage.jsx @@ -148,10 +148,8 @@ const ProfilePage = () => { - {/* Карточка смены пароля */} Смена пароля} - bordered={false} style={{borderRadius: 12, boxShadow: "0 4px 12px rgba(0,0,0,0.05)"}} >
diff --git a/web/src/Redux/store.js b/web/src/Redux/store.js index 361d648..09d276a 100644 --- a/web/src/Redux/store.js +++ b/web/src/Redux/store.js @@ -4,6 +4,7 @@ import usersReducer from "./Slices/usersSlice.js"; import {authApi} from "../Api/authApi.js"; import {usersApi} from "../Api/usersApi.js"; import {rolesApi} from "../Api/rolesApi.js"; +import {statusesApi} from "../Api/statusesApi.js"; export const store = configureStore({ reducer: { @@ -14,12 +15,15 @@ export const store = configureStore({ [usersApi.reducerPath]: usersApi.reducer, [rolesApi.reducerPath]: rolesApi.reducer, + + [statusesApi.reducerPath]: statusesApi.reducer }, middleware: (getDefaultMiddleware) => ( getDefaultMiddleware().concat( authApi.middleware, usersApi.middleware, rolesApi.middleware, + statusesApi.middleware, ) ), });