Рефакторинг структуры проекта: шаблоны, статика и модули приложения #1
@@ -118,3 +118,7 @@ docker compose up -d --build
|
||||
- логотип MONT слева без овального контейнера;
|
||||
- hero-блок с горным фоном;
|
||||
- подпись внизу справа: `Made by Galyaviev`, `ruslan@ipcom.su`.
|
||||
- Перед крупными рефакторингами делать checkpoint:
|
||||
- создать отдельную git-ветку;
|
||||
- при необходимости поставить тег на текущее состояние;
|
||||
- при проблемах откатываться к checkpoint через git.
|
||||
|
||||
77
static/css/admin.css
Normal file
77
static/css/admin.css
Normal file
@@ -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; }
|
||||
349
static/css/index.css
Normal file
349
static/css/index.css
Normal file
@@ -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; }
|
||||
}
|
||||
37
static/css/login.css
Normal file
37
static/css/login.css
Normal file
@@ -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; }
|
||||
92
static/js/admin.js
Normal file
92
static/js/admin.js
Normal file
@@ -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();
|
||||
})();
|
||||
271
static/js/index.js
Normal file
271
static/js/index.js
Normal file
@@ -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 = '<div class="row-card"><strong>По текущим фильтрам ничего не найдено</strong></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "row-card";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = row.vendor;
|
||||
card.appendChild(title);
|
||||
const tags = document.createElement("div");
|
||||
tags.className = "tags";
|
||||
for (const product of row.products) {
|
||||
const hasUrl = product.url && String(product.url).trim().length > 0;
|
||||
const tag = document.createElement(hasUrl ? "a" : "span");
|
||||
tag.className = "tag";
|
||||
tag.textContent = product.name;
|
||||
if (hasUrl) {
|
||||
tag.href = product.url;
|
||||
tag.target = "_blank";
|
||||
tag.rel = "noopener noreferrer";
|
||||
}
|
||||
tags.appendChild(tag);
|
||||
}
|
||||
card.appendChild(tags);
|
||||
el.resultRows.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
el.modeInfra.classList.toggle("active", state.scope === "infra");
|
||||
el.modeIb.classList.toggle("active", state.scope === "ib");
|
||||
document.body.classList.toggle("scope-ib", state.scope === "ib");
|
||||
renderChips();
|
||||
renderResults();
|
||||
}
|
||||
|
||||
async function loadScopeData(scope) {
|
||||
const res = await fetch(`/api/data?scope=${encodeURIComponent(scope)}`);
|
||||
state.data = await res.json();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await loadScopeData(state.scope);
|
||||
render();
|
||||
}
|
||||
|
||||
async function switchScope(scope) {
|
||||
if (scope === state.scope) return;
|
||||
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();
|
||||
151
templates/admin.html
Normal file
151
templates/admin.html
Normal file
@@ -0,0 +1,151 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin Matrix</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}" />
|
||||
</head>
|
||||
<body class="{% if scope == 'ib' %}ib{% endif %}">
|
||||
<main class="wrap">
|
||||
<section class="top">
|
||||
<div>
|
||||
<strong>Админ-панель матрицы</strong>
|
||||
<div class="scope-switch" style="margin-top:8px;">
|
||||
<a class="scope-chip {% if scope == 'infra' %}active{% endif %}" href="{{ request.path }}?scope=infra">Инфраструктура</a>
|
||||
<a class="scope-chip {% if scope == 'ib' %}active{% endif %}" href="{{ request.path }}?scope=ib">ИБ</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<a href="/" style="text-decoration:none;"><button class="warn" type="button">На сайт</button></a>
|
||||
<form method="post" style="margin:0;">
|
||||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||||
<input type="hidden" name="action" value="logout" />
|
||||
<button class="danger" type="submit">Выйти</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="box">
|
||||
<h3>Добавить вендора</h3>
|
||||
<form method="post" class="inline">
|
||||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||||
<input type="hidden" name="action" value="add_vendor" />
|
||||
<input type="text" name="name" placeholder="Название вендора" required />
|
||||
<button class="pri" type="submit">Добавить</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="box">
|
||||
<h3>Добавить категорию</h3>
|
||||
<form method="post" class="inline">
|
||||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||||
<input type="hidden" name="action" value="add_category" />
|
||||
<input type="text" name="name" placeholder="Название категории" required />
|
||||
<button class="pri" type="submit">Добавить</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="box">
|
||||
<h3>Добавить продукт</h3>
|
||||
<form method="post" class="inline inline-product">
|
||||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||||
<input type="hidden" name="action" value="add_product" />
|
||||
<select name="vendor_id" required style="padding:9px 10px; border:1px solid #c6d8fb; border-radius:9px;">
|
||||
{% for v in vendors %}
|
||||
<option value="{{ v.id }}">{{ v.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="text" name="name" placeholder="Название продукта" required />
|
||||
<input type="text" name="url" placeholder="URL продукта (необязательно)" />
|
||||
<button class="pri" type="submit">Добавить</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lists">
|
||||
<div class="box">
|
||||
<h3>Удалить вендора</h3>
|
||||
<div class="list-box">
|
||||
{% for v in vendors %}
|
||||
<form class="list-item" method="post">
|
||||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||||
<span>{{ v.name }}</span>
|
||||
<input type="hidden" name="action" value="delete_vendor" />
|
||||
<input type="hidden" name="vendor_id" value="{{ v.id }}" />
|
||||
<button class="danger" type="submit">Удалить</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<h3>Удалить категорию</h3>
|
||||
<div class="list-box">
|
||||
{% for c in categories %}
|
||||
<form class="list-item" method="post">
|
||||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||||
<span>{{ c.name }}</span>
|
||||
<input type="hidden" name="action" value="delete_category" />
|
||||
<input type="hidden" name="category_id" value="{{ c.id }}" />
|
||||
<button class="danger" type="submit">Удалить</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<h3>Удалить продукт</h3>
|
||||
<div class="list-box">
|
||||
{% for p in products %}
|
||||
<form class="list-item" method="post">
|
||||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||||
<span>
|
||||
{{ p.vendor_name }} :: {{ p.name }}
|
||||
{% if p.url %}<a href="{{ p.url }}" target="_blank" rel="noopener noreferrer" style="margin-left:6px; font-size:11px;">ссылка</a>{% endif %}
|
||||
</span>
|
||||
<input type="hidden" name="action" value="delete_product" />
|
||||
<input type="hidden" name="product_id" value="{{ p.id }}" />
|
||||
<button class="danger" type="submit">Удалить</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="matrix-wrap">
|
||||
<p class="matrix-tip">Прокрутка: колесо/тачпад вниз-вверх внутри таблицы, полосой ниже - влево-вправо.</p>
|
||||
<form method="post" id="matrixForm">
|
||||
<input type="hidden" name="scope" value="{{ scope }}" />
|
||||
<input type="hidden" name="action" value="save_matrix" />
|
||||
<div id="matrixHScroll" class="matrix-h-scroll"><div id="matrixHScrollInner" class="matrix-h-scroll-inner"></div></div>
|
||||
<div id="matrixScroll" class="matrix-scroll">
|
||||
<table id="matrixTable">
|
||||
<tr>
|
||||
<th>Вендор / Продукт</th>
|
||||
{% for c in categories %}
|
||||
<th>{{ c.name }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for p in products %}
|
||||
<tr>
|
||||
<td><strong>{{ p.vendor_name }}</strong><br/>{{ p.name }}</td>
|
||||
{% for c in categories %}
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="pc_{{ p.id }}_{{ c.id }}"
|
||||
{% if (p.id, c.id) in links %}checked{% endif %}
|
||||
/>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
81
templates/index.html
Normal file
81
templates/index.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Корзина МОНТ</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@600;700&family=Manrope:wght@400;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<section class="brand-strip">
|
||||
<div class="brand-logo">
|
||||
<img src="/assets/mont-logo" alt="MONT logo" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-layout">
|
||||
<div>
|
||||
<h1>Вендоры в корзине МОНТ</h1>
|
||||
<p>Актуальная матрица вендоров, продуктов и категорий. Выбирайте вендоров или категории, чтобы видеть релевантные продуктовые линейки в Инфраструктуре и ИБ.</p>
|
||||
<div class="mode-switch">
|
||||
<button id="modeInfra" class="mode-btn active" type="button">Инфраструктура</button>
|
||||
<button id="modeIb" class="mode-btn" type="button">ИБ</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="board">
|
||||
<article class="card">
|
||||
<h2>Вендоры</h2>
|
||||
<input id="vendorSearch" class="search" placeholder="Поиск вендора..." />
|
||||
<div id="vendorList" class="chip-grid"></div>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h2>Категории</h2>
|
||||
<input id="categorySearch" class="search" placeholder="Поиск категории..." />
|
||||
<div id="categoryList" class="chip-grid"></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="footer-bar">
|
||||
<div id="stats">Загрузка...</div>
|
||||
<button class="action" id="clearBtn">Сбросить фильтры</button>
|
||||
</div>
|
||||
|
||||
<section class="result">
|
||||
<div class="result-head">
|
||||
<h3>Вендоры и продукты (после фильтрации)</h3>
|
||||
</div>
|
||||
<div id="resultRows" class="rows"></div>
|
||||
</section>
|
||||
<div class="credit">
|
||||
<div class="name">Made by Galyaviev</div>
|
||||
<a href="mailto:RGalyaviev@mont.com">RGalyaviev@mont.com</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Yandex.Metrika counter -->
|
||||
<script type="text/javascript">
|
||||
(function(m,e,t,r,i,k,a){
|
||||
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||
m[i].l=1*new Date();
|
||||
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
||||
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
|
||||
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=108577107', 'ym');
|
||||
|
||||
ym(108577107, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
|
||||
</script>
|
||||
<noscript><div><img src="https://mc.yandex.ru/watch/108577107" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
||||
<!-- /Yandex.Metrika counter -->
|
||||
|
||||
<script src="{{ url_for('static', filename='js/index.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
templates/login.html
Normal file
22
templates/login.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin Login</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}" />
|
||||
</head>
|
||||
<body>
|
||||
<form method="post">
|
||||
<h1>Редактор матрицы</h1>
|
||||
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||
<input type="hidden" name="action" value="login" />
|
||||
<input name="username" placeholder="Логин" autocomplete="username" required />
|
||||
<input type="password" name="password" placeholder="Пароль" autocomplete="current-password" required />
|
||||
<button type="submit">Войти</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
22
zkart_app/__init__.py
Normal file
22
zkart_app/__init__.py
Normal file
@@ -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()
|
||||
16
zkart_app/config.py
Normal file
16
zkart_app/config.py
Normal file
@@ -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"}
|
||||
708
zkart_app/db.py
Normal file
708
zkart_app/db.py
Normal file
@@ -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)
|
||||
167
zkart_app/routes.py
Normal file
167
zkart_app/routes.py
Normal file
@@ -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")
|
||||
Reference in New Issue
Block a user