From 5a8471e85c56c904842c7f5dbc134f193beefaf7 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Tue, 12 May 2026 16:13:55 +0300 Subject: [PATCH] feat: vendor edit with name/website/mont_page in admin panel --- static/js/admin.js | 57 +-- templates/admin.html | 192 ++++++++-- zkart_app/routes.py | 825 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 938 insertions(+), 136 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index aeef699..2d1765a 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -4,12 +4,29 @@ const matrixTable = document.getElementById("matrixTable"); const topScroll = document.getElementById("matrixHScroll"); const topScrollInner = document.getElementById("matrixHScrollInner"); + document.addEventListener("click", async (event) => { + const button = event.target.closest("[data-copy-target]"); + if (!button) return; + const field = document.getElementById(button.dataset.copyTarget); + if (!field) return; + field.select(); + field.setSelectionRange(0, field.value.length); + try { + await navigator.clipboard.writeText(field.value); + } catch (error) { + document.execCommand("copy"); + } + const originalText = button.textContent; + button.textContent = "Скопировано"; + setTimeout(() => { + button.textContent = originalText; + }, 1600); + }); + if (!matrixForm || !matrixScroll || !matrixTable || !topScroll || !topScrollInner) return; let isDirty = false; let syncing = false; - let saveTimer = null; - let saveInFlight = false; function markDirty() { isDirty = true; @@ -33,29 +50,9 @@ 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", () => { @@ -89,4 +86,20 @@ window.addEventListener("resize", updateTopScrollWidth); updateTopScrollWidth(); syncScrollFromMatrix(); + + document.addEventListener("click", (event) => { + const button = event.target.closest("[data-edit-product]"); + if (!button) return; + const form = document.querySelector(`[data-product-edit="${button.dataset.editProduct}"]`); + if (!form) return; + form.hidden = !form.hidden; + }); + + document.addEventListener("click", (event) => { + const button = event.target.closest("[data-edit-vendor]"); + if (!button) return; + const form = document.querySelector(`[data-vendor-edit="${button.dataset.editVendor}"]`); + if (!form) return; + form.hidden = !form.hidden; + }); })(); diff --git a/templates/admin.html b/templates/admin.html index 7a78942..517bc08 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -15,11 +15,16 @@
Админ-панель матрицы
+ {% if 'infra' in allowed_scopes %} Инфраструктура + {% endif %} + {% if 'ib' in allowed_scopes %} ИБ + {% endif %}
-
+
+
@@ -29,6 +34,102 @@
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% if pending_changes %} +
+
+

{% if is_super_admin %}Ожидают утверждения{% else %}Мои изменения на проверке{% endif %}

+ {% if is_super_admin %} + + + + + + {% endif %} +
+
+ {% for change in pending_changes %} +
+
+ #{{ change.id }} · {{ change.title }} + {{ change.created_at }} · {{ change.scope_label }} · {{ change.created_by }} +
{{ change.description }}
+
+ {% if is_super_admin %} +
+
+ + + + +
+
+ + + + +
+
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + {% if is_super_admin %} +
+

Администраторы

+ {% if created_admin_credentials %} +
+ Новый админ создан +
Логин: {{ created_admin_credentials.username }}
+
Пароль: {{ created_admin_credentials.password }}
+
Доступ: {{ created_admin_credentials.access }}
+
+ + +
+
+ {% endif %} +
+ + + + + + +
+
+ {% for user in admin_users %} +
+ + + + + {{ user.username }} + {{ user.role }} · {{ user.access_scopes }} + + {% if user.role != 'super' %} + + {% endif %} +
+ {% endfor %} +
+
+ {% endif %} +

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

@@ -55,7 +156,7 @@ @@ -67,16 +168,40 @@
-

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

+

Вендоры

{% for v in vendors %} -
- - {{ v.name }} - - - -
+
+
+ + {{ v.name }}{% if v.pending %} (на проверке){% endif %} + {% if v.website %}сайт{% endif %} + {% if v.mont_page %}MONT{% endif %} + + {% if not v.pending %} +
+ +
+ + + + +
+
+ {% endif %} +
+ {% if not v.pending %} + + {% endif %} +
{% endfor %}
@@ -86,28 +211,48 @@ {% for c in categories %}
- {{ c.name }} + {{ c.name }}{% if c.pending %} (на проверке){% endif %} - + {% if not c.pending %}{% endif %}
{% endfor %}
-

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

+

Продукты

{% for p in products %} -
- - - {{ p.vendor_name }} :: {{ p.name }} - {% if p.url %}ссылка{% endif %} - - - - -
+
+
+ + {{ p.vendor_name }} :: {{ p.name }} + {% if p.pending %}(на проверке){% endif %} + {% if p.url %}ссылка{% endif %} + + {% if not p.pending %} +
+ +
+ + + + +
+
+ {% endif %} +
+ {% if not p.pending %} + + {% endif %} +
{% endfor %}
@@ -136,6 +281,7 @@ type="checkbox" name="pc_{{ p.id }}_{{ c.id }}" {% if (p.id, c.id) in links %}checked{% endif %} + {% if p.pending or c.pending %}disabled{% endif %} /> {% endfor %} diff --git a/zkart_app/routes.py b/zkart_app/routes.py index 9131cee..c3ec581 100644 --- a/zkart_app/routes.py +++ b/zkart_app/routes.py @@ -1,16 +1,51 @@ from __future__ import annotations -from flask import Blueprint, jsonify, redirect, render_template, request, send_from_directory, session +import json +import secrets +import sqlite3 +from datetime import datetime +from html import escape +from urllib.parse import urlencode +from urllib.request import Request, urlopen +from zoneinfo import ZoneInfo -from .config import ADMIN_LOGIN, ADMIN_PASSWORD, ADMIN_PATH, BASE_DIR +from flask import Blueprint, flash, jsonify, redirect, render_template, request, send_from_directory, session + +from .config import ( + ADMIN_LOGIN, + ADMIN_PASSWORD, + ADMIN_PATH, + BASE_DIR, + SUPER_ADMIN_LOGIN, + SUPER_ADMIN_PASSWORD, + TELEGRAM_BOT_TOKEN, + TELEGRAM_CHAT_ID, +) from .db import fetch_scope_data, get_db, scope_tables bp = Blueprint("main", __name__) +MOSCOW_TZ = ZoneInfo("Europe/Moscow") def require_admin() -> bool: return bool(session.get("is_admin")) +def is_super_admin() -> bool: + return session.get("admin_role") == "super" + + +def allowed_scopes() -> set[str]: + if is_super_admin(): + return {"infra", "ib"} + raw = session.get("admin_scopes") or "infra,ib" + scopes = {item.strip() for item in raw.split(",") if item.strip()} + return scopes or {"infra"} + + +def can_access_scope(scope: str) -> bool: + return scope in allowed_scopes() + + def parse_int(form_value: str | None) -> int | None: if not form_value: return None @@ -20,115 +55,407 @@ def parse_int(form_value: str | None) -> int | None: return None -@bp.get("/") -def index(): - return render_template("index.html") +def redirect_admin(scope: str): + return redirect(f"{ADMIN_PATH}?scope={scope}") -@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)) +def make_admin_password() -> str: + return secrets.token_urlsafe(9) -@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) +def parse_admin_scopes() -> str: + scopes = [] + if request.form.get("allow_infra"): + scopes.append("infra") + if request.form.get("allow_ib"): + scopes.append("ib") + return ",".join(scopes) - 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) +def local_now() -> str: + return datetime.now(MOSCOW_TZ).strftime("%Y-%m-%d %H:%M:%S") - 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,)) +def scope_label(scope: str) -> str: + return "ИБ" if scope == "ib" else "Инфраструктура" - 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), - ) +def rebuild_vendor_categories(conn, tables: dict[str, str]) -> None: + 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 + """ + ) - 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,)) +def collect_change_payload(action: str, scope: str, conn, tables: dict[str, str]) -> dict | None: + if action == "add_vendor": + name = (request.form.get("name") or "").strip() + return {"name": name} if name else None - 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,)) + if action == "add_category": + name = (request.form.get("name") or "").strip() + return {"name": name} if name else None - 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( + if 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 not vendor_id or not name: + return None + vendor = conn.execute(f"SELECT name FROM {tables['vendors']} WHERE id = ?", (vendor_id,)).fetchone() + return { + "vendor_id": vendor_id, + "vendor_name": vendor["name"] if vendor else "", + "name": name, + "url": url, + } + + if action == "update_product": + product_id = parse_int(request.form.get("product_id")) + name = (request.form.get("name") or "").strip() + url = (request.form.get("url") or "").strip() + if not product_id or not name: + return None + product = conn.execute( + f""" + SELECT p.name, p.url, v.name AS vendor_name + FROM {tables['products']} p + JOIN {tables['vendors']} v ON v.id = p.vendor_id + WHERE p.id = ? + """, + (product_id,), + ).fetchone() + return { + "product_id": product_id, + "vendor_name": product["vendor_name"] if product else "", + "old_name": product["name"] if product else "", + "old_url": product["url"] if product else "", + "name": name, + "url": url, + } + + if action == "update_vendor": + vendor_id = parse_int(request.form.get("vendor_id")) + name = (request.form.get("name") or "").strip() + website = (request.form.get("website") or "").strip() + mont_page = (request.form.get("mont_page") or "").strip() + if not vendor_id or not name: + return None + vendor = conn.execute(f"SELECT name, website, mont_page FROM {tables['vendors']} WHERE id = ?", (vendor_id,)).fetchone() + return { + "vendor_id": vendor_id, + "old_name": vendor["name"] if vendor else "", + "name": name, + "website": website, + "mont_page": mont_page, + } + + if action == "delete_vendor": + vendor_id = parse_int(request.form.get("vendor_id")) + if not vendor_id: + return None + vendor = conn.execute(f"SELECT name FROM {tables['vendors']} WHERE id = ?", (vendor_id,)).fetchone() + return {"vendor_id": vendor_id, "vendor_name": vendor["name"] if vendor else ""} + + if action == "delete_category": + category_id = parse_int(request.form.get("category_id")) + if not category_id: + return None + category = conn.execute(f"SELECT name FROM {tables['categories']} WHERE id = ?", (category_id,)).fetchone() + return {"category_id": category_id, "category_name": category["name"] if category else ""} + + if action == "delete_product": + product_id = parse_int(request.form.get("product_id")) + if not product_id: + return None + product = conn.execute( + f""" + SELECT p.name, v.name AS vendor_name + FROM {tables['products']} p + JOIN {tables['vendors']} v ON v.id = p.vendor_id + WHERE p.id = ? + """, + (product_id,), + ).fetchone() + return { + "product_id": product_id, + "product_name": product["name"] if product else "", + "vendor_name": product["vendor_name"] if product else "", + } + + if 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']}")] + pairs: list[list[int]] = [] + for product_id in products: + for category_id in categories: + if request.form.get(f"pc_{product_id}_{category_id}"): + pairs.append([product_id, category_id]) + current_pairs = { + (r["product_id"], r["category_id"]) + for r in conn.execute(f"SELECT product_id, category_id FROM {tables['product_categories']}") + } + new_pairs = {tuple(pair) for pair in pairs} + changed_pairs = sorted((new_pairs - current_pairs) | (current_pairs - new_pairs)) + product_labels = { + r["id"]: f"{r['vendor_name']} :: {r['name']}" + for r in conn.execute( f""" - INSERT OR IGNORE INTO {tables['vendor_categories']}(vendor_id, category_id) - SELECT DISTINCT p.vendor_id, pc.category_id + SELECT p.id, p.name, v.name AS vendor_name FROM {tables['products']} p - JOIN {tables['product_categories']} pc ON pc.product_id = p.id + JOIN {tables['vendors']} v ON v.id = p.vendor_id """ ) + } + category_labels = { + r["id"]: r["name"] + for r in conn.execute(f"SELECT id, name FROM {tables['categories']}") + } + added = [] + removed = [] + for product_id, category_id in changed_pairs: + item = { + "product_id": product_id, + "category_id": category_id, + "product": product_labels.get(product_id, str(product_id)), + "category": category_labels.get(category_id, str(category_id)), + } + if (product_id, category_id) in new_pairs: + added.append(item) + else: + removed.append(item) + return {"pairs": pairs, "added": added, "removed": removed} - conn.commit() - conn.close() - return redirect(f"{ADMIN_PATH}?scope={scope}") + return None - vendors = [dict(r) for r in conn.execute(f"SELECT id, name FROM {tables['vendors']} ORDER BY lower(name)")] + +def apply_change(conn, tables: dict[str, str], action: str, payload: dict) -> None: + if action == "add_vendor": + conn.execute(f"INSERT OR IGNORE INTO {tables['vendors']}(name) VALUES (?)", (payload["name"],)) + + elif action == "add_category": + conn.execute(f"INSERT OR IGNORE INTO {tables['categories']}(name) VALUES (?)", (payload["name"],)) + + elif action == "add_product": + conn.execute( + f"INSERT OR IGNORE INTO {tables['products']}(vendor_id, name, url) VALUES (?, ?, ?)", + (payload["vendor_id"], payload["name"], payload.get("url") or None), + ) + if payload.get("url"): + conn.execute( + f"UPDATE {tables['products']} SET url = ? WHERE vendor_id = ? AND name = ?", + (payload["url"], payload["vendor_id"], payload["name"]), + ) + + elif action == "update_product": + conn.execute( + f"UPDATE {tables['products']} SET name = ?, url = ? WHERE id = ?", + (payload["name"], payload.get("url") or None, payload["product_id"]), + ) + + elif action == "update_vendor": + conn.execute( + f"UPDATE {tables['vendors']} SET name = ?, website = ?, mont_page = ? WHERE id = ?", + (payload["name"], payload.get("website") or None, payload.get("mont_page") or None, payload["vendor_id"]), + ) + + elif action == "delete_vendor": + conn.execute(f"DELETE FROM {tables['vendors']} WHERE id = ?", (payload["vendor_id"],)) + + elif action == "delete_category": + conn.execute(f"DELETE FROM {tables['categories']} WHERE id = ?", (payload["category_id"],)) + + elif action == "delete_product": + conn.execute(f"DELETE FROM {tables['products']} WHERE id = ?", (payload["product_id"],)) + + elif action == "save_matrix": + conn.execute(f"DELETE FROM {tables['product_categories']}") + conn.executemany( + f"INSERT OR IGNORE INTO {tables['product_categories']}(product_id, category_id) VALUES (?, ?)", + [tuple(pair) for pair in payload.get("pairs", [])], + ) + rebuild_vendor_categories(conn, tables) + + +def queue_change(conn, scope: str, action: str, payload: dict) -> int: + cur = conn.execute( + """ + INSERT INTO pending_changes(scope, action, payload, created_by, created_at) + VALUES (?, ?, ?, ?, ?) + """, + ( + scope, + action, + json.dumps(payload, ensure_ascii=False), + session.get("admin_login") or ADMIN_LOGIN, + local_now(), + ), + ) + return int(cur.lastrowid) + + +def describe_change(action: str, payload: dict) -> str: + if action == "add_vendor": + return f"Добавить вендора: {payload.get('name')}" + if action == "add_category": + return f"Добавить категорию: {payload.get('name')}" + if action == "add_product": + parts = [ + "Добавить продукт", + f"Вендор: {payload.get('vendor_name') or payload.get('vendor_id')}", + f"Название: {payload.get('name')}", + ] + if payload.get("url"): + parts.append(f"URL: {payload.get('url')}") + return "\n".join(parts) + if action == "update_product": + return "\n".join( + [ + "Изменить продукт", + f"Вендор: {payload.get('vendor_name') or '-'}", + f"Было: {payload.get('old_name') or '-'}", + f"Стало: {payload.get('name')}", + f"URL был: {payload.get('old_url') or '-'}", + f"URL станет: {payload.get('url') or '-'}", + ] + ) + if action == "update_vendor": + parts = [f"Изменить вендора: {payload.get('old_name') or payload.get('vendor_id')} → {payload.get('name')}"] + if payload.get("website"): + parts.append(f"Сайт: {payload['website']}") + if payload.get("mont_page"): + parts.append(f"MONT: {payload['mont_page']}") + return "\n".join(parts) + if action == "delete_vendor": + return f"Удалить вендора: {payload.get('vendor_name') or payload.get('vendor_id')}" + if action == "delete_category": + return f"Удалить категорию: {payload.get('category_name') or payload.get('category_id')}" + if action == "delete_product": + product = payload.get("product_name") or payload.get("product_id") + vendor = payload.get("vendor_name") or "-" + return f"Удалить продукт: {vendor} :: {product}" + if action == "save_matrix": + lines = [ + f"Изменить матрицу: связей выбрано {len(payload.get('pairs', []))}", + f"Добавлено связей: {len(payload.get('added', []))}", + f"Снято связей: {len(payload.get('removed', []))}", + ] + added = payload.get("added", []) + removed = payload.get("removed", []) + if added: + lines.append("") + lines.append("Добавить:") + lines.extend([f"+ {item.get('product')} -> {item.get('category')}" for item in added[:80]]) + if len(added) > 80: + lines.append(f"... еще {len(added) - 80}") + if removed: + lines.append("") + lines.append("Снять:") + lines.extend([f"- {item.get('product')} -> {item.get('category')}" for item in removed[:80]]) + if len(removed) > 80: + lines.append(f"... еще {len(removed) - 80}") + return "\n".join(lines) + return json.dumps(payload, ensure_ascii=False, indent=2) + + +def change_title(action: str) -> str: + return { + "add_vendor": "Добавление вендора", + "add_category": "Добавление категории", + "add_product": "Добавление продукта", + "update_vendor": "Изменение вендора", + "update_product": "Изменение продукта", + "delete_vendor": "Удаление вендора", + "delete_category": "Удаление категории", + "delete_product": "Удаление продукта", + "save_matrix": "Изменение матрицы", + }.get(action, action) + + +def notify_pending_change(change_id: int, scope: str, action: str, payload: dict) -> bool: + if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID: + return False + + admin_url = request.url_root.rstrip("/") + f"{ADMIN_PATH}?scope={scope}" + raw_details = describe_change(action, payload) + if len(raw_details) > 3000: + raw_details = raw_details[:3000] + "\n... сокращено, полный список в админке" + details = escape(raw_details) + text = "\n".join( + [ + "Zkart: нужно подтверждение", + "", + f"Заявка: #{change_id}", + f"Тип: {escape(change_title(action))}", + f"Раздел: {escape(scope_label(scope))}", + f"Админ: {escape(session.get('admin_login') or ADMIN_LOGIN)}", + "", + f"Изменение:\n
{details}
", + "", + f"Открыть админку", + ] + ) + data = urlencode( + { + "chat_id": TELEGRAM_CHAT_ID, + "text": text, + "parse_mode": "HTML", + "disable_web_page_preview": "1", + } + ).encode("utf-8") + req = Request( + f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage", + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + try: + with urlopen(req, timeout=5) as response: + return 200 <= response.status < 300 + except Exception: + return False + + +def approve_pending_change(conn, row, reviewed_by: str) -> bool: + savepoint = f"approve_change_{int(row['id'])}" + conn.execute(f"SAVEPOINT {savepoint}") + try: + change_tables = scope_tables(row["scope"]) + apply_change(conn, change_tables, row["action"], json.loads(row["payload"])) + conn.execute( + """ + UPDATE pending_changes + SET status = 'approved', reviewed_by = ?, reviewed_at = ? + WHERE id = ? + """, + (reviewed_by, local_now(), row["id"]), + ) + conn.execute(f"RELEASE SAVEPOINT {savepoint}") + return True + except (KeyError, TypeError, ValueError, sqlite3.DatabaseError): + conn.execute(f"ROLLBACK TO SAVEPOINT {savepoint}") + conn.execute(f"RELEASE SAVEPOINT {savepoint}") + return False + + +def prepare_pending_change(change: dict) -> dict: + change["payload"] = json.loads(change["payload"]) + change["title"] = change_title(change["action"]) + change["scope_label"] = scope_label(change["scope"]) + change["description"] = describe_change(change["action"], change["payload"]) + return change + + +def fetch_admin_matrix(conn, tables: dict[str, str]) -> tuple[list[dict], list[dict], list[dict], set[tuple[int, int]]]: + vendors = [dict(r) for r in conn.execute( + f"SELECT id, name, COALESCE(website,'') as website, COALESCE(mont_page,'') as mont_page " + f"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) @@ -146,6 +473,317 @@ def admin_login_or_panel(): (r["product_id"], r["category_id"]) for r in conn.execute(f"SELECT product_id, category_id FROM {tables['product_categories']}") } + return vendors, categories, products, links + + +def apply_pending_overlay( + vendors: list[dict], + categories: list[dict], + products: list[dict], + links: set[tuple[int, int]], + pending_changes: list[dict], +) -> tuple[list[dict], list[dict], list[dict], set[tuple[int, int]]]: + vendors = [dict(item) for item in vendors] + categories = [dict(item) for item in categories] + products = [dict(item) for item in products] + links = set(links) + + for change in sorted(pending_changes, key=lambda item: item["id"]): + if change["scope"] not in {"infra", "ib"}: + continue + action = change["action"] + payload = change["payload"] + + if action == "add_vendor": + vendors.append({"id": -change["id"], "name": payload.get("name", ""), "pending": True}) + elif action == "add_category": + categories.append({"id": -change["id"], "name": payload.get("name", ""), "pending": True}) + elif action == "add_product": + products.append( + { + "id": -change["id"], + "name": payload.get("name", ""), + "vendor_id": payload.get("vendor_id"), + "vendor_name": payload.get("vendor_name", ""), + "url": payload.get("url") or None, + "pending": True, + } + ) + elif action == "update_product": + for product in products: + if product["id"] == payload.get("product_id"): + product["name"] = payload.get("name", product["name"]) + product["url"] = payload.get("url") or None + product["pending"] = True + break + elif action == "update_vendor": + vendor_id = payload.get("vendor_id") + for vendor in vendors: + if vendor["id"] == vendor_id: + vendor["name"] = payload.get("name", vendor["name"]) + vendor["website"] = payload.get("website") or "" + vendor["mont_page"] = payload.get("mont_page") or "" + vendor["pending"] = True + break + elif action == "delete_vendor": + vendor_id = payload.get("vendor_id") + vendors = [item for item in vendors if item["id"] != vendor_id] + products = [item for item in products if item.get("vendor_id") != vendor_id] + elif action == "delete_category": + category_id = payload.get("category_id") + categories = [item for item in categories if item["id"] != category_id] + links = {pair for pair in links if pair[1] != category_id} + elif action == "delete_product": + product_id = payload.get("product_id") + products = [item for item in products if item["id"] != product_id] + links = {pair for pair in links if pair[0] != product_id} + elif action == "save_matrix": + links = {tuple(pair) for pair in payload.get("pairs", [])} + + vendors.sort(key=lambda item: item["name"].lower()) + categories.sort(key=lambda item: item["name"].lower()) + products.sort(key=lambda item: (item.get("vendor_name", "").lower(), item.get("name", "").lower())) + return vendors, categories, products, links + + +@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.get("/sdjlkfhsjkadahjksdhjgfkhsssssdjkjfljsdfjklsdajfkldsjflksdjfkldsj") +def legacy_admin_redirect(): + raw_scope = (request.args.get("scope") or "infra").strip().lower() + scope = "ib" if raw_scope in {"ib", "sec", "security"} else "infra" + return redirect_admin(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": + username = request.form.get("username") or "" + password = request.form.get("password") or "" + admin_user = conn.execute( + "SELECT username, password, role, access_scopes FROM admin_users WHERE username = ?", + (username,), + ).fetchone() + if admin_user and password == admin_user["password"]: + session["is_admin"] = True + session["admin_role"] = admin_user["role"] + session["admin_login"] = admin_user["username"] + session["admin_scopes"] = admin_user["access_scopes"] + 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 not can_access_scope(scope): + conn.close() + return redirect_admin(next(iter(allowed_scopes()))) + + if request.method == "POST": + action = request.form.get("action", "") + if action == "logout": + session.pop("is_admin", None) + session.pop("admin_role", None) + session.pop("admin_login", None) + session.pop("admin_scopes", None) + conn.close() + return redirect(ADMIN_PATH) + + if not can_access_scope(scope): + flash("Нет доступа к этому разделу.", "error") + conn.close() + return redirect_admin(next(iter(allowed_scopes()))) + + if action == "approve_all_changes": + if not is_super_admin(): + flash("Недостаточно прав для подтверждения изменений.", "error") + conn.close() + return redirect_admin(scope) + rows = conn.execute( + "SELECT * FROM pending_changes WHERE status = 'pending' ORDER BY id" + ).fetchall() + reviewed_by = session.get("admin_login") or SUPER_ADMIN_LOGIN + approved = 0 + failed = 0 + for row in rows: + if approve_pending_change(conn, row, reviewed_by): + approved += 1 + else: + failed += 1 + conn.commit() + conn.close() + if failed: + flash(f"Утверждено заявок: {approved}. Не удалось применить: {failed}.", "error") + else: + flash(f"Все заявки утверждены: {approved}.", "ok") + return redirect_admin(scope) + + if action == "create_admin": + if not is_super_admin(): + flash("Недостаточно прав для управления админами.", "error") + conn.close() + return redirect_admin(scope) + username = (request.form.get("username") or "").strip() + access = parse_admin_scopes() + if not username or not access: + flash("Укажите логин и минимум один раздел доступа.", "error") + conn.close() + return redirect_admin(scope) + password = make_admin_password() + try: + conn.execute( + """ + INSERT INTO admin_users(username, password, role, access_scopes, created_at) + VALUES (?, ?, 'admin', ?, ?) + """, + (username, password, access, local_now()), + ) + conn.commit() + session["created_admin_credentials"] = { + "username": username, + "password": password, + "access": access, + } + flash(f"Админ создан. Логин: {username}. Пароль: {password}", "ok") + except sqlite3.IntegrityError: + conn.rollback() + session.pop("created_admin_credentials", None) + flash("Админ с таким логином уже существует.", "error") + conn.close() + return redirect_admin(scope) + + if action == "delete_admin": + if not is_super_admin(): + flash("Недостаточно прав для управления админами.", "error") + conn.close() + return redirect_admin(scope) + admin_id = parse_int(request.form.get("admin_id")) + if admin_id: + target = conn.execute("SELECT role, username FROM admin_users WHERE id = ?", (admin_id,)).fetchone() + if target and target["role"] == "super": + flash("Супер-админа удалить нельзя.", "error") + elif target and target["username"] == session.get("admin_login"): + flash("Нельзя удалить текущего пользователя.", "error") + else: + conn.execute("DELETE FROM admin_users WHERE id = ? AND role <> 'super'", (admin_id,)) + conn.commit() + flash("Админ удален.", "ok") + conn.close() + return redirect_admin(scope) + + if action in {"approve_change", "reject_change"}: + if not is_super_admin(): + flash("Недостаточно прав для подтверждения изменений.", "error") + conn.close() + return redirect_admin(scope) + change_id = parse_int(request.form.get("change_id")) + row = None + if change_id: + row = conn.execute( + "SELECT * FROM pending_changes WHERE id = ? AND status = 'pending'", + (change_id,), + ).fetchone() + if not row: + flash("Заявка не найдена или уже обработана.", "error") + conn.close() + return redirect_admin(scope) + if action == "reject_change": + conn.execute( + """ + UPDATE pending_changes + SET status = 'rejected', reviewed_by = ?, reviewed_at = ? + WHERE id = ? + """, + (session.get("admin_login") or SUPER_ADMIN_LOGIN, local_now(), change_id), + ) + flash("Заявка отклонена.", "ok") + else: + if approve_pending_change(conn, row, session.get("admin_login") or SUPER_ADMIN_LOGIN): + flash("Заявка утверждена и применена.", "ok") + else: + flash("Не удалось применить заявку. Проверьте, не изменились ли связанные записи.", "error") + conn.close() + return redirect_admin(scope) + conn.commit() + conn.close() + return redirect_admin(scope) + + payload = collect_change_payload(action, scope, conn, tables) + if payload is not None: + if is_super_admin(): + try: + apply_change(conn, tables, action, payload) + flash("Изменения сохранены.", "ok") + except sqlite3.IntegrityError: + conn.rollback() + flash("Не удалось сохранить: возможно, такое название уже есть у выбранного вендора.", "error") + conn.close() + return redirect_admin(scope) + else: + change_id = queue_change(conn, scope, action, payload) + notify_pending_change(change_id, scope, action, payload) + flash("Изменения отправлены супер админу. Они вступят в силу после утверждения.", "ok") + + conn.commit() + conn.close() + return redirect_admin(scope) + + vendors, categories, products, links = fetch_admin_matrix(conn, tables) + if is_super_admin(): + pending_rows = conn.execute( + """ + SELECT id, scope, action, payload, created_by, created_at + FROM pending_changes + WHERE status = 'pending' + ORDER BY id DESC + """ + ) + else: + pending_rows = conn.execute( + """ + SELECT id, scope, action, payload, created_by, created_at + FROM pending_changes + WHERE status = 'pending' AND created_by = ? + ORDER BY id DESC + """, + (session.get("admin_login") or ADMIN_LOGIN,), + ) + pending_changes = [prepare_pending_change(dict(r)) for r in pending_rows] + if not is_super_admin(): + scoped_pending = [change for change in pending_changes if change["scope"] == scope] + vendors, categories, products, links = apply_pending_overlay(vendors, categories, products, links, scoped_pending) + admin_users = [ + dict(r) + for r in conn.execute( + """ + SELECT id, username, role, access_scopes, created_at + FROM admin_users + ORDER BY role DESC, lower(username) + """ + ) + ] + created_admin_credentials = session.pop("created_admin_credentials", None) conn.close() return render_template( "admin.html", @@ -154,6 +792,11 @@ def admin_login_or_panel(): products=products, links=links, scope=scope, + is_super_admin=is_super_admin(), + allowed_scopes=allowed_scopes(), + pending_changes=pending_changes, + admin_users=admin_users, + created_admin_credentials=created_admin_credentials, )