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

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
+97 -78
View File
@@ -67,105 +67,124 @@
#### **Аутентификация** (`/api/auth`) #### **Аутентификация** (`/api/auth`)
| Метод | Эндпоинт | Доступ | Описание | | Метод | Эндпоинт | Доступ | Описание |
|--------|-----------------------------------------------|----------------|------------------------------------------| |--------|------------------------------|----------------|------------------------------------------|
| POST | `/api/auth/register` | Публичный | Регистрация нового пользователя | | POST | `/register` | Публичный | Регистрация нового пользователя |
| POST | `/api/auth/token` | Публичный | Получение JWT токенов (access + refresh) | | POST | `/token` | Публичный | Получение JWT токенов (access + refresh) |
| POST | `/api/auth/refresh` | Публичный | Обновление пары токенов | | POST | `/refresh` | Публичный | Обновление пары токенов |
| GET | `/api/auth/me` | Авторизованный | Информация о текущем пользователе | | GET | `/me` | Авторизованный | Информация о текущем пользователе |
| PUT | `/api/auth/me` | Авторизованный | Обновление профиля текущего пользователя | | PUT | `/me` | Авторизованный | Обновление профиля текущего пользователя |
| GET | `/api/auth/users` | Сотрудник | Список всех пользователей | | GET | `/2fa` | Авторизованный | Создаёт QR-код для включения 2FA |
| POST | `/api/auth/users/{user_id}/roles/{role_name}` | Админ | Назначение роли пользователю | | POST | `/2fa/verify` | Неполный вход | Завершает вход при включеной 2FA |
| DELETE | `/api/auth/users/{user_id}/roles/{role_name}` | Админ | Удаление роли у пользователя | | POST | `/2fa/enable` | Авторизованный | Включает двухваткорную аутентификацию |
| GET | `/api/auth/roles` | Авторизованный | Список ролей в системе | | POST | `/2fa/disable` | Авторизованный | Выключает двухваткорную аутентификацию |
| GET | `/recovery-codes/status` | Авторизованный | Проверяет состояние кодов восстановления |
| POST | `/recovery-codes/regenerate` | Авторизованный | Пересоздает коды восстановления пароля |
| POST | `/password/reset` | Публичный | Сброс пароля с помощью одноразового кода |
#### **Авторы** (`/api/authors`) #### **Авторы** (`/api/authors`)
| Метод | Эндпоинт | Доступ | Описание | | Метод | Эндпоинт | Доступ | Описание |
|--------|---------------------|-----------|---------------------------------| |--------|----------|-----------|---------------------------------|
| POST | `/api/authors` | Сотрудник | Создать нового автора | | POST | `/` | Сотрудник | Создать нового автора |
| GET | `/api/authors` | Публичный | Получить список всех авторов | | GET | `/` | Публичный | Получить список всех авторов |
| GET | `/api/authors/{id}` | Публичный | Получить автора по ID с книгами | | GET | `/{id}` | Публичный | Получить автора по ID с книгами |
| PUT | `/api/authors/{id}` | Сотрудник | Обновить автора по ID | | PUT | `/{id}` | Сотрудник | Обновить автора по ID |
| DELETE | `/api/authors/{id}` | Сотрудник | Удалить автора по ID | | DELETE | `/{id}` | Сотрудник | Удалить автора по ID |
#### **Книги** (`/api/books`) #### **Книги** (`/api/books`)
| Метод | Эндпоинт | Доступ | Описание | | Метод | Эндпоинт | Доступ | Описание |
|--------|---------------------|-----------|-----------------------------------------------------------| |--------|-----------|-----------|----------------------------------------------|
| GET | `/api/books/filter` | Публичный | Фильтрация книг по названию, авторам, жанрам с пагинацией | | POST | `/` | Сотрудник | Создать новую книгу |
| POST | `/api/books` | Сотрудник | Создать новую книгу | | GET | `/` | Публичный | Получить список всех книг |
| GET | `/api/books` | Публичный | Получить список всех книг | | GET | `/{id}` | Публичный | Получить книгу по ID с авторами и жанрами |
| GET | `/api/books/{id}` | Публичный | Получить книгу по ID с авторами и жанрами | | PUT | `/{id}` | Сотрудник | Обновить книгу по ID |
| PUT | `/api/books/{id}` | Сотрудник | Обновить книгу по ID | | DELETE | `/{id}` | Сотрудник | Удалить книгу по ID |
| DELETE | `/api/books/{id}` | Сотрудник | Удалить книгу по ID | | GET | `/filter` | Публичный | Фильтрация книг по названию, авторам, жанрам |
#### **Жанры** (`/api/genres`) #### **Жанры** (`/api/genres`)
| Метод | Эндпоинт | Доступ | Описание | | Метод | Эндпоинт | Доступ | Описание |
|--------|--------------------|-----------|-------------------------------| |--------|----------|-----------|-------------------------------|
| POST | `/api/genres` | Сотрудник | Создать новый жанр | | POST | `/` | Сотрудник | Создать новый жанр |
| GET | `/api/genres` | Публичный | Получить список всех жанров | | GET | `/` | Публичный | Получить список всех жанров |
| GET | `/api/genres/{id}` | Публичный | Получить жанр по ID с книгами | | GET | `/{id}` | Публичный | Получить жанр по ID с книгами |
| PUT | `/api/genres/{id}` | Сотрудник | Обновить жанр по ID | | PUT | `/{id}` | Сотрудник | Обновить жанр по ID |
| DELETE | `/api/genres/{id}` | Сотрудник | Удалить жанр по ID | | DELETE | `/{id}` | Сотрудник | Удалить жанр по ID |
#### **Выдачи** (`/api/loans`) #### **Выдачи** (`/api/loans`)
| Метод | Эндпоинт | Доступ | Описание | | Метод | Эндпоинт | Доступ | Описание |
|--------|------------------------------------|----------------|--------------------------------------------------------------| |--------|-------------------------|----------------|------------------------------------------------------------|
| POST | `/api/loans` | Авторизованный | Создать выдачу/бронь (читатели для себя, Сотрудник для всех) | | POST | `/` | Авторизованный | Создать выдачу/бронь (читатели на себя, cотрудник на всех) |
| GET | `/api/loans` | Авторизованный | Список выдач (читатели видят свои, Сотрудник видят все) | | GET | `/` | Авторизованный | Список выдач (читатели видят свои, Сотрудник видят все) |
| GET | `/api/loans/analytics` | Админ | Аналитика выдач и возвратов | | GET | `{id}` | Авторизованный | Получить выдачу по ID (читатели только свои) |
| GET | `/api/loans/{id}` | Авторизованный | Получить выдачу по ID (читатели только свои) | | PUT | `{id}` | Авторизованный | Обновить выдачу (читатели только свои) |
| PUT | `/api/loans/{id}` | Авторизованный | Обновить выдачу (читатели только свои) | | DELETE | `{id}` | Авторизованный | Удалить выдачу/бронь (только для RESERVED статуса) |
| POST | `/api/loans/{id}/confirm` | Сотрудник | Подтвердить бронь (меняет статус на BORROWED) | | POST | `{id}/confirm` | Сотрудник | Подтвердить бронь (меняет статус на BORROWED) |
| POST | `/api/loans/{id}/return` | Сотрудник | Вернуть книгу и закрыть выдачу | | POST | `{id}/return` | Сотрудник | Вернуть книгу и закрыть выдачу |
| DELETE | `/api/loans/{id}` | Авторизованный | Удалить выдачу/бронь (только для RESERVED статуса) | | GET | `book/{book_id}/active` | Сотрудник | Получить активную выдачу книги |
| GET | `/api/loans/book/{book_id}/active` | Сотрудник | Получить активную выдачу книги | | POST | `issue` | Админ | Выдать книгу напрямую без бронирования |
| POST | `/api/loans/issue` | Админ | Выдать книгу напрямую без бронирования | | GET | `analytics` | Админ | Аналитика выдач и возвратов |
#### **Связи** (`/api`) #### **Связи** (`/api`)
| Метод | Эндпоинт | Доступ | Описание | | Метод | Эндпоинт | Доступ | Описание |
|--------|----------------------------------|-----------|-------------------------------| |--------|------------------------------|-----------|-------------------------------|
| POST | `/api/relationships/author-book` | Сотрудник | Связать автора и книгу | | POST | `/relationships/author-book` | Сотрудник | Связать автора и книгу |
| DELETE | `/api/relationships/author-book` | Сотрудник | Удалить связь автор-книга | | DELETE | `/relationships/author-book` | Сотрудник | Удалить связь автор-книга |
| GET | `/api/authors/{id}/books` | Публичный | Получить список книг автора | | GET | `/authors/{id}/books` | Публичный | Получить список книг автора |
| GET | `/api/books/{id}/authors` | Публичный | Получить список авторов книги | | GET | `/books/{id}/authors` | Публичный | Получить список авторов книги |
| POST | `/api/relationships/genre-book` | Сотрудник | Связать жанр и книгу | | POST | `/relationships/genre-book` | Сотрудник | Связать жанр и книгу |
| DELETE | `/api/relationships/genre-book` | Сотрудник | Удалить связь жанр-книга | | DELETE | `/relationships/genre-book` | Сотрудник | Удалить связь жанр-книга |
| GET | `/api/genres/{id}/books` | Публичный | Получить список книг жанра | | GET | `/genres/{id}/books` | Публичный | Получить список книг жанра |
| GET | `/api/books/{id}/genres` | Публичный | Получить список жанров книги | | 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`) #### **Прочее** (`/api`)
| Метод | Эндпоинт | Доступ | Описание | | Метод | Эндпоинт | Доступ | Описание |
|-------|--------------|-----------|----------------------| |-------|----------|-----------|----------------------|
| GET | `/api/info` | Публичный | Информация о сервисе | | GET | `/info` | Публичный | Информация о сервисе |
| GET | `/api/stats` | Публичный | Статистика системы | | GET | `/stats` | Публичный | Статистика системы |
### **Веб-страницы** ### **Веб-страницы**
| Путь | Доступ | Описание | | Путь | Доступ | Описание |
|---------------------|----------------|-----------------------------------------| |---------------------|----------------|-----------------------------|
| `/` | Публичный | Главная страница | | `/` | Публичный | Главная страница |
| `/auth` | Публичный | Страница авторизации | | `/api` | Публичный | Ссылки на документацию |
| `/profile` | Авторизованный | Профиль пользователя | | `/auth` | Публичный | Страница авторизации |
| `/books` | Публичный | Каталог книг с фильтрацией | | `/profile` | Авторизованный | Профиль пользователя |
| `/book/{id}` | Публичный | Страница просмотра книги | | `/books` | Публичный | Каталог книг с фильтрацией |
| `/book/create` | Сотрудник | Создание новой книги | | `/book/{id}` | Публичный | Страница просмотра книги |
| `/book/{id}/edit` | Сотрудник | Редактирование книги | | `/book/create` | Сотрудник | Создание новой книги |
| `/authors` | Публичный | Список авторов | | `/book/{id}/edit` | Сотрудник | Редактирование книги |
| `/author/{id}` | Публичный | Страница автора | | `/authors` | Публичный | Список авторов |
| `/author/create` | Сотрудник | Создание автора | | `/author/{id}` | Публичный | Страница автора |
| `/author/{id}/edit` | Сотрудник | Редактирование автора | | `/author/create` | Сотрудник | Создание автора |
| `/genre/create` | Сотрудник | Создание жанра | | `/author/{id}/edit` | Сотрудник | Редактирование автора |
| `/genre/{id}/edit` | Сотрудник | Редактирование жанра | | `/genre/create` | Сотрудник | Создание жанра |
| `/my-books` | Авторизованный | Мои выдачи | | `/genre/{id}/edit` | Сотрудник | Редактирование жанра |
| `/users` | Сотрудник | Управление пользователями | | `/my-books` | Авторизованный | Мои выдачи |
| `/analytics` | Админ | Аналитика выдач и возвратов | | `/users` | Админ | Управление пользователями |
| `/api` | Публичный | Страница с ссылками на документацию API | | `/analytics` | Админ | Аналитика выдач и возвратов |
### **Схема базы данных** ### **Схема базы данных**
+1 -1
View File
@@ -11,7 +11,7 @@ from jose import jwt, JWTError, ExpiredSignatureError
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlmodel import Session, select 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.models.dto import TokenData
from library_service.settings import get_session, get_logger from library_service.settings import get_session, get_logger
-1
View File
@@ -1,6 +1,5 @@
"""Модуль резервных кодов восстановления пароля""" """Модуль резервных кодов восстановления пароля"""
import os
import secrets import secrets
from datetime import datetime, timezone, timedelta 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 library_service.models.db import Role, User
from .core import get_password_hash 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() 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 .loan import LoanBase, LoanCreate, LoanList, LoanRead, LoanUpdate
from .recovery import RecoveryCodesResponse, RecoveryCodesStatus, RecoveryCodeUse from .recovery import RecoveryCodesResponse, RecoveryCodesStatus, RecoveryCodeUse
from .token import Token, TokenData, PartialToken from .token import Token, TokenData, PartialToken
from .combined import ( from .misc import (
AuthorWithBooks, AuthorWithBooks,
GenreWithBooks, GenreWithBooks,
BookWithAuthors, BookWithAuthors,
@@ -19,6 +19,8 @@ from .combined import (
LoanWithBook, LoanWithBook,
LoginResponse, LoginResponse,
RegisterResponse, RegisterResponse,
UserCreateByAdmin,
UserUpdateByAdmin,
TOTPSetupResponse, TOTPSetupResponse,
TOTPVerifyRequest, TOTPVerifyRequest,
TOTPDisableRequest, TOTPDisableRequest,
@@ -67,6 +69,8 @@ __all__ = [
"TOTPVerifyRequest", "TOTPVerifyRequest",
"TOTPDisableRequest", "TOTPDisableRequest",
"RecoveryCodeUse", "RecoveryCodeUse",
"UserCreateByAdmin",
"UserUpdateByAdmin",
"LoginResponse", "LoginResponse",
"RegisterResponse", "RegisterResponse",
"RecoveryCodesStatus", "RecoveryCodesStatus",
@@ -1,4 +1,4 @@
"""Модуль объединёных объектов""" """Модуль разных моделей"""
from datetime import datetime from datetime import datetime
from typing import List from typing import List
@@ -11,8 +11,8 @@ from .book import BookRead
from .loan import LoanRead from .loan import LoanRead
from ..enums import BookStatus from ..enums import BookStatus
from .user import UserRead from .user import UserCreate, UserRead, UserUpdate
from .recovery import RecoveryCodesResponse, RecoveryCodesStatus from .recovery import RecoveryCodesResponse
class AuthorWithBooks(SQLModel): class AuthorWithBooks(SQLModel):
@@ -80,6 +80,20 @@ class BookStatusUpdate(SQLModel):
status: str 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): class LoginResponse(SQLModel):
"""Модель для авторизации пользователя""" """Модель для авторизации пользователя"""
+3
View File
@@ -1,4 +1,5 @@
"""Модуль объединения роутеров""" """Модуль объединения роутеров"""
from fastapi import APIRouter from fastapi import APIRouter
from .auth import router as auth_router 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 .genres import router as genres_router
from .loans import router as loans_router from .loans import router as loans_router
from .relationships import router as relationships_router from .relationships import router as relationships_router
from .users import router as users_router
from .misc import router as misc_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(books_router, prefix="/api")
api_router.include_router(genres_router, prefix="/api") api_router.include_router(genres_router, prefix="/api")
api_router.include_router(loans_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") api_router.include_router(relationships_router, prefix="/api")
+2 -131
View File
@@ -2,13 +2,10 @@
from datetime import timedelta from datetime import timedelta
from typing import Annotated 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.security import OAuth2PasswordRequestForm
from fastapi.templating import Jinja2Templates
from sqlmodel import Session, select from sqlmodel import Session, select
import pyotp
from library_service.models.db import Role, User from library_service.models.db import Role, User
from library_service.models.dto import ( 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"]) router = APIRouter(prefix="/auth", tags=["authentication"])
@@ -157,7 +153,7 @@ def login(
"/refresh", "/refresh",
response_model=Token, response_model=Token,
summary="Обновление токена", summary="Обновление токена",
description="Получение новой пары токенов (Access + Refresh) используя действующий Refresh токен", description="Получение новой пары токенов, используя действующий Refresh токен",
) )
def refresh_token( def refresh_token(
refresh_token: str = Body(..., embed=True), 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( @router.get(
"/2fa", "/2fa",
response_model=TOTPSetupResponse, response_model=TOTPSetupResponse,
+9 -5
View File
@@ -25,7 +25,7 @@ from library_service.models.dto import (
BookUpdate, BookUpdate,
GenreRead, GenreRead,
) )
from library_service.models.dto.combined import ( from library_service.models.dto.misc import (
BookWithAuthorsAndGenres, BookWithAuthorsAndGenres,
BookFilteredList, BookFilteredList,
) )
@@ -71,13 +71,17 @@ def filter_books(
if author_ids: if author_ids:
statement = statement.join(AuthorBookLink).where( statement = statement.join(AuthorBookLink).where(
AuthorBookLink.author_id.in_(author_ids) AuthorBookLink.author_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference]
) # ty: ignore[unresolved-attribute, unresolved-reference] author_ids
)
)
if genre_ids: if genre_ids:
statement = statement.join(GenreBookLink).where( statement = statement.join(GenreBookLink).where(
GenreBookLink.genre_id.in_(genre_ids) GenreBookLink.genre_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference]
) # ty: ignore[unresolved-attribute, unresolved-reference] genre_ids
)
)
total_statement = select(func.count()).select_from(statement.subquery()) total_statement = select(func.count()).select_from(statement.subquery())
total = session.exec(total_statement).one() 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": "genres", "description": "Действия с жанрами."},
{"name": "loans", "description": "Действия с выдачами."}, {"name": "loans", "description": "Действия с выдачами."},
{"name": "relations", "description": "Действия со связями."}, {"name": "relations", "description": "Действия со связями."},
{"name": "users", "description": "Действия с пользователями."},
{"name": "misc", "description": "Прочие."}, {"name": "misc", "description": "Прочие."},
] ]
+1 -1
View File
@@ -13,7 +13,7 @@ $(document).ready(() => {
function loadProfile() { function loadProfile() {
Promise.all([ Promise.all([
Api.get("/api/auth/me"), 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), Api.get("/api/auth/recovery-codes/status").catch(() => null),
]) ])
.then(async ([user, rolesData, recoveryStatus]) => { .then(async ([user, rolesData, recoveryStatus]) => {
+112 -108
View File
@@ -5,18 +5,11 @@ $(document).ready(() => {
); );
return; return;
} }
setTimeout(() => {
if (!window.isAdmin()) {
$("#users-container").html(
document.getElementById("access-denied-template").innerHTML,
);
}
}, 100);
let allRoles = []; let allRoles = [];
let users = []; let users = [];
let currentPage = 1; let currentPage = 1;
let pageSize = 20; const pageSize = 20;
let totalUsers = 0; let totalUsers = 0;
let searchQuery = ""; let searchQuery = "";
let selectedFilterRoles = new Set(); let selectedFilterRoles = new Set();
@@ -28,8 +21,8 @@ $(document).ready(() => {
showLoadingState(); showLoadingState();
Promise.all([ Promise.all([
Api.get("/api/auth/users?skip=0&limit=100"), Api.get("/api/users?skip=0&limit=100"),
Api.get("/api/auth/roles"), Api.get("/api/users/roles"),
]) ])
.then(([usersData, rolesData]) => { .then(([usersData, rolesData]) => {
users = usersData.users; users = usersData.users;
@@ -57,12 +50,12 @@ $(document).ready(() => {
.attr("data-name", role.name) .attr("data-name", role.name)
.html( .html(
`<div> `<div>
<div class="font-medium text-sm">${Utils.escapeHtml(role.name)}</div> <div class="font-medium text-sm">${Utils.escapeHtml(role.name)}</div>
${role.description ? `<div class="text-xs text-gray-500">${Utils.escapeHtml(role.description)}</div>` : ""} ${role.description ? `<div class="text-xs text-gray-500">${Utils.escapeHtml(role.description)}</div>` : ""}
</div> </div>
<svg class="check-icon w-4 h-4 text-green-600 hidden" fill="currentColor" viewBox="0 0 20 20"> <svg class="check-icon w-4 h-4 text-green-600 hidden" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path> <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>`, </svg>`,
) )
.appendTo($dropdown); .appendTo($dropdown);
}); });
@@ -139,13 +132,11 @@ $(document).ready(() => {
} }
function loadUsers() { function loadUsers() {
const params = new URLSearchParams(); const skip = (currentPage - 1) * pageSize;
params.append("skip", (currentPage - 1) * pageSize);
params.append("limit", pageSize);
showLoadingState(); showLoadingState();
Api.get(`/api/auth/users?${params.toString()}`) Api.get(`/api/users?skip=${skip}&limit=${pageSize}`)
.then((data) => { .then((data) => {
users = data.users; users = data.users;
totalUsers = data.total; totalUsers = data.total;
@@ -220,6 +211,8 @@ $(document).ready(() => {
} }
const rolesContainer = clone.querySelector(".user-roles"); const rolesContainer = clone.querySelector(".user-roles");
let totalPayroll = 0;
if (user.roles && user.roles.length > 0) { if (user.roles && user.roles.length > 0) {
user.roles.forEach((roleName) => { user.roles.forEach((roleName) => {
const badge = roleBadgeTpl.content.cloneNode(true); const badge = roleBadgeTpl.content.cloneNode(true);
@@ -238,12 +231,25 @@ $(document).ready(() => {
removeBtn.dataset.userId = user.id; removeBtn.dataset.userId = user.id;
removeBtn.dataset.roleName = roleName; removeBtn.dataset.roleName = roleName;
rolesContainer.appendChild(badge); rolesContainer.appendChild(badge);
const fullRole = allRoles.find((r) => r.name === roleName);
if (fullRole && fullRole.payroll) {
totalPayroll += fullRole.payroll;
}
}); });
} else { } else {
rolesContainer.innerHTML = rolesContainer.innerHTML =
'<span class="text-gray-400 text-sm italic">Нет ролей</span>'; '<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"); const addRoleBtn = clone.querySelector(".add-role-btn");
addRoleBtn.dataset.userId = user.id; addRoleBtn.dataset.userId = user.id;
@@ -265,30 +271,30 @@ $(document).ready(() => {
function showLoadingState() { function showLoadingState() {
$("#users-container").html(` $("#users-container").html(`
<div class="space-y-4"> <div class="space-y-4">
${Array(3) ${Array(3)
.fill() .fill()
.map( .map(
() => ` () => `
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse"> <div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div class="w-14 h-14 bg-gray-200 rounded-full"></div> <div class="w-14 h-14 bg-gray-200 rounded-full"></div>
<div class="flex-1"> <div class="flex-1">
<div class="h-5 bg-gray-200 rounded w-1/4 mb-2"></div> <div class="h-5 bg-gray-200 rounded w-1/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/3 mb-2"></div> <div class="h-4 bg-gray-200 rounded w-1/3 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/2 mb-3"></div> <div class="h-4 bg-gray-200 rounded w-1/2 mb-3"></div>
<div class="flex gap-2"> <div class="flex gap-2">
<div class="h-6 bg-gray-200 rounded-full w-16"></div> <div class="h-6 bg-gray-200 rounded-full w-16"></div>
<div class="h-6 bg-gray-200 rounded-full w-20"></div> <div class="h-6 bg-gray-200 rounded-full w-20"></div>
</div> </div>
</div> </div>
</div> </div>
</div>
`,
)
.join("")}
</div> </div>
`); `,
)
.join("")}
</div>
`);
} }
function renderPagination() { function renderPagination() {
@@ -297,12 +303,12 @@ $(document).ready(() => {
if (totalPages <= 1) return; if (totalPages <= 1) return;
const $pagination = $(` const $pagination = $(`
<div class="flex justify-center items-center gap-2 mt-6 mb-4"> <div class="flex justify-center items-center gap-2 mt-6 mb-4">
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition" ${currentPage === 1 ? "disabled" : ""}>&larr;</button> <button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition" ${currentPage === 1 ? "disabled" : ""}>&larr;</button>
<div id="page-numbers" class="flex gap-1"></div> <div id="page-numbers" class="flex gap-1"></div>
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition" ${currentPage === totalPages ? "disabled" : ""}>&rarr;</button> <button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition" ${currentPage === totalPages ? "disabled" : ""}>&rarr;</button>
</div> </div>
`); `);
const $pageNumbers = $pagination.find("#page-numbers"); const $pageNumbers = $pagination.find("#page-numbers");
const pages = generatePageNumbers(currentPage, totalPages); const pages = generatePageNumbers(currentPage, totalPages);
@@ -313,8 +319,8 @@ $(document).ready(() => {
} else { } else {
const isActive = page === currentPage; const isActive = page === currentPage;
$pageNumbers.append(` $pageNumbers.append(`
<button class="page-btn px-3 py-2 rounded-lg transition-colors ${isActive ? "bg-gray-600 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">${page}</button> <button class="page-btn px-3 py-2 rounded-lg transition-colors ${isActive ? "bg-gray-600 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">${page}</button>
`); `);
} }
}); });
@@ -383,13 +389,13 @@ $(document).ready(() => {
} }
const $dropdown = $(` const $dropdown = $(`
<div class="role-add-dropdown absolute z-50 bg-white border border-gray-200 rounded-lg shadow-xl overflow-hidden" style="min-width: 200px;"> <div class="role-add-dropdown absolute z-50 bg-white border border-gray-200 rounded-lg shadow-xl overflow-hidden" style="min-width: 200px;">
<div class="p-2 border-b border-gray-100"> <div class="p-2 border-b border-gray-100">
<input type="text" class="role-search-input w-full border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400" placeholder="Поиск роли..." autocomplete="off" /> <input type="text" class="role-search-input w-full border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400" placeholder="Поиск роли..." autocomplete="off" />
</div> </div>
<div class="role-items max-h-48 overflow-y-auto"></div> <div class="role-items max-h-48 overflow-y-auto"></div>
</div> </div>
`); `);
const $roleItems = $dropdown.find(".role-items"); const $roleItems = $dropdown.find(".role-items");
@@ -402,12 +408,12 @@ $(document).ready(() => {
: "hover:bg-gray-50"; : "hover:bg-gray-50";
$roleItems.append(` $roleItems.append(`
<div class="role-item p-2 ${roleClass} cursor-pointer transition-colors border-b border-gray-50 last:border-0" data-role-name="${Utils.escapeHtml(role.name)}"> <div class="role-item p-2 ${roleClass} cursor-pointer transition-colors border-b border-gray-50 last:border-0" data-role-name="${Utils.escapeHtml(role.name)}">
<div class="font-medium text-sm text-gray-800">${Utils.escapeHtml(role.name)}</div> <div class="font-medium text-sm text-gray-800">${Utils.escapeHtml(role.name)}</div>
${role.description ? `<div class="text-xs text-gray-500">${Utils.escapeHtml(role.description)}</div>` : ""} ${role.description ? `<div class="text-xs text-gray-500">${Utils.escapeHtml(role.description)}</div>` : ""}
${role.payroll ? `<div class="text-xs text-green-600">Оклад: ${role.payroll}</div>` : ""} ${role.payroll ? `<div class="text-xs text-green-600">Оклад: ${role.payroll}</div>` : ""}
</div> </div>
`); `);
}); });
const $button = $(button); const $button = $(button);
@@ -457,12 +463,9 @@ $(document).ready(() => {
} }
function addRoleToUser(userId, roleName) { function addRoleToUser(userId, roleName) {
Api.request( Api.request(`/api/users/${userId}/roles/${encodeURIComponent(roleName)}`, {
`/api/auth/users/${userId}/roles/${encodeURIComponent(roleName)}`, method: "POST",
{ })
method: "POST",
},
)
.then((updatedUser) => { .then((updatedUser) => {
const userIndex = users.findIndex((u) => u.id === userId); const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex !== -1) { if (userIndex !== -1) {
@@ -485,12 +488,9 @@ $(document).ready(() => {
return; return;
} }
Api.request( Api.request(`/api/users/${userId}/roles/${encodeURIComponent(roleName)}`, {
`/api/auth/users/${userId}/roles/${encodeURIComponent(roleName)}`, method: "DELETE",
{ })
method: "DELETE",
},
)
.then((updatedUser) => { .then((updatedUser) => {
const userIndex = users.findIndex((u) => u.id === userId); const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex !== -1) { if (userIndex !== -1) {
@@ -514,7 +514,6 @@ $(document).ready(() => {
$("#edit-user-fullname").val(user.full_name || ""); $("#edit-user-fullname").val(user.full_name || "");
$("#edit-user-password").val(""); $("#edit-user-password").val("");
$("#edit-user-active").prop("checked", user.is_active); $("#edit-user-active").prop("checked", user.is_active);
$("#edit-user-verified").prop("checked", user.is_verified);
$("#edit-user-modal").removeClass("hidden"); $("#edit-user-modal").removeClass("hidden");
} }
@@ -529,6 +528,7 @@ $(document).ready(() => {
const email = $("#edit-user-email").val().trim(); const email = $("#edit-user-email").val().trim();
const fullName = $("#edit-user-fullname").val().trim(); const fullName = $("#edit-user-fullname").val().trim();
const password = $("#edit-user-password").val(); const password = $("#edit-user-password").val();
const isActive = $("#edit-user-active").prop("checked");
if (!email) { if (!email) {
Utils.showToast("Email обязателен", "error"); Utils.showToast("Email обязателен", "error");
@@ -538,36 +538,26 @@ $(document).ready(() => {
const updateData = { const updateData = {
email: email, email: email,
full_name: fullName || null, full_name: fullName || null,
is_active: isActive,
}; };
if (password) { if (password) {
updateData.password = password; updateData.password = password;
} }
Api.put(`/api/auth/me`, updateData) Api.put(`/api/users/${userId}`, updateData)
.then((updatedUser) => { .then((updatedUser) => {
const userIndex = users.findIndex((u) => u.id === userId); const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex !== -1) { if (userIndex !== -1) {
users[userIndex] = { ...users[userIndex], ...updatedUser }; users[userIndex] = updatedUser;
} }
renderUsers(); renderUsers();
closeEditModal(); closeEditModal();
Utils.showToast("Пользователь обновлён", "success"); Utils.showToast("Пользователь обновлён", "success");
}) })
.catch((error) => { .catch((error) => {
console.warn("API update failed, updating locally:", error); console.error(error);
const userIndex = users.findIndex((u) => u.id === userId); Utils.showToast(error.message || "Ошибка обновления", "error");
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");
}); });
} }
@@ -582,7 +572,16 @@ $(document).ready(() => {
} }
userToDelete = user; userToDelete = user;
const actionText = user.is_active ? "деактивировать" : "удалить навсегда";
$("#delete-user-name").text(user.full_name || user.username); $("#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"); $("#delete-user-modal").removeClass("hidden");
} }
@@ -594,22 +593,27 @@ $(document).ready(() => {
function confirmDeleteUser() { function confirmDeleteUser() {
if (!userToDelete) return; if (!userToDelete) return;
Utils.showToast("Удаление пользователей не поддерживается API", "error"); Api.delete(`/api/users/${userToDelete.id}`)
closeDeleteModal(); .then((deletedUser) => {
if (deletedUser.is_active === false) {
// Api.delete(`/api/auth/users/${userToDelete.id}`) const userIndex = users.findIndex((u) => u.id === userToDelete.id);
// .then(() => { if (userIndex !== -1) {
// users = users.filter(u => u.id !== userToDelete.id); users[userIndex] = deletedUser;
// totalUsers--; }
// $("#total-users-count").text(totalUsers); Utils.showToast("Пользователь деактивирован", "success");
// renderUsers(); } else {
// closeDeleteModal(); users = users.filter((u) => u.id !== userToDelete.id);
// Utils.showToast("Пользователь удалён", "success"); totalUsers--;
// }) $("#total-users-count").text(totalUsers);
// .catch((error) => { Utils.showToast("Пользователь удалён", "success");
// console.error(error); }
// Utils.showToast(error.message || "Ошибка удаления", "error"); renderUsers();
// }); closeDeleteModal();
})
.catch((error) => {
console.error(error);
Utils.showToast(error.message || "Ошибка удаления", "error");
});
} }
$("#users-container").on("click", ".add-role-btn", function (e) { $("#users-container").on("click", ".add-role-btn", function (e) {
+12 -15
View File
@@ -38,7 +38,7 @@
type="text" type="text"
id="role-filter-input" id="role-filter-input"
placeholder="Фильтр по роли..." 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" autocomplete="off"
/> />
<div <div
@@ -150,7 +150,7 @@
</button> </button>
</div> </div>
</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"> <div class="flex items-center flex-wrap gap-2">
<span class="text-sm font-medium text-gray-700" <span class="text-sm font-medium text-gray-700"
>Роли:</span >Роли:</span
@@ -177,6 +177,15 @@
</button> </button>
</div> </div>
</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> </div>
</div> </div>
@@ -312,7 +321,7 @@
placeholder="••••••••" placeholder="••••••••"
/> />
</div> </div>
<div class="flex items-center gap-4"> <div>
<label <label
class="flex items-center gap-2 cursor-pointer" class="flex items-center gap-2 cursor-pointer"
> >
@@ -325,18 +334,6 @@
>Активен</span >Активен</span
> >
</label> </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> </div>
</div> </div>
Generated
+1 -1
View File
@@ -619,7 +619,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/e8/ef/324f4a28ed0152a32
[[package]] [[package]]
name = "libraryapi" name = "libraryapi"
version = "0.4.0" version = "0.5.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },