mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 12:31:09 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09b7cb17a5 | |||
| 3473c31f73 | |||
| a3203d713d | |||
| 961bf95af7 | |||
| 64a46645c5 | |||
| e7f2987eea | |||
| 85b531e6e1 | |||
| 345ff8f23f | |||
| e64d3da7f4 | |||
| 719631158d | |||
| f6ac03a869 | |||
| 16a043843a | |||
| 756e941f99 | |||
| 2c24f66de0 | |||
| a757e69ad5 | |||
|
|
20dbf34fa6 |
@@ -1,4 +1,9 @@
|
||||
# DEFAULT_ADMIN_USERNAME = "admin"
|
||||
# DEFAULT_ADMIN_EMAIL = "admin@example.com"
|
||||
# DEFAULT_ADMIN_PASSWORD = "password-is-generated-randomly-on-first-launch"
|
||||
|
||||
POSTGRES_HOST = "localhost"
|
||||
POSTGRES_PORT = "5432"
|
||||
POSTGRES_USER = "postgres"
|
||||
POSTGRES_PASSWORD = "postgres"
|
||||
POSTGRES_DB = "postgres"
|
||||
POSTGRES_SERVER = "db"
|
||||
POSTGRES_DB = "lib"
|
||||
|
||||
@@ -96,6 +96,43 @@
|
||||
|--------|-------------|-------------------------------|
|
||||
| GET | `/api/info` | Получить информацию о сервисе |
|
||||
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
AUTHOR {
|
||||
int id PK "ID автора"
|
||||
string name "Имя автора"
|
||||
}
|
||||
|
||||
BOOK {
|
||||
int id PK "ID книги"
|
||||
string title "Название книги"
|
||||
string description "Описание книги"
|
||||
}
|
||||
|
||||
GENRE {
|
||||
int id PK "ID жанра"
|
||||
string name "Название жанра"
|
||||
}
|
||||
|
||||
AUTHOR_BOOK {
|
||||
int author_id FK "ID автора"
|
||||
int book_id FK "ID книги"
|
||||
}
|
||||
|
||||
GENRE_BOOK {
|
||||
int genre_id FK "ID жанра"
|
||||
int book_id FK "ID книги"
|
||||
}
|
||||
|
||||
AUTHOR ||--o{ AUTHOR_BOOK : "писал"
|
||||
BOOK ||--o{ AUTHOR_BOOK : "написан"
|
||||
|
||||
BOOK ||--o{ GENRE_BOOK : "принадлежит"
|
||||
GENRE ||--o{ GENRE_BOOK : "содержит"
|
||||
```
|
||||
|
||||
|
||||
### **Используемые технологии**
|
||||
|
||||
- **FastAPI**: Современный web фреймворк для построения API с использованием Python, известный своей скоростью и простотой использования.
|
||||
@@ -105,3 +142,5 @@
|
||||
- **PostgreSQL**: Сильная, открытая реляционная система управления базами данных.
|
||||
- **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах.
|
||||
- **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker.
|
||||
- **Tailwind**: CSS-фреймворк, позволяющий стилизовать веб-интерфейсы, применяя готовые низкоуровневые классы.
|
||||
- **Cash**: Микро JavaScript-библиотека, созданная как очень быстрая и компактная альтернатива jQuery.
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import requests
|
||||
from typing import Optional
|
||||
|
||||
# Конфигурация
|
||||
USERNAME = "admin"
|
||||
PASSWORD = "7WaVlcj8EWzEbbdab9kqRw"
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
|
||||
class LibraryAPI:
|
||||
def __init__(self, base_url: str):
|
||||
self.base_url = base_url
|
||||
self.token: Optional[str] = None
|
||||
self.session = requests.Session()
|
||||
|
||||
def login(self, username: str, password: str) -> bool:
|
||||
"""Авторизация и получение токена"""
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/api/auth/token",
|
||||
data={"username": username, "password": password},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
self.token = response.json()["access_token"]
|
||||
self.session.headers.update({"Authorization": f"Bearer {self.token}"})
|
||||
print(f"✓ Авторизация успешна для пользователя: {username}")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ Ошибка авторизации: {response.text}")
|
||||
return False
|
||||
|
||||
def register(self, username: str, email: str, password: str, full_name: str = None) -> bool:
|
||||
"""Регистрация нового пользователя"""
|
||||
data = {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password
|
||||
}
|
||||
if full_name:
|
||||
data["full_name"] = full_name
|
||||
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/api/auth/register",
|
||||
json=data
|
||||
)
|
||||
if response.status_code == 201:
|
||||
print(f"✓ Пользователь {username} зарегистрирован")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ Ошибка регистрации: {response.text}")
|
||||
return False
|
||||
|
||||
def create_author(self, name: str) -> Optional[int]:
|
||||
"""Создание автора"""
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/api/authors/",
|
||||
json={"name": name}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
author_id = response.json()["id"]
|
||||
print(f" ✓ Автор создан: {name} (ID: {author_id})")
|
||||
return author_id
|
||||
else:
|
||||
print(f" ✗ Ошибка создания автора {name}: {response.text}")
|
||||
return None
|
||||
|
||||
def create_book(self, title: str, description: str) -> Optional[int]:
|
||||
"""Создание книги"""
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/api/books/",
|
||||
json={"title": title, "description": description}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
book_id = response.json()["id"]
|
||||
print(f" ✓ Книга создана: {title} (ID: {book_id})")
|
||||
return book_id
|
||||
else:
|
||||
print(f" ✗ Ошибка создания книги {title}: {response.text}")
|
||||
return None
|
||||
|
||||
def create_genre(self, name: str) -> Optional[int]:
|
||||
"""Создание жанра"""
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/api/genres/",
|
||||
json={"name": name}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
genre_id = response.json()["id"]
|
||||
print(f" ✓ Жанр создан: {name} (ID: {genre_id})")
|
||||
return genre_id
|
||||
else:
|
||||
print(f" ✗ Ошибка создания жанра {name}: {response.text}")
|
||||
return None
|
||||
|
||||
def link_author_book(self, author_id: int, book_id: int) -> bool:
|
||||
"""Связь автора и книги"""
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/api/relationships/author-book",
|
||||
params={"author_id": author_id, "book_id": book_id}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
print(f" ↔ Связь автор-книга: {author_id} ↔ {book_id}")
|
||||
return True
|
||||
else:
|
||||
print(f" ✗ Ошибка связи автор-книга: {response.text}")
|
||||
return False
|
||||
|
||||
def link_genre_book(self, genre_id: int, book_id: int) -> bool:
|
||||
"""Связь жанра и книги"""
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/api/relationships/genre-book",
|
||||
params={"genre_id": genre_id, "book_id": book_id}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
print(f" ↔ Связь жанр-книга: {genre_id} ↔ {book_id}")
|
||||
return True
|
||||
else:
|
||||
print(f" ✗ Ошибка связи жанр-книга: {response.text}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
api = LibraryAPI(BASE_URL)
|
||||
|
||||
# Авторизация
|
||||
if not api.login(USERNAME, PASSWORD):
|
||||
print("Не удалось авторизоваться. Проверьте логин и пароль.")
|
||||
return
|
||||
|
||||
print("\n📚 Создание авторов...")
|
||||
authors_data = [
|
||||
"Лев Толстой",
|
||||
"Фёдор Достоевский",
|
||||
"Антон Чехов",
|
||||
"Александр Пушкин",
|
||||
"Михаил Булгаков",
|
||||
"Николай Гоголь",
|
||||
"Иван Тургенев",
|
||||
"Борис Пастернак",
|
||||
"Михаил Лермонтов",
|
||||
"Александр Солженицын",
|
||||
"Максим Горький",
|
||||
"Иван Бунин"
|
||||
]
|
||||
|
||||
authors = {}
|
||||
for name in authors_data:
|
||||
author_id = api.create_author(name)
|
||||
if author_id:
|
||||
authors[name] = author_id
|
||||
|
||||
print("\n🏷️ Создание жанров...")
|
||||
genres_data = [
|
||||
"Роман",
|
||||
"Повесть",
|
||||
"Рассказ",
|
||||
"Поэзия",
|
||||
"Драма",
|
||||
"Философская проза",
|
||||
"Историческая проза",
|
||||
"Сатира"
|
||||
]
|
||||
|
||||
genres = {}
|
||||
for name in genres_data:
|
||||
genre_id = api.create_genre(name)
|
||||
if genre_id:
|
||||
genres[name] = genre_id
|
||||
|
||||
print("\n📖 Создание книг...")
|
||||
books_data = [
|
||||
{
|
||||
"title": "Война и мир",
|
||||
"description": "Роман-эпопея Льва Толстого, описывающий русское общество в эпоху войн против Наполеона в 1805—1812 годах. Одно из величайших произведений мировой литературы.",
|
||||
"authors": ["Лев Толстой"],
|
||||
"genres": ["Роман", "Историческая проза"]
|
||||
},
|
||||
{
|
||||
"title": "Анна Каренина",
|
||||
"description": "Роман Льва Толстого о трагической любви замужней дамы Анны Карениной к блестящему офицеру Вронскому. История страсти, ревности и роковых решений.",
|
||||
"authors": ["Лев Толстой"],
|
||||
"genres": ["Роман", "Драма"]
|
||||
},
|
||||
{
|
||||
"title": "Преступление и наказание",
|
||||
"description": "Социально-психологический роман Фёдора Достоевского о бедном студенте Раскольникове, совершившем убийство и мучающемся угрызениями совести.",
|
||||
"authors": ["Фёдор Достоевский"],
|
||||
"genres": ["Роман", "Философская проза"]
|
||||
},
|
||||
{
|
||||
"title": "Братья Карамазовы",
|
||||
"description": "Последний роман Достоевского, история семьи Карамазовых, затрагивающая глубокие вопросы веры, свободы воли и морали.",
|
||||
"authors": ["Фёдор Достоевский"],
|
||||
"genres": ["Роман", "Философская проза", "Драма"]
|
||||
},
|
||||
{
|
||||
"title": "Идиот",
|
||||
"description": "Роман о князе Мышкине — человеке с чистой душой, который сталкивается с жестокостью и корыстью петербургского общества.",
|
||||
"authors": ["Фёдор Достоевский"],
|
||||
"genres": ["Роман", "Философская проза"]
|
||||
},
|
||||
{
|
||||
"title": "Вишнёвый сад",
|
||||
"description": "Пьеса Антона Чехова о разорении дворянского гнезда и продаже родового имения с вишнёвым садом.",
|
||||
"authors": ["Антон Чехов"],
|
||||
"genres": ["Драма"]
|
||||
},
|
||||
{
|
||||
"title": "Чайка",
|
||||
"description": "Пьеса Чехова о любви, искусстве и несбывшихся мечтах, разворачивающаяся в усадьбе на берегу озера.",
|
||||
"authors": ["Антон Чехов"],
|
||||
"genres": ["Драма"]
|
||||
},
|
||||
{
|
||||
"title": "Палата № 6",
|
||||
"description": "Повесть о враче психиатрической больницы, который начинает сомневаться в границах между нормой и безумием.",
|
||||
"authors": ["Антон Чехов"],
|
||||
"genres": ["Повесть", "Философская проза"]
|
||||
},
|
||||
{
|
||||
"title": "Евгений Онегин",
|
||||
"description": "Роман в стихах Александра Пушкина — энциклопедия русской жизни начала XIX века и история несчастной любви.",
|
||||
"authors": ["Александр Пушкин"],
|
||||
"genres": ["Роман", "Поэзия"]
|
||||
},
|
||||
{
|
||||
"title": "Капитанская дочка",
|
||||
"description": "Исторический роман Пушкина о событиях Пугачёвского восстания, любви и чести.",
|
||||
"authors": ["Александр Пушкин"],
|
||||
"genres": ["Роман", "Историческая проза"]
|
||||
},
|
||||
{
|
||||
"title": "Пиковая дама",
|
||||
"description": "Повесть о молодом офицере Германне, одержимом желанием узнать тайну трёх карт.",
|
||||
"authors": ["Александр Пушкин"],
|
||||
"genres": ["Повесть"]
|
||||
},
|
||||
{
|
||||
"title": "Мастер и Маргарита",
|
||||
"description": "Роман Михаила Булгакова о визите дьявола в Москву 1930-х годов, переплетённый с историей Понтия Пилата.",
|
||||
"authors": ["Михаил Булгаков"],
|
||||
"genres": ["Роман", "Сатира", "Философская проза"]
|
||||
},
|
||||
{
|
||||
"title": "Собачье сердце",
|
||||
"description": "Повесть-сатира о профессоре Преображенском, превратившем бродячего пса в человека.",
|
||||
"authors": ["Михаил Булгаков"],
|
||||
"genres": ["Повесть", "Сатира"]
|
||||
},
|
||||
{
|
||||
"title": "Белая гвардия",
|
||||
"description": "Роман о семье Турбиных в Киеве во время Гражданской войны 1918-1919 годов.",
|
||||
"authors": ["Михаил Булгаков"],
|
||||
"genres": ["Роман", "Историческая проза"]
|
||||
},
|
||||
{
|
||||
"title": "Мёртвые души",
|
||||
"description": "Поэма Николая Гоголя о похождениях Чичикова, скупающего «мёртвые души» крепостных крестьян.",
|
||||
"authors": ["Николай Гоголь"],
|
||||
"genres": ["Роман", "Сатира"]
|
||||
},
|
||||
{
|
||||
"title": "Ревизор",
|
||||
"description": "Комедия о чиновниках уездного города, принявших проезжего за ревизора из Петербурга.",
|
||||
"authors": ["Николай Гоголь"],
|
||||
"genres": ["Драма", "Сатира"]
|
||||
},
|
||||
{
|
||||
"title": "Шинель",
|
||||
"description": "Повесть о маленьком человеке — титулярном советнике Акакии Башмачкине и его мечте о новой шинели.",
|
||||
"authors": ["Николай Гоголь"],
|
||||
"genres": ["Повесть"]
|
||||
},
|
||||
{
|
||||
"title": "Отцы и дети",
|
||||
"description": "Роман Ивана Тургенева о конфликте поколений и нигилизме на примере Евгения Базарова.",
|
||||
"authors": ["Иван Тургенев"],
|
||||
"genres": ["Роман", "Философская проза"]
|
||||
},
|
||||
{
|
||||
"title": "Записки охотника",
|
||||
"description": "Цикл рассказов Тургенева о русской деревне и крестьянах, написанный с глубоким сочувствием к народу.",
|
||||
"authors": ["Иван Тургенев"],
|
||||
"genres": ["Рассказ"]
|
||||
},
|
||||
{
|
||||
"title": "Доктор Живаго",
|
||||
"description": "Роман Бориса Пастернака о судьбе русского интеллигента в эпоху революции и Гражданской войны.",
|
||||
"authors": ["Борис Пастернак"],
|
||||
"genres": ["Роман", "Историческая проза", "Поэзия"]
|
||||
},
|
||||
{
|
||||
"title": "Герой нашего времени",
|
||||
"description": "Роман Михаила Лермонтова о Печорине — «лишнем человеке», скучающем и разочарованном в жизни.",
|
||||
"authors": ["Михаил Лермонтов"],
|
||||
"genres": ["Роман", "Философская проза"]
|
||||
},
|
||||
{
|
||||
"title": "Архипелаг ГУЛАГ",
|
||||
"description": "Документально-художественное исследование Александра Солженицына о системе советских лагерей.",
|
||||
"authors": ["Александр Солженицын"],
|
||||
"genres": ["Историческая проза"]
|
||||
},
|
||||
{
|
||||
"title": "Один день Ивана Денисовича",
|
||||
"description": "Повесть о одном дне заключённого советского лагеря, положившая начало лагерной прозе.",
|
||||
"authors": ["Александр Солженицын"],
|
||||
"genres": ["Повесть", "Историческая проза"]
|
||||
},
|
||||
{
|
||||
"title": "На дне",
|
||||
"description": "Пьеса Максима Горького о жителях ночлежки для бездомных — людях, оказавшихся на дне жизни.",
|
||||
"authors": ["Максим Горький"],
|
||||
"genres": ["Драма", "Философская проза"]
|
||||
},
|
||||
{
|
||||
"title": "Тёмные аллеи",
|
||||
"description": "Сборник рассказов Ивана Бунина о любви — трагической, мимолётной и прекрасной.",
|
||||
"authors": ["Иван Бунин"],
|
||||
"genres": ["Рассказ"]
|
||||
}
|
||||
]
|
||||
|
||||
books = {}
|
||||
for book in books_data:
|
||||
book_id = api.create_book(book["title"], book["description"])
|
||||
if book_id:
|
||||
books[book["title"]] = {
|
||||
"id": book_id,
|
||||
"authors": book["authors"],
|
||||
"genres": book["genres"]
|
||||
}
|
||||
|
||||
print("\n🔗 Создание связей...")
|
||||
|
||||
for book_title, book_info in books.items():
|
||||
book_id = book_info["id"]
|
||||
|
||||
for author_name in book_info["authors"]:
|
||||
if author_name in authors:
|
||||
api.link_author_book(authors[author_name], book_id)
|
||||
|
||||
for genre_name in book_info["genres"]:
|
||||
if genre_name in genres:
|
||||
api.link_genre_book(genres[genre_name], book_id)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("📊 ИТОГИ:")
|
||||
print(f" • Авторов создано: {len(authors)}")
|
||||
print(f" • Жанров создано: {len(genres)}")
|
||||
print(f" • Книг создано: {len(books)}")
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+2
-2
@@ -17,8 +17,8 @@ services:
|
||||
- .:/code
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
- db
|
||||
# depends_on:
|
||||
# - db
|
||||
|
||||
tests:
|
||||
container_name: tests
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Модуль авторизации и аутентификации"""
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.models.db import Role, User
|
||||
from library_service.models.dto import TokenData
|
||||
from library_service.settings import get_session, get_logger
|
||||
|
||||
|
||||
# Конфигурация из переменных окружения
|
||||
ALGORITHM = os.getenv("ALGORITHM", "HS256")
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
|
||||
|
||||
|
||||
# Получение логгера
|
||||
logger = get_logger("uvicorn")
|
||||
|
||||
# OAuth2 схема
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
|
||||
|
||||
# Хэширование паролей
|
||||
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Проверка пароль по его хешу."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Хэширование пароля."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||
"""Создание JWT access токена."""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_refresh_token(data: dict) -> str:
|
||||
"""Создание JWT refresh токена."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_token(token: str) -> TokenData:
|
||||
"""Декодирование и проверка JWT токенов."""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
user_id: int = payload.get("user_id")
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return TokenData(username=username, user_id=user_id)
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def authenticate_user(session: Session, username: str, password: str) -> User | None:
|
||||
"""Аутентификация пользователя по имени пользователя и паролю."""
|
||||
statement = select(User).where(User.username == username)
|
||||
user = session.exec(statement).first()
|
||||
if not user or not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token: Annotated[str, Depends(oauth2_scheme)],
|
||||
session: Session = Depends(get_session),
|
||||
) -> User:
|
||||
"""Получить текущего авторизованного пользователя."""
|
||||
token_data = decode_token(token)
|
||||
|
||||
user = session.get(User, token_data.user_id)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def get_current_active_user(
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> User:
|
||||
"""Получить текущего активного пользователя."""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def require_role(role_name: str):
|
||||
"""Dependency, требующая выполнения определенной роли."""
|
||||
|
||||
def role_checker(current_user: User = Depends(get_current_active_user)) -> User:
|
||||
user_roles = [role.name for role in current_user.roles]
|
||||
if role_name not in user_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Role '{role_name}' required",
|
||||
)
|
||||
return current_user
|
||||
|
||||
return role_checker
|
||||
|
||||
|
||||
# Создание dependencies
|
||||
RequireAuth = Annotated[User, Depends(get_current_active_user)]
|
||||
RequireAdmin = Annotated[User, Depends(require_role("admin"))]
|
||||
RequireModerator = Annotated[User, Depends(require_role("moderator"))]
|
||||
|
||||
|
||||
def seed_roles(session: Session) -> dict[str, Role]:
|
||||
"""Создаёт роли по умолчанию, если их нет."""
|
||||
default_roles = [
|
||||
{"name": "admin", "description": "Администратор системы"},
|
||||
{"name": "librarian", "description": "Библиотекарь"},
|
||||
{"name": "member", "description": "Посетитель библиотеки"},
|
||||
]
|
||||
|
||||
roles = {}
|
||||
for role_data in default_roles:
|
||||
existing = session.exec(
|
||||
select(Role).where(Role.name == role_data["name"])
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
roles[role_data["name"]] = existing
|
||||
else:
|
||||
role = Role(**role_data)
|
||||
session.add(role)
|
||||
session.commit()
|
||||
session.refresh(role)
|
||||
roles[role_data["name"]] = role
|
||||
logger.info(f"[+] Created role: {role_data['name']}")
|
||||
|
||||
return roles
|
||||
|
||||
|
||||
def seed_admin(session: Session, admin_role: Role) -> User | None:
|
||||
"""Создаёт администратора по умолчанию, если нет ни одного."""
|
||||
existing_admins = session.exec(
|
||||
select(User).join(User.roles).where(Role.name == "admin")
|
||||
).all()
|
||||
|
||||
if existing_admins:
|
||||
logger.info(f"[=] Admin already exists: {existing_admins[0].username}, skipping creation")
|
||||
return None
|
||||
|
||||
admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
|
||||
admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "admin@example.com")
|
||||
admin_password = os.getenv("DEFAULT_ADMIN_PASSWORD")
|
||||
|
||||
generated = False
|
||||
if not admin_password:
|
||||
import secrets
|
||||
admin_password = secrets.token_urlsafe(16)
|
||||
generated = True
|
||||
|
||||
admin_user = User(
|
||||
username=admin_username,
|
||||
email=admin_email,
|
||||
full_name="Системный администратор",
|
||||
hashed_password=get_password_hash(admin_password),
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
admin_user.roles.append(admin_role)
|
||||
|
||||
session.add(admin_user)
|
||||
session.commit()
|
||||
session.refresh(admin_user)
|
||||
|
||||
logger.info(f"[+] Created admin user: {admin_username}")
|
||||
|
||||
if generated:
|
||||
logger.warning("=" * 50)
|
||||
logger.warning(f"[!] GENERATED ADMIN PASSWORD: {admin_password}")
|
||||
logger.warning("[!] Save this password! It won't be shown again!")
|
||||
logger.warning("=" * 50)
|
||||
|
||||
return admin_user
|
||||
|
||||
|
||||
def run_seeds(session: Session) -> None:
|
||||
"""Запускаем создание ролей и администратора."""
|
||||
roles = seed_roles(session)
|
||||
seed_admin(session, roles["admin"])
|
||||
@@ -1 +0,0 @@
|
||||
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg"><rect x="10" y="10" width="80" height="80" rx="4" ry="4" fill="#fff" stroke="#000" stroke-width="2"/><rect x="20" y="15" width="60" height="70" rx="10" ry="10"/><rect x="20" y="15" width="60" height="66" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="62" rx="10" ry="10"/><rect x="20" y="15" width="60" height="60" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="56" rx="10" ry="10"/><rect x="20" y="15" width="60" height="54" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="50" rx="10" ry="10"/><rect x="20" y="15" width="60" height="48" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="44" rx="10" ry="10"/><rect x="22" y="21" width="2" height="58" rx="10" ry="10" stroke="#000" stroke-width="4"/><rect x="22" y="55" width="4" height="26" rx="2" ry="15"/><text x="50" y="40" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" stroke="#fff" stroke-width=".5" fill="none" font-size="20">『LiB』</text></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
+35
-14
@@ -1,30 +1,51 @@
|
||||
"""Основной модуль"""
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from toml import load
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlmodel import Session
|
||||
|
||||
from .settings import engine, get_app
|
||||
from .auth import run_seeds
|
||||
from .routers import api_router
|
||||
from .routers.misc import get_info
|
||||
|
||||
app = get_app()
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
from .settings import engine, get_app, get_logger
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
print("[+] Initializing...")
|
||||
"""Жизненный цикл сервиса"""
|
||||
logger = get_logger("uvicorn")
|
||||
logger.info("[+] Initializing database...")
|
||||
|
||||
# Настройка базы данных
|
||||
with engine.begin() as connection:
|
||||
alembic_cfg.attributes["connection"] = connection
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
alembic_cfg.attributes["configure_logging"] = False
|
||||
alembic_cfg.attributes["connection"] = connection
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
except Exception as e:
|
||||
logger.error(f"[-] Migration failed: {e}")
|
||||
raise e
|
||||
|
||||
print("[+] Starting...")
|
||||
logger.info("[+] Running seeds...")
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
run_seeds(session)
|
||||
logger.info("[+] Database setup completed.")
|
||||
except Exception as e:
|
||||
logger.error(f"[-] Seeding failed: {e}")
|
||||
|
||||
logger.info("[+] Starting application...")
|
||||
yield # Обработка запросов
|
||||
print("[+] Application shutdown")
|
||||
logger.info("[+] Application shutdown")
|
||||
|
||||
|
||||
app = get_app(lifespan)
|
||||
|
||||
|
||||
# Подключение маршрутов
|
||||
app.include_router(api_router)
|
||||
static_path = Path(__file__).parent / "static"
|
||||
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
from .dto import *
|
||||
"""Модуль моделей"""
|
||||
from .db import *
|
||||
from .dto import *
|
||||
from .enums import *
|
||||
@@ -1,25 +1,22 @@
|
||||
"""Модуль моделей для базы данных"""
|
||||
from .author import Author
|
||||
from .book import Book
|
||||
from .genre import Genre
|
||||
from .role import Role
|
||||
from .user import User
|
||||
from .links import (
|
||||
AuthorBookLink,
|
||||
GenreBookLink,
|
||||
AuthorWithBooks,
|
||||
BookWithAuthors,
|
||||
GenreWithBooks,
|
||||
BookWithGenres,
|
||||
BookWithAuthorsAndGenres,
|
||||
UserRoleLink
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Author",
|
||||
"Book",
|
||||
"Genre",
|
||||
"Role",
|
||||
"User",
|
||||
"AuthorBookLink",
|
||||
"AuthorWithBooks",
|
||||
"BookWithAuthors",
|
||||
"GenreBookLink",
|
||||
"GenreWithBooks",
|
||||
"BookWithGenres",
|
||||
"BookWithAuthorsAndGenres",
|
||||
"UserRoleLink",
|
||||
]
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
from sqlmodel import SQLModel, Field, Relationship
|
||||
from ..dto.author import AuthorBase
|
||||
from .links import AuthorBookLink
|
||||
"""Модуль DB-моделей авторов"""
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlmodel import Field, Relationship
|
||||
|
||||
from library_service.models.dto.author import AuthorBase
|
||||
from library_service.models.db.links import AuthorBookLink
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .book import Book
|
||||
|
||||
|
||||
class Author(AuthorBase, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True, index=True)
|
||||
"""Модель автора в базе данных"""
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
books: List["Book"] = Relationship(
|
||||
back_populates="authors", link_model=AuthorBookLink
|
||||
)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
from sqlmodel import SQLModel, Field, Relationship
|
||||
from ..dto.book import BookBase
|
||||
from .links import AuthorBookLink, GenreBookLink
|
||||
"""Модуль DB-моделей книг"""
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlmodel import Field, Relationship
|
||||
|
||||
from library_service.models.dto.book import BookBase
|
||||
from library_service.models.db.links import AuthorBookLink, GenreBookLink
|
||||
from library_service.models.enums import BookStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .author import Author
|
||||
@@ -9,10 +13,13 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class Book(BookBase, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True, index=True)
|
||||
"""Модель книги в базе данных"""
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
status: BookStatus = Field(default=BookStatus.ACTIVE)
|
||||
authors: List["Author"] = Relationship(
|
||||
back_populates="books", link_model=AuthorBookLink
|
||||
)
|
||||
genres: List["Genre"] = Relationship(
|
||||
back_populates="books", link_model=GenreBookLink
|
||||
)
|
||||
loans: List["BookUserLink"] = Relationship(sa_relationship_kwargs={"cascade": "all, delete"})
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
from sqlmodel import SQLModel, Field, Relationship
|
||||
from ..dto.genre import GenreBase
|
||||
from .links import GenreBookLink
|
||||
"""Модуль DB-моделей жанров"""
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlmodel import Field, Relationship
|
||||
|
||||
from library_service.models.dto.genre import GenreBase
|
||||
from library_service.models.db.links import GenreBookLink
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .book import Book
|
||||
|
||||
|
||||
class Genre(GenreBase, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True, index=True)
|
||||
"""Модель жанра в базе данных"""
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
books: List["Book"] = Relationship(
|
||||
back_populates="genres", link_model=GenreBookLink
|
||||
)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"""Модуль связей между сущностями в БД"""
|
||||
from datetime import datetime
|
||||
from sqlmodel import SQLModel, Field
|
||||
from typing import List
|
||||
|
||||
from library_service.models.dto.author import AuthorRead
|
||||
from library_service.models.dto.book import BookRead
|
||||
from library_service.models.dto.genre import GenreRead
|
||||
|
||||
|
||||
class AuthorBookLink(SQLModel, table=True):
|
||||
"""Модель связи автора и книги"""
|
||||
author_id: int | None = Field(
|
||||
default=None, foreign_key="author.id", primary_key=True
|
||||
)
|
||||
@@ -14,26 +12,31 @@ class AuthorBookLink(SQLModel, table=True):
|
||||
|
||||
|
||||
class GenreBookLink(SQLModel, table=True):
|
||||
"""Модель связи жанра и книги"""
|
||||
genre_id: int | None = Field(default=None, foreign_key="genre.id", primary_key=True)
|
||||
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
|
||||
|
||||
|
||||
class AuthorWithBooks(AuthorRead):
|
||||
books: List[BookRead] = Field(default_factory=list)
|
||||
class UserRoleLink(SQLModel, table=True):
|
||||
"""Модель связи роли и пользователя"""
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
user_id: int | None = Field(default=None, foreign_key="users.id", primary_key=True)
|
||||
role_id: int | None = Field(default=None, foreign_key="roles.id", primary_key=True)
|
||||
|
||||
|
||||
class BookWithAuthors(BookRead):
|
||||
authors: List[AuthorRead] = Field(default_factory=list)
|
||||
class BookUserLink(SQLModel, table=True):
|
||||
"""
|
||||
Модель истории выдачи книг (Loan).
|
||||
Связывает книгу и пользователя с фиксацией времени.
|
||||
"""
|
||||
__tablename__ = "book_loans"
|
||||
|
||||
|
||||
class BookWithGenres(BookRead):
|
||||
genres: List[GenreRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GenreWithBooks(GenreRead):
|
||||
books: List[BookRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BookWithAuthorsAndGenres(BookRead):
|
||||
authors: List[AuthorRead] = Field(default_factory=list)
|
||||
genres: List[GenreRead] = Field(default_factory=list)
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
|
||||
book_id: int = Field(foreign_key="book.id")
|
||||
user_id: int = Field(foreign_key="users.id")
|
||||
|
||||
borrowed_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
due_date: datetime
|
||||
returned_at: datetime | None = Field(default=None)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Модуль DB-моделей ролей"""
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlmodel import Field, Relationship
|
||||
|
||||
from library_service.models.dto.role import RoleBase
|
||||
from library_service.models.db.links import UserRoleLink
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .user import User
|
||||
|
||||
|
||||
class Role(RoleBase, table=True):
|
||||
"""Модель роли в базе данных"""
|
||||
__tablename__ = "roles"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
|
||||
users: List["User"] = Relationship(back_populates="roles", link_model=UserRoleLink)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Модуль DB-моделей пользователей"""
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlmodel import Field, Relationship
|
||||
|
||||
from library_service.models.dto.user import UserBase
|
||||
from library_service.models.db.links import UserRoleLink
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .role import Role
|
||||
|
||||
|
||||
class User(UserBase, table=True):
|
||||
"""Модель пользователя в базе данных"""
|
||||
__tablename__ = "users"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
hashed_password: str = Field(nullable=False)
|
||||
is_active: bool = Field(default=True)
|
||||
is_verified: bool = Field(default=False)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime | None = Field(
|
||||
default=None, sa_column_kwargs={"onupdate": datetime.utcnow}
|
||||
)
|
||||
|
||||
# Связи
|
||||
roles: List["Role"] = Relationship(back_populates="users", link_model=UserRoleLink)
|
||||
loans: List["BookUserLink"] = Relationship(sa_relationship_kwargs={"cascade": "all, delete"})
|
||||
@@ -1,7 +1,13 @@
|
||||
from .author import AuthorBase, AuthorCreate, AuthorUpdate, AuthorRead, AuthorList
|
||||
from .book import BookBase, BookCreate, BookUpdate, BookRead, BookList
|
||||
|
||||
from .genre import GenreBase, GenreCreate, GenreUpdate, GenreRead, GenreList
|
||||
"""Модуль DTO-моделей"""
|
||||
from .author import AuthorBase, AuthorCreate, AuthorList, AuthorRead, AuthorUpdate
|
||||
from .genre import GenreBase, GenreCreate, GenreList, GenreRead, GenreUpdate
|
||||
from .book import BookBase, BookCreate, BookList, BookRead, BookUpdate
|
||||
from .role import RoleBase, RoleCreate, RoleList, RoleRead, RoleUpdate
|
||||
from .user import UserBase, UserCreate, UserList, UserRead, UserUpdate, UserLogin
|
||||
from .loan import LoanBase, LoanCreate, LoanList, LoanRead, LoanUpdate
|
||||
from .token import Token, TokenData
|
||||
from .combined import (AuthorWithBooks, GenreWithBooks, BookWithAuthors, BookWithGenres,
|
||||
BookWithAuthorsAndGenres, BookFilteredList, BookStatusUpdate, LoanWithBook)
|
||||
|
||||
__all__ = [
|
||||
"AuthorBase",
|
||||
@@ -14,9 +20,30 @@ __all__ = [
|
||||
"BookUpdate",
|
||||
"BookRead",
|
||||
"BookList",
|
||||
"BookFilteredList",
|
||||
"BookStatusUpdate",
|
||||
"GenreBase",
|
||||
"GenreCreate",
|
||||
"GenreUpdate",
|
||||
"GenreRead",
|
||||
"GenreList",
|
||||
"LoanBase",
|
||||
"LoanCreate",
|
||||
"LoanUpdate",
|
||||
"LoanRead",
|
||||
"LoanList",
|
||||
"LoanWithBook",
|
||||
"UserBase",
|
||||
"UserCreate",
|
||||
"UserUpdate",
|
||||
"UserRead",
|
||||
"UserList",
|
||||
"UserLogin",
|
||||
"RoleBase",
|
||||
"RoleCreate",
|
||||
"RoleUpdate",
|
||||
"RoleRead",
|
||||
"RoleList",
|
||||
"Token",
|
||||
"TokenData",
|
||||
]
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
from sqlmodel import SQLModel
|
||||
"""Модуль DTO-моделей авторов"""
|
||||
from typing import List
|
||||
|
||||
from pydantic import ConfigDict
|
||||
from typing import Optional, List
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class AuthorBase(SQLModel):
|
||||
"""Базовая модель автора"""
|
||||
name: str
|
||||
|
||||
model_config = ConfigDict( # pyright: ignore
|
||||
@@ -12,17 +15,21 @@ class AuthorBase(SQLModel):
|
||||
|
||||
|
||||
class AuthorCreate(AuthorBase):
|
||||
"""Модель автора для создания"""
|
||||
pass
|
||||
|
||||
|
||||
class AuthorUpdate(SQLModel):
|
||||
name: Optional[str] = None
|
||||
"""Модель автора для обновления"""
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class AuthorRead(AuthorBase):
|
||||
"""Модель автора для чтения"""
|
||||
id: int
|
||||
|
||||
|
||||
class AuthorList(SQLModel):
|
||||
"""Список авторов"""
|
||||
authors: List[AuthorRead]
|
||||
total: int
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
from sqlmodel import SQLModel
|
||||
"""Модуль DTO-моделей книг"""
|
||||
from typing import List
|
||||
|
||||
from pydantic import ConfigDict
|
||||
from typing import Optional, List
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from library_service.models.enums import BookStatus
|
||||
|
||||
|
||||
class BookBase(SQLModel):
|
||||
"""Базовая модель книги"""
|
||||
title: str
|
||||
description: str
|
||||
|
||||
@@ -15,18 +20,24 @@ class BookBase(SQLModel):
|
||||
|
||||
|
||||
class BookCreate(BookBase):
|
||||
"""Модель книги для создания"""
|
||||
pass
|
||||
|
||||
|
||||
class BookUpdate(SQLModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
"""Модель книги для обновления"""
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: BookStatus | None = None
|
||||
|
||||
|
||||
class BookRead(BookBase):
|
||||
"""Модель книги для чтения"""
|
||||
id: int
|
||||
status: BookStatus
|
||||
|
||||
|
||||
class BookList(SQLModel):
|
||||
"""Список книг"""
|
||||
books: List[BookRead]
|
||||
total: int
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Модуль объединёных объектов"""
|
||||
from typing import List
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
from .author import AuthorRead
|
||||
from .genre import GenreRead
|
||||
from .book import BookRead
|
||||
from .loan import LoanRead
|
||||
|
||||
|
||||
class AuthorWithBooks(SQLModel):
|
||||
"""Модель автора с книгами"""
|
||||
id: int
|
||||
name: str
|
||||
books: List[BookRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GenreWithBooks(SQLModel):
|
||||
"""Модель жанра с книгами"""
|
||||
id: int
|
||||
name: str
|
||||
books: List[BookRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BookWithAuthors(SQLModel):
|
||||
"""Модель книги с авторами"""
|
||||
id: int
|
||||
title: str
|
||||
description: str
|
||||
authors: List[AuthorRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BookWithGenres(SQLModel):
|
||||
"""Модель книги с жанрами"""
|
||||
id: int
|
||||
title: str
|
||||
description: str
|
||||
genres: List[GenreRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BookWithAuthorsAndGenres(SQLModel):
|
||||
"""Модель с авторами и жанрами"""
|
||||
id: int
|
||||
title: str
|
||||
description: str
|
||||
authors: List[AuthorRead] = Field(default_factory=list)
|
||||
genres: List[GenreRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BookFilteredList(SQLModel):
|
||||
"""Список книг с фильтрацией"""
|
||||
books: List[BookWithAuthorsAndGenres]
|
||||
total: int
|
||||
|
||||
class LoanWithBook(LoanRead):
|
||||
"""Модель выдачи, включающая данные о книге"""
|
||||
book: BookRead
|
||||
|
||||
class BookStatusUpdate(SQLModel):
|
||||
"""Модель для ручного изменения статуса библиотекарем"""
|
||||
status: str
|
||||
@@ -1,9 +1,12 @@
|
||||
from sqlmodel import SQLModel
|
||||
"""Модуль DTO-моделей жанров"""
|
||||
from typing import List
|
||||
|
||||
from pydantic import ConfigDict
|
||||
from typing import Optional, List
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class GenreBase(SQLModel):
|
||||
"""Базовая модель жанра"""
|
||||
name: str
|
||||
|
||||
model_config = ConfigDict( # pyright: ignore
|
||||
@@ -12,17 +15,21 @@ class GenreBase(SQLModel):
|
||||
|
||||
|
||||
class GenreCreate(GenreBase):
|
||||
"""Модель жанра для создания"""
|
||||
pass
|
||||
|
||||
|
||||
class GenreUpdate(SQLModel):
|
||||
name: Optional[str] = None
|
||||
"""Модель жанра для обновления"""
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class GenreRead(GenreBase):
|
||||
"""Модель жанра для чтения"""
|
||||
id: int
|
||||
|
||||
|
||||
class GenreList(SQLModel):
|
||||
"""Списко жанров"""
|
||||
genres: List[GenreRead]
|
||||
total: int
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Модуль DTO-моделей для выдачи книг"""
|
||||
from typing import List
|
||||
|
||||
from datetime import datetime
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class LoanBase(SQLModel):
|
||||
"""Базовая модель выдачи"""
|
||||
book_id: int
|
||||
user_id: int
|
||||
due_date: datetime
|
||||
|
||||
|
||||
class LoanCreate(LoanBase):
|
||||
"""Модель для создания записи о выдаче"""
|
||||
pass
|
||||
|
||||
|
||||
class LoanUpdate(SQLModel):
|
||||
"""Модель для обновления записи о выдаче"""
|
||||
returned_at: datetime | None = None
|
||||
|
||||
|
||||
class LoanRead(LoanBase):
|
||||
"""Модель чтения записи о выдаче"""
|
||||
id: int
|
||||
borrowed_at: datetime
|
||||
returned_at: datetime | None = None
|
||||
|
||||
|
||||
class LoanList(SQLModel):
|
||||
"""Список выдач"""
|
||||
loans: List[LoanRead]
|
||||
total: int
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Модуль DTO-моделей ролей"""
|
||||
from typing import List
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class RoleBase(SQLModel):
|
||||
"""Базовая модель роли"""
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class RoleCreate(RoleBase):
|
||||
"""Модель роли для создания"""
|
||||
pass
|
||||
|
||||
|
||||
class RoleUpdate(SQLModel):
|
||||
"""Модель роли для обновления"""
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class RoleRead(RoleBase):
|
||||
"""Модель роли для чтения"""
|
||||
id: int
|
||||
|
||||
|
||||
class RoleList(SQLModel):
|
||||
"""Список ролей"""
|
||||
roles: List[RoleRead]
|
||||
total: int
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Модуль DTO-моделей токенов"""
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class Token(SQLModel):
|
||||
"""Модель токена"""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
refresh_token: str | None = None
|
||||
|
||||
|
||||
class TokenData(SQLModel):
|
||||
"""Модель содержимого токена"""
|
||||
username: str | None = None
|
||||
user_id: int | None = None
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Модуль DTO-моделей пользователей"""
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
from pydantic import ConfigDict, EmailStr, field_validator
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class UserBase(SQLModel):
|
||||
"""Базовая модель пользователя"""
|
||||
username: str = Field(min_length=3, max_length=50, index=True, unique=True)
|
||||
email: EmailStr = Field(index=True, unique=True)
|
||||
full_name: str | None = Field(default=None, max_length=100)
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"username": "johndoe",
|
||||
"email": "john@example.com",
|
||||
"full_name": "John Doe",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""Модель пользователя для создания"""
|
||||
password: str = Field(min_length=8, max_length=100)
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password(cls, v: str) -> str:
|
||||
"""Валидация пароля"""
|
||||
if not re.search(r"[A-Z]", v):
|
||||
raise ValueError("Пароль должен содержать символы в верхнем регистре")
|
||||
if not re.search(r"[a-z]", v):
|
||||
raise ValueError("Пароль должен содержать символы в нижнем регистре")
|
||||
if not re.search(r"\d", v):
|
||||
raise ValueError("пароль должен содержать цифры")
|
||||
return v
|
||||
|
||||
|
||||
class UserLogin(SQLModel):
|
||||
"""Модель аутентификации для пользователя"""
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserRead(UserBase):
|
||||
"""Модель пользователя для чтения"""
|
||||
id: int
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
roles: List[str] = []
|
||||
|
||||
|
||||
class UserUpdate(SQLModel):
|
||||
"""Модель пользователя для обновления"""
|
||||
email: EmailStr | None = None
|
||||
full_name: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class UserList(SQLModel):
|
||||
"""Список пользователей"""
|
||||
users: List[UserRead]
|
||||
total: int
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Модуль перечислений (Enums)"""
|
||||
from enum import Enum
|
||||
|
||||
class BookStatus(str, Enum):
|
||||
"""Статусы книги"""
|
||||
ACTIVE = "active"
|
||||
BORROWED = "borrowed"
|
||||
RESERVED = "reserved"
|
||||
RESTORATION = "restoration"
|
||||
WRITTEN_OFF = "written_off"
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Модуль объединения роутеров"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .auth import router as auth_router
|
||||
from .authors import router as authors_router
|
||||
from .books import router as books_router
|
||||
from .genres import router as genres_router
|
||||
@@ -9,8 +11,9 @@ from .misc import router as misc_router
|
||||
api_router = APIRouter()
|
||||
|
||||
# Подключение всех маршрутов
|
||||
api_router.include_router(authors_router)
|
||||
api_router.include_router(books_router)
|
||||
api_router.include_router(genres_router)
|
||||
api_router.include_router(relationships_router)
|
||||
api_router.include_router(misc_router)
|
||||
api_router.include_router(auth_router, prefix="/api")
|
||||
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(relationships_router, prefix="/api")
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Модуль работы с авторизацией и аутентификацией пользователей"""
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.models.db import Role, User
|
||||
from library_service.models.dto import Token, UserCreate, UserRead, UserUpdate, UserList, RoleRead, RoleList
|
||||
from library_service.settings import get_session
|
||||
from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAdmin,
|
||||
RequireAuth, authenticate_user, get_password_hash,
|
||||
create_access_token, create_refresh_token)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register",
|
||||
response_model=UserRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Регистрация нового пользователя",
|
||||
description="Создает нового пользователя в системе",
|
||||
)
|
||||
def register(user_data: UserCreate, session: Session = Depends(get_session)):
|
||||
"""Эндпоинт регистрации пользователя"""
|
||||
# Проверка если username существует
|
||||
existing_user = session.exec(
|
||||
select(User).where(User.username == user_data.username)
|
||||
).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already registered",
|
||||
)
|
||||
|
||||
# Проверка если email существует
|
||||
existing_email = session.exec(
|
||||
select(User).where(User.email == user_data.email)
|
||||
).first()
|
||||
if existing_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
|
||||
)
|
||||
|
||||
db_user = User(
|
||||
**user_data.model_dump(exclude={"password"}),
|
||||
hashed_password=get_password_hash(user_data.password)
|
||||
)
|
||||
|
||||
default_role = session.exec(select(Role).where(Role.name == "user")).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=[role.name for role in db_user.roles])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/token",
|
||||
response_model=Token,
|
||||
summary="Получение токена",
|
||||
description="Аутентификация и получение JWT токена",
|
||||
)
|
||||
def login(
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт аутентификации и получения JWT токена"""
|
||||
user = authenticate_user(session, form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username, "user_id": user.id},
|
||||
expires_delta=access_token_expires,
|
||||
)
|
||||
refresh_token = create_refresh_token(
|
||||
data={"sub": user.username, "user_id": user.id}
|
||||
)
|
||||
|
||||
return Token(
|
||||
access_token=access_token, refresh_token=refresh_token, token_type="bearer"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=UserRead,
|
||||
summary="Текущий пользователь",
|
||||
description="Получить информацию о текущем авторизованном пользователе",
|
||||
)
|
||||
def read_users_me(current_user: RequireAuth):
|
||||
"""Эндпоинт получения информации о себе"""
|
||||
return UserRead(
|
||||
**current_user.model_dump(), roles=[role.name for role in current_user.roles]
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/me",
|
||||
response_model=UserRead,
|
||||
summary="Обновить профиль",
|
||||
description="Обновить информацию текущего пользователя",
|
||||
)
|
||||
def update_user_me(
|
||||
user_update: UserUpdate,
|
||||
current_user: RequireAuth,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт обновления пользователя"""
|
||||
if user_update.email:
|
||||
current_user.email = user_update.email
|
||||
if user_update.full_name:
|
||||
current_user.full_name = user_update.full_name
|
||||
if user_update.password:
|
||||
current_user.hashed_password = get_password_hash(user_update.password)
|
||||
|
||||
session.add(current_user)
|
||||
session.commit()
|
||||
session.refresh(current_user)
|
||||
|
||||
return UserRead(
|
||||
**current_user.model_dump(), roles=[role.name for role in current_user.roles]
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/users",
|
||||
response_model=UserList,
|
||||
summary="Список пользователей",
|
||||
description="Получить список всех пользователей (только для админов)",
|
||||
)
|
||||
def read_users(
|
||||
admin: RequireAdmin,
|
||||
session: Session = Depends(get_session),
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
):
|
||||
"""Эндпоинт получения списка всех пользователей"""
|
||||
users = session.exec(select(User).offset(skip).limit(limit)).all()
|
||||
return UserList(
|
||||
users=[UserRead(**user.model_dump()) 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(
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт получения списа ролей"""
|
||||
roles = session.exec(select(Role)).all()
|
||||
return RoleList(
|
||||
roles=[RoleRead(**role.model_dump()) for role in roles],
|
||||
total=len(roles),
|
||||
)
|
||||
@@ -1,28 +1,28 @@
|
||||
from fastapi import APIRouter, Path, Depends, HTTPException
|
||||
"""Модуль работы с авторами"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.auth import RequireAuth
|
||||
from library_service.settings import get_session
|
||||
from library_service.models.db import Author, AuthorBookLink, Book, AuthorWithBooks
|
||||
from library_service.models.dto import (
|
||||
AuthorCreate,
|
||||
AuthorUpdate,
|
||||
AuthorRead,
|
||||
AuthorList,
|
||||
BookRead,
|
||||
)
|
||||
|
||||
from library_service.models.db import Author, AuthorBookLink, Book
|
||||
from library_service.models.dto import (BookRead, AuthorWithBooks,
|
||||
AuthorCreate, AuthorList, AuthorRead, AuthorUpdate)
|
||||
|
||||
router = APIRouter(prefix="/authors", tags=["authors"])
|
||||
|
||||
|
||||
# Create an author
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=AuthorRead,
|
||||
summary="Создать автора",
|
||||
description="Добавляет автора в систему",
|
||||
)
|
||||
def create_author(author: AuthorCreate, session: Session = Depends(get_session)):
|
||||
def create_author(
|
||||
current_user: RequireAuth,
|
||||
author: AuthorCreate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт создания автора"""
|
||||
db_author = Author(**author.model_dump())
|
||||
session.add(db_author)
|
||||
session.commit()
|
||||
@@ -30,7 +30,6 @@ def create_author(author: AuthorCreate, session: Session = Depends(get_session))
|
||||
return AuthorRead(**db_author.model_dump())
|
||||
|
||||
|
||||
# Read authors
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=AuthorList,
|
||||
@@ -38,6 +37,7 @@ def create_author(author: AuthorCreate, session: Session = Depends(get_session))
|
||||
description="Возвращает список всех авторов в системе",
|
||||
)
|
||||
def read_authors(session: Session = Depends(get_session)):
|
||||
"""Эндпоинт чтения списка авторов"""
|
||||
authors = session.exec(select(Author)).all()
|
||||
return AuthorList(
|
||||
authors=[AuthorRead(**author.model_dump()) for author in authors],
|
||||
@@ -45,7 +45,6 @@ def read_authors(session: Session = Depends(get_session)):
|
||||
)
|
||||
|
||||
|
||||
# Read an author with their books
|
||||
@router.get(
|
||||
"/{author_id}",
|
||||
response_model=AuthorWithBooks,
|
||||
@@ -56,6 +55,7 @@ def get_author(
|
||||
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт чтения конкретного автора"""
|
||||
author = session.get(Author, author_id)
|
||||
if not author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
@@ -72,7 +72,6 @@ def get_author(
|
||||
return AuthorWithBooks(**author_data)
|
||||
|
||||
|
||||
# Update an author
|
||||
@router.put(
|
||||
"/{author_id}",
|
||||
response_model=AuthorRead,
|
||||
@@ -80,10 +79,12 @@ def get_author(
|
||||
description="Обновляет информацию об авторе в системе",
|
||||
)
|
||||
def update_author(
|
||||
current_user: RequireAuth,
|
||||
author: AuthorUpdate,
|
||||
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт обновления автора"""
|
||||
db_author = session.get(Author, author_id)
|
||||
if not db_author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
@@ -97,7 +98,6 @@ def update_author(
|
||||
return AuthorRead(**db_author.model_dump())
|
||||
|
||||
|
||||
# Delete an author
|
||||
@router.delete(
|
||||
"/{author_id}",
|
||||
response_model=AuthorRead,
|
||||
@@ -105,9 +105,11 @@ def update_author(
|
||||
description="Удаляет автора из системы",
|
||||
)
|
||||
def delete_author(
|
||||
current_user: RequireAuth,
|
||||
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт удаления автора"""
|
||||
author = session.get(Author, author_id)
|
||||
if not author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
|
||||
@@ -1,29 +1,80 @@
|
||||
from fastapi import APIRouter, Path, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
"""Модуль работы с книгами"""
|
||||
from typing import List
|
||||
|
||||
from library_service.models.db.links import BookWithAuthorsAndGenres
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlmodel import Session, select, col, func
|
||||
|
||||
from library_service.auth import RequireAuth
|
||||
from library_service.settings import get_session
|
||||
from library_service.models.db import Author, Book, BookWithAuthors, AuthorBookLink
|
||||
from library_service.models.dto import (
|
||||
AuthorRead,
|
||||
BookList,
|
||||
BookRead,
|
||||
BookCreate,
|
||||
BookUpdate,
|
||||
from library_service.models.db import Author, AuthorBookLink, Book, GenreBookLink, Genre
|
||||
from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate, GenreRead
|
||||
from library_service.models.dto.combined import (
|
||||
BookWithAuthorsAndGenres,
|
||||
BookFilteredList
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/books", tags=["books"])
|
||||
|
||||
|
||||
# Create a book
|
||||
@router.get(
|
||||
"/filter",
|
||||
response_model=BookFilteredList,
|
||||
summary="Фильтрация книг",
|
||||
description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией"
|
||||
)
|
||||
def filter_books(
|
||||
session: Session = Depends(get_session),
|
||||
q: str | None = Query(None, min_length=3, max_length=50, description="Поиск"),
|
||||
author_ids: List[int] | None = Query(None, description="Список ID авторов"),
|
||||
genre_ids: List[int] | None = Query(None, description="Список ID жанров"),
|
||||
page: int = Query(1, gt=0, description="Номер страницы"),
|
||||
size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"),
|
||||
):
|
||||
"""Эндпоинт получения отфильтрованного списка книг"""
|
||||
statement = select(Book).distinct()
|
||||
|
||||
if q:
|
||||
statement = statement.where(
|
||||
(col(Book.title).ilike(f"%{q}%")) | (col(Book.description).ilike(f"%{q}%"))
|
||||
)
|
||||
|
||||
if author_ids:
|
||||
statement = statement.join(AuthorBookLink).where(AuthorBookLink.author_id.in_(author_ids))
|
||||
|
||||
if genre_ids:
|
||||
statement = statement.join(GenreBookLink).where(GenreBookLink.genre_id.in_(genre_ids))
|
||||
|
||||
total_statement = select(func.count()).select_from(statement.subquery())
|
||||
total = session.exec(total_statement).one()
|
||||
|
||||
offset = (page - 1) * size
|
||||
statement = statement.offset(offset).limit(size)
|
||||
results = session.exec(statement).all()
|
||||
|
||||
books_with_data = []
|
||||
for db_book in results:
|
||||
books_with_data.append(
|
||||
BookWithAuthorsAndGenres(
|
||||
**db_book.model_dump(),
|
||||
authors=[AuthorRead(**a.model_dump()) for a in db_book.authors],
|
||||
genres=[GenreRead(**g.model_dump()) for g in db_book.genres]
|
||||
)
|
||||
)
|
||||
|
||||
return BookFilteredList(books=books_with_data, total=total)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=Book,
|
||||
summary="Создать книгу",
|
||||
description="Добавляет книгу в систему",
|
||||
)
|
||||
def create_book(book: BookCreate, session: Session = Depends(get_session)):
|
||||
def create_book(
|
||||
current_user: RequireAuth, book: BookCreate, session: Session = Depends(get_session)
|
||||
):
|
||||
"""Эндпоинт создания книги"""
|
||||
db_book = Book(**book.model_dump())
|
||||
session.add(db_book)
|
||||
session.commit()
|
||||
@@ -31,7 +82,6 @@ def create_book(book: BookCreate, session: Session = Depends(get_session)):
|
||||
return BookRead(**db_book.model_dump())
|
||||
|
||||
|
||||
# Read books
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=BookList,
|
||||
@@ -39,13 +89,13 @@ def create_book(book: BookCreate, session: Session = Depends(get_session)):
|
||||
description="Возвращает список всех книг в системе",
|
||||
)
|
||||
def read_books(session: Session = Depends(get_session)):
|
||||
"""Эндпоинт чтения списка книг"""
|
||||
books = session.exec(select(Book)).all()
|
||||
return BookList(
|
||||
books=[BookRead(**book.model_dump()) for book in books], total=len(books)
|
||||
)
|
||||
|
||||
|
||||
# Read a book with their authors and genres
|
||||
@router.get(
|
||||
"/{book_id}",
|
||||
response_model=BookWithAuthorsAndGenres,
|
||||
@@ -56,6 +106,7 @@ def get_book(
|
||||
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт чтения конкретной книги"""
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
@@ -76,10 +127,9 @@ def get_book(
|
||||
book_data["authors"] = author_reads
|
||||
book_data["genres"] = genre_reads
|
||||
|
||||
return BookWithAuthors(**book_data)
|
||||
return BookWithAuthorsAndGenres(**book_data)
|
||||
|
||||
|
||||
# Update a book
|
||||
@router.put(
|
||||
"/{book_id}",
|
||||
response_model=Book,
|
||||
@@ -87,10 +137,12 @@ def get_book(
|
||||
description="Обновляет информацию о книге в системе",
|
||||
)
|
||||
def update_book(
|
||||
current_user: RequireAuth,
|
||||
book: BookUpdate,
|
||||
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт обновления книги"""
|
||||
db_book = session.get(Book, book_id)
|
||||
if not db_book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
@@ -102,7 +154,6 @@ def update_book(
|
||||
return db_book
|
||||
|
||||
|
||||
# Delete a book
|
||||
@router.delete(
|
||||
"/{book_id}",
|
||||
response_model=BookRead,
|
||||
@@ -110,9 +161,11 @@ def update_book(
|
||||
description="Удаляет книгу их системы",
|
||||
)
|
||||
def delete_book(
|
||||
current_user: RequireAuth,
|
||||
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт удаления книги"""
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
from fastapi import APIRouter, Path, Depends, HTTPException
|
||||
"""Модуль работы с жанрами"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.auth import RequireAuth
|
||||
from library_service.models.db import Book, Genre, GenreBookLink
|
||||
from library_service.models.dto import BookRead, GenreCreate, GenreList, GenreRead, GenreUpdate, GenreWithBooks
|
||||
from library_service.settings import get_session
|
||||
from library_service.models.db import Genre, GenreBookLink, Book, GenreWithBooks
|
||||
from library_service.models.dto import (
|
||||
GenreCreate,
|
||||
GenreUpdate,
|
||||
GenreRead,
|
||||
GenreList,
|
||||
BookRead,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/genres", tags=["genres"])
|
||||
|
||||
|
||||
# Create a genre
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=GenreRead,
|
||||
summary="Создать жанр",
|
||||
description="Добавляет жанр книг в систему",
|
||||
)
|
||||
def create_genre(genre: GenreCreate, session: Session = Depends(get_session)):
|
||||
def create_genre(
|
||||
current_user: RequireAuth,
|
||||
genre: GenreCreate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт создания жанра"""
|
||||
db_genre = Genre(**genre.model_dump())
|
||||
session.add(db_genre)
|
||||
session.commit()
|
||||
@@ -30,7 +29,6 @@ def create_genre(genre: GenreCreate, session: Session = Depends(get_session)):
|
||||
return GenreRead(**db_genre.model_dump())
|
||||
|
||||
|
||||
# Read genres
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=GenreList,
|
||||
@@ -38,13 +36,13 @@ def create_genre(genre: GenreCreate, session: Session = Depends(get_session)):
|
||||
description="Возвращает список всех жанров в системе",
|
||||
)
|
||||
def read_genres(session: Session = Depends(get_session)):
|
||||
"""Эндпоинт чтения списка жанров"""
|
||||
genres = session.exec(select(Genre)).all()
|
||||
return GenreList(
|
||||
genres=[GenreRead(**genre.model_dump()) for genre in genres], total=len(genres)
|
||||
)
|
||||
|
||||
|
||||
# Read a genre with their books
|
||||
@router.get(
|
||||
"/{genre_id}",
|
||||
response_model=GenreWithBooks,
|
||||
@@ -55,6 +53,7 @@ def get_genre(
|
||||
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт чтения конкретного жанра"""
|
||||
genre = session.get(Genre, genre_id)
|
||||
if not genre:
|
||||
raise HTTPException(status_code=404, detail="Genre not found")
|
||||
@@ -71,7 +70,6 @@ def get_genre(
|
||||
return GenreWithBooks(**genre_data)
|
||||
|
||||
|
||||
# Update a genre
|
||||
@router.put(
|
||||
"/{genre_id}",
|
||||
response_model=GenreRead,
|
||||
@@ -79,10 +77,12 @@ def get_genre(
|
||||
description="Обновляет информацию о жанре в системе",
|
||||
)
|
||||
def update_genre(
|
||||
current_user: RequireAuth,
|
||||
genre: GenreUpdate,
|
||||
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт обновления жанра"""
|
||||
db_genre = session.get(Genre, genre_id)
|
||||
if not db_genre:
|
||||
raise HTTPException(status_code=404, detail="Genre not found")
|
||||
@@ -96,7 +96,6 @@ def update_genre(
|
||||
return GenreRead(**db_genre.model_dump())
|
||||
|
||||
|
||||
# Delete a genre
|
||||
@router.delete(
|
||||
"/{genre_id}",
|
||||
response_model=GenreRead,
|
||||
@@ -104,9 +103,11 @@ def update_genre(
|
||||
description="Удаляет автора из системы",
|
||||
)
|
||||
def delete_genre(
|
||||
current_user: RequireAuth,
|
||||
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт удаления жанра"""
|
||||
genre = session.get(Genre, genre_id)
|
||||
if not genre:
|
||||
raise HTTPException(status_code=404, detail="Genre not found")
|
||||
|
||||
@@ -1,55 +1,121 @@
|
||||
from fastapi import APIRouter, Path, Request, FastAPI
|
||||
from fastapi.params import Depends
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
"""Модуль прочих эндпоинтов"""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from library_service.settings import get_app
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlmodel import Session, select, func
|
||||
|
||||
from library_service.settings import get_app, get_session
|
||||
from library_service.models.db import Author, Book, Genre, User
|
||||
|
||||
# Загрузка шаблонов
|
||||
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
|
||||
|
||||
router = APIRouter(tags=["misc"])
|
||||
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
|
||||
|
||||
|
||||
# Форматированная информация о приложении
|
||||
def get_info(app) -> Dict:
|
||||
"""Форматированная информация о приложении"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"app_info": {
|
||||
"title": app.title,
|
||||
"version": app.version,
|
||||
"description": app.description,
|
||||
"description": app.description.rsplit('|', 1)[0],
|
||||
},
|
||||
"server_time": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# Эндпоинт главной страницы
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def root(request: Request, app=Depends(get_app)):
|
||||
return templates.TemplateResponse(request, "index.html", get_info(app))
|
||||
async def root(request: Request):
|
||||
"""Эндпоинт главной страницы"""
|
||||
return templates.TemplateResponse(request, "index.html")
|
||||
|
||||
|
||||
@router.get("/authors", include_in_schema=False)
|
||||
async def authors(request: Request):
|
||||
"""Эндпоинт страницы выбора автора"""
|
||||
return templates.TemplateResponse(request, "authors.html")
|
||||
|
||||
|
||||
@router.get("/author/{author_id}", include_in_schema=False)
|
||||
async def author(request: Request, author_id: int):
|
||||
"""Эндпоинт страницы автора"""
|
||||
return templates.TemplateResponse(request, "author.html")
|
||||
|
||||
|
||||
@router.get("/books", include_in_schema=False)
|
||||
async def books(request: Request):
|
||||
"""Эндпоинт страницы выбора книг"""
|
||||
return templates.TemplateResponse(request, "books.html")
|
||||
|
||||
|
||||
@router.get("/book/{book_id}", include_in_schema=False)
|
||||
async def book(request: Request, book_id: int):
|
||||
"""Эндпоинт страницы книги"""
|
||||
return templates.TemplateResponse(request, "book.html")
|
||||
|
||||
|
||||
@router.get("/auth", include_in_schema=False)
|
||||
async def auth(request: Request):
|
||||
"""Эндпоинт страницы авторизации"""
|
||||
return templates.TemplateResponse(request, "auth.html")
|
||||
|
||||
|
||||
@router.get("/profile", include_in_schema=False)
|
||||
async def profile(request: Request):
|
||||
"""Эндпоинт страницы профиля"""
|
||||
return templates.TemplateResponse(request, "profile.html")
|
||||
|
||||
|
||||
@router.get("/api", include_in_schema=False)
|
||||
async def api(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Страница с сылками на документацию API"""
|
||||
return templates.TemplateResponse(request, "api.html", get_info(app))
|
||||
|
||||
|
||||
# Редирект иконки вкладки
|
||||
@router.get("/favicon.ico", include_in_schema=False)
|
||||
def redirect_favicon():
|
||||
"""Редирект иконки вкладки"""
|
||||
return RedirectResponse("/favicon.svg")
|
||||
|
||||
|
||||
# Эндпоинт иконки вкладки
|
||||
@router.get("/favicon.svg", include_in_schema=False)
|
||||
async def favicon():
|
||||
return FileResponse("library_service/favicon.svg", media_type="image/svg+xml")
|
||||
"""Эндпоинт иконки вкладки"""
|
||||
return FileResponse(
|
||||
"library_service/static/favicon.svg", media_type="image/svg+xml"
|
||||
)
|
||||
|
||||
|
||||
# Эндпоинт информации об API
|
||||
@router.get(
|
||||
"/api/info",
|
||||
summary="Информация о сервисе",
|
||||
description="Возвращает информацию о системе",
|
||||
description="Возвращает общую информацию о системе",
|
||||
)
|
||||
async def api_info(app=Depends(get_app)):
|
||||
async def api_info(app=Depends(lambda: get_app())):
|
||||
"""Эндпоинт информации об API"""
|
||||
return JSONResponse(content=get_info(app))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/stats",
|
||||
summary="Статистика сервиса",
|
||||
description="Возвращает статистическую информацию о системе",
|
||||
)
|
||||
async def api_stats(session: Session = Depends(get_session)):
|
||||
"""Эндпоинт стстистики системы"""
|
||||
authors = select(func.count()).select_from(Author)
|
||||
books = select(func.count()).select_from(Book)
|
||||
genres = select(func.count()).select_from(Genre)
|
||||
users = select(func.count()).select_from(User)
|
||||
return JSONResponse(content={
|
||||
"authors": session.exec(authors).one(),
|
||||
"books": session.exec(books).one(),
|
||||
"genres": session.exec(genres).one(),
|
||||
"users": session.exec(users).one(),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,82 @@
|
||||
"""Модуль работы со связями"""
|
||||
from typing import Dict, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
from typing import List, Dict
|
||||
|
||||
from library_service.settings import get_session
|
||||
from library_service.models.db import Author, Book, Genre, AuthorBookLink, GenreBookLink
|
||||
from library_service.auth import RequireAuth
|
||||
from library_service.models.db import Author, AuthorBookLink, Book, Genre, GenreBookLink
|
||||
from library_service.models.dto import AuthorRead, BookRead, GenreRead
|
||||
from library_service.settings import get_session
|
||||
|
||||
|
||||
router = APIRouter(tags=["relations"])
|
||||
|
||||
|
||||
# Add author to book
|
||||
def check_entity_exists(session, model, entity_id, entity_name):
|
||||
"""Проверка существования связи между сущностями в БД"""
|
||||
entity = session.get(model, entity_id)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"{entity_name} not found")
|
||||
return entity
|
||||
|
||||
|
||||
def add_relationship(session, link_model, id1, field1, id2, field2, detail):
|
||||
"""Создание связи между сущностями в БД"""
|
||||
existing_link = session.exec(
|
||||
select(link_model)
|
||||
.where(getattr(link_model, field1) == id1)
|
||||
.where(getattr(link_model, field2) == id2)
|
||||
).first()
|
||||
|
||||
if existing_link:
|
||||
raise HTTPException(status_code=400, detail=detail)
|
||||
|
||||
link = link_model(**{field1: id1, field2: id2})
|
||||
session.add(link)
|
||||
session.commit()
|
||||
session.refresh(link)
|
||||
return link
|
||||
|
||||
|
||||
def remove_relationship(session, link_model, id1, field1, id2, field2):
|
||||
"""Удаление связи между сущностями в БД"""
|
||||
link = session.exec(
|
||||
select(link_model)
|
||||
.where(getattr(link_model, field1) == id1)
|
||||
.where(getattr(link_model, field2) == id2)
|
||||
).first()
|
||||
|
||||
if not link:
|
||||
raise HTTPException(status_code=404, detail="Relationship not found")
|
||||
|
||||
session.delete(link)
|
||||
session.commit()
|
||||
return {"message": "Relationship removed successfully"}
|
||||
|
||||
|
||||
def get_related(
|
||||
session,
|
||||
main_model,
|
||||
main_id,
|
||||
main_name,
|
||||
related_model,
|
||||
link_model,
|
||||
link_main_field,
|
||||
link_related_field,
|
||||
read_model
|
||||
):
|
||||
"""Получение связанных в БД сущностей"""
|
||||
check_entity_exists(session, main_model, main_id, main_name)
|
||||
|
||||
related = session.exec(
|
||||
select(related_model).join(link_model)
|
||||
.where(getattr(link_model, link_main_field) == main_id)
|
||||
).all()
|
||||
|
||||
return [read_model(**obj.model_dump()) for obj in related]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/relationships/author-book",
|
||||
response_model=AuthorBookLink,
|
||||
@@ -17,33 +84,19 @@ router = APIRouter(tags=["relations"])
|
||||
description="Добавляет связь между автором и книгой в систему",
|
||||
)
|
||||
def add_author_to_book(
|
||||
author_id: int, book_id: int, session: Session = Depends(get_session)
|
||||
current_user: RequireAuth,
|
||||
author_id: int,
|
||||
book_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
author = session.get(Author, author_id)
|
||||
if not author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
"""Эндпоинт добавления автора к книге"""
|
||||
check_entity_exists(session, Author, author_id, "Author")
|
||||
check_entity_exists(session, Book, book_id, "Book")
|
||||
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
|
||||
existing_link = session.exec(
|
||||
select(AuthorBookLink)
|
||||
.where(AuthorBookLink.author_id == author_id)
|
||||
.where(AuthorBookLink.book_id == book_id)
|
||||
).first()
|
||||
|
||||
if existing_link:
|
||||
raise HTTPException(status_code=400, detail="Relationship already exists")
|
||||
|
||||
link = AuthorBookLink(author_id=author_id, book_id=book_id)
|
||||
session.add(link)
|
||||
session.commit()
|
||||
session.refresh(link)
|
||||
return link
|
||||
return add_relationship(session, AuthorBookLink,
|
||||
author_id, "author_id", book_id, "book_id", "Relationship already exists")
|
||||
|
||||
|
||||
# Remove author from book
|
||||
@router.delete(
|
||||
"/relationships/author-book",
|
||||
response_model=Dict[str, str],
|
||||
@@ -51,23 +104,16 @@ def add_author_to_book(
|
||||
description="Удаляет связь между автором и книгой в системе",
|
||||
)
|
||||
def remove_author_from_book(
|
||||
author_id: int, book_id: int, session: Session = Depends(get_session)
|
||||
current_user: RequireAuth,
|
||||
author_id: int,
|
||||
book_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
link = session.exec(
|
||||
select(AuthorBookLink)
|
||||
.where(AuthorBookLink.author_id == author_id)
|
||||
.where(AuthorBookLink.book_id == book_id)
|
||||
).first()
|
||||
|
||||
if not link:
|
||||
raise HTTPException(status_code=404, detail="Relationship not found")
|
||||
|
||||
session.delete(link)
|
||||
session.commit()
|
||||
return {"message": "Relationship removed successfully"}
|
||||
"""Эндпоинт удаления автора из книги"""
|
||||
return remove_relationship(session, AuthorBookLink,
|
||||
author_id, "author_id", book_id, "book_id")
|
||||
|
||||
|
||||
# Get author's books
|
||||
@router.get(
|
||||
"/authors/{author_id}/books/",
|
||||
response_model=List[BookRead],
|
||||
@@ -75,18 +121,12 @@ def remove_author_from_book(
|
||||
description="Возвращает все книги в системе, написанные автором",
|
||||
)
|
||||
def get_books_for_author(author_id: int, session: Session = Depends(get_session)):
|
||||
author = session.get(Author, author_id)
|
||||
if not author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
|
||||
books = session.exec(
|
||||
select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id)
|
||||
).all()
|
||||
|
||||
return [BookRead(**book.model_dump()) for book in books]
|
||||
"""Эндпоинт получения книг, написанных автором"""
|
||||
return get_related(session,
|
||||
Author, author_id, "Author", Book,
|
||||
AuthorBookLink, "author_id", "book_id", BookRead)
|
||||
|
||||
|
||||
# Get book's authors
|
||||
@router.get(
|
||||
"/books/{book_id}/authors/",
|
||||
response_model=List[AuthorRead],
|
||||
@@ -94,18 +134,12 @@ def get_books_for_author(author_id: int, session: Session = Depends(get_session)
|
||||
description="Возвращает всех авторов книги в системе",
|
||||
)
|
||||
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
|
||||
authors = session.exec(
|
||||
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id)
|
||||
).all()
|
||||
|
||||
return [AuthorRead(**author.model_dump()) for author in authors]
|
||||
"""Эндпоинт получения авторов книги"""
|
||||
return get_related(session,
|
||||
Book, book_id, "Book", Author,
|
||||
AuthorBookLink, "book_id", "author_id", AuthorRead)
|
||||
|
||||
|
||||
# Add genre to book
|
||||
@router.post(
|
||||
"/relationships/genre-book",
|
||||
response_model=GenreBookLink,
|
||||
@@ -113,33 +147,19 @@ def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
|
||||
description="Добавляет связь между книгой и жанром в систему",
|
||||
)
|
||||
def add_genre_to_book(
|
||||
genre_id: int, book_id: int, session: Session = Depends(get_session)
|
||||
current_user: RequireAuth,
|
||||
genre_id: int,
|
||||
book_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
genre = session.get(Genre, genre_id)
|
||||
if not genre:
|
||||
raise HTTPException(status_code=404, detail="Genre not found")
|
||||
"""Эндпоинт добавления жанра к книге"""
|
||||
check_entity_exists(session, Genre, genre_id, "Genre")
|
||||
check_entity_exists(session, Book, book_id, "Book")
|
||||
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
|
||||
existing_link = session.exec(
|
||||
select(GenreBookLink)
|
||||
.where(GenreBookLink.genre_id == genre_id)
|
||||
.where(GenreBookLink.book_id == book_id)
|
||||
).first()
|
||||
|
||||
if existing_link:
|
||||
raise HTTPException(status_code=400, detail="Relationship already exists")
|
||||
|
||||
link = GenreBookLink(genre_id=genre_id, book_id=book_id)
|
||||
session.add(link)
|
||||
session.commit()
|
||||
session.refresh(link)
|
||||
return link
|
||||
return add_relationship(session, GenreBookLink,
|
||||
genre_id, "genre_id", book_id, "book_id", "Relationship already exists")
|
||||
|
||||
|
||||
# Remove author from book
|
||||
@router.delete(
|
||||
"/relationships/genre-book",
|
||||
response_model=Dict[str, str],
|
||||
@@ -147,55 +167,37 @@ def add_genre_to_book(
|
||||
description="Удаляет связь между жанром и книгой в системе",
|
||||
)
|
||||
def remove_genre_from_book(
|
||||
genre_id: int, book_id: int, session: Session = Depends(get_session)
|
||||
current_user: RequireAuth,
|
||||
genre_id: int,
|
||||
book_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
link = session.exec(
|
||||
select(GenreBookLink)
|
||||
.where(GenreBookLink.genre_id == genre_id)
|
||||
.where(GenreBookLink.book_id == book_id)
|
||||
).first()
|
||||
|
||||
if not link:
|
||||
raise HTTPException(status_code=404, detail="Relationship not found")
|
||||
|
||||
session.delete(link)
|
||||
session.commit()
|
||||
return {"message": "Relationship removed successfully"}
|
||||
"""Эндпоинт удаления жанра из книги"""
|
||||
return remove_relationship(session, GenreBookLink,
|
||||
genre_id, "genre_id", book_id, "book_id")
|
||||
|
||||
|
||||
# Get genre's books
|
||||
@router.get(
|
||||
"/genres/{author_id}/books/",
|
||||
"/genres/{genre_id}/books/",
|
||||
response_model=List[BookRead],
|
||||
summary="Получить книги, написанные в жанре",
|
||||
description="Возвращает все книги в системе в этом жанре",
|
||||
)
|
||||
def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
|
||||
genre = session.get(Genre, genre_id)
|
||||
if not genre:
|
||||
raise HTTPException(status_code=404, detail="Genre not found")
|
||||
|
||||
books = session.exec(
|
||||
select(Book).join(GenreBookLink).where(GenreBookLink.author_id == genre_id)
|
||||
).all()
|
||||
|
||||
return [BookRead(**book.model_dump()) for book in books]
|
||||
"""Эндпоинт получения книг с жанром"""
|
||||
return get_related(session,
|
||||
Genre, genre_id, "Genre", Book,
|
||||
GenreBookLink, "genre_id", "book_id", BookRead)
|
||||
|
||||
|
||||
# Get book's genres
|
||||
@router.get(
|
||||
"/books/{book_id}/genres/",
|
||||
response_model=List[GenreRead],
|
||||
summary="Получить жанры книги",
|
||||
description="Возвращает все жанры книги в системе",
|
||||
)
|
||||
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
|
||||
genres = session.exec(
|
||||
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id)
|
||||
).all()
|
||||
|
||||
return [GenreRead(**author.model_dump()) for genre in genres]
|
||||
def get_genres_for_book(book_id: int, session: Session = Depends(get_session)):
|
||||
"""Эндпоинт получения жанров книги"""
|
||||
return get_related(session,
|
||||
Book, book_id, "Book", Genre,
|
||||
GenreBookLink, "book_id", "genre_id", GenreRead)
|
||||
|
||||
+51
-36
@@ -1,59 +1,74 @@
|
||||
import os
|
||||
"""Модуль настроек проекта"""
|
||||
import os, logging
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
from sqlmodel import create_engine, SQLModel, Session
|
||||
from sqlmodel import Session, create_engine
|
||||
from toml import load
|
||||
|
||||
load_dotenv()
|
||||
|
||||
with open("pyproject.toml") as f:
|
||||
with open("pyproject.toml", 'r', encoding='utf-8') as f:
|
||||
config = load(f)
|
||||
|
||||
|
||||
# Dependency to get the FastAPI application instance
|
||||
def get_app() -> FastAPI:
|
||||
return FastAPI(
|
||||
title=config["tool"]["poetry"]["name"],
|
||||
description=config["tool"]["poetry"]["description"],
|
||||
version=config["tool"]["poetry"]["version"],
|
||||
openapi_tags=[
|
||||
{
|
||||
"name": "authors",
|
||||
"description": "Operations with authors.",
|
||||
},
|
||||
{
|
||||
"name": "books",
|
||||
"description": "Operations with books.",
|
||||
},
|
||||
{
|
||||
"name": "genres",
|
||||
"description": "Operations with genres.",
|
||||
},
|
||||
{
|
||||
"name": "relations",
|
||||
"description": "Operations with relations.",
|
||||
},
|
||||
{
|
||||
"name": "misc",
|
||||
"description": "Miscellaneous operations.",
|
||||
},
|
||||
],
|
||||
)
|
||||
def get_app(lifespan=None, /) -> FastAPI:
|
||||
"""Dependency для получения экземпляра FastAPI application"""
|
||||
if not hasattr(get_app, 'instance'):
|
||||
get_app.instance = FastAPI(
|
||||
title=config["tool"]["poetry"]["name"],
|
||||
description=config["tool"]["poetry"]["description"] + " | [Вернутьсяна главную](/)",
|
||||
version=config["tool"]["poetry"]["version"],
|
||||
lifespan=lifespan,
|
||||
openapi_tags=[
|
||||
{
|
||||
"name": "authentication",
|
||||
"description": "Авторизация пользователя."
|
||||
},
|
||||
{
|
||||
"name": "authors",
|
||||
"description": "Действия с авторами.",
|
||||
},
|
||||
{
|
||||
"name": "books",
|
||||
"description": "Действия с книгами.",
|
||||
},
|
||||
{
|
||||
"name": "genres",
|
||||
"description": "Действия с жанрами.",
|
||||
},
|
||||
{
|
||||
"name": "relations",
|
||||
"description": "Действия с связями.",
|
||||
},
|
||||
{
|
||||
"name": "misc",
|
||||
"description": "Прочие.",
|
||||
},
|
||||
],
|
||||
)
|
||||
return get_app.instance
|
||||
|
||||
|
||||
HOST = os.getenv("POSTGRES_HOST")
|
||||
PORT = os.getenv("POSTGRES_PORT")
|
||||
USER = os.getenv("POSTGRES_USER")
|
||||
PASSWORD = os.getenv("POSTGRES_PASSWORD")
|
||||
DATABASE = os.getenv("POSTGRES_DB")
|
||||
HOST = os.getenv("POSTGRES_SERVER")
|
||||
|
||||
if not USER or not PASSWORD or not DATABASE or not HOST:
|
||||
raise ValueError("Missing environment variables")
|
||||
|
||||
POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:5432/{DATABASE}"
|
||||
engine = create_engine(POSTGRES_DATABASE_URL, echo=True, future=True)
|
||||
POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}"
|
||||
engine = create_engine(POSTGRES_DATABASE_URL, echo=False, future=True)
|
||||
|
||||
|
||||
# Dependency to get a database session
|
||||
def get_session():
|
||||
"""Dependency, для получение сессии БД"""
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
def get_logger(name: str = "uvicorn"):
|
||||
"""Dependency, для получение логгера"""
|
||||
return logging.getLogger(name)
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
$(function () {
|
||||
const $loginTab = $("#login-tab");
|
||||
const $registerTab = $("#register-tab");
|
||||
const $loginForm = $("#login-form");
|
||||
const $registerForm = $("#register-form");
|
||||
|
||||
const $guestLink = $("#guest-link");
|
||||
const $userBtn = $("#user-btn");
|
||||
const $userDropdown = $("#user-dropdown");
|
||||
const $userArrow = $("#user-arrow");
|
||||
const $userAvatar = $("#user-avatar");
|
||||
const $dropdownName = $("#dropdown-name");
|
||||
const $dropdownUsername = $("#dropdown-username");
|
||||
const $dropdownEmail = $("#dropdown-email");
|
||||
const $logoutBtn = $("#logout-btn");
|
||||
const $menuContainer = $("#user-menu-container");
|
||||
|
||||
function switchToLogin() {
|
||||
$loginTab.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").removeClass("text-gray-400");
|
||||
$registerTab.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").addClass("text-gray-400");
|
||||
$loginForm.removeClass("hidden"); $registerForm.addClass("hidden");
|
||||
history.replaceState(null, "", "/auth#login");
|
||||
}
|
||||
|
||||
function switchToRegister() {
|
||||
$registerTab.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").removeClass("text-gray-400");
|
||||
$loginTab.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").addClass("text-gray-400");
|
||||
$registerForm.removeClass("hidden"); $loginForm.addClass("hidden");
|
||||
history.replaceState(null, "", "/auth#register");
|
||||
}
|
||||
|
||||
$loginTab.on("click", switchToLogin);
|
||||
$registerTab.on("click", switchToRegister);
|
||||
|
||||
$("body").on("click", ".toggle-password", function () {
|
||||
const $btn = $(this);
|
||||
const $input = $btn.siblings("input");
|
||||
const $eyeOpen = $btn.find(".eye-open");
|
||||
const $eyeClosed = $btn.find(".eye-closed");
|
||||
|
||||
if ($input.attr("type") === "password") {
|
||||
$input.attr("type", "text");
|
||||
$eyeOpen.addClass("hidden");
|
||||
$eyeClosed.removeClass("hidden");
|
||||
} else {
|
||||
$input.attr("type", "password");
|
||||
$eyeOpen.removeClass("hidden");
|
||||
$eyeClosed.addClass("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
$("#register-password").on("input", function () {
|
||||
const password = $(this).val();
|
||||
let strength = 0;
|
||||
if (password.length >= 8) strength++;
|
||||
if (password.length >= 12) strength++;
|
||||
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
|
||||
if (/\d/.test(password)) strength++;
|
||||
if (/[^a-zA-Z0-9]/.test(password)) strength++;
|
||||
|
||||
const levels = [
|
||||
{ width: "0%", color: "", text: "" },
|
||||
{ width: "20%", color: "bg-red-500", text: "Очень слабый" },
|
||||
{ width: "40%", color: "bg-orange-500", text: "Слабый" },
|
||||
{ width: "60%", color: "bg-yellow-500", text: "Средний" },
|
||||
{ width: "80%", color: "bg-lime-500", text: "Хороший" },
|
||||
{ width: "100%", color: "bg-green-500", text: "Отличный" },
|
||||
];
|
||||
|
||||
const level = levels[strength];
|
||||
const $bar = $("#password-strength-bar");
|
||||
|
||||
$bar.css("width", level.width);
|
||||
$bar.attr("class", "h-full transition-all duration-300 " + level.color);
|
||||
$("#password-strength-text").text(level.text);
|
||||
|
||||
checkPasswordMatch();
|
||||
});
|
||||
|
||||
function checkPasswordMatch() {
|
||||
const password = $("#register-password").val();
|
||||
const confirm = $("#register-password-confirm").val();
|
||||
const $error = $("#password-match-error");
|
||||
|
||||
if (confirm && password !== confirm) {
|
||||
$error.removeClass("hidden");
|
||||
return false;
|
||||
} else {
|
||||
$error.addClass("hidden");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$("#register-password-confirm").on("input", checkPasswordMatch);
|
||||
|
||||
$loginForm.on("submit", async function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
const $errorDiv = $("#login-error");
|
||||
const $submitBtn = $("#login-submit");
|
||||
const username = $("#login-username").val();
|
||||
const password = $("#login-password").val();
|
||||
|
||||
$errorDiv.addClass("hidden");
|
||||
$submitBtn.prop("disabled", true).text("Вход...");
|
||||
|
||||
try {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append("username", username);
|
||||
formData.append("password", password);
|
||||
|
||||
const response = await fetch("/api/auth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: formData.toString(),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem("refresh_token", data.refresh_token);
|
||||
}
|
||||
window.location.href = "/";
|
||||
} else {
|
||||
$errorDiv.text(data.detail || "Неверное имя пользователя или пароль");
|
||||
$errorDiv.removeClass("hidden");
|
||||
$submitBtn.prop("disabled", false).text("Войти");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
$errorDiv.text("Ошибка соединения с сервером");
|
||||
$errorDiv.removeClass("hidden");
|
||||
$submitBtn.prop("disabled", false).text("Войти");
|
||||
}
|
||||
});
|
||||
|
||||
$registerForm.on("submit", async function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
const $errorDiv = $("#register-error");
|
||||
const $successDiv = $("#register-success");
|
||||
const $submitBtn = $("#register-submit");
|
||||
|
||||
if (!checkPasswordMatch()) {
|
||||
$errorDiv.text("Пароли не совпадают").removeClass("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = {
|
||||
username: $("#register-username").val(),
|
||||
email: $("#register-email").val(),
|
||||
full_name: $("#register-fullname").val() || null,
|
||||
password: $("#register-password").val(),
|
||||
};
|
||||
|
||||
$errorDiv.addClass("hidden");
|
||||
$successDiv.addClass("hidden");
|
||||
$submitBtn.prop("disabled", true).text("Регистрация...");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(userData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
$successDiv.text("Регистрация успешна! Переключаемся на вход...").removeClass("hidden");
|
||||
setTimeout(() => {
|
||||
$("#login-username").val(userData.username);
|
||||
switchToLogin();
|
||||
}, 2000);
|
||||
} else {
|
||||
let errorMessage = data.detail;
|
||||
if (Array.isArray(data.detail)) {
|
||||
errorMessage = data.detail.map((err) => err.msg).join(". ");
|
||||
}
|
||||
$errorDiv.text(errorMessage || "Ошибка регистрации").removeClass("hidden");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Register error:", error);
|
||||
$errorDiv.text("Ошибка соединения с сервером").removeClass("hidden");
|
||||
} finally {
|
||||
$submitBtn.prop("disabled", false).text("Зарегистрироваться");
|
||||
}
|
||||
});
|
||||
|
||||
let isDropdownOpen = false;
|
||||
|
||||
function openDropdown() {
|
||||
isDropdownOpen = true;
|
||||
$userDropdown.removeClass("hidden");
|
||||
$userArrow.addClass("rotate-180");
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
isDropdownOpen = false;
|
||||
$userDropdown.addClass("hidden");
|
||||
$userArrow.removeClass("rotate-180");
|
||||
}
|
||||
|
||||
$userBtn.on("click", function (e) {
|
||||
e.stopPropagation();
|
||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||
});
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("keydown", function (e) {
|
||||
if (e.key === "Escape" && isDropdownOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$logoutBtn.on("click", function () {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.href = "/";
|
||||
});
|
||||
|
||||
function showGuest() {
|
||||
$guestLink.removeClass("hidden");
|
||||
$userBtn.addClass("hidden").removeClass("flex");
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
function showUser(user) {
|
||||
$guestLink.addClass("hidden");
|
||||
$userBtn.removeClass("hidden").addClass("flex");
|
||||
|
||||
const displayName = user.full_name || user.username;
|
||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||
|
||||
$userAvatar.text(firstLetter);
|
||||
$dropdownName.text(displayName);
|
||||
$dropdownUsername.text("@" + user.username);
|
||||
$dropdownEmail.text(user.email);
|
||||
}
|
||||
|
||||
|
||||
function updateUserAvatar(email) {
|
||||
if (!email) return;
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const emailHash = sha256(cleanEmail);
|
||||
|
||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||
const avatarImg = document.getElementById('user-avatar');
|
||||
if (avatarImg) { avatarImg.src = avatarUrl; }
|
||||
}
|
||||
|
||||
if (window.location.hash === "#register") { switchToRegister(); }
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
|
||||
if (!token) {
|
||||
showGuest();
|
||||
} else {
|
||||
fetch("/api/auth/me", {
|
||||
headers: { Authorization: "Bearer " + token },
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) return response.json();
|
||||
throw new Error("Unauthorized");
|
||||
})
|
||||
.then((user) => {
|
||||
showUser(user);
|
||||
updateUserAvatar(user.email);
|
||||
|
||||
document.getElementById('user-btn').classList.remove('hidden');
|
||||
document.getElementById('guest-link').classList.add('hidden');
|
||||
if (window.location.pathname === "/auth") { window.location.href = "/"; }
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
showGuest();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
$(document).ready(() => {
|
||||
const pathParts = window.location.pathname.split("/");
|
||||
const authorId = pathParts[pathParts.length - 1];
|
||||
|
||||
if (!authorId || isNaN(authorId)) {
|
||||
showErrorState("Некорректный ID автора");
|
||||
return;
|
||||
}
|
||||
|
||||
loadAuthor(authorId);
|
||||
|
||||
function loadAuthor(id) {
|
||||
showLoadingState();
|
||||
|
||||
fetch(`/api/authors/${id}`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error("Автор не найден");
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((author) => {
|
||||
renderAuthor(author);
|
||||
renderBooks(author.books);
|
||||
document.title = `LiB - ${author.name}`;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error loading author:", error);
|
||||
showErrorState(error.message);
|
||||
});
|
||||
}
|
||||
|
||||
function renderAuthor(author) {
|
||||
const $card = $("#author-card");
|
||||
const firstLetter = author.name.charAt(0).toUpperCase();
|
||||
const booksCount = author.books ? author.books.length : 0;
|
||||
const booksWord = getWordForm(booksCount, ["книга", "книги", "книг"]);
|
||||
|
||||
$card.html(`
|
||||
<div class="flex items-start">
|
||||
<!-- Аватар -->
|
||||
<div class="w-24 h-24 bg-gray-500 text-white rounded-full flex items-center justify-center text-4xl font-bold mr-6 flex-shrink-0">
|
||||
${firstLetter}
|
||||
</div>
|
||||
|
||||
<!-- Информация -->
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h1 class="text-3xl font-bold text-gray-900">${escapeHtml(author.name)}</h1>
|
||||
<span class="text-sm text-gray-500">ID: ${author.id}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center text-gray-600 mb-4">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
<span>${booksCount} ${booksWord} в библиотеке</span>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка назад -->
|
||||
<a href="/authors" class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Вернуться к списку авторов
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function renderBooks(books) {
|
||||
const $container = $("#books-container");
|
||||
$container.empty();
|
||||
|
||||
if (!books || books.length === 0) {
|
||||
$container.html(`
|
||||
<div class="text-center py-8">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
<p class="text-gray-500">У этого автора пока нет книг в библиотеке</p>
|
||||
</div>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
const $grid = $('<div class="space-y-4"></div>');
|
||||
|
||||
books.forEach((book) => {
|
||||
const $bookCard = $(`
|
||||
<div class="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow duration-200 cursor-pointer book-card" data-id="${book.id}">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors mb-2">
|
||||
${escapeHtml(book.title)}
|
||||
</h3>
|
||||
<p class="text-gray-600 text-sm line-clamp-3">
|
||||
${escapeHtml(book.description || "Описание отсутствует")}
|
||||
</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-gray-400 ml-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$grid.append($bookCard);
|
||||
});
|
||||
|
||||
$container.append($grid);
|
||||
|
||||
$container.off("click", ".book-card").on("click", ".book-card", function () {
|
||||
const bookId = $(this).data("id");
|
||||
window.location.href = `/book/${bookId}`;
|
||||
});
|
||||
}
|
||||
|
||||
function showLoadingState() {
|
||||
const $authorCard = $("#author-card");
|
||||
const $booksContainer = $("#books-container");
|
||||
|
||||
$authorCard.html(`
|
||||
<div class="flex items-start animate-pulse">
|
||||
<div class="w-24 h-24 bg-gray-200 rounded-full mr-6"></div>
|
||||
<div class="flex-1">
|
||||
<div class="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/5"></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$booksContainer.html(`
|
||||
<div class="space-y-4">
|
||||
${Array(3)
|
||||
.fill()
|
||||
.map(
|
||||
() => `
|
||||
<div class="border border-gray-200 rounded-lg p-4 animate-pulse">
|
||||
<div class="h-5 bg-gray-200 rounded w-1/2 mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-full mb-1"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function showErrorState(message) {
|
||||
const $authorCard = $("#author-card");
|
||||
const $booksSection = $("#books-section");
|
||||
|
||||
$booksSection.hide();
|
||||
|
||||
$authorCard.html(`
|
||||
<div class="text-center py-8">
|
||||
<svg class="mx-auto h-16 w-16 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
<h3 class="text-xl font-medium text-gray-900 mb-2">${escapeHtml(message)}</h3>
|
||||
<p class="text-gray-500 mb-6">Не удалось загрузить информацию об авторе</p>
|
||||
<div class="flex justify-center gap-4">
|
||||
<button id="retry-btn" class="bg-gray-500 text-white px-6 py-2 rounded-lg hover:bg-gray-600 transition-colors">
|
||||
Попробовать снова
|
||||
</button>
|
||||
<a href="/authors" class="bg-white text-gray-700 px-6 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
|
||||
К списку авторов
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$("#retry-btn").on("click", function () {
|
||||
$booksSection.show();
|
||||
loadAuthor(authorId);
|
||||
});
|
||||
}
|
||||
|
||||
function getWordForm(number, forms) {
|
||||
const cases = [2, 0, 1, 1, 1, 2];
|
||||
const index =
|
||||
number % 100 > 4 && number % 100 < 20
|
||||
? 2
|
||||
: cases[Math.min(number % 10, 5)];
|
||||
return forms[index];
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
const $guestLink = $("#guest-link");
|
||||
const $userBtn = $("#user-btn");
|
||||
const $userDropdown = $("#user-dropdown");
|
||||
const $userArrow = $("#user-arrow");
|
||||
const $userAvatar = $("#user-avatar");
|
||||
const $dropdownName = $("#dropdown-name");
|
||||
const $dropdownUsername = $("#dropdown-username");
|
||||
const $dropdownEmail = $("#dropdown-email");
|
||||
const $logoutBtn = $("#logout-btn");
|
||||
|
||||
let isDropdownOpen = false;
|
||||
|
||||
function openDropdown() {
|
||||
isDropdownOpen = true;
|
||||
$userDropdown.removeClass("hidden");
|
||||
$userArrow.addClass("rotate-180");
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
isDropdownOpen = false;
|
||||
$userDropdown.addClass("hidden");
|
||||
$userArrow.removeClass("rotate-180");
|
||||
}
|
||||
|
||||
$userBtn.on("click", function (e) {
|
||||
e.stopPropagation();
|
||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||
});
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("keydown", function (e) {
|
||||
if (e.key === "Escape" && isDropdownOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$logoutBtn.on("click", function () {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
function showGuest() {
|
||||
$guestLink.removeClass("hidden");
|
||||
$userBtn.addClass("hidden").removeClass("flex");
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
function showUser(user) {
|
||||
$guestLink.addClass("hidden");
|
||||
$userBtn.removeClass("hidden").addClass("flex");
|
||||
|
||||
const displayName = user.full_name || user.username;
|
||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||
|
||||
$userAvatar.text(firstLetter);
|
||||
$dropdownName.text(displayName);
|
||||
$dropdownUsername.text("@" + user.username);
|
||||
$dropdownEmail.text(user.email);
|
||||
}
|
||||
|
||||
function updateUserAvatar(email) {
|
||||
if (!email) return;
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const emailHash = sha256(cleanEmail);
|
||||
|
||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||
const avatarImg = document.getElementById("user-avatar");
|
||||
if (avatarImg) {
|
||||
avatarImg.src = avatarUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
|
||||
if (!token) {
|
||||
showGuest();
|
||||
} else {
|
||||
fetch("/api/auth/me", {
|
||||
headers: { Authorization: "Bearer " + token },
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) return response.json();
|
||||
throw new Error("Unauthorized");
|
||||
})
|
||||
.then((user) => {
|
||||
showUser(user);
|
||||
updateUserAvatar(user.email);
|
||||
|
||||
document.getElementById("user-btn").classList.remove("hidden");
|
||||
document.getElementById("guest-link").classList.add("hidden");
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
showGuest();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
$(document).ready(() => {
|
||||
let allAuthors = [];
|
||||
let filteredAuthors = [];
|
||||
let currentPage = 1;
|
||||
let pageSize = 12;
|
||||
let currentSort = "name_asc";
|
||||
|
||||
loadAuthors();
|
||||
|
||||
function loadAuthors() {
|
||||
showLoadingState();
|
||||
|
||||
fetch("/api/authors")
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
allAuthors = data.authors;
|
||||
applyFiltersAndSort();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error loading authors:", error);
|
||||
showErrorState();
|
||||
});
|
||||
}
|
||||
|
||||
function applyFiltersAndSort() {
|
||||
const searchQuery = $("#author-search-input").val().trim().toLowerCase();
|
||||
|
||||
filteredAuthors = allAuthors.filter((author) =>
|
||||
author.name.toLowerCase().includes(searchQuery)
|
||||
);
|
||||
|
||||
filteredAuthors.sort((a, b) => {
|
||||
const nameA = a.name.toLowerCase();
|
||||
const nameB = b.name.toLowerCase();
|
||||
|
||||
if (currentSort === "name_asc") {
|
||||
return nameA.localeCompare(nameB, "ru");
|
||||
} else {
|
||||
return nameB.localeCompare(nameA, "ru");
|
||||
}
|
||||
});
|
||||
|
||||
updateResultsCounter();
|
||||
|
||||
renderAuthors();
|
||||
renderPagination();
|
||||
}
|
||||
|
||||
function updateResultsCounter() {
|
||||
const $counter = $("#results-counter");
|
||||
const total = filteredAuthors.length;
|
||||
|
||||
if (total === 0) {
|
||||
$counter.text("Авторы не найдены");
|
||||
} else {
|
||||
const wordForm = getWordForm(total, ["автор", "автора", "авторов"]);
|
||||
$counter.text(`Найдено: ${total} ${wordForm}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getWordForm(number, forms) {
|
||||
const cases = [2, 0, 1, 1, 1, 2];
|
||||
const index =
|
||||
number % 100 > 4 && number % 100 < 20
|
||||
? 2
|
||||
: cases[Math.min(number % 10, 5)];
|
||||
return forms[index];
|
||||
}
|
||||
|
||||
function renderAuthors() {
|
||||
const $container = $("#authors-container");
|
||||
$container.empty();
|
||||
|
||||
if (filteredAuthors.length === 0) {
|
||||
$container.html(`
|
||||
<div class="bg-white p-8 rounded-lg shadow-md text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Авторы не найдены</h3>
|
||||
<p class="text-gray-500">Попробуйте изменить параметры поиска</p>
|
||||
</div>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const pageAuthors = filteredAuthors.slice(startIndex, endIndex);
|
||||
|
||||
const $grid = $('<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"></div>');
|
||||
|
||||
pageAuthors.forEach((author) => {
|
||||
const firstLetter = author.name.charAt(0).toUpperCase();
|
||||
|
||||
const $authorCard = $(`
|
||||
<div class="bg-white p-4 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer author-card" data-id="${author.id}">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-gray-500 text-white rounded-full flex items-center justify-center text-xl font-bold mr-4">
|
||||
${firstLetter}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors">
|
||||
${escapeHtml(author.name)}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">ID: ${author.id}</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$grid.append($authorCard);
|
||||
});
|
||||
|
||||
$container.append($grid);
|
||||
|
||||
$container.off("click", ".author-card").on("click", ".author-card", function () {
|
||||
const authorId = $(this).data("id");
|
||||
window.location.href = `/author/${authorId}`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
const $paginationContainer = $("#pagination-container");
|
||||
$paginationContainer.empty();
|
||||
|
||||
const totalPages = Math.ceil(filteredAuthors.length / pageSize);
|
||||
|
||||
if (totalPages <= 1) return;
|
||||
|
||||
const $pagination = $(`
|
||||
<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" ${currentPage === 1 ? "disabled" : ""}>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<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" ${currentPage === totalPages ? "disabled" : ""}>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const $pageNumbers = $pagination.find("#page-numbers");
|
||||
|
||||
const pages = generatePageNumbers(currentPage, totalPages);
|
||||
|
||||
pages.forEach((page) => {
|
||||
if (page === "...") {
|
||||
$pageNumbers.append(`<span class="px-3 py-2">...</span>`);
|
||||
} else {
|
||||
const isActive = page === currentPage;
|
||||
$pageNumbers.append(`
|
||||
<button class="page-btn px-3 py-2 rounded-lg ${isActive ? "bg-gray-500 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">
|
||||
${page}
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
$paginationContainer.append($pagination);
|
||||
|
||||
$paginationContainer.find("#prev-page").on("click", function () {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
renderAuthors();
|
||||
renderPagination();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
|
||||
$paginationContainer.find("#next-page").on("click", function () {
|
||||
if (currentPage < totalPages) {
|
||||
currentPage++;
|
||||
renderAuthors();
|
||||
renderPagination();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
|
||||
$paginationContainer.find(".page-btn").on("click", function () {
|
||||
const page = parseInt($(this).data("page"));
|
||||
if (page !== currentPage) {
|
||||
currentPage = page;
|
||||
renderAuthors();
|
||||
renderPagination();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function generatePageNumbers(current, total) {
|
||||
const pages = [];
|
||||
const delta = 2;
|
||||
|
||||
for (let i = 1; i <= total; i++) {
|
||||
if (
|
||||
i === 1 ||
|
||||
i === total ||
|
||||
(i >= current - delta && i <= current + delta)
|
||||
) {
|
||||
pages.push(i);
|
||||
} else if (pages[pages.length - 1] !== "...") {
|
||||
pages.push("...");
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
function scrollToTop() {
|
||||
$("html, body").animate({ scrollTop: 0 }, 300);
|
||||
}
|
||||
|
||||
function showLoadingState() {
|
||||
const $container = $("#authors-container");
|
||||
$container.html(`
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
${Array(6)
|
||||
.fill()
|
||||
.map(
|
||||
() => `
|
||||
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-gray-200 rounded-full mr-4"></div>
|
||||
<div class="flex-1">
|
||||
<div class="h-5 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function showErrorState() {
|
||||
const $container = $("#authors-container");
|
||||
$container.html(`
|
||||
<div class="bg-red-50 p-8 rounded-lg shadow-md text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-red-900 mb-2">Ошибка загрузки</h3>
|
||||
<p class="text-red-700 mb-4">Не удалось загрузить список авторов</p>
|
||||
<button id="retry-btn" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">
|
||||
Попробовать снова
|
||||
</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$("#retry-btn").on("click", loadAuthors);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function initializeFilters() {
|
||||
const $authorSearch = $("#author-search-input");
|
||||
const $resetBtn = $("#reset-filters-btn");
|
||||
const $sortRadios = $('input[name="sort"]');
|
||||
|
||||
let searchTimeout;
|
||||
$authorSearch.on("input", function () {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage = 1;
|
||||
applyFiltersAndSort();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
$authorSearch.on("keypress", function (e) {
|
||||
if (e.which === 13) {
|
||||
clearTimeout(searchTimeout);
|
||||
currentPage = 1;
|
||||
applyFiltersAndSort();
|
||||
}
|
||||
});
|
||||
|
||||
$sortRadios.on("change", function () {
|
||||
currentSort = $(this).val();
|
||||
currentPage = 1;
|
||||
applyFiltersAndSort();
|
||||
});
|
||||
|
||||
$resetBtn.on("click", function () {
|
||||
$authorSearch.val("");
|
||||
$('input[name="sort"][value="name_asc"]').prop("checked", true);
|
||||
currentSort = "name_asc";
|
||||
currentPage = 1;
|
||||
applyFiltersAndSort();
|
||||
});
|
||||
}
|
||||
|
||||
initializeFilters();
|
||||
|
||||
const $guestLink = $("#guest-link");
|
||||
const $userBtn = $("#user-btn");
|
||||
const $userDropdown = $("#user-dropdown");
|
||||
const $userArrow = $("#user-arrow");
|
||||
const $userAvatar = $("#user-avatar");
|
||||
const $dropdownName = $("#dropdown-name");
|
||||
const $dropdownUsername = $("#dropdown-username");
|
||||
const $dropdownEmail = $("#dropdown-email");
|
||||
const $logoutBtn = $("#logout-btn");
|
||||
|
||||
let isDropdownOpen = false;
|
||||
|
||||
function openDropdown() {
|
||||
isDropdownOpen = true;
|
||||
$userDropdown.removeClass("hidden");
|
||||
$userArrow.addClass("rotate-180");
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
isDropdownOpen = false;
|
||||
$userDropdown.addClass("hidden");
|
||||
$userArrow.removeClass("rotate-180");
|
||||
}
|
||||
|
||||
$userBtn.on("click", function (e) {
|
||||
e.stopPropagation();
|
||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||
});
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("keydown", function (e) {
|
||||
if (e.key === "Escape" && isDropdownOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$logoutBtn.on("click", function () {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
function showGuest() {
|
||||
$guestLink.removeClass("hidden");
|
||||
$userBtn.addClass("hidden").removeClass("flex");
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
function showUser(user) {
|
||||
$guestLink.addClass("hidden");
|
||||
$userBtn.removeClass("hidden").addClass("flex");
|
||||
|
||||
const displayName = user.full_name || user.username;
|
||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||
|
||||
$userAvatar.text(firstLetter);
|
||||
$dropdownName.text(displayName);
|
||||
$dropdownUsername.text("@" + user.username);
|
||||
$dropdownEmail.text(user.email);
|
||||
}
|
||||
|
||||
function updateUserAvatar(email) {
|
||||
if (!email) return;
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const emailHash = sha256(cleanEmail);
|
||||
|
||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||
const avatarImg = document.getElementById("user-avatar");
|
||||
if (avatarImg) {
|
||||
avatarImg.src = avatarUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
|
||||
if (!token) {
|
||||
showGuest();
|
||||
} else {
|
||||
fetch("/api/auth/me", {
|
||||
headers: { Authorization: "Bearer " + token },
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) return response.json();
|
||||
throw new Error("Unauthorized");
|
||||
})
|
||||
.then((user) => {
|
||||
showUser(user);
|
||||
updateUserAvatar(user.email);
|
||||
|
||||
document.getElementById("user-btn").classList.remove("hidden");
|
||||
document.getElementById("guest-link").classList.add("hidden");
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
showGuest();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg
|
||||
width="800px"
|
||||
height="800px"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M0.877014 7.49988C0.877014 3.84219 3.84216 0.877045 7.49985 0.877045C11.1575 0.877045 14.1227 3.84219 14.1227 7.49988C14.1227 11.1575 11.1575 14.1227 7.49985 14.1227C3.84216 14.1227 0.877014 11.1575 0.877014 7.49988ZM7.49985 1.82704C4.36683 1.82704 1.82701 4.36686 1.82701 7.49988C1.82701 8.97196 2.38774 10.3131 3.30727 11.3213C4.19074 9.94119 5.73818 9.02499 7.50023 9.02499C9.26206 9.02499 10.8093 9.94097 11.6929 11.3208C12.6121 10.3127 13.1727 8.97172 13.1727 7.49988C13.1727 4.36686 10.6328 1.82704 7.49985 1.82704ZM10.9818 11.9787C10.2839 10.7795 8.9857 9.97499 7.50023 9.97499C6.01458 9.97499 4.71624 10.7797 4.01845 11.9791C4.97952 12.7272 6.18765 13.1727 7.49985 13.1727C8.81227 13.1727 10.0206 12.727 10.9818 11.9787ZM5.14999 6.50487C5.14999 5.207 6.20212 4.15487 7.49999 4.15487C8.79786 4.15487 9.84999 5.207 9.84999 6.50487C9.84999 7.80274 8.79786 8.85487 7.49999 8.85487C6.20212 8.85487 5.14999 7.80274 5.14999 6.50487ZM7.49999 5.10487C6.72679 5.10487 6.09999 5.73167 6.09999 6.50487C6.09999 7.27807 6.72679 7.90487 7.49999 7.90487C8.27319 7.90487 8.89999 7.27807 8.89999 6.50487C8.89999 5.73167 8.27319 5.10487 7.49999 5.10487Z"
|
||||
fill="#000000"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,321 @@
|
||||
$(document).ready(() => {
|
||||
const pathParts = window.location.pathname.split("/");
|
||||
const bookId = pathParts[pathParts.length - 1];
|
||||
|
||||
if (!bookId || isNaN(bookId)) {
|
||||
showErrorState("Некорректный ID книги");
|
||||
return;
|
||||
}
|
||||
|
||||
loadBook(bookId);
|
||||
|
||||
function loadBook(id) {
|
||||
showLoadingState();
|
||||
|
||||
fetch(`/api/books/${id}`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error("Книга не найдена");
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((book) => {
|
||||
renderBook(book);
|
||||
renderAuthors(book.authors);
|
||||
renderGenres(book.genres);
|
||||
document.title = `LiB - ${book.title}`;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error loading book:", error);
|
||||
showErrorState(error.message);
|
||||
});
|
||||
}
|
||||
|
||||
function renderBook(book) {
|
||||
const $card = $("#book-card");
|
||||
const authorsText = book.authors.map((a) => a.name).join(", ") || "Автор неизвестен";
|
||||
|
||||
$card.html(`
|
||||
<div class="flex flex-col md:flex-row items-start">
|
||||
<!-- Иконка книги -->
|
||||
<div class="w-32 h-40 bg-gradient-to-br from-gray-400 to-gray-600 rounded-lg flex items-center justify-center mb-4 md:mb-0 md:mr-6 flex-shrink-0 shadow-md">
|
||||
<svg class="w-16 h-16 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Информация о книге -->
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h1 class="text-3xl font-bold text-gray-900">${escapeHtml(book.title)}</h1>
|
||||
<span class="text-sm text-gray-500 ml-4">ID: ${book.id}</span>
|
||||
</div>
|
||||
|
||||
<p class="text-lg text-gray-600 mb-4">
|
||||
${escapeHtml(authorsText)}
|
||||
</p>
|
||||
|
||||
<div class="prose prose-gray max-w-none mb-6">
|
||||
<p class="text-gray-700 leading-relaxed">
|
||||
${escapeHtml(book.description || "Описание отсутствует")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка назад -->
|
||||
<a href="/books" class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Вернуться к списку книг
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function renderAuthors(authors) {
|
||||
const $container = $("#authors-container");
|
||||
const $section = $("#authors-section");
|
||||
$container.empty();
|
||||
|
||||
if (!authors || authors.length === 0) {
|
||||
$section.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const $grid = $('<div class="flex flex-wrap gap-3"></div>');
|
||||
|
||||
authors.forEach((author) => {
|
||||
const firstLetter = author.name.charAt(0).toUpperCase();
|
||||
|
||||
const $authorCard = $(`
|
||||
<a href="/author/${author.id}" class="flex items-center bg-gray-50 hover:bg-gray-100 rounded-lg p-3 transition-colors duration-200 border border-gray-200">
|
||||
<div class="w-10 h-10 bg-gray-500 text-white rounded-full flex items-center justify-center text-lg font-bold mr-3">
|
||||
${firstLetter}
|
||||
</div>
|
||||
<span class="text-gray-900 font-medium">${escapeHtml(author.name)}</span>
|
||||
<svg class="w-4 h-4 text-gray-400 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
`);
|
||||
|
||||
$grid.append($authorCard);
|
||||
});
|
||||
|
||||
$container.append($grid);
|
||||
}
|
||||
|
||||
function renderGenres(genres) {
|
||||
const $container = $("#genres-container");
|
||||
const $section = $("#genres-section");
|
||||
$container.empty();
|
||||
|
||||
if (!genres || genres.length === 0) {
|
||||
$section.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const $grid = $('<div class="flex flex-wrap gap-2"></div>');
|
||||
|
||||
genres.forEach((genre) => {
|
||||
const $genreTag = $(`
|
||||
<a href="/books?genre_id=${genre.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-full transition-colors duration-200">
|
||||
<svg class="w-4 h-4 mr-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/>
|
||||
</svg>
|
||||
${escapeHtml(genre.name)}
|
||||
</a>
|
||||
`);
|
||||
|
||||
$grid.append($genreTag);
|
||||
});
|
||||
|
||||
$container.append($grid);
|
||||
}
|
||||
|
||||
function showLoadingState() {
|
||||
const $bookCard = $("#book-card");
|
||||
const $authorsContainer = $("#authors-container");
|
||||
const $genresContainer = $("#genres-container");
|
||||
|
||||
$bookCard.html(`
|
||||
<div class="flex flex-col md:flex-row items-start animate-pulse">
|
||||
<div class="w-32 h-40 bg-gray-200 rounded-lg mb-4 md:mb-0 md:mr-6"></div>
|
||||
<div class="flex-1">
|
||||
<div class="h-8 bg-gray-200 rounded w-2/3 mb-4"></div>
|
||||
<div class="h-5 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div class="space-y-2 mb-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$authorsContainer.html(`
|
||||
<div class="flex gap-3 animate-pulse">
|
||||
<div class="flex items-center bg-gray-100 rounded-lg p-3">
|
||||
<div class="w-10 h-10 bg-gray-200 rounded-full mr-3"></div>
|
||||
<div class="h-5 bg-gray-200 rounded w-24"></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$genresContainer.html(`
|
||||
<div class="flex gap-2 animate-pulse">
|
||||
<div class="h-10 bg-gray-200 rounded-full w-24"></div>
|
||||
<div class="h-10 bg-gray-200 rounded-full w-32"></div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function showErrorState(message) {
|
||||
const $bookCard = $("#book-card");
|
||||
const $authorsSection = $("#authors-section");
|
||||
const $genresSection = $("#genres-section");
|
||||
|
||||
$authorsSection.hide();
|
||||
$genresSection.hide();
|
||||
|
||||
$bookCard.html(`
|
||||
<div class="text-center py-8">
|
||||
<svg class="mx-auto h-16 w-16 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
<h3 class="text-xl font-medium text-gray-900 mb-2">${escapeHtml(message)}</h3>
|
||||
<p class="text-gray-500 mb-6">Не удалось загрузить информацию о книге</p>
|
||||
<div class="flex justify-center gap-4">
|
||||
<button id="retry-btn" class="bg-gray-500 text-white px-6 py-2 rounded-lg hover:bg-gray-600 transition-colors">
|
||||
Попробовать снова
|
||||
</button>
|
||||
<a href="/books" class="bg-white text-gray-700 px-6 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
|
||||
К списку книг
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$("#retry-btn").on("click", function () {
|
||||
$authorsSection.show();
|
||||
$genresSection.show();
|
||||
loadBook(bookId);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
const $guestLink = $("#guest-link");
|
||||
const $userBtn = $("#user-btn");
|
||||
const $userDropdown = $("#user-dropdown");
|
||||
const $userArrow = $("#user-arrow");
|
||||
const $userAvatar = $("#user-avatar");
|
||||
const $dropdownName = $("#dropdown-name");
|
||||
const $dropdownUsername = $("#dropdown-username");
|
||||
const $dropdownEmail = $("#dropdown-email");
|
||||
const $logoutBtn = $("#logout-btn");
|
||||
|
||||
let isDropdownOpen = false;
|
||||
|
||||
function openDropdown() {
|
||||
isDropdownOpen = true;
|
||||
$userDropdown.removeClass("hidden");
|
||||
$userArrow.addClass("rotate-180");
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
isDropdownOpen = false;
|
||||
$userDropdown.addClass("hidden");
|
||||
$userArrow.removeClass("rotate-180");
|
||||
}
|
||||
|
||||
$userBtn.on("click", function (e) {
|
||||
e.stopPropagation();
|
||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||
});
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("keydown", function (e) {
|
||||
if (e.key === "Escape" && isDropdownOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$logoutBtn.on("click", function () {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
function showGuest() {
|
||||
$guestLink.removeClass("hidden");
|
||||
$userBtn.addClass("hidden").removeClass("flex");
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
function showUser(user) {
|
||||
$guestLink.addClass("hidden");
|
||||
$userBtn.removeClass("hidden").addClass("flex");
|
||||
|
||||
const displayName = user.full_name || user.username;
|
||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||
|
||||
$userAvatar.text(firstLetter);
|
||||
$dropdownName.text(displayName);
|
||||
$dropdownUsername.text("@" + user.username);
|
||||
$dropdownEmail.text(user.email);
|
||||
}
|
||||
|
||||
function updateUserAvatar(email) {
|
||||
if (!email) return;
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const emailHash = sha256(cleanEmail);
|
||||
|
||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||
const avatarImg = document.getElementById("user-avatar");
|
||||
if (avatarImg) {
|
||||
avatarImg.src = avatarUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
|
||||
if (!token) {
|
||||
showGuest();
|
||||
} else {
|
||||
fetch("/api/auth/me", {
|
||||
headers: { Authorization: "Bearer " + token },
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) return response.json();
|
||||
throw new Error("Unauthorized");
|
||||
})
|
||||
.then((user) => {
|
||||
showUser(user);
|
||||
updateUserAvatar(user.email);
|
||||
|
||||
document.getElementById("user-btn").classList.remove("hidden");
|
||||
document.getElementById("guest-link").classList.add("hidden");
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
showGuest();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,534 @@
|
||||
$(document).ready(() => {
|
||||
let selectedAuthors = new Map();
|
||||
let selectedGenres = new Map();
|
||||
let currentPage = 1;
|
||||
let pageSize = 20;
|
||||
let totalBooks = 0;
|
||||
|
||||
Promise.all([
|
||||
fetch("/api/authors").then((response) => response.json()),
|
||||
fetch("/api/genres").then((response) => response.json()),
|
||||
])
|
||||
.then(([authorsData, genresData]) => {
|
||||
const $dropdown = $("#author-dropdown");
|
||||
authorsData.authors.forEach((author) => {
|
||||
$("<div>")
|
||||
.addClass("p-2 hover:bg-gray-100 cursor-pointer author-item")
|
||||
.attr("data-id", author.id)
|
||||
.attr("data-name", author.name)
|
||||
.text(author.name)
|
||||
.appendTo($dropdown);
|
||||
});
|
||||
|
||||
const $list = $("#genres-list");
|
||||
genresData.genres.forEach((genre) => {
|
||||
$("<li>")
|
||||
.addClass("mb-1")
|
||||
.html(
|
||||
`<label class="custom-checkbox flex items-center">
|
||||
<input type="checkbox" data-id="${genre.id}" data-name="${genre.name}" />
|
||||
<span class="checkmark"></span>
|
||||
${genre.name}
|
||||
</label>`,
|
||||
)
|
||||
.appendTo($list);
|
||||
});
|
||||
|
||||
initializeAuthorDropdown();
|
||||
initializeFilters();
|
||||
|
||||
loadBooks();
|
||||
})
|
||||
.catch((error) => console.error("Error loading data:", error));
|
||||
|
||||
function loadBooks() {
|
||||
const searchQuery = $("#book-search-input").val().trim();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (searchQuery.length >= 3) {
|
||||
params.append("q", searchQuery);
|
||||
}
|
||||
|
||||
selectedAuthors.forEach((name, id) => {
|
||||
params.append("author_ids", id);
|
||||
});
|
||||
|
||||
selectedGenres.forEach((name, id) => {
|
||||
params.append("genre_ids", id);
|
||||
});
|
||||
|
||||
params.append("page", currentPage);
|
||||
params.append("size", pageSize);
|
||||
|
||||
const url = `/api/books/filter?${params.toString()}`;
|
||||
|
||||
showLoadingState();
|
||||
|
||||
fetch(url)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
totalBooks = data.total;
|
||||
renderBooks(data.books);
|
||||
renderPagination();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error loading books:", error);
|
||||
showErrorState();
|
||||
});
|
||||
}
|
||||
|
||||
function renderBooks(books) {
|
||||
const $container = $("#books-container");
|
||||
$container.empty();
|
||||
|
||||
if (books.length === 0) {
|
||||
$container.html(`
|
||||
<div class="bg-white p-8 rounded-lg shadow-md text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Книги не найдены</h3>
|
||||
<p class="text-gray-500">Попробуйте изменить параметры поиска или фильтры</p>
|
||||
</div>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
books.forEach((book) => {
|
||||
const authorsText =
|
||||
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен";
|
||||
const genresText =
|
||||
book.genres.map((g) => g.name).join(", ") || "Без жанра";
|
||||
|
||||
const $bookCard = $(`
|
||||
<div class="bg-white p-4 rounded-lg shadow-md mb-4 hover:shadow-lg transition-shadow duration-200 cursor-pointer book-card" data-id="${book.id}">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-bold mb-1 text-gray-900 hover:text-blue-600 transition-colors">
|
||||
${escapeHtml(book.title)}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mb-2">
|
||||
<span class="font-medium">Авторы:</span> ${escapeHtml(authorsText)}
|
||||
</p>
|
||||
<p class="text-gray-700 text-sm mb-2">
|
||||
${escapeHtml(book.description || "Описание отсутствует")}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
${book.genres
|
||||
.map(
|
||||
(g) => `
|
||||
<span class="inline-block bg-gray-100 text-gray-600 text-xs px-2 py-1 rounded-full">
|
||||
${escapeHtml(g.name)}
|
||||
</span>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$container.append($bookCard);
|
||||
});
|
||||
|
||||
$container.on("click", ".book-card", function () {
|
||||
const bookId = $(this).data("id");
|
||||
window.location.href = `/book/${bookId}`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
$("#pagination-container").remove();
|
||||
|
||||
const totalPages = Math.ceil(totalBooks / pageSize);
|
||||
|
||||
if (totalPages <= 1) return;
|
||||
|
||||
const $pagination = $(`
|
||||
<div id="pagination-container" 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" ${currentPage === 1 ? "disabled" : ""}>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<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" ${currentPage === totalPages ? "disabled" : ""}>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const $pageNumbers = $pagination.find("#page-numbers");
|
||||
|
||||
const pages = generatePageNumbers(currentPage, totalPages);
|
||||
|
||||
pages.forEach((page) => {
|
||||
if (page === "...") {
|
||||
$pageNumbers.append(`<span class="px-3 py-2">...</span>`);
|
||||
} else {
|
||||
const isActive = page === currentPage;
|
||||
$pageNumbers.append(`
|
||||
<button class="page-btn px-3 py-2 rounded-lg ${isActive ? "bg-gray-500 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">
|
||||
${page}
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
$("#books-container").after($pagination);
|
||||
|
||||
$("#prev-page").on("click", function () {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
loadBooks();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
|
||||
$("#next-page").on("click", function () {
|
||||
if (currentPage < totalPages) {
|
||||
currentPage++;
|
||||
loadBooks();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
|
||||
$(".page-btn").on("click", function () {
|
||||
const page = parseInt($(this).data("page"));
|
||||
if (page !== currentPage) {
|
||||
currentPage = page;
|
||||
loadBooks();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function generatePageNumbers(current, total) {
|
||||
const pages = [];
|
||||
const delta = 2;
|
||||
|
||||
for (let i = 1; i <= total; i++) {
|
||||
if (
|
||||
i === 1 ||
|
||||
i === total ||
|
||||
(i >= current - delta && i <= current + delta)
|
||||
) {
|
||||
pages.push(i);
|
||||
} else if (pages[pages.length - 1] !== "...") {
|
||||
pages.push("...");
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
function scrollToTop() {
|
||||
$("html, body").animate({ scrollTop: 0 }, 300);
|
||||
}
|
||||
|
||||
function showLoadingState() {
|
||||
const $container = $("#books-container");
|
||||
$container.html(`
|
||||
<div class="space-y-4">
|
||||
${Array(3)
|
||||
.fill()
|
||||
.map(
|
||||
() => `
|
||||
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
|
||||
<div class="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-full mb-2"></div>
|
||||
<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-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function showErrorState() {
|
||||
const $container = $("#books-container");
|
||||
$container.html(`
|
||||
<div class="bg-red-50 p-8 rounded-lg shadow-md text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-red-900 mb-2">Ошибка загрузки</h3>
|
||||
<p class="text-red-700 mb-4">Не удалось загрузить список книг</p>
|
||||
<button id="retry-btn" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">
|
||||
Попробовать снова
|
||||
</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$("#retry-btn").on("click", loadBooks);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function initializeAuthorDropdown() {
|
||||
const $input = $("#author-search-input");
|
||||
const $dropdown = $("#author-dropdown");
|
||||
const $container = $("#selected-authors-container");
|
||||
|
||||
function updateHighlights() {
|
||||
$dropdown.find(".author-item").each(function () {
|
||||
const id = $(this).attr("data-id");
|
||||
const isSelected = selectedAuthors.has(parseInt(id));
|
||||
$(this)
|
||||
.toggleClass("bg-gray-300 text-gray-600", isSelected)
|
||||
.toggleClass("hover:bg-gray-100", !isSelected);
|
||||
});
|
||||
}
|
||||
|
||||
function filterDropdown(query) {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
$dropdown.find(".author-item").each(function () {
|
||||
$(this).toggle($(this).text().toLowerCase().includes(lowerQuery));
|
||||
});
|
||||
}
|
||||
|
||||
function renderChips() {
|
||||
$container.find(".author-chip").remove();
|
||||
selectedAuthors.forEach((name, id) => {
|
||||
$(`<span class="author-chip flex items-center bg-gray-500 text-white text-sm font-medium px-2.5 py-0.5 rounded-full">
|
||||
${escapeHtml(name)}
|
||||
<button type="button" class="remove-author ml-1.5 inline-flex items-center p-0.5 text-gray-200 hover:text-white hover:bg-gray-600 rounded-full" data-id="${id}">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 14 14">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>`).insertBefore($input);
|
||||
});
|
||||
updateHighlights();
|
||||
}
|
||||
|
||||
function toggleAuthor(id, name) {
|
||||
id = parseInt(id);
|
||||
if (selectedAuthors.has(id)) {
|
||||
selectedAuthors.delete(id);
|
||||
} else {
|
||||
selectedAuthors.add(id, name);
|
||||
selectedAuthors.set(id, name);
|
||||
}
|
||||
$input.val("");
|
||||
filterDropdown("");
|
||||
renderChips();
|
||||
}
|
||||
|
||||
$input.on("focus", () => $dropdown.removeClass("hidden"));
|
||||
|
||||
$input.on("input", function () {
|
||||
filterDropdown($(this).val().toLowerCase());
|
||||
$dropdown.removeClass("hidden");
|
||||
});
|
||||
|
||||
$(document).on("click", (e) => {
|
||||
if (
|
||||
!$(e.target).closest("#selected-authors-container, #author-dropdown")
|
||||
.length
|
||||
) {
|
||||
$dropdown.addClass("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
$dropdown.on("click", ".author-item", function (e) {
|
||||
e.stopPropagation();
|
||||
toggleAuthor($(this).attr("data-id"), $(this).attr("data-name"));
|
||||
$input.focus();
|
||||
});
|
||||
|
||||
$container.on("click", ".remove-author", function (e) {
|
||||
e.stopPropagation();
|
||||
selectedAuthors.delete(parseInt($(this).attr("data-id")));
|
||||
renderChips();
|
||||
$input.focus();
|
||||
});
|
||||
|
||||
$container.on("click", (e) => {
|
||||
if (!$(e.target).closest(".author-chip").length) {
|
||||
$input.focus();
|
||||
}
|
||||
});
|
||||
|
||||
window.renderAuthorChips = renderChips;
|
||||
window.updateAuthorHighlights = updateHighlights;
|
||||
}
|
||||
|
||||
function initializeFilters() {
|
||||
const $bookSearch = $("#book-search-input");
|
||||
const $applyBtn = $("#apply-filters-btn");
|
||||
const $resetBtn = $("#reset-filters-btn");
|
||||
|
||||
$("#genres-list").on("change", "input[type='checkbox']", function () {
|
||||
const id = parseInt($(this).attr("data-id"));
|
||||
const name = $(this).attr("data-name");
|
||||
if ($(this).is(":checked")) {
|
||||
selectedGenres.set(id, name);
|
||||
} else {
|
||||
selectedGenres.delete(id);
|
||||
}
|
||||
});
|
||||
|
||||
$applyBtn.on("click", function () {
|
||||
currentPage = 1;
|
||||
loadBooks();
|
||||
});
|
||||
|
||||
$resetBtn.on("click", function () {
|
||||
$bookSearch.val("");
|
||||
|
||||
selectedAuthors.clear();
|
||||
$("#selected-authors-container .author-chip").remove();
|
||||
if (window.updateAuthorHighlights) window.updateAuthorHighlights();
|
||||
|
||||
selectedGenres.clear();
|
||||
$("#genres-list input[type='checkbox']").prop("checked", false);
|
||||
|
||||
currentPage = 1;
|
||||
loadBooks();
|
||||
});
|
||||
|
||||
let searchTimeout;
|
||||
$bookSearch.on("input", function () {
|
||||
clearTimeout(searchTimeout);
|
||||
const query = $(this).val().trim();
|
||||
|
||||
if (query.length >= 3 || query.length === 0) {
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage = 1;
|
||||
loadBooks();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
$bookSearch.on("keypress", function (e) {
|
||||
if (e.which === 13) {
|
||||
clearTimeout(searchTimeout);
|
||||
currentPage = 1;
|
||||
loadBooks();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const $guestLink = $("#guest-link");
|
||||
const $userBtn = $("#user-btn");
|
||||
const $userDropdown = $("#user-dropdown");
|
||||
const $userArrow = $("#user-arrow");
|
||||
const $userAvatar = $("#user-avatar");
|
||||
const $dropdownName = $("#dropdown-name");
|
||||
const $dropdownUsername = $("#dropdown-username");
|
||||
const $dropdownEmail = $("#dropdown-email");
|
||||
const $logoutBtn = $("#logout-btn");
|
||||
|
||||
let isDropdownOpen = false;
|
||||
|
||||
function openDropdown() {
|
||||
isDropdownOpen = true;
|
||||
$userDropdown.removeClass("hidden");
|
||||
$userArrow.addClass("rotate-180");
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
isDropdownOpen = false;
|
||||
$userDropdown.addClass("hidden");
|
||||
$userArrow.removeClass("rotate-180");
|
||||
}
|
||||
|
||||
$userBtn.on("click", function (e) {
|
||||
e.stopPropagation();
|
||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||
});
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("keydown", function (e) {
|
||||
if (e.key === "Escape" && isDropdownOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$logoutBtn.on("click", function () {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
function showGuest() {
|
||||
$guestLink.removeClass("hidden");
|
||||
$userBtn.addClass("hidden").removeClass("flex");
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
function showUser(user) {
|
||||
$guestLink.addClass("hidden");
|
||||
$userBtn.removeClass("hidden").addClass("flex");
|
||||
|
||||
const displayName = user.full_name || user.username;
|
||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||
|
||||
$userAvatar.text(firstLetter);
|
||||
$dropdownName.text(displayName);
|
||||
$dropdownUsername.text("@" + user.username);
|
||||
$dropdownEmail.text(user.email);
|
||||
}
|
||||
|
||||
function updateUserAvatar(email) {
|
||||
if (!email) return;
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const emailHash = sha256(cleanEmail);
|
||||
|
||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||
const avatarImg = document.getElementById("user-avatar");
|
||||
if (avatarImg) {
|
||||
avatarImg.src = avatarUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
|
||||
if (!token) {
|
||||
showGuest();
|
||||
} else {
|
||||
fetch("/api/auth/me", {
|
||||
headers: { Authorization: "Bearer " + token },
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) return response.json();
|
||||
throw new Error("Unauthorized");
|
||||
})
|
||||
.then((user) => {
|
||||
showUser(user);
|
||||
updateUserAvatar(user.email);
|
||||
|
||||
document.getElementById("user-btn").classList.remove("hidden");
|
||||
document.getElementById("guest-link").classList.add("hidden");
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
showGuest();
|
||||
});
|
||||
}
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,62 @@
|
||||
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg"><rect
|
||||
x="10"
|
||||
y="10"
|
||||
width="80"
|
||||
height="80"
|
||||
rx="4"
|
||||
ry="4"
|
||||
fill="#fff"
|
||||
stroke="#000"
|
||||
stroke-width="2"
|
||||
/><rect x="20" y="15" width="60" height="70" rx="10" ry="10" /><rect
|
||||
x="20"
|
||||
y="15"
|
||||
width="60"
|
||||
height="66"
|
||||
rx="10"
|
||||
ry="10"
|
||||
fill="#fff"
|
||||
/><rect x="20" y="15" width="60" height="62" rx="10" ry="10" /><rect
|
||||
x="20"
|
||||
y="15"
|
||||
width="60"
|
||||
height="60"
|
||||
rx="10"
|
||||
ry="10"
|
||||
fill="#fff"
|
||||
/><rect x="20" y="15" width="60" height="56" rx="10" ry="10" /><rect
|
||||
x="20"
|
||||
y="15"
|
||||
width="60"
|
||||
height="54"
|
||||
rx="10"
|
||||
ry="10"
|
||||
fill="#fff"
|
||||
/><rect x="20" y="15" width="60" height="50" rx="10" ry="10" /><rect
|
||||
x="20"
|
||||
y="15"
|
||||
width="60"
|
||||
height="48"
|
||||
rx="10"
|
||||
ry="10"
|
||||
fill="#fff"
|
||||
/><rect x="20" y="15" width="60" height="44" rx="10" ry="10" /><rect
|
||||
x="22"
|
||||
y="21"
|
||||
width="2"
|
||||
height="58"
|
||||
rx="10"
|
||||
ry="10"
|
||||
stroke="#000"
|
||||
stroke-width="4"
|
||||
/><rect x="22" y="55" width="4" height="26" rx="2" ry="15" /><text
|
||||
x="50"
|
||||
y="40"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
alignment-baseline="middle"
|
||||
stroke="#fff"
|
||||
stroke-width=".5"
|
||||
fill="none"
|
||||
font-size="20"
|
||||
>『LiB』</text></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,384 @@
|
||||
const $svg = $("#bookSvg");
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
const svgWidth = 200;
|
||||
const svgHeight = 250;
|
||||
const lineCount = 5;
|
||||
const lineDelay = 16;
|
||||
const bookWidth = 120;
|
||||
const bookHeight = 180;
|
||||
const bookX = (svgWidth - bookWidth) / 2;
|
||||
const bookY = (svgHeight - bookHeight) / 2;
|
||||
const desiredLineSpacing = 8;
|
||||
const baseLineWidth = 2;
|
||||
const maxLineWidth = 10;
|
||||
const maxLineHeight = bookHeight - 24;
|
||||
const innerPaddingX = 10;
|
||||
const appearStagger = 8;
|
||||
|
||||
let lineSpacing;
|
||||
if (lineCount > 1) {
|
||||
const maxSpan = Math.max(0, bookWidth - maxLineWidth - 2 * innerPaddingX);
|
||||
const wishSpan = desiredLineSpacing * (lineCount - 1);
|
||||
const realSpan = Math.min(wishSpan, maxSpan);
|
||||
lineSpacing = realSpan / (lineCount - 1);
|
||||
} else {
|
||||
lineSpacing = 0;
|
||||
}
|
||||
const linesSpan = lineSpacing * (lineCount - 1);
|
||||
|
||||
const rightBase = bookX + bookWidth - innerPaddingX - maxLineWidth;
|
||||
const lineStartX = rightBase - linesSpan;
|
||||
|
||||
const leftLimit = bookX + innerPaddingX;
|
||||
|
||||
let phase = 0;
|
||||
let time = 0;
|
||||
|
||||
const baseAppearDuration = 40;
|
||||
const appearDuration = baseAppearDuration + (lineCount - 1) * appearStagger;
|
||||
|
||||
const baseFlipDuration = 120;
|
||||
const flipDuration = baseFlipDuration + (lineCount - 1) * lineDelay;
|
||||
|
||||
const baseDisappearDuration = 40;
|
||||
const disappearDuration =
|
||||
baseDisappearDuration + (lineCount - 1) * appearStagger;
|
||||
|
||||
const pauseDuration = 30;
|
||||
|
||||
const book = document.createElementNS(NS, "rect");
|
||||
$(book)
|
||||
.attr("x", bookX)
|
||||
.attr("y", bookY)
|
||||
.attr("width", bookWidth)
|
||||
.attr("height", bookHeight)
|
||||
.attr("fill", "#374151")
|
||||
.attr("rx", "4");
|
||||
$svg.append(book);
|
||||
|
||||
const lines = [];
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const line = document.createElementNS(NS, "rect");
|
||||
$(line).attr("fill", "#ffffff").attr("rx", "1");
|
||||
$svg.append(line);
|
||||
|
||||
const baseX = lineStartX + i * lineSpacing;
|
||||
const targetX = leftLimit + i * lineSpacing;
|
||||
const moveDistance = baseX - targetX;
|
||||
|
||||
lines.push({
|
||||
el: $(line),
|
||||
baseX,
|
||||
targetX,
|
||||
moveDistance,
|
||||
currentX: baseX,
|
||||
width: baseLineWidth,
|
||||
height: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function easeInOutQuad(t) {
|
||||
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
||||
}
|
||||
|
||||
function easeOutQuad(t) {
|
||||
return 1 - (1 - t) * (1 - t);
|
||||
}
|
||||
|
||||
function easeInQuad(t) {
|
||||
return t * t;
|
||||
}
|
||||
|
||||
function updateLine(line) {
|
||||
const $el = line.el;
|
||||
const centerY = bookY + bookHeight / 2;
|
||||
|
||||
$el
|
||||
.attr("x", line.currentX)
|
||||
.attr("y", centerY - line.height / 2)
|
||||
.attr("width", line.width)
|
||||
.attr("height", Math.max(0, line.height));
|
||||
}
|
||||
|
||||
function animateBook() {
|
||||
time++;
|
||||
|
||||
if (phase === 0) {
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const delay = (lineCount - 1 - i) * appearStagger;
|
||||
const localTime = Math.max(0, time - delay);
|
||||
const progress = Math.min(1, localTime / baseAppearDuration);
|
||||
const easedProgress = easeOutQuad(progress);
|
||||
|
||||
lines[i].height = maxLineHeight * easedProgress;
|
||||
lines[i].currentX = lines[i].baseX;
|
||||
lines[i].width = baseLineWidth;
|
||||
updateLine(lines[i]);
|
||||
}
|
||||
|
||||
if (time >= appearDuration) {
|
||||
phase = 1;
|
||||
time = 0;
|
||||
}
|
||||
} else if (phase === 1) {
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const delay = i * lineDelay;
|
||||
const localTime = Math.max(0, time - delay);
|
||||
const progress = Math.min(1, localTime / baseFlipDuration);
|
||||
|
||||
const moveProgress = easeInOutQuad(progress);
|
||||
lines[i].currentX = lines[i].baseX - lines[i].moveDistance * moveProgress;
|
||||
|
||||
const widthProgress =
|
||||
progress < 0.5
|
||||
? easeOutQuad(progress * 2)
|
||||
: 1 - easeInQuad((progress - 0.5) * 2);
|
||||
|
||||
lines[i].width =
|
||||
baseLineWidth + (maxLineWidth - baseLineWidth) * widthProgress;
|
||||
|
||||
updateLine(lines[i]);
|
||||
}
|
||||
|
||||
if (time >= flipDuration) {
|
||||
phase = 2;
|
||||
time = 0;
|
||||
}
|
||||
} else if (phase === 2) {
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const delay = (lineCount - 1 - i) * appearStagger;
|
||||
const localTime = Math.max(0, time - delay);
|
||||
const progress = Math.min(1, localTime / baseDisappearDuration);
|
||||
const easedProgress = easeInQuad(progress);
|
||||
|
||||
lines[i].height = maxLineHeight * (1 - easedProgress);
|
||||
updateLine(lines[i]);
|
||||
}
|
||||
|
||||
if (time >= disappearDuration + pauseDuration) {
|
||||
phase = 0;
|
||||
time = 0;
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
lines[i].currentX = lines[i].baseX;
|
||||
lines[i].width = baseLineWidth;
|
||||
lines[i].height = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(animateBook);
|
||||
}
|
||||
|
||||
animateBook();
|
||||
|
||||
function animateCounter($element, target, duration = 2000) {
|
||||
const start = 0;
|
||||
const startTime = performance.now();
|
||||
|
||||
function update(currentTime) {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
const easedProgress = 1 - Math.pow(1 - progress, 3);
|
||||
const current = Math.floor(start + (target - start) * easedProgress);
|
||||
|
||||
$element.text(current.toLocaleString("ru-RU"));
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(update);
|
||||
} else {
|
||||
$element.text(target.toLocaleString("ru-RU"));
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const response = await fetch("/api/stats");
|
||||
if (!response.ok) {
|
||||
throw new Error("Ошибка загрузки статистики");
|
||||
}
|
||||
|
||||
const stats = await response.json();
|
||||
|
||||
setTimeout(() => {
|
||||
const $booksEl = $("#stat-books");
|
||||
const $authorsEl = $("#stat-authors");
|
||||
const $genresEl = $("#stat-genres");
|
||||
const $usersEl = $("#stat-users");
|
||||
|
||||
if ($booksEl.length) {
|
||||
animateCounter($booksEl, stats.books, 1500);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if ($authorsEl.length) {
|
||||
animateCounter($authorsEl, stats.authors, 1500);
|
||||
}
|
||||
}, 150);
|
||||
|
||||
setTimeout(() => {
|
||||
if ($genresEl.length) {
|
||||
animateCounter($genresEl, stats.genres, 1500);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
setTimeout(() => {
|
||||
if ($usersEl.length) {
|
||||
animateCounter($usersEl, stats.users, 1500);
|
||||
}
|
||||
}, 450);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error("Ошибка загрузки статистики:", error);
|
||||
|
||||
$("#stat-books").text("—");
|
||||
$("#stat-authors").text("—");
|
||||
$("#stat-genres").text("—");
|
||||
$("#stat-users").text("—");
|
||||
}
|
||||
}
|
||||
|
||||
function observeStatCards() {
|
||||
const $cards = $(".stat-card");
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry, index) => {
|
||||
if (entry.isIntersecting) {
|
||||
setTimeout(() => {
|
||||
$(entry.target)
|
||||
.addClass("animate-fade-in")
|
||||
.css({
|
||||
opacity: "1",
|
||||
transform: "translateY(0)",
|
||||
});
|
||||
}, index * 100);
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
$cards.each((index, card) => {
|
||||
$(card).css({
|
||||
opacity: "0",
|
||||
transform: "translateY(20px)",
|
||||
transition: "opacity 0.5s ease, transform 0.5s ease",
|
||||
});
|
||||
observer.observe(card);
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(() => {
|
||||
loadStats();
|
||||
observeStatCards();
|
||||
|
||||
const $guestLink = $("#guest-link");
|
||||
const $userBtn = $("#user-btn");
|
||||
const $userDropdown = $("#user-dropdown");
|
||||
const $userArrow = $("#user-arrow");
|
||||
const $userAvatar = $("#user-avatar");
|
||||
const $dropdownName = $("#dropdown-name");
|
||||
const $dropdownUsername = $("#dropdown-username");
|
||||
const $dropdownEmail = $("#dropdown-email");
|
||||
const $logoutBtn = $("#logout-btn");
|
||||
|
||||
let isDropdownOpen = false;
|
||||
|
||||
function openDropdown() {
|
||||
isDropdownOpen = true;
|
||||
$userDropdown.removeClass("hidden");
|
||||
$userArrow.addClass("rotate-180");
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
isDropdownOpen = false;
|
||||
$userDropdown.addClass("hidden");
|
||||
$userArrow.removeClass("rotate-180");
|
||||
}
|
||||
|
||||
$userBtn.on("click", function (e) {
|
||||
e.stopPropagation();
|
||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||
});
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("keydown", function (e) {
|
||||
if (e.key === "Escape" && isDropdownOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$logoutBtn.on("click", function () {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
function showGuest() {
|
||||
$guestLink.removeClass("hidden");
|
||||
$userBtn.addClass("hidden").removeClass("flex");
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
function showUser(user) {
|
||||
$guestLink.addClass("hidden");
|
||||
$userBtn.removeClass("hidden").addClass("flex");
|
||||
|
||||
const displayName = user.full_name || user.username;
|
||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||
|
||||
$userAvatar.text(firstLetter);
|
||||
$dropdownName.text(displayName);
|
||||
$dropdownUsername.text("@" + user.username);
|
||||
$dropdownEmail.text(user.email);
|
||||
}
|
||||
|
||||
function updateUserAvatar(email) {
|
||||
if (!email) return;
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const emailHash = sha256(cleanEmail);
|
||||
|
||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||
const avatarImg = document.getElementById("user-avatar");
|
||||
if (avatarImg) {
|
||||
avatarImg.src = avatarUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
|
||||
if (!token) {
|
||||
showGuest();
|
||||
} else {
|
||||
fetch("/api/auth/me", {
|
||||
headers: { Authorization: "Bearer " + token },
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) return response.json();
|
||||
throw new Error("Unauthorized");
|
||||
})
|
||||
.then((user) => {
|
||||
showUser(user);
|
||||
updateUserAvatar(user.email);
|
||||
|
||||
document.getElementById("user-btn").classList.remove("hidden");
|
||||
document.getElementById("guest-link").classList.add("hidden");
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
showGuest();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="20" y="15" width="60" height="70" rx="10" ry="10" /><rect
|
||||
x="20"
|
||||
y="15"
|
||||
width="60"
|
||||
height="66"
|
||||
rx="10"
|
||||
ry="10"
|
||||
fill="#fff"
|
||||
/><rect x="20" y="15" width="60" height="62" rx="10" ry="10" />
|
||||
<rect
|
||||
x="20"
|
||||
y="15"
|
||||
width="60"
|
||||
height="60"
|
||||
rx="10"
|
||||
ry="10"
|
||||
fill="#fff"
|
||||
/><rect x="20" y="15" width="60" height="56" rx="10" ry="10" />
|
||||
<rect
|
||||
x="20"
|
||||
y="15"
|
||||
width="60"
|
||||
height="54"
|
||||
rx="10"
|
||||
ry="10"
|
||||
fill="#fff"
|
||||
/><rect x="20" y="15" width="60" height="50" rx="10" ry="10" />
|
||||
<rect
|
||||
x="20"
|
||||
y="15"
|
||||
width="60"
|
||||
height="48"
|
||||
rx="10"
|
||||
ry="10"
|
||||
fill="#fff"
|
||||
/><rect x="20" y="15" width="60" height="44" rx="10" ry="10" />
|
||||
<rect
|
||||
x="22"
|
||||
y="21"
|
||||
width="2"
|
||||
height="58"
|
||||
rx="10"
|
||||
ry="10"
|
||||
stroke="#000"
|
||||
stroke-width="4"
|
||||
/><rect x="22" y="55" width="4" height="26" rx="2" ry="15" />
|
||||
<text
|
||||
x="50"
|
||||
y="40"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
alignment-baseline="middle"
|
||||
stroke="#fff"
|
||||
stroke-width=".5"
|
||||
fill="none"
|
||||
font-size="20"
|
||||
>『LiB』</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
@@ -0,0 +1,509 @@
|
||||
$(document).ready(() => {
|
||||
let currentUser = null;
|
||||
let allRoles = [];
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
|
||||
if (!token) {
|
||||
window.location.href = "/login";
|
||||
return;
|
||||
}
|
||||
|
||||
loadProfile();
|
||||
|
||||
function loadProfile() {
|
||||
showLoadingState();
|
||||
|
||||
Promise.all([
|
||||
fetch("/api/auth/me", {
|
||||
headers: { Authorization: "Bearer " + token },
|
||||
}).then((response) => {
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}),
|
||||
fetch("/api/auth/roles", {
|
||||
headers: { Authorization: "Bearer " + token },
|
||||
}).then((response) => {
|
||||
if (response.ok) return response.json();
|
||||
return { roles: [] };
|
||||
}),
|
||||
])
|
||||
.then(([user, rolesData]) => {
|
||||
currentUser = user;
|
||||
allRoles = rolesData.roles || [];
|
||||
renderProfile(user);
|
||||
renderAccountInfo(user);
|
||||
renderRoles(user.roles, allRoles);
|
||||
renderActions();
|
||||
document.title = `LiB - ${user.full_name || user.username}`;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error loading profile:", error);
|
||||
if (error.message === "Unauthorized") {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.href = "/login";
|
||||
} else {
|
||||
showErrorState(error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderProfile(user) {
|
||||
const $card = $("#profile-card");
|
||||
const displayName = user.full_name || user.username;
|
||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||
|
||||
const emailHash = sha256(user.email.trim().toLowerCase());
|
||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||
|
||||
$card.html(`
|
||||
<div class="flex flex-col sm:flex-row items-center sm:items-start">
|
||||
<!-- Аватар -->
|
||||
<div class="relative mb-4 sm:mb-0 sm:mr-6">
|
||||
<img src="${avatarUrl}" alt="Аватар"
|
||||
class="w-24 h-24 rounded-full object-cover border-4 border-gray-200"
|
||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||
<div class="w-24 h-24 bg-gray-500 text-white rounded-full items-center justify-center text-4xl font-bold hidden">
|
||||
${firstLetter}
|
||||
</div>
|
||||
<!-- Статус верификации -->
|
||||
${user.is_verified ? `
|
||||
<div class="absolute -bottom-1 -right-1 bg-green-500 rounded-full p-1" title="Подтверждённый аккаунт">
|
||||
<svg class="w-4 h-4 text-white" 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"/>
|
||||
</svg>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Информация -->
|
||||
<div class="flex-1 text-center sm:text-left">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-1">${escapeHtml(displayName)}</h1>
|
||||
<p class="text-gray-500 mb-3">@${escapeHtml(user.username)}</p>
|
||||
|
||||
<!-- Статусы -->
|
||||
<div class="flex flex-wrap justify-center sm:justify-start gap-2">
|
||||
${user.is_active ? `
|
||||
<span class="inline-flex items-center bg-green-100 text-green-800 text-sm px-3 py-1 rounded-full">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
||||
Активен
|
||||
</span>
|
||||
` : `
|
||||
<span class="inline-flex items-center bg-red-100 text-red-800 text-sm px-3 py-1 rounded-full">
|
||||
<span class="w-2 h-2 bg-red-500 rounded-full mr-2"></span>
|
||||
Заблокирован
|
||||
</span>
|
||||
`}
|
||||
${user.is_verified ? `
|
||||
<span class="inline-flex items-center bg-blue-100 text-blue-800 text-sm px-3 py-1 rounded-full">
|
||||
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Подтверждён
|
||||
</span>
|
||||
` : `
|
||||
<span class="inline-flex items-center bg-yellow-100 text-yellow-800 text-sm px-3 py-1 rounded-full">
|
||||
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Не подтверждён
|
||||
</span>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function renderAccountInfo(user) {
|
||||
const $container = $("#account-container");
|
||||
|
||||
$container.html(`
|
||||
<div class="space-y-4">
|
||||
<!-- ID пользователя -->
|
||||
<div class="flex items-center justify-between py-3 border-b border-gray-100">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">ID пользователя</p>
|
||||
<p class="text-gray-900">${user.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Имя пользователя -->
|
||||
<div class="flex items-center justify-between py-3 border-b border-gray-100">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Имя пользователя</p>
|
||||
<p class="text-gray-900">@${escapeHtml(user.username)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Полное имя -->
|
||||
<div class="flex items-center justify-between py-3 border-b border-gray-100">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Полное имя</p>
|
||||
<p class="text-gray-900">${escapeHtml(user.full_name || "Не указано")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Email</p>
|
||||
<p class="text-gray-900">${escapeHtml(user.email)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function renderRoles(userRoles, allRoles) {
|
||||
const $container = $("#roles-container");
|
||||
|
||||
if (!userRoles || userRoles.length === 0) {
|
||||
$container.html(`
|
||||
<p class="text-gray-500">У вас нет назначенных ролей</p>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
const roleDescriptions = {};
|
||||
allRoles.forEach((role) => {
|
||||
roleDescriptions[role.name] = role.description;
|
||||
});
|
||||
|
||||
const roleIcons = {
|
||||
admin: `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||
</svg>`,
|
||||
librarian: `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>`,
|
||||
member: `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
</svg>`,
|
||||
};
|
||||
|
||||
const roleColors = {
|
||||
admin: "bg-red-100 text-red-800 border-red-200",
|
||||
librarian: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
member: "bg-green-100 text-green-800 border-green-200",
|
||||
};
|
||||
|
||||
let rolesHtml = '<div class="space-y-3">';
|
||||
|
||||
userRoles.forEach((roleName) => {
|
||||
const description = roleDescriptions[roleName] || "Описание недоступно";
|
||||
const icon = roleIcons[roleName] || roleIcons.member;
|
||||
const colorClass = roleColors[roleName] || roleColors.member;
|
||||
|
||||
rolesHtml += `
|
||||
<div class="flex items-center p-4 rounded-lg border ${colorClass}">
|
||||
<div class="flex-shrink-0 mr-4">
|
||||
${icon}
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium capitalize">${escapeHtml(roleName)}</h4>
|
||||
<p class="text-sm opacity-75">${escapeHtml(description)}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
rolesHtml += '</div>';
|
||||
|
||||
$container.html(rolesHtml);
|
||||
}
|
||||
|
||||
function renderActions() {
|
||||
const $container = $("#actions-container");
|
||||
|
||||
$container.html(`
|
||||
<div class="space-y-3">
|
||||
<!-- Смена пароля -->
|
||||
<button id="change-password-btn" class="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-gray-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||
</svg>
|
||||
<span class="text-gray-700">Сменить пароль</span>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Выход -->
|
||||
<button id="logout-profile-btn" class="w-full flex items-center justify-between p-4 bg-red-50 hover:bg-red-100 rounded-lg transition-colors">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-red-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
<span class="text-red-700">Выйти из аккаунта</span>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$("#change-password-btn").on("click", openPasswordModal);
|
||||
$("#logout-profile-btn").on("click", logout);
|
||||
}
|
||||
|
||||
function openPasswordModal() {
|
||||
$("#password-modal").removeClass("hidden").addClass("flex");
|
||||
$("#current-password").focus();
|
||||
}
|
||||
|
||||
function closePasswordModal() {
|
||||
$("#password-modal").removeClass("flex").addClass("hidden");
|
||||
$("#password-form")[0].reset();
|
||||
$("#password-error").addClass("hidden").text("");
|
||||
}
|
||||
|
||||
$("#close-password-modal, #cancel-password").on("click", closePasswordModal);
|
||||
|
||||
$("#password-modal").on("click", function (e) {
|
||||
if (e.target === this) {
|
||||
closePasswordModal();
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("keydown", function (e) {
|
||||
if (e.key === "Escape" && $("#password-modal").hasClass("flex")) {
|
||||
closePasswordModal();
|
||||
}
|
||||
});
|
||||
|
||||
$("#password-form").on("submit", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const currentPassword = $("#current-password").val();
|
||||
const newPassword = $("#new-password").val();
|
||||
const confirmPassword = $("#confirm-password").val();
|
||||
const $error = $("#password-error");
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
$error.text("Пароли не совпадают").removeClass("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
$error.text("Пароль должен содержать минимум 6 символов").removeClass("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: смена пароля, 2FA
|
||||
// fetch("/api/auth/change-password", {
|
||||
// method: "POST",
|
||||
// headers: {
|
||||
// "Content-Type": "application/json",
|
||||
// Authorization: "Bearer " + token,
|
||||
// },
|
||||
// body: JSON.stringify({
|
||||
// current_password: currentPassword,
|
||||
// new_password: newPassword,
|
||||
// }),
|
||||
// })
|
||||
// .then((response) => {
|
||||
// if (!response.ok) throw new Error("Ошибка смены пароля");
|
||||
// return response.json();
|
||||
// })
|
||||
// .then(() => {
|
||||
// closePasswordModal();
|
||||
// showNotification("Пароль успешно изменён", "success");
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// $error.text(error.message).removeClass("hidden");
|
||||
// });
|
||||
|
||||
$error.text("Функция смены пароля временно недоступна").removeClass("hidden");
|
||||
});
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
function showLoadingState() {
|
||||
const $profileCard = $("#profile-card");
|
||||
const $accountContainer = $("#account-container");
|
||||
const $rolesContainer = $("#roles-container");
|
||||
const $actionsContainer = $("#actions-container");
|
||||
|
||||
$profileCard.html(`
|
||||
<div class="flex flex-col sm:flex-row items-center sm:items-start animate-pulse">
|
||||
<div class="w-24 h-24 bg-gray-200 rounded-full mb-4 sm:mb-0 sm:mr-6"></div>
|
||||
<div class="flex-1 text-center sm:text-left">
|
||||
<div class="h-7 bg-gray-200 rounded w-48 mx-auto sm:mx-0 mb-2"></div>
|
||||
<div class="h-5 bg-gray-200 rounded w-32 mx-auto sm:mx-0 mb-3"></div>
|
||||
<div class="flex justify-center sm:justify-start gap-2">
|
||||
<div class="h-7 bg-gray-200 rounded-full w-20"></div>
|
||||
<div class="h-7 bg-gray-200 rounded-full w-28"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$accountContainer.html(`
|
||||
<div class="space-y-4 animate-pulse">
|
||||
${Array(4)
|
||||
.fill()
|
||||
.map(
|
||||
() => `
|
||||
<div class="flex items-center py-3 border-b border-gray-100">
|
||||
<div class="w-5 h-5 bg-gray-200 rounded mr-3"></div>
|
||||
<div>
|
||||
<div class="h-3 bg-gray-200 rounded w-16 mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-40"></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`);
|
||||
|
||||
$rolesContainer.html(`
|
||||
<div class="space-y-3 animate-pulse">
|
||||
<div class="h-16 bg-gray-200 rounded-lg"></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$actionsContainer.html(`
|
||||
<div class="space-y-3 animate-pulse">
|
||||
<div class="h-14 bg-gray-200 rounded-lg"></div>
|
||||
<div class="h-14 bg-gray-200 rounded-lg"></div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function showErrorState(message) {
|
||||
const $profileCard = $("#profile-card");
|
||||
const $accountSection = $("#account-section");
|
||||
const $rolesSection = $("#roles-section");
|
||||
const $actionsSection = $("#actions-section");
|
||||
|
||||
$accountSection.hide();
|
||||
$rolesSection.hide();
|
||||
$actionsSection.hide();
|
||||
|
||||
$profileCard.html(`
|
||||
<div class="text-center py-8">
|
||||
<svg class="mx-auto h-16 w-16 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
<h3 class="text-xl font-medium text-gray-900 mb-2">${escapeHtml(message)}</h3>
|
||||
<p class="text-gray-500 mb-6">Не удалось загрузить профиль</p>
|
||||
<div class="flex justify-center gap-4">
|
||||
<button id="retry-btn" class="bg-gray-500 text-white px-6 py-2 rounded-lg hover:bg-gray-600 transition-colors">
|
||||
Попробовать снова
|
||||
</button>
|
||||
<a href="/" class="bg-white text-gray-700 px-6 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
|
||||
На главную
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$("#retry-btn").on("click", function () {
|
||||
$accountSection.show();
|
||||
$rolesSection.show();
|
||||
$actionsSection.show();
|
||||
loadProfile();
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
const $guestLink = $("#guest-link");
|
||||
const $userBtn = $("#user-btn");
|
||||
const $userDropdown = $("#user-dropdown");
|
||||
const $userArrow = $("#user-arrow");
|
||||
const $userAvatar = $("#user-avatar");
|
||||
const $dropdownName = $("#dropdown-name");
|
||||
const $dropdownUsername = $("#dropdown-username");
|
||||
const $dropdownEmail = $("#dropdown-email");
|
||||
const $logoutBtn = $("#logout-btn");
|
||||
|
||||
let isDropdownOpen = false;
|
||||
|
||||
function openDropdown() {
|
||||
isDropdownOpen = true;
|
||||
$userDropdown.removeClass("hidden");
|
||||
$userArrow.addClass("rotate-180");
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
isDropdownOpen = false;
|
||||
$userDropdown.addClass("hidden");
|
||||
$userArrow.removeClass("rotate-180");
|
||||
}
|
||||
|
||||
$userBtn.on("click", function (e) {
|
||||
e.stopPropagation();
|
||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||
});
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$logoutBtn.on("click", logout);
|
||||
|
||||
function showGuest() {
|
||||
$guestLink.removeClass("hidden");
|
||||
$userBtn.addClass("hidden").removeClass("flex");
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
function showUser(user) {
|
||||
$guestLink.addClass("hidden");
|
||||
$userBtn.removeClass("hidden").addClass("flex");
|
||||
|
||||
const displayName = user.full_name || user.username;
|
||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||
|
||||
$userAvatar.text(firstLetter);
|
||||
$dropdownName.text(displayName);
|
||||
$dropdownUsername.text("@" + user.username);
|
||||
$dropdownEmail.text(user.email);
|
||||
}
|
||||
|
||||
if (currentUser) {
|
||||
showUser(currentUser);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
@font-face {
|
||||
font-family: "Novem";
|
||||
src: url("novem.regular.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Dited";
|
||||
src: url("dited.regular.ttf") format("truetype");
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: "Novem", sans-serif;
|
||||
letter-spacing: 10px;
|
||||
}
|
||||
|
||||
nav ul li a {
|
||||
font-family: "Dited", sans-serif;
|
||||
letter-spacing: 2.5px;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
.custom-checkbox {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.custom-checkbox input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
background-color: #fff;
|
||||
border: 2px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.custom-checkbox:hover input ~ .checkmark {
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.custom-checkbox input:checked ~ .checkmark {
|
||||
background-color: #6b7280;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.checkmark:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.custom-checkbox input:checked ~ .checkmark:after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.custom-checkbox .checkmark:after {
|
||||
left: 6.5px;
|
||||
top: 6px;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.auth-tab {
|
||||
font-family: "Dited", sans-serif;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
#login-form,
|
||||
#register-form {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.flex.justify-center.gap-4 button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.shake {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
10%,
|
||||
30%,
|
||||
50%,
|
||||
70%,
|
||||
90% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
20%,
|
||||
40%,
|
||||
60%,
|
||||
80% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
}
|
||||
|
||||
#req-length,
|
||||
#req-upper,
|
||||
#req-lower,
|
||||
#req-digit {
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.req-icon {
|
||||
font-size: 10px;
|
||||
width: 12px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#login-form:not(.hidden),
|
||||
#register-form:not(.hidden) {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
#login-tab,
|
||||
#register-tab {
|
||||
font-family: "Dited", sans-serif;
|
||||
letter-spacing: 1.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#user-dropdown {
|
||||
animation: dropdownFade 0.1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes dropdownFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
#user-arrow.rotate-180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
@keyframes pulse-soft {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-soft {
|
||||
animation: pulse-soft 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#bookSvg {
|
||||
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.stat-card:hover svg {
|
||||
transform: scale(1.1);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card svg {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #374151 0%, #6b7280 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ app_info.title }}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
border-bottom: 2px solid #3498db;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
margin: 15px 0;
|
||||
}
|
||||
a {
|
||||
display: inline-block;
|
||||
padding: 8px 15px;
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
a:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="/favicon.ico" />
|
||||
<h1>Welcome to {{ app_info.title }}!</h1>
|
||||
<p>Description: {{ app_info.description }}</p>
|
||||
<p>Version: {{ app_info.version }}</p>
|
||||
<p>Current Time: {{ server_time }}</p>
|
||||
<p>Status: {{ status }}</p>
|
||||
<ul>
|
||||
<li><a href="/">Home page</a></li>
|
||||
<li>
|
||||
<a href="https://github.com/wowlikon/LibraryAPI">Source Code</a>
|
||||
</li>
|
||||
<li><a href="/docs">Swagger UI</a></li>
|
||||
<li><a href="/redoc">ReDoc</a></li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,327 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Авторизация{% endblock %} {%
|
||||
block content %}
|
||||
<div class="flex flex-1 items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="flex border-b border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
id="login-tab"
|
||||
class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-700 bg-gray-50 border-b-2 border-gray-500"
|
||||
>
|
||||
Вход
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="register-tab"
|
||||
class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
Регистрация
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="login-form" class="p-6">
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="login-username"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Имя пользователя</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="login-username"
|
||||
name="username"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||
placeholder="Введите имя пользователя"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="login-password"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Пароль</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="password"
|
||||
id="login-password"
|
||||
name="password"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||
placeholder="Введите пароль"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
onclick="togglePassword(this)"
|
||||
>
|
||||
<svg
|
||||
class="eye-open w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
class="eye-closed w-5 h-5 hidden"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<label
|
||||
class="custom-checkbox flex items-center text-sm text-gray-600"
|
||||
>
|
||||
<input type="checkbox" id="remember-me" />
|
||||
<span class="checkmark"></span>Запомнить меня
|
||||
</label>
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm text-gray-500 hover:text-gray-700 transition"
|
||||
>Забыли пароль?</a
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
id="login-error"
|
||||
class="hidden mb-4 p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm"
|
||||
></div>
|
||||
<button
|
||||
type="submit"
|
||||
id="login-submit"
|
||||
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium"
|
||||
>
|
||||
Войти
|
||||
</button>
|
||||
</form>
|
||||
<form
|
||||
id="register-form"
|
||||
class="p-6 hidden"
|
||||
onsubmit="return handleRegister(event);"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="register-username"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Имя пользователя</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="register-username"
|
||||
name="username"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||
placeholder="Придумайте имя пользователя (мин. 3 символа)"
|
||||
required
|
||||
minlength="3"
|
||||
maxlength="50"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="register-email"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Email</label
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
id="register-email"
|
||||
name="email"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||
placeholder="example@mail.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="register-fullname"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Полное имя
|
||||
<span class="text-gray-400"
|
||||
>(необязательно)</span
|
||||
></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="register-fullname"
|
||||
name="full_name"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||
placeholder="Иван Иванов"
|
||||
maxlength="100"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="register-password"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Пароль</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="password"
|
||||
id="register-password"
|
||||
name="password"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||
placeholder="Минимум 8 символов, A-Z, a-z, 0-9"
|
||||
required
|
||||
minlength="8"
|
||||
maxlength="100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
onclick="togglePassword(this)"
|
||||
>
|
||||
<svg
|
||||
class="eye-open w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
class="eye-closed w-5 h-5 hidden"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div
|
||||
class="h-1 w-full bg-gray-200 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
id="password-strength-bar"
|
||||
class="h-full w-0 transition-all duration-300"
|
||||
></div>
|
||||
</div>
|
||||
<p
|
||||
id="password-strength-text"
|
||||
class="text-xs mt-1 text-gray-500"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="register-password-confirm"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Подтвердите пароль</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="password"
|
||||
id="register-password-confirm"
|
||||
name="password_confirm"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||
placeholder="Повторите пароль"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
onclick="togglePassword(this)"
|
||||
>
|
||||
<svg
|
||||
class="eye-open w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
class="eye-closed w-5 h-5 hidden"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="password-match-error"
|
||||
class="text-xs mt-1 text-red-500 hidden"
|
||||
>
|
||||
Пароли не совпадают
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id="register-error"
|
||||
class="hidden mb-4 p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm"
|
||||
></div>
|
||||
<div
|
||||
id="register-success"
|
||||
class="hidden mb-4 p-3 bg-green-100 border border-green-300 text-green-700 rounded-lg text-sm"
|
||||
></div>
|
||||
<button
|
||||
type="submit"
|
||||
id="register-submit"
|
||||
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
Зарегистрироваться
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script type="text/javascript" src="/static/auth.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Автор{% endblock %} {% block
|
||||
content %}
|
||||
<div class="flex flex-1 mt-4 p-4">
|
||||
<main class="flex-1 max-w-4xl mx-auto">
|
||||
<div id="author-card" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||
</div>
|
||||
<div id="books-section" class="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 class="text-xl font-semibold mb-4">Книги автора</h2>
|
||||
<div id="books-container">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script type="text/javascript" src="/static/author.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,72 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Авторы{% endblock %} {% block
|
||||
content %}
|
||||
<div class="flex flex-1 mt-4 p-4">
|
||||
<aside
|
||||
class="w-1/4 bg-white p-4 rounded-lg shadow-md mr-4 h-fit resize-x overflow-auto min-w-64 max-w-96"
|
||||
>
|
||||
<h2 class="text-xl font-semibold mb-4">Поиск</h2>
|
||||
<div class="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
id="author-search-input"
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
|
||||
placeholder="Поиск авторов..."
|
||||
maxlength="50"
|
||||
/>
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold mb-4">Сортировка</h2>
|
||||
<div class="mb-4">
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="sort"
|
||||
value="name_asc"
|
||||
checked
|
||||
class="w-4 h-4 text-gray-500 focus:ring-gray-500"
|
||||
/>
|
||||
<span class="ml-2 text-gray-700">По имени (А-Я)</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="sort"
|
||||
value="name_desc"
|
||||
class="w-4 h-4 text-gray-500 focus:ring-gray-500"
|
||||
/>
|
||||
<span class="ml-2 text-gray-700">По имени (Я-А)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
id="reset-filters-btn"
|
||||
class="w-full bg-white text-gray-500 py-2 px-4 rounded-lg border border-gray-300 hover:bg-gray-50 transition duration-200"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
<div
|
||||
id="results-counter"
|
||||
class="mt-4 text-center text-sm text-gray-500"
|
||||
></div>
|
||||
</aside>
|
||||
<main class="flex-1">
|
||||
<div id="authors-container"></div>
|
||||
<div id="pagination-container"></div>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script type="text/javascript" src="/static/authors.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,78 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<title>{% block title %}LiB{% endblock %}</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script async="" src="https://cdnjs.cloudflare.com/ajax/libs/js-sha256/0.11.0/sha256.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cash/8.1.5/cash.min.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body class="flex flex-col min-h-screen bg-gray-100">
|
||||
<header class="bg-gray-500 text-white p-4 shadow-md">
|
||||
<div class="mx-auto pl-5 pr-3 flex justify-between items-center">
|
||||
<a class="flex gap-4 items-center max-w-10 h-auto" href="/">
|
||||
<img class="invert" src="/static/logo.svg" />
|
||||
<h1 class="text-2xl font-bold">LiB</h1>
|
||||
</a>
|
||||
<nav>
|
||||
<ul class="flex space-x-4">
|
||||
<li><a href="/" class="hover:text-gray-200">Главная</a></li>
|
||||
<li><a href="/books" class="hover:text-gray-200">Книги</a></li>
|
||||
<li><a href="/authors" class="hover:text-gray-200">Авторы</a></li>
|
||||
<li><a href="/about" class="hover:text-gray-200">О нас</a></li>
|
||||
<li><a href="/api" class="hover:text-gray-200">API</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="relative" id="user-menu-area">
|
||||
<a href="/auth" id="guest-link" class="block hover:opacity-80 transition"><img class="w-6 h-6 invert" src="/static/avatar.svg" /></a>
|
||||
<button type="button" id="user-btn" class="hidden items-center gap-2 hover:opacity-80 transition focus:outline-none">
|
||||
<img
|
||||
id="user-avatar"
|
||||
src="https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y"
|
||||
class="w-8 h-8 rounded-full border-2 border-white object-cover bg-gray-600"
|
||||
alt="User Avatar"
|
||||
/>
|
||||
|
||||
<svg id="user-arrow" class="w-4 h-4 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="user-dropdown" class="hidden absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-200">
|
||||
<p id="dropdown-name" class="text-sm font-semibold text-gray-900 truncate">Пользователь</p>
|
||||
<p id="dropdown-username" class="text-sm text-gray-500 truncate">@username</p>
|
||||
<p id="dropdown-email" class="text-xs text-gray-400 truncate mt-1">email@example.com</p>
|
||||
</div>
|
||||
<a href="/profile" class="flex items-center px-4 py-2 text-sm hover:bg-gray-100">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>
|
||||
<p class="text-gray-700 text-sm">Мой профиль</p>
|
||||
</a>
|
||||
<a href="/my-books" class="flex items-center px-4 py-2 text-sm hover:bg-gray-100">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg>
|
||||
<p class="text-gray-700 text-sm">Мои книги</p>
|
||||
</a>
|
||||
<div class="border-t border-gray-200 mt-1 pt-1">
|
||||
<button type="button" id="logout-btn" class="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50">
|
||||
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path></svg>
|
||||
<p class="text-gray-700 text-sm">Выйти</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<footer class="bg-gray-800 text-white p-4 mt-8">
|
||||
<div class="container mx-auto text-center">
|
||||
<p>© 2025 My Awesome Library. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Книга{% endblock %} {% block
|
||||
content %}
|
||||
<div class="flex flex-1 mt-4 p-4">
|
||||
<main class="flex-1 max-w-4xl mx-auto">
|
||||
<div id="book-card" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||
</div>
|
||||
<div id="authors-section" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Авторы</h2>
|
||||
<div id="authors-container">
|
||||
</div>
|
||||
</div>
|
||||
<div id="genres-section" class="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 class="text-xl font-semibold mb-4">Жанры</h2>
|
||||
<div id="genres-container">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script type="text/javascript" src="/static/book.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Главная{% endblock %} {% block
|
||||
content %}
|
||||
<div class="flex flex-1 mt-4 p-4">
|
||||
<aside
|
||||
class="w-1/4 bg-white p-4 rounded-lg shadow-md mr-4 h-fit resize-x overflow-auto min-w-64 max-w-96"
|
||||
>
|
||||
<h2 class="text-xl font-semibold mb-4">Поиск</h2>
|
||||
<div class="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
id="book-search-input"
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
|
||||
placeholder="Поиск книг (мин. 3 символа)..."
|
||||
minlength="3"
|
||||
maxlength="50"
|
||||
/>
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold mb-4">Фильтры</h2>
|
||||
<div class="mb-4">
|
||||
<h3 class="font-medium mb-2">Авторы</h3>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded-md bg-white min-h-[42px]"
|
||||
id="selected-authors-container"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="author-search-input"
|
||||
class="flex-grow outline-none bg-transparent min-w-[100px]"
|
||||
placeholder="Начните вводить..."
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="author-dropdown"
|
||||
class="absolute z-10 w-full bg-white border border-gray-300 rounded-md mt-1 hidden max-h-60 overflow-y-auto shadow-lg"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<h3 class="font-medium mb-2">Жанры</h3>
|
||||
<ul id="genres-list" class="max-h-60 overflow-y-auto"></ul>
|
||||
</div>
|
||||
<button
|
||||
id="apply-filters-btn"
|
||||
class="w-full bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition duration-200 mb-2"
|
||||
>
|
||||
Применить фильтры
|
||||
</button>
|
||||
<button
|
||||
id="reset-filters-btn"
|
||||
class="w-full bg-white text-gray-500 py-2 px-4 rounded-lg border border-gray-300 hover:bg-gray-50 transition duration-200"
|
||||
>
|
||||
Сбросить фильтры
|
||||
</button>
|
||||
<div
|
||||
id="results-counter"
|
||||
class="mt-4 text-center text-sm text-gray-500"
|
||||
></div>
|
||||
</aside>
|
||||
<main class="flex-1">
|
||||
<div id="books-container"></div>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script type="text/javascript" src="/static/books.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,60 +1,195 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ app_info.title }}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
border-bottom: 2px solid #3498db;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
margin: 15px 0;
|
||||
}
|
||||
a {
|
||||
display: inline-block;
|
||||
padding: 8px 15px;
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
a:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="/favicon.ico" />
|
||||
<h1>Welcome to {{ app_info.title }}!</h1>
|
||||
<p>Description: {{ app_info.description }}</p>
|
||||
<p>Version: {{ app_info.version }}</p>
|
||||
<p>Current Time: {{ server_time }}</p>
|
||||
<p>Status: {{ status }}</p>
|
||||
<ul>
|
||||
<li><a href="/docs">Swagger UI</a></li>
|
||||
<li><a href="/redoc">ReDoc</a></li>
|
||||
<li>
|
||||
<a href="https://github.com/wowlikon/LibraryAPI">Source Code</a>
|
||||
</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
{% extends "base.html" %} {% block title %}LiB - Библиотека{% endblock %} {%
|
||||
block content %}
|
||||
<div class="flex flex-1 items-center justify-center p-4">
|
||||
<div class="w-full max-w-4xl">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="text-center py-8 border-b border-gray-200">
|
||||
<h2 class="text-3xl font-bold text-gray-800 mb-2">
|
||||
Добро пожаловать в LiB
|
||||
</h2>
|
||||
<p class="text-gray-500">Ваша персональная библиотека книг</p>
|
||||
</div>
|
||||
<div class="p-8">
|
||||
<div
|
||||
class="flex flex-col lg:flex-row items-center justify-center gap-12"
|
||||
>
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
id="bookSvg"
|
||||
width="400"
|
||||
height="500"
|
||||
viewBox="0 0 200 250"
|
||||
class="drop-shadow-lg"
|
||||
></svg>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div
|
||||
class="stat-card bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-6 text-center transform transition-all duration-300 hover:scale-105 hover:shadow-lg"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<svg
|
||||
class="w-10 h-10 mx-auto text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
id="stat-books"
|
||||
class="text-4xl font-bold text-gray-800 mb-1 stat-number"
|
||||
data-target="0"
|
||||
>
|
||||
0
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 font-medium">
|
||||
Книг
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-6 text-center transform transition-all duration-300 hover:scale-105 hover:shadow-lg"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<svg
|
||||
class="w-10 h-10 mx-auto text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
id="stat-authors"
|
||||
class="text-4xl font-bold text-gray-800 mb-1 stat-number"
|
||||
data-target="0"
|
||||
>
|
||||
0
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 font-medium">
|
||||
Авторов
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-6 text-center transform transition-all duration-300 hover:scale-105 hover:shadow-lg"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<svg
|
||||
class="w-10 h-10 mx-auto text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
id="stat-genres"
|
||||
class="text-4xl font-bold text-gray-800 mb-1 stat-number"
|
||||
data-target="0"
|
||||
>
|
||||
0
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 font-medium">
|
||||
Жанров
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-6 text-center transform transition-all duration-300 hover:scale-105 hover:shadow-lg"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<svg
|
||||
class="w-10 h-10 mx-auto text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
id="stat-users"
|
||||
class="text-4xl font-bold text-gray-800 mb-1 stat-number"
|
||||
data-target="0"
|
||||
>
|
||||
0
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 font-medium">
|
||||
Пользователей
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-8 pb-8">
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a
|
||||
href="/books"
|
||||
class="inline-flex items-center justify-center px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition duration-200 font-medium shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
></path>
|
||||
</svg>
|
||||
Смотреть книги
|
||||
</a>
|
||||
<a
|
||||
href="/authors"
|
||||
class="inline-flex items-center justify-center px-6 py-3 bg-white text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition duration-200 font-medium shadow-sm hover:shadow-md transform hover:-translate-y-0.5"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
></path>
|
||||
</svg>
|
||||
Все авторы
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 text-center text-gray-400 text-sm">
|
||||
<p>LiB — Библиотека. Создано с ❤️</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script type="text/javascript" src="/static/index.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Профиль{% endblock %} {% block
|
||||
content %}
|
||||
|
||||
<div class="flex flex-1 mt-4 p-4">
|
||||
<main class="flex-1 max-w-2xl mx-auto">
|
||||
<div id="profile-card" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||
</div>
|
||||
<div id="account-section" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Информация об аккаунте</h2>
|
||||
<div id="account-container">
|
||||
</div>
|
||||
</div>
|
||||
<div id="roles-section" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Роли и права</h2>
|
||||
<div id="roles-container">
|
||||
</div>
|
||||
</div>
|
||||
<div id="actions-section" class="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 class="text-xl font-semibold mb-4">Действия</h2>
|
||||
<div id="actions-container">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<div id="password-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-md mx-4">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-xl font-semibold">Смена пароля</h3>
|
||||
<button id="close-password-modal" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="password-form">
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-medium mb-2">Текущий пароль</label>
|
||||
<input type="password" id="current-password"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
|
||||
required minlength="6">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-medium mb-2">Новый пароль</label>
|
||||
<input type="password" id="new-password"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
|
||||
required minlength="6">
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-gray-700 text-sm font-medium mb-2">Подтвердите новый пароль</label>
|
||||
<input type="password" id="confirm-password"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
|
||||
required minlength="6">
|
||||
</div>
|
||||
<div id="password-error" class="mb-4 text-red-600 text-sm hidden"></div>
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="flex-1 bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition-colors">
|
||||
Сменить пароль
|
||||
</button>
|
||||
<button type="button" id="cancel-password" class="flex-1 bg-white text-gray-700 py-2 px-4 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
<script type="text/javascript" src="/static/profile.js"></script>
|
||||
{% endblock %}
|
||||
+5
-5
@@ -1,13 +1,11 @@
|
||||
from logging.config import fileConfig
|
||||
from alembic import context
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from library_service.settings import POSTGRES_DATABASE_URL
|
||||
|
||||
print(POSTGRES_DATABASE_URL)
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
@@ -17,10 +15,12 @@ config.set_main_option("sqlalchemy.url", POSTGRES_DATABASE_URL)
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
if config.attributes.get("configure_logging", True):
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
from library_service.models.enums import *
|
||||
from library_service.models.db import *
|
||||
|
||||
target_metadata = SQLModel.metadata
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Loans
|
||||
|
||||
Revision ID: 02ed6e775351
|
||||
Revises: b838606ad8d1
|
||||
Create Date: 2025-12-20 10:36:30.853896
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '02ed6e775351'
|
||||
down_revision: Union[str, None] = 'b838606ad8d1'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
book_status_enum = sa.Enum('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus')
|
||||
book_status_enum.create(op.get_bind())
|
||||
op.create_table('book_loans',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('book_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('borrowed_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('due_date', sa.DateTime(), nullable=False),
|
||||
sa.Column('returned_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['book_id'], ['book.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_book_loans_id'), 'book_loans', ['id'], unique=False)
|
||||
op.add_column('book', sa.Column('status', book_status_enum, nullable=False, server_default='active'))
|
||||
op.drop_index(op.f('ix_roles_name'), table_name='roles')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_index(op.f('ix_roles_name'), 'roles', ['name'], unique=True)
|
||||
op.drop_column('book', 'status')
|
||||
op.drop_index(op.f('ix_book_loans_id'), table_name='book_loans')
|
||||
op.drop_table('book_loans')
|
||||
book_status_enum = sa.Enum('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus')
|
||||
book_status_enum.drop(op.get_bind())
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,45 +1,53 @@
|
||||
"""genres
|
||||
"""Genres
|
||||
|
||||
Revision ID: 9d7a43ac5dfc
|
||||
Revises: d266fdc61e99
|
||||
Create Date: 2025-06-25 11:24:30.229418
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9d7a43ac5dfc'
|
||||
down_revision: Union[str, None] = 'd266fdc61e99'
|
||||
revision: str = "9d7a43ac5dfc"
|
||||
down_revision: Union[str, None] = "d266fdc61e99"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('genre',
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_table(
|
||||
"genre",
|
||||
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f('ix_genre_id'), 'genre', ['id'], unique=False)
|
||||
op.create_table('genrebooklink',
|
||||
sa.Column('genre_id', sa.Integer(), nullable=False),
|
||||
sa.Column('book_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['book_id'], ['book.id'], ),
|
||||
sa.ForeignKeyConstraint(['genre_id'], ['genre.id'], ),
|
||||
sa.PrimaryKeyConstraint('genre_id', 'book_id')
|
||||
op.create_index(op.f("ix_genre_id"), "genre", ["id"], unique=False)
|
||||
op.create_table(
|
||||
"genrebooklink",
|
||||
sa.Column("genre_id", sa.Integer(), nullable=False),
|
||||
sa.Column("book_id", sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["book_id"],
|
||||
["book.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["genre_id"],
|
||||
["genre.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("genre_id", "book_id"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('genrebooklink')
|
||||
op.drop_index(op.f('ix_genre_id'), table_name='genre')
|
||||
op.drop_table('genre')
|
||||
op.drop_table("genrebooklink")
|
||||
op.drop_index(op.f("ix_genre_id"), table_name="genre")
|
||||
op.drop_table("genre")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Auth
|
||||
|
||||
Revision ID: b838606ad8d1
|
||||
Revises: 9d7a43ac5dfc
|
||||
Create Date: 2025-12-07 20:18:05.839579
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b838606ad8d1"
|
||||
down_revision: Union[str, None] = "9d7a43ac5dfc"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"roles",
|
||||
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_roles_id"), "roles", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_roles_name"), "roles", ["name"], unique=True)
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column(
|
||||
"username", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False
|
||||
),
|
||||
sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column(
|
||||
"full_name", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True
|
||||
),
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"hashed_password", sqlmodel.sql.sqltypes.AutoString(), nullable=False
|
||||
),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_verified", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
||||
op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True)
|
||||
op.create_table(
|
||||
"user_roles",
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("role_id", sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["role_id"],
|
||||
["roles.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("user_id", "role_id"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("user_roles")
|
||||
op.drop_index(op.f("ix_users_username"), table_name="users")
|
||||
op.drop_index(op.f("ix_users_id"), table_name="users")
|
||||
op.drop_index(op.f("ix_users_email"), table_name="users")
|
||||
op.drop_table("users")
|
||||
op.drop_index(op.f("ix_roles_name"), table_name="roles")
|
||||
op.drop_index(op.f("ix_roles_id"), table_name="roles")
|
||||
op.drop_table("roles")
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,19 +1,19 @@
|
||||
"""init
|
||||
"""Init
|
||||
|
||||
Revision ID: d266fdc61e99
|
||||
Revises:
|
||||
Revises:
|
||||
Create Date: 2025-05-27 18:04:22.279035
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd266fdc61e99'
|
||||
revision: str = "d266fdc61e99"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
@@ -21,34 +21,43 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('author',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_table(
|
||||
"author",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f('ix_author_id'), 'author', ['id'], unique=False)
|
||||
op.create_table('book',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_index(op.f("ix_author_id"), "author", ["id"], unique=False)
|
||||
op.create_table(
|
||||
"book",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f('ix_book_id'), 'book', ['id'], unique=False)
|
||||
op.create_table('authorbooklink',
|
||||
sa.Column('author_id', sa.Integer(), nullable=False),
|
||||
sa.Column('book_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['author_id'], ['author.id'], ),
|
||||
sa.ForeignKeyConstraint(['book_id'], ['book.id'], ),
|
||||
sa.PrimaryKeyConstraint('author_id', 'book_id')
|
||||
op.create_index(op.f("ix_book_id"), "book", ["id"], unique=False)
|
||||
op.create_table(
|
||||
"authorbooklink",
|
||||
sa.Column("author_id", sa.Integer(), nullable=False),
|
||||
sa.Column("book_id", sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["author_id"],
|
||||
["author.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["book_id"],
|
||||
["book.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("author_id", "book_id"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('authorbooklink')
|
||||
op.drop_index(op.f('ix_book_id'), table_name='book')
|
||||
op.drop_table('book')
|
||||
op.drop_index(op.f('ix_author_id'), table_name='author')
|
||||
op.drop_table('author')
|
||||
op.drop_table("authorbooklink")
|
||||
op.drop_index(op.f("ix_book_id"), table_name="book")
|
||||
op.drop_table("book")
|
||||
op.drop_index(op.f("ix_author_id"), table_name="author")
|
||||
op.drop_table("author")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
Generated
+615
-118
@@ -1,4 +1,16 @@
|
||||
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
version = "25.1.0"
|
||||
description = "File support for asyncio."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695"},
|
||||
{file = "aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
@@ -47,12 +59,82 @@ files = [
|
||||
[package.dependencies]
|
||||
idna = ">=2.8"
|
||||
sniffio = ">=1.1"
|
||||
typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
|
||||
|
||||
[package.extras]
|
||||
doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
|
||||
test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""]
|
||||
trio = ["trio (>=0.26.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "argon2-cffi"
|
||||
version = "25.1.0"
|
||||
description = "Argon2 for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741"},
|
||||
{file = "argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
argon2-cffi-bindings = "*"
|
||||
|
||||
[[package]]
|
||||
name = "argon2-cffi-bindings"
|
||||
version = "25.1.0"
|
||||
description = "Low-level CFFI bindings for Argon2"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690"},
|
||||
{file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520"},
|
||||
{file = "argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
cffi = [
|
||||
{version = ">=1.0.1", markers = "python_version < \"3.14\""},
|
||||
{version = ">=2.0.0b1", markers = "python_version >= \"3.14\""},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "astroid"
|
||||
version = "4.0.2"
|
||||
description = "An abstract syntax tree for Python with inference support."
|
||||
optional = false
|
||||
python-versions = ">=3.10.0"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "astroid-4.0.2-py3-none-any.whl", hash = "sha256:d7546c00a12efc32650b19a2bb66a153883185d3179ab0d4868086f807338b9b"},
|
||||
{file = "astroid-4.0.2.tar.gz", hash = "sha256:ac8fb7ca1c08eb9afec91ccc23edbd8ac73bb22cbdd7da1d488d9fb8d6579070"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "25.1.0"
|
||||
@@ -110,6 +192,103 @@ files = [
|
||||
{file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
description = "Foreign Function Interface for Python calling C code."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
|
||||
{file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
|
||||
{file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"},
|
||||
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"},
|
||||
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"},
|
||||
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"},
|
||||
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"},
|
||||
{file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"},
|
||||
{file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"},
|
||||
{file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"},
|
||||
{file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"},
|
||||
{file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"},
|
||||
{file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"},
|
||||
{file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"},
|
||||
{file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"},
|
||||
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"},
|
||||
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"},
|
||||
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"},
|
||||
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"},
|
||||
{file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"},
|
||||
{file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"},
|
||||
{file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"},
|
||||
{file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"},
|
||||
{file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"},
|
||||
{file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"},
|
||||
{file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"},
|
||||
{file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"},
|
||||
{file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"},
|
||||
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"},
|
||||
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"},
|
||||
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"},
|
||||
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"},
|
||||
{file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"},
|
||||
{file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"},
|
||||
{file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"},
|
||||
{file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"},
|
||||
{file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"},
|
||||
{file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"},
|
||||
{file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"},
|
||||
{file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"},
|
||||
{file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"},
|
||||
{file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"},
|
||||
{file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"},
|
||||
{file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"},
|
||||
{file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"},
|
||||
{file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"},
|
||||
{file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"},
|
||||
{file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"},
|
||||
{file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"},
|
||||
{file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"},
|
||||
{file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"},
|
||||
{file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"},
|
||||
{file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"},
|
||||
{file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"},
|
||||
{file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"},
|
||||
{file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"},
|
||||
{file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"},
|
||||
{file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"},
|
||||
{file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"},
|
||||
{file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"},
|
||||
{file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"},
|
||||
{file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"},
|
||||
{file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"},
|
||||
{file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"},
|
||||
{file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"},
|
||||
{file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"},
|
||||
{file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"},
|
||||
{file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"},
|
||||
{file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"},
|
||||
{file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"},
|
||||
{file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"},
|
||||
{file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"},
|
||||
{file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"},
|
||||
{file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"},
|
||||
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"},
|
||||
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"},
|
||||
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"},
|
||||
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"},
|
||||
{file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"},
|
||||
{file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"},
|
||||
{file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"},
|
||||
{file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"},
|
||||
{file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"},
|
||||
{file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.2.1"
|
||||
@@ -138,6 +317,99 @@ files = [
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.3"
|
||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||
optional = false
|
||||
python-versions = "!=3.9.0,!=3.9.1,>=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"},
|
||||
{file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"},
|
||||
{file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"},
|
||||
{file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""}
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"]
|
||||
docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"]
|
||||
nox = ["nox[uv] (>=2024.4.15)"]
|
||||
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
|
||||
sdist = ["build (>=1.0.0)"]
|
||||
ssh = ["bcrypt (>=3.1.5)"]
|
||||
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
|
||||
test-randomorder = ["pytest-randomly"]
|
||||
|
||||
[[package]]
|
||||
name = "dill"
|
||||
version = "0.4.0"
|
||||
description = "serialize all of Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049"},
|
||||
{file = "dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
graph = ["objgraph (>=1.7.2)"]
|
||||
profile = ["gprof2dot (>=2022.7.29)"]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.7.0"
|
||||
@@ -159,6 +431,25 @@ idna = ["idna (>=3.7)"]
|
||||
trio = ["trio (>=0.23)"]
|
||||
wmi = ["wmi (>=1.5.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.19.1"
|
||||
description = "ECDSA cryptographic signature library (pure python)"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3"},
|
||||
{file = "ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
six = ">=1.9.0"
|
||||
|
||||
[package.extras]
|
||||
gmpy = ["gmpy"]
|
||||
gmpy2 = ["gmpy2"]
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.2.0"
|
||||
@@ -235,7 +526,7 @@ description = "Lightweight in-process concurrent programming"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "python_version == \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"
|
||||
markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"
|
||||
files = [
|
||||
{file = "greenlet-3.2.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:1afd685acd5597349ee6d7a88a8bec83ce13c106ac78c196ee9dde7c04fe87be"},
|
||||
{file = "greenlet-3.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:761917cac215c61e9dc7324b2606107b3b292a8349bdebb31503ab4de3f559ac"},
|
||||
@@ -439,6 +730,22 @@ files = [
|
||||
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "isort"
|
||||
version = "7.0.0"
|
||||
description = "A Python utility / library to sort Python imports."
|
||||
optional = false
|
||||
python-versions = ">=3.10.0"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1"},
|
||||
{file = "isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
colors = ["colorama"]
|
||||
plugins = ["setuptools"]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
@@ -585,6 +892,18 @@ files = [
|
||||
{file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mccabe"
|
||||
version = "0.7.0"
|
||||
description = "McCabe checker, plugin for flake8"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
|
||||
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
@@ -703,6 +1022,27 @@ files = [
|
||||
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "passlib"
|
||||
version = "1.7.4"
|
||||
description = "comprehensive password hashing framework supporting over 30 schemes"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"},
|
||||
{file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
argon2-cffi = {version = ">=18.2.0", optional = true, markers = "extra == \"argon2\""}
|
||||
|
||||
[package.extras]
|
||||
argon2 = ["argon2-cffi (>=18.2.0)"]
|
||||
bcrypt = ["bcrypt (>=3.1.0)"]
|
||||
build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"]
|
||||
totp = ["cryptography"]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "0.12.1"
|
||||
@@ -826,23 +1166,49 @@ files = [
|
||||
{file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.1"
|
||||
description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
|
||||
{file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.23"
|
||||
description = "C parser in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "implementation_name != \"PyPy\""
|
||||
files = [
|
||||
{file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
|
||||
{file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.11.7"
|
||||
version = "2.12.5"
|
||||
description = "Data validation using Python type hints"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"},
|
||||
{file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"},
|
||||
{file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"},
|
||||
{file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
annotated-types = ">=0.6.0"
|
||||
pydantic-core = "2.33.2"
|
||||
typing-extensions = ">=4.12.2"
|
||||
typing-inspection = ">=0.4.0"
|
||||
email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""}
|
||||
pydantic-core = "2.41.5"
|
||||
typing-extensions = ">=4.14.1"
|
||||
typing-inspection = ">=0.4.2"
|
||||
|
||||
[package.extras]
|
||||
email = ["email-validator (>=2.0.0)"]
|
||||
@@ -850,115 +1216,137 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.33.2"
|
||||
version = "2.41.5"
|
||||
description = "Core functionality for Pydantic validation and serialization"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"},
|
||||
{file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"},
|
||||
{file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
|
||||
typing-extensions = ">=4.14.1"
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-extra-types"
|
||||
@@ -1023,6 +1411,31 @@ files = [
|
||||
[package.extras]
|
||||
windows-terminal = ["colorama (>=0.4.6)"]
|
||||
|
||||
[[package]]
|
||||
name = "pylint"
|
||||
version = "4.0.4"
|
||||
description = "python code static checker"
|
||||
optional = false
|
||||
python-versions = ">=3.10.0"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pylint-4.0.4-py3-none-any.whl", hash = "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0"},
|
||||
{file = "pylint-4.0.4.tar.gz", hash = "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
astroid = ">=4.0.2,<=4.1.dev0"
|
||||
colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
|
||||
dill = {version = ">=0.3.7", markers = "python_version >= \"3.12\""}
|
||||
isort = ">=5,<5.13 || >5.13,<8"
|
||||
mccabe = ">=0.6,<0.8"
|
||||
platformdirs = ">=2.2"
|
||||
tomlkit = ">=0.10.1"
|
||||
|
||||
[package.extras]
|
||||
spelling = ["pyenchant (>=3.2,<4.0)"]
|
||||
testutils = ["gitpython (>3)"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.1"
|
||||
@@ -1045,6 +1458,26 @@ pygments = ">=2.7.2"
|
||||
[package.extras]
|
||||
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.3.0"
|
||||
description = "Pytest support for asyncio"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"},
|
||||
{file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pytest = ">=8.2,<10"
|
||||
typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""}
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
|
||||
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "0.21.1"
|
||||
@@ -1060,6 +1493,30 @@ files = [
|
||||
[package.extras]
|
||||
cli = ["click (>=5.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "python-jose"
|
||||
version = "3.5.0"
|
||||
description = "JOSE implementation in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771"},
|
||||
{file = "python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"cryptography\""}
|
||||
ecdsa = "!=0.15"
|
||||
pyasn1 = ">=0.5.0"
|
||||
rsa = ">=4.0,<4.1.1 || >4.1.1,<4.4 || >4.4,<5.0"
|
||||
|
||||
[package.extras]
|
||||
cryptography = ["cryptography (>=3.4.0)"]
|
||||
pycrypto = ["pycrypto (>=2.6.0,<2.7.0)"]
|
||||
pycryptodome = ["pycryptodome (>=3.3.1,<4.0.0)"]
|
||||
test = ["pytest", "pytest-cov"]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.20"
|
||||
@@ -1171,6 +1628,21 @@ click = ">=8.1.7"
|
||||
rich = ">=13.7.1"
|
||||
typing-extensions = ">=4.12.2"
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "4.9.1"
|
||||
description = "Pure-Python RSA implementation"
|
||||
optional = false
|
||||
python-versions = "<4,>=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"},
|
||||
{file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pyasn1 = ">=0.1.3"
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
@@ -1183,6 +1655,18 @@ files = [
|
||||
{file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
description = "Python 2 and 3 compatibility utilities"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
|
||||
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
@@ -1337,6 +1821,18 @@ files = [
|
||||
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomlkit"
|
||||
version = "0.13.3"
|
||||
description = "Style preserving TOML library"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"},
|
||||
{file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.16.0"
|
||||
@@ -1357,26 +1853,27 @@ typing-extensions = ">=3.7.4.3"
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.14.0"
|
||||
version = "4.15.0"
|
||||
description = "Backported and Experimental Type Hints for Python 3.9+"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"},
|
||||
{file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"},
|
||||
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
||||
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
||||
]
|
||||
markers = {dev = "python_version == \"3.12\""}
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
description = "Runtime typing introspection tools"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"},
|
||||
{file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"},
|
||||
{file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
|
||||
{file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1749,5 +2246,5 @@ files = [
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.13"
|
||||
content-hash = "a3555dac28547317a5d8d75507b5923d03b58412a988a260b627c079782bc15c"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "a8d44f0decfa3ba437e998207c16ca7429ee42e930e8aa1d40f87231e71f219f"
|
||||
|
||||
+10
-6
@@ -1,13 +1,13 @@
|
||||
[tool.poetry]
|
||||
name = "LibraryAPI"
|
||||
version = "0.1.3"
|
||||
description = "Это простое API для управления авторами и книгами."
|
||||
version = "0.2.0"
|
||||
description = "Это простое API для управления авторами, книгами и их жанрами."
|
||||
authors = ["wowlikon"]
|
||||
readme = "README.md"
|
||||
packages = [{ include = "library_service" }]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.13"
|
||||
python = "^3.12"
|
||||
fastapi = { extras = ["all"], version = "^0.115.12" }
|
||||
psycopg2-binary = "^2.9.10"
|
||||
alembic = "^1.16.1"
|
||||
@@ -16,13 +16,17 @@ sqlmodel = "^0.0.24"
|
||||
uvicorn = "^0.34.3"
|
||||
jinja2 = "^3.1.6"
|
||||
toml = "^0.10.2"
|
||||
python-jose = {extras = ["cryptography"], version = "^3.5.0"}
|
||||
passlib = {extras = ["argon2"], version = "^1.7.4"}
|
||||
aiofiles = "^25.1.0"
|
||||
pydantic = {extras = ["email"], version = "^2.12.5"}
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^25.1.0"
|
||||
pytest = "^8.4.1"
|
||||
|
||||
[tool.poetry.requires-plugins]
|
||||
poetry-plugin-export = ">=1.8"
|
||||
isort = "^7.0.0"
|
||||
pytest-asyncio = "^1.3.0"
|
||||
pylint = "^4.0.4"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
|
||||
+5
-4
@@ -1,23 +1,24 @@
|
||||
from fastapi import FastAPI
|
||||
from tests.mock_routers import books, authors, genres, relationships
|
||||
|
||||
from library_service.routers.misc import router as misc_router
|
||||
from tests.mock_routers import authors, books, genres, relationships
|
||||
|
||||
|
||||
def create_mock_app() -> FastAPI:
|
||||
"""Create FastAPI app with mock routers for testing"""
|
||||
"""Создание FastAPI app с моками роутеров для тестов"""
|
||||
app = FastAPI(
|
||||
title="Library API Test",
|
||||
description="Library API for testing without database",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
# Include mock routers
|
||||
# Подключение мок-роутеров
|
||||
app.include_router(books.router)
|
||||
app.include_router(authors.router)
|
||||
app.include_router(genres.router)
|
||||
app.include_router(relationships.router)
|
||||
|
||||
# Include real misc router (it doesn't use database)
|
||||
# Подключение реального misc роутера
|
||||
app.include_router(misc_router)
|
||||
|
||||
return app
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from tests.mocks.mock_storage import mock_storage
|
||||
|
||||
router = APIRouter(prefix="/authors", tags=["authors"])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from tests.mocks.mock_storage import mock_storage
|
||||
|
||||
router = APIRouter(prefix="/books", tags=["books"])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from tests.mocks.mock_storage import mock_storage
|
||||
|
||||
router = APIRouter(prefix="/genres", tags=["genres"])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from tests.mocks.mock_storage import mock_storage
|
||||
|
||||
router = APIRouter(tags=["relations"])
|
||||
@@ -36,5 +37,4 @@ def get_authors_for_book(book_id: int):
|
||||
|
||||
@router.post("/relationships/genre-book")
|
||||
def add_genre_to_book(genre_id: int, book_id: int):
|
||||
# For tests that need genre functionality
|
||||
return {"genre_id": genre_id, "book_id": book_id}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Optional, List, Any
|
||||
from typing import Any, List
|
||||
|
||||
from tests.mocks.mock_storage import mock_storage
|
||||
|
||||
|
||||
@@ -8,20 +9,13 @@ class MockSession:
|
||||
def __init__(self):
|
||||
self.storage = mock_storage
|
||||
|
||||
def add(self, obj: Any):
|
||||
"""Mock add - not needed for our implementation"""
|
||||
pass
|
||||
def add(self, obj: Any): ...
|
||||
|
||||
def commit(self):
|
||||
"""Mock commit - not needed for our implementation"""
|
||||
pass
|
||||
def commit(self): ...
|
||||
|
||||
def refresh(self, obj: Any):
|
||||
"""Mock refresh - not needed for our implementation"""
|
||||
pass
|
||||
def refresh(self, obj: Any): ...
|
||||
|
||||
def get(self, model_class, pk: int):
|
||||
"""Mock get method to retrieve object by primary key"""
|
||||
if hasattr(model_class, "__name__"):
|
||||
model_name = model_class.__name__.lower()
|
||||
else:
|
||||
@@ -35,12 +29,9 @@ class MockSession:
|
||||
return self.storage.get_genre(pk)
|
||||
return None
|
||||
|
||||
def delete(self, obj: Any):
|
||||
"""Mock delete - handled in storage methods"""
|
||||
pass
|
||||
def delete(self, obj: Any): ...
|
||||
|
||||
def exec(self, statement):
|
||||
"""Mock exec method for queries"""
|
||||
return MockResult([])
|
||||
|
||||
|
||||
|
||||
+14
-16
@@ -1,4 +1,4 @@
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
class MockStorage:
|
||||
@@ -15,7 +15,7 @@ class MockStorage:
|
||||
self.genre_id_counter = 1
|
||||
|
||||
def clear_all(self):
|
||||
"""Clear all data"""
|
||||
"""Очистка всех данных"""
|
||||
self.books.clear()
|
||||
self.authors.clear()
|
||||
self.genres.clear()
|
||||
@@ -33,7 +33,7 @@ class MockStorage:
|
||||
self.book_id_counter += 1
|
||||
return book
|
||||
|
||||
def get_book(self, book_id: int) -> Optional[dict]:
|
||||
def get_book(self, book_id: int) -> dict | None:
|
||||
return self.books.get(book_id)
|
||||
|
||||
def get_all_books(self) -> List[dict]:
|
||||
@@ -42,9 +42,9 @@ class MockStorage:
|
||||
def update_book(
|
||||
self,
|
||||
book_id: int,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> dict | None:
|
||||
if book_id not in self.books:
|
||||
return None
|
||||
book = self.books[book_id]
|
||||
@@ -54,7 +54,7 @@ class MockStorage:
|
||||
book["description"] = description
|
||||
return book
|
||||
|
||||
def delete_book(self, book_id: int) -> Optional[dict]:
|
||||
def delete_book(self, book_id: int) -> dict | None:
|
||||
if book_id not in self.books:
|
||||
return None
|
||||
book = self.books.pop(book_id)
|
||||
@@ -74,15 +74,15 @@ class MockStorage:
|
||||
self.author_id_counter += 1
|
||||
return author
|
||||
|
||||
def get_author(self, author_id: int) -> Optional[dict]:
|
||||
def get_author(self, author_id: int) -> dict | None:
|
||||
return self.authors.get(author_id)
|
||||
|
||||
def get_all_authors(self) -> List[dict]:
|
||||
return list(self.authors.values())
|
||||
|
||||
def update_author(
|
||||
self, author_id: int, name: Optional[str] = None
|
||||
) -> Optional[dict]:
|
||||
self, author_id: int, name: str | None = None
|
||||
) -> dict | None:
|
||||
if author_id not in self.authors:
|
||||
return None
|
||||
author = self.authors[author_id]
|
||||
@@ -90,7 +90,7 @@ class MockStorage:
|
||||
author["name"] = name
|
||||
return author
|
||||
|
||||
def delete_author(self, author_id: int) -> Optional[dict]:
|
||||
def delete_author(self, author_id: int) -> dict | None:
|
||||
if author_id not in self.authors:
|
||||
return None
|
||||
author = self.authors.pop(author_id)
|
||||
@@ -107,15 +107,13 @@ class MockStorage:
|
||||
self.genre_id_counter += 1
|
||||
return genre
|
||||
|
||||
def get_genre(self, genre_id: int) -> Optional[dict]:
|
||||
def get_genre(self, genre_id: int) -> dict | None:
|
||||
return self.genres.get(genre)
|
||||
|
||||
def get_all_authors(self) -> List[dict]:
|
||||
return list(self.authors.values())
|
||||
|
||||
def update_genre(
|
||||
self, genre_id: int, name: Optional[str] = None
|
||||
) -> Optional[dict]:
|
||||
def update_genre(self, genre_id: int, name: str | None = None) -> dict | None:
|
||||
if genre_id not in self.genres:
|
||||
return None
|
||||
genre = self.genres[genre_id]
|
||||
@@ -123,7 +121,7 @@ class MockStorage:
|
||||
genre["name"] = name
|
||||
return genre
|
||||
|
||||
def delete_genre(self, genre_id: int) -> Optional[dict]:
|
||||
def delete_genre(self, genre_id: int) -> dict | None:
|
||||
if genre_id not in self.genres:
|
||||
return None
|
||||
genre = self.genres.pop(genre_id)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.mock_app import mock_app
|
||||
from tests.mocks.mock_storage import mock_storage
|
||||
|
||||
@@ -8,7 +9,6 @@ client = TestClient(mock_app)
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_database():
|
||||
"""Clear mock storage before each test"""
|
||||
mock_storage.clear_all()
|
||||
yield
|
||||
mock_storage.clear_all()
|
||||
@@ -29,7 +29,6 @@ def test_create_author():
|
||||
|
||||
|
||||
def test_list_authors():
|
||||
# First create an author
|
||||
client.post("/authors", json={"name": "Test Author"})
|
||||
|
||||
response = client.get("/authors")
|
||||
@@ -42,7 +41,6 @@ def test_list_authors():
|
||||
|
||||
|
||||
def test_get_existing_author():
|
||||
# First create an author
|
||||
client.post("/authors", json={"name": "Test Author"})
|
||||
|
||||
response = client.get("/authors/1")
|
||||
@@ -63,7 +61,6 @@ def test_get_not_existing_author():
|
||||
|
||||
|
||||
def test_update_author():
|
||||
# First create an author
|
||||
client.post("/authors", json={"name": "Test Author"})
|
||||
|
||||
response = client.get("/authors/1")
|
||||
@@ -84,10 +81,7 @@ def test_update_not_existing_author():
|
||||
|
||||
|
||||
def test_delete_author():
|
||||
# First create an author
|
||||
client.post("/authors", json={"name": "Test Author"})
|
||||
|
||||
# Update it first
|
||||
client.put("/authors/1", json={"name": "Updated Author"})
|
||||
|
||||
response = client.get("/authors/1")
|
||||
|
||||
+6
-18
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.mock_app import mock_app
|
||||
from tests.mocks.mock_storage import mock_storage
|
||||
|
||||
@@ -8,7 +9,6 @@ client = TestClient(mock_app)
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_database():
|
||||
"""Clear mock storage before each test"""
|
||||
mock_storage.clear_all()
|
||||
yield
|
||||
mock_storage.clear_all()
|
||||
@@ -35,9 +35,7 @@ def test_create_book():
|
||||
|
||||
|
||||
def test_list_books():
|
||||
# First create a book
|
||||
client.post(
|
||||
"/books", json={"title": "Test Book", "description": "Test Description"}
|
||||
client.post("/books", json={"title": "Test Book", "description": "Test Description"}
|
||||
)
|
||||
|
||||
response = client.get("/books")
|
||||
@@ -50,9 +48,7 @@ def test_list_books():
|
||||
|
||||
|
||||
def test_get_existing_book():
|
||||
# First create a book
|
||||
client.post(
|
||||
"/books", json={"title": "Test Book", "description": "Test Description"}
|
||||
client.post("/books", json={"title": "Test Book", "description": "Test Description"}
|
||||
)
|
||||
|
||||
response = client.get("/books/1")
|
||||
@@ -74,9 +70,7 @@ def test_get_not_existing_book():
|
||||
|
||||
|
||||
def test_update_book():
|
||||
# First create a book
|
||||
client.post(
|
||||
"/books", json={"title": "Test Book", "description": "Test Description"}
|
||||
client.post("/books", json={"title": "Test Book", "description": "Test Description"}
|
||||
)
|
||||
|
||||
response = client.get("/books/1")
|
||||
@@ -102,14 +96,8 @@ def test_update_not_existing_book():
|
||||
|
||||
|
||||
def test_delete_book():
|
||||
# First create a book
|
||||
client.post(
|
||||
"/books", json={"title": "Test Book", "description": "Test Description"}
|
||||
)
|
||||
|
||||
# Update it first
|
||||
client.put(
|
||||
"/books/1", json={"title": "Updated Book", "description": "Updated Description"}
|
||||
client.post("/books", json={"title": "Test Book", "description": "Test Description"})
|
||||
client.put("/books/1", json={"title": "Updated Book", "description": "Updated Description"}
|
||||
)
|
||||
|
||||
response = client.get("/books/1")
|
||||
|
||||
+10
-22
@@ -1,6 +1,8 @@
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.mock_app import mock_app
|
||||
from tests.mocks.mock_storage import mock_storage
|
||||
|
||||
@@ -9,20 +11,15 @@ client = TestClient(mock_app)
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_database():
|
||||
"""Setup and cleanup mock database for each test"""
|
||||
# Clear data before each test
|
||||
mock_storage.clear_all()
|
||||
yield
|
||||
# Clear data after each test (optional, but good practice)
|
||||
mock_storage.clear_all()
|
||||
|
||||
|
||||
# Test the main page of the application
|
||||
def test_main_page():
|
||||
response = client.get("/") # Send GET request to the main page
|
||||
response = client.get("/api")
|
||||
try:
|
||||
content = response.content.decode("utf-8") # Decode response content
|
||||
# Find indices of key elements in the content
|
||||
content = response.content.decode("utf-8")
|
||||
title_idx = content.index("Welcome to ")
|
||||
description_idx = content.index("Description: ")
|
||||
version_idx = content.index("Version: ")
|
||||
@@ -38,25 +35,16 @@ def test_main_page():
|
||||
assert content[time_idx + 1] != "<", "Time not provided"
|
||||
assert content[status_idx + 1] != "<", "Status not provided"
|
||||
except Exception as e:
|
||||
print(f"Error: {e}") # Print error if an exception occurs
|
||||
assert False, "Unexpected error" # Force test failure on unexpected error
|
||||
print(f"Error: {e}")
|
||||
assert False, "Unexpected error"
|
||||
|
||||
|
||||
# Test application info endpoint
|
||||
def test_app_info_test():
|
||||
response = client.get("/api/info") # Send GET request to the info endpoint
|
||||
response = client.get("/api/info")
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
assert response.json()["status"] == "ok", "Status not ok"
|
||||
assert response.json()["app_info"]["title"] != "", "Title not provided"
|
||||
assert response.json()["app_info"]["description"] != "", "Description not provided"
|
||||
assert response.json()["app_info"]["version"] != "", "Version not provided"
|
||||
# Check time difference
|
||||
assert (
|
||||
0
|
||||
< (
|
||||
datetime.now() - datetime.fromisoformat(response.json()["server_time"])
|
||||
).total_seconds()
|
||||
), "Negative time difference"
|
||||
assert (
|
||||
datetime.now() - datetime.fromisoformat(response.json()["server_time"])
|
||||
).total_seconds() < 1, "Time difference too large"
|
||||
assert (0 < (datetime.now() - datetime.fromisoformat(response.json()["server_time"])).total_seconds()), "Negative time difference"
|
||||
assert (datetime.now() - datetime.fromisoformat(response.json()["server_time"])).total_seconds() < 1, "Time difference too large"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.mock_app import mock_app
|
||||
from tests.mocks.mock_storage import mock_storage
|
||||
|
||||
@@ -8,7 +9,6 @@ client = TestClient(mock_app)
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_database():
|
||||
"""Clear mock storage before each test"""
|
||||
mock_storage.clear_all()
|
||||
yield
|
||||
mock_storage.clear_all()
|
||||
@@ -30,28 +30,18 @@ def make_genrebook_relationship(genre_id, book_id):
|
||||
|
||||
|
||||
def test_prepare_data():
|
||||
# Create books
|
||||
assert client.post(
|
||||
"/books", json={"title": "Test Book 1", "description": "Test Description 1"}
|
||||
).status_code == 200
|
||||
assert client.post(
|
||||
"/books", json={"title": "Test Book 2", "description": "Test Description 2"}
|
||||
).status_code == 200
|
||||
assert client.post(
|
||||
"/books", json={"title": "Test Book 3", "description": "Test Description 3"}
|
||||
).status_code == 200
|
||||
assert (client.post("/books", json={"title": "Test Book 1", "description": "Test Description 1"}).status_code == 200)
|
||||
assert (client.post("/books", json={"title": "Test Book 2", "description": "Test Description 2"}).status_code == 200)
|
||||
assert (client.post("/books", json={"title": "Test Book 3", "description": "Test Description 3"}).status_code == 200)
|
||||
|
||||
# Create authors
|
||||
assert client.post("/authors", json={"name": "Test Author 1"}).status_code == 200
|
||||
assert client.post("/authors", json={"name": "Test Author 2"}).status_code == 200
|
||||
assert client.post("/authors", json={"name": "Test Author 3"}).status_code == 200
|
||||
|
||||
# Create genres
|
||||
assert client.post("/genres", json={"name": "Test Genre 1"}).status_code == 200
|
||||
assert client.post("/genres", json={"name": "Test Genre 2"}).status_code == 200
|
||||
assert client.post("/genres", json={"name": "Test Genre 3"}).status_code == 200
|
||||
|
||||
# Create relationships
|
||||
make_authorbook_relationship(1, 1)
|
||||
make_authorbook_relationship(2, 1)
|
||||
make_authorbook_relationship(1, 2)
|
||||
@@ -63,8 +53,8 @@ def test_prepare_data():
|
||||
make_genrebook_relationship(2, 3)
|
||||
make_genrebook_relationship(3, 3)
|
||||
|
||||
|
||||
def test_get_book_authors():
|
||||
# Setup test data
|
||||
test_prepare_data()
|
||||
|
||||
response1 = client.get("/books/1/authors")
|
||||
@@ -91,7 +81,6 @@ def test_get_book_authors():
|
||||
|
||||
|
||||
def test_get_author_books():
|
||||
# Setup test data
|
||||
test_prepare_data()
|
||||
|
||||
response1 = client.get("/authors/1/books")
|
||||
|
||||
Reference in New Issue
Block a user