From 2f3d6f0e1e242e1cbf210ad6bfbbb43b66a1a1a7 Mon Sep 17 00:00:00 2001 From: wowlikon Date: Sat, 31 Jan 2026 00:49:05 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D0=B0=20404,=20=D0=B1=D0=BE=D0=BB=D0=B5=D0=B5=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D1=80=D0=BE=D0=B1=D0=BD=D0=B0=D1=8F=20=D0=B8=D0=BD=D0=BE?= =?UTF-8?q?=D1=84=D1=80=D0=BC=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BE=D0=B1=20?= =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0=D1=85,=20=D1=83=D0=BB?= =?UTF-8?q?=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=84=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D1=8D=D0=BD=D0=B4=D0=B0=20=D0=B8=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F,=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20docker-compose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 +- docker-compose.yml | 22 ++- library_service/main.py | 42 +++++- library_service/routers/auth.py | 2 +- library_service/routers/misc.py | 6 + library_service/services/describe_er.py | 64 ++++++++- library_service/static/page/auth.js | 50 ++++++- library_service/static/page/unknown.js | 175 ++++++++++++++++++++++++ library_service/static/utils.js | 17 ++- library_service/templates/api.html | 50 ++++++- library_service/templates/auth.html | 17 ++- library_service/templates/unknown.html | 79 +++++++++++ uv.lock | 2 +- 13 files changed, 500 insertions(+), 35 deletions(-) create mode 100644 library_service/static/page/unknown.js create mode 100644 library_service/templates/unknown.html diff --git a/README.md b/README.md index 8746ee6..4a17610 100644 --- a/README.md +++ b/README.md @@ -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-фреймворк для стилизации интерфейса diff --git a/docker-compose.yml b/docker-compose.yml index 3b67833..e75a7d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,8 +15,18 @@ services: - 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 @@ -49,13 +59,13 @@ services: volumes: - ./data/llm:/root/.ollama networks: - - proxy - # ports: - # - 11434:11434 + - 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,7 +78,7 @@ 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" diff --git a/library_service/main.py b/library_service/main.py index ea0c513..62e160e 100644 --- a/library_service/main.py +++ b/library_service/main.py @@ -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, ) diff --git a/library_service/routers/auth.py b/library_service/routers/auth.py index 15fd319..1c71dd6 100644 --- a/library_service/routers/auth.py +++ b/library_service/routers/auth.py @@ -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", ) diff --git a/library_service/routers/misc.py b/library_service/routers/misc.py index 0668476..9903825 100644 --- a/library_service/routers/misc.py +++ b/library_service/routers/misc.py @@ -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): """Рендерит страницу создания жанра""" diff --git a/library_service/services/describe_er.py b/library_service/services/describe_er.py index 77142df..21221d1 100644 --- a/library_service/services/describe_er.py +++ b/library_service/services/describe_er.py @@ -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) diff --git a/library_service/static/page/auth.js b/library_service/static/page/auth.js index 92ca099..a0fd17d 100644 --- a/library_service/static/page/auth.js +++ b/library_service/static/page/auth.js @@ -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) { diff --git a/library_service/static/page/unknown.js b/library_service/static/page/unknown.js new file mode 100644 index 0000000..1704044 --- /dev/null +++ b/library_service/static/page/unknown.js @@ -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(); diff --git a/library_service/static/utils.js b/library_service/static/utils.js index febad3d..bda3893 100644 --- a/library_service/static/utils.js +++ b/library_service/static/utils.js @@ -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) { diff --git a/library_service/templates/api.html b/library_service/templates/api.html index b7f4fd1..bb62380 100644 --- a/library_service/templates/api.html +++ b/library_service/templates/api.html @@ -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 }); }); diff --git a/library_service/templates/auth.html b/library_service/templates/auth.html index bb46d0a..4108d5e 100644 --- a/library_service/templates/auth.html +++ b/library_service/templates/auth.html @@ -3,7 +3,7 @@
-
+
@@ -340,6 +340,17 @@
+ {% endblock %} {% block scripts %} diff --git a/library_service/templates/unknown.html b/library_service/templates/unknown.html new file mode 100644 index 0000000..62d83be --- /dev/null +++ b/library_service/templates/unknown.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} {% block title %}LiB - Страница не найдена{% endblock %} +{% block content %} +
+
+
+
+
+ +
+ +

+ Страница не найдена +

+ +

+ К сожалению, запрашиваемая страница не существует. +

+

+ Возможно, она была удалена или вы ввели неверный адрес. +

+ +
+ + Путь: + {{ request.url.path }} + +
+ +
+ + + + + + На главную + +
+
+ +
+

Возможно, вы искали:

+ +
+
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/uv.lock b/uv.lock index 26d802f..dcf4e6f 100644 --- a/uv.lock +++ b/uv.lock @@ -631,7 +631,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/e8/ef/324f4a28ed0152a32 [[package]] name = "lib" -version = "0.7.0" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "aiofiles" },