1817 lines
65 KiB
Python
1817 lines
65 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
import json
|
||
import sqlite3
|
||
from pathlib import Path
|
||
from typing import Iterable
|
||
|
||
from flask import Flask, jsonify, redirect, render_template_string, request, send_from_directory, session
|
||
|
||
try:
|
||
from openpyxl import load_workbook
|
||
except ImportError:
|
||
load_workbook = None
|
||
|
||
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"
|
||
ADMIN_PASSWORD = "batmannotmont"
|
||
|
||
app = Flask(__name__)
|
||
app.secret_key = os.getenv("SECRET_KEY", "change-me-please")
|
||
ENABLE_BOOTSTRAP = os.getenv("ENABLE_BOOTSTRAP", "0").strip().lower() in {"1", "true", "yes", "on"}
|
||
|
||
|
||
def get_db() -> sqlite3.Connection:
|
||
conn = sqlite3.connect(DB_PATH)
|
||
conn.row_factory = sqlite3.Row
|
||
conn.execute("PRAGMA foreign_keys = ON")
|
||
return conn
|
||
|
||
|
||
def seed_data(conn: sqlite3.Connection) -> None:
|
||
categories = [
|
||
"Augmented Reality",
|
||
"IoT",
|
||
"Robotic Process Automation (RPA)",
|
||
"Автоматизация бизнес-процессов",
|
||
"Видеосвязь и веб-конференции",
|
||
"Визуализация и анализ данных",
|
||
"Виртуализация",
|
||
"Виртуализация рабочих мест, VDI",
|
||
"Виртуализация, гиперконвергенция",
|
||
"Виртуализация, классическая виртуализация",
|
||
"Графические редакторы (замена Visio)",
|
||
"Компьютерная техника",
|
||
"Контейнерные платформы",
|
||
"Корпоративные почтовые серверы",
|
||
"Корпоративные коммуникации",
|
||
"Облачные платформы",
|
||
"Облачные сервисы и сопроводительные решения",
|
||
"Онлайн-переводчик",
|
||
"Операционные системы",
|
||
"СУБД",
|
||
"Оцифровка бумажных документов",
|
||
"Платформы для онлайн-обучения",
|
||
"ПО в сфере ИИ",
|
||
"Программные маркетплейсы",
|
||
"Программы для смартфонов",
|
||
"Работа с PDF",
|
||
"Работа с мультимедиа (видео, фото, графика)",
|
||
"Разработка в ИИ",
|
||
"Резервное копирование и управление данными",
|
||
"Речевые технологии, компьютерное зрение",
|
||
"САПР",
|
||
"Серверное и WiFi оборудование",
|
||
"Системы хранения данных",
|
||
"Системы ЭДО",
|
||
"Средства разработки ПО",
|
||
"Техническая поддержка и консалтинг",
|
||
"Удаленное управление устройствами",
|
||
"Файлы и диски",
|
||
]
|
||
|
||
vendors = [
|
||
"Adobe",
|
||
"AliveColors",
|
||
"Amazon Web Services (AWS)",
|
||
"ANWORK",
|
||
"CommuniGate Pro (СБК)",
|
||
"Content AI (ex-ABBYY)",
|
||
"DocTrix",
|
||
"EvaTeam",
|
||
"eXpress",
|
||
"FanRuan",
|
||
"GStarCAD",
|
||
"Handy Backup",
|
||
"InfoWatch",
|
||
"iSpring",
|
||
"Just AI",
|
||
"Kairos Digital",
|
||
"LITEBIM",
|
||
"LiteManager",
|
||
"livedigital",
|
||
"Master PDF (Code Industry)",
|
||
"MIND Software",
|
||
"Monq",
|
||
"NextBox",
|
||
"Paragon Software Group",
|
||
"SL Soft",
|
||
"Positive Technologies",
|
||
"Postgres Pro",
|
||
"Pragmatic Tools",
|
||
"Pro32",
|
||
"PROMT",
|
||
"Quasar",
|
||
"Radmin",
|
||
"RDW Computers",
|
||
"Renga Software",
|
||
"SETERE Group",
|
||
"Sharx DC",
|
||
"SpaceVM",
|
||
"TestIT",
|
||
"Uncom OS",
|
||
"Utinet",
|
||
"Valo Cloud",
|
||
"Vinteo",
|
||
"VK Tech",
|
||
"АЛМИ Партнер",
|
||
"АСКОН",
|
||
"Базальт",
|
||
"БФТ",
|
||
"ГазИнформСервис",
|
||
"Гравитон",
|
||
"ГрафТех",
|
||
"Группа Астра",
|
||
"ИТ Роут",
|
||
"Киберпротект",
|
||
"Контур",
|
||
"Кредо-Диалог",
|
||
"Лаборатория Касперского",
|
||
"Лаборатория Числитель",
|
||
"Мовавика",
|
||
"МойОфис",
|
||
"МТС Линк",
|
||
"Нанософт разработка",
|
||
"НЛПК",
|
||
"Облакотека",
|
||
"Р7",
|
||
"РЕД СОФТ",
|
||
"РОСА",
|
||
"Росплатформа",
|
||
"Сакура ПРО",
|
||
"Салют для бизнеса (SberDevices)",
|
||
"Труконф",
|
||
"Флант (Deckhouse)",
|
||
"ЦРТ",
|
||
"ЦИТИП",
|
||
"Яндекс 360 для бизнеса",
|
||
]
|
||
|
||
vendor_links = {
|
||
"Adobe": ["Работа с PDF", "Оцифровка бумажных документов", "Работа с мультимедиа (видео, фото, графика)"],
|
||
"Amazon Web Services (AWS)": ["Облачные платформы", "Облачные сервисы и сопроводительные решения", "ПО в сфере ИИ"],
|
||
"CommuniGate Pro (СБК)": ["Корпоративные почтовые серверы", "Корпоративные коммуникации"],
|
||
"Content AI (ex-ABBYY)": ["Оцифровка бумажных документов", "Онлайн-переводчик", "Работа с PDF"],
|
||
"eXpress": ["Корпоративные коммуникации", "Программы для смартфонов"],
|
||
"FanRuan": ["Визуализация и анализ данных"],
|
||
"GStarCAD": ["САПР"],
|
||
"Handy Backup": ["Резервное копирование и управление данными"],
|
||
"iSpring": ["Платформы для онлайн-обучения"],
|
||
"Just AI": ["ПО в сфере ИИ", "Речевые технологии, компьютерное зрение"],
|
||
"LiteManager": ["Удаленное управление устройствами"],
|
||
"Master PDF (Code Industry)": ["Работа с PDF"],
|
||
"Paragon Software Group": ["Файлы и диски", "Резервное копирование и управление данными"],
|
||
"Postgres Pro": ["СУБД"],
|
||
"PROMT": ["Онлайн-переводчик"],
|
||
"Radmin": ["Удаленное управление устройствами"],
|
||
"Renga Software": ["САПР"],
|
||
"SpaceVM": ["Виртуализация", "Виртуализация рабочих мест, VDI"],
|
||
"Uncom OS": ["Операционные системы"],
|
||
"VK Tech": ["Облачные платформы", "Корпоративные коммуникации", "ПО в сфере ИИ"],
|
||
"Базальт": ["Операционные системы"],
|
||
"ГазИнформСервис": ["Системы ЭДО", "Техническая поддержка и консалтинг"],
|
||
"Группа Астра": ["Операционные системы", "Виртуализация", "СУБД"],
|
||
"Киберпротект": ["Резервное копирование и управление данными"],
|
||
"Контур": ["Системы ЭДО", "Корпоративные коммуникации"],
|
||
"Лаборатория Касперского": ["Техническая поддержка и консалтинг", "Средства разработки ПО"],
|
||
"МойОфис": ["Корпоративные коммуникации", "Программы для смартфонов", "Файлы и диски"],
|
||
"МТС Линк": ["Видеосвязь и веб-конференции", "Платформы для онлайн-обучения"],
|
||
"Р7": ["Корпоративные коммуникации", "Файлы и диски"],
|
||
"РЕД СОФТ": ["Операционные системы", "СУБД"],
|
||
"РОСА": ["Операционные системы"],
|
||
"Росплатформа": ["Облачные платформы", "Виртуализация, гиперконвергенция"],
|
||
"Салют для бизнеса (SberDevices)": ["ПО в сфере ИИ", "Речевые технологии, компьютерное зрение"],
|
||
"Труконф": ["Видеосвязь и веб-конференции", "Корпоративные коммуникации"],
|
||
"Флант (Deckhouse)": ["Контейнерные платформы", "Облачные платформы"],
|
||
"ЦРТ": ["Речевые технологии, компьютерное зрение", "ПО в сфере ИИ"],
|
||
"Яндекс 360 для бизнеса": ["Корпоративные коммуникации", "Файлы и диски", "Программы для смартфонов"],
|
||
}
|
||
|
||
conn.executemany("INSERT INTO categories(name) VALUES (?)", [(name,) for name in categories])
|
||
conn.executemany("INSERT INTO vendors(name) VALUES (?)", [(name,) for name in vendors])
|
||
|
||
category_ids = {r["name"]: r["id"] for r in conn.execute("SELECT id, name FROM categories")}
|
||
vendor_ids = {r["name"]: r["id"] for r in conn.execute("SELECT id, name FROM vendors")}
|
||
|
||
pairs: list[tuple[int, int]] = []
|
||
for vendor, cats in vendor_links.items():
|
||
v_id = vendor_ids.get(vendor)
|
||
if not v_id:
|
||
continue
|
||
for cat in cats:
|
||
c_id = category_ids.get(cat)
|
||
if c_id:
|
||
pairs.append((v_id, c_id))
|
||
|
||
conn.executemany(
|
||
"INSERT OR IGNORE INTO vendor_categories(vendor_id, category_id) VALUES (?, ?)",
|
||
pairs,
|
||
)
|
||
|
||
|
||
def init_db() -> None:
|
||
conn = get_db()
|
||
conn.executescript(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS vendors (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL UNIQUE
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS categories (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL UNIQUE
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS vendor_categories (
|
||
vendor_id INTEGER NOT NULL,
|
||
category_id INTEGER NOT NULL,
|
||
PRIMARY KEY (vendor_id, category_id),
|
||
FOREIGN KEY(vendor_id) REFERENCES vendors(id) ON DELETE CASCADE,
|
||
FOREIGN KEY(category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS ib_vendors (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL UNIQUE
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS ib_categories (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL UNIQUE
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS ib_vendor_categories (
|
||
vendor_id INTEGER NOT NULL,
|
||
category_id INTEGER NOT NULL,
|
||
PRIMARY KEY (vendor_id, category_id),
|
||
FOREIGN KEY(vendor_id) REFERENCES ib_vendors(id) ON DELETE CASCADE,
|
||
FOREIGN KEY(category_id) REFERENCES ib_categories(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS products (
|
||
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
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS product_categories (
|
||
product_id INTEGER NOT NULL,
|
||
category_id INTEGER NOT NULL,
|
||
PRIMARY KEY (product_id, category_id),
|
||
FOREIGN KEY(product_id) REFERENCES products(id) ON DELETE CASCADE,
|
||
FOREIGN KEY(category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS ib_products (
|
||
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
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS ib_product_categories (
|
||
product_id INTEGER NOT NULL,
|
||
category_id INTEGER NOT NULL,
|
||
PRIMARY KEY (product_id, category_id),
|
||
FOREIGN KEY(product_id) REFERENCES ib_products(id) ON DELETE CASCADE,
|
||
FOREIGN KEY(category_id) REFERENCES ib_categories(id) ON DELETE CASCADE
|
||
);
|
||
"""
|
||
)
|
||
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 and ENABLE_BOOTSTRAP:
|
||
seed_data(conn)
|
||
|
||
has_ib_data = conn.execute("SELECT EXISTS(SELECT 1 FROM ib_vendors)").fetchone()[0]
|
||
if not has_ib_data and ENABLE_BOOTSTRAP:
|
||
ib_matrix = None
|
||
from_xlsx = load_matrices_from_xlsx()
|
||
if from_xlsx:
|
||
ib_matrix = from_xlsx.get("ib")
|
||
if not ib_matrix:
|
||
ib_matrix = IB_MATRIX
|
||
seed_ib_data(conn, ib_matrix)
|
||
|
||
if ENABLE_BOOTSTRAP:
|
||
bootstrap_products_from_vendor_links(conn, "infra")
|
||
bootstrap_products_from_vendor_links(conn, "ib")
|
||
import_infra_products_from_json(conn)
|
||
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
|
||
def fetch_matrix() -> dict:
|
||
conn = get_db()
|
||
vendors = [dict(r) for r in conn.execute("SELECT id, name FROM vendors ORDER BY lower(name)")]
|
||
categories = [dict(r) for r in conn.execute("SELECT id, name FROM categories ORDER BY lower(name)")]
|
||
links = [dict(r) for r in conn.execute("SELECT vendor_id, category_id FROM vendor_categories")]
|
||
conn.close()
|
||
return {"vendors": vendors, "categories": categories, "links": links}
|
||
|
||
|
||
def scope_tables(scope: str) -> dict[str, str]:
|
||
if scope == "ib":
|
||
return {
|
||
"vendors": "ib_vendors",
|
||
"categories": "ib_categories",
|
||
"vendor_categories": "ib_vendor_categories",
|
||
"products": "ib_products",
|
||
"product_categories": "ib_product_categories",
|
||
}
|
||
return {
|
||
"vendors": "vendors",
|
||
"categories": "categories",
|
||
"vendor_categories": "vendor_categories",
|
||
"products": "products",
|
||
"product_categories": "product_categories",
|
||
}
|
||
|
||
|
||
def seed_ib_data(conn: sqlite3.Connection, matrix: dict) -> None:
|
||
categories = [item["name"] for item in matrix.get("categories", [])]
|
||
vendors = [item["name"] for item in matrix.get("vendors", [])]
|
||
links = matrix.get("links", [])
|
||
|
||
conn.executemany("INSERT OR IGNORE INTO ib_categories(name) VALUES (?)", [(name,) for name in categories])
|
||
conn.executemany("INSERT OR IGNORE INTO ib_vendors(name) VALUES (?)", [(name,) for name in vendors])
|
||
|
||
category_ids = {r["name"]: r["id"] for r in conn.execute("SELECT id, name FROM ib_categories")}
|
||
vendor_ids = {r["name"]: r["id"] for r in conn.execute("SELECT id, name FROM ib_vendors")}
|
||
src_category_by_id = {item["id"]: item["name"] for item in matrix.get("categories", [])}
|
||
src_vendor_by_id = {item["id"]: item["name"] for item in matrix.get("vendors", [])}
|
||
|
||
pairs: list[tuple[int, int]] = []
|
||
for link in links:
|
||
src_vendor_name = src_vendor_by_id.get(link["vendor_id"])
|
||
src_category_name = src_category_by_id.get(link["category_id"])
|
||
if not src_vendor_name or not src_category_name:
|
||
continue
|
||
db_vendor_id = vendor_ids.get(src_vendor_name)
|
||
db_category_id = category_ids.get(src_category_name)
|
||
if db_vendor_id and db_category_id:
|
||
pairs.append((db_vendor_id, db_category_id))
|
||
|
||
conn.executemany(
|
||
"INSERT OR IGNORE INTO ib_vendor_categories(vendor_id, category_id) VALUES (?, ?)",
|
||
pairs,
|
||
)
|
||
|
||
|
||
def fetch_ib_matrix() -> dict:
|
||
conn = get_db()
|
||
vendors = [dict(r) for r in conn.execute("SELECT id, name FROM ib_vendors ORDER BY lower(name)")]
|
||
categories = [dict(r) for r in conn.execute("SELECT id, name FROM ib_categories ORDER BY lower(name)")]
|
||
links = [dict(r) for r in conn.execute("SELECT vendor_id, category_id FROM ib_vendor_categories")]
|
||
conn.close()
|
||
return {"vendors": vendors, "categories": categories, "links": links}
|
||
|
||
|
||
def fetch_scope_data(scope: str) -> dict:
|
||
tables = scope_tables(scope)
|
||
conn = get_db()
|
||
vendors = [dict(r) for r in conn.execute(f"SELECT id, name FROM {tables['vendors']} ORDER BY lower(name)")]
|
||
categories = [dict(r) for r in conn.execute(f"SELECT id, name FROM {tables['categories']} ORDER BY lower(name)")]
|
||
products = [
|
||
dict(r)
|
||
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)
|
||
"""
|
||
)
|
||
]
|
||
product_links = [
|
||
dict(r)
|
||
for r in conn.execute(
|
||
f"SELECT product_id, category_id FROM {tables['product_categories']}"
|
||
)
|
||
]
|
||
links = [
|
||
dict(r)
|
||
for r in conn.execute(
|
||
f"SELECT vendor_id, category_id FROM {tables['vendor_categories']}"
|
||
)
|
||
]
|
||
conn.close()
|
||
return {
|
||
"vendors": vendors,
|
||
"categories": categories,
|
||
"products": products,
|
||
"product_links": product_links,
|
||
"links": links,
|
||
}
|
||
|
||
|
||
def bootstrap_products_from_vendor_links(conn: sqlite3.Connection, scope: str) -> None:
|
||
tables = scope_tables(scope)
|
||
has_products = conn.execute(f"SELECT EXISTS(SELECT 1 FROM {tables['products']})").fetchone()[0]
|
||
if has_products:
|
||
return
|
||
vendors = [dict(r) for r in conn.execute(f"SELECT id, name FROM {tables['vendors']}")]
|
||
for vendor in vendors:
|
||
cur = conn.execute(
|
||
f"INSERT INTO {tables['products']}(vendor_id, name) VALUES (?, ?)",
|
||
(vendor["id"], "Базовый продукт"),
|
||
)
|
||
product_id = cur.lastrowid
|
||
categories = [
|
||
r["category_id"]
|
||
for r in conn.execute(
|
||
f"SELECT category_id FROM {tables['vendor_categories']} WHERE vendor_id = ?",
|
||
(vendor["id"],),
|
||
)
|
||
]
|
||
conn.executemany(
|
||
f"INSERT OR IGNORE INTO {tables['product_categories']}(product_id, category_id) VALUES (?, ?)",
|
||
[(product_id, c_id) for c_id in categories],
|
||
)
|
||
|
||
|
||
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],
|
||
vendor_links: dict[str, list[str]],
|
||
) -> dict:
|
||
categories_payload = [{"id": i + 1, "name": name} for i, name in enumerate(categories)]
|
||
vendors_payload = [{"id": i + 1, "name": name} for i, name in enumerate(vendors)]
|
||
category_ids = {item["name"]: item["id"] for item in categories_payload}
|
||
vendor_ids = {item["name"]: item["id"] for item in vendors_payload}
|
||
links_payload: list[dict[str, int]] = []
|
||
for vendor_name, linked_categories in vendor_links.items():
|
||
v_id = vendor_ids.get(vendor_name)
|
||
if not v_id:
|
||
continue
|
||
for category_name in linked_categories:
|
||
c_id = category_ids.get(category_name)
|
||
if c_id:
|
||
links_payload.append({"vendor_id": v_id, "category_id": c_id})
|
||
return {"vendors": vendors_payload, "categories": categories_payload, "links": links_payload}
|
||
|
||
|
||
def parse_xlsx_matrix_sheet(
|
||
sheet,
|
||
*,
|
||
header_row: int,
|
||
data_start_row: int,
|
||
category_start_col: int,
|
||
) -> dict:
|
||
category_cols: list[tuple[int, str]] = []
|
||
for col in range(category_start_col, sheet.max_column + 1):
|
||
raw = sheet.cell(header_row, col).value
|
||
if raw is None:
|
||
continue
|
||
name = str(raw).strip()
|
||
if name:
|
||
category_cols.append((col, name))
|
||
|
||
categories_payload = [{"id": i + 1, "name": name} for i, (_, name) in enumerate(category_cols)]
|
||
category_id_by_col = {col: idx + 1 for idx, (col, _) in enumerate(category_cols)}
|
||
|
||
vendors_payload: list[dict[str, str | int]] = []
|
||
links_payload: list[dict[str, int]] = []
|
||
|
||
for row in range(data_start_row, sheet.max_row + 1):
|
||
raw_vendor = sheet.cell(row, 1).value
|
||
if raw_vendor is None:
|
||
continue
|
||
vendor_name = str(raw_vendor).strip()
|
||
if not vendor_name:
|
||
continue
|
||
lowered = vendor_name.lower()
|
||
if "вендор" in lowered or "решение" in lowered or "категория" in lowered:
|
||
continue
|
||
|
||
vendor_id = len(vendors_payload) + 1
|
||
vendors_payload.append({"id": vendor_id, "name": vendor_name})
|
||
|
||
for col, _ in category_cols:
|
||
mark = sheet.cell(row, col).value
|
||
if mark is None:
|
||
continue
|
||
if str(mark).strip() == "":
|
||
continue
|
||
links_payload.append({"vendor_id": vendor_id, "category_id": category_id_by_col[col]})
|
||
|
||
return {"vendors": vendors_payload, "categories": categories_payload, "links": links_payload}
|
||
|
||
|
||
def load_matrices_from_xlsx() -> dict[str, dict] | None:
|
||
if load_workbook is None:
|
||
return None
|
||
if not XLSX_PATH.exists():
|
||
return None
|
||
wb = load_workbook(XLSX_PATH, data_only=True)
|
||
if "инфра" not in wb.sheetnames or "инфобез" not in wb.sheetnames:
|
||
return None
|
||
infra = parse_xlsx_matrix_sheet(
|
||
wb["инфра"],
|
||
header_row=1,
|
||
data_start_row=2,
|
||
category_start_col=4,
|
||
)
|
||
ib = parse_xlsx_matrix_sheet(
|
||
wb["инфобез"],
|
||
header_row=2,
|
||
data_start_row=4,
|
||
category_start_col=3,
|
||
)
|
||
return {"infra": infra, "ib": ib}
|
||
|
||
|
||
IB_CATEGORIES = [
|
||
"Защита конечных устройств (EDR/EPP)",
|
||
"Безопасность мобильных устройств",
|
||
"Межсетевые экраны и NGFW",
|
||
"Удаленный доступ (VPN)",
|
||
"Защита от DDoS",
|
||
"Защита виртуальных сред",
|
||
"NTA / анализ сетевого трафика",
|
||
"Защита АСУ ТП",
|
||
"Sandbox",
|
||
"Управление уязвимостями (VM)",
|
||
"Управление событиями (SIEM)",
|
||
"SOAR",
|
||
"SGRC / комплаенс",
|
||
"Поведенческий анализ (UEBA)",
|
||
"Антифрод",
|
||
"KMS / криптозащита",
|
||
"DLP",
|
||
"Классификация и маркировка данных",
|
||
"Защита баз данных",
|
||
"DRM",
|
||
"DAM / доступ к секретам",
|
||
"Биометрическая аутентификация",
|
||
"MFA",
|
||
"Менеджер паролей",
|
||
"SWG / веб-безопасность",
|
||
"Родительский контроль",
|
||
]
|
||
|
||
IB_VENDORS = [
|
||
"Bifit Mitigator",
|
||
"BI.ZONE",
|
||
"Check Point",
|
||
"F6",
|
||
"InfoWatch",
|
||
"Positive Technologies",
|
||
"Лаборатория Касперского",
|
||
"Киберпротект",
|
||
"Код Безопасности",
|
||
"Р7",
|
||
"Контур",
|
||
"UserGate",
|
||
"С-Терра",
|
||
"Гарда",
|
||
"КриптоПро",
|
||
"Эшелон",
|
||
"R-Vision",
|
||
"RuSIEM",
|
||
"SkyDNS",
|
||
"IKOD",
|
||
"StaffCop",
|
||
"Zecurion",
|
||
"Nano Security",
|
||
"StopPhish",
|
||
]
|
||
|
||
IB_VENDOR_LINKS = {
|
||
"Bifit Mitigator": ["Антифрод", "UEBA", "SIEM"],
|
||
"BI.ZONE": ["SIEM", "SOAR", "SGRC / комплаенс", "VM", "DLP", "Антифрод"],
|
||
"Check Point": ["Межсетевые экраны и NGFW", "VPN", "Защита конечных устройств (EDR/EPP)", "SWG / веб-безопасность"],
|
||
"F6": ["Антифрод", "Защита от DDoS", "NTA / анализ сетевого трафика"],
|
||
"InfoWatch": ["DLP", "Классификация и маркировка данных", "DRM"],
|
||
"Positive Technologies": ["VM", "NTA / анализ сетевого трафика", "SIEM", "SOAR", "SGRC / комплаенс"],
|
||
"Лаборатория Касперского": ["Защита конечных устройств (EDR/EPP)", "Sandbox", "SIEM", "SWG / веб-безопасность"],
|
||
"Киберпротект": ["DLP", "Защита баз данных", "DRM"],
|
||
"Код Безопасности": ["Межсетевые экраны и NGFW", "VPN", "Защита виртуальных сред", "KMS / криптозащита"],
|
||
"Р7": ["MFA", "Менеджер паролей"],
|
||
"Контур": ["MFA", "Биометрическая аутентификация"],
|
||
"UserGate": ["Межсетевые экраны и NGFW", "SWG / веб-безопасность", "VPN"],
|
||
"С-Терра": ["VPN", "KMS / криптозащита"],
|
||
"Гарда": ["DLP", "Классификация и маркировка данных", "SIEM"],
|
||
"КриптоПро": ["KMS / криптозащита", "MFA", "Биометрическая аутентификация"],
|
||
"Эшелон": ["VM", "SIEM", "SGRC / комплаенс"],
|
||
"R-Vision": ["SIEM", "SOAR", "SGRC / комплаенс", "UEBA"],
|
||
"RuSIEM": ["SIEM", "SOAR"],
|
||
"SkyDNS": ["SWG / веб-безопасность", "Родительский контроль"],
|
||
"IKOD": ["DAM / доступ к секретам", "Менеджер паролей"],
|
||
"StaffCop": ["UEBA", "DLP"],
|
||
"Zecurion": ["DLP", "Классификация и маркировка данных", "Защита баз данных"],
|
||
"Nano Security": ["Защита конечных устройств (EDR/EPP)", "Sandbox"],
|
||
"StopPhish": ["Антифрод", "MFA"],
|
||
}
|
||
|
||
IB_MATRIX = build_matrix_from_lists(IB_VENDORS, IB_CATEGORIES, IB_VENDOR_LINKS)
|
||
|
||
|
||
def require_admin() -> bool:
|
||
return bool(session.get("is_admin"))
|
||
|
||
|
||
def parse_int(form_value: str | None) -> int | None:
|
||
if not form_value:
|
||
return None
|
||
try:
|
||
return int(form_value)
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
@app.get("/")
|
||
def index():
|
||
return render_template_string(INDEX_HTML)
|
||
|
||
|
||
@app.get("/api/data")
|
||
def api_data():
|
||
scope = (request.args.get("scope") or "infra").strip().lower()
|
||
if scope in {"ib", "sec", "security"}:
|
||
scope = "ib"
|
||
else:
|
||
scope = "infra"
|
||
return jsonify(fetch_scope_data(scope))
|
||
|
||
|
||
@app.route(ADMIN_PATH, methods=["GET", "POST"])
|
||
def admin_login_or_panel():
|
||
conn = get_db()
|
||
raw_scope = (request.args.get("scope") or request.form.get("scope") or "infra").strip().lower()
|
||
scope = "ib" if raw_scope in {"ib", "sec", "security"} else "infra"
|
||
tables = scope_tables(scope)
|
||
|
||
if request.method == "POST" and not require_admin() and request.form.get("action") == "login":
|
||
if request.form.get("username") == ADMIN_LOGIN and request.form.get("password") == ADMIN_PASSWORD:
|
||
session["is_admin"] = True
|
||
conn.close()
|
||
return redirect(ADMIN_PATH)
|
||
conn.close()
|
||
return render_template_string(LOGIN_HTML, error="Неверный логин или пароль")
|
||
|
||
if not require_admin():
|
||
conn.close()
|
||
return render_template_string(LOGIN_HTML, error=None)
|
||
|
||
if request.method == "POST":
|
||
action = request.form.get("action", "")
|
||
if action == "logout":
|
||
session.pop("is_admin", None)
|
||
conn.close()
|
||
return redirect(ADMIN_PATH)
|
||
|
||
if action == "add_vendor":
|
||
name = (request.form.get("name") or "").strip()
|
||
if name:
|
||
conn.execute(f"INSERT OR IGNORE INTO {tables['vendors']}(name) VALUES (?)", (name,))
|
||
|
||
elif action == "add_category":
|
||
name = (request.form.get("name") or "").strip()
|
||
if name:
|
||
conn.execute(f"INSERT OR IGNORE INTO {tables['categories']}(name) VALUES (?)", (name,))
|
||
|
||
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, 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"))
|
||
if v_id:
|
||
conn.execute(f"DELETE FROM {tables['vendors']} WHERE id = ?", (v_id,))
|
||
|
||
elif action == "delete_category":
|
||
c_id = parse_int(request.form.get("category_id"))
|
||
if c_id:
|
||
conn.execute(f"DELETE FROM {tables['categories']} WHERE id = ?", (c_id,))
|
||
|
||
elif action == "delete_product":
|
||
p_id = parse_int(request.form.get("product_id"))
|
||
if p_id:
|
||
conn.execute(f"DELETE FROM {tables['products']} WHERE id = ?", (p_id,))
|
||
|
||
elif action == "save_matrix":
|
||
products = [r["id"] for r in conn.execute(f"SELECT id FROM {tables['products']}")]
|
||
categories = [r["id"] for r in conn.execute(f"SELECT id FROM {tables['categories']}")]
|
||
new_pairs: list[tuple[int, int]] = []
|
||
for p_id in products:
|
||
for c_id in categories:
|
||
if request.form.get(f"pc_{p_id}_{c_id}"):
|
||
new_pairs.append((p_id, c_id))
|
||
conn.execute(f"DELETE FROM {tables['product_categories']}")
|
||
conn.executemany(
|
||
f"INSERT OR IGNORE INTO {tables['product_categories']}(product_id, category_id) VALUES (?, ?)",
|
||
new_pairs,
|
||
)
|
||
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
|
||
"""
|
||
)
|
||
|
||
conn.commit()
|
||
conn.close()
|
||
return redirect(f"{ADMIN_PATH}?scope={scope}")
|
||
|
||
vendors = [dict(r) for r in conn.execute(f"SELECT id, name FROM {tables['vendors']} ORDER BY lower(name)")]
|
||
categories = [dict(r) for r in conn.execute(f"SELECT id, name FROM {tables['categories']} ORDER BY lower(name)")]
|
||
products = [
|
||
dict(r)
|
||
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)
|
||
"""
|
||
)
|
||
]
|
||
links = {
|
||
(r["product_id"], r["category_id"])
|
||
for r in conn.execute(f"SELECT product_id, category_id FROM {tables['product_categories']}")
|
||
}
|
||
conn.close()
|
||
return render_template_string(
|
||
ADMIN_HTML,
|
||
vendors=vendors,
|
||
categories=categories,
|
||
products=products,
|
||
links=links,
|
||
scope=scope,
|
||
)
|
||
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
return {"status": "ok"}
|
||
|
||
|
||
@app.get("/assets/mont-logo")
|
||
def mont_logo():
|
||
return send_from_directory(BASE_DIR, "mont_logo.png")
|
||
|
||
|
||
INDEX_HTML = """
|
||
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Z-карта вендоров</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@600;700&family=Manrope:wght@400;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--bg: #eef4ff;
|
||
--bg2: #dde9ff;
|
||
--panel: #ffffff;
|
||
--text: #15203b;
|
||
--muted: #526079;
|
||
--line: #c8d7f7;
|
||
--brand: #1f4ea3;
|
||
--brand-2: #3578ef;
|
||
--accent: #0f7b56;
|
||
--radius: 18px;
|
||
--shadow: 0 20px 55px rgba(16, 43, 95, .14);
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
body {
|
||
margin: 0;
|
||
min-height: 100vh;
|
||
font-family: Manrope, sans-serif;
|
||
color: var(--text);
|
||
background:
|
||
radial-gradient(1200px 700px at -10% -10%, #c8dbff 0%, transparent 55%),
|
||
radial-gradient(900px 500px at 110% -20%, #d8f4ec 0%, transparent 50%),
|
||
linear-gradient(160deg, var(--bg) 0%, var(--bg2) 100%);
|
||
}
|
||
body.scope-ib {
|
||
background:
|
||
radial-gradient(1200px 700px at -10% -10%, #ffb3b3 0%, transparent 58%),
|
||
radial-gradient(900px 500px at 110% -20%, #ffbeb3 0%, transparent 52%),
|
||
linear-gradient(160deg, #ffd7d7 0%, #ffb8b8 100%);
|
||
}
|
||
body.scope-ib .hero {
|
||
background:
|
||
linear-gradient(160deg, rgba(255,255,255,.09), rgba(255,255,255,0) 45%),
|
||
linear-gradient(125deg, #7a1f2a 0%, #b43444 55%, #d34d57 100%);
|
||
}
|
||
body.scope-ib .hero::after {
|
||
background-color: rgba(90, 15, 28, .45);
|
||
}
|
||
body.scope-ib .hero::before {
|
||
background:
|
||
linear-gradient(to top, rgba(255,255,255,.9) 0 2px, rgba(255,255,255,0) 2px),
|
||
rgba(74, 9, 24, .42);
|
||
}
|
||
body.scope-ib .mode-btn.active {
|
||
background: linear-gradient(140deg, #9a2331, #c03d4c);
|
||
box-shadow: 0 8px 18px rgba(158, 33, 51, .35);
|
||
}
|
||
|
||
.wrap {
|
||
width: min(1400px, calc(100% - 32px));
|
||
margin: 18px auto 28px;
|
||
}
|
||
|
||
.brand-strip {
|
||
margin-bottom: 12px;
|
||
display: flex;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.hero {
|
||
background:
|
||
linear-gradient(160deg, rgba(255,255,255,.12), rgba(255,255,255,0) 45%),
|
||
linear-gradient(125deg, #173d83 0%, #2c63ca 55%, #3f83ff 100%);
|
||
color: #f5f8ff;
|
||
border-radius: calc(var(--radius) + 4px);
|
||
padding: 26px;
|
||
box-shadow: var(--shadow);
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.hero-layout {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 0;
|
||
align-items: center;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.hero::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: auto -4% 0 -4%;
|
||
height: 62%;
|
||
background-color: rgba(17, 46, 102, .38);
|
||
clip-path: polygon(0 100%, 0 84%, 9% 52%, 17% 70%, 28% 42%, 39% 73%, 50% 32%, 61% 66%, 73% 38%, 84% 69%, 93% 47%, 100% 60%, 100% 100%);
|
||
}
|
||
|
||
.hero::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: auto -3% 0 -3%;
|
||
height: 50%;
|
||
background:
|
||
linear-gradient(to top, rgba(255,255,255,.9) 0 2px, rgba(255,255,255,0) 2px),
|
||
rgba(9, 31, 76, .32);
|
||
clip-path: polygon(0 100%, 0 90%, 8% 67%, 16% 82%, 25% 58%, 35% 85%, 47% 50%, 58% 80%, 69% 55%, 80% 79%, 91% 62%, 100% 74%, 100% 100%);
|
||
}
|
||
|
||
.hero h1 {
|
||
margin: 0 0 8px;
|
||
font-family: "Space Grotesk", sans-serif;
|
||
font-size: clamp(26px, 4.8vw, 48px);
|
||
letter-spacing: .3px;
|
||
line-height: 1.02;
|
||
}
|
||
|
||
.brand-logo {
|
||
width: clamp(170px, 24vw, 280px);
|
||
padding: 0;
|
||
border: 0;
|
||
background: transparent;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.brand-logo img {
|
||
width: 100%;
|
||
height: auto;
|
||
display: block;
|
||
object-fit: contain;
|
||
filter: drop-shadow(0 6px 10px rgba(0, 0, 0, .14));
|
||
}
|
||
|
||
.hero p {
|
||
margin: 0;
|
||
max-width: 860px;
|
||
color: rgba(245,248,255,.92);
|
||
font-size: 16px;
|
||
}
|
||
|
||
.board {
|
||
margin-top: 18px;
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 16px;
|
||
}
|
||
|
||
.mode-switch {
|
||
display: inline-flex;
|
||
gap: 6px;
|
||
padding: 6px;
|
||
border-radius: 14px;
|
||
background: #ffffff;
|
||
border: 1px solid #d9e6ff;
|
||
box-shadow: 0 8px 24px rgba(26, 58, 118, .08);
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.mode-btn {
|
||
border: 0;
|
||
border-radius: 10px;
|
||
padding: 9px 14px;
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
letter-spacing: .2px;
|
||
color: #2a4e8d;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
transition: .18s ease;
|
||
}
|
||
|
||
.mode-btn:hover { background: #eef4ff; }
|
||
.mode-btn.active {
|
||
color: #fff;
|
||
background: linear-gradient(140deg, #1f4ea3, #3978e0);
|
||
box-shadow: 0 8px 18px rgba(38, 86, 176, .28);
|
||
}
|
||
|
||
.card {
|
||
background: var(--panel);
|
||
border: 1px solid #dfebff;
|
||
border-radius: var(--radius);
|
||
box-shadow: 0 10px 30px rgba(24, 56, 116, .08);
|
||
padding: 14px;
|
||
min-height: 320px;
|
||
}
|
||
|
||
.card h2 {
|
||
margin: 0 0 10px;
|
||
font-size: 14px;
|
||
text-transform: uppercase;
|
||
letter-spacing: .9px;
|
||
color: #234782;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.search {
|
||
width: 100%;
|
||
border: 1px solid #cfe0ff;
|
||
border-radius: 12px;
|
||
padding: 11px 12px;
|
||
font-size: 14px;
|
||
margin-bottom: 10px;
|
||
outline: none;
|
||
transition: .2s ease;
|
||
}
|
||
|
||
.search:focus {
|
||
border-color: #5b91f6;
|
||
box-shadow: 0 0 0 4px rgba(91,145,246,.14);
|
||
}
|
||
|
||
.chip-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
max-height: 380px;
|
||
overflow: auto;
|
||
padding-right: 4px;
|
||
}
|
||
|
||
.chip {
|
||
border: 1px solid #ccdbf7;
|
||
border-radius: 999px;
|
||
padding: 8px 12px;
|
||
background: #f7faff;
|
||
color: #22427a;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
transition: .18s ease;
|
||
}
|
||
|
||
.chip:hover {
|
||
transform: translateY(-1px);
|
||
border-color: #98b9ef;
|
||
background: #f0f6ff;
|
||
}
|
||
|
||
.chip.active {
|
||
background: linear-gradient(140deg, #1f4ea3, #3978e0);
|
||
border-color: transparent;
|
||
color: #fff;
|
||
box-shadow: 0 6px 16px rgba(34,83,172,.25);
|
||
}
|
||
|
||
.chip.dim {
|
||
opacity: .45;
|
||
}
|
||
|
||
.footer-bar {
|
||
margin-top: 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
button.action {
|
||
border: 0;
|
||
border-radius: 10px;
|
||
background: linear-gradient(140deg, #0d7e59, #0a6648);
|
||
color: #fff;
|
||
padding: 9px 14px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.result {
|
||
margin-top: 16px;
|
||
background: #fff;
|
||
border-radius: var(--radius);
|
||
border: 1px solid #dfebff;
|
||
box-shadow: 0 10px 30px rgba(24, 56, 116, .07);
|
||
padding: 14px;
|
||
}
|
||
|
||
.result-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
gap: 12px;
|
||
}
|
||
|
||
.result-head h3 {
|
||
margin: 0;
|
||
font-size: 17px;
|
||
color: #1f3f77;
|
||
}
|
||
|
||
.rows {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.row-card {
|
||
border: 1px solid var(--line);
|
||
background: linear-gradient(180deg, #ffffff, #f5f9ff);
|
||
border-radius: 12px;
|
||
padding: 10px;
|
||
}
|
||
|
||
.row-card strong { color: #1a3e79; font-size: 14px; }
|
||
.tags { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; }
|
||
.tag {
|
||
font-size: 12px;
|
||
border-radius: 999px;
|
||
background: #eaf2ff;
|
||
color: #234b89;
|
||
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;
|
||
right: 16px;
|
||
bottom: 10px;
|
||
text-align: right;
|
||
line-height: 1.1;
|
||
z-index: 5;
|
||
}
|
||
.credit .name {
|
||
font-family: Caveat, cursive;
|
||
font-size: 28px;
|
||
color: #1c3f7c;
|
||
}
|
||
.credit a {
|
||
font-size: 13px;
|
||
color: #2f5fae;
|
||
text-decoration: none;
|
||
font-weight: 700;
|
||
}
|
||
|
||
@media (max-width: 980px) {
|
||
.brand-logo { max-width: 240px; }
|
||
.board { grid-template-columns: 1fr; }
|
||
.hero { padding: 20px; }
|
||
.credit { right: 8px; bottom: 6px; }
|
||
.credit .name { font-size: 10px; }
|
||
.credit a { font-size: 8px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main class="wrap">
|
||
<section class="brand-strip">
|
||
<div class="brand-logo">
|
||
<img src="/assets/mont-logo" alt="MONT logo" />
|
||
</div>
|
||
</section>
|
||
|
||
<section class="hero">
|
||
<div class="hero-layout">
|
||
<div>
|
||
<h1>Вендоры в корзине МОНТ</h1>
|
||
<p>Актуальная матрица вендоров, продуктов и категорий. Выбирайте вендоров или категории, чтобы видеть релевантные продуктовые линейки в Инфраструктуре и ИБ.</p>
|
||
<div class="mode-switch">
|
||
<button id="modeInfra" class="mode-btn active" type="button">Инфраструктура</button>
|
||
<button id="modeIb" class="mode-btn" type="button">ИБ</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="board">
|
||
<article class="card">
|
||
<h2>Вендоры</h2>
|
||
<input id="vendorSearch" class="search" placeholder="Поиск вендора..." />
|
||
<div id="vendorList" class="chip-grid"></div>
|
||
</article>
|
||
|
||
<article class="card">
|
||
<h2>Категории</h2>
|
||
<input id="categorySearch" class="search" placeholder="Поиск категории..." />
|
||
<div id="categoryList" class="chip-grid"></div>
|
||
</article>
|
||
</section>
|
||
|
||
<div class="footer-bar">
|
||
<div id="stats">Загрузка...</div>
|
||
<button class="action" id="clearBtn">Сбросить фильтры</button>
|
||
</div>
|
||
|
||
<section class="result">
|
||
<div class="result-head">
|
||
<h3>Вендоры и продукты (после фильтрации)</h3>
|
||
</div>
|
||
<div id="resultRows" class="rows"></div>
|
||
</section>
|
||
<div class="credit">
|
||
<div class="name">Made by Galyaviev</div>
|
||
<a href="mailto:RGalyaviev@mont.com">RGalyaviev@mont.com</a>
|
||
</div>
|
||
</main>
|
||
|
||
<script>
|
||
const state = {
|
||
data: { vendors: [], categories: [], products: [], product_links: [], links: [] },
|
||
scope: "infra",
|
||
selectedVendors: new Set(),
|
||
selectedCategories: new Set(),
|
||
vendorSearch: "",
|
||
categorySearch: "",
|
||
};
|
||
|
||
const el = {
|
||
vendorList: document.getElementById("vendorList"),
|
||
categoryList: document.getElementById("categoryList"),
|
||
vendorSearch: document.getElementById("vendorSearch"),
|
||
categorySearch: document.getElementById("categorySearch"),
|
||
resultRows: document.getElementById("resultRows"),
|
||
stats: document.getElementById("stats"),
|
||
clearBtn: document.getElementById("clearBtn"),
|
||
modeInfra: document.getElementById("modeInfra"),
|
||
modeIb: document.getElementById("modeIb"),
|
||
};
|
||
|
||
function normalize(s) {
|
||
return s.toLowerCase().replace(/ё/g, "е");
|
||
}
|
||
|
||
function getMaps() {
|
||
const productsByVendor = new Map();
|
||
const categoriesByProduct = new Map();
|
||
const productsByCategory = new Map();
|
||
|
||
for (const v of state.data.vendors) productsByVendor.set(v.id, new Set());
|
||
for (const p of state.data.products) categoriesByProduct.set(p.id, new Set());
|
||
for (const c of state.data.categories) productsByCategory.set(c.id, new Set());
|
||
|
||
for (const p of state.data.products) {
|
||
if (!productsByVendor.has(p.vendor_id)) productsByVendor.set(p.vendor_id, new Set());
|
||
productsByVendor.get(p.vendor_id).add(p.id);
|
||
}
|
||
for (const l of state.data.product_links) {
|
||
if (!categoriesByProduct.has(l.product_id)) categoriesByProduct.set(l.product_id, new Set());
|
||
if (!productsByCategory.has(l.category_id)) productsByCategory.set(l.category_id, new Set());
|
||
categoriesByProduct.get(l.product_id).add(l.category_id);
|
||
productsByCategory.get(l.category_id).add(l.product_id);
|
||
}
|
||
|
||
return { productsByVendor, categoriesByProduct, productsByCategory };
|
||
}
|
||
|
||
function visibleSets() {
|
||
const { productsByVendor, categoriesByProduct, productsByCategory } = getMaps();
|
||
|
||
const allowedCategories = new Set(state.data.categories.map(c => c.id));
|
||
const allowedVendors = new Set(state.data.vendors.map(v => v.id));
|
||
const allowedProducts = new Set(state.data.products.map(p => p.id));
|
||
|
||
if (state.selectedVendors.size > 0) {
|
||
const fromVendorProducts = new Set();
|
||
for (const vId of state.selectedVendors) {
|
||
for (const pId of (productsByVendor.get(vId) || [])) fromVendorProducts.add(pId);
|
||
}
|
||
for (const pId of Array.from(allowedProducts)) {
|
||
if (!fromVendorProducts.has(pId)) allowedProducts.delete(pId);
|
||
}
|
||
}
|
||
|
||
if (state.selectedCategories.size > 0) {
|
||
const fromCategories = new Set();
|
||
for (const cId of state.selectedCategories) {
|
||
for (const pId of (productsByCategory.get(cId) || [])) fromCategories.add(pId);
|
||
}
|
||
for (const pId of Array.from(allowedProducts)) {
|
||
if (!fromCategories.has(pId)) allowedProducts.delete(pId);
|
||
}
|
||
}
|
||
|
||
for (const cId of Array.from(allowedCategories)) {
|
||
const products = productsByCategory.get(cId) || new Set();
|
||
if (![...products].some(pId => allowedProducts.has(pId))) {
|
||
allowedCategories.delete(cId);
|
||
}
|
||
}
|
||
|
||
for (const vId of Array.from(allowedVendors)) {
|
||
const products = productsByVendor.get(vId) || new Set();
|
||
if (![...products].some(pId => allowedProducts.has(pId))) {
|
||
allowedVendors.delete(vId);
|
||
}
|
||
}
|
||
|
||
return { allowedCategories, allowedVendors, allowedProducts, productsByVendor, categoriesByProduct };
|
||
}
|
||
|
||
function renderChips() {
|
||
const { allowedCategories, allowedVendors } = visibleSets();
|
||
|
||
const vendorQ = normalize(state.vendorSearch);
|
||
const categoryQ = normalize(state.categorySearch);
|
||
|
||
el.vendorList.innerHTML = "";
|
||
for (const vendor of state.data.vendors) {
|
||
if (vendorQ && !normalize(vendor.name).includes(vendorQ)) continue;
|
||
const node = document.createElement("button");
|
||
node.className = "chip";
|
||
if (state.selectedVendors.has(vendor.id)) node.classList.add("active");
|
||
else if (!allowedVendors.has(vendor.id)) node.classList.add("dim");
|
||
node.textContent = vendor.name;
|
||
node.addEventListener("click", () => {
|
||
if (state.selectedVendors.has(vendor.id)) state.selectedVendors.delete(vendor.id);
|
||
else state.selectedVendors.add(vendor.id);
|
||
render();
|
||
});
|
||
el.vendorList.appendChild(node);
|
||
}
|
||
|
||
el.categoryList.innerHTML = "";
|
||
const showOnlyLinkedCategories = state.selectedVendors.size > 0;
|
||
for (const category of state.data.categories) {
|
||
if (categoryQ && !normalize(category.name).includes(categoryQ)) continue;
|
||
if (showOnlyLinkedCategories && !allowedCategories.has(category.id) && !state.selectedCategories.has(category.id)) continue;
|
||
const node = document.createElement("button");
|
||
node.className = "chip";
|
||
if (state.selectedCategories.has(category.id)) node.classList.add("active");
|
||
else if (!allowedCategories.has(category.id)) node.classList.add("dim");
|
||
node.textContent = category.name;
|
||
node.addEventListener("click", () => {
|
||
if (state.selectedCategories.has(category.id)) state.selectedCategories.delete(category.id);
|
||
else state.selectedCategories.add(category.id);
|
||
render();
|
||
});
|
||
el.categoryList.appendChild(node);
|
||
}
|
||
|
||
el.stats.textContent = `Вендоров: ${allowedVendors.size}/${state.data.vendors.length} | Категорий: ${allowedCategories.size}/${state.data.categories.length} | Продуктов: ${visibleSets().allowedProducts.size}/${state.data.products.length}`;
|
||
}
|
||
|
||
function renderResults() {
|
||
const { allowedCategories, allowedVendors, allowedProducts, productsByVendor, categoriesByProduct } = visibleSets();
|
||
const productsById = new Map(state.data.products.map(p => [p.id, p]));
|
||
|
||
const rows = [];
|
||
for (const vendor of state.data.vendors) {
|
||
if (!allowedVendors.has(vendor.id)) continue;
|
||
const productIds = [...(productsByVendor.get(vendor.id) || [])]
|
||
.filter(pId => allowedProducts.has(pId))
|
||
.filter(pId => {
|
||
if (state.selectedCategories.size === 0) return true;
|
||
const cats = categoriesByProduct.get(pId) || new Set();
|
||
return [...state.selectedCategories].some(cId => cats.has(cId));
|
||
});
|
||
const products = productIds.map(pId => productsById.get(pId)).filter(Boolean);
|
||
if (products.length === 0) continue;
|
||
rows.push({ vendor: vendor.name, products });
|
||
}
|
||
|
||
el.resultRows.innerHTML = "";
|
||
if (rows.length === 0) {
|
||
el.resultRows.innerHTML = '<div class="row-card"><strong>По текущим фильтрам ничего не найдено</strong></div>';
|
||
return;
|
||
}
|
||
|
||
for (const row of rows) {
|
||
const card = document.createElement("article");
|
||
card.className = "row-card";
|
||
const title = document.createElement("strong");
|
||
title.textContent = row.vendor;
|
||
card.appendChild(title);
|
||
const tags = document.createElement("div");
|
||
tags.className = "tags";
|
||
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 = product.name;
|
||
if (hasUrl) {
|
||
tag.href = product.url;
|
||
tag.target = "_blank";
|
||
tag.rel = "noopener noreferrer";
|
||
}
|
||
tags.appendChild(tag);
|
||
}
|
||
card.appendChild(tags);
|
||
el.resultRows.appendChild(card);
|
||
}
|
||
}
|
||
|
||
function render() {
|
||
el.modeInfra.classList.toggle("active", state.scope === "infra");
|
||
el.modeIb.classList.toggle("active", state.scope === "ib");
|
||
document.body.classList.toggle("scope-ib", state.scope === "ib");
|
||
renderChips();
|
||
renderResults();
|
||
}
|
||
|
||
async function loadScopeData(scope) {
|
||
const res = await fetch(`/api/data?scope=${encodeURIComponent(scope)}`);
|
||
state.data = await res.json();
|
||
}
|
||
|
||
async function init() {
|
||
await loadScopeData(state.scope);
|
||
render();
|
||
}
|
||
|
||
async function switchScope(scope) {
|
||
if (scope === state.scope) return;
|
||
state.scope = scope;
|
||
state.selectedVendors.clear();
|
||
state.selectedCategories.clear();
|
||
state.vendorSearch = "";
|
||
state.categorySearch = "";
|
||
el.vendorSearch.value = "";
|
||
el.categorySearch.value = "";
|
||
await loadScopeData(scope);
|
||
render();
|
||
}
|
||
|
||
el.vendorSearch.addEventListener("input", e => {
|
||
state.vendorSearch = e.target.value || "";
|
||
render();
|
||
});
|
||
|
||
el.categorySearch.addEventListener("input", e => {
|
||
state.categorySearch = e.target.value || "";
|
||
render();
|
||
});
|
||
|
||
el.clearBtn.addEventListener("click", () => {
|
||
state.selectedVendors.clear();
|
||
state.selectedCategories.clear();
|
||
state.vendorSearch = "";
|
||
state.categorySearch = "";
|
||
el.vendorSearch.value = "";
|
||
el.categorySearch.value = "";
|
||
render();
|
||
});
|
||
|
||
el.modeInfra.addEventListener("click", () => {
|
||
switchScope("infra");
|
||
});
|
||
|
||
el.modeIb.addEventListener("click", () => {
|
||
switchScope("ib");
|
||
});
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
LOGIN_HTML = """
|
||
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Admin Login</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||
<style>
|
||
body {
|
||
margin: 0;
|
||
min-height: 100vh;
|
||
display: grid;
|
||
place-items: center;
|
||
background: linear-gradient(135deg, #dbe8ff, #eff5ff);
|
||
font-family: Manrope, sans-serif;
|
||
}
|
||
form {
|
||
width: min(430px, calc(100% - 24px));
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
border: 1px solid #d5e3fb;
|
||
padding: 22px;
|
||
box-shadow: 0 16px 36px rgba(22,61,126,.13);
|
||
}
|
||
h1 { margin: 0 0 14px; color: #1f4ea3; font-size: 22px; }
|
||
input {
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
margin-bottom: 10px;
|
||
border: 1px solid #c8daf8;
|
||
border-radius: 10px;
|
||
font-size: 14px;
|
||
box-sizing: border-box;
|
||
}
|
||
button {
|
||
width: 100%;
|
||
padding: 10px;
|
||
border: 0;
|
||
border-radius: 10px;
|
||
color: #fff;
|
||
font-weight: 700;
|
||
background: linear-gradient(130deg, #1f4ea3, #3775de);
|
||
cursor: pointer;
|
||
}
|
||
.error { color: #a02020; margin: 0 0 10px; font-size: 14px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<form method="post">
|
||
<h1>Редактор матрицы</h1>
|
||
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||
<input type="hidden" name="action" value="login" />
|
||
<input name="username" placeholder="Логин" autocomplete="username" required />
|
||
<input type="password" name="password" placeholder="Пароль" autocomplete="current-password" required />
|
||
<button type="submit">Войти</button>
|
||
</form>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
ADMIN_HTML = """
|
||
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Admin Matrix</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root { --b:#1f4ea3; --line:#cfe0ff; }
|
||
* { box-sizing: border-box; }
|
||
body { margin:0; font-family:Manrope,sans-serif; background:#f0f5ff; color:#1a2746; }
|
||
body.ib { background:#fff1f1; }
|
||
.wrap { width:min(1600px, calc(100% - 24px)); margin:12px auto 24px; }
|
||
.top {
|
||
background: linear-gradient(130deg, #1f4ea3, #3977df);
|
||
border-radius: 14px;
|
||
color:#fff;
|
||
padding:14px 16px;
|
||
display:flex;
|
||
justify-content:space-between;
|
||
align-items:center;
|
||
gap:10px;
|
||
}
|
||
body.ib .top { background: linear-gradient(130deg, #9b2f3a, #c24a56); }
|
||
.scope-switch { display:flex; gap:8px; }
|
||
.scope-chip {
|
||
display:inline-block;
|
||
padding:7px 11px;
|
||
border-radius:9px;
|
||
text-decoration:none;
|
||
font-weight:700;
|
||
background:#e7efff;
|
||
color:#1f3f77;
|
||
border:1px solid #ccdcff;
|
||
}
|
||
.scope-chip.active { background:#fff; color:#112847; }
|
||
.grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap:10px; margin:12px 0; }
|
||
.box {
|
||
background:#fff;
|
||
border:1px solid #d4e3ff;
|
||
border-radius:12px;
|
||
padding:12px;
|
||
}
|
||
form.inline { display:flex; gap:8px; }
|
||
input[type="text"] { flex:1; padding:9px 10px; border:1px solid #c6d8fb; border-radius:9px; }
|
||
button { border:0; border-radius:9px; padding:9px 11px; cursor:pointer; font-weight:700; }
|
||
.pri { background:#1f4ea3; color:#fff; }
|
||
.warn { background:#e8eefc; color:#223963; }
|
||
.danger { background:#ffefef; color:#8e1d1d; }
|
||
|
||
.lists { display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; margin-bottom:10px; }
|
||
.list-item { display:flex; justify-content:space-between; align-items:center; gap:8px; border:1px solid var(--line); border-radius:10px; padding:6px 8px; margin-bottom:6px; background:#fff; }
|
||
|
||
.matrix-wrap { background:#fff; border:1px solid #d4e3ff; border-radius:12px; padding:10px; overflow:auto; }
|
||
table { border-collapse: collapse; min-width: 1200px; }
|
||
th, td { border:1px solid var(--line); padding:6px; font-size:12px; text-align:center; }
|
||
th { position: sticky; top: 0; background:#eaf2ff; z-index: 2; }
|
||
th:first-child, td:first-child { position: sticky; left:0; background:#eef5ff; z-index: 1; text-align:left; min-width: 280px; }
|
||
td input { transform: scale(1.05); }
|
||
</style>
|
||
</head>
|
||
<body class="{% if scope == 'ib' %}ib{% endif %}">
|
||
<main class="wrap">
|
||
<section class="top">
|
||
<div>
|
||
<strong>Админ-панель матрицы</strong>
|
||
<div class="scope-switch" style="margin-top:8px;">
|
||
<a class="scope-chip {% if scope == 'infra' %}active{% endif %}" href="{{ request.path }}?scope=infra">Инфраструктура</a>
|
||
<a class="scope-chip {% if scope == 'ib' %}active{% endif %}" href="{{ request.path }}?scope=ib">ИБ</a>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex; gap:8px;">
|
||
<a href="/" style="text-decoration:none;"><button class="warn" type="button">На сайт</button></a>
|
||
<form method="post" style="margin:0;">
|
||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||
<input type="hidden" name="action" value="logout" />
|
||
<button class="danger" type="submit">Выйти</button>
|
||
</form>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="grid">
|
||
<div class="box">
|
||
<h3>Добавить вендора</h3>
|
||
<form method="post" class="inline">
|
||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||
<input type="hidden" name="action" value="add_vendor" />
|
||
<input type="text" name="name" placeholder="Название вендора" required />
|
||
<button class="pri" type="submit">Добавить</button>
|
||
</form>
|
||
</div>
|
||
<div class="box">
|
||
<h3>Добавить категорию</h3>
|
||
<form method="post" class="inline">
|
||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||
<input type="hidden" name="action" value="add_category" />
|
||
<input type="text" name="name" placeholder="Название категории" required />
|
||
<button class="pri" type="submit">Добавить</button>
|
||
</form>
|
||
</div>
|
||
<div class="box">
|
||
<h3>Добавить продукт</h3>
|
||
<form method="post" class="inline">
|
||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||
<input type="hidden" name="action" value="add_product" />
|
||
<select name="vendor_id" required style="padding:9px 10px; border:1px solid #c6d8fb; border-radius:9px;">
|
||
{% for v in vendors %}
|
||
<option value="{{ v.id }}">{{ v.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<input type="text" name="name" placeholder="Название продукта" required />
|
||
<input type="text" name="url" placeholder="URL продукта (необязательно)" />
|
||
<button class="pri" type="submit">Добавить</button>
|
||
</form>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="lists">
|
||
<div class="box">
|
||
<h3>Удалить вендора</h3>
|
||
{% for v in vendors %}
|
||
<form class="list-item" method="post">
|
||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||
<span>{{ v.name }}</span>
|
||
<input type="hidden" name="action" value="delete_vendor" />
|
||
<input type="hidden" name="vendor_id" value="{{ v.id }}" />
|
||
<button class="danger" type="submit">Удалить</button>
|
||
</form>
|
||
{% endfor %}
|
||
</div>
|
||
<div class="box">
|
||
<h3>Удалить категорию</h3>
|
||
{% for c in categories %}
|
||
<form class="list-item" method="post">
|
||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||
<span>{{ c.name }}</span>
|
||
<input type="hidden" name="action" value="delete_category" />
|
||
<input type="hidden" name="category_id" value="{{ c.id }}" />
|
||
<button class="danger" type="submit">Удалить</button>
|
||
</form>
|
||
{% endfor %}
|
||
</div>
|
||
<div class="box">
|
||
<h3>Удалить продукт</h3>
|
||
{% for p in products %}
|
||
<form class="list-item" method="post">
|
||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||
<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>
|
||
</form>
|
||
{% endfor %}
|
||
</div>
|
||
</section>
|
||
|
||
<section class="matrix-wrap">
|
||
<form method="post">
|
||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||
<input type="hidden" name="action" value="save_matrix" />
|
||
<button class="pri" type="submit" style="margin-bottom:10px;">Сохранить матрицу продуктов</button>
|
||
<table>
|
||
<tr>
|
||
<th>Вендор / Продукт</th>
|
||
{% for c in categories %}
|
||
<th>{{ c.name }}</th>
|
||
{% endfor %}
|
||
</tr>
|
||
{% for p in products %}
|
||
<tr>
|
||
<td><strong>{{ p.vendor_name }}</strong><br/>{{ p.name }}</td>
|
||
{% for c in categories %}
|
||
<td>
|
||
<input
|
||
type="checkbox"
|
||
name="pc_{{ p.id }}_{{ c.id }}"
|
||
{% if (p.id, c.id) in links %}checked{% endif %}
|
||
/>
|
||
</td>
|
||
{% endfor %}
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
</form>
|
||
</section>
|
||
</main>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
if __name__ == "__main__":
|
||
init_db()
|
||
app.run(host="0.0.0.0", port=5000, debug=True)
|
||
else:
|
||
init_db()
|