mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 04:31:09 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6014db3c81 | |||
| 0e159df16e | |||
| 2f3d6f0e1e | |||
| 657f1b96f2 |
@@ -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
|
||||
|
||||
+39
-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.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,7 @@ if __name__ == "__main__":
|
||||
"library_service.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
forwarded_allow_ips="*",
|
||||
log_config=LOGGING_CONFIG,
|
||||
access_log=False,
|
||||
)
|
||||
|
||||
@@ -368,7 +368,7 @@ 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",
|
||||
)
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@ async def root(request: Request):
|
||||
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)
|
||||
async def create_genre(request: Request):
|
||||
"""Рендерит страницу создания жанра"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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 +84,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 +98,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 +340,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>
|
||||
|
||||
@@ -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
@@ -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})"
|
||||
|
||||
Reference in New Issue
Block a user