Compare commits

...

4 Commits

14 changed files with 550 additions and 61 deletions
+4 -5
View File
@@ -29,6 +29,7 @@
3. Настройте переменные окружения: 3. Настройте переменные окружения:
```bash ```bash
cp example-docker.env .env # или example-local.env для запуска без docker
edit .env edit .env
``` ```
@@ -47,11 +48,6 @@
uv run alembic revision --autogenerate -m "Migration name" uv run alembic revision --autogenerate -m "Migration name"
``` ```
Для запуска тестов:
```bash
docker compose up test
```
### **Роли пользователей** ### **Роли пользователей**
- **admin**: Полный доступ ко всем функциям системы - **admin**: Полный доступ ко всем функциям системы
@@ -269,6 +265,8 @@ erDiagram
- **ACTIVE**: Книга доступна для выдачи - **ACTIVE**: Книга доступна для выдачи
- **RESERVED**: Книга забронирована (ожидает подтверждения) - **RESERVED**: Книга забронирована (ожидает подтверждения)
- **BORROWED**: Книга выдана пользователю - **BORROWED**: Книга выдана пользователю
- **RESTORATION**: Книга на реставрации
- **WRITTEN_OFF**: Книга списана
### **Используемые технологии** ### **Используемые технологии**
@@ -277,6 +275,7 @@ erDiagram
- **SQLModel**: Библиотека для взаимодействия с базами данных, объединяющая SQLAlchemy и Pydantic - **SQLModel**: Библиотека для взаимодействия с базами данных, объединяющая SQLAlchemy и Pydantic
- **Alembic**: Инструмент для миграции базы данных на основе SQLAlchemy - **Alembic**: Инструмент для миграции базы данных на основе SQLAlchemy
- **PostgreSQL**: Реляционная система управления базами данных - **PostgreSQL**: Реляционная система управления базами данных
- **Ollama**: Инструмент для локального запуска и управления большими языковыми моделями
- **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах - **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах
- **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker - **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker
- **Tailwind CSS**: CSS-фреймворк для стилизации интерфейса - **Tailwind CSS**: CSS-фреймворк для стилизации интерфейса
+22 -12
View File
@@ -9,14 +9,24 @@ services:
max-file: "3" max-file: "3"
volumes: volumes:
- ./data/db:/var/lib/postgresql/data - ./data/db:/var/lib/postgresql/data
# networks: networks:
# - proxy - proxy
ports: ports: # !сменить внешний порт перед использованием!
- 5432:5432 - 5432:5432
env_file: env_file:
- ./.env - ./.env
command:
- "postgres"
- "-c"
- "wal_level=logical"
- "-c"
- "max_replication_slots=10"
- "-c"
- "max_wal_senders=10"
- "-c"
- "listen_addresses=*"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -48,14 +58,14 @@ services:
max-file: "3" max-file: "3"
volumes: volumes:
- ./data/llm:/root/.ollama - ./data/llm:/root/.ollama
# networks: networks:
# - proxy - proxy
ports: ports: # !только локальный тест!
- 11434:11434 - 11434:11434
env_file: env_file:
- ./.env - ./.env
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl http://localhost:11434"] test: ["CMD", "ollama", "list"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -68,14 +78,14 @@ services:
build: . build: .
container_name: api container_name: api
restart: unless-stopped 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: logging:
options: options:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
# networks: networks:
# - proxy - proxy
ports: ports: # !только локальный тест!
- 8000:8000 - 8000:8000
env_file: env_file:
- ./.env - ./.env
+39 -3
View File
@@ -1,6 +1,6 @@
"""Основной модуль""" """Основной модуль"""
import asyncio import asyncio, sys, traceback
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -9,13 +9,15 @@ from uuid import uuid4
from alembic import command from alembic import command
from alembic.config import Config 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.staticfiles import StaticFiles
from fastapi.responses import JSONResponse
from ollama import Client, ResponseError from ollama import Client, ResponseError
from sqlmodel import Session from sqlmodel import Session
from library_service.auth import run_seeds from library_service.auth import run_seeds
from library_service.routers import api_router 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.services.captcha import limiter, cleanup_task, require_captcha
from library_service.settings import ( from library_service.settings import (
LOGGING_CONFIG, LOGGING_CONFIG,
@@ -69,7 +71,40 @@ async def lifespan(_):
app = get_app(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.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") @app.middleware("http")
async def log_requests(request: Request, call_next): async def log_requests(request: Request, call_next):
"""Middleware для логирования HTTP-запросов""" """Middleware для логирования HTTP-запросов"""
@@ -149,6 +184,7 @@ if __name__ == "__main__":
"library_service.main:app", "library_service.main:app",
host="0.0.0.0", host="0.0.0.0",
port=8000, port=8000,
forwarded_allow_ips="*",
log_config=LOGGING_CONFIG, log_config=LOGGING_CONFIG,
access_log=False, access_log=False,
) )
+1 -1
View File
@@ -368,7 +368,7 @@ def verify_2fa(
if not verified: if not verified:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid 2FA code", detail="Invalid 2FA code",
) )
+6
View File
@@ -40,6 +40,12 @@ async def root(request: Request):
return templates.TemplateResponse(request, "index.html") return templates.TemplateResponse(request, "index.html")
@router.get("/unknown", include_in_schema=False)
async def unknown(request: Request):
"""Рендерит страницу 404 ошибки"""
return templates.TemplateResponse(request, "unknown.html")
@router.get("/genre/create", include_in_schema=False) @router.get("/genre/create", include_in_schema=False)
async def create_genre(request: Request): async def create_genre(request: Request):
"""Рендерит страницу создания жанра""" """Рендерит страницу создания жанра"""
+61 -3
View File
@@ -1,9 +1,21 @@
"""Модуль генерации описания схемы БД""" """Модуль генерации описания схемы БД"""
import enum
import inspect 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 sqlalchemy.inspection import inspect as sa_inspect
from sqlmodel import SQLModel from sqlmodel import SQLModel
@@ -184,6 +196,41 @@ class SchemaGenerator:
return relations 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]: def generate(self) -> Dict[str, Any]:
entities = [] entities = []
@@ -212,8 +259,19 @@ class SchemaGenerator:
field_obj = {"id": col.name, "label": label} field_obj = {"id": col.name, "label": label}
tooltip_parts = []
if col.name in descriptions: 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) entity_fields.append(field_obj)
+47 -3
View File
@@ -3,6 +3,7 @@ $(() => {
loginForm: "#login-form", loginForm: "#login-form",
registerForm: "#register-form", registerForm: "#register-form",
resetForm: "#reset-password-form", resetForm: "#reset-password-form",
authTabs: "#auth-tabs",
loginTab: "#login-tab", loginTab: "#login-tab",
registerTab: "#register-tab", registerTab: "#register-tab",
forgotBtn: "#forgot-password-btn", forgotBtn: "#forgot-password-btn",
@@ -121,6 +122,13 @@ $(() => {
}; };
const showForm = (formId) => { 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}`, `${SELECTORS.loginForm}, ${SELECTORS.registerForm}, ${SELECTORS.resetForm}`,
).addClass("hidden"); ).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 = () => { const resetLoginState = () => {
clearPartialToken(); clearPartialToken();
stopTotpTimer(); stopTotpTimer();
@@ -151,6 +170,7 @@ $(() => {
username: "", username: "",
rememberMe: false, rememberMe: false,
}; };
$(SELECTORS.authTabs).removeClass("hide-animated");
$(SELECTORS.totpSection).addClass("hidden"); $(SELECTORS.totpSection).addClass("hidden");
$(SELECTORS.totpInput).val(""); $(SELECTORS.totpInput).val("");
$(SELECTORS.credentialsSection).removeClass("hidden"); $(SELECTORS.credentialsSection).removeClass("hidden");
@@ -185,6 +205,7 @@ $(() => {
const savedToken = sessionStorage.getItem(STORAGE_KEYS.partialToken); const savedToken = sessionStorage.getItem(STORAGE_KEYS.partialToken);
const savedUsername = sessionStorage.getItem(STORAGE_KEYS.partialUsername); const savedUsername = sessionStorage.getItem(STORAGE_KEYS.partialUsername);
if (savedToken && savedUsername) { if (savedToken && savedUsername) {
$(SELECTORS.authTabs).addClass("hide-animated");
loginState.partialToken = savedToken; loginState.partialToken = savedToken;
loginState.username = savedUsername; loginState.username = savedUsername;
loginState.step = "2fa"; loginState.step = "2fa";
@@ -279,6 +300,7 @@ $(() => {
loginState.partialToken = data.partial_token; loginState.partialToken = data.partial_token;
loginState.step = "2fa"; loginState.step = "2fa";
savePartialToken(data.partial_token, username); savePartialToken(data.partial_token, username);
$(SELECTORS.authTabs).addClass("hide-animated");
$(SELECTORS.credentialsSection).addClass("hidden"); $(SELECTORS.credentialsSection).addClass("hidden");
$(SELECTORS.totpSection).removeClass("hidden"); $(SELECTORS.totpSection).removeClass("hidden");
startTotpTimer(); startTotpTimer();
@@ -362,6 +384,19 @@ $(() => {
}, 1500); }, 1500);
} }
} catch (error) { } 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") { if (error.detail && error.detail.error === "captcha_required") {
Utils.showToast(TEXTS.captchaRequired, "error"); Utils.showToast(TEXTS.captchaRequired, "error");
const $capElement = $(SELECTORS.capWidget); const $capElement = $(SELECTORS.capWidget);
@@ -372,11 +407,19 @@ $(() => {
); );
return; return;
} }
let msg = error.message;
if (error.detail && Array.isArray(error.detail)) { 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 { } finally {
$submitBtn $submitBtn
.prop("disabled", false) .prop("disabled", false)
@@ -544,6 +587,7 @@ $(() => {
}; };
initLoginState(); initLoginState();
handleHash();
const widget = $(SELECTORS.capWidget).get(0); const widget = $(SELECTORS.capWidget).get(0);
if (widget && widget.shadowRoot) { if (widget && widget.shadowRoot) {
+175
View File
@@ -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();
+12 -5
View File
@@ -112,11 +112,18 @@ const Api = {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
throw new Error( const error = new Error("API Error");
errorData.detail || Object.assign(error, errorData);
errorData.error_description ||
`Ошибка ${response.status}`, 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(); return response.json();
} catch (error) { } catch (error) {
+45 -5
View File
@@ -168,7 +168,8 @@
jsPlumb.ready(function () { jsPlumb.ready(function () {
const instance = jsPlumb.getInstance({ const instance = jsPlumb.getInstance({
Container: "erDiagram", 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"); const container = document.getElementById("erDiagram");
@@ -274,16 +275,55 @@
}); });
diagramData.relations.forEach(rel => { 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({ instance.connect({
source: `field-${rel.fromEntity}-${rel.fromField}`, source: `field-${rel.fromEntity}-${rel.fromField}`,
target: `field-${rel.toEntity}-${rel.toField}`, target: `field-${rel.toEntity}-${rel.toField}`,
anchor: ["Continuous", { faces: ["left", "right"] }], anchor: ["Continuous", { faces: ["left", "right"] }],
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2 }, paintStyle: { stroke: "#7f8c8d", strokeWidth: 2 },
hoverPaintStyle: { stroke: "#3498db", strokeWidth: 3 }, hoverPaintStyle: { stroke: "#3498db", strokeWidth: 3 },
overlays: [ overlays: overlays
["Label", { label: rel.fromMultiplicity || "", location: 0.1, cssClass: "relation-label" }],
["Label", { label: rel.toMultiplicity || "", location: 0.9, cssClass: "relation-label" }]
]
}); });
}); });
+14 -3
View File
@@ -3,7 +3,7 @@
<div class="flex flex-1 items-center justify-center p-4"> <div class="flex flex-1 items-center justify-center p-4">
<div class="w-full max-w-md"> <div class="w-full max-w-md">
<div class="bg-white rounded-lg shadow-md overflow-hidden"> <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" <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"> 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 +84,7 @@
<div class="mb-6"> <div class="mb-6">
<input type="text" id="login-totp" name="totp_code" <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" /> placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code" />
</div> </div>
@@ -98,7 +98,7 @@
</div> </div>
<button type="submit" id="login-submit" <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> </button>
</form> </form>
@@ -340,6 +340,17 @@
</div> </div>
</div> </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 %} {% endblock %} {% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget"></script> <script src="https://cdn.jsdelivr.net/npm/@cap.js/widget"></script>
<script src="/static/page/auth.js"></script> <script src="/static/page/auth.js"></script>
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %} {% block title %}LiB - Страница не найдена{% endblock %}
{% 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 %}
+28 -4
View File
@@ -36,6 +36,20 @@ BEGIN
END \$\$; END \$\$;
EOF 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}..." echo "Ждем удаленный хост ${REMOTE_HOST}:${REMOTE_PORT}..."
TIMEOUT=300 TIMEOUT=300
ELAPSED=0 ELAPSED=0
@@ -44,8 +58,9 @@ while ! PGPASSWORD="${POSTGRES_PASSWORD}" psql -h "${REMOTE_HOST}" -p ${REMOTE_P
sleep 5 sleep 5
ELAPSED=$((ELAPSED + 5)) ELAPSED=$((ELAPSED + 5))
if [ $ELAPSED -ge $TIMEOUT ]; then if [ $ELAPSED -ge $TIMEOUT ]; then
echo "Таймаут ожидания удаленного хоста. Репликация НЕ настроена." echo "Таймаут ожидания удаленного хоста. Подписка НЕ настроена."
echo "Вы можете запустить этот скрипт вручную позже:" echo "Публикация создана - удаленный хост сможет подписаться на нас."
echo "Для создания подписки запустите позже:"
echo "docker compose restart replication-setup" echo "docker compose restart replication-setup"
exit 0 exit 0
fi fi
@@ -53,6 +68,14 @@ while ! PGPASSWORD="${POSTGRES_PASSWORD}" psql -h "${REMOTE_HOST}" -p ${REMOTE_P
done done
echo "Удаленный хост доступен" 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';") 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 if [ "$EXISTING" -gt 0 ]; then
@@ -73,5 +96,6 @@ EOF
fi fi
echo "" echo ""
echo "Репликация настроена!" echo "=== Репликация настроена! ==="
echo "Этот узел (${NODE_ID}) теперь синхронизирован с ${REMOTE_HOST}" echo "Публикация: all_tables_pub (другие могут подписаться на нас)"
echo "Подписка: sub_from_remote (мы получаем данные от ${REMOTE_HOST})"
Generated
+1 -1
View File
@@ -631,7 +631,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/e8/ef/324f4a28ed0152a32
[[package]] [[package]]
name = "lib" name = "lib"
version = "0.7.0" version = "0.8.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },