From 254a52fd474230eb2e06f36b9ba46e6f48decf30 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Thu, 16 Apr 2026 15:55:44 +0000 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82?= =?UTF-8?q?=D1=83=D1=80=D1=8B=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0?= =?UTF-8?q?:=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D1=8B,=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D0=BA=D0=B0=20=D0=B8=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D0=B8=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTEXT.md | 4 + main.py | 1981 +---------------------------------------- matrix.db | Bin 122880 -> 122880 bytes static/css/admin.css | 77 ++ static/css/index.css | 349 ++++++++ static/css/login.css | 37 + static/js/admin.js | 92 ++ static/js/index.js | 271 ++++++ templates/admin.html | 151 ++++ templates/index.html | 81 ++ templates/login.html | 22 + zkart_app/__init__.py | 22 + zkart_app/config.py | 16 + zkart_app/db.py | 708 +++++++++++++++ zkart_app/routes.py | 167 ++++ 15 files changed, 1998 insertions(+), 1980 deletions(-) create mode 100644 static/css/admin.css create mode 100644 static/css/index.css create mode 100644 static/css/login.css create mode 100644 static/js/admin.js create mode 100644 static/js/index.js create mode 100644 templates/admin.html create mode 100644 templates/index.html create mode 100644 templates/login.html create mode 100644 zkart_app/__init__.py create mode 100644 zkart_app/config.py create mode 100644 zkart_app/db.py create mode 100644 zkart_app/routes.py diff --git a/CONTEXT.md b/CONTEXT.md index 6387ec0..b7f81c7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -118,3 +118,7 @@ docker compose up -d --build - логотип MONT слева без овального контейнера; - hero-блок с горным фоном; - подпись внизу справа: `Made by Galyaviev`, `ruslan@ipcom.su`. +- Перед крупными рефакторингами делать checkpoint: + - создать отдельную git-ветку; + - при необходимости поставить тег на текущее состояние; + - при проблемах откатываться к checkpoint через git. diff --git a/main.py b/main.py index da92df4..dea5c7f 100644 --- a/main.py +++ b/main.py @@ -1,1986 +1,7 @@ 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 = """ - - - - - - Корзина МОНТ - - - - - - - -
-
- -
- -
-
-
-

Вендоры в корзине МОНТ

-

Актуальная матрица вендоров, продуктов и категорий. Выбирайте вендоров или категории, чтобы видеть релевантные продуктовые линейки в Инфраструктуре и ИБ.

-
- - -
-
-
-
- -
-
-

Вендоры

- -
-
- -
-

Категории

- -
-
-
- - - -
-
-

Вендоры и продукты (после фильтрации)

-
-
-
-
-
Made by Galyaviev
- RGalyaviev@mont.com -
-
- - - - - - - - - -""" - -LOGIN_HTML = """ - - - - - - Admin Login - - - - - - -
-

Редактор матрицы

- {% if error %}

{{ error }}

{% endif %} - - - - -
- - -""" - -ADMIN_HTML = """ - - - - - - Admin Matrix - - - - - - -
-
-
- Админ-панель матрицы - -
-
- -
- - - -
-
-
- -
-
-

Добавить вендора

-
- - - - -
-
-
-

Добавить категорию

-
- - - - -
-
-
-

Добавить продукт

-
- - - - - - -
-
-
- -
-
-

Удалить вендора

-
- {% for v in vendors %} -
- - {{ v.name }} - - - -
- {% endfor %} -
-
-
-

Удалить категорию

-
- {% for c in categories %} -
- - {{ c.name }} - - - -
- {% endfor %} -
-
-
-

Удалить продукт

-
- {% for p in products %} -
- - - {{ p.vendor_name }} :: {{ p.name }} - {% if p.url %}ссылка{% endif %} - - - - -
- {% endfor %} -
-
-
- -
-

Прокрутка: колесо/тачпад вниз-вверх внутри таблицы, полосой ниже - влево-вправо.

-
- - -
-
- - - - {% for c in categories %} - - {% endfor %} - - {% for p in products %} - - - {% for c in categories %} - - {% endfor %} - - {% endfor %} -
Вендор / Продукт{{ c.name }}
{{ p.vendor_name }}
{{ p.name }}
- -
-
-
-
-
- - - -""" +from zkart_app import app if __name__ == "__main__": - init_db() app.run(host="0.0.0.0", port=5000, debug=True) -else: - init_db() diff --git a/matrix.db b/matrix.db index c7f4bae7cd1c31ef7b0e07171e9804a8fcd33887..7bb605456cbabfe237c9a68774e7720c2251cccc 100644 GIT binary patch delta 1848 zcmaizTWl0n7{}+EIp5ity1TQz*({fB)gbh;o!+2OD5Ys)v@X&sMG)zxU1>?XL$+JI zRhAhv7z{~Go9qK=w8|T)K{v1%jK%l@F)B$!x==gb*w(?m!n`F{D% z_y2C^%(2U!W0yVStD*G6H7!^wRra)g-v6<#wB~e8cmk8qm6;A))C%(>iWGifcXLTs ztZyK04w(lBV)O?8Dj~))Cvof zFs#%^GKb1G7RrDV(@ihsM*N^W1%1(E?4X%o*|&eOgOn(9+VPC1_cO@JsWPXY$#@RWK!JKgy`!E{Z>x9JE9$IzLA|EVu);U#UX=jI5wA2S zASC@Jph)^vK(2I4KtQ@FASnGJfJiq4_@$o(p!AafUHUPz_y#?pG~vv7546jbbc7zF zQM!ljqR-JrT1U(2(=INZagchjOW1kSjI93*ioqidUlM$`QZY;=e%3F-tb zrgW%v5uwJVT&Q*_09AtS2f=Vjhf0?a!h#kERJi1aa+eUwT+*Rb(BIF(2A6VSJ*Vt& zA*A0T`=ooAR>$F!0-x!7!q@IA_8s*dAhW(I-wy9z>2u>ynw=kqcNG~Qq=zS=L-!L{ zEl2pTyel4)7P8+@!dHq+hS)zbKvGWN)@2m51YN!x%Kj{)WrSSQ@g{YRqlzF0qy z0D)rRi$oI#O;B_KYlWl3G>1W63h0U@5>WsjfuIOz>0~eOzwn*W!6CL=*4Qd>T)U}C zT3l*$XG^zqKYMBlUe8l%VJhu82b~4VdrF70Uj9@5Mm{Nb%OUt3rq~Pyq?&C+3Cd&6 zkjusxNG1=DG#JL-IZ29?txT z1WXf-iq)k0jXGOl>f#EpuMvawP>W*L83dzHnQhcGjy-#IKRDyjj@B1 zS%WonJG%f_p%6%+5J;gANTCqpd`6(cXVddV7+JSRi88LLVhLpU&ODuC0^_3ThbAzp zZa+VPaSk&R%iig^Qy7Ez83Y-S!S-`g822zUv9bbP1vH>{E@Qb6vnKP7>FgUAgMngk M%-eTtX7tbj08&_Ts{jB1 diff --git a/static/css/admin.css b/static/css/admin.css new file mode 100644 index 0000000..e5caecc --- /dev/null +++ b/static/css/admin.css @@ -0,0 +1,77 @@ + :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; + overflow: hidden; + } + form.inline { display:flex; gap:8px; flex-wrap: wrap; } + .inline > * { min-width: 0; } + .inline-product { + display:grid; + grid-template-columns: minmax(130px, 1fr) minmax(150px, 1fr) minmax(180px, 1.2fr) auto; + align-items:center; + gap:8px; + } + .inline-product button { white-space: nowrap; } + @media (max-width: 1300px) { + .inline-product { grid-template-columns: 1fr 1fr; } + } + @media (max-width: 720px) { + .inline-product { grid-template-columns: 1fr; } + } + 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-box { max-height: 430px; overflow-y: auto; padding-right: 4px; } + .list-box::-webkit-scrollbar { width:12px; } + .list-box::-webkit-scrollbar-thumb { background:#bfd4ff; border-radius: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; min-height: 36px; } + .matrix-wrap { background:#fff; border:1px solid #d4e3ff; border-radius:12px; padding:10px; } + .matrix-scroll { overflow:auto; max-height:72vh; border:1px solid #dce7ff; border-radius:10px; } + .matrix-scroll::-webkit-scrollbar, + .matrix-h-scroll::-webkit-scrollbar { height:24px; width:14px; } + .matrix-scroll::-webkit-scrollbar-thumb, + .matrix-h-scroll::-webkit-scrollbar-thumb { background:#bfd4ff; border-radius:10px; } + .matrix-h-scroll { overflow-x:auto; overflow-y:hidden; height:28px; margin:8px 0 10px; border:1px solid #dce7ff; border-radius:10px; background:#f6f9ff; } + .matrix-h-scroll-inner { height:1px; } + table { border-collapse: collapse; min-width: 1200px; width:max-content; } + 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; } + th:first-child { z-index: 3; } + td input { transform: scale(1.05); } + .matrix-tip { margin:0 0 6px; font-size:12px; color:#37507d; } diff --git a/static/css/index.css b/static/css/index.css new file mode 100644 index 0000000..491b091 --- /dev/null +++ b/static/css/index.css @@ -0,0 +1,349 @@ + :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: 14px; + color: #1c3f7c; + } + .credit a { + font-size: 7px; + 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: 8px; } + .credit a { font-size: 6px; } + } diff --git a/static/css/login.css b/static/css/login.css new file mode 100644 index 0000000..731c2e6 --- /dev/null +++ b/static/css/login.css @@ -0,0 +1,37 @@ + 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; } diff --git a/static/js/admin.js b/static/js/admin.js new file mode 100644 index 0000000..aeef699 --- /dev/null +++ b/static/js/admin.js @@ -0,0 +1,92 @@ + (function () { + const matrixForm = document.getElementById("matrixForm"); + const matrixScroll = document.getElementById("matrixScroll"); + const matrixTable = document.getElementById("matrixTable"); + const topScroll = document.getElementById("matrixHScroll"); + const topScrollInner = document.getElementById("matrixHScrollInner"); + if (!matrixForm || !matrixScroll || !matrixTable || !topScroll || !topScrollInner) return; + + let isDirty = false; + let syncing = false; + let saveTimer = null; + let saveInFlight = false; + + function markDirty() { + isDirty = true; + } + + function updateTopScrollWidth() { + topScrollInner.style.width = matrixTable.scrollWidth + "px"; + } + + function syncScrollFromTop() { + if (syncing) return; + syncing = true; + matrixScroll.scrollLeft = topScroll.scrollLeft; + syncing = false; + } + + function syncScrollFromMatrix() { + if (syncing) return; + syncing = true; + topScroll.scrollLeft = matrixScroll.scrollLeft; + syncing = false; + } + + async function autoSaveMatrix() { + if (saveInFlight) return; + saveInFlight = true; + try { + const formData = new FormData(matrixForm); + const response = await fetch(window.location.href, { + method: "POST", + body: formData, + credentials: "same-origin", + }); + if (!response.ok) throw new Error("save failed"); + isDirty = false; + } catch (error) { + } finally { + saveInFlight = false; + } + } + + matrixForm.addEventListener("change", (event) => { + if (!(event.target && event.target.matches('input[type="checkbox"]'))) return; + markDirty(); + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(autoSaveMatrix, 250); + }); + + matrixForm.addEventListener("submit", () => { + isDirty = false; + }); + + window.addEventListener("beforeunload", (event) => { + if (!isDirty) return; + event.preventDefault(); + event.returnValue = ""; + }); + + document.addEventListener("click", (event) => { + const anchor = event.target.closest("a"); + if (!anchor || !isDirty) return; + const ok = window.confirm("Есть несохраненные изменения матрицы. Нажмите OK, чтобы остаться и сначала сохранить."); + if (!ok) return; + event.preventDefault(); + }); + + document.addEventListener("submit", (event) => { + const form = event.target; + if (!form || form === matrixForm || !isDirty) return; + const ok = window.confirm("Есть несохраненные изменения матрицы. Нажмите OK, чтобы остаться и сначала сохранить."); + if (!ok) return; + event.preventDefault(); + }); + + topScroll.addEventListener("scroll", syncScrollFromTop); + matrixScroll.addEventListener("scroll", syncScrollFromMatrix); + window.addEventListener("resize", updateTopScrollWidth); + updateTopScrollWidth(); + syncScrollFromMatrix(); + })(); diff --git a/static/js/index.js b/static/js/index.js new file mode 100644 index 0000000..c3118b8 --- /dev/null +++ b/static/js/index.js @@ -0,0 +1,271 @@ + 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"), + }; + + let clickAudioCtx = null; + + function playScopeClick() { + try { + if (!clickAudioCtx) { + clickAudioCtx = new (window.AudioContext || window.webkitAudioContext)(); + } + const now = clickAudioCtx.currentTime; + const osc = clickAudioCtx.createOscillator(); + const gain = clickAudioCtx.createGain(); + osc.type = "triangle"; + osc.frequency.setValueAtTime(1800, now); + osc.frequency.exponentialRampToValueAtTime(950, now + 0.025); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.linearRampToValueAtTime(0.14, now + 0.0025); + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.04); + osc.connect(gain); + gain.connect(clickAudioCtx.destination); + osc.start(now); + osc.stop(now + 0.045); + } catch (_) { + // Ignore audio failures silently. + } + } + + 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 = '
По текущим фильтрам ничего не найдено
'; + 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; + playScopeClick(); + 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(); diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..7a78942 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,151 @@ + + + + + + Admin Matrix + + + + + + +
+
+
+ Админ-панель матрицы + +
+
+ +
+ + + +
+
+
+ +
+
+

Добавить вендора

+
+ + + + +
+
+
+

Добавить категорию

+
+ + + + +
+
+
+

Добавить продукт

+
+ + + + + + +
+
+
+ +
+
+

Удалить вендора

+
+ {% for v in vendors %} +
+ + {{ v.name }} + + + +
+ {% endfor %} +
+
+
+

Удалить категорию

+
+ {% for c in categories %} +
+ + {{ c.name }} + + + +
+ {% endfor %} +
+
+
+

Удалить продукт

+
+ {% for p in products %} +
+ + + {{ p.vendor_name }} :: {{ p.name }} + {% if p.url %}ссылка{% endif %} + + + + +
+ {% endfor %} +
+
+
+ +
+

Прокрутка: колесо/тачпад вниз-вверх внутри таблицы, полосой ниже - влево-вправо.

+
+ + +
+
+ + + + {% for c in categories %} + + {% endfor %} + + {% for p in products %} + + + {% for c in categories %} + + {% endfor %} + + {% endfor %} +
Вендор / Продукт{{ c.name }}
{{ p.vendor_name }}
{{ p.name }}
+ +
+
+
+
+
+ + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..3790483 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,81 @@ + + + + + + Корзина МОНТ + + + + + + + +
+
+ +
+ +
+
+
+

Вендоры в корзине МОНТ

+

Актуальная матрица вендоров, продуктов и категорий. Выбирайте вендоров или категории, чтобы видеть релевантные продуктовые линейки в Инфраструктуре и ИБ.

+
+ + +
+
+
+
+ +
+
+

Вендоры

+ +
+
+ +
+

Категории

+ +
+
+
+ + + +
+
+

Вендоры и продукты (после фильтрации)

+
+
+
+
+
Made by Galyaviev
+ RGalyaviev@mont.com +
+
+ + + + + + + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..a6c9596 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,22 @@ + + + + + + Admin Login + + + + + + +
+

Редактор матрицы

+ {% if error %}

{{ error }}

{% endif %} + + + + +
+ + diff --git a/zkart_app/__init__.py b/zkart_app/__init__.py new file mode 100644 index 0000000..f79d1bf --- /dev/null +++ b/zkart_app/__init__.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from flask import Flask + +from .config import BASE_DIR, SECRET_KEY +from .db import init_db +from .routes import bp + + +def create_app() -> Flask: + app = Flask( + __name__, + template_folder=str(BASE_DIR / "templates"), + static_folder=str(BASE_DIR / "static"), + ) + app.secret_key = SECRET_KEY + app.register_blueprint(bp) + init_db() + return app + + +app = create_app() diff --git a/zkart_app/config.py b/zkart_app/config.py new file mode 100644 index 0000000..fa76f11 --- /dev/null +++ b/zkart_app/config.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.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" + +SECRET_KEY = os.getenv("SECRET_KEY", "change-me-please") +ENABLE_BOOTSTRAP = os.getenv("ENABLE_BOOTSTRAP", "0").strip().lower() in {"1", "true", "yes", "on"} diff --git a/zkart_app/db.py b/zkart_app/db.py new file mode 100644 index 0000000..28067c1 --- /dev/null +++ b/zkart_app/db.py @@ -0,0 +1,708 @@ +from __future__ import annotations + +import json +import sqlite3 +from typing import Iterable + +try: + from openpyxl import load_workbook +except ImportError: + load_workbook = None + +from .config import DB_PATH, ENABLE_BOOTSTRAP, INFRA_JSON_FILES, XLSX_PATH + +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) diff --git a/zkart_app/routes.py b/zkart_app/routes.py new file mode 100644 index 0000000..9131cee --- /dev/null +++ b/zkart_app/routes.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from flask import Blueprint, jsonify, redirect, render_template, request, send_from_directory, session + +from .config import ADMIN_LOGIN, ADMIN_PASSWORD, ADMIN_PATH, BASE_DIR +from .db import fetch_scope_data, get_db, scope_tables + +bp = Blueprint("main", __name__) + +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 + + +@bp.get("/") +def index(): + return render_template("index.html") + + +@bp.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)) + + +@bp.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("login.html", error="Неверный логин или пароль") + + if not require_admin(): + conn.close() + return render_template("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( + "admin.html", + vendors=vendors, + categories=categories, + products=products, + links=links, + scope=scope, + ) + + +@bp.get("/health") +def health(): + return {"status": "ok"} + + +@bp.get("/assets/mont-logo") +def mont_logo(): + return send_from_directory(BASE_DIR, "mont_logo.png")