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 @@
{{ created_admin_credentials.username }}{{ created_admin_credentials.password }}{{ created_admin_credentials.access }}{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,
)