mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 04:31:09 +00:00
Compare commits
13 Commits
9f814e7271
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a336d50ad0 | |||
| 38642a6910 | |||
| d442a37820 | |||
| 80acdceba6 | |||
| 4368ee0d3c | |||
| 4f9c472a54 | |||
| a6811a3e86 | |||
| 19d322c9d9 | |||
| dfa4d14afc | |||
| 6014db3c81 | |||
| 0e159df16e | |||
| 2f3d6f0e1e | |||
| 657f1b96f2 |
@@ -1,43 +0,0 @@
|
||||
# Postgres
|
||||
POSTGRES_HOST="localhost"
|
||||
POSTGRES_PORT="5432"
|
||||
POSTGRES_USER="postgres"
|
||||
POSTGRES_PASSWORD="postgres"
|
||||
POSTGRES_DB="lib"
|
||||
|
||||
# Ollama
|
||||
OLLAMA_URL="http://localhost:11434"
|
||||
OLLAMA_MAX_LOADED_MODELS=1
|
||||
OLLAMA_NUM_THREADS=4
|
||||
OLLAMA_KEEP_ALIVE=5m
|
||||
|
||||
# Default admin account
|
||||
# DEFAULT_ADMIN_USERNAME="admin"
|
||||
# DEFAULT_ADMIN_EMAIL="admin@example.com"
|
||||
# DEFAULT_ADMIN_PASSWORD="password-is-generated-randomly-on-first-launch"
|
||||
SECRET_KEY="your-secret-key-change-in-production"
|
||||
|
||||
# JWT
|
||||
ALGORITHM="HS256"
|
||||
REFRESH_TOKEN_EXPIRE_DAYS="7"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES="15"
|
||||
PARTIAL_TOKEN_EXPIRE_MINUTES="5"
|
||||
|
||||
# Hash
|
||||
ARGON2_TYPE="id"
|
||||
ARGON2_TIME_COST="3"
|
||||
ARGON2_MEMORY_COST="65536"
|
||||
ARGON2_PARALLELISM="4"
|
||||
ARGON2_SALT_LENGTH="16"
|
||||
ARGON2_HASH_LENGTH="48"
|
||||
|
||||
# Recovery codes
|
||||
RECOVERY_CODES_COUNT="10"
|
||||
RECOVERY_CODE_SEGMENTS="4"
|
||||
RECOVERY_CODE_SEGMENT_BYTES="2"
|
||||
RECOVERY_MIN_REMAINING_WARNING="3"
|
||||
RECOVERY_MAX_AGE_DAYS="365"
|
||||
|
||||
# TOTP_2FA
|
||||
TOTP_ISSUER="LiB"
|
||||
TOTP_VALID_WINDOW="1"
|
||||
Vendored
+1
@@ -1,4 +1,5 @@
|
||||
.env
|
||||
library_service/static/books/
|
||||
*.log
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
3. Настройте переменные окружения:
|
||||
```bash
|
||||
cp example-docker.env .env # или example-local.env для запуска без docker
|
||||
edit .env
|
||||
```
|
||||
|
||||
@@ -47,11 +48,6 @@
|
||||
uv run alembic revision --autogenerate -m "Migration name"
|
||||
```
|
||||
|
||||
Для запуска тестов:
|
||||
```bash
|
||||
docker compose up test
|
||||
```
|
||||
|
||||
### **Роли пользователей**
|
||||
|
||||
- **admin**: Полный доступ ко всем функциям системы
|
||||
@@ -269,6 +265,8 @@ erDiagram
|
||||
- **ACTIVE**: Книга доступна для выдачи
|
||||
- **RESERVED**: Книга забронирована (ожидает подтверждения)
|
||||
- **BORROWED**: Книга выдана пользователю
|
||||
- **RESTORATION**: Книга на реставрации
|
||||
- **WRITTEN_OFF**: Книга списана
|
||||
|
||||
### **Используемые технологии**
|
||||
|
||||
@@ -277,6 +275,7 @@ erDiagram
|
||||
- **SQLModel**: Библиотека для взаимодействия с базами данных, объединяющая SQLAlchemy и Pydantic
|
||||
- **Alembic**: Инструмент для миграции базы данных на основе SQLAlchemy
|
||||
- **PostgreSQL**: Реляционная система управления базами данных
|
||||
- **Ollama**: Инструмент для локального запуска и управления большими языковыми моделями
|
||||
- **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах
|
||||
- **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker
|
||||
- **Tailwind CSS**: CSS-фреймворк для стилизации интерфейса
|
||||
|
||||
+22
-12
@@ -9,14 +9,24 @@ services:
|
||||
max-file: "3"
|
||||
volumes:
|
||||
- ./data/db:/var/lib/postgresql/data
|
||||
# networks:
|
||||
# - proxy
|
||||
ports:
|
||||
networks:
|
||||
- proxy
|
||||
ports: # !сменить внешний порт перед использованием!
|
||||
- 5432:5432
|
||||
env_file:
|
||||
- ./.env
|
||||
command:
|
||||
- "postgres"
|
||||
- "-c"
|
||||
- "wal_level=logical"
|
||||
- "-c"
|
||||
- "max_replication_slots=10"
|
||||
- "-c"
|
||||
- "max_wal_senders=10"
|
||||
- "-c"
|
||||
- "listen_addresses=*"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -48,14 +58,14 @@ services:
|
||||
max-file: "3"
|
||||
volumes:
|
||||
- ./data/llm:/root/.ollama
|
||||
# networks:
|
||||
# - proxy
|
||||
ports:
|
||||
networks:
|
||||
- proxy
|
||||
ports: # !только локальный тест!
|
||||
- 11434:11434
|
||||
env_file:
|
||||
- ./.env
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl http://localhost:11434"]
|
||||
test: ["CMD", "ollama", "list"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -68,14 +78,14 @@ services:
|
||||
build: .
|
||||
container_name: api
|
||||
restart: unless-stopped
|
||||
command: bash -c "uvicorn library_service.main:app --host 0.0.0.0 --port 8000 --forwarded-allow-ips=*"
|
||||
command: python library_service/main.py
|
||||
logging:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
# networks:
|
||||
# - proxy
|
||||
ports:
|
||||
networks:
|
||||
- proxy
|
||||
ports: # !только локальный тест!
|
||||
- 8000:8000
|
||||
env_file:
|
||||
- ./.env
|
||||
|
||||
+23
-22
@@ -1,9 +1,9 @@
|
||||
# Postgres
|
||||
POSTGRES_HOST="db"
|
||||
POSTGRES_PORT="5432"
|
||||
POSTGRES_USER="postgres"
|
||||
POSTGRES_PASSWORD="postgres"
|
||||
POSTGRES_DB="lib"
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_DB=lib
|
||||
REMOTE_HOST=
|
||||
REMOTE_PORT=
|
||||
NODE_ID=
|
||||
@@ -19,28 +19,29 @@ DEFAULT_ADMIN_USERNAME="admin"
|
||||
DEFAULT_ADMIN_EMAIL="admin@example.com"
|
||||
DEFAULT_ADMIN_PASSWORD="Password12345"
|
||||
SECRET_KEY="your-secret-key-change-in-production"
|
||||
DOMAIN="mydomain.com"
|
||||
|
||||
# JWT
|
||||
ALGORITHM="HS256"
|
||||
REFRESH_TOKEN_EXPIRE_DAYS="7"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES="15"
|
||||
PARTIAL_TOKEN_EXPIRE_MINUTES="5"
|
||||
ALGORITHM=HS256
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
PARTIAL_TOKEN_EXPIRE_MINUTES=5
|
||||
|
||||
# Hash
|
||||
ARGON2_TYPE="id"
|
||||
ARGON2_TIME_COST="3"
|
||||
ARGON2_MEMORY_COST="65536"
|
||||
ARGON2_PARALLELISM="4"
|
||||
ARGON2_SALT_LENGTH="16"
|
||||
ARGON2_HASH_LENGTH="48"
|
||||
ARGON2_TYPE=id
|
||||
ARGON2_TIME_COST=3
|
||||
ARGON2_MEMORY_COST=65536
|
||||
ARGON2_PARALLELISM=4
|
||||
ARGON2_SALT_LENGTH=16
|
||||
ARGON2_HASH_LENGTH=48
|
||||
|
||||
# Recovery codes
|
||||
RECOVERY_CODES_COUNT="10"
|
||||
RECOVERY_CODE_SEGMENTS="4"
|
||||
RECOVERY_CODE_SEGMENT_BYTES="2"
|
||||
RECOVERY_MIN_REMAINING_WARNING="3"
|
||||
RECOVERY_MAX_AGE_DAYS="365"
|
||||
RECOVERY_CODES_COUNT=10
|
||||
RECOVERY_CODE_SEGMENTS=4
|
||||
RECOVERY_CODE_SEGMENT_BYTES=2
|
||||
RECOVERY_MIN_REMAINING_WARNING=3
|
||||
RECOVERY_MAX_AGE_DAYS=365
|
||||
|
||||
# TOTP_2FA
|
||||
TOTP_ISSUER="LiB"
|
||||
TOTP_VALID_WINDOW="1"
|
||||
TOTP_ISSUER=LiB
|
||||
TOTP_VALID_WINDOW=1
|
||||
|
||||
+23
-22
@@ -1,9 +1,9 @@
|
||||
# Postgres
|
||||
POSTGRES_HOST="localhost"
|
||||
POSTGRES_PORT="5432"
|
||||
POSTGRES_USER="postgres"
|
||||
POSTGRES_PASSWORD="postgres"
|
||||
POSTGRES_DB="lib"
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_DB=lib
|
||||
|
||||
# Ollama
|
||||
OLLAMA_URL="http://localhost:11434"
|
||||
@@ -16,28 +16,29 @@ DEFAULT_ADMIN_USERNAME="admin"
|
||||
DEFAULT_ADMIN_EMAIL="admin@example.com"
|
||||
DEFAULT_ADMIN_PASSWORD="Password12345"
|
||||
SECRET_KEY="your-secret-key-change-in-production"
|
||||
DOMAIN="mydomain.com"
|
||||
|
||||
# JWT
|
||||
ALGORITHM="HS256"
|
||||
REFRESH_TOKEN_EXPIRE_DAYS="7"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES="15"
|
||||
PARTIAL_TOKEN_EXPIRE_MINUTES="5"
|
||||
ALGORITHM=HS256
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
PARTIAL_TOKEN_EXPIRE_MINUTES=5
|
||||
|
||||
# Hash
|
||||
ARGON2_TYPE="id"
|
||||
ARGON2_TIME_COST="3"
|
||||
ARGON2_MEMORY_COST="65536"
|
||||
ARGON2_PARALLELISM="4"
|
||||
ARGON2_SALT_LENGTH="16"
|
||||
ARGON2_HASH_LENGTH="48"
|
||||
ARGON2_TYPE=id
|
||||
ARGON2_TIME_COST=3
|
||||
ARGON2_MEMORY_COST=65536
|
||||
ARGON2_PARALLELISM=4
|
||||
ARGON2_SALT_LENGTH=16
|
||||
ARGON2_HASH_LENGTH=48
|
||||
|
||||
# Recovery codes
|
||||
RECOVERY_CODES_COUNT="10"
|
||||
RECOVERY_CODE_SEGMENTS="4"
|
||||
RECOVERY_CODE_SEGMENT_BYTES="2"
|
||||
RECOVERY_MIN_REMAINING_WARNING="3"
|
||||
RECOVERY_MAX_AGE_DAYS="365"
|
||||
RECOVERY_CODES_COUNT=10
|
||||
RECOVERY_CODE_SEGMENTS=4
|
||||
RECOVERY_CODE_SEGMENT_BYTES=2
|
||||
RECOVERY_MIN_REMAINING_WARNING=3
|
||||
RECOVERY_MAX_AGE_DAYS=365
|
||||
|
||||
# TOTP_2FA
|
||||
TOTP_ISSUER="LiB"
|
||||
TOTP_VALID_WINDOW="1"
|
||||
TOTP_ISSUER=LiB
|
||||
TOTP_VALID_WINDOW=1
|
||||
|
||||
+40
-3
@@ -1,6 +1,6 @@
|
||||
"""Основной модуль"""
|
||||
|
||||
import asyncio
|
||||
import asyncio, sys, traceback
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -9,13 +9,15 @@ from uuid import uuid4
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from fastapi import FastAPI, Depends, Request, Response, status
|
||||
from fastapi import FastAPI, Depends, Request, Response, status, HTTPException
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import JSONResponse
|
||||
from ollama import Client, ResponseError
|
||||
from sqlmodel import Session
|
||||
|
||||
from library_service.auth import run_seeds
|
||||
from library_service.routers import api_router
|
||||
from library_service.routers.misc import unknown
|
||||
from library_service.services.captcha import limiter, cleanup_task, require_captcha
|
||||
from library_service.settings import (
|
||||
LOGGING_CONFIG,
|
||||
@@ -69,7 +71,40 @@ async def lifespan(_):
|
||||
app = get_app(lifespan)
|
||||
|
||||
|
||||
# Улучшеное логгирование
|
||||
@app.exception_handler(status.HTTP_404_NOT_FOUND)
|
||||
async def custom_not_found_handler(request: Request, exc: HTTPException):
|
||||
path = request.url.path
|
||||
|
||||
if path.startswith("/api"):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={"detail": "API endpoint not found", "path": path},
|
||||
)
|
||||
|
||||
return await unknown(request, app)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def catch_exceptions_middleware(request: Request, call_next):
|
||||
"""Middleware для подробного json-описания Internal error"""
|
||||
try:
|
||||
return await call_next(request)
|
||||
except Exception as exc:
|
||||
exc_type, exc_value, exc_tb = sys.exc_info()
|
||||
logger = get_logger()
|
||||
logger.exception(exc)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"message": str(exc),
|
||||
"type": exc_type.__name__ if exc_type else "Unknown",
|
||||
"path": str(request.url),
|
||||
"method": request.method,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
"""Middleware для логирования HTTP-запросов"""
|
||||
@@ -149,6 +184,8 @@ if __name__ == "__main__":
|
||||
"library_service.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
proxy_headers=True,
|
||||
forwarded_allow_ips="*",
|
||||
log_config=LOGGING_CONFIG,
|
||||
access_log=False,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Модуль DB-моделей книг"""
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
from uuid import UUID
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import Column, String
|
||||
@@ -26,7 +27,8 @@ class Book(BookBase, table=True):
|
||||
sa_column=Column(String, nullable=False, default="active"),
|
||||
description="Статус",
|
||||
)
|
||||
embedding: list[float] | None = Field(sa_column=Column(Vector(1024)))
|
||||
embedding: list[float] | None = Field(sa_column=Column(Vector(1024)), description="Эмбэдинг для векторного поиска")
|
||||
preview_id: UUID | None = Field(default=None, unique=True, index=True, description="UUID файла изображения")
|
||||
authors: List["Author"] = Relationship(
|
||||
back_populates="books", link_model=AuthorBookLink
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 .recovery import RecoveryCodesResponse, RecoveryCodesStatus, RecoveryCodeUse
|
||||
from .token import Token, TokenData, PartialToken
|
||||
from .token import TokenData
|
||||
from .misc import (
|
||||
AuthorWithBooks,
|
||||
GenreWithBooks,
|
||||
@@ -62,9 +62,7 @@ __all__ = [
|
||||
"RoleUpdate",
|
||||
"RoleRead",
|
||||
"RoleList",
|
||||
"Token",
|
||||
"TokenData",
|
||||
"PartialToken",
|
||||
"TOTPSetupResponse",
|
||||
"TOTPVerifyRequest",
|
||||
"TOTPDisableRequest",
|
||||
|
||||
@@ -11,8 +11,8 @@ class AuthorBase(SQLModel):
|
||||
|
||||
name: str = Field(description="Псевдоним")
|
||||
|
||||
model_config = ConfigDict( # pyright: ignore
|
||||
json_schema_extra={"example": {"name": "author_name"}}
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={"example": {"name": "John Doe"}}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ class BookRead(BookBase):
|
||||
|
||||
id: int = Field(description="Идентификатор")
|
||||
status: BookStatus = Field(description="Статус")
|
||||
preview_urls: dict[str, str] = Field(default_factory=dict, description="URL изображений")
|
||||
|
||||
|
||||
class BookList(SQLModel):
|
||||
|
||||
@@ -39,6 +39,7 @@ class BookWithAuthors(SQLModel):
|
||||
description: str = Field(description="Описание")
|
||||
page_count: int = Field(description="Количество страниц")
|
||||
status: BookStatus | None = Field(None, description="Статус")
|
||||
preview_urls: dict[str, str] = Field(default_factory=dict, description="URL изображений")
|
||||
authors: List[AuthorRead] = Field(
|
||||
default_factory=list, description="Список авторов"
|
||||
)
|
||||
@@ -52,6 +53,7 @@ class BookWithGenres(SQLModel):
|
||||
description: str = Field(description="Описание")
|
||||
page_count: int = Field(description="Количество страниц")
|
||||
status: BookStatus | None = Field(None, description="Статус")
|
||||
preview_urls: dict[str, str] | None = Field(default=None, description="URL изображений")
|
||||
genres: List[GenreRead] = Field(default_factory=list, description="Список жанров")
|
||||
|
||||
|
||||
@@ -63,6 +65,7 @@ class BookWithAuthorsAndGenres(SQLModel):
|
||||
description: str = Field(description="Описание")
|
||||
page_count: int = Field(description="Количество страниц")
|
||||
status: BookStatus | None = Field(None, description="Статус")
|
||||
preview_urls: dict[str, str] | None = Field(default=None, description="URL изображений")
|
||||
authors: List[AuthorRead] = Field(
|
||||
default_factory=list, description="Список авторов"
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from typing import List
|
||||
|
||||
from pydantic import ConfigDict
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
||||
@@ -12,6 +13,16 @@ class RoleBase(SQLModel):
|
||||
description: str | None = Field(None, description="Описание")
|
||||
payroll: int = Field(0, description="Оплата")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"name": "admin",
|
||||
"description": "system administrator",
|
||||
"payroll": 500,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class RoleCreate(RoleBase):
|
||||
"""Модель роли для создания"""
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
"""Модуль DTO-моделей токенов"""
|
||||
"""Модуль DTO-модели токена"""
|
||||
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
||||
class Token(SQLModel):
|
||||
"""Модель токена"""
|
||||
|
||||
access_token: str = Field(description="Токен доступа")
|
||||
token_type: str = Field("bearer", description="Тип токена")
|
||||
refresh_token: str | None = Field(None, description="Токен обновления")
|
||||
|
||||
|
||||
class PartialToken(SQLModel):
|
||||
"""Частичный токен — для подтверждения 2FA"""
|
||||
|
||||
partial_token: str = Field(description="Частичный токен")
|
||||
token_type: str = Field("partial", description="Тип токена")
|
||||
requires_2fa: bool = Field(True, description="Требуется TOTP-код")
|
||||
|
||||
|
||||
class TokenData(SQLModel):
|
||||
"""Модель содержимого токена"""
|
||||
|
||||
|
||||
@@ -12,15 +12,12 @@ from sqlmodel import Session, select
|
||||
from library_service.services import require_captcha
|
||||
from library_service.models.db import Role, User
|
||||
from library_service.models.dto import (
|
||||
Token,
|
||||
UserCreate,
|
||||
UserRead,
|
||||
UserUpdate,
|
||||
UserList,
|
||||
RoleRead,
|
||||
RoleList,
|
||||
Token,
|
||||
PartialToken,
|
||||
LoginResponse,
|
||||
RecoveryCodeUse,
|
||||
RegisterResponse,
|
||||
@@ -147,11 +144,14 @@ def login(
|
||||
)
|
||||
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return LoginResponse(
|
||||
access_token=create_access_token(
|
||||
new_access_token = create_access_token(
|
||||
data=token_data, expires_delta=access_token_expires
|
||||
),
|
||||
refresh_token=create_refresh_token(data=token_data),
|
||||
)
|
||||
new_refresh_token = create_refresh_token(data=token_data)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=new_access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
token_type="bearer",
|
||||
requires_2fa=False,
|
||||
)
|
||||
@@ -159,7 +159,7 @@ def login(
|
||||
|
||||
@router.post(
|
||||
"/refresh",
|
||||
response_model=Token,
|
||||
response_model=LoginResponse,
|
||||
summary="Обновление токена",
|
||||
description="Получение новой пары токенов, используя действующий Refresh токен",
|
||||
)
|
||||
@@ -190,19 +190,18 @@ def refresh_token(
|
||||
detail="User is inactive",
|
||||
)
|
||||
|
||||
token_data = {"sub": user.username, "user_id": user.id}
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
new_access_token = create_access_token(
|
||||
data={"sub": user.username, "user_id": user.id},
|
||||
expires_delta=access_token_expires,
|
||||
)
|
||||
new_refresh_token = create_refresh_token(
|
||||
data={"sub": user.username, "user_id": user.id}
|
||||
data=token_data, expires_delta=access_token_expires
|
||||
)
|
||||
new_refresh_token = create_refresh_token(data=token_data)
|
||||
|
||||
return Token(
|
||||
return LoginResponse(
|
||||
access_token=new_access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
token_type="bearer",
|
||||
requires_2fa=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -343,7 +342,7 @@ def disable_2fa(
|
||||
|
||||
@router.post(
|
||||
"/2fa/verify",
|
||||
response_model=Token,
|
||||
response_model=LoginResponse,
|
||||
summary="Верификация 2FA",
|
||||
description="Завершает аутентификацию с помощью TOTP кода или резервного кода",
|
||||
)
|
||||
@@ -368,18 +367,22 @@ def verify_2fa(
|
||||
|
||||
if not verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid 2FA code",
|
||||
)
|
||||
|
||||
token_data = {"sub": user.username, "user_id": user.id}
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
return Token(
|
||||
access_token=create_access_token(
|
||||
new_access_token = create_access_token(
|
||||
data=token_data, expires_delta=access_token_expires
|
||||
),
|
||||
refresh_token=create_refresh_token(data=token_data),
|
||||
)
|
||||
new_refresh_token = create_refresh_token(data=token_data)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=new_access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
token_type="bearer",
|
||||
requires_2fa=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Модуль работы с книгами"""
|
||||
from library_service.services import transcode_image
|
||||
import shutil
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import Field
|
||||
from typing_extensions import Annotated
|
||||
@@ -10,12 +13,12 @@ from sqlalchemy import text, case, distinct
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status, UploadFile, File
|
||||
from ollama import Client
|
||||
from sqlmodel import Session, select, col, func
|
||||
|
||||
from library_service.auth import RequireStaff
|
||||
from library_service.settings import get_session, OLLAMA_URL
|
||||
from library_service.settings import get_session, OLLAMA_URL, BOOKS_PREVIEW_DIR
|
||||
from library_service.models.enums import BookStatus
|
||||
from library_service.models.db import (
|
||||
Author,
|
||||
@@ -47,9 +50,9 @@ def close_active_loan(session: Session, book_id: int) -> None:
|
||||
"""Закрывает активную выдачу книги при изменении статуса"""
|
||||
active_loan = session.exec(
|
||||
select(BookUserLink)
|
||||
.where(BookUserLink.book_id == book_id)
|
||||
.where(BookUserLink.returned_at == None) # noqa: E711
|
||||
).first()
|
||||
.where(BookUserLink.book_id == book_id) # ty: ignore
|
||||
.where(BookUserLink.returned_at == None) # ty: ignore
|
||||
).first() # ty: ignore
|
||||
|
||||
if active_loan:
|
||||
active_loan.returned_at = datetime.now(timezone.utc)
|
||||
@@ -72,19 +75,19 @@ def filter_books(
|
||||
size: int = Query(20, gt=0, le=100),
|
||||
):
|
||||
statement = select(Book).options(
|
||||
selectinload(Book.authors), selectinload(Book.genres), defer(Book.embedding)
|
||||
selectinload(Book.authors), selectinload(Book.genres), defer(Book.embedding) # ty: ignore
|
||||
)
|
||||
|
||||
if min_page_count:
|
||||
statement = statement.where(Book.page_count >= min_page_count)
|
||||
statement = statement.where(Book.page_count >= min_page_count) # ty: ignore
|
||||
if max_page_count:
|
||||
statement = statement.where(Book.page_count <= max_page_count)
|
||||
statement = statement.where(Book.page_count <= max_page_count) # ty: ignore
|
||||
|
||||
if author_ids:
|
||||
statement = statement.where(
|
||||
exists().where(
|
||||
AuthorBookLink.book_id == Book.id,
|
||||
AuthorBookLink.author_id.in_(author_ids),
|
||||
AuthorBookLink.book_id == Book.id, # ty: ignore
|
||||
AuthorBookLink.author_id.in_(author_ids), # ty: ignore
|
||||
)
|
||||
)
|
||||
|
||||
@@ -92,7 +95,7 @@ def filter_books(
|
||||
for genre_id in genre_ids:
|
||||
statement = statement.where(
|
||||
exists().where(
|
||||
GenreBookLink.book_id == Book.id, GenreBookLink.genre_id == genre_id
|
||||
GenreBookLink.book_id == Book.id, GenreBookLink.genre_id == genre_id # ty: ignore
|
||||
)
|
||||
)
|
||||
|
||||
@@ -101,13 +104,13 @@ def filter_books(
|
||||
|
||||
if q:
|
||||
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=q)["embedding"]
|
||||
distance_col = Book.embedding.cosine_distance(emb)
|
||||
statement = statement.where(Book.embedding.is_not(None))
|
||||
distance_col = Book.embedding.cosine_distance(emb) # ty: ignore
|
||||
statement = statement.where(Book.embedding.is_not(None)) # ty: ignore
|
||||
|
||||
keyword_match = case((Book.title.ilike(f"%{q}%"), 0), else_=1)
|
||||
keyword_match = case((Book.title.ilike(f"%{q}%"), 0), else_=1) # ty: ignore
|
||||
statement = statement.order_by(keyword_match, distance_col)
|
||||
else:
|
||||
statement = statement.order_by(Book.id)
|
||||
statement = statement.order_by(Book.id) # ty: ignore
|
||||
|
||||
offset = (page - 1) * size
|
||||
statement = statement.offset(offset).limit(size)
|
||||
@@ -131,10 +134,18 @@ def create_book(
|
||||
full_text = book.title + " " + book.description
|
||||
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text)
|
||||
db_book = Book(**book.model_dump(), embedding=emb["embedding"])
|
||||
|
||||
session.add(db_book)
|
||||
session.commit()
|
||||
session.refresh(db_book)
|
||||
return BookRead(**db_book.model_dump(exclude={"embedding"}))
|
||||
|
||||
book_data = db_book.model_dump(exclude={"embedding", "preview_id"})
|
||||
book_data["preview_urls"] = {
|
||||
"png": f"/static/books/{db_book.preview_id}.png",
|
||||
"jpeg": f"/static/books/{db_book.preview_id}.jpg",
|
||||
"webp": f"/static/books/{db_book.preview_id}.webp",
|
||||
} if db_book.preview_id else {}
|
||||
return BookRead(**book_data)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -145,9 +156,20 @@ def create_book(
|
||||
)
|
||||
def read_books(session: Session = Depends(get_session)):
|
||||
"""Возвращает список всех книг"""
|
||||
books = session.exec(select(Book)).all()
|
||||
books = session.exec(select(Book)).all() # ty: ignore
|
||||
|
||||
books_data = []
|
||||
for book in books:
|
||||
book_data = book.model_dump(exclude={"embedding", "preview_id"})
|
||||
book_data["preview_urls"] = {
|
||||
"png": f"/static/books/{book.preview_id}.png",
|
||||
"jpeg": f"/static/books/{book.preview_id}.jpg",
|
||||
"webp": f"/static/books/{book.preview_id}.webp",
|
||||
} if book.preview_id else {}
|
||||
books_data.append(book_data)
|
||||
|
||||
return BookList(
|
||||
books=[BookRead(**book.model_dump(exclude={"embedding"})) for book in books],
|
||||
books=[BookRead(**book_data) for book_data in books_data],
|
||||
total=len(books),
|
||||
)
|
||||
|
||||
@@ -170,18 +192,23 @@ def get_book(
|
||||
)
|
||||
|
||||
authors = session.scalars(
|
||||
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id)
|
||||
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id) # ty: ignore
|
||||
).all()
|
||||
|
||||
author_reads = [AuthorRead(**author.model_dump()) for author in authors]
|
||||
|
||||
genres = session.scalars(
|
||||
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id)
|
||||
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id) # ty: ignore
|
||||
).all()
|
||||
|
||||
genre_reads = [GenreRead(**genre.model_dump()) for genre in genres]
|
||||
|
||||
book_data = book.model_dump(exclude={"embedding"})
|
||||
book_data = book.model_dump(exclude={"embedding", "preview_id"})
|
||||
book_data["preview_urls"] = {
|
||||
"png": f"/static/books/{book.preview_id}.png",
|
||||
"jpeg": f"/static/books/{book.preview_id}.jpg",
|
||||
"webp": f"/static/books/{book.preview_id}.webp",
|
||||
} if book.preview_id else {}
|
||||
book_data["authors"] = author_reads
|
||||
book_data["genres"] = genre_reads
|
||||
|
||||
@@ -233,11 +260,21 @@ def update_book(
|
||||
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text)
|
||||
db_book.embedding = emb["embedding"]
|
||||
|
||||
if book_update.page_count is not None:
|
||||
db_book.page_count = book_update.page_count
|
||||
|
||||
session.add(db_book)
|
||||
session.commit()
|
||||
session.refresh(db_book)
|
||||
|
||||
return BookRead(**db_book.model_dump(exclude={"embedding"}))
|
||||
book_data = db_book.model_dump(exclude={"embedding", "preview_id"})
|
||||
book_data["preview_urls"] = {
|
||||
"png": f"/static/books/{db_book.preview_id}.png",
|
||||
"jpeg": f"/static/books/{db_book.preview_id}.jpg",
|
||||
"webp": f"/static/books/{db_book.preview_id}.webp",
|
||||
} if db_book.preview_id else {}
|
||||
|
||||
return BookRead(**book_data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -267,3 +304,69 @@ def delete_book(
|
||||
session.delete(book)
|
||||
session.commit()
|
||||
return book_read
|
||||
|
||||
@router.post("/{book_id}/preview")
|
||||
async def upload_book_preview(
|
||||
current_user: RequireStaff,
|
||||
file: UploadFile = File(...),
|
||||
book_id: int = Path(..., gt=0),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
if not (file.content_type or "").startswith("image/"):
|
||||
raise HTTPException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Image required")
|
||||
|
||||
if (file.size or 0) > 32 * 1024 * 1024:
|
||||
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "File larger than 10 MB")
|
||||
|
||||
file_uuid= uuid4()
|
||||
tmp_path = BOOKS_PREVIEW_DIR / f"{file_uuid}.upload"
|
||||
|
||||
with open(tmp_path, "wb") as f:
|
||||
shutil.copyfileobj(file.file, f)
|
||||
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
tmp_path.unlink()
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
|
||||
|
||||
transcode_image(tmp_path)
|
||||
tmp_path.unlink()
|
||||
|
||||
if book.preview_id:
|
||||
for path in BOOKS_PREVIEW_DIR.glob(f"{book.preview_id}.*"):
|
||||
if path.exists():
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
book.preview_id = file_uuid
|
||||
session.add(book)
|
||||
session.commit()
|
||||
|
||||
return {
|
||||
"preview": {
|
||||
"png": f"/static/books/{file_uuid}.png",
|
||||
"jpeg": f"/static/books/{file_uuid}.jpg",
|
||||
"webp": f"/static/books/{file_uuid}.webp",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{book_id}/preview")
|
||||
async def remove_book_preview(
|
||||
current_user: RequireStaff,
|
||||
book_id: int = Path(..., gt=0),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
|
||||
|
||||
if book.preview_id:
|
||||
for path in BOOKS_PREVIEW_DIR.glob(f"{book.preview_id}.*"):
|
||||
if path.exists():
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
book.preview_id = None
|
||||
session.add(book)
|
||||
session.commit()
|
||||
|
||||
return {"preview_urls": []}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Модуль прочих эндпоинтов и веб-страниц"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -31,109 +33,117 @@ def get_info(app) -> Dict:
|
||||
"description": app.description.rsplit("|", 1)[0],
|
||||
},
|
||||
"server_time": datetime.now().isoformat(),
|
||||
"domain": os.getenv("DOMAIN", ""),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def root(request: Request):
|
||||
async def root(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит главную страницу"""
|
||||
return templates.TemplateResponse(request, "index.html")
|
||||
return templates.TemplateResponse(request, "index.html", get_info(app) | {"request": request, "title": "LiB - Библиотека"})
|
||||
|
||||
|
||||
@router.get("/unknown", include_in_schema=False)
|
||||
async def unknown(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу 404 ошибки"""
|
||||
return templates.TemplateResponse(request, "unknown.html", get_info(app) | {"request": request, "title": "LiB - Страница не найдена"})
|
||||
|
||||
|
||||
@router.get("/genre/create", include_in_schema=False)
|
||||
async def create_genre(request: Request):
|
||||
async def create_genre(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу создания жанра"""
|
||||
return templates.TemplateResponse(request, "create_genre.html")
|
||||
return templates.TemplateResponse(request, "create_genre.html", get_info(app) | {"request": request, "title": "LiB - Создать жанр"})
|
||||
|
||||
|
||||
@router.get("/genre/{genre_id}/edit", include_in_schema=False)
|
||||
async def edit_genre(request: Request, genre_id: int):
|
||||
async def edit_genre(request: Request, genre_id: int, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу редактирования жанра"""
|
||||
return templates.TemplateResponse(request, "edit_genre.html")
|
||||
return templates.TemplateResponse(request, "edit_genre.html", get_info(app) | {"request": request, "title": "LiB - Редактировать жанр", "id": genre_id})
|
||||
|
||||
|
||||
@router.get("/authors", include_in_schema=False)
|
||||
async def authors(request: Request):
|
||||
async def authors(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу списка авторов"""
|
||||
return templates.TemplateResponse(request, "authors.html")
|
||||
return templates.TemplateResponse(request, "authors.html", get_info(app) | {"request": request, "title": "LiB - Авторы"})
|
||||
|
||||
|
||||
@router.get("/author/create", include_in_schema=False)
|
||||
async def create_author(request: Request):
|
||||
async def create_author(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу создания автора"""
|
||||
return templates.TemplateResponse(request, "create_author.html")
|
||||
return templates.TemplateResponse(request, "create_author.html", get_info(app) | {"request": request, "title": "LiB - Создать автора"})
|
||||
|
||||
|
||||
@router.get("/author/{author_id}/edit", include_in_schema=False)
|
||||
async def edit_author(request: Request, author_id: int):
|
||||
async def edit_author(request: Request, author_id: int, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу редактирования автора"""
|
||||
return templates.TemplateResponse(request, "edit_author.html")
|
||||
return templates.TemplateResponse(request, "edit_author.html", get_info(app) | {"request": request, "title": "LiB - Редактировать автора", "id": author_id})
|
||||
|
||||
|
||||
@router.get("/author/{author_id}", include_in_schema=False)
|
||||
async def author(request: Request, author_id: int):
|
||||
async def author(request: Request, author_id: int, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу просмотра автора"""
|
||||
return templates.TemplateResponse(request, "author.html")
|
||||
return templates.TemplateResponse(request, "author.html", get_info(app) | {"request": request, "title": "LiB - Автор", "id": author_id})
|
||||
|
||||
|
||||
@router.get("/books", include_in_schema=False)
|
||||
async def books(request: Request):
|
||||
async def books(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу списка книг"""
|
||||
return templates.TemplateResponse(request, "books.html")
|
||||
return templates.TemplateResponse(request, "books.html", get_info(app) | {"request": request, "title": "LiB - Книги"})
|
||||
|
||||
|
||||
@router.get("/book/create", include_in_schema=False)
|
||||
async def create_book(request: Request):
|
||||
async def create_book(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу создания книги"""
|
||||
return templates.TemplateResponse(request, "create_book.html")
|
||||
return templates.TemplateResponse(request, "create_book.html", get_info(app) | {"request": request, "title": "LiB - Создать книгу"})
|
||||
|
||||
|
||||
@router.get("/book/{book_id}/edit", include_in_schema=False)
|
||||
async def edit_book(request: Request, book_id: int):
|
||||
async def edit_book(request: Request, book_id: int, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу редактирования книги"""
|
||||
return templates.TemplateResponse(request, "edit_book.html")
|
||||
return templates.TemplateResponse(request, "edit_book.html", get_info(app) | {"request": request, "title": "LiB - Редактировать книгу", "id": book_id})
|
||||
|
||||
|
||||
@router.get("/book/{book_id}", include_in_schema=False)
|
||||
async def book(request: Request, book_id: int):
|
||||
async def book(request: Request, book_id: int, app=Depends(lambda: get_app()), session=Depends(get_session)):
|
||||
"""Рендерит страницу просмотра книги"""
|
||||
return templates.TemplateResponse(request, "book.html")
|
||||
book = session.get(Book, book_id)
|
||||
return templates.TemplateResponse(request, "book.html", get_info(app) | {"request": request, "title": "LiB - Книга", "id": book_id, "img": book.preview_id})
|
||||
|
||||
|
||||
@router.get("/auth", include_in_schema=False)
|
||||
async def auth(request: Request):
|
||||
async def auth(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу авторизации"""
|
||||
return templates.TemplateResponse(request, "auth.html")
|
||||
return templates.TemplateResponse(request, "auth.html", get_info(app) | {"request": request, "title": "LiB - Авторизация"})
|
||||
|
||||
|
||||
@router.get("/2fa", include_in_schema=False)
|
||||
async def set2fa(request: Request):
|
||||
async def set2fa(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу установки двухфакторной аутентификации"""
|
||||
return templates.TemplateResponse(request, "2fa.html")
|
||||
return templates.TemplateResponse(request, "2fa.html", get_info(app) | {"request": request, "title": "LiB - Двухфакторная аутентификация"})
|
||||
|
||||
|
||||
@router.get("/profile", include_in_schema=False)
|
||||
async def profile(request: Request):
|
||||
async def profile(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу профиля пользователя"""
|
||||
return templates.TemplateResponse(request, "profile.html")
|
||||
return templates.TemplateResponse(request, "profile.html", get_info(app) | {"request": request, "title": "LiB - Профиль"})
|
||||
|
||||
|
||||
@router.get("/users", include_in_schema=False)
|
||||
async def users(request: Request):
|
||||
async def users(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу управления пользователями"""
|
||||
return templates.TemplateResponse(request, "users.html")
|
||||
return templates.TemplateResponse(request, "users.html", get_info(app) | {"request": request, "title": "LiB - Пользователи"})
|
||||
|
||||
|
||||
@router.get("/my-books", include_in_schema=False)
|
||||
async def my_books(request: Request):
|
||||
async def my_books(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу моих книг пользователя"""
|
||||
return templates.TemplateResponse(request, "my_books.html")
|
||||
return templates.TemplateResponse(request, "my_books.html", get_info(app) | {"request": request, "title": "LiB - Мои книги"})
|
||||
|
||||
|
||||
@router.get("/analytics", include_in_schema=False)
|
||||
async def analytics(request: Request):
|
||||
async def analytics(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу аналитики выдач"""
|
||||
return templates.TemplateResponse(request, "analytics.html")
|
||||
return templates.TemplateResponse(request, "analytics.html", get_info(app) | {"request": request, "title": "LiB - Аналитика"})
|
||||
|
||||
|
||||
@router.get("/favicon.ico", include_in_schema=False)
|
||||
@@ -172,6 +182,7 @@ async def api_info(app=Depends(lambda: get_app())):
|
||||
description="Возвращает схему базы данных с описаниями полей",
|
||||
)
|
||||
async def api_schema():
|
||||
"""Возвращает информацию для создания er-диаграммы"""
|
||||
return generator.generate()
|
||||
|
||||
|
||||
@@ -180,7 +191,7 @@ async def api_schema():
|
||||
summary="Статистика сервиса",
|
||||
description="Возвращает статистическую информацию о системе",
|
||||
)
|
||||
async def api_stats(session: Session = Depends(get_session)):
|
||||
async def api_stats(session=Depends(get_session)):
|
||||
"""Возвращает статистику системы"""
|
||||
authors = select(func.count()).select_from(Author)
|
||||
books = select(func.count()).select_from(Book)
|
||||
|
||||
@@ -13,6 +13,7 @@ from .captcha import (
|
||||
prng,
|
||||
)
|
||||
from .describe_er import SchemaGenerator
|
||||
from .image_processing import transcode_image
|
||||
|
||||
__all__ = [
|
||||
"limiter",
|
||||
@@ -28,4 +29,5 @@ __all__ = [
|
||||
"REDEEM_TTL",
|
||||
"prng",
|
||||
"SchemaGenerator",
|
||||
"transcode_image",
|
||||
]
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
"""Модуль генерации описания схемы БД"""
|
||||
|
||||
import enum
|
||||
import inspect
|
||||
from typing import List, Dict, Any, Set, Type, Tuple
|
||||
from typing import (
|
||||
List,
|
||||
Dict,
|
||||
Any,
|
||||
Set,
|
||||
Type,
|
||||
Tuple,
|
||||
Optional,
|
||||
Union,
|
||||
get_origin,
|
||||
get_args,
|
||||
)
|
||||
|
||||
from pydantic.fields import FieldInfo
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy.inspection import inspect as sa_inspect
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
@@ -184,6 +196,41 @@ class SchemaGenerator:
|
||||
|
||||
return relations
|
||||
|
||||
def _extract_enum_from_annotation(self, annotation) -> Optional[Type[enum.Enum]]:
|
||||
if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
|
||||
return annotation
|
||||
|
||||
origin = get_origin(annotation)
|
||||
if origin is Union:
|
||||
for arg in get_args(annotation):
|
||||
if isinstance(arg, type) and issubclass(arg, enum.Enum):
|
||||
return arg
|
||||
|
||||
return None
|
||||
|
||||
def _get_enum_values(self, model: Type[SQLModel], col) -> Optional[List[str]]:
|
||||
if isinstance(col.type, SAEnum):
|
||||
if col.type.enum_class is not None:
|
||||
return [e.value for e in col.type.enum_class]
|
||||
if col.type.enums:
|
||||
return list(col.type.enums)
|
||||
|
||||
try:
|
||||
annotations = {}
|
||||
for cls in model.__mro__:
|
||||
if hasattr(cls, "__annotations__"):
|
||||
annotations.update(cls.__annotations__)
|
||||
|
||||
if col.name in annotations:
|
||||
annotation = annotations[col.name]
|
||||
enum_class = self._extract_enum_from_annotation(annotation)
|
||||
if enum_class:
|
||||
return [e.value for e in enum_class]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def generate(self) -> Dict[str, Any]:
|
||||
entities = []
|
||||
|
||||
@@ -212,8 +259,19 @@ class SchemaGenerator:
|
||||
|
||||
field_obj = {"id": col.name, "label": label}
|
||||
|
||||
tooltip_parts = []
|
||||
|
||||
if col.name in descriptions:
|
||||
field_obj["tooltip"] = descriptions[col.name]
|
||||
tooltip_parts.append(descriptions[col.name])
|
||||
|
||||
enum_values = self._get_enum_values(model, col)
|
||||
if enum_values:
|
||||
tooltip_parts.append(
|
||||
"Варианты:\n" + "\n".join(f"• {v}" for v in enum_values)
|
||||
)
|
||||
|
||||
if tooltip_parts:
|
||||
field_obj["tooltip"] = "\n\n".join(tooltip_parts)
|
||||
|
||||
entity_fields.append(field_obj)
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
|
||||
TARGET_RATIO = 5 / 7
|
||||
|
||||
|
||||
def crop_image(img: Image.Image, target_ratio: float = TARGET_RATIO) -> Image.Image:
|
||||
w, h = img.size
|
||||
current_ratio = w / h
|
||||
|
||||
if current_ratio > target_ratio:
|
||||
new_w = int(h * target_ratio)
|
||||
left = (w - new_w) // 2
|
||||
right = left + new_w
|
||||
top = 0
|
||||
bottom = h
|
||||
else:
|
||||
new_h = int(w / target_ratio)
|
||||
top = (h - new_h) // 2
|
||||
bottom = top + new_h
|
||||
left = 0
|
||||
right = w
|
||||
|
||||
return img.crop((left, top, right, bottom))
|
||||
|
||||
|
||||
def transcode_image(
|
||||
src_path: str | Path,
|
||||
*,
|
||||
jpeg_quality: int = 85,
|
||||
webp_quality: int = 80,
|
||||
webp_lossless: bool = False,
|
||||
resize_to: tuple[int, int] | None = None,
|
||||
):
|
||||
src_path = Path(src_path)
|
||||
|
||||
if not src_path.exists():
|
||||
raise FileNotFoundError(src_path)
|
||||
|
||||
stem = src_path.stem
|
||||
folder = src_path.parent
|
||||
|
||||
img = Image.open(src_path).convert("RGBA")
|
||||
img = crop_image(img)
|
||||
|
||||
if resize_to:
|
||||
img = img.resize(resize_to, Image.LANCZOS)
|
||||
|
||||
png_path = folder / f"{stem}.png"
|
||||
img.save(
|
||||
png_path,
|
||||
format="PNG",
|
||||
optimize=True,
|
||||
interlace=1,
|
||||
)
|
||||
|
||||
jpg_path = folder / f"{stem}.jpg"
|
||||
img.convert("RGB").save(
|
||||
jpg_path,
|
||||
format="JPEG",
|
||||
quality=jpeg_quality,
|
||||
progressive=True,
|
||||
optimize=True,
|
||||
subsampling="4:2:0",
|
||||
)
|
||||
|
||||
webp_path = folder / f"{stem}.webp"
|
||||
img.save(
|
||||
webp_path,
|
||||
format="WEBP",
|
||||
quality=webp_quality,
|
||||
lossless=webp_lossless,
|
||||
method=6,
|
||||
)
|
||||
|
||||
return {
|
||||
"png": png_path,
|
||||
"jpeg": jpg_path,
|
||||
"webp": webp_path,
|
||||
}
|
||||
@@ -10,6 +10,9 @@ from toml import load
|
||||
|
||||
load_dotenv()
|
||||
|
||||
BOOKS_PREVIEW_DIR = Path(__file__).parent / "static" / "books"
|
||||
BOOKS_PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open("pyproject.toml", "r", encoding="utf-8") as f:
|
||||
_pyproject = load(f)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ $(() => {
|
||||
loginForm: "#login-form",
|
||||
registerForm: "#register-form",
|
||||
resetForm: "#reset-password-form",
|
||||
authTabs: "#auth-tabs",
|
||||
loginTab: "#login-tab",
|
||||
registerTab: "#register-tab",
|
||||
forgotBtn: "#forgot-password-btn",
|
||||
@@ -121,6 +122,13 @@ $(() => {
|
||||
};
|
||||
|
||||
const showForm = (formId) => {
|
||||
let newHash = "";
|
||||
if (formId === SELECTORS.loginForm) newHash = "login";
|
||||
else if (formId === SELECTORS.registerForm) newHash = "register";
|
||||
else if (formId === SELECTORS.resetForm) newHash = "reset";
|
||||
if (newHash && window.location.hash !== "#" + newHash) {
|
||||
window.history.pushState(null, null, "#" + newHash);
|
||||
}
|
||||
$(
|
||||
`${SELECTORS.loginForm}, ${SELECTORS.registerForm}, ${SELECTORS.resetForm}`,
|
||||
).addClass("hidden");
|
||||
@@ -142,6 +150,17 @@ $(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleHash = () => {
|
||||
const hash = window.location.hash.toLowerCase();
|
||||
if (hash === "#register" || hash === "#signup") {
|
||||
showForm(SELECTORS.registerForm);
|
||||
$(SELECTORS.registerTab).trigger("click");
|
||||
} else if (hash === "#login" || hash === "#signin") {
|
||||
showForm(SELECTORS.loginForm);
|
||||
$(SELECTORS.loginTab).trigger("click");
|
||||
}
|
||||
};
|
||||
|
||||
const resetLoginState = () => {
|
||||
clearPartialToken();
|
||||
stopTotpTimer();
|
||||
@@ -151,6 +170,7 @@ $(() => {
|
||||
username: "",
|
||||
rememberMe: false,
|
||||
};
|
||||
$(SELECTORS.authTabs).removeClass("hide-animated");
|
||||
$(SELECTORS.totpSection).addClass("hidden");
|
||||
$(SELECTORS.totpInput).val("");
|
||||
$(SELECTORS.credentialsSection).removeClass("hidden");
|
||||
@@ -185,6 +205,7 @@ $(() => {
|
||||
const savedToken = sessionStorage.getItem(STORAGE_KEYS.partialToken);
|
||||
const savedUsername = sessionStorage.getItem(STORAGE_KEYS.partialUsername);
|
||||
if (savedToken && savedUsername) {
|
||||
$(SELECTORS.authTabs).addClass("hide-animated");
|
||||
loginState.partialToken = savedToken;
|
||||
loginState.username = savedUsername;
|
||||
loginState.step = "2fa";
|
||||
@@ -279,6 +300,7 @@ $(() => {
|
||||
loginState.partialToken = data.partial_token;
|
||||
loginState.step = "2fa";
|
||||
savePartialToken(data.partial_token, username);
|
||||
$(SELECTORS.authTabs).addClass("hide-animated");
|
||||
$(SELECTORS.credentialsSection).addClass("hidden");
|
||||
$(SELECTORS.totpSection).removeClass("hidden");
|
||||
startTotpTimer();
|
||||
@@ -362,6 +384,19 @@ $(() => {
|
||||
}, 1500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Debug error object:", error);
|
||||
|
||||
const cleanMsg = (text) => {
|
||||
if (!text) return "";
|
||||
if (text.includes("value is not a valid email address")) {
|
||||
return "Некорректный адрес электронной почты";
|
||||
}
|
||||
|
||||
text = text.replace(/^Value error,\s*/i, "");
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
};
|
||||
|
||||
let msg = "Ошибка регистрации";
|
||||
if (error.detail && error.detail.error === "captcha_required") {
|
||||
Utils.showToast(TEXTS.captchaRequired, "error");
|
||||
const $capElement = $(SELECTORS.capWidget);
|
||||
@@ -372,11 +407,19 @@ $(() => {
|
||||
);
|
||||
return;
|
||||
}
|
||||
let msg = error.message;
|
||||
|
||||
if (error.detail && Array.isArray(error.detail)) {
|
||||
msg = error.detail.map((e) => e.msg).join(". ");
|
||||
msg = error.detail.map((e) => cleanMsg(e.msg)).join(". ");
|
||||
} else if (Array.isArray(error)) {
|
||||
msg = error.map((e) => cleanMsg(e.msg || e.message)).join(". ");
|
||||
} else if (typeof error.detail === "string") {
|
||||
msg = cleanMsg(error.detail);
|
||||
} else if (error.message && !error.message.includes("[object Object]")) {
|
||||
msg = cleanMsg(error.message);
|
||||
}
|
||||
Utils.showToast(msg || "Ошибка регистрации", "error");
|
||||
|
||||
console.log("Resulting msg:", msg);
|
||||
Utils.showToast(msg, "error");
|
||||
} finally {
|
||||
$submitBtn
|
||||
.prop("disabled", false)
|
||||
@@ -544,6 +587,7 @@ $(() => {
|
||||
};
|
||||
|
||||
initLoginState();
|
||||
handleHash();
|
||||
|
||||
const widget = $(SELECTORS.capWidget).get(0);
|
||||
if (widget && widget.shadowRoot) {
|
||||
|
||||
@@ -6,6 +6,11 @@ $(document).ready(() => {
|
||||
let currentSort = "name_asc";
|
||||
|
||||
loadAuthors();
|
||||
const USER_CAN_MANAGE =
|
||||
typeof window.canManage === "function" && window.canManage();
|
||||
if (USER_CAN_MANAGE) {
|
||||
$("#add-author-btn").removeClass("hidden");
|
||||
}
|
||||
|
||||
function loadAuthors() {
|
||||
showLoadingState();
|
||||
|
||||
@@ -34,6 +34,7 @@ $(document).ready(() => {
|
||||
|
||||
const pathParts = window.location.pathname.split("/");
|
||||
const bookId = parseInt(pathParts[pathParts.length - 1]);
|
||||
let isDraggingOver = false;
|
||||
let currentBook = null;
|
||||
let cachedUsers = null;
|
||||
let selectedLoanUserId = null;
|
||||
@@ -48,6 +49,28 @@ $(document).ready(() => {
|
||||
}
|
||||
loadBookData();
|
||||
setupEventHandlers();
|
||||
setupCoverUpload();
|
||||
}
|
||||
|
||||
function getPreviewUrl(book) {
|
||||
if (!book.preview_urls) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const priorities = ["webp", "jpeg", "jpg", "png"];
|
||||
|
||||
for (const format of priorities) {
|
||||
if (book.preview_urls[format]) {
|
||||
return book.preview_urls[format];
|
||||
}
|
||||
}
|
||||
|
||||
const availableFormats = Object.keys(book.preview_urls);
|
||||
if (availableFormats.length > 0) {
|
||||
return book.preview_urls[availableFormats[0]];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function setupEventHandlers() {
|
||||
@@ -75,6 +98,270 @@ $(document).ready(() => {
|
||||
$("#loan-due-date").val(future.toISOString().split("T")[0]);
|
||||
}
|
||||
|
||||
function setupCoverUpload() {
|
||||
const $container = $("#book-cover-container");
|
||||
const $fileInput = $("#cover-file-input");
|
||||
|
||||
$fileInput.on("change", function (e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
uploadCover(file);
|
||||
}
|
||||
$(this).val("");
|
||||
});
|
||||
|
||||
$container.on("dragenter", function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!window.canManage()) return;
|
||||
isDraggingOver = true;
|
||||
showDropOverlay();
|
||||
});
|
||||
|
||||
$container.on("dragover", function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!window.canManage()) return;
|
||||
isDraggingOver = true;
|
||||
});
|
||||
|
||||
$container.on("dragleave", function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!window.canManage()) return;
|
||||
|
||||
const rect = this.getBoundingClientRect();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||
isDraggingOver = false;
|
||||
hideDropOverlay();
|
||||
}
|
||||
});
|
||||
|
||||
$container.on("drop", function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!window.canManage()) return;
|
||||
|
||||
isDraggingOver = false;
|
||||
hideDropOverlay();
|
||||
|
||||
const files = e.dataTransfer?.files || [];
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
Utils.showToast("Пожалуйста, загрузите изображение", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
uploadCover(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showDropOverlay() {
|
||||
const $container = $("#book-cover-container");
|
||||
$container.find(".drop-overlay").remove();
|
||||
|
||||
const $overlay = $(`
|
||||
<div class="drop-overlay absolute inset-0 flex flex-col items-center justify-center z-20 pointer-events-none">
|
||||
<div class="absolute inset-2 border-2 border-dashed border-gray-600 rounded-lg"></div>
|
||||
<svg class="w-10 h-10 text-gray-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
|
||||
</svg>
|
||||
<span class="text-gray-700 text-sm font-medium text-center px-4">Отпустите для загрузки</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$container.append($overlay);
|
||||
}
|
||||
|
||||
function hideDropOverlay() {
|
||||
$("#book-cover-container .drop-overlay").remove();
|
||||
}
|
||||
|
||||
async function uploadCover(file) {
|
||||
const $container = $("#book-cover-container");
|
||||
|
||||
const maxSize = 32 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
Utils.showToast("Файл слишком большой. Максимум 32 MB", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
Utils.showToast("Пожалуйста, загрузите изображение", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const $loader = $(`
|
||||
<div class="upload-loader absolute inset-0 bg-black bg-opacity-50 flex flex-col items-center justify-center z-20">
|
||||
<svg class="animate-spin w-8 h-8 text-white mb-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span class="text-white text-sm">Загрузка...</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$container.find(".upload-loader").remove();
|
||||
$container.append($loader);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await Api.uploadFile(
|
||||
`/api/books/${bookId}/preview`,
|
||||
formData,
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.preview) {
|
||||
currentBook.preview_urls = response.preview;
|
||||
} else if (response.preview_urls) {
|
||||
currentBook.preview_urls = response.preview_urls;
|
||||
} else {
|
||||
currentBook = response;
|
||||
}
|
||||
|
||||
Utils.showToast("Обложка успешно загружена", "success");
|
||||
renderBookCover(currentBook);
|
||||
} catch (error) {
|
||||
console.error("Upload error:", error);
|
||||
Utils.showToast(error.message || "Ошибка загрузки обложки", "error");
|
||||
} finally {
|
||||
$container.find(".upload-loader").remove();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCover() {
|
||||
if (!confirm("Удалить обложку книги?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $container = $("#book-cover-container");
|
||||
|
||||
const $loader = $(`
|
||||
<div class="upload-loader absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center z-20">
|
||||
<svg class="animate-spin w-8 h-8 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$container.find(".upload-loader").remove();
|
||||
$container.append($loader);
|
||||
|
||||
try {
|
||||
await Api.delete(`/api/books/${bookId}/preview`);
|
||||
|
||||
currentBook.preview_urls = null;
|
||||
Utils.showToast("Обложка удалена", "success");
|
||||
renderBookCover(currentBook);
|
||||
} catch (error) {
|
||||
console.error("Delete error:", error);
|
||||
Utils.showToast(error.message || "Ошибка удаления обложки", "error");
|
||||
} finally {
|
||||
$container.find(".upload-loader").remove();
|
||||
}
|
||||
}
|
||||
|
||||
function renderBookCover(book) {
|
||||
const $container = $("#book-cover-container");
|
||||
const canManage = window.canManage();
|
||||
const previewUrl = getPreviewUrl(book);
|
||||
|
||||
if (previewUrl) {
|
||||
$container.html(`
|
||||
<img
|
||||
src="${Utils.escapeHtml(previewUrl)}"
|
||||
alt="Обложка книги ${Utils.escapeHtml(book.title)}"
|
||||
class="w-full h-full object-cover"
|
||||
onerror="this.onerror=null; this.parentElement.querySelector('.cover-fallback').classList.remove('hidden'); this.classList.add('hidden');"
|
||||
/>
|
||||
<div class="cover-fallback hidden w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center absolute inset-0">
|
||||
<svg class="w-20 h-20 text-white opacity-80" 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>
|
||||
${
|
||||
canManage
|
||||
? `
|
||||
<button
|
||||
id="delete-cover-btn"
|
||||
class="absolute top-2 right-2 w-7 h-7 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||
title="Удалить обложку"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all flex flex-col items-center justify-center cursor-pointer z-0" id="cover-replace-overlay">
|
||||
<svg class="w-8 h-8 text-white opacity-0 group-hover:opacity-100 transition-opacity mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
|
||||
</svg>
|
||||
<span class="text-white text-center opacity-0 group-hover:opacity-100 transition-opacity text-xs font-medium pointer-events-none px-2">
|
||||
Заменить
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
`);
|
||||
|
||||
if (canManage) {
|
||||
$("#delete-cover-btn").on("click", function (e) {
|
||||
e.stopPropagation();
|
||||
deleteCover();
|
||||
});
|
||||
|
||||
$("#cover-replace-overlay").on("click", function () {
|
||||
$("#cover-file-input").trigger("click");
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (canManage) {
|
||||
$container.html(`
|
||||
<div
|
||||
id="cover-upload-zone"
|
||||
class="w-full h-full bg-gray-100 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-200 transition-all text-center relative"
|
||||
>
|
||||
<div class="absolute inset-2 border-2 border-dashed border-gray-300 rounded-lg pointer-events-none"></div>
|
||||
<svg class="w-8 h-8 text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
|
||||
</svg>
|
||||
<span class="text-gray-500 text-xs font-medium px-2">
|
||||
Добавить обложку
|
||||
</span>
|
||||
<span class="text-gray-400 text-xs mt-1 px-2">
|
||||
или перетащите
|
||||
</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$("#cover-upload-zone").on("click", function () {
|
||||
$("#cover-file-input").trigger("click");
|
||||
});
|
||||
} else {
|
||||
$container.html(`
|
||||
<div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
|
||||
<svg class="w-20 h-20 text-white opacity-80" 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>
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadBookData() {
|
||||
Api.get(`/api/books/${bookId}`)
|
||||
.then((book) => {
|
||||
@@ -234,12 +521,16 @@ $(document).ready(() => {
|
||||
function renderBook(book) {
|
||||
$("#book-title").text(book.title);
|
||||
$("#book-id").text(`ID: ${book.id}`);
|
||||
|
||||
renderBookCover(book);
|
||||
|
||||
if (book.page_count && book.page_count > 0) {
|
||||
$("#book-page-count-value").text(book.page_count);
|
||||
$("#book-page-count-text").removeClass("hidden");
|
||||
} else {
|
||||
$("#book-page-count-text").addClass("hidden");
|
||||
}
|
||||
|
||||
$("#book-authors-text").text(
|
||||
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен",
|
||||
);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const $svg = $("#canvas");
|
||||
|
||||
const CONFIG = {
|
||||
holeRadius: 60,
|
||||
maxRadius: 220,
|
||||
tilt: 0.4,
|
||||
|
||||
ringsCount: 7,
|
||||
ringSpeed: 0.002,
|
||||
ringStroke: 5,
|
||||
|
||||
particlesCount: 40,
|
||||
particleSpeedBase: 0.02,
|
||||
particleFallSpeed: 0.2,
|
||||
};
|
||||
|
||||
function create(tag, attrs) {
|
||||
const el = document.createElementNS(NS, tag);
|
||||
for (let k in attrs) el.setAttribute(k, attrs[k]);
|
||||
return el;
|
||||
}
|
||||
|
||||
const $layerBack = $(create("g", { id: "layer-back" }));
|
||||
const $layerHole = $(create("g", { id: "layer-hole" }));
|
||||
const $layerFront = $(create("g", { id: "layer-front" }));
|
||||
|
||||
$svg.append($layerBack, $layerHole, $layerFront);
|
||||
|
||||
const holeHalo = create("circle", {
|
||||
cx: 0,
|
||||
cy: 0,
|
||||
r: CONFIG.holeRadius + 4,
|
||||
fill: "#ffffff",
|
||||
stroke: "none",
|
||||
});
|
||||
const holeBody = create("circle", {
|
||||
cx: 0,
|
||||
cy: 0,
|
||||
r: CONFIG.holeRadius,
|
||||
fill: "#000000",
|
||||
});
|
||||
$layerHole.append(holeHalo, holeBody);
|
||||
|
||||
class Ring {
|
||||
constructor(offset) {
|
||||
this.progress = offset;
|
||||
|
||||
const style = {
|
||||
fill: "none",
|
||||
stroke: "#000",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-width": CONFIG.ringStroke,
|
||||
};
|
||||
|
||||
this.elBack = create("path", style);
|
||||
this.elFront = create("path", style);
|
||||
|
||||
$layerBack.append(this.elBack);
|
||||
$layerFront.append(this.elFront);
|
||||
}
|
||||
|
||||
update() {
|
||||
this.progress += CONFIG.ringSpeed;
|
||||
if (this.progress >= 1) this.progress -= 1;
|
||||
|
||||
const t = this.progress;
|
||||
|
||||
const currentR =
|
||||
CONFIG.maxRadius - t * (CONFIG.maxRadius - CONFIG.holeRadius);
|
||||
const currentRy = currentR * CONFIG.tilt;
|
||||
|
||||
const distFromHole = currentR - CONFIG.holeRadius;
|
||||
const distFromEdge = CONFIG.maxRadius - currentR;
|
||||
|
||||
const fadeHole = Math.min(1, distFromHole / 40);
|
||||
const fadeEdge = Math.min(1, distFromEdge / 40);
|
||||
|
||||
const opacity = fadeHole * fadeEdge;
|
||||
|
||||
if (opacity <= 0.01) {
|
||||
this.elBack.setAttribute("opacity", 0);
|
||||
this.elFront.setAttribute("opacity", 0);
|
||||
} else {
|
||||
const dBack = `M ${-currentR} 0 A ${currentR} ${currentRy} 0 0 1 ${currentR} 0`;
|
||||
const dFront = `M ${-currentR} 0 A ${currentR} ${currentRy} 0 0 0 ${currentR} 0`;
|
||||
|
||||
this.elBack.setAttribute("d", dBack);
|
||||
this.elFront.setAttribute("d", dFront);
|
||||
|
||||
this.elBack.setAttribute("opacity", opacity);
|
||||
this.elFront.setAttribute("opacity", opacity);
|
||||
|
||||
const sw =
|
||||
CONFIG.ringStroke *
|
||||
(0.6 + 0.4 * (distFromHole / (CONFIG.maxRadius - CONFIG.holeRadius)));
|
||||
this.elBack.setAttribute("stroke-width", sw);
|
||||
this.elFront.setAttribute("stroke-width", sw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Particle {
|
||||
constructor() {
|
||||
this.el = create("circle", { fill: "#000" });
|
||||
this.reset(true);
|
||||
$layerFront.append(this.el);
|
||||
this.inFront = true;
|
||||
}
|
||||
|
||||
reset(randomStart = false) {
|
||||
this.angle = Math.random() * Math.PI * 2;
|
||||
this.r = randomStart
|
||||
? CONFIG.holeRadius +
|
||||
Math.random() * (CONFIG.maxRadius - CONFIG.holeRadius)
|
||||
: CONFIG.maxRadius;
|
||||
|
||||
this.speed = CONFIG.particleSpeedBase + Math.random() * 0.02;
|
||||
this.size = 1.5 + Math.random() * 2.5;
|
||||
}
|
||||
|
||||
update() {
|
||||
const acceleration = CONFIG.maxRadius / this.r;
|
||||
this.angle += this.speed * acceleration;
|
||||
this.r -= CONFIG.particleFallSpeed * (acceleration * 0.8);
|
||||
|
||||
const x = Math.cos(this.angle) * this.r;
|
||||
const y = Math.sin(this.angle) * this.r * CONFIG.tilt;
|
||||
|
||||
const isNowFront = Math.sin(this.angle) > 0;
|
||||
|
||||
if (this.inFront !== isNowFront) {
|
||||
this.inFront = isNowFront;
|
||||
if (this.inFront) {
|
||||
$layerFront.append(this.el);
|
||||
} else {
|
||||
$layerBack.append(this.el);
|
||||
}
|
||||
}
|
||||
|
||||
const distFromHole = this.r - CONFIG.holeRadius;
|
||||
const distFromEdge = CONFIG.maxRadius - this.r;
|
||||
|
||||
const fadeHole = Math.min(1, distFromHole / 30);
|
||||
const fadeEdge = Math.min(1, distFromEdge / 30);
|
||||
const opacity = fadeHole * fadeEdge;
|
||||
|
||||
this.el.setAttribute("cx", x);
|
||||
this.el.setAttribute("cy", y);
|
||||
this.el.setAttribute("r", this.size * Math.min(1, this.r / 100));
|
||||
this.el.setAttribute("opacity", opacity);
|
||||
|
||||
if (this.r <= CONFIG.holeRadius) {
|
||||
this.reset(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rings = [];
|
||||
for (let i = 0; i < CONFIG.ringsCount; i++) {
|
||||
rings.push(new Ring(i / CONFIG.ringsCount));
|
||||
}
|
||||
|
||||
const particles = [];
|
||||
for (let i = 0; i < CONFIG.particlesCount; i++) {
|
||||
particles.push(new Particle());
|
||||
}
|
||||
|
||||
function animate() {
|
||||
rings.forEach((r) => r.update());
|
||||
particles.forEach((p) => p.update());
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
animate();
|
||||
@@ -112,11 +112,18 @@ const Api = {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.detail ||
|
||||
errorData.error_description ||
|
||||
`Ошибка ${response.status}`,
|
||||
);
|
||||
const error = new Error("API Error");
|
||||
Object.assign(error, errorData);
|
||||
|
||||
if (typeof errorData.detail === "string") {
|
||||
error.message = errorData.detail;
|
||||
} else if (errorData.error_description) {
|
||||
error.message = errorData.error_description;
|
||||
} else if (!errorData.detail) {
|
||||
error.message = `Ошибка ${response.status}`;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
@@ -153,6 +160,67 @@ const Api = {
|
||||
body: formData.toString(),
|
||||
});
|
||||
},
|
||||
|
||||
async uploadFile(endpoint, formData) {
|
||||
const fullUrl = this.getBaseUrl() + endpoint;
|
||||
const token = StorageHelper.get("access_token");
|
||||
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(fullUrl, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
const refreshed = await Auth.tryRefresh();
|
||||
if (refreshed) {
|
||||
headers["Authorization"] =
|
||||
`Bearer ${StorageHelper.get("access_token")}`;
|
||||
const retryResponse = await fetch(fullUrl, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
if (retryResponse.ok) {
|
||||
return retryResponse.json();
|
||||
}
|
||||
}
|
||||
Auth.logout();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
let errorMessage = `Ошибка ${response.status}`;
|
||||
|
||||
if (typeof errorData.detail === "string") {
|
||||
errorMessage = errorData.detail;
|
||||
} else if (Array.isArray(errorData.detail)) {
|
||||
errorMessage = errorData.detail.map((e) => e.msg || e).join(", ");
|
||||
} else if (errorData.detail?.message) {
|
||||
errorMessage = errorData.detail.message;
|
||||
} else if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
}
|
||||
|
||||
const error = new Error(errorMessage);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const Auth = {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Настройка 2FA{% endblock %}
|
||||
{% block content %}
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="flex flex-1 items-center justify-center p-4 bg-gray-100">
|
||||
<div
|
||||
class="w-full max-w-4xl bg-white rounded-lg shadow-md overflow-hidden flex flex-col md:flex-row"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Аналитика{% endblock %}
|
||||
{% block content %}
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="container mx-auto p-4 max-w-7xl">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Аналитика выдач и возвратов</h1>
|
||||
@@ -10,8 +9,8 @@
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="text-sm font-medium text-gray-600">Период анализа:</label>
|
||||
<select id="period-select" class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-gray-400 transition bg-white">
|
||||
<option value="7">7 дней</option>
|
||||
<option value="30" selected>30 дней</option>
|
||||
<option value="7" selected>7 дней</option>
|
||||
<option value="30">30 дней</option>
|
||||
<option value="90">90 дней</option>
|
||||
<option value="180">180 дней</option>
|
||||
<option value="365">365 дней</option>
|
||||
|
||||
@@ -168,7 +168,8 @@
|
||||
jsPlumb.ready(function () {
|
||||
const instance = jsPlumb.getInstance({
|
||||
Container: "erDiagram",
|
||||
Connector: ["Flowchart", { stub: 30, gap: 10, cornerRadius: 5, alwaysRespectStubs: true }]
|
||||
Endpoint: "Blank",
|
||||
Connector: ["Flowchart", { stub: 30, gap: 0, cornerRadius: 5, alwaysRespectStubs: true }]
|
||||
});
|
||||
|
||||
const container = document.getElementById("erDiagram");
|
||||
@@ -274,16 +275,55 @@
|
||||
});
|
||||
|
||||
diagramData.relations.forEach(rel => {
|
||||
const overlays = [];
|
||||
|
||||
if (rel.fromMultiplicity === '1') {
|
||||
overlays.push(["Arrow", {
|
||||
location: 8, width: 14, length: 1, foldback: 1, direction: 1,
|
||||
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
|
||||
}]);
|
||||
overlays.push(["Arrow", {
|
||||
location: 14, width: 14, length: 1, foldback: 1, direction: 1,
|
||||
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
|
||||
}]);
|
||||
} else if (rel.fromMultiplicity === 'N' || rel.fromMultiplicity === '*') {
|
||||
overlays.push(["Arrow", {
|
||||
location: 8, width: 14, length: 1, foldback: 1, direction: 1,
|
||||
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
|
||||
}]);
|
||||
overlays.push(["Arrow", {
|
||||
location: 10, width: 14, length: 10, foldback: 0.1, direction: 1,
|
||||
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
|
||||
}]);
|
||||
}
|
||||
|
||||
if (rel.toMultiplicity === '1') {
|
||||
overlays.push(["Arrow", {
|
||||
location: -8, width: 14, length: 1, foldback: 1, direction: -1,
|
||||
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
|
||||
}]);
|
||||
overlays.push(["Arrow", {
|
||||
location: -14, width: 14, length: 1, foldback: 1, direction: -1,
|
||||
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
|
||||
}]);
|
||||
} else if (rel.toMultiplicity === 'N' || rel.toMultiplicity === '*') {
|
||||
overlays.push(["Arrow", {
|
||||
location: -8, width: 14, length: 1, foldback: 1, direction: -1,
|
||||
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
|
||||
}]);
|
||||
overlays.push(["Arrow", {
|
||||
location: -10, width: 14, length: 10, foldback: 0.1, direction: -1,
|
||||
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
|
||||
}]);
|
||||
}
|
||||
|
||||
instance.connect({
|
||||
source: `field-${rel.fromEntity}-${rel.fromField}`,
|
||||
target: `field-${rel.toEntity}-${rel.toField}`,
|
||||
anchor: ["Continuous", { faces: ["left", "right"] }],
|
||||
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2 },
|
||||
hoverPaintStyle: { stroke: "#3498db", strokeWidth: 3 },
|
||||
overlays: [
|
||||
["Label", { label: rel.fromMultiplicity || "", location: 0.1, cssClass: "relation-label" }],
|
||||
["Label", { label: rel.toMultiplicity || "", location: 0.9, cssClass: "relation-label" }]
|
||||
]
|
||||
overlays: overlays
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Авторизация{% endblock %}
|
||||
{% block content %}
|
||||
{% extends "base.html" %}{% 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">
|
||||
<div id="auth-tabs" 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">
|
||||
Вход
|
||||
@@ -84,7 +83,7 @@
|
||||
|
||||
<div class="mb-6">
|
||||
<input type="text" id="login-totp" name="totp_code"
|
||||
class="w-full px-4 py-4 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200 text-center text-3xl tracking-[0.5em] font-mono"
|
||||
class="w-full p-4 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200 text-center text-3xl tracking-[0.5em] font-mono"
|
||||
placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code" />
|
||||
</div>
|
||||
|
||||
@@ -98,7 +97,7 @@
|
||||
</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">
|
||||
class="w-full bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium">
|
||||
Войти
|
||||
</button>
|
||||
</form>
|
||||
@@ -340,6 +339,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
#auth-tabs {
|
||||
transition: transform 0.3s ease, opacity 0.2s ease, height 0.1s ease;
|
||||
transform: translateY(0);
|
||||
}
|
||||
#auth-tabs.hide-animated {
|
||||
transform: translateY(-12px);
|
||||
pointer-events: none;
|
||||
height: 0; opacity: 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget"></script>
|
||||
<script src="/static/page/auth.js"></script>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Автор{% endblock %}
|
||||
{% block content %}
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="container mx-auto p-4 max-w-4xl">
|
||||
<div id="author-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Авторы{% endblock %}
|
||||
{% block content %}
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="container mx-auto p-4">
|
||||
<div
|
||||
class="flex flex-col md:flex-row justify-between items-center mb-6 gap-4"
|
||||
@@ -7,6 +6,12 @@
|
||||
<h2 class="text-2xl font-bold text-gray-800">Авторы</h2>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 w-full md:w-auto">
|
||||
<a href="/author/create" id="add-author-btn" class="hidden flex justify-center items-center px-4 py-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition whitespace-nowrap">
|
||||
<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 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Добавить автора
|
||||
</a>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<title>{% block title %}LiB{% endblock %}</title>
|
||||
<title>{{ title }}</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta property="og:title" content="{{ title }}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:description" content="Ваша персональная библиотека книг" />
|
||||
<meta property="og:url" content="{{ request.url.scheme }}://{{ domain }}/" />
|
||||
<script
|
||||
defer
|
||||
src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"
|
||||
@@ -18,6 +22,7 @@
|
||||
class="flex flex-col min-h-screen bg-gray-100"
|
||||
x-data="{
|
||||
user: null,
|
||||
menuOpen: false,
|
||||
async init() {
|
||||
document.addEventListener('auth:login', async (e) => {
|
||||
this.user = e.detail;
|
||||
@@ -28,31 +33,43 @@
|
||||
}"
|
||||
>
|
||||
<header class="bg-gray-600 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="/">
|
||||
<div class="mx-auto px-3 md:pl-5 md:pr-3 flex justify-between items-center">
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
@click="menuOpen = !menuOpen"
|
||||
class="md:hidden flex gap-2 items-center hover:opacity-80 transition focus:outline-none"
|
||||
:aria-expanded="menuOpen"
|
||||
aria-label="Меню навигации"
|
||||
>
|
||||
<img class="invert max-w-10 h-auto" src="/static/logo.svg" />
|
||||
<h1 class="text-xl font-bold">
|
||||
<span class="text-gray-300 mr-1">≡</span>LiB
|
||||
</h1>
|
||||
</button>
|
||||
|
||||
<a class="hidden md: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>
|
||||
</div>
|
||||
|
||||
<nav class="hidden md:block">
|
||||
<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
|
||||
>
|
||||
<a href="/books" class="hover:text-gray-200">Книги</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/authors" class="hover:text-gray-200"
|
||||
>Авторы</a
|
||||
>
|
||||
<a href="/authors" class="hover:text-gray-200">Авторы</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/api" class="hover:text-gray-200">API</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="relative" x-data="{ open: false }">
|
||||
<template x-if="!user">
|
||||
<a
|
||||
@@ -104,7 +121,7 @@
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition
|
||||
class="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden text-gray-900"
|
||||
class="absolute right-0 mt-2 w-56 max-w-[calc(100vw-2rem)] bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden text-gray-900"
|
||||
style="display: none"
|
||||
>
|
||||
<div class="px-4 py-3 border-b border-gray-200">
|
||||
@@ -229,17 +246,71 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
x-show="menuOpen"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
@click.outside="menuOpen = false"
|
||||
class="md:hidden mt-4 pb-2 border-t border-gray-500"
|
||||
style="display: none"
|
||||
>
|
||||
<ul class="flex flex-col space-y-1 pt-3">
|
||||
<li>
|
||||
<a
|
||||
href="/"
|
||||
@click="menuOpen = false"
|
||||
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
|
||||
>
|
||||
Главная
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/books"
|
||||
@click="menuOpen = false"
|
||||
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
|
||||
>
|
||||
Книги
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/authors"
|
||||
@click="menuOpen = false"
|
||||
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
|
||||
>
|
||||
Авторы
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/api"
|
||||
@click="menuOpen = false"
|
||||
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
|
||||
>
|
||||
API
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="flex-grow">{% block content %}{% endblock %}</main>
|
||||
<div
|
||||
id="toast-container"
|
||||
class="fixed bottom-5 right-5 flex flex-col gap-2 z-50"
|
||||
class="fixed bottom-5 left-4 right-4 md:left-auto md:right-5 flex flex-col gap-2 z-50 items-center md:items-end"
|
||||
></div>
|
||||
|
||||
<footer class="bg-gray-800 text-white p-4 mt-8">
|
||||
<div class="container mx-auto text-center">
|
||||
<p>© 2026 LiB Library. Разработано в рамках дипломного проекта.
|
||||
Код открыт под лицензией <a href="https://github.com/wowlikon/LiB/blob/main/LICENSE">MIT</a>.
|
||||
<div class="container mx-auto text-center text-sm md:text-base">
|
||||
<p>
|
||||
© 2026 LiB Library. Разработано в рамках дипломного проекта.
|
||||
<br class="sm:hidden" />
|
||||
Код открыт под лицензией
|
||||
<a href="https://github.com/wowlikon/LiB/blob/main/LICENSE" class="underline hover:text-gray-300">MIT</a>.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Книга{% endblock %}
|
||||
{% block content %}
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="container mx-auto p-4 max-w-6xl">
|
||||
<div id="book-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
@@ -67,8 +66,10 @@
|
||||
class="flex flex-col items-center mb-6 md:mb-0 md:mr-8 flex-shrink-0 w-full md:w-auto"
|
||||
>
|
||||
<div
|
||||
class="w-40 h-56 bg-gradient-to-br from-gray-400 to-gray-600 rounded-lg flex items-center justify-center shadow-md mb-4"
|
||||
id="book-cover-container"
|
||||
class="relative w-40 h-56 rounded-lg shadow-md mb-4 overflow-hidden flex items-center justify-center bg-gray-100 group"
|
||||
>
|
||||
<div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
|
||||
<svg
|
||||
class="w-20 h-20 text-white opacity-80"
|
||||
fill="none"
|
||||
@@ -83,13 +84,14 @@
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="cover-file-input" class="hidden" accept="image/*" />
|
||||
<div
|
||||
id="book-status-container"
|
||||
class="relative w-full flex justify-center z-10 mb-4"
|
||||
></div>
|
||||
<div id="book-actions-container" class="w-full"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 w-full">
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-start md:justify-between mb-2"
|
||||
@@ -301,3 +303,8 @@
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/page/book.js"></script>
|
||||
{% endblock %}
|
||||
{% block extra_head %}
|
||||
{% if img %}
|
||||
<meta property="og:image" content="{{ request.url.scheme }}://{{ domain }}/static/books/{{ img }}.jpg" />
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Книги{% endblock %}
|
||||
{% block content %}
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<style>
|
||||
.range-double {
|
||||
height: 0;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Создание автора{% endblock %}
|
||||
{% block content %}
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="container mx-auto p-4 max-w-xl">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
|
||||
<div class="mb-8 border-b border-gray-100 pb-4">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Создание книги{% endblock %}
|
||||
{% block content %}
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="container mx-auto p-4 max-w-3xl">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
|
||||
<div class="mb-8 border-b border-gray-100 pb-4">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Создание жанра{% endblock %}
|
||||
{% block content %}
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="container mx-auto p-4 max-w-xl">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
|
||||
<div class="mb-8 border-b border-gray-100 pb-4">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Редактирование автора{% endblock %}
|
||||
{% block content %}
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="container mx-auto p-4 max-w-2xl">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
|
||||
<div class="mb-8 border-b border-gray-100 pb-4">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Редактирование книги{% endblock %}
|
||||
{% block content %}
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="container mx-auto p-4 max-w-3xl">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
|
||||
<div class="mb-8 border-b border-gray-100 pb-4">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Редактирование жанра{% endblock %}
|
||||
{% block content %}
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="container mx-auto p-4 max-w-2xl">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
|
||||
<div class="mb-8 border-b border-gray-100 pb-4">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Библиотека{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="flex flex-1 items-center justify-center p-4">
|
||||
<div class="w-full max-w-4xl">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Мои книги{% endblock %}
|
||||
{% block content %}
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="container mx-auto p-4 max-w-6xl">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Мои книги</h1>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Профиль{% endblock %}
|
||||
{% block content %}
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="container mx-auto p-4 max-w-2xl"
|
||||
x-data="{ showPasswordModal: false, showDisable2FAModal: false, showRecoveryCodesModal: false, is2FAEnabled: false, recoveryCodesRemaining: null }"
|
||||
@update-2fa.window="is2FAEnabled = $event.detail"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="flex flex-1 items-center justify-center p-4 min-h-[70vh]">
|
||||
<div class="w-full max-w-2xl">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="p-8 text-center">
|
||||
<div class="mb-6 relative">
|
||||
<svg id="canvas" viewBox="-250 -50 500 100" style="width: 70vmin; height: 25vmin; max-width: 600px; max-height: 600px"></svg>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold text-gray-800 mb-3">
|
||||
Страница не найдена
|
||||
</h1>
|
||||
|
||||
<p class="text-gray-500 mb-2">
|
||||
К сожалению, запрашиваемая страница не существует.
|
||||
</p>
|
||||
<p class="text-gray-400 text-sm mb-8">
|
||||
Возможно, она была удалена или вы ввели неверный адрес.
|
||||
</p>
|
||||
|
||||
<div class="bg-gray-100 rounded-lg px-4 py-3 mb-8 inline-block">
|
||||
<code id="pathh" class="text-gray-600 text-sm">
|
||||
<span class="text-gray-400">Путь:</span>
|
||||
{{ request.url.path }}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<button
|
||||
onclick="history.back()"
|
||||
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="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
||||
</svg>
|
||||
Назад
|
||||
</button>
|
||||
<a
|
||||
href="/"
|
||||
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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||
</svg>
|
||||
На главную
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 px-8 py-6 border-t border-gray-200">
|
||||
<p class="text-gray-500 text-sm text-center mb-4">Возможно, вы искали:</p>
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<a href="/books" class="inline-flex items-center px-4 py-2 bg-white border border-gray-200 rounded-lg text-gray-600 hover:border-gray-400 hover:text-gray-800 transition text-sm">
|
||||
<svg class="w-4 h-4 mr-2 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>
|
||||
Книги
|
||||
</a>
|
||||
<a href="/authors" class="inline-flex items-center px-4 py-2 bg-white border border-gray-200 rounded-lg text-gray-600 hover:border-gray-400 hover:text-gray-800 transition text-sm">
|
||||
<svg class="w-4 h-4 mr-2 text-gray-400" 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>
|
||||
<a href="/api" class="inline-flex items-center px-4 py-2 bg-white border border-gray-200 rounded-lg text-gray-600 hover:border-gray-400 hover:text-gray-800 transition text-sm">
|
||||
<svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
|
||||
</svg>
|
||||
API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/page/unknown.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,4 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Пользователи{% endblock %}
|
||||
{% block content %}
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="container mx-auto p-4">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Book preview
|
||||
|
||||
Revision ID: abbc38275032
|
||||
Revises: 6c616cc9d1f0
|
||||
Create Date: 2026-02-01 14:41:14.611420
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel, pgvector
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'abbc38275032'
|
||||
down_revision: Union[str, None] = '6c616cc9d1f0'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('book', sa.Column('preview_id', sa.Uuid(), nullable=True))
|
||||
op.create_index(op.f('ix_book_preview_id'), 'book', ['preview_id'], unique=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_book_preview_id'), table_name='book')
|
||||
op.drop_column('book', 'preview_id')
|
||||
# ### end Alembic commands ###
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "LiB"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
description = "Это простое API для управления авторами, книгами и их жанрами."
|
||||
authors = [{ name = "wowlikon" }]
|
||||
readme = "README.md"
|
||||
|
||||
+28
-4
@@ -36,6 +36,20 @@ BEGIN
|
||||
END \$\$;
|
||||
EOF
|
||||
|
||||
echo "Проверяем/создаем публикацию..."
|
||||
|
||||
PUB_EXISTS=$(PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -tAc "SELECT COUNT(*) FROM pg_publication WHERE pubname = 'all_tables_pub';")
|
||||
|
||||
if [ "$PUB_EXISTS" -gt 0 ]; then
|
||||
echo "Публикация уже существует"
|
||||
else
|
||||
echo "Создаем публикацию..."
|
||||
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" <<EOF
|
||||
CREATE PUBLICATION all_tables_pub FOR ALL TABLES;
|
||||
EOF
|
||||
echo "Публикация создана!"
|
||||
fi
|
||||
|
||||
echo "Ждем удаленный хост ${REMOTE_HOST}:${REMOTE_PORT}..."
|
||||
TIMEOUT=300
|
||||
ELAPSED=0
|
||||
@@ -44,8 +58,9 @@ while ! PGPASSWORD="${POSTGRES_PASSWORD}" psql -h "${REMOTE_HOST}" -p ${REMOTE_P
|
||||
sleep 5
|
||||
ELAPSED=$((ELAPSED + 5))
|
||||
if [ $ELAPSED -ge $TIMEOUT ]; then
|
||||
echo "Таймаут ожидания удаленного хоста. Репликация НЕ настроена."
|
||||
echo "Вы можете запустить этот скрипт вручную позже:"
|
||||
echo "Таймаут ожидания удаленного хоста. Подписка НЕ настроена."
|
||||
echo "Публикация создана - удаленный хост сможет подписаться на нас."
|
||||
echo "Для создания подписки запустите позже:"
|
||||
echo "docker compose restart replication-setup"
|
||||
exit 0
|
||||
fi
|
||||
@@ -53,6 +68,14 @@ while ! PGPASSWORD="${POSTGRES_PASSWORD}" psql -h "${REMOTE_HOST}" -p ${REMOTE_P
|
||||
done
|
||||
echo "Удаленный хост доступен"
|
||||
|
||||
REMOTE_PUB=$(PGPASSWORD="${POSTGRES_PASSWORD}" psql -h "${REMOTE_HOST}" -p ${REMOTE_PORT} -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -tAc "SELECT COUNT(*) FROM pg_publication WHERE pubname = 'all_tables_pub';" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$REMOTE_PUB" -eq 0 ]; then
|
||||
echo "ВНИМАНИЕ: На удалённом хосте нет публикации 'all_tables_pub'!"
|
||||
echo "Подписка не будет создана. Сначала запустите скрипт на удалённом хосте."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
EXISTING=$(PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -tAc "SELECT COUNT(*) FROM pg_subscription WHERE subname = 'sub_from_remote';")
|
||||
|
||||
if [ "$EXISTING" -gt 0 ]; then
|
||||
@@ -73,5 +96,6 @@ EOF
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Репликация настроена!"
|
||||
echo "Этот узел (${NODE_ID}) теперь синхронизирован с ${REMOTE_HOST}"
|
||||
echo "=== Репликация настроена! ==="
|
||||
echo "Публикация: all_tables_pub (другие могут подписаться на нас)"
|
||||
echo "Подписка: sub_from_remote (мы получаем данные от ${REMOTE_HOST})"
|
||||
|
||||
@@ -479,7 +479,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
|
||||
@@ -487,7 +486,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
|
||||
@@ -495,7 +493,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
|
||||
@@ -503,7 +500,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
|
||||
@@ -631,7 +627,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/e8/ef/324f4a28ed0152a32
|
||||
|
||||
[[package]]
|
||||
name = "lib"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
|
||||
Reference in New Issue
Block a user