diff --git a/Dockerfile b/Dockerfile index 03dc75a..bb66020 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml index 2fdad59..d030b92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/main.py b/main.py index 9cfe731..2210737 100644 --- a/main.py +++ b/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 %} + @@ -1625,7 +1759,10 @@ ADMIN_HTML = """ {% for p in products %}
- {{ p.vendor_name }} :: {{ p.name }} + + {{ p.vendor_name }} :: {{ p.name }} + {% if p.url %}ссылка{% endif %} + diff --git a/matrix.db b/matrix.db index b5cb303..54b9c7c 100644 Binary files a/matrix.db and b/matrix.db differ diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..abba34f --- /dev/null +++ b/nginx.conf @@ -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; + } +} diff --git a/requirements.txt b/requirements.txt index 29ab029..6d03542 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ Flask>=3.0.0 openpyxl>=3.1.0 +gunicorn>=23.0.0