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 . .
|
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:
|
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
151
main.py
@@ -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,11 +788,17 @@ 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":
|
||||||
v_id = parse_int(request.form.get("vendor_id"))
|
v_id = parse_int(request.form.get("vendor_id"))
|
||||||
@@ -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>
|
||||||
|
|||||||
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
|
Flask>=3.0.0
|
||||||
openpyxl>=3.1.0
|
openpyxl>=3.1.0
|
||||||
|
gunicorn>=23.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user