feat: production stack (gunicorn+nginx) and infra product URL import
This commit is contained in:
@@ -10,6 +10,6 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5000
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
CMD ["gunicorn", "-w", "3", "-b", "0.0.0.0:8000", "main:app", "--timeout", "60"]
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
services:
|
||||
zkart:
|
||||
app:
|
||||
build: .
|
||||
container_name: zkart-app
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
SECRET_KEY: ${SECRET_KEY:-change-me-please}
|
||||
volumes:
|
||||
- ./matrix.db:/app/matrix.db
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: zkart-nginx
|
||||
depends_on:
|
||||
- app
|
||||
ports:
|
||||
- "5000:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
restart: unless-stopped
|
||||
|
||||
151
main.py
151
main.py
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
@@ -15,6 +16,7 @@ except ImportError:
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
DB_PATH = BASE_DIR / "matrix.db"
|
||||
XLSX_PATH = BASE_DIR / "Z-card_РФ.xlsx"
|
||||
INFRA_JSON_FILES = [BASE_DIR / "infra1", BASE_DIR / "infra2", BASE_DIR / "infra3", BASE_DIR / "infra4"]
|
||||
|
||||
ADMIN_PATH = "/sdjlkfhsjkadahjksdhjgfkhsssssdjkjfljsdfjklsdajfkldsjflksdjfkldsj"
|
||||
ADMIN_LOGIN = "batman"
|
||||
@@ -256,6 +258,7 @@ def init_db() -> None:
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
vendor_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT,
|
||||
UNIQUE(vendor_id, name),
|
||||
FOREIGN KEY(vendor_id) REFERENCES vendors(id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -272,6 +275,7 @@ def init_db() -> None:
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
vendor_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT,
|
||||
UNIQUE(vendor_id, name),
|
||||
FOREIGN KEY(vendor_id) REFERENCES ib_vendors(id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -285,6 +289,14 @@ def init_db() -> None:
|
||||
);
|
||||
"""
|
||||
)
|
||||
try:
|
||||
conn.execute("ALTER TABLE products ADD COLUMN url TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE ib_products ADD COLUMN url TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
has_data = conn.execute("SELECT EXISTS(SELECT 1 FROM vendors)").fetchone()[0]
|
||||
if not has_data:
|
||||
@@ -301,6 +313,7 @@ def init_db() -> None:
|
||||
seed_ib_data(conn, ib_matrix)
|
||||
bootstrap_products_from_vendor_links(conn, "infra")
|
||||
bootstrap_products_from_vendor_links(conn, "ib")
|
||||
import_infra_products_from_json(conn)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -382,6 +395,7 @@ def fetch_scope_data(scope: str) -> dict:
|
||||
for r in conn.execute(
|
||||
f"""
|
||||
SELECT p.id, p.name, p.vendor_id, v.name AS vendor_name
|
||||
, p.url
|
||||
FROM {tables['products']} p
|
||||
JOIN {tables['vendors']} v ON v.id = p.vendor_id
|
||||
ORDER BY lower(v.name), lower(p.name)
|
||||
@@ -435,6 +449,101 @@ def bootstrap_products_from_vendor_links(conn: sqlite3.Connection, scope: str) -
|
||||
)
|
||||
|
||||
|
||||
def import_infra_products_from_json(conn: sqlite3.Connection) -> None:
|
||||
present_files = [p for p in INFRA_JSON_FILES if p.exists()]
|
||||
if not present_files:
|
||||
return
|
||||
marker_exists = conn.execute("SELECT EXISTS(SELECT 1 FROM products WHERE url IS NOT NULL AND trim(url) <> '')").fetchone()[0]
|
||||
if marker_exists:
|
||||
return
|
||||
|
||||
tables = scope_tables("infra")
|
||||
vendors = {r["name"]: r["id"] for r in conn.execute(f"SELECT id, name FROM {tables['vendors']}")}
|
||||
categories = {r["name"]: r["id"] for r in conn.execute(f"SELECT id, name FROM {tables['categories']}")}
|
||||
|
||||
imported_products = 0
|
||||
imported_links = 0
|
||||
skipped = 0
|
||||
|
||||
for path in present_files:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(payload, list):
|
||||
continue
|
||||
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
vendor_name = (item.get("vendor") or "").strip()
|
||||
product_name = (item.get("product") or "").strip()
|
||||
if not vendor_name or not product_name:
|
||||
skipped += 1
|
||||
continue
|
||||
if "нет подтвержденного соответствия" in product_name.lower():
|
||||
skipped += 1
|
||||
continue
|
||||
vendor_id = vendors.get(vendor_name)
|
||||
if not vendor_id:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
product_url = ""
|
||||
evidence = item.get("evidence") or []
|
||||
if isinstance(evidence, list):
|
||||
for entry in evidence:
|
||||
if isinstance(entry, dict):
|
||||
url = (entry.get("url") or "").strip()
|
||||
if url:
|
||||
product_url = url
|
||||
break
|
||||
|
||||
conn.execute(
|
||||
f"INSERT OR IGNORE INTO {tables['products']}(vendor_id, name, url) VALUES (?, ?, ?)",
|
||||
(vendor_id, product_name, product_url or None),
|
||||
)
|
||||
conn.execute(
|
||||
f"UPDATE {tables['products']} SET url = COALESCE(NULLIF(url, ''), ?) WHERE vendor_id = ? AND name = ?",
|
||||
(product_url or None, vendor_id, product_name),
|
||||
)
|
||||
product_id_row = conn.execute(
|
||||
f"SELECT id FROM {tables['products']} WHERE vendor_id = ? AND name = ?",
|
||||
(vendor_id, product_name),
|
||||
).fetchone()
|
||||
if not product_id_row:
|
||||
skipped += 1
|
||||
continue
|
||||
product_id = product_id_row["id"]
|
||||
imported_products += 1
|
||||
|
||||
category_names = item.get("categories") or []
|
||||
if isinstance(category_names, list):
|
||||
for category_name_raw in category_names:
|
||||
category_name = str(category_name_raw).strip()
|
||||
category_id = categories.get(category_name)
|
||||
if not category_id:
|
||||
continue
|
||||
conn.execute(
|
||||
f"INSERT OR IGNORE INTO {tables['product_categories']}(product_id, category_id) VALUES (?, ?)",
|
||||
(product_id, category_id),
|
||||
)
|
||||
imported_links += 1
|
||||
|
||||
conn.execute(f"DELETE FROM {tables['vendor_categories']}")
|
||||
conn.execute(
|
||||
f"""
|
||||
INSERT OR IGNORE INTO {tables['vendor_categories']}(vendor_id, category_id)
|
||||
SELECT DISTINCT p.vendor_id, pc.category_id
|
||||
FROM {tables['products']} p
|
||||
JOIN {tables['product_categories']} pc ON pc.product_id = p.id
|
||||
"""
|
||||
)
|
||||
if imported_products == 0 and skipped > 0:
|
||||
# Preserve non-empty startup state if JSON couldn't be mapped.
|
||||
bootstrap_products_from_vendor_links(conn, "infra")
|
||||
|
||||
|
||||
def build_matrix_from_lists(
|
||||
vendors: list[str],
|
||||
categories: list[str],
|
||||
@@ -679,11 +788,17 @@ def admin_login_or_panel():
|
||||
elif action == "add_product":
|
||||
vendor_id = parse_int(request.form.get("vendor_id"))
|
||||
name = (request.form.get("name") or "").strip()
|
||||
url = (request.form.get("url") or "").strip()
|
||||
if vendor_id and name:
|
||||
conn.execute(
|
||||
f"INSERT OR IGNORE INTO {tables['products']}(vendor_id, name) VALUES (?, ?)",
|
||||
(vendor_id, name),
|
||||
f"INSERT OR IGNORE INTO {tables['products']}(vendor_id, name, url) VALUES (?, ?, ?)",
|
||||
(vendor_id, name, url or None),
|
||||
)
|
||||
if url:
|
||||
conn.execute(
|
||||
f"UPDATE {tables['products']} SET url = ? WHERE vendor_id = ? AND name = ?",
|
||||
(url, vendor_id, name),
|
||||
)
|
||||
|
||||
elif action == "delete_vendor":
|
||||
v_id = parse_int(request.form.get("vendor_id"))
|
||||
@@ -734,6 +849,7 @@ def admin_login_or_panel():
|
||||
for r in conn.execute(
|
||||
f"""
|
||||
SELECT p.id, p.name, p.vendor_id, v.name AS vendor_name
|
||||
, p.url
|
||||
FROM {tables['products']} p
|
||||
JOIN {tables['vendors']} v ON v.id = p.vendor_id
|
||||
ORDER BY lower(v.name), lower(p.name)
|
||||
@@ -1085,6 +1201,17 @@ INDEX_HTML = """
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d2e3ff;
|
||||
}
|
||||
a.tag {
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
a.tag::after {
|
||||
content: "↗";
|
||||
font-size: 11px;
|
||||
opacity: .85;
|
||||
}
|
||||
|
||||
.credit {
|
||||
position: fixed;
|
||||
@@ -1318,7 +1445,7 @@ INDEX_HTML = """
|
||||
const cats = categoriesByProduct.get(pId) || new Set();
|
||||
return [...state.selectedCategories].some(cId => cats.has(cId));
|
||||
});
|
||||
const products = productIds.map(pId => productsById.get(pId)?.name).filter(Boolean);
|
||||
const products = productIds.map(pId => productsById.get(pId)).filter(Boolean);
|
||||
if (products.length === 0) continue;
|
||||
rows.push({ vendor: vendor.name, products });
|
||||
}
|
||||
@@ -1337,10 +1464,16 @@ INDEX_HTML = """
|
||||
card.appendChild(title);
|
||||
const tags = document.createElement("div");
|
||||
tags.className = "tags";
|
||||
for (const name of row.products) {
|
||||
const tag = document.createElement("span");
|
||||
for (const product of row.products) {
|
||||
const hasUrl = product.url && String(product.url).trim().length > 0;
|
||||
const tag = document.createElement(hasUrl ? "a" : "span");
|
||||
tag.className = "tag";
|
||||
tag.textContent = name;
|
||||
tag.textContent = product.name;
|
||||
if (hasUrl) {
|
||||
tag.href = product.url;
|
||||
tag.target = "_blank";
|
||||
tag.rel = "noopener noreferrer";
|
||||
}
|
||||
tags.appendChild(tag);
|
||||
}
|
||||
card.appendChild(tags);
|
||||
@@ -1590,6 +1723,7 @@ ADMIN_HTML = """
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="text" name="name" placeholder="Название продукта" required />
|
||||
<input type="text" name="url" placeholder="URL продукта (необязательно)" />
|
||||
<button class="pri" type="submit">Добавить</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1625,7 +1759,10 @@ ADMIN_HTML = """
|
||||
{% for p in products %}
|
||||
<form class="list-item" method="post">
|
||||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||||
<span>{{ p.vendor_name }} :: {{ p.name }}</span>
|
||||
<span>
|
||||
{{ p.vendor_name }} :: {{ p.name }}
|
||||
{% if p.url %}<a href="{{ p.url }}" target="_blank" rel="noopener noreferrer" style="margin-left:6px; font-size:11px;">ссылка</a>{% endif %}
|
||||
</span>
|
||||
<input type="hidden" name="action" value="delete_product" />
|
||||
<input type="hidden" name="product_id" value="{{ p.id }}" />
|
||||
<button class="danger" type="submit">Удалить</button>
|
||||
|
||||
17
nginx.conf
Normal file
17
nginx.conf
Normal file
@@ -0,0 +1,17 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 20m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://app:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
Flask>=3.0.0
|
||||
openpyxl>=3.1.0
|
||||
gunicorn>=23.0.0
|
||||
|
||||
Reference in New Issue
Block a user