Улучшение админки

This commit is contained in:
2026-01-20 01:01:42 +03:00
parent e507896b7a
commit 1e0c3478a1
15 changed files with 564 additions and 346 deletions
+74 -55
View File
@@ -68,88 +68,107 @@
#### **Аутентификация** (`/api/auth`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|-----------------------------------------------|----------------|------------------------------------------|
| POST | `/api/auth/register` | Публичный | Регистрация нового пользователя |
| POST | `/api/auth/token` | Публичный | Получение JWT токенов (access + refresh) |
| POST | `/api/auth/refresh` | Публичный | Обновление пары токенов |
| GET | `/api/auth/me` | Авторизованный | Информация о текущем пользователе |
| PUT | `/api/auth/me` | Авторизованный | Обновление профиля текущего пользователя |
| GET | `/api/auth/users` | Сотрудник | Список всех пользователей |
| POST | `/api/auth/users/{user_id}/roles/{role_name}` | Админ | Назначение роли пользователю |
| DELETE | `/api/auth/users/{user_id}/roles/{role_name}` | Админ | Удаление роли у пользователя |
| GET | `/api/auth/roles` | Авторизованный | Список ролей в системе |
|--------|------------------------------|----------------|------------------------------------------|
| POST | `/register` | Публичный | Регистрация нового пользователя |
| POST | `/token` | Публичный | Получение JWT токенов (access + refresh) |
| POST | `/refresh` | Публичный | Обновление пары токенов |
| GET | `/me` | Авторизованный | Информация о текущем пользователе |
| PUT | `/me` | Авторизованный | Обновление профиля текущего пользователя |
| GET | `/2fa` | Авторизованный | Создаёт QR-код для включения 2FA |
| POST | `/2fa/verify` | Неполный вход | Завершает вход при включеной 2FA |
| POST | `/2fa/enable` | Авторизованный | Включает двухваткорную аутентификацию |
| POST | `/2fa/disable` | Авторизованный | Выключает двухваткорную аутентификацию |
| GET | `/recovery-codes/status` | Авторизованный | Проверяет состояние кодов восстановления |
| POST | `/recovery-codes/regenerate` | Авторизованный | Пересоздает коды восстановления пароля |
| POST | `/password/reset` | Публичный | Сброс пароля с помощью одноразового кода |
#### **Авторы** (`/api/authors`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|---------------------|-----------|---------------------------------|
| POST | `/api/authors` | Сотрудник | Создать нового автора |
| GET | `/api/authors` | Публичный | Получить список всех авторов |
| GET | `/api/authors/{id}` | Публичный | Получить автора по ID с книгами |
| PUT | `/api/authors/{id}` | Сотрудник | Обновить автора по ID |
| DELETE | `/api/authors/{id}` | Сотрудник | Удалить автора по ID |
|--------|----------|-----------|---------------------------------|
| POST | `/` | Сотрудник | Создать нового автора |
| GET | `/` | Публичный | Получить список всех авторов |
| GET | `/{id}` | Публичный | Получить автора по ID с книгами |
| PUT | `/{id}` | Сотрудник | Обновить автора по ID |
| DELETE | `/{id}` | Сотрудник | Удалить автора по ID |
#### **Книги** (`/api/books`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|---------------------|-----------|-----------------------------------------------------------|
| GET | `/api/books/filter` | Публичный | Фильтрация книг по названию, авторам, жанрам с пагинацией |
| POST | `/api/books` | Сотрудник | Создать новую книгу |
| GET | `/api/books` | Публичный | Получить список всех книг |
| GET | `/api/books/{id}` | Публичный | Получить книгу по ID с авторами и жанрами |
| PUT | `/api/books/{id}` | Сотрудник | Обновить книгу по ID |
| DELETE | `/api/books/{id}` | Сотрудник | Удалить книгу по ID |
|--------|-----------|-----------|----------------------------------------------|
| POST | `/` | Сотрудник | Создать новую книгу |
| GET | `/` | Публичный | Получить список всех книг |
| GET | `/{id}` | Публичный | Получить книгу по ID с авторами и жанрами |
| PUT | `/{id}` | Сотрудник | Обновить книгу по ID |
| DELETE | `/{id}` | Сотрудник | Удалить книгу по ID |
| GET | `/filter` | Публичный | Фильтрация книг по названию, авторам, жанрам |
#### **Жанры** (`/api/genres`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|--------------------|-----------|-------------------------------|
| POST | `/api/genres` | Сотрудник | Создать новый жанр |
| GET | `/api/genres` | Публичный | Получить список всех жанров |
| GET | `/api/genres/{id}` | Публичный | Получить жанр по ID с книгами |
| PUT | `/api/genres/{id}` | Сотрудник | Обновить жанр по ID |
| DELETE | `/api/genres/{id}` | Сотрудник | Удалить жанр по ID |
|--------|----------|-----------|-------------------------------|
| POST | `/` | Сотрудник | Создать новый жанр |
| GET | `/` | Публичный | Получить список всех жанров |
| GET | `/{id}` | Публичный | Получить жанр по ID с книгами |
| PUT | `/{id}` | Сотрудник | Обновить жанр по ID |
| DELETE | `/{id}` | Сотрудник | Удалить жанр по ID |
#### **Выдачи** (`/api/loans`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|------------------------------------|----------------|--------------------------------------------------------------|
| POST | `/api/loans` | Авторизованный | Создать выдачу/бронь (читатели для себя, Сотрудник для всех) |
| GET | `/api/loans` | Авторизованный | Список выдач (читатели видят свои, Сотрудник видят все) |
| GET | `/api/loans/analytics` | Админ | Аналитика выдач и возвратов |
| GET | `/api/loans/{id}` | Авторизованный | Получить выдачу по ID (читатели только свои) |
| PUT | `/api/loans/{id}` | Авторизованный | Обновить выдачу (читатели только свои) |
| POST | `/api/loans/{id}/confirm` | Сотрудник | Подтвердить бронь (меняет статус на BORROWED) |
| POST | `/api/loans/{id}/return` | Сотрудник | Вернуть книгу и закрыть выдачу |
| DELETE | `/api/loans/{id}` | Авторизованный | Удалить выдачу/бронь (только для RESERVED статуса) |
| GET | `/api/loans/book/{book_id}/active` | Сотрудник | Получить активную выдачу книги |
| POST | `/api/loans/issue` | Админ | Выдать книгу напрямую без бронирования |
|--------|-------------------------|----------------|------------------------------------------------------------|
| POST | `/` | Авторизованный | Создать выдачу/бронь (читатели на себя, cотрудник на всех) |
| GET | `/` | Авторизованный | Список выдач (читатели видят свои, Сотрудник видят все) |
| GET | `{id}` | Авторизованный | Получить выдачу по ID (читатели только свои) |
| PUT | `{id}` | Авторизованный | Обновить выдачу (читатели только свои) |
| DELETE | `{id}` | Авторизованный | Удалить выдачу/бронь (только для RESERVED статуса) |
| POST | `{id}/confirm` | Сотрудник | Подтвердить бронь (меняет статус на BORROWED) |
| POST | `{id}/return` | Сотрудник | Вернуть книгу и закрыть выдачу |
| GET | `book/{book_id}/active` | Сотрудник | Получить активную выдачу книги |
| POST | `issue` | Админ | Выдать книгу напрямую без бронирования |
| GET | `analytics` | Админ | Аналитика выдач и возвратов |
#### **Связи** (`/api`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|----------------------------------|-----------|-------------------------------|
| POST | `/api/relationships/author-book` | Сотрудник | Связать автора и книгу |
| DELETE | `/api/relationships/author-book` | Сотрудник | Удалить связь автор-книга |
| GET | `/api/authors/{id}/books` | Публичный | Получить список книг автора |
| GET | `/api/books/{id}/authors` | Публичный | Получить список авторов книги |
| POST | `/api/relationships/genre-book` | Сотрудник | Связать жанр и книгу |
| DELETE | `/api/relationships/genre-book` | Сотрудник | Удалить связь жанр-книга |
| GET | `/api/genres/{id}/books` | Публичный | Получить список книг жанра |
| GET | `/api/books/{id}/genres` | Публичный | Получить список жанров книги |
|--------|------------------------------|-----------|-------------------------------|
| POST | `/relationships/author-book` | Сотрудник | Связать автора и книгу |
| DELETE | `/relationships/author-book` | Сотрудник | Удалить связь автор-книга |
| GET | `/authors/{id}/books` | Публичный | Получить список книг автора |
| GET | `/books/{id}/authors` | Публичный | Получить список авторов книги |
| POST | `/relationships/genre-book` | Сотрудник | Связать жанр и книгу |
| DELETE | `/relationships/genre-book` | Сотрудник | Удалить связь жанр-книга |
| GET | `/genres/{id}/books` | Публичный | Получить список книг жанра |
| GET | `/books/{id}/genres` | Публичный | Получить список жанров книги |
#### **Пользователи** (`/api/users`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|-------------------------------|----------------|------------------------------|
| POST | `/` | Админ | Создать нового пользователя |
| GET | `/` | Админ | Список всех пользователей |
| GET | `/{id}` | Админ | Получить пользователя по ID |
| PUT | `/{id}` | Админ | Обновить пользователя по ID |
| DELETE | `/{id}` | Админ | Удалить пользователя по ID |
| POST | `/{user_id}/roles/{role_name}` | Админ | Назначение роли пользователю |
| DELETE | `/{user_id}/roles/{role_name}` | Админ | Удаление роли у пользователя |
| GET | `/roles` | Авторизованный | Список ролей в системе |
#### **Прочее** (`/api`)
| Метод | Эндпоинт | Доступ | Описание |
|-------|--------------|-----------|----------------------|
| GET | `/api/info` | Публичный | Информация о сервисе |
| GET | `/api/stats` | Публичный | Статистика системы |
|-------|----------|-----------|----------------------|
| GET | `/info` | Публичный | Информация о сервисе |
| GET | `/stats` | Публичный | Статистика системы |
### **Веб-страницы**
| Путь | Доступ | Описание |
|---------------------|----------------|-----------------------------------------|
|---------------------|----------------|-----------------------------|
| `/` | Публичный | Главная страница |
| `/api` | Публичный | Ссылки на документацию |
| `/auth` | Публичный | Страница авторизации |
| `/profile` | Авторизованный | Профиль пользователя |
| `/books` | Публичный | Каталог книг с фильтрацией |
@@ -163,9 +182,9 @@
| `/genre/create` | Сотрудник | Создание жанра |
| `/genre/{id}/edit` | Сотрудник | Редактирование жанра |
| `/my-books` | Авторизованный | Мои выдачи |
| `/users` | Сотрудник | Управление пользователями |
| `/users` | Админ | Управление пользователями |
| `/analytics` | Админ | Аналитика выдач и возвратов |
| `/api` | Публичный | Страница с ссылками на документацию API |
### **Схема базы данных**
+1 -1
View File
@@ -11,7 +11,7 @@ from jose import jwt, JWTError, ExpiredSignatureError
from passlib.context import CryptContext
from sqlmodel import Session, select
from library_service.models.db import Role, User
from library_service.models.db import User
from library_service.models.dto import TokenData
from library_service.settings import get_session, get_logger
-1
View File
@@ -1,6 +1,5 @@
"""Модуль резервных кодов восстановления пароля"""
import os
import secrets
from datetime import datetime, timezone, timedelta
+1 -1
View File
@@ -6,7 +6,7 @@ from sqlmodel import Session, select
from library_service.models.db import Role, User
from .core import get_password_hash
from library_service.settings import get_session, get_logger
from library_service.settings import get_logger
# Получение логгера
logger = get_logger()
+5 -1
View File
@@ -8,7 +8,7 @@ from .user import UserBase, UserCreate, UserList, UserRead, UserUpdate, UserLogi
from .loan import LoanBase, LoanCreate, LoanList, LoanRead, LoanUpdate
from .recovery import RecoveryCodesResponse, RecoveryCodesStatus, RecoveryCodeUse
from .token import Token, TokenData, PartialToken
from .combined import (
from .misc import (
AuthorWithBooks,
GenreWithBooks,
BookWithAuthors,
@@ -19,6 +19,8 @@ from .combined import (
LoanWithBook,
LoginResponse,
RegisterResponse,
UserCreateByAdmin,
UserUpdateByAdmin,
TOTPSetupResponse,
TOTPVerifyRequest,
TOTPDisableRequest,
@@ -67,6 +69,8 @@ __all__ = [
"TOTPVerifyRequest",
"TOTPDisableRequest",
"RecoveryCodeUse",
"UserCreateByAdmin",
"UserUpdateByAdmin",
"LoginResponse",
"RegisterResponse",
"RecoveryCodesStatus",
@@ -1,4 +1,4 @@
"""Модуль объединёных объектов"""
"""Модуль разных моделей"""
from datetime import datetime
from typing import List
@@ -11,8 +11,8 @@ from .book import BookRead
from .loan import LoanRead
from ..enums import BookStatus
from .user import UserRead
from .recovery import RecoveryCodesResponse, RecoveryCodesStatus
from .user import UserCreate, UserRead, UserUpdate
from .recovery import RecoveryCodesResponse
class AuthorWithBooks(SQLModel):
@@ -80,6 +80,20 @@ class BookStatusUpdate(SQLModel):
status: str
class UserCreateByAdmin(UserCreate):
"""Создание пользователя администратором"""
is_active: bool = True
roles: list[str] | None = None
class UserUpdateByAdmin(UserUpdate):
"""Обновление пользователя администратором"""
is_active: bool | None = None
roles: list[str] | None = None
class LoginResponse(SQLModel):
"""Модель для авторизации пользователя"""
+3
View File
@@ -1,4 +1,5 @@
"""Модуль объединения роутеров"""
from fastapi import APIRouter
from .auth import router as auth_router
@@ -7,6 +8,7 @@ from .books import router as books_router
from .genres import router as genres_router
from .loans import router as loans_router
from .relationships import router as relationships_router
from .users import router as users_router
from .misc import router as misc_router
@@ -20,4 +22,5 @@ api_router.include_router(authors_router, prefix="/api")
api_router.include_router(books_router, prefix="/api")
api_router.include_router(genres_router, prefix="/api")
api_router.include_router(loans_router, prefix="/api")
api_router.include_router(users_router, prefix="/api")
api_router.include_router(relationships_router, prefix="/api")
+2 -131
View File
@@ -2,13 +2,10 @@
from datetime import timedelta
from typing import Annotated
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException, status, Request
from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.templating import Jinja2Templates
from sqlmodel import Session, select
import pyotp
from library_service.models.db import Role, User
from library_service.models.dto import (
@@ -56,7 +53,6 @@ from library_service.auth import (
)
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
router = APIRouter(prefix="/auth", tags=["authentication"])
@@ -157,7 +153,7 @@ def login(
"/refresh",
response_model=Token,
summary="Обновление токена",
description="Получение новой пары токенов (Access + Refresh) используя действующий Refresh токен",
description="Получение новой пары токенов, используя действующий Refresh токен",
)
def refresh_token(
refresh_token: str = Body(..., embed=True),
@@ -243,131 +239,6 @@ def update_user_me(
)
@router.get(
"/users",
response_model=UserList,
summary="Список пользователей",
description="Получить список всех пользователей (только для админов)",
)
def read_users(
current_user: RequireStaff,
skip: int = 0,
limit: int = 100,
session: Session = Depends(get_session),
):
"""Возвращает список всех пользователей"""
users = session.exec(select(User).offset(skip).limit(limit)).all()
return UserList(
users=[
UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
for user in users
],
total=len(users),
)
@router.post(
"/users/{user_id}/roles/{role_name}",
response_model=UserRead,
summary="Назначить роль пользователю",
description="Добавить указанную роль пользователю",
)
def add_role_to_user(
user_id: int,
role_name: str,
admin: RequireAdmin,
session: Session = Depends(get_session),
):
"""Добавляет роль пользователю"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
role = session.exec(select(Role).where(Role.name == role_name)).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role '{role_name}' not found",
)
if role in user.roles:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User already has this role",
)
user.roles.append(role)
session.add(user)
session.commit()
session.refresh(user)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
@router.delete(
"/users/{user_id}/roles/{role_name}",
response_model=UserRead,
summary="Удалить роль у пользователя",
description="Убрать указанную роль у пользователя",
)
def remove_role_from_user(
user_id: int,
role_name: str,
admin: RequireAdmin,
session: Session = Depends(get_session),
):
"""Удаляет роль у пользователя"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
role = session.exec(select(Role).where(Role.name == role_name)).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role '{role_name}' not found",
)
if role not in user.roles:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User does not have this role",
)
user.roles.remove(role)
session.add(user)
session.commit()
session.refresh(user)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
@router.get(
"/roles",
response_model=RoleList,
summary="Получить список ролей",
description="Возвращает список ролей",
)
def get_roles(
auth: RequireAuth,
session: Session = Depends(get_session),
):
"""Возвращает список ролей в системе"""
user_roles = [role.name for role in auth.roles]
exclude = {"payroll"} if "admin" in user_roles else set()
roles = session.exec(select(Role)).all()
return RoleList(
roles=[RoleRead(**role.model_dump(exclude=exclude)) for role in roles],
total=len(roles),
)
@router.get(
"/2fa",
response_model=TOTPSetupResponse,
+9 -5
View File
@@ -25,7 +25,7 @@ from library_service.models.dto import (
BookUpdate,
GenreRead,
)
from library_service.models.dto.combined import (
from library_service.models.dto.misc import (
BookWithAuthorsAndGenres,
BookFilteredList,
)
@@ -71,13 +71,17 @@ def filter_books(
if author_ids:
statement = statement.join(AuthorBookLink).where(
AuthorBookLink.author_id.in_(author_ids)
) # ty: ignore[unresolved-attribute, unresolved-reference]
AuthorBookLink.author_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference]
author_ids
)
)
if genre_ids:
statement = statement.join(GenreBookLink).where(
GenreBookLink.genre_id.in_(genre_ids)
) # ty: ignore[unresolved-attribute, unresolved-reference]
GenreBookLink.genre_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference]
genre_ids
)
)
total_statement = select(func.count()).select_from(statement.subquery())
total = session.exec(total_statement).one()
+302
View File
@@ -0,0 +1,302 @@
"""Модуль управления пользователями (для администраторов)"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from library_service.models.db import Role, User
from library_service.models.dto import (
RoleRead,
RoleList,
UserRead,
UserList,
UserCreateByAdmin,
UserUpdateByAdmin,
)
from library_service.settings import get_session
from library_service.auth import (
RequireAuth,
RequireAdmin,
RequireStaff,
get_password_hash,
)
router = APIRouter(prefix="/users", tags=["users"])
@router.get(
"/roles",
response_model=RoleList,
summary="Список ролей",
)
def get_roles(
auth: RequireAuth,
session: Session = Depends(get_session),
):
"""Возвращает список ролей в системе"""
user_roles = [role.name for role in auth.roles]
exclude = {"payroll"} if "admin" not in user_roles else set()
roles = session.exec(select(Role)).all()
return RoleList(
roles=[RoleRead(**role.model_dump(exclude=exclude)) for role in roles],
total=len(roles),
)
@router.get(
"/",
response_model=UserList,
summary="Список пользователей",
)
def list_users(
current_user: RequireStaff,
skip: int = 0,
limit: int = 100,
session: Session = Depends(get_session),
):
"""Возвращает список всех пользователей"""
users = session.exec(select(User).offset(skip).limit(limit)).all()
total = session.exec(select(User)).all()
return UserList(
users=[
UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
for user in users
],
total=len(total),
)
@router.post(
"/",
response_model=UserRead,
status_code=status.HTTP_201_CREATED,
summary="Создать пользователя",
)
def create_user(
user_data: UserCreateByAdmin,
current_user: RequireAdmin,
session: Session = Depends(get_session),
):
"""Создает пользователя (без резервных кодов)"""
if session.exec(select(User).where(User.username == user_data.username)).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered",
)
if session.exec(select(User).where(User.email == user_data.email)).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
db_user = User(
username=user_data.username,
email=user_data.email,
full_name=user_data.full_name,
hashed_password=get_password_hash(user_data.password),
is_active=user_data.is_active,
)
if user_data.roles:
for role_name in user_data.roles:
role = session.exec(select(Role).where(Role.name == role_name)).first()
if role:
db_user.roles.append(role)
else:
default_role = session.exec(select(Role).where(Role.name == "member")).first()
if default_role:
db_user.roles.append(default_role)
session.add(db_user)
session.commit()
session.refresh(db_user)
return UserRead(**db_user.model_dump(), roles=[r.name for r in db_user.roles])
@router.get(
"/{user_id}",
response_model=UserRead,
summary="Получить пользователя",
)
def get_user(
user_id: int,
current_user: RequireStaff,
session: Session = Depends(get_session),
):
"""Возвращает пользователя по ID"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
@router.put(
"/{user_id}",
response_model=UserRead,
summary="Обновить пользователя",
)
def update_user(
user_id: int,
user_data: UserUpdateByAdmin,
current_user: RequireAdmin,
session: Session = Depends(get_session),
):
"""Обновляет данные пользователя"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if user_data.email and user_data.email != user.email:
existing = session.exec(
select(User).where(User.email == user_data.email)
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
user.email = user_data.email
if user_data.full_name is not None:
user.full_name = user_data.full_name
if user_data.password:
user.hashed_password = get_password_hash(user_data.password)
if user_data.is_active is not None:
user.is_active = user_data.is_active
if user_data.roles is not None:
user.roles.clear()
for role_name in user_data.roles:
role = session.exec(select(Role).where(Role.name == role_name)).first()
if role:
user.roles.append(role)
session.add(user)
session.commit()
session.refresh(user)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
@router.delete(
"/{user_id}",
response_model=UserRead,
summary="Удалить пользователя",
)
def delete_user(
user_id: int,
current_user: RequireAdmin,
session: Session = Depends(get_session),
):
"""Деактивирует пользователя, при повторном вызове — удаляет физически"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if user.is_active:
user.is_active = False
session.add(user)
session.commit()
session.refresh(user)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
else:
user_read = UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
session.delete(user)
session.commit()
return user_read
@router.post(
"/{user_id}/roles/{role_name}",
response_model=UserRead,
summary="Назначить роль пользователю",
)
def add_role_to_user(
user_id: int,
role_name: str,
current_user: RequireAdmin,
session: Session = Depends(get_session),
):
"""Добавляет роль пользователю"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
role = session.exec(select(Role).where(Role.name == role_name)).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role '{role_name}' not found",
)
if role in user.roles:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User already has this role",
)
user.roles.append(role)
session.add(user)
session.commit()
session.refresh(user)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
@router.delete(
"/{user_id}/roles/{role_name}",
response_model=UserRead,
summary="Удалить роль у пользователя",
)
def remove_role_from_user(
user_id: int,
role_name: str,
current_user: RequireAdmin,
session: Session = Depends(get_session),
):
"""Удаляет роль у пользователя"""
user = session.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
role = session.exec(select(Role).where(Role.name == role_name)).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role '{role_name}' not found",
)
if role not in user.roles:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User does not have this role",
)
user.roles.remove(role)
session.add(user)
session.commit()
session.refresh(user)
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
+1
View File
@@ -60,6 +60,7 @@ OPENAPI_TAGS = [
{"name": "genres", "description": "Действия с жанрами."},
{"name": "loans", "description": "Действия с выдачами."},
{"name": "relations", "description": "Действия со связями."},
{"name": "users", "description": "Действия с пользователями."},
{"name": "misc", "description": "Прочие."},
]
+1 -1
View File
@@ -13,7 +13,7 @@ $(document).ready(() => {
function loadProfile() {
Promise.all([
Api.get("/api/auth/me"),
Api.get("/api/auth/roles").catch(() => ({ roles: [] })),
Api.get("/api/users/roles").catch(() => ({ roles: [] })),
Api.get("/api/auth/recovery-codes/status").catch(() => null),
])
.then(async ([user, rolesData, recoveryStatus]) => {
+59 -55
View File
@@ -5,18 +5,11 @@ $(document).ready(() => {
);
return;
}
setTimeout(() => {
if (!window.isAdmin()) {
$("#users-container").html(
document.getElementById("access-denied-template").innerHTML,
);
}
}, 100);
let allRoles = [];
let users = [];
let currentPage = 1;
let pageSize = 20;
const pageSize = 20;
let totalUsers = 0;
let searchQuery = "";
let selectedFilterRoles = new Set();
@@ -28,8 +21,8 @@ $(document).ready(() => {
showLoadingState();
Promise.all([
Api.get("/api/auth/users?skip=0&limit=100"),
Api.get("/api/auth/roles"),
Api.get("/api/users?skip=0&limit=100"),
Api.get("/api/users/roles"),
])
.then(([usersData, rolesData]) => {
users = usersData.users;
@@ -139,13 +132,11 @@ $(document).ready(() => {
}
function loadUsers() {
const params = new URLSearchParams();
params.append("skip", (currentPage - 1) * pageSize);
params.append("limit", pageSize);
const skip = (currentPage - 1) * pageSize;
showLoadingState();
Api.get(`/api/auth/users?${params.toString()}`)
Api.get(`/api/users?skip=${skip}&limit=${pageSize}`)
.then((data) => {
users = data.users;
totalUsers = data.total;
@@ -220,6 +211,8 @@ $(document).ready(() => {
}
const rolesContainer = clone.querySelector(".user-roles");
let totalPayroll = 0;
if (user.roles && user.roles.length > 0) {
user.roles.forEach((roleName) => {
const badge = roleBadgeTpl.content.cloneNode(true);
@@ -238,12 +231,25 @@ $(document).ready(() => {
removeBtn.dataset.userId = user.id;
removeBtn.dataset.roleName = roleName;
rolesContainer.appendChild(badge);
const fullRole = allRoles.find((r) => r.name === roleName);
if (fullRole && fullRole.payroll) {
totalPayroll += fullRole.payroll;
}
});
} else {
rolesContainer.innerHTML =
'<span class="text-gray-400 text-sm italic">Нет ролей</span>';
}
if (totalPayroll > 0) {
const payrollBadge = clone.querySelector(".user-payroll");
const payrollAmount = clone.querySelector(".user-payroll-amount");
payrollBadge.classList.remove("hidden");
payrollAmount.textContent = totalPayroll.toLocaleString("ru-RU");
}
const addRoleBtn = clone.querySelector(".add-role-btn");
addRoleBtn.dataset.userId = user.id;
@@ -457,12 +463,9 @@ $(document).ready(() => {
}
function addRoleToUser(userId, roleName) {
Api.request(
`/api/auth/users/${userId}/roles/${encodeURIComponent(roleName)}`,
{
Api.request(`/api/users/${userId}/roles/${encodeURIComponent(roleName)}`, {
method: "POST",
},
)
})
.then((updatedUser) => {
const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex !== -1) {
@@ -485,12 +488,9 @@ $(document).ready(() => {
return;
}
Api.request(
`/api/auth/users/${userId}/roles/${encodeURIComponent(roleName)}`,
{
Api.request(`/api/users/${userId}/roles/${encodeURIComponent(roleName)}`, {
method: "DELETE",
},
)
})
.then((updatedUser) => {
const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex !== -1) {
@@ -514,7 +514,6 @@ $(document).ready(() => {
$("#edit-user-fullname").val(user.full_name || "");
$("#edit-user-password").val("");
$("#edit-user-active").prop("checked", user.is_active);
$("#edit-user-verified").prop("checked", user.is_verified);
$("#edit-user-modal").removeClass("hidden");
}
@@ -529,6 +528,7 @@ $(document).ready(() => {
const email = $("#edit-user-email").val().trim();
const fullName = $("#edit-user-fullname").val().trim();
const password = $("#edit-user-password").val();
const isActive = $("#edit-user-active").prop("checked");
if (!email) {
Utils.showToast("Email обязателен", "error");
@@ -538,36 +538,26 @@ $(document).ready(() => {
const updateData = {
email: email,
full_name: fullName || null,
is_active: isActive,
};
if (password) {
updateData.password = password;
}
Api.put(`/api/auth/me`, updateData)
Api.put(`/api/users/${userId}`, updateData)
.then((updatedUser) => {
const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex !== -1) {
users[userIndex] = { ...users[userIndex], ...updatedUser };
users[userIndex] = updatedUser;
}
renderUsers();
closeEditModal();
Utils.showToast("Пользователь обновлён", "success");
})
.catch((error) => {
console.warn("API update failed, updating locally:", error);
const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex !== -1) {
users[userIndex].email = email;
users[userIndex].full_name = fullName || null;
users[userIndex].is_active = $("#edit-user-active").prop("checked");
users[userIndex].is_verified = $("#edit-user-verified").prop(
"checked",
);
}
renderUsers();
closeEditModal();
Utils.showToast("Изменения сохранены локально", "info");
console.error(error);
Utils.showToast(error.message || "Ошибка обновления", "error");
});
}
@@ -582,7 +572,16 @@ $(document).ready(() => {
}
userToDelete = user;
const actionText = user.is_active ? "деактивировать" : "удалить навсегда";
$("#delete-user-name").text(user.full_name || user.username);
$("#delete-user-modal .text-sm.text-gray-500").html(
`Вы уверены, что хотите <strong>${actionText}</strong> пользователя <strong>${Utils.escapeHtml(user.full_name || user.username)}</strong>?` +
(user.is_active
? ""
: " <span class='text-red-600'>Это действие необратимо!</span>"),
);
$("#delete-user-modal").removeClass("hidden");
}
@@ -594,22 +593,27 @@ $(document).ready(() => {
function confirmDeleteUser() {
if (!userToDelete) return;
Utils.showToast("Удаление пользователей не поддерживается API", "error");
Api.delete(`/api/users/${userToDelete.id}`)
.then((deletedUser) => {
if (deletedUser.is_active === false) {
const userIndex = users.findIndex((u) => u.id === userToDelete.id);
if (userIndex !== -1) {
users[userIndex] = deletedUser;
}
Utils.showToast("Пользователь деактивирован", "success");
} else {
users = users.filter((u) => u.id !== userToDelete.id);
totalUsers--;
$("#total-users-count").text(totalUsers);
Utils.showToast("Пользователь удалён", "success");
}
renderUsers();
closeDeleteModal();
// Api.delete(`/api/auth/users/${userToDelete.id}`)
// .then(() => {
// users = users.filter(u => u.id !== userToDelete.id);
// totalUsers--;
// $("#total-users-count").text(totalUsers);
// renderUsers();
// closeDeleteModal();
// Utils.showToast("Пользователь удалён", "success");
// })
// .catch((error) => {
// console.error(error);
// Utils.showToast(error.message || "Ошибка удаления", "error");
// });
})
.catch((error) => {
console.error(error);
Utils.showToast(error.message || "Ошибка удаления", "error");
});
}
$("#users-container").on("click", ".add-role-btn", function (e) {
+12 -15
View File
@@ -38,7 +38,7 @@
type="text"
id="role-filter-input"
placeholder="Фильтр по роли..."
class="w-full md:w-56 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
class="w-full md:w-56 border rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
autocomplete="off"
/>
<div
@@ -150,7 +150,7 @@
</button>
</div>
</div>
<div class="mt-3">
<div class="mt-3 flex flex-col sm:flex-row sm:items-end justify-between gap-3">
<div class="flex items-center flex-wrap gap-2">
<span class="text-sm font-medium text-gray-700"
>Роли:</span
@@ -177,6 +177,15 @@
</button>
</div>
</div>
<div class="user-payroll hidden shrink-0 flex items-center gap-1.5 bg-emerald-50 text-emerald-700 px-3 py-1 rounded-lg border border-emerald-100 shadow-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-xs font-semibold uppercase tracking-wider text-emerald-600">Оклад:</div>
<div class="font-bold font-mono text-lg leading-none user-payroll-amount"></div>
<div class="text-xs font-medium"></div>
</div>
</div>
</div>
</div>
</div>
@@ -312,7 +321,7 @@
placeholder="••••••••"
/>
</div>
<div class="flex items-center gap-4">
<div>
<label
class="flex items-center gap-2 cursor-pointer"
>
@@ -325,18 +334,6 @@
>Активен</span
>
</label>
<label
class="flex items-center gap-2 cursor-pointer"
>
<input
type="checkbox"
id="edit-user-verified"
class="w-4 h-4 text-green-600 rounded focus:ring-green-500"
/>
<span class="text-sm text-gray-700"
>Подтверждён</span
>
</label>
</div>
</div>
</div>
Generated
+1 -1
View File
@@ -619,7 +619,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/e8/ef/324f4a28ed0152a32
[[package]]
name = "libraryapi"
version = "0.4.0"
version = "0.5.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },