склади и инструмент готовы

This commit is contained in:
2025-12-07 19:36:28 +03:00
parent 54bf21d52d
commit 65a3bc1671
65 changed files with 3485 additions and 115 deletions
+2
View File
@@ -3,3 +3,5 @@ DB_PORT=5432
DB_NAME=toolbox
DB_USER=toolbox
DB_PASS=z7kWLkSKa6
APP_SECRET=7!@FAH#4%a@*C!2g3353^jN6M#5@2@2
+41
View File
@@ -0,0 +1,41 @@
import os
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from api.routers import router
import config
ENV = os.getenv("APP_ENV", "dev") # dev по умолчанию
def create_app():
if ENV == "prod":
# Полное отключение документации
app = FastAPI(
title="API сервер проекта ToolsBox",
summary="Сервис управления складом инструментов",
docs_url=None,
redoc_url=None,
openapi_url=None,
)
else:
# Dev-режим: документация включена
app = FastAPI(
title="API сервер проекта ToolsBox",
summary="Сервис управления складом инструментов",
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
)
return app
app = create_app()
# Подключение static
app.mount(
"/static",
StaticFiles(directory=f"{config.RELOAD_DIR}api/static"),
name="static",
)
app.include_router(router)
Binary file not shown.
+80
View File
@@ -0,0 +1,80 @@
from fastapi import APIRouter, Depends, Request
from db.handlers.categories import CategoryHandler
from utils import render, requestDict, logger
from .user import router as user
from .stocks import router as stocks
router = APIRouter()
router.include_router(user, prefix="/user", tags=["user"])
router.include_router(stocks, prefix="/stocks", tags=["stocks"])
@router.get("/")
async def main_page(request: Request):
return await render(request)
@router.post("/")
async def post_requests(
request_data: dict = Depends(requestDict),
):
from db.handlers.records import ServiceRecordsHandler, StocksRecordsHandler
from db.handlers.toolbox import ToolboxHandler
from db.handlers.toolkit import ToolkitHandler
from db.handlers.user import UserHandler
reqData = {
"tab": request_data.get("body").get("tabId"),
"userData": request_data.get("body").get("cookiesData").get("userData"),
"accessData": request_data.get("body").get("cookiesData").get("accessData"),
}
resultData = {"status": "error", "data": {}}
logger.info(f"Получение данных для вкладки {reqData.get('tab')}")
match reqData.get("tab"):
case "toolbox":
if reqData.get("accessData").get("view_all_toolboxes", False):
toolbox = await ToolboxHandler.getAll()
else:
toolbox = await ToolboxHandler.getByOwner(
reqData.get("userData").get("id")
)
if toolbox:
resultData["status"] = "ok"
resultData["data"] = toolbox
case "requests":
requests = await StocksRecordsHandler.get(reqData.get("userData").get("id"))
if isinstance(requests, list):
resultData["status"] = "ok"
resultData["data"] = requests
case "toolkits":
toolkits = await ToolkitHandler.getAll()
categories = await CategoryHandler.getAll()
if toolkits:
resultData["status"] = "ok"
resultData["data"] = {
"toolkits": toolkits,
"categories": categories,
}
case "jurnal_toolkits":
jurnal_toolkits = await StocksRecordsHandler.get()
if jurnal_toolkits:
resultData["status"] = "ok"
resultData["data"] = jurnal_toolkits
case "jurnal_service":
jurnal_service = await ServiceRecordsHandler.get()
if jurnal_service:
resultData["status"] = "ok"
resultData["data"] = jurnal_service
case "users":
users = await UserHandler.getAll()
if users:
for user in users:
user.pop("hashed_password")
resultData["status"] = "ok"
resultData["data"] = users
case _:
pass
return resultData
Binary file not shown.
Binary file not shown.
Binary file not shown.
+149
View File
@@ -0,0 +1,149 @@
from fastapi import APIRouter, Depends
from db.handlers.actions import StocksActions
from db.handlers.categories import CategoryHandler
from db.handlers.records import StocksRecordsHandler
from db.handlers.stock import StockHandler
from db.handlers.toolbox import ToolboxHandler
from db.handlers.toolkit import ToolkitHandler
from utils import requestDict, logger
router = APIRouter()
@router.post("/")
async def post_requests(
request_data: dict = Depends(requestDict),
):
toolboxId = request_data.get("body").get("toolboxId")
logger.info(f"Получение инструментов для тулбокса {toolboxId}")
response = {"status": "error", "data": {}}
stocksData = await StockHandler.getByToolboxId(toolboxId)
if not stocksData:
return response
toolkitsIds = set(stock["toolkit_id"] for stock in stocksData)
toolkitsData = await ToolkitHandler.getSeveral(list(toolkitsIds))
if not toolkitsData:
return response
categoriesIds = set(toolkit["category_id"] for toolkit in toolkitsData)
categoriesData = await CategoryHandler.getSeveral(list(categoriesIds))
if not categoriesData:
return response
response["status"] = "ok"
response["data"] = {
"stocks": stocksData,
"toolkits": toolkitsData,
"categories": categoriesData,
}
return response
@router.post("/action", summary="Запрос на перемещение инструмента")
async def post_requests(
request_data: dict = Depends(requestDict),
):
action = request_data.get("body").get("action").get("operation")
logger.info(f"Получение запроса на перемещение ({action}) инструмента")
userId = request_data.get("body").get("userData").get("id")
sourceTollboxId = (
request_data.get("body").get("action").get("selectedItem").get("toolboxId")
)
toolkitId = request_data.get("body").get("action").get("selectedItem").get("id")
quantity = request_data.get("body").get("action").get("quantity")
price = round(
request_data.get("body").get("action").get("selectedItem").get("totalCost")
/ request_data.get("body").get("action").get("selectedItem").get("available"),
2,
)
reason = request_data.get("body").get("action").get("comment")
resonse = {"status": "error"}
match action:
case "writeoff":
result = await StocksActions.movingRequest(
"Списание",
toolkitId,
sourceTollboxId,
None,
quantity,
reason,
userId,
price,
)
if result:
resonse["status"] = "ok"
case "get":
targetTollboxId = await ToolboxHandler.getIdByOwner(userId)
result = await StocksActions.takeToolkit(
sourceTollboxId,
targetTollboxId,
toolkitId,
quantity,
reason,
userId,
price,
)
if result:
resonse["status"] = "ok"
case "return":
targetTollboxId = await StocksRecordsHandler.getOriginalToolboxId(
toolkitId, sourceTollboxId
)
if targetTollboxId:
result = await StocksActions.movingRequest(
"Возврат",
toolkitId,
sourceTollboxId,
targetTollboxId,
quantity,
reason,
userId,
price,
)
if result:
resonse["status"] = "ok"
return resonse
@router.post("/toolkit", summary="Запрос остатка инструмента")
async def toolkit_request(
request_data: dict = Depends(requestDict),
):
response = {"status": "error", "data": {}}
logger.info(f"Получение запроса остатка инструмента")
# logger.info(request_data)
toolkitId = request_data.get("body").get("toolkitId")
stocks = await StockHandler.getByToolkitId(toolkitId)
if not stocks:
return response
userId = request_data.get("body").get("userId")
allToolboxes = request_data.get("body").get("allToolboxes")
toolboxes = (
await ToolboxHandler.getByOwner(userId)
if not allToolboxes
else await ToolboxHandler.getAll()
)
if not toolboxes:
return response
toolboxesTitles = {toolbox["id"]: toolbox["title"] for toolbox in toolboxes}
stocksData = {"count": 0, "toolboxes": {}}
for stock in stocks:
toolboxTitle = toolboxesTitles.get(stock["toolbox_id"], None)
if not toolboxTitle:
continue
stocksData["count"] += stock["quantity"]
if toolboxTitle not in stocksData["toolboxes"]:
stocksData["toolboxes"][toolboxTitle] = {
"count": stock["quantity"],
"placement": stock["placement"],
}
else:
stocksData["toolboxes"][toolboxTitle]["count"] += stock["quantity"]
response["status"] = "ok"
response["data"] = stocksData
logger.info(response)
return response
+44
View File
@@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends, Request
from db.handlers.access import AccessLevelHandler
from db.handlers.user import UserHandler
from utils import requestDict, logger, render
router = APIRouter()
@router.get("/", name="userInfo", summary="Получение информации о пользователе")
async def get_user():
return
@router.post("/")
async def create_user():
return
@router.get("/login", name="Authentication", summary="Авторизация пользователя")
async def authenticationPage(request: Request):
return await render(request)
@router.post("/login")
async def authentication(
request_data: dict = Depends(requestDict),
):
resultData = {"status": "error", "user": {}, "access": {}}
login = request_data.get("body").get("login", None)
password = request_data.get("body").get("password", None)
if not login or not password:
logger.error("Не указан логин или пароль")
return resultData
userData = await UserHandler.auth(login, password)
if not userData:
return resultData
accessData = await AccessLevelHandler.get(userData["access_level_id"])
if not accessData:
return resultData
resultData["status"] = "ok"
resultData["user"] = userData
resultData["access"] = accessData
return resultData
+345
View File
@@ -0,0 +1,345 @@
:root {
--header-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--card-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Заголовок с градиентом и стеклянным эффектом */
.glass-header {
background: var(--header-bg);
backdrop-filter: blur(10px);
background-color: rgba(255, 255, 255, 0.95);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
}
/* Стили для навигационных кнопок */
.tab-nav-btn {
position: relative;
background: transparent !important;
border: none !important;
border-radius: 0.75rem !important;
transition: all 0.3s ease !important;
max-width: 200px !important;
}
.tab-nav-btn:hover {
background-color: rgba(var(--bs-primary-rgb), 0.05) !important;
transform: translateY(-2px);
}
.tab-nav-btn.active {
background-color: rgba(var(--bs-primary-rgb), 0.1) !important;
border: 1px solid rgba(var(--bs-primary-rgb), 0.2) !important;
box-shadow: 0 4px 12px rgba(var(--bs-primary-rgb), 0.15);
}
.tab-nav-btn.active .nav-indicator {
position: absolute;
bottom: -10px;
left: 50%;
transform: translateX(-50%);
width: 60%;
height: 3px;
background-color: var(--bs-primary);
border-radius: 3px;
}
/* Только изменение цвета текста на темно-серый */
.tab-nav-btn.active .nav-icon {
color: #495057 !important;
}
.tab-nav-btn.active .nav-title {
color: #495057 !important;
}
/* Кнопки выбора склада */
.toolbox-nav-btn {
position: relative;
background: transparent !important;
border-radius: 0.75rem !important;
transition: all 0.3s ease !important;
padding: 0.75rem 1rem !important;
min-width: 140px !important;
}
.toolbox-nav-btn:hover {
background-color: rgba(59, 130, 246, 0.05) !important;
transform: translateY(-2px);
color: #495057 !important; /* Темно-серый при наведении */
}
.toolbox-nav-btn.active {
background-color: rgba(59, 130, 246, 0.1) !important;
border: 1px solid rgba(59, 130, 246, 0.2) !important;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
color: #495057 !important; /* Темно-серый при активном состоянии */
}
.toolbox-nav-btn.active .toolbox-nav-indicator {
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 50%;
height: 3px;
background-color: #3b82f6;
border-radius: 3px;
}
/* Убираем стили Bootstrap для активного состояния */
.toolbox-nav-btn.active.btn-primary {
background-color: rgba(59, 130, 246, 0.1) !important;
border-color: rgba(59, 130, 246, 0.2) !important;
color: #495057 !important;
}
/* Стили для контейнера данных склада */
.toolbox-content-container {
min-height: 400px;
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
}
/* Анимации */
.tab-pane.fade {
transition: opacity 0.25s linear;
}
.tab-pane.fade.show.active {
opacity: 1;
}
/* Стили для карточек */
.card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0,0,0,0.1) !important;
}
#mainTabsNav {
display: inline-flex !important;
width: auto !important;
flex: 0 0 auto !important;
flex-wrap: nowrap !important;
}
#mainTabsNavWrapper {
text-align: center;
}
/* Адаптивность */
@media (max-width: 768px) {
.tab-nav-btn {
min-width: 100px !important;
padding: 0.75rem 0.5rem !important;
}
.toolbox-nav-btn {
min-width: 120px !important;
padding: 0.5rem 0.75rem !important;
}
.nav-title {
font-size: 0.8rem !important;
}
.nav-icon {
font-size: 1.5rem !important;
}
}
@media (max-width: 576px) {
#mainTabsNav {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tab-nav-btn {
min-width: 90px !important;
flex-shrink: 0;
}
.toolbox-nav-btn {
min-width: 110px !important;
flex-shrink: 0;
}
}
/* Стили для таблицы с фиксированным заголовком */
.table-responsive {
position: relative;
}
.table-responsive thead th {
position: sticky;
top: 0;
background-color: #f8f9fa;
z-index: 10;
box-shadow: 0 2px 2px -1px rgba(0,0,0,0.1);
}
/* Стили для выделенных строк */
.table-striped tbody tr.table-active:nth-child(odd) {
background-color: rgba(13, 110, 253, 0.3) !important;
}
.table-striped tbody tr.table-active:nth-child(even) {
background-color: rgba(13, 110, 253, 0.2) !important;
}
/* Стили для изображений */
.toolkit-image-link {
text-decoration: none;
transition: opacity 0.2s;
}
.toolkit-image-link:hover {
opacity: 0.8;
}
/* Стили для пагинации */
.pagination {
margin-bottom: 0;
}
.page-link {
cursor: pointer;
}
/* Стили для таблицы сортировки */
th[data-sort] {
cursor: pointer;
position: relative;
user-select: none;
}
th[data-sort]:hover {
background-color: #e9ecef;
}
/* Стили для бейджей статусов */
.badge.bg-success { background-color: #198754 !important; }
.badge.bg-warning { background-color: #ffc107 !important; color: #000; }
.badge.bg-danger { background-color: #dc3545 !important; }
/* Стили для кнопок действий в строке */
.action-buttons {
opacity: 0;
transition: opacity 0.2s;
}
tr:hover .action-buttons {
opacity: 1;
}
/* Уменьшаем отступы у кнопок в строках */
.action-buttons .btn {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
/* Стили для поля поиска */
.input-group {
max-width: 500px;
}
/* Стили для более заметных стрелок карусели */
.carousel-control-prev,
.carousel-control-next {
width: 40px !important;
height: 40px !important;
top: 50% !important;
transform: translateY(-50%) !important;
background-color: rgba(0, 0, 0, 0.5) !important;
border-radius: 50% !important;
opacity: 0.8 !important;
transition: all 0.3s ease !important;
}
.carousel-control-prev {
left: 10px !important;
}
.carousel-control-next {
right: 10px !important;
}
.carousel-control-prev:hover,
.carousel-control-next:hover {
opacity: 1 !important;
background-color: rgba(0, 0, 0, 0.7) !important;
transform: translateY(-50%) scale(1.1) !important;
}
.carousel-control-prev-icon,
.carousel-control-next-icon {
width: 20px !important;
height: 20px !important;
filter: invert(1) !important; /* Делает стрелки белыми */
}
/* Для лучшей видимости на темных изображениях можно добавить тень */
.carousel-control-prev-icon,
.carousel-control-next-icon {
filter: invert(1) drop-shadow(0 0 2px rgba(0, 0, 0, 0.5)) !important;
}
/* Стили для индикаторов карусели */
.carousel-indicators button.active {
background-color: #007bff !important;
border-color: #007bff !important;
}
/* Стили для карточек */
.toolkit-card {
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
height: 100%;
}
.toolkit-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.toolkit-card-img {
height: 200px;
object-fit: contain;
object-position: center;
}
.toolkit-description {
display: -webkit-box;
/* -webkit-line-clamp: 3; */
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.category-badge {
font-size: 0.8rem;
padding: 0.25rem 0.75rem;
}
.filter-btn.active {
background-color: #0d6efd;
color: white;
}
.filter-btn:not(.active) {
background-color: #f8f9fa;
}
/* Адаптивные стили */
@media (max-width: 768px) {
.toolkit-card-img {
height: 150px;
}
}
+5
View File
@@ -0,0 +1,5 @@
body {
background-image: url("../images/background.png");
background-repeat: no-repeat;
background-size: cover;
}
+98
View File
@@ -0,0 +1,98 @@
/* Минимальный стиль для страницы входа */
:root{
--card-w: 420px;
--accent: #0d6efd; /* bootstrap primary */
--bg: #f6f7fb;
--card-bg: #ffffff;
--muted: #6c757d;
}
html,body{
height:100%;
}
.login-page{
min-height:100vh;
background: linear-gradient(180deg, #eef3fb 0%, var(--bg) 100%);
padding: 2rem;
}
.login-card{
width: var(--card-w);
background: var(--card-bg);
border-radius: 12px;
box-shadow: 0 6px 24px rgba(20,25,40,0.08);
padding: 1.25rem;
display:flex;
flex-direction:column;
gap: .75rem;
}
.login-header{
display:flex;
align-items:center;
gap:.75rem;
border-bottom: 1px solid #f1f3f5;
padding-bottom:.5rem;
margin-bottom:.5rem;
}
.brand-icon{
width:36px;
height:36px;
object-fit:contain;
border-radius:6px;
}
.login-header h1{
font-size:1.05rem;
margin:0;
}
.login-form {
display:flex;
flex-direction:column;
gap:.65rem;
}
.form-group label{
font-weight:600;
font-size:.9rem;
margin-bottom:.25rem;
display:block;
}
.form-control{
padding:.6rem .75rem;
border-radius:8px;
border:1px solid #e6e9ef;
font-size: .95rem;
outline:none;
}
.form-control:focus{
box-shadow:0 0 0 3px rgba(13,110,253,0.08);
border-color: var(--accent);
}
.form-actions{
margin-top:.25rem;
}
.form-error{
color:#b00020;
font-size:.9rem;
padding:.25rem .25rem;
}
.login-footer{
margin-top:.5rem;
text-align:center;
color:var(--muted);
font-size:.85rem;
}
/* responsiveness */
@media (max-width:480px){
.login-card{ width: 100%; border-radius:0; min-height:100vh; justify-content:center; }
}
+153
View File
@@ -0,0 +1,153 @@
/* Карточка клиента с современным дизайном */
.client-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 16px;
padding: 1.25rem;
box-shadow: var(--card-shadow);
transition: var(--transition-smooth);
min-width: 300px;
}
.client-card:hover {
transform: translateY(-2px);
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.15);
}
/* Аватар с градиентной обводкой */
.client-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
border: 3px solid transparent;
background: linear-gradient(white, white) padding-box,
linear-gradient(135deg, #667eea 0%, #764ba2 100%) border-box;
padding: 2px;
background-color: #f8f9fa;
}
/* Имя клиента */
.client-name {
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-size: 1.1rem;
min-height: 1.5rem;
}
/* Кнопка выхода */
.logout-btn {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
border: none;
border-radius: 12px;
padding: 0.5rem 1.25rem;
font-weight: 600;
color: white;
transition: var(--transition-smooth);
position: relative;
overflow: hidden;
}
.logout-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: 0.5s;
}
.logout-btn:hover {
transform: translateY(-1px);
box-shadow: 0 5px 15px rgba(245, 87, 108, 0.3);
}
.logout-btn:hover::before {
left: 100%;
}
/* Анимации */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-up {
animation: fadeInUp 0.6s ease-out;
}
/* Стили для состояния загрузки */
.client-avatar[src=""],
.client-avatar:not([src]) {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
.client-name:empty::before,
.client-role:empty::before {
content: "Загрузка...";
color: #ccc;
font-style: italic;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Адаптивность */
@media (max-width: 768px) {
.client-card {
padding: 1rem;
min-width: auto;
width: 100%;
}
.client-avatar {
width: 48px;
height: 48px;
}
.glass-header {
padding: 1rem 0;
}
.client-name {
font-size: 1rem;
}
.logout-btn span:not(.bi) {
display: none;
}
.logout-btn .bi {
margin-right: 0;
}
}
/* Микро-интеракции */
.clickable {
cursor: pointer;
transition: var(--transition-smooth);
}
.clickable:hover {
transform: scale(1.02);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

+18
View File
@@ -0,0 +1,18 @@
// api.js
export async function apiRequest(url, payload = {}, method = 'POST') {
const res = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(payload),
credentials: 'same-origin'
});
if (!res.ok) {
const text = await res.text();
throw new Error(`HTTP ${res.status}: ${text}`);
}
return res.json();
}
+84
View File
@@ -0,0 +1,84 @@
// auth.js
import { setCookie } from '/static/js/cookies.js';
import { apiRequest } from '/static/js/api.js';
const form = document.getElementById('loginForm');
const loginInput = document.getElementById('loginInput');
const passwordInput = document.getElementById('passwordInput');
const submitBtn = document.getElementById('submitBtn');
const formError = document.getElementById('formError');
function showError(msg) {
formError.hidden = false;
formError.textContent = msg;
}
function clearError() {
formError.hidden = true;
formError.textContent = '';
}
async function getUserEndpoint() {
const meta = document.querySelector('meta[name="userAuth-endpoint"]');
return meta ? meta.getAttribute('content') : '/user';
}
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
clearError();
submitBtn.disabled = true;
submitBtn.textContent = 'Входим...';
const login = loginInput.value.trim();
const password = passwordInput.value;
if (!login || !password) {
showError('Введите логин и пароль');
submitBtn.disabled = false;
submitBtn.textContent = 'Войти';
return;
}
try {
const url = await getUserEndpoint();
// Отправляем данные
const resp = await apiRequest(url, { login, password });
// Пример ожидаемого формата:
// { "status": "ok" | "error", "user": {...}, "access": {...} }
if (!resp || typeof resp !== 'object') {
throw new Error('Некорректный ответ от сервера');
}
if (resp.status && (resp.status === 'error' || resp.status === 'fail')) {
// Ошибка авторизации
showError(resp.message || 'Неправильный логин или пароль');
submitBtn.disabled = false;
submitBtn.textContent = 'Войти';
return;
}
// Если статус == success / ok / authenticated и есть user + access — считаем успешной
const okStatuses = new Set(['ok', 'success', 'authenticated']);
const isOk = resp.status && okStatuses.has(String(resp.status).toLowerCase());
// fallback: если resp.user или resp.access присутствуют — считаем ок
const hasData = resp.user && typeof resp.user === 'object' && resp.access && typeof resp.access === 'object';
if (!isOk && !hasData) {
showError('Авторизация не выполнена: пустой ответ');
submitBtn.disabled = false;
submitBtn.textContent = 'Войти';
return;
}
await setCookie('toolbox_user', JSON.stringify(resp.user));
await setCookie('toolbox_access', JSON.stringify(resp.access));
window.location.href = '/'; // или другой URL
} catch (err) {
console.error(err);
showError(err.message || 'Ошибка при авторизации');
submitBtn.disabled = false;
submitBtn.textContent = 'Войти';
}
});
+77
View File
@@ -0,0 +1,77 @@
import { deriveKeyFromSecret, encryptJSON, decryptToJSON } from '/static/js/crypto.js';
async function getAppSecret() {
const meta = document.querySelector('meta[name="app-secret"]');
return meta ? meta.getAttribute('content') : null;
}
export async function setCookie(name, value, days = 180) {
const appSecret = await getAppSecret();
let cookieValue;
if (!appSecret) {
console.warn('APP SECRET is missing — cookies will be stored without client-side encryption.');
cookieValue = encodeURIComponent(JSON.stringify(value));
} else {
try {
const key = await deriveKeyFromSecret(appSecret);
cookieValue = await encryptJSON(key, value);
} catch (error) {
console.error('Encryption failed:', error);
cookieValue = encodeURIComponent(JSON.stringify(value));
}
}
const secure = false; // TODO включить после тестов
const sameSite = 'Lax';
const expires = new Date(Date.now() + days * 864e5).toUTCString();
let cookie = `${encodeURIComponent(name)}=${cookieValue}; expires=${expires}; path=/`;
if (secure) cookie += '; Secure';
if (sameSite) cookie += `; SameSite=${sameSite}`;
document.cookie = cookie;
}
export async function getCookie(name) {
const cookies = document.cookie ? document.cookie.split('; ') : [];
for (const c of cookies) {
const parts = c.split('=');
if (parts.length < 2) continue;
const cookieName = decodeURIComponent(parts[0]);
if (cookieName !== name) continue;
const cookieValue = parts.slice(1).join('=');
const appSecret = await getAppSecret();
if (appSecret) {
try {
const key = await deriveKeyFromSecret(appSecret);
const decrypted = await decryptToJSON(key, cookieValue);
return JSON.parse(decrypted);
} catch (error) {
console.error('Decryption failed for cookie', name, error);
return
}
} else {
try {
return JSON.parse(decodeURIComponent(cookieValue));
} catch (e) {
return decodeURIComponent(cookieValue);
}
}
}
return null;
}
export function deleteCookie(name) {
try {
document.cookie = `${encodeURIComponent(name)}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`;
} catch (e) {
console.error(e)
}
}
+94
View File
@@ -0,0 +1,94 @@
export async function deriveKeyFromSecret(secret, salt = 'toolbox_salt_v1') {
if (!secret || typeof secret !== 'string') {
throw new Error('Invalid secret: must be a non-empty string');
}
const enc = new TextEncoder();
const secretKey = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'PBKDF2' },
false,
['deriveKey']
);
const key = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: enc.encode(salt),
iterations: 100000,
hash: 'SHA-256'
},
secretKey,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
return key;
}
function _randBytes(len = 12) {
const b = new Uint8Array(len);
crypto.getRandomValues(b);
return b;
}
export async function encryptJSON(key, obj) {
if (!key || !(key instanceof CryptoKey)) {
throw new Error('Valid CryptoKey is required');
}
const enc = new TextEncoder();
const iv = _randBytes(12);
const plain = enc.encode(JSON.stringify(obj));
const cipher = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
key,
plain
);
const combined = new Uint8Array(iv.byteLength + cipher.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(cipher), iv.byteLength);
// Безопасное преобразование в base64
const binaryString = Array.from(combined, byte =>
String.fromCharCode(byte)).join('');
return btoa(binaryString);
}
export async function decryptToJSON(key, b64) {
if (!key || !(key instanceof CryptoKey)) {
throw new Error('Valid CryptoKey is required');
}
if (!b64 || typeof b64 !== 'string') {
throw new Error('Invalid base64 string');
}
try {
// Безопасное преобразование из base64
const binaryString = atob(b64);
const raw = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
raw[i] = binaryString.charCodeAt(i);
}
const iv = raw.slice(0, 12);
const cipher = raw.slice(12);
const plainBuf = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv },
key,
cipher
);
const dec = new TextDecoder();
return JSON.parse(dec.decode(plainBuf));
} catch (error) {
console.error('Decryption error:', error);
throw new Error('Failed to decrypt data: ' + error.message);
}
}
File diff suppressed because it is too large Load Diff
+177
View File
@@ -0,0 +1,177 @@
import { getCookie, deleteCookie } from '/static/js/cookies.js';
class ClientManager {
constructor() {
this.userData = null;
this.accessData = null;
this.init();
}
async init() {
try {
// Получаем данные пользователя из cookie
this.userData = await getCookie('toolbox_user');
this.accessData = await getCookie('toolbox_access');
if (!this.userData || !this.accessData) {
console.warn('User data or access data not found in cookie');
this.clearUserCookie();
window.location.href = '/user/login';
}
// Вставляем данные пользователя в DOM
this.renderUserInfo();
// Инициализируем обработчики
this.initLogoutHandler();
this.initHoverEffects();
this.addAvatarErrorHandler();
} catch (error) {
console.error('Error initializing client manager:', error);
}
}
renderUserInfo() {
if (!this.userData) {
// Если нет данных, показываем заглушку
this.renderFallbackUser();
return;
}
// Находим элементы для обновления
const avatar = document.querySelector('.client-avatar');
const nameElement = document.querySelector('.client-name');
const roleElement = document.querySelector('.client-role');
// Обновляем аватар
if (avatar) {
avatar.src = this.userData.photo || 'static/images/users/default.png';
avatar.alt = this.userData.username || 'Пользователь';
}
// Обновляем имя
if (nameElement) {
nameElement.textContent = this.userData.username || 'Неизвестный пользователь';
}
// Обновляем роль
if (roleElement) {
const role = this.accessData.title || 'Неизвестная роль';
roleElement.textContent = role;
}
}
renderFallbackUser() {
const userCard = document.querySelector('.client-card');
if (userCard) {
userCard.innerHTML = `
<div class="d-flex align-items-center gap-3">
<div class="position-relative">
<img src="images/users/default.png"
alt="Гость"
class="client-avatar">
<span class="position-absolute bottom-0 end-0 bg-warning border border-2 border-white rounded-circle p-1"></span>
</div>
<div class="me-3">
<h6 class="client-name mb-1">Гость</h6>
<div class="d-flex align-items-center">
<span class="badge bg-warning bg-opacity-10 text-warning fs-7 px-2 py-1 me-2">
<i class="bi bi-exclamation-triangle me-1"></i>
Не авторизован
</span>
</div>
</div>
<div>
<a href="/login" class="btn btn-primary btn-sm">
<i class="bi bi-box-arrow-in-right me-1"></i>
Войти
</a>
</div>
</div>
`;
}
}
initLogoutHandler() {
const logoutBtn = document.getElementById('clientLogoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', (e) => {
e.preventDefault();
this.handleLogout(logoutBtn);
});
}
}
handleLogout(button) {
// Анимация нажатия
button.style.transform = 'scale(0.95)';
// Добавляем иконку загрузки
const originalContent = button.innerHTML;
button.innerHTML = `
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Выход...
`;
button.disabled = true;
// Очищаем cookie пользователя
this.clearUserCookie();
// Переход на страницу выхода
setTimeout(() => {
window.location.href = '/user/login';
}, 800);
}
clearUserCookie() {
try {
const cookieNames = ['toolbox_user', 'toolbox_access'];
cookieNames.forEach(cookieName => {
deleteCookie(cookieName);
});
} catch (error) {
console.warn('Error clearing cookies:', error);
}
}
initHoverEffects() {
const clientCard = document.querySelector('.client-card');
if (clientCard) {
clientCard.addEventListener('mouseenter', () => {
clientCard.style.transform = 'translateY(-4px) scale(1.01)';
});
clientCard.addEventListener('mouseleave', () => {
clientCard.style.transform = 'translateY(0) scale(1)';
});
}
}
addAvatarErrorHandler() {
const avatar = document.querySelector('.client-avatar');
if (avatar) {
avatar.onerror = () => {
avatar.src = 'static/images/users/default.png';
};
}
}
}
// Инициализация при загрузке документа
document.addEventListener('DOMContentLoaded', () => {
// Инициализируем менеджер пользователя
window.clientManager = new ClientManager();
// Добавляем анимацию появления
const elements = document.querySelectorAll('.animate-fade-up');
elements.forEach((el, index) => {
el.style.animationDelay = `${index * 0.1}s`;
});
});
// Экспорт для использования в других модулях
export { ClientManager };
+80
View File
@@ -0,0 +1,80 @@
{% extends "layout.html" %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="{{ getUrl('static', path='css/user.css') }}">
<link rel="stylesheet" href="{{ getUrl('static', path='css/index.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lightbox2/2.11.4/css/lightbox.min.css">
{% endblock %}
{% block body %}
<!-- Заголовок с современным дизайном -->
<header class="glass-header py-3 mb-4 flex-fill">
<div class="container-xxl">
<div class="d-flex flex-column flex-md-row align-items-md-center justify-content-between">
<!-- Заголовок слева -->
<div class="mb-3 mb-md-0 animate-fade-up">
<div class="d-flex align-items-center">
<img src="{{ getUrl('static', path='images/logo.png') }}" alt="Toolbox" class="me-2 rounded"
width="70">
<div>
<h1 class="h3 fw-bold mb-1">Toolbox</h1>
<p class="text-muted mb-0 opacity-85">
<i class="bi bi-cloud-check me-1"></i>
Онлайн-сервис для управления инструментами и расходниками
</p>
</div>
</div>
</div>
<!-- Информация о клиенте справа -->
<div class="animate-fade-up" style="animation-delay: 0.2s">
<div class="client-card d-inline-block">
<div class="d-flex align-items-center gap-3">
<!-- Аватар с индикатором активности -->
<div class="position-relative clickable">
<img src="" alt="Загрузка..." class="client-avatar">
<span
class="position-absolute bottom-0 end-0 bg-success border border-2 border-white rounded-circle p-1"></span>
</div>
<!-- Информация -->
<div class="me-3">
<h6 class="client-name mb-1">Загрузка...</h6>
<div class="d-flex align-items-center">
<span
class="badge bg-primary bg-opacity-10 text-primary fs-7 px-2 py-1 me-2 client-role">
<i class="bi bi-shield-check me-1"></i>
Загрузка...
</span>
</div>
</div>
<!-- Кнопка выхода -->
<div class="dropdown">
<button class="logout-btn btn d-flex align-items-center" type="button" id="clientLogoutBtn">
<i class="bi bi-box-arrow-right me-2"></i>
<span class="d-none d-md-inline">Выйти</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</header>
<!-- Основной контент -->
<main class="container-xxl" id="mainContent">
</main>
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lightbox2/2.11.4/js/lightbox.min.js"></script>
<script type="module" src="{{ getUrl('static', path='js/user.js') }}"></script>
<script type="module" src="{{ getUrl('static', path='js/index.js') }}"></script>
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<meta name="app-secret" content={{content.app_secret}}>
<title>{% block title %}Toolbox{% endblock %}</title>
<link rel="icon" href="{{ getUrl('static', path='favicon.ico') }}">
<link rel="stylesheet" href="{{ getUrl('static', path='css/layout.css') }}">
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
{% block head %}{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
<!-- Bootstrap 5 JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"
crossorigin="anonymous"></script>
<script type="module" src="{{ getUrl('static', path='js/crypto.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
{% extends "layout.html" %}
{% block title %}Вход — Toolbox{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ getUrl('static', path='css/login.css') }}">
<meta name="userAuth-endpoint" content="{{ getUrl('Authentication') }}">
{% endblock %}
{% block body %}
<main class="login-page d-flex justify-content-center align-items-center">
<div class="login-card">
<header class="login-header">
<img src="{{ getUrl('static', path='favicon.ico') }}" alt="Toolbox" class="brand-icon">
<h1>Toolbox — Вход</h1>
</header>
<form id="loginForm" class="login-form" autocomplete="on">
<div class="form-group">
<label for="loginInput">Логин</label>
<input id="loginInput" name="login" type="text" class="form-control" required autofocus>
</div>
<div class="form-group">
<label for="passwordInput">Пароль</label>
<input id="passwordInput" name="password" type="password" class="form-control" required>
</div>
<div id="formError" class="form-error" aria-live="polite" hidden></div>
<div class="form-actions">
<button id="submitBtn" class="btn btn-primary w-100" type="submit">Войти</button>
</div>
</form>
<footer class="login-footer">
<small>Онлайн-сервис учета инструментов</small>
</footer>
</div>
</main>
{% endblock %}
{% block scripts %}
<!-- Подключаем только модули, разделённые по задачам -->
<script type="module" src="{{ getUrl('static', path='js/cookies.js') }}"></script>
<script type="module" src="{{ getUrl('static', path='js/api.js') }}"></script>
<script type="module" src="{{ getUrl('static', path='js/auth.js') }}"></script>
{% endblock %}
+5
View File
@@ -9,3 +9,8 @@ DB_PORT = os.environ.get("DB_PORT")
DB_NAME = os.environ.get("DB_NAME")
DB_USER = os.environ.get("DB_USER")
DB_PASS = os.environ.get("DB_PASS")
# Gunicorn
RELOAD_DIR = os.path.dirname(os.path.abspath(__file__)).replace("config", "")
TEMPLATES_DIR = os.path.join(RELOAD_DIR, "api/templates")
APP_SECRET = os.environ.get("APP_SECRET")
Binary file not shown.
+1 -1
View File
@@ -32,7 +32,7 @@ propagate=0
[formatter_formatter]
class=colorlog.ColoredFormatter
format=%(log_color)s%(asctime)s: [%(levelname)s] %(message)s [%(filename)s:%(lineno)d '%(funcName)s']
format=%(log_color)s%(asctime)s [%(name)s]: [%(levelname)s] %(message)s [%(filename)s:%(lineno)d '%(funcName)s']
datefmt=%Y-%m-%d %H:%M:%S
[handler_logconsole]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15 -30
View File
@@ -21,9 +21,7 @@ class StocksActions:
placement: str,
reason: str,
):
logger.info(
f"Оприходование инструмента {toolkit_id} на складе {toolbox_id} ..."
)
logger.info(f"Приход инструмента {toolkit_id} на складе {toolbox_id} ...")
newStocks = await StockHandler.add(
toolkit_id=toolkit_id,
toolbox_id=toolbox_id,
@@ -33,12 +31,12 @@ class StocksActions:
)
if newStocks is None:
logger.error(
f"Оприходование инструмента {toolkit_id} на складе {toolbox_id} не удалось"
f"Приход инструмента {toolkit_id} на складе {toolbox_id} не удалось"
)
return False
recorded = await StocksRecordsHandler.add(
action="Оприходование",
action="Приход",
source_stock_id=None,
target_stock_id=newStocks.get("id"),
source_toolbox_id=None,
@@ -51,7 +49,7 @@ class StocksActions:
return_record=True,
)
logger.info(
f"Оприходование инструмента {toolkit_id} на складе {toolbox_id} прошло {'успешно' if recorded else 'не успешно'}"
f"Приход инструмента {toolkit_id} на складе {toolbox_id} прошло {'успешно' if recorded else 'не успешно'}"
)
if recorded:
accepted = await StocksRecordsHandler.decide(
@@ -61,6 +59,11 @@ class StocksActions:
logger.error(
f"Принятие записи о оприходовании инструмента {toolkit_id} на складе {toolbox_id} не удалось"
)
refillUpdated = await ToolkitHandler.updateRefillDate(toolkit_id)
if not refillUpdated:
logger.error(
f"Обновление даты последнего заполнения инструмента {toolkit_id} не удалось"
)
return accepted
return False
@@ -70,8 +73,6 @@ class StocksActions:
target_toolbox_id: int,
toolkit_id: int,
quantity: int,
user_id: int,
reason: str,
target_placement: str = None,
):
logger.info(
@@ -169,27 +170,13 @@ class StocksActions:
f"Изменение остатков инструмента {toolkit_id} на склад {target_toolbox_id} по цене {stock['price']} успешно завершено"
)
# recorded = await StocksRecordsHandler.add(
# action=action,
# source_stock_id=stock["id"],
# target_stock_id=targetStock.get("id"),
# source_toolbox_id=source_toolbox_id,
# target_toolbox_id=target_toolbox_id,
# toolkit_id=toolkit_id,
# init_user_id=user_id,
# reason=reason,
# quantity=quantity,
# price=stock["price"],
# )
# if not recorded:
# logger.error(
# f"Ошибка создания записи о {action} инструмента {toolkit_id} со склада {source_toolbox_id} на склад {target_toolbox_id}"
# )
# return False
logger.info(
f"{action} инструмента {toolkit_id} со склада {source_toolbox_id} на склад {target_toolbox_id} прошло успешно"
)
moveUpdated = await ToolkitHandler.updateMovindDate(toolkit_id)
if not moveUpdated:
logger.error(f"Ошибка обновления даты движения инструмента {toolkit_id}")
return False
return movementsList
async def movingRequest(
@@ -220,7 +207,7 @@ class StocksActions:
return_record=return_record,
)
logger.info(
f"Запрос на {action} инструмента {toolkit_id} со склада {source_toolbox_id} в количестве {quantity} по цене {price} {'успешно завершен' if recorded else 'завершен с ошибкой'}"
f"Запрос на {action} инструмента {toolkit_id} со склада {source_toolbox_id} в количестве {quantity} по цене {price} {'успешно создан' if recorded else 'завершен с ошибкой'}"
)
return recorded
@@ -270,8 +257,6 @@ class StocksActions:
target_toolbox_id=target_toolbox_id,
toolkit_id=movingRecord.toolkit_id,
quantity=movingRecord.quantity,
user_id=user_id,
reason=movingRecord.reason,
)
if not stocksMovements:
logger.error(f"Ошибка при {movingRecord.action} инструмента")
@@ -388,9 +373,9 @@ class StocksActions:
for toolkit in toolkits:
if "Сверло" in toolkit.get("title"):
toolboxId = toolboxesDict.get("Шкаф")
placement = None
else:
toolboxId = toolboxesDict.get("Стеллаж")
placement = chr(65 + random.randint(0, 25)) + str(random.randint(1, 19))
registryCount = 5
+5
View File
@@ -58,6 +58,11 @@ class CategoryHandler:
categories = await CRUD.read(query, True)
return [category.toDict() for category in categories] if categories else []
async def getSeveral(categoryIds: list[int]) -> list[dict]:
query = select(Category).where(Category.id.in_(categoryIds))
categories = await CRUD.read(query, True)
return [category.toDict() for category in categories] if categories else []
async def delete(categoryId: int, user_id: int = None):
query = select(Category).where(Category.id == categoryId)
category = await CRUD.read(query)
+77 -21
View File
@@ -35,11 +35,11 @@ class StocksRecordsHandler:
"price": price,
}
try:
logger.info(f"Создание записи: {action} от {init_user_id}")
logger.debug(f"Создание записи: {action} от {init_user_id}")
logger.debug(recordData)
record = StocksRecords(**recordData)
await record.save()
logger.info(f"Запись успешно создана, id: {record.id}")
logger.debug(f"Запись успешно создана, id: {record.id}")
return True if not return_record else record
except Exception as e:
logger.error(f"Ошибка создания записи: {str(e)}")
@@ -54,7 +54,7 @@ class StocksRecordsHandler:
accept: bool = True,
):
try:
logger.info(
logger.debug(
f"{'Принятие' if accept else 'Отклонение'} записи {record.id} от {decision_user_id}"
)
record.decision_user_id = decision_user_id
@@ -64,7 +64,7 @@ class StocksRecordsHandler:
record.price = price
record.accepted = accept
await record.save()
logger.info(
logger.debug(
f"Запись {record.id} успешно {'принята' if accept else 'отклонена'} {decision_user_id} в {record.decided_at.strftime('%Y-%m-%d %H:%M:%S')}"
)
return True
@@ -83,16 +83,16 @@ class StocksRecordsHandler:
return recordDB
try:
logger.info(f"Обновление записи {record_id} от {edit_user_id}")
logger.debug(f"Обновление записи {record_id} от {edit_user_id}")
record = await StocksRecords.get(id=record_id)
record.edit_user_id = edit_user_id
record.edited_at = datetime.now()
logger.info("Вносим изменения в записи о движении инструмента")
logger.debug("Вносим изменения в записи о движении инструмента")
if record.source_stock_id:
logger.info(
logger.debug(
"Вносим изменения в записи о движении инструмента из исходного склада"
)
sourceStockRecord = await StockHandler.get(
@@ -103,12 +103,12 @@ class StocksRecordsHandler:
sourceStockRecord = updateStockRecord(sourceStockRecord, kwargs)
await sourceStockRecord.save()
logger.info(
logger.debug(
"Внесли изменения в записи о движении инструмента из исходного склада"
)
if record.target_stock_id:
logger.info(
logger.debug(
"Вносим изменения в записи о движении инструмента в целевой склад"
)
targetStockRecord = await StockHandler.get(
@@ -119,11 +119,11 @@ class StocksRecordsHandler:
targetStockRecord = updateStockRecord(targetStockRecord, kwargs)
await targetStockRecord.save()
logger.info(
logger.debug(
"Внесли изменения в записи о движении инструмента в целевой склад"
)
logger.info("Внесли изменения в записи о движении инструмента")
logger.debug("Внесли изменения в записи о движении инструмента")
edited = {}
for key, value in kwargs.items():
@@ -132,7 +132,7 @@ class StocksRecordsHandler:
edited[key] = {"original": originalValue, "new": value}
record.edited = edited
await record.save()
logger.info(
logger.debug(
f"Запись {record_id} успешно обновлена {edit_user_id} в {record.updated_at.strftime('%Y-%m-%d %H:%M:%S')}"
)
logger.debug(edited)
@@ -148,19 +148,23 @@ class StocksRecordsHandler:
try:
if user_id:
userInfo = f"пользователя {user_id} "
decided = "не решенных "
daysLimit = ""
query = select(StocksRecords).where(
StocksRecords.init_user_id == user_id,
StocksRecords.created_at > datetime.now() - timedelta(days=days),
StocksRecords.decision_user_id == None,
)
else:
userInfo = "всех пользователей "
decided = ""
daysLimit = f"за последние {days} дн."
query = select(StocksRecords).where(
StocksRecords.created_at > datetime.now() - timedelta(days=days),
)
logger.info(f"Получение всех записей {userInfo}за последние {days} дн.")
logger.debug(f"Получение всех {decided}записей {userInfo}{daysLimit}")
records = await CRUD.read(query, True)
logger.info(
f"{len(records)} записей {userInfo}за последние {days} дн. успешно получены"
logger.debug(
f"{len(records)} {decided}записей {userInfo}{daysLimit} успешно получены"
)
if len(records) == 0:
return []
@@ -176,28 +180,80 @@ class StocksRecordsHandler:
from db import CRUD
try:
logger.info(f"Получение записи {record_id}")
logger.debug(f"Получение записи {record_id}")
query = select(StocksRecords).where(StocksRecords.id == record_id)
stocksRecord = await CRUD.read(query)
if not stocksRecord:
logger.info(f"Запись {record_id} не найдена")
logger.debug(f"Запись {record_id} не найдена")
return False
logger.info(f"Запись {record_id} успешно получена")
logger.debug(f"Запись {record_id} успешно получена")
return stocksRecord.toDict() if not record else stocksRecord
except Exception as e:
logger.error(f"Ошибка получения записи: {str(e)}")
return False
async def getOriginalToolboxId(toolkitId: int, targetToolboxId: int):
from db import CRUD
try:
logger.debug(
f"Получение записи о перемещении инструмента {toolkitId} на склад {targetToolboxId}"
)
query = select(StocksRecords).where(
StocksRecords.toolkit_id == toolkitId,
StocksRecords.target_toolbox_id == targetToolboxId,
)
stocksRecord = await CRUD.read(query)
if not stocksRecord:
logger.debug(f"Запись {toolkitId} не найдена")
return False
logger.debug(f"Запись {toolkitId} успешно получена")
return stocksRecord.source_toolbox_id
except Exception as e:
logger.error(f"Ошибка получения записи: {str(e)}")
return False
class ServiceRecordsHandler:
async def add(user_id: int, details: dict):
try:
logger.info(f"Создание записи: {user_id}")
logger.debug(f"Создание записи: {user_id}")
logger.debug(details)
record = ServicesRecords(user_id=user_id, details=details)
await record.save()
logger.info(f"Запись успешно создана, id: {record.id}")
logger.debug(f"Запись успешно создана, id: {record.id}")
return True
except Exception as e:
logger.error(f"Ошибка создания записи: {str(e)}")
return False
async def get(user_id: int = None, days: int = 30):
from db import CRUD
try:
if user_id:
userInfo = f"пользователя {user_id} "
daysLimit = ""
query = select(ServicesRecords).where(
ServicesRecords.user_id == user_id,
)
else:
userInfo = "всех пользователей "
daysLimit = f"за последние {days} дн."
query = select(ServicesRecords).where(
ServicesRecords.created_at > datetime.now() - timedelta(days=days),
)
logger.debug(f"Получение всех записей {userInfo}{daysLimit}")
records = await CRUD.read(query, True)
logger.debug(
f"{len(records)} записей {userInfo}{daysLimit} успешно получены"
)
if len(records) == 0:
return []
records.sort(key=lambda x: x.created_at, reverse=True)
recordsData = [record.toDict() for record in records]
logger.debug(recordsData)
return recordsData
except Exception as e:
logger.error(f"Ошибка получения записей: {str(e)}")
return False
+5
View File
@@ -77,6 +77,11 @@ class ToolboxHandler:
toolboxes = await CRUD.read(query, True)
return [toolbox.toDict() for toolbox in toolboxes] if toolboxes else []
async def getIdByOwner(ownerId: int) -> int:
query = select(Toolbox).where(Toolbox.owner_id == ownerId)
toolbox = await CRUD.read(query)
return toolbox.id
async def delete(toolboxId: int, user_id: int = None):
query = select(Toolbox).where(Toolbox.id == toolboxId)
toolbox = await CRUD.read(query)
+25 -1
View File
@@ -1,3 +1,4 @@
from datetime import datetime
from utils import logger, saveImage, safeFilename, deleteImage
from db import CRUD
from db.schemas.toolkit import Toolkit
@@ -27,7 +28,7 @@ class ToolkitHandler:
return {}
try:
imageDict = {"main": "images/tools/default.png", "additional": []}
imageDict = {"main": "static/images/tools/default.png", "additional": []}
if "image" in toolkitData:
imageData = toolkitData.pop("image")
mainImage = imageData.get("main")
@@ -54,6 +55,21 @@ class ToolkitHandler:
await ServiceRecordsHandler.add(user_id, {"Добавлен инструмент": toolkitData})
return newToolkit
async def updateMovindDate(toolkitId: int):
editedToolkit = await ToolkitHandler.edit(toolkitId, moved_at=datetime.now())
if not editedToolkit:
logger.error("Инструмент не обновлен")
return False
return True
async def updateRefillDate(toolkitId: int):
logger.info(f"Обновление даты пополнения инструмента {toolkitId}...")
editedToolkit = await ToolkitHandler.edit(toolkitId, refilled_at=datetime.now())
if not editedToolkit:
logger.error("Инструмент не обновлен")
return False
return True
async def edit(toolkitId: int, **kwargs):
query = select(Toolkit).where(Toolkit.id == toolkitId)
toolkit = await CRUD.read(query)
@@ -98,6 +114,7 @@ class ToolkitHandler:
kwargs["image"] = imageDict
user_id = kwargs.pop("user_id", None)
logger.debug(f"Обновление инструмента {toolkit.title}...")
editedToolkit = await toolkit.edit(**kwargs)
except Exception as e:
logger.error(f"Ошибка обновления инструмента: {str(e)}")
@@ -208,6 +225,13 @@ class ToolkitHandler:
"Радиус": "0.4",
"Ещё что-то": "Ещё столько-то",
},
"image": {
"main": "static/images/tools/default.png",
"additional": [
"static/images/users/default.png",
"static/images/logo.png",
],
},
"category_id": categories["Токарка"],
"quantity_min": 20,
"quantity_min_extra": 10,
+8 -4
View File
@@ -59,7 +59,7 @@ class UserHandler:
return {}
if userAccessLevel.get("available_own_toolbox"):
newToolboxData = {
"title": f"Тулбокс {newUser.username}",
"title": f"Т{newUser.username}",
"description": f"Оборудование, полученное сотрудником {newUser.username}, под личную материальную ответственность",
"owner_id": newUser.id,
}
@@ -111,7 +111,7 @@ class UserHandler:
if editedUser.available_own_toolbox:
newToolboxData = {
"title": f"Тулбокс {editedUser.username}",
"description": f"Оборудование, полученное сотрудником {editedUser.username}, под личную материальную ответственность",
"description": f"Оборудование, полученное сотрудником '{editedUser.username}' под личную материальную ответственность",
"owner_id": editedUser.id,
}
newToolbox = await ToolboxHandler.addNewToolbox(newToolboxData)
@@ -177,16 +177,20 @@ class UserHandler:
query = select(User).where(User.login == login)
user = await CRUD.read(query)
if not user:
logger.error("Пользователь с таким логином не найден")
logger.error(f"Пользователь с логином {login} не найден")
return {}
if not pwd_verify(password, user.hashed_password):
logger.error("Неверный пароль")
logger.error(f"Неверный пароль пользователя {user.username}")
await ServiceRecordsHandler.add(
user.id, {"Неверный пароль пользователя": user.username}
)
return {}
userData = user.toDict()
userData.pop("hashed_password")
await ServiceRecordsHandler.add(
user.id, {"Авторизован пользователь": user.username}
)
logger.info(f"Пользователь {user.username} успешно авторизован")
return userData
async def initialize():
-9
View File
@@ -124,27 +124,18 @@ class DatabaseInitializer:
async def _initialize_data(self, waiting: bool = False):
"""Initialize required data"""
def waitForUser(waiting):
if waiting:
input("Для продолжения нажмите Enter...")
try:
logger.info("Инициализация данных...")
logger.warning("Инициализация Прав доступа...")
await AccessLevelHandler.initialize()
# waitForUser(waiting)
logger.warning("Инициализация Пользователей...")
await UserHandler.initialize()
# waitForUser(waiting)
logger.warning("Инициализация Туллбоксов...")
await ToolboxHandler.initialize()
# waitForUser(waiting)
logger.warning("Инициализация Категорий...")
await CategoryHandler.initialize()
# waitForUser(waiting)
logger.warning("Инициализация Инструментов...")
await ToolkitHandler.initialize()
# waitForUser(waiting)
logger.warning("Инициализация Складов...")
await StocksActions.initialize()
logger.info("Данные успешно инициализированы")
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -40,5 +40,5 @@ class Toolkit(Base):
async def save(self):
return await CRUD.create(self, refresh=True)
async def edit(id: int, **kwargs):
return await CRUD.update(Toolkit, id, **kwargs)
async def edit(self, **kwargs):
return await CRUD.update(Toolkit, self.id, **kwargs)
+1 -1
View File
@@ -10,7 +10,7 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True)
login = Column(String, unique=True, index=True)
username = Column(String, unique=True, index=True)
photo = Column(String, default="images/users/default.png")
photo = Column(String, default="static/images/users/default.png")
hashed_password = Column(String)
access_level_id = Column(Integer, ForeignKey("access_level.id", ondelete="CASCADE"))
is_active = Column(Boolean, default=True)
Executable
+17
View File
@@ -0,0 +1,17 @@
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
RUN mkdir /app
WORKDIR /app
COPY . .
RUN uv sync --frozen
WORKDIR /app
CMD exec /app/.venv/bin/gunicorn \
-w 4 \
-k uvicorn.workers.UvicornWorker \
"api:app" \
-b "0.0.0.0:8081" \
--log-config="/app/config/log.ini"
+21
View File
@@ -1,4 +1,21 @@
from pathlib import Path
from utils import logger
import config
import logging.config
def startDev():
import uvicorn
from pathlib import Path
uvicorn.run(
"api:app",
host="0.0.0.0",
port=8081,
reload=True,
reload_dirs=[config.RELOAD_DIR],
log_config=None,
)
async def main():
@@ -12,8 +29,12 @@ async def main():
except Exception as e:
logger.error(f"Инициализация базы завершилась ошибкой: {str(e)}", exc_info=True)
startDev()
if __name__ == "__main__":
import asyncio
log_config_path = Path("config/log.ini")
logging.config.fileConfig(log_config_path, disable_existing_loggers=False)
asyncio.run(main())
+4
View File
@@ -8,8 +8,12 @@ dependencies = [
"argon2-cffi>=25.1.0",
"asyncpg>=0.31.0",
"colorlog>=6.10.1",
"fastapi>=0.123.10",
"greenlet>=3.2.4",
"gunicorn>=23.0.0",
"jinja2>=3.1.6",
"pillow>=12.0.0",
"python-dotenv>=1.2.1",
"sqlalchemy>=2.0.44",
"uvicorn>=0.38.0",
]
+4
View File
@@ -3,3 +3,7 @@ from .for_DB import *
from .password import *
from .image import *
from .safe_filename import *
from .web import *
from .request_parser import RequestParser
requestDict = RequestParser()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+31 -34
View File
@@ -1,56 +1,53 @@
from utils.loggers import logger
# def saveImage(imageData, fileName: str):
# try:
# imageFormat = imageData.split(';')[0].split('/')[1]
# if imageFormat != 'png':
# logger.error(f"Неподдерживаемый формат изображения: {imageFormat}")
# return False
# imageData = imageData.split(';base64,')[1]
# with open(f"static/images/{fileName}", "wb") as f:
# f.write(base64.b64decode(imageData))
# logger.info(f"Изображение {fileName} успешно сохранено")
# return True
# except Exception as e:
# logger.error(f"Ошибка сохранения изображения: {str(e)}")
# return False
# UPLOAD_DIR = "uploads"
# os.makedirs(UPLOAD_DIR, exist_ok=True)
def saveImage(file_bytes, fileName: str):
def saveImage(file_bytes: bytes, file_name: str) -> bool:
import os
from PIL import Image
import io
# Загружаем изображение через Pillow
# Убедимся, что путь существует
file_name = f"api/{file_name}"
os.makedirs(os.path.dirname(file_name), exist_ok=True)
# Загружаем изображение
try:
img = Image.open(io.BytesIO(file_bytes))
except Exception:
logger.error("Неподдерживаемый формат изображения")
return False
# Конвертация (если нужно)
if img.mode not in ("RGB", "RGBA", "P"):
img = img.convert("RGB")
# Сохранение в выбранный формат
try:
logger.info(f"Сохраняем изображение {fileName}")
img.save(fileName, "PNG")
img.load()
except Exception as e:
logger.error(f"Ошибка сохранения изображения: {str(e)}")
logger.error(f"[ImageSave] Unsupported image format: {e}")
return False
logger.info(f"Изображение {fileName} успешно сохранено")
# Конвертация в нормальный режим (PNG поддерживает RGBA/RGB)
try:
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGB")
except Exception as e:
logger.error(f"[ImageSave] Mode conversion error: {e}")
return False
# Сохраняем как PNG
try:
target_path = file_name
if not target_path.lower().endswith(".png"):
target_path += ".png"
logger.info(f"[ImageSave] Saving image to {target_path}")
img.save(target_path, "PNG")
return True
except Exception as e:
logger.error(f"[ImageSave] Error saving image: {e}")
return False
def deleteImage(fileName: str):
if fileName.endswith("default.png"):
return True
try:
import os
file_name = f"api/{file_name}"
logger.info(f"Удаляем изображение {fileName}")
os.remove(f"static/images/{fileName}")
logger.info(f"Изображение {fileName} успешно удалено")
+15 -2
View File
@@ -1,10 +1,20 @@
import logging
import logging.config
import json
from pathlib import Path
class SmartLogger(logging.Logger):
def _log(self, level, msg, args, exc_info=None, extra=None, stack_info=False, stacklevel=2):
def _log(
self,
level,
msg,
args,
exc_info=None,
extra=None,
stack_info=False,
stacklevel=2,
):
# Увеличиваем stacklevel до 2, чтобы показать реального вызывающего
if isinstance(msg, (dict, list)):
msg = json.dumps(msg, indent=4, ensure_ascii=False)
@@ -12,9 +22,12 @@ class SmartLogger(logging.Logger):
logging.setLoggerClass(SmartLogger)
logging.config.fileConfig("config/log.ini")
# logging.config.fileConfig("config/log.ini")
logger = logging.getLogger("toolbox")
log_config_path = Path("config/log.ini")
logging.config.fileConfig(log_config_path, disable_existing_loggers=False)
def setLogLevel(level: str = ""):
match level:
-3
View File
@@ -5,8 +5,6 @@ from argon2.exceptions import (
InvalidHash,
)
from utils.loggers import logger
pwd_hasher = PasswordHasher(
time_cost=3,
@@ -26,7 +24,6 @@ def pwd_verify(pwd_plain: str, stored_hash: str) -> bool:
try:
valid = pwd_hasher.verify(stored_hash, pwd_plain)
except (VerifyMismatchError, VerificationError, InvalidHash) as e:
logger.warning(f"Password verification failed: {e.__class__.__name__}")
return False
return valid
+58
View File
@@ -0,0 +1,58 @@
# async def upload(requestData: dict = Depends(RequestParser())):
import json
from fastapi import Request, UploadFile
class RequestParser:
def __init__(self, include_files=True):
self.include_files = include_files
async def __call__(self, request: Request) -> dict:
parsed = {
"method": request.method,
"url": str(request.url),
"query": dict(request.query_params),
"headers": dict(request.headers),
"cookies": request.cookies,
"body": None,
"files": {},
}
# ----- BODY (JSON, Text) -----
raw_body = await request.body()
if raw_body:
try:
parsed["body"] = json.loads(raw_body.decode("utf-8"))
except Exception:
parsed["body"] = raw_body.decode("utf-8", errors="ignore")
# ----- FORM / FILES -----
try:
form = await request.form()
for key, value in form.items():
# Файл
if isinstance(value, UploadFile):
if self.include_files:
file_bytes = await value.read()
parsed["files"][key] = {
"filename": value.filename,
"content_type": value.content_type,
"content": file_bytes,
}
else:
# Добавляем как текстовое поле
if parsed["body"] is None:
parsed["body"] = {}
if isinstance(parsed["body"], dict):
parsed["body"][key] = value
except Exception:
pass
parsed["headers"].pop("cookie", None)
# logger.info(f"[RequestParser] Parsed request:")
# logger.info(parsed)
return parsed
+39
View File
@@ -0,0 +1,39 @@
from fastapi import Request
from fastapi.templating import Jinja2Templates
from typing import Any
import config
templates = Jinja2Templates(directory=config.TEMPLATES_DIR)
def getUrl(name: str, **path_params: Any):
from api import app
try:
url = app.url_path_for(name, **path_params)
except Exception:
url = app.url_path_for(name)
return url
async def render(
request: Request,
):
context = {
"request": request,
"content": {"app_secret": config.APP_SECRET},
}
fileName = f"{request.scope['path']}/index.html"
response = templates.TemplateResponse(fileName, context)
return response
def_list = [
getUrl,
]
for def_ in def_list:
templates.env.globals[def_.__name__] = def_
Generated
+273
View File
@@ -6,6 +6,36 @@ resolution-markers = [
"python_full_version < '3.14'",
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" },
]
[[package]]
name = "argon2-cffi"
version = "25.1.0"
@@ -126,6 +156,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -147,6 +189,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743, upload-time = "2025-10-16T16:14:10.512Z" },
]
[[package]]
name = "fastapi"
version = "0.123.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/ff/e01087de891010089f1620c916c0c13130f3898177955c13e2b02d22ec4a/fastapi-0.123.10.tar.gz", hash = "sha256:624d384d7cda7c096449c889fc776a0571948ba14c3c929fa8e9a78cd0b0a6a8", size = 356360, upload-time = "2025-12-05T21:27:46.237Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/f0/7cb92c4a720def85240fd63fbbcf147ce19e7a731c8e1032376bb5a486ac/fastapi-0.123.10-py3-none-any.whl", hash = "sha256:0503b7b7bc71bc98f7c90c9117d21fdf6147c0d74703011b87936becc86985c1", size = 111774, upload-time = "2025-12-05T21:27:44.78Z" },
]
[[package]]
name = "greenlet"
version = "3.2.4"
@@ -175,6 +232,109 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
]
[[package]]
name = "gunicorn"
version = "23.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pillow"
version = "12.0.0"
@@ -242,6 +402,74 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
@@ -272,6 +500,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" },
]
[[package]]
name = "starlette"
version = "0.50.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
]
[[package]]
name = "tools-stock"
version = "0.1.0"
@@ -280,10 +520,14 @@ dependencies = [
{ name = "argon2-cffi" },
{ name = "asyncpg" },
{ name = "colorlog" },
{ name = "fastapi" },
{ name = "greenlet" },
{ name = "gunicorn" },
{ name = "jinja2" },
{ name = "pillow" },
{ name = "python-dotenv" },
{ name = "sqlalchemy" },
{ name = "uvicorn" },
]
[package.metadata]
@@ -291,10 +535,14 @@ requires-dist = [
{ name = "argon2-cffi", specifier = ">=25.1.0" },
{ name = "asyncpg", specifier = ">=0.31.0" },
{ name = "colorlog", specifier = ">=6.10.1" },
{ name = "fastapi", specifier = ">=0.123.10" },
{ name = "greenlet", specifier = ">=3.2.4" },
{ name = "gunicorn", specifier = ">=23.0.0" },
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "pillow", specifier = ">=12.0.0" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "sqlalchemy", specifier = ">=2.0.44" },
{ name = "uvicorn", specifier = ">=0.38.0" },
]
[[package]]
@@ -305,3 +553,28 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
]