feat: production stack (gunicorn+nginx) and infra product URL import

This commit is contained in:
2026-04-14 21:20:11 +00:00
parent 69684b59cf
commit f74298e3fd
6 changed files with 176 additions and 12 deletions

View File

@@ -10,6 +10,6 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
EXPOSE 5000 EXPOSE 8000
CMD ["python", "main.py"] CMD ["gunicorn", "-w", "3", "-b", "0.0.0.0:8000", "main:app", "--timeout", "60"]

View File

@@ -1,11 +1,20 @@
services: services:
zkart: app:
build: . build: .
container_name: zkart-app container_name: zkart-app
ports:
- "5000:5000"
environment: environment:
SECRET_KEY: ${SECRET_KEY:-change-me-please} SECRET_KEY: ${SECRET_KEY:-change-me-please}
volumes: volumes:
- ./matrix.db:/app/matrix.db - ./matrix.db:/app/matrix.db
restart: unless-stopped 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
View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import json
import sqlite3 import sqlite3
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Iterable
@@ -15,6 +16,7 @@ except ImportError:
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "matrix.db" DB_PATH = BASE_DIR / "matrix.db"
XLSX_PATH = BASE_DIR / "Z-card_РФ.xlsx" 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_PATH = "/sdjlkfhsjkadahjksdhjgfkhsssssdjkjfljsdfjklsdajfkldsjflksdjfkldsj"
ADMIN_LOGIN = "batman" ADMIN_LOGIN = "batman"
@@ -256,6 +258,7 @@ def init_db() -> None:
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
vendor_id INTEGER NOT NULL, vendor_id INTEGER NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
url TEXT,
UNIQUE(vendor_id, name), UNIQUE(vendor_id, name),
FOREIGN KEY(vendor_id) REFERENCES vendors(id) ON DELETE CASCADE FOREIGN KEY(vendor_id) REFERENCES vendors(id) ON DELETE CASCADE
); );
@@ -272,6 +275,7 @@ def init_db() -> None:
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
vendor_id INTEGER NOT NULL, vendor_id INTEGER NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
url TEXT,
UNIQUE(vendor_id, name), UNIQUE(vendor_id, name),
FOREIGN KEY(vendor_id) REFERENCES ib_vendors(id) ON DELETE CASCADE 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] has_data = conn.execute("SELECT EXISTS(SELECT 1 FROM vendors)").fetchone()[0]
if not has_data: if not has_data:
@@ -301,6 +313,7 @@ def init_db() -> None:
seed_ib_data(conn, ib_matrix) seed_ib_data(conn, ib_matrix)
bootstrap_products_from_vendor_links(conn, "infra") bootstrap_products_from_vendor_links(conn, "infra")
bootstrap_products_from_vendor_links(conn, "ib") bootstrap_products_from_vendor_links(conn, "ib")
import_infra_products_from_json(conn)
conn.commit() conn.commit()
conn.close() conn.close()
@@ -382,6 +395,7 @@ def fetch_scope_data(scope: str) -> dict:
for r in conn.execute( for r in conn.execute(
f""" f"""
SELECT p.id, p.name, p.vendor_id, v.name AS vendor_name SELECT p.id, p.name, p.vendor_id, v.name AS vendor_name
, p.url
FROM {tables['products']} p FROM {tables['products']} p
JOIN {tables['vendors']} v ON v.id = p.vendor_id JOIN {tables['vendors']} v ON v.id = p.vendor_id
ORDER BY lower(v.name), lower(p.name) 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( def build_matrix_from_lists(
vendors: list[str], vendors: list[str],
categories: list[str], categories: list[str],
@@ -679,10 +788,16 @@ def admin_login_or_panel():
elif action == "add_product": elif action == "add_product":
vendor_id = parse_int(request.form.get("vendor_id")) vendor_id = parse_int(request.form.get("vendor_id"))
name = (request.form.get("name") or "").strip() name = (request.form.get("name") or "").strip()
url = (request.form.get("url") or "").strip()
if vendor_id and name: if vendor_id and name:
conn.execute( conn.execute(
f"INSERT OR IGNORE INTO {tables['products']}(vendor_id, name) VALUES (?, ?)", f"INSERT OR IGNORE INTO {tables['products']}(vendor_id, name, url) VALUES (?, ?, ?)",
(vendor_id, name), (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": elif action == "delete_vendor":
@@ -734,6 +849,7 @@ def admin_login_or_panel():
for r in conn.execute( for r in conn.execute(
f""" f"""
SELECT p.id, p.name, p.vendor_id, v.name AS vendor_name SELECT p.id, p.name, p.vendor_id, v.name AS vendor_name
, p.url
FROM {tables['products']} p FROM {tables['products']} p
JOIN {tables['vendors']} v ON v.id = p.vendor_id JOIN {tables['vendors']} v ON v.id = p.vendor_id
ORDER BY lower(v.name), lower(p.name) ORDER BY lower(v.name), lower(p.name)
@@ -1085,6 +1201,17 @@ INDEX_HTML = """
padding: 4px 8px; padding: 4px 8px;
border: 1px solid #d2e3ff; 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 { .credit {
position: fixed; position: fixed;
@@ -1318,7 +1445,7 @@ INDEX_HTML = """
const cats = categoriesByProduct.get(pId) || new Set(); const cats = categoriesByProduct.get(pId) || new Set();
return [...state.selectedCategories].some(cId => cats.has(cId)); 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; if (products.length === 0) continue;
rows.push({ vendor: vendor.name, products }); rows.push({ vendor: vendor.name, products });
} }
@@ -1337,10 +1464,16 @@ INDEX_HTML = """
card.appendChild(title); card.appendChild(title);
const tags = document.createElement("div"); const tags = document.createElement("div");
tags.className = "tags"; tags.className = "tags";
for (const name of row.products) { for (const product of row.products) {
const tag = document.createElement("span"); const hasUrl = product.url && String(product.url).trim().length > 0;
const tag = document.createElement(hasUrl ? "a" : "span");
tag.className = "tag"; 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); tags.appendChild(tag);
} }
card.appendChild(tags); card.appendChild(tags);
@@ -1590,6 +1723,7 @@ ADMIN_HTML = """
{% endfor %} {% endfor %}
</select> </select>
<input type="text" name="name" placeholder="Название продукта" required /> <input type="text" name="name" placeholder="Название продукта" required />
<input type="text" name="url" placeholder="URL продукта (необязательно)" />
<button class="pri" type="submit">Добавить</button> <button class="pri" type="submit">Добавить</button>
</form> </form>
</div> </div>
@@ -1625,7 +1759,10 @@ ADMIN_HTML = """
{% for p in products %} {% for p in products %}
<form class="list-item" method="post"> <form class="list-item" method="post">
<input type="hidden" name="scope" value="{{ scope }}" /> <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="action" value="delete_product" />
<input type="hidden" name="product_id" value="{{ p.id }}" /> <input type="hidden" name="product_id" value="{{ p.id }}" />
<button class="danger" type="submit">Удалить</button> <button class="danger" type="submit">Удалить</button>

BIN
matrix.db

Binary file not shown.

17
nginx.conf Normal file
View 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;
}
}

View File

@@ -1,2 +1,3 @@
Flask>=3.0.0 Flask>=3.0.0
openpyxl>=3.1.0 openpyxl>=3.1.0
gunicorn>=23.0.0