Compare commits

...

4 Commits

20 changed files with 2377 additions and 2005 deletions

10
.gitignore vendored
View File

@@ -3,3 +3,13 @@ __pycache__/
.venv/ .venv/
CONTEXT.md CONTEXT.md
CONTEXT.local.md CONTEXT.local.md
backups/
infra1
infra2
infra3
infra4
иб1
иб2
иб3
иб4
Z-card_РФ.xlsx

View File

@@ -6,13 +6,23 @@
- в двух режимах: `Инфраструктура` и `ИБ`. - в двух режимах: `Инфраструктура` и `ИБ`.
## Основные файлы ## Основные файлы
- `main.py` - Flask-приложение (главная страница, API, админка, инициализация/миграция БД, импорт ИБ при необходимости). - `main.py` - минимальный entrypoint Flask-приложения (запуск `app`).
- `zkart_app/__init__.py` - фабрика/инициализация приложения (`create_app`, регистрация роутов, `init_db`).
- `zkart_app/config.py` - конфиг и константы (пути, `ADMIN_PATH`, логин/пароль, env-флаги).
- `zkart_app/routes.py` - HTTP-роуты (`/`, `/api/data`, `/health`, админка, `/assets/mont-logo`).
- `zkart_app/db.py` - работа с SQLite, схемы, сидинг, импорт из `infra1-4`, загрузка матриц из XLSX.
- `templates/index.html` - шаблон главной страницы.
- `templates/admin.html` - шаблон админки.
- `templates/login.html` - шаблон логина в админку.
- `static/css/*` - стили интерфейса (главная/админка/логин).
- `static/js/*` - клиентские скрипты (главная/админка).
- `requirements.txt` - зависимости Python. - `requirements.txt` - зависимости Python.
- `matrix.db` - SQLite-база со всеми сущностями по двум режимам. - `matrix.db` - SQLite-база со всеми сущностями по двум режимам.
- `Dockerfile` - контейнеризация приложения. - `Dockerfile` - контейнеризация приложения.
- `docker-compose.yml` - запуск сервиса в Docker. - `docker-compose.yml` - запуск сервиса в Docker.
- `.dockerignore` - исключения для docker build context. - `.dockerignore` - исключения для docker build context.
- `mont_logo.png` - логотип, используется на главной. - `mont_logo.png` - логотип, используется на главной.
- `favicon.png` - favicon сайта.
## Роуты ## Роуты
- `/` - основной интерфейс с фильтрами. - `/` - основной интерфейс с фильтрами.
@@ -118,3 +128,7 @@ docker compose up -d --build
- логотип MONT слева без овального контейнера; - логотип MONT слева без овального контейнера;
- hero-блок с горным фоном; - hero-блок с горным фоном;
- подпись внизу справа: `Made by Galyaviev`, `ruslan@ipcom.su`. - подпись внизу справа: `Made by Galyaviev`, `ruslan@ipcom.su`.
- Перед крупными рефакторингами делать checkpoint:
- создать отдельную git-ветку;
- при необходимости поставить тег на текущее состояние;
- при проблемах откатываться к checkpoint через git.

View File

@@ -12,4 +12,4 @@ COPY . .
EXPOSE 8000 EXPOSE 8000
CMD ["gunicorn", "-w", "3", "-b", "0.0.0.0:8000", "main:app", "--timeout", "60"] CMD ["sh", "-c", "gunicorn -w ${WEB_CONCURRENCY:-4} --threads ${GUNICORN_THREADS:-5} -b 0.0.0.0:8000 main:app --timeout 60"]

View File

@@ -1,31 +1,49 @@
# MONT Vendor Maps # MONT Vendor Maps
Flask-приложение для визуализации и редактирования матрицы: Flask-приложение для визуализации и редактирования матрицы:
- вендор -> продукты -> категории - `вендор -> продукты -> категории`
- в двух режимах: Инфраструктура и ИБ - два контура: `Инфраструктура` и `ИБ`
## Возможности ## Возможности
- фильтрация по вендорам и категориям - фильтрация по вендорам и категориям на главной
- отображение продуктов по выбранным фильтрам - отображение продуктов по выбранным фильтрам
- переключение контуров `Инфраструктура / ИБ` - переключение контура `Инфраструктура / ИБ`
- отдельный визуальный стиль для ИБ - админ-панель:
- админ-панель с редактированием: - добавление/удаление вендоров
- вендоров - добавление/удаление категорий
- категорий - добавление/удаление продуктов
- продуктов - редактирование матрицы `продукт × категория` (автосохранение по чекбоксам)
- матрицы `продукт × категория` - удобная прокрутка матрицы (вертикальная + горизонтальная)
## Текущая структура проекта
- `main.py` - минимальный entrypoint
- `zkart_app/__init__.py` - `create_app`, регистрация роутов, `init_db`
- `zkart_app/config.py` - конфиг/константы (`ADMIN_PATH`, env, пути)
- `zkart_app/routes.py` - HTTP-роуты
- `zkart_app/db.py` - работа с SQLite, импорт и bootstrap
- `templates/` - HTML-шаблоны (`index.html`, `admin.html`, `login.html`)
- `static/css/` - CSS
- `static/js/` - JS
- `matrix.db` - рабочая SQLite БД
## Роуты
- `/` - главная страница
- `/api/data?scope=infra|ib` - API данных матрицы
- `/health` - healthcheck
- `/assets/mont-logo` - логотип
- `/{ADMIN_PATH}` - админка (секретный путь в `zkart_app/config.py`)
## API ## API
`GET /api/data?scope=infra|ib` `GET /api/data?scope=infra|ib`
Возвращает JSON: Возвращает:
- `vendors` - `vendors`
- `categories` - `categories`
- `products` - `products`
- `product_links` - `product_links`
- `links` (агрегированные vendor-category, для совместимости) - `links`
## Локальный запуск ## Запуск локально
```bash ```bash
python3 -m venv .venv python3 -m venv .venv
.venv/bin/python -m ensurepip --upgrade .venv/bin/python -m ensurepip --upgrade
@@ -38,17 +56,19 @@ python3 -m venv .venv
docker compose up -d --build docker compose up -d --build
``` ```
Приложение доступно на порту `5000`. Порт: `5000`.
## Переменные окружения ## Переменные окружения
- `SECRET_KEY` секрет Flask-сессии. - `SECRET_KEY` - секрет Flask-сессии
- `ENABLE_BOOTSTRAP` — управление стартовым наполнением данных: - `ENABLE_BOOTSTRAP`:
- `0` (по умолчанию): не выполнять автосидинг/автоимпорт; - `0` (по умолчанию) - не выполнять автосидинг/автоимпорт
- `1`: разрешить bootstrap (seed + импорт из `infra1..infra4` при подходящих условиях). - `1` - разрешить bootstrap (seed + импорт)
- `WEB_CONCURRENCY` - число gunicorn worker-процессов (по умолчанию `4`)
- `GUNICORN_THREADS` - число потоков на worker (по умолчанию `5`)
## База данных По умолчанию Gunicorn дает минимум `20` параллельных обработок (`4 x 5`).
SQLite: `matrix.db`.
Содержит данные по двум контурам (`infra` и `ib`), включая продукты и связи категорий. ## Данные
- Инфраструктурные пакеты импорта: `infra1..infra4`
Если `matrix.db` уже заполнена, запускайте с `ENABLE_BOOTSTRAP=0` (дефолт), чтобы не выполнять лишнюю инициализацию. - ИБ-пакеты импорта: б1..иб4` (используются для массового заполнения `ib_*` таблиц)
- Бэкапы БД: директория `backups/`

Binary file not shown.

View File

@@ -4,6 +4,8 @@ services:
container_name: zkart-app container_name: zkart-app
environment: environment:
SECRET_KEY: ${SECRET_KEY:-change-me-please} SECRET_KEY: ${SECRET_KEY:-change-me-please}
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-4}
GUNICORN_THREADS: ${GUNICORN_THREADS:-5}
volumes: volumes:
- ./matrix.db:/app/matrix.db - ./matrix.db:/app/matrix.db
restart: unless-stopped restart: unless-stopped

1981
main.py

File diff suppressed because it is too large Load Diff

BIN
matrix.db

Binary file not shown.

162
static/css/admin.css Normal file
View File

@@ -0,0 +1,162 @@
:root { --b:#1f4ea3; --line:#cfe0ff; }
* { box-sizing: border-box; }
body { margin:0; font-family:Manrope,sans-serif; background:#f0f5ff; color:#1a2746; }
body.ib { background:#eefaf3; }
.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, #1f7a4a, #37a96b); }
.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; }
@media (max-width: 980px) {
.wrap {
width: calc(100% - 16px);
margin: 8px auto 16px;
}
.top {
flex-direction: column;
align-items: stretch;
padding: 12px;
}
.top > div:last-child {
width: 100%;
display: grid !important;
grid-template-columns: 1fr 1fr;
}
.top > div:last-child a,
.top > div:last-child form,
.top > div:last-child button {
width: 100%;
}
.scope-switch {
flex-wrap: wrap;
}
.scope-chip {
flex: 1 1 auto;
text-align: center;
}
.grid {
grid-template-columns: 1fr;
}
.lists {
grid-template-columns: 1fr;
}
.list-box {
max-height: 300px;
}
.list-item {
align-items: flex-start;
flex-wrap: wrap;
}
.matrix-wrap {
padding: 8px;
}
.matrix-scroll {
max-height: 62vh;
}
th, td {
font-size: 11px;
padding: 5px;
}
th:first-child,
td:first-child {
min-width: 170px;
}
input[type="text"],
select,
button {
min-height: 40px;
}
}
@media (max-width: 600px) {
.top > div:last-child {
grid-template-columns: 1fr;
}
.inline-product {
grid-template-columns: 1fr;
}
.matrix-h-scroll {
height: 24px;
}
.matrix-scroll::-webkit-scrollbar,
.matrix-h-scroll::-webkit-scrollbar {
height: 18px;
width: 12px;
}
th:first-child,
td:first-child {
min-width: 145px;
}
.matrix-tip {
font-size: 11px;
}
}

492
static/css/index.css Normal file
View File

@@ -0,0 +1,492 @@
: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%, #b8f3cf 0%, transparent 58%),
radial-gradient(900px 500px at 110% -20%, #b2efd6 0%, transparent 52%),
linear-gradient(160deg, #daf8e8 0%, #bdeecf 100%);
}
body.scope-ib .hero {
background:
linear-gradient(160deg, rgba(255,255,255,.09), rgba(255,255,255,0) 45%),
linear-gradient(125deg, #1f7a4a 0%, #2f9c61 55%, #47b879 100%);
}
body.scope-ib .hero::after {
background-color: rgba(10, 74, 45, .42);
}
body.scope-ib .hero::before {
background:
linear-gradient(to top, rgba(255,255,255,.9) 0 2px, rgba(255,255,255,0) 2px),
rgba(10, 61, 38, .40);
}
body.scope-ib .mode-btn.active {
background: linear-gradient(140deg, #1d8d54, #2fac6d);
box-shadow: 0 8px 18px rgba(24, 124, 72, .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;
}
.active-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 12px;
padding: 9px;
border-radius: 12px;
border: 1px solid #c9dafd;
background: linear-gradient(145deg, #e8f0ff, #dbe8ff);
box-shadow: inset 0 1px 0 rgba(255,255,255,.65);
}
.active-filter {
display: inline-flex;
align-items: center;
gap: 7px;
border-radius: 999px;
padding: 6px 8px 6px 10px;
border: 1px solid #cfe0ff;
background: linear-gradient(145deg, #f8fbff, #edf4ff);
color: #21457f;
font-size: 12px;
font-weight: 700;
}
.active-filter .kind {
opacity: .7;
font-weight: 600;
letter-spacing: .2px;
}
.active-filter .remove {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border: 0;
border-radius: 999px;
background: #dbe9ff;
color: #1f4a8f;
font-size: 14px;
line-height: 1;
cursor: pointer;
padding: 0;
transition: .16s ease;
}
.active-filter .remove:hover {
background: #c4dbff;
transform: scale(1.05);
}
.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; }
}
@media (max-width: 768px) {
.wrap {
width: calc(100% - 16px);
margin: 10px auto 18px;
}
.brand-strip {
margin-bottom: 8px;
}
.brand-logo {
width: clamp(132px, 42vw, 190px);
}
.hero {
padding: 16px 14px 20px;
border-radius: 16px;
}
.hero h1 {
font-size: clamp(22px, 7vw, 32px);
line-height: 1.06;
}
.hero p {
font-size: 14px;
}
.mode-switch {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
}
.mode-btn {
width: 100%;
min-height: 42px;
}
.card {
min-height: 0;
padding: 12px;
}
.chip-grid {
max-height: 260px;
}
.chip {
font-size: 12px;
padding: 8px 10px;
}
.footer-bar {
flex-direction: column;
align-items: stretch;
gap: 8px;
background: rgba(255, 255, 255, .78);
border: 1px solid #dbe7ff;
border-radius: 12px;
padding: 10px;
}
button.action {
width: 100%;
min-height: 42px;
}
.result {
margin-top: 12px;
padding: 12px;
}
.active-filters {
margin-bottom: 10px;
}
.active-filter {
width: 100%;
justify-content: space-between;
}
.result-head h3 {
font-size: 15px;
}
.rows {
grid-template-columns: 1fr;
gap: 10px;
}
.credit {
right: 8px;
bottom: 8px;
background: rgba(255, 255, 255, .86);
border: 1px solid #dbe6ff;
border-radius: 10px;
padding: 4px 6px;
}
}
@media (max-width: 420px) {
.hero::before,
.hero::after {
opacity: .72;
}
.search {
padding: 10px 11px;
}
.tag {
font-size: 11px;
}
}

37
static/css/login.css Normal file
View 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
View 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();
})();

354
static/js/index.js Normal file
View File

@@ -0,0 +1,354 @@
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"),
resultSection: document.querySelector(".result"),
filtersSection: document.querySelector(".board"),
activeFilters: document.getElementById("activeFilters"),
};
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", () => {
const wasSelected = state.selectedVendors.has(vendor.id);
if (wasSelected) state.selectedVendors.delete(vendor.id);
else state.selectedVendors.add(vendor.id);
render();
if (wasSelected) scrollAfterDeselect();
else scrollToResultsSmooth();
});
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", () => {
const wasSelected = state.selectedCategories.has(category.id);
if (wasSelected) state.selectedCategories.delete(category.id);
else state.selectedCategories.add(category.id);
render();
if (wasSelected) scrollAfterDeselect();
else scrollToResultsSmooth();
});
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();
renderActiveFilters();
renderResults();
}
function scrollToResultsSmooth() {
if (!el.resultSection) return;
const top = Math.max(el.resultSection.getBoundingClientRect().top + window.scrollY - 10, 0);
window.scrollTo({ top, behavior: "smooth" });
}
function scrollToFiltersSmooth() {
if (!el.filtersSection) return;
const top = Math.max(el.filtersSection.getBoundingClientRect().top + window.scrollY - 8, 0);
window.scrollTo({ top, behavior: "smooth" });
}
function scrollToTopSmooth() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
function scrollAfterDeselect() {
if (window.innerWidth > 980) {
scrollToTopSmooth();
return;
}
scrollToFiltersSmooth();
}
function renderActiveFilters() {
if (!el.activeFilters) return;
el.activeFilters.innerHTML = "";
const vendorsById = new Map(state.data.vendors.map(v => [v.id, v.name]));
const categoriesById = new Map(state.data.categories.map(c => [c.id, c.name]));
const items = [];
for (const id of state.selectedVendors) {
const name = vendorsById.get(id);
if (name) items.push({ kind: "Вендор", name, id, type: "vendor" });
}
for (const id of state.selectedCategories) {
const name = categoriesById.get(id);
if (name) items.push({ kind: "Категория", name, id, type: "category" });
}
if (items.length === 0) {
el.activeFilters.style.display = "none";
return;
}
el.activeFilters.style.display = "flex";
for (const item of items) {
const node = document.createElement("div");
node.className = "active-filter";
const text = document.createElement("span");
text.innerHTML = `<span class="kind">${item.kind}:</span> ${item.name}`;
node.appendChild(text);
const remove = document.createElement("button");
remove.className = "remove";
remove.type = "button";
remove.setAttribute("aria-label", `Убрать фильтр ${item.name}`);
remove.textContent = "×";
remove.addEventListener("click", () => {
if (item.type === "vendor") state.selectedVendors.delete(item.id);
else state.selectedCategories.delete(item.id);
render();
scrollToFiltersSmooth();
});
node.appendChild(remove);
el.activeFilters.appendChild(node);
}
}
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();
scrollAfterDeselect();
});
el.modeInfra.addEventListener("click", () => {
switchScope("infra");
});
el.modeIb.addEventListener("click", () => {
switchScope("ib");
});
init();

151
templates/admin.html Normal file
View 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>

82
templates/index.html Normal file
View File

@@ -0,0 +1,82 @@
<!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="activeFilters" class="active-filters"></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
View 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
View 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
View 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
View 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
View 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")