initial site

This commit is contained in:
merrow committed 2026-10-11 22:24:54 +05:00
commit da5b259d06
167 files changed
+34157

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
__pycache__
*.pyc
data/
migrate/
.dockerignore
Dockerfile
*.log
# NF-09/NF-19: dev-бэкапы, dev-скрипты и секреты не должны попадать в образ,
# даже если Dockerfile когда-нибудь начнёт копировать каталог целиком
*.bak*
*.mjs
.env
+28
View File
@@ -0,0 +1,28 @@
# NEON CRM — Python + PostgreSQL, без веб-фреймворков.
# NF-20: дайджест-пин базового образа (воспроизводимая сборка).
FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py storage.py storage_sqlite.py notes_api.py ./
COPY seed/ ./seed/
COPY index.html ./
COPY css/ ./css/
COPY js/ ./js/
RUN groupadd --system neon && useradd --system --gid neon --create-home neon \
&& chown -R neon:neon /app
USER neon
EXPOSE 47613
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:47613/api/healthz', timeout=3).status == 200 else 1)"
CMD ["python", "server.py"]
+1645
View File
File diff suppressed because it is too large. Load diff
+118
View File
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>НЕОН CRM — панель управления</title>
<meta name="description" content="CRM кальянного лаунжа НЕОН: стоп-лист табаков и меню, управление заказами.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230A0A0F'/%3E%3Ctext x='32' y='45' font-family='Arial Black, Arial' font-size='34' font-weight='900' text-anchor='middle' fill='%2300F0FF'%3EН%3C/text%3E%3C/svg%3E">
<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;500;600;700;800&family=Unbounded:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<!-- ЭКРАН ВХОДА -->
<section class="login" id="loginScreen">
<div class="glow glow-cyan"></div>
<div class="glow glow-purple"></div>
<div class="login-card">
<div class="login-logo">
<span class="logo-neon">Н</span>
<div>
<h1>НЕОН <em>CRM</em></h1>
<p>Панель управления лаунджем · Нягань</p>
</div>
</div>
<form id="loginForm" autocomplete="off">
<label class="field">
<span>Логин</span>
<input type="text" id="loginUser" placeholder="admin" value="" autocomplete="username">
</label>
<label class="field">
<span>Пароль</span>
<input type="password" id="loginPass" placeholder="••••••••" value="" autocomplete="current-password">
</label>
<button class="btn btn-neon btn-block" type="submit">Войти</button>
</form>
<p class="login-error hidden" id="loginError" role="alert"></p>
</div>
<p class="login-foot">neon-bar86.ru · внутренняя система</p>
</section>
<!-- ПРИЛОЖЕНИЕ -->
<div class="app hidden" id="app">
<aside class="sidebar">
<div class="side-top">
<div class="brand">
<span class="logo-neon">Н</span>
<div class="brand-txt">
<strong>НЕОН <em>CRM</em></strong>
<small id="brandRole">—</small>
</div>
</div>
<!-- карточка аккаунта перед списком вкладок: имя, роль и выход всегда сверху -->
<div class="user-card">
<span class="avatar" id="userAvatar">М</span>
<div>
<strong id="userName">—</strong>
<small id="userRole">—</small>
</div>
<button class="icon-btn" id="passwordBtn" title="Сменить пароль" type="button">🔑</button>
<button class="icon-btn" id="logoutBtn" title="Выйти" type="button">✕</button>
</div>
<nav class="nav" id="nav"></nav>
</div>
<div class="side-bottom">
<button class="btn btn-ghost btn-sm btn-block offline-retry hidden" id="offlineRetry" type="button">⟳ Переподключиться</button>
</div>
</aside>
<main class="main">
<header class="topbar">
<div>
<h2 id="pageTitle">—</h2>
<p id="pageSub">—</p>
</div>
<div class="topbar-right">
<span class="shift" id="shift">—</span>
<span class="topbar-actions" id="shiftActions"></span>
<button class="icon-btn bell" id="bellBtn" title="Новые заказы" type="button">
🔔<span class="bell-count hidden" id="bellCount">0</span>
</button>
<span class="clock" id="clock">--:--</span>
</div>
</header>
<div class="view" id="viewRoot"></div>
</main>
<!-- автопрокрутка страницы: появляется, когда список не влезает в экран -->
<div class="scroll-nav hidden" id="scrollNav">
<button class="scroll-btn" id="scrollUp" title="Прокрутить вверх" type="button">▲</button>
<button class="scroll-btn" id="scrollDown" title="Прокрутить вниз" type="button">▼</button>
</div>
</div>
<div class="toast-wrap" id="toasts" role="status" aria-live="polite"></div>
<!-- модальные окна подтверждения и ввода: живут вне viewRoot, чтобы
перерисовка (опрос заказов каждые 10 с) не закрывала открытое окно -->
<div class="modal-overlay hidden" id="modalOverlay">
<div class="modal" id="modalBox" role="dialog" aria-modal="true"></div>
</div>
<!-- скрытый выбор фото позиции: живёт вне viewRoot, чтобы перерисовка
списка (опрос заказов каждые 10 с) не отваливала элемент во время
открытого диалога выбора файла -->
<input type="file" id="photoInput" accept="image/avif,image/jpeg,image/png,image/webp,image/gif" style="display:none">
<script src="js/app.js"></script>
</body>
</html>
+3870
View File
File diff suppressed because it is too large. Load diff
+84
View File
@@ -0,0 +1,84 @@
-- -*- coding: utf-8 -*-
-- ============================================================================
-- НЕОН CRM — доски заметок/задач (общая + приватные «владелец ↔ админ»).
--
-- Идемпотентна: повторный запуск не падает и не меняет данные.
-- CRM применяет этот же DDL автоматически при старте (SCHEMA в storage.py,
-- сид общей доски — boards_seed_shared()); файл нужен для ручного применения
-- и как канонический список объектов.
--
-- Применить вручную:
-- docker compose exec -T db psql -U neon -d neon_crm \
-- -v ON_ERROR_STOP=1 < crm/migrate/notes_boards.sql
--
-- Откат (теряются все заметки и история!):
-- docker compose exec -T db psql -U neon -d neon_crm \
-- -c "DROP TABLE IF EXISTS note_activity, notes, board_members, boards CASCADE"
--
-- Отступление от исходного ТЗ: пользователи в этой базе — crm_users с текстовым
-- PK login (целочисленного users.id не существует), поэтому все ссылки идут
-- на crm_users(login).
-- ============================================================================
CREATE TABLE IF NOT EXISTS boards (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('shared', 'private')),
title TEXT NOT NULL,
created_by_login TEXT NOT NULL REFERENCES crm_users(login),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- участники приватных досок; для kind='shared' не используется —
-- общая доска доступна всем ролям без проверки
CREATE TABLE IF NOT EXISTS board_members (
board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
user_login TEXT NOT NULL REFERENCES crm_users(login) ON DELETE CASCADE,
PRIMARY KEY (board_id, user_login)
);
-- заметка = задача: заголовок, текст, исполнитель, срок, «важно», статус.
-- К сменам не привязана — доступна в любое время по факту логина.
-- Логины автора/исполнителя без FK (как orders.waiter в storage.py):
-- удаление сотрудника не должно ломать его задачи.
CREATE TABLE IF NOT EXISTS notes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
author_login TEXT NOT NULL,
assignee_login TEXT,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'done')),
important BOOLEAN NOT NULL DEFAULT FALSE,
due_date DATE,
done_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_notes_board ON notes (board_id, status);
CREATE INDEX IF NOT EXISTS idx_notes_assignee ON notes (assignee_login) WHERE status <> 'done';
-- журнал действий: created/status/assigned/edited/deleted/board_created.
-- details: для assigned — логин исполнителя, для deleted — id заметки.
-- note_id REFERENCES ... ON DELETE SET NULL: сама запись переживает удаление
-- заметки, id удалённой сохраняется в details (для синхронизации клиентов).
-- user_login без FK — журнал переживает удаление сотрудника.
CREATE TABLE IF NOT EXISTS note_activity (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
note_id BIGINT REFERENCES notes(id) ON DELETE SET NULL,
user_login TEXT NOT NULL,
action TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- одна общая доска на базу; владелец = первый по логину owner (база после
-- seed CRM уже содержит учётки). Повторный запуск ничего не добавляет.
INSERT INTO boards (kind, title, created_by_login)
SELECT 'shared', 'Общая', u.login
FROM crm_users u
WHERE u.role = 'owner'
AND NOT EXISTS (SELECT 1 FROM boards WHERE kind = 'shared')
ORDER BY u.login
LIMIT 1;
+227
View File
@@ -0,0 +1,227 @@
# -*- coding: utf-8 -*-
"""
НЕОН CRM — доски заметок/задач (HTTP-хендлеры).
Маршруты (все требуют X-Auth-Token):
GET /api/boards — доступные доски
POST /api/boards — приватная доска «владелец ↔ админ»
PATCH /api/boards/<id> — переименовать (owner/admin)
DELETE /api/boards/<id> — удалить (только owner)
GET /api/boards/<id>/notes — заметки (?since= — только изменённые)
POST /api/boards/<id>/notes — создать заметку
GET /api/boards/<id>/activity — лента событий доски
PATCH /api/notes/<id> — правка заметки
DELETE /api/notes/<id> — удаление (owner/admin/автор)
Хендлеры собраны в NotesRoutes — миксин для Handler из server.py, чтобы
точка интеграции маршрутов была одной строкой на метод. Доступ к доске
проверяет storage.board_guard(): чужая приватная доска неотличима от
несуществующей — оба случая отдаются как 404 {"error": "not found"}.
"""
import re
from datetime import datetime
from urllib.parse import parse_qs, urlsplit
import storage
from storage import clean_guest_text # NF-14: staff-тексты без NUL/bidi-мусора
BOARD_ID_RE = re.compile(r"^/api/boards/(\d+)$")
BOARD_NOTES_RE = re.compile(r"^/api/boards/(\d+)/notes$")
BOARD_ACTIVITY_RE = re.compile(r"^/api/boards/(\d+)/activity$")
NOTE_ID_RE = re.compile(r"^/api/notes/(\d+)$")
NOTE_STATUSES = ("open", "in_progress", "done")
# роли с правами администратора — как ADMIN_ROLES в server.py
# (оттуда импортировать нельзя: циклический импорт)
ADMIN_ROLES = ("owner", "admin")
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
TITLE_MAX = 200
BODY_MAX = 4000
ACTIVITY_LIMIT_MAX = 200
class NotesRoutes:
"""Хендлеры досок; подключаются к Handler в server.py."""
def _empty(self, code: int = 204):
self._drain_body() # NF-13: 204 без чтения тела ломал бы keep-alive
self.send_response(code)
self.send_header("Content-Length", "0")
self.end_headers()
# ---------------- доски ----------------
def _handle_boards_get(self, user: dict):
self._json({"boards": storage.boards_list(user)})
def _handle_board_create(self, user: dict):
if user["role"] != "owner":
self._json({"error": "owner only"}, 403)
return
body = self._read_body() or {}
title = clean_guest_text(str(body.get("title") or ""), TITLE_MAX).strip()
admin_login = str(body.get("admin_login") or "").strip()
if not title:
self._json({"error": "укажите название доски"}, 400)
return
users = {u["login"]: u for u in storage.list_users()}
admin = users.get(admin_login)
if admin is None or admin["role"] != "admin":
self._json({"error": "выберите администратора — участника доски"}, 400)
return
board = storage.board_create(title, admin_login, user["login"])
self._json({"board": board}, 201)
def _handle_board_patch(self, user: dict, board_id: int):
board = storage.board_guard(board_id, user)
if board is None:
self._json({"error": "not found"}, 404)
return
if user["role"] not in ADMIN_ROLES:
self._json({"error": "admin only"}, 403)
return
body = self._read_body() or {}
title = clean_guest_text(str(body.get("title") or ""), TITLE_MAX).strip()
if not title:
self._json({"error": "укажите название доски"}, 400)
return
self._json({"board": storage.board_rename(board_id, title)})
def _handle_board_delete(self, user: dict, board_id: int):
board = storage.board_guard(board_id, user)
if board is None:
self._json({"error": "not found"}, 404)
return
if user["role"] != "owner":
self._json({"error": "owner only"}, 403)
return
if not storage.board_delete(board_id):
self._json({"error": "not found"}, 404)
return
self._json({"ok": True})
# ---------------- заметки ----------------
def _handle_board_notes_get(self, user: dict, board_id: int):
if storage.board_guard(board_id, user) is None:
self._json({"error": "not found"}, 404)
return
since = None
raw_since = (parse_qs(urlsplit(self.path).query).get("since") or [""])[0]
if raw_since:
# '+' в зоне вида +05:00 при разборе query превращается в пробел
raw_since = raw_since.replace(" ", "+")
try:
since = datetime.fromisoformat(raw_since)
except ValueError:
self._json({"error": "bad since"}, 400)
return
self._json(storage.notes_list(board_id, since))
def _handle_note_create(self, user: dict, board_id: int):
board = storage.board_guard(board_id, user)
if board is None:
self._json({"error": "not found"}, 404)
return
body = self._read_body() or {}
title = clean_guest_text(str(body.get("title") or ""), TITLE_MAX).strip()
if not title:
self._json({"error": "укажите заголовок задачи"}, 400)
return
text = clean_guest_text(str(body.get("body") or ""), BODY_MAX)
important = bool(body.get("important"))
assignee = body.get("assignee")
if assignee is not None:
assignee = str(assignee).strip() or None
if assignee and not storage.note_assignee_ok(board, assignee):
self._json({"error": "исполнитель — не участник этой доски"}, 400)
return
due = None
if body.get("due_date"):
if not DATE_RE.match(str(body["due_date"])):
self._json({"error": "срок в формате ГГГГ-ММ-ДД"}, 400)
return
due = body["due_date"]
note = storage.note_create(board, user, title, text, assignee, important, due)
self._json({"note": note}, 201)
def _handle_note_patch(self, user: dict, note_id: int):
note = storage.note_get(note_id)
board = storage.board_guard(note["board_id"], user) if note else None
if note is None or board is None:
self._json({"error": "not found"}, 404)
return
body = self._read_body() or {}
if not body:
self._json({"error": "нечего изменять"}, 400)
return
fields: dict = {}
if "title" in body:
title = clean_guest_text(str(body["title"] or ""), TITLE_MAX).strip()
if not title:
self._json({"error": "укажите заголовок задачи"}, 400)
return
fields["title"] = title
if "body" in body:
fields["body"] = clean_guest_text(str(body["body"] or ""), BODY_MAX)
if "important" in body:
if not isinstance(body["important"], bool):
self._json({"error": "important (bool) required"}, 400)
return
fields["important"] = body["important"]
if "status" in body:
if body["status"] not in NOTE_STATUSES:
self._json({"error": "статус: open, in_progress или done"}, 400)
return
fields["status"] = body["status"]
if "due_date" in body:
if body["due_date"] is None:
fields["due_date"] = None
elif DATE_RE.match(str(body["due_date"])):
fields["due_date"] = str(body["due_date"])
else:
self._json({"error": "срок в формате ГГГГ-ММ-ДД"}, 400)
return
if "assignee" in body:
assignee = body["assignee"]
if assignee is not None:
assignee = str(assignee).strip() or None
if assignee and not storage.note_assignee_ok(board, assignee):
self._json({"error": "исполнитель — не участник этой доски"}, 400)
return
fields["assignee_login"] = assignee
updated = storage.note_update(note_id, user, board, fields)
if updated is None:
self._json({"error": "not found"}, 404)
return
self._json({"note": updated})
def _handle_note_delete(self, user: dict, note_id: int):
note = storage.note_get(note_id)
board = storage.board_guard(note["board_id"], user) if note else None
if note is None or board is None:
self._json({"error": "not found"}, 404)
return
if user["role"] not in ADMIN_ROLES and note["author"]["id"] != user["login"]:
self._json({"error": "удалять может автор, админ или владелец"}, 403)
return
if storage.note_delete(note_id, user["login"]) is None:
self._json({"error": "not found"}, 404)
return
self._empty(204)
# ---------------- журнал ----------------
def _handle_board_activity(self, user: dict, board_id: int):
if storage.board_guard(board_id, user) is None:
self._json({"error": "not found"}, 404)
return
qs = parse_qs(urlsplit(self.path).query)
try:
limit = min(ACTIVITY_LIMIT_MAX, max(1, int((qs.get("limit") or ["50"])[0])))
except ValueError:
limit = 50
self._json({"events": storage.board_activity(board_id, limit)})
+1
View File
@@ -0,0 +1 @@
psycopg[binary,pool]>=3.2,<4
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+238
View File
@@ -0,0 +1,238 @@
[
{
"seed_id": "fifa-25",
"kind": "ps5",
"title": "FIFA 25",
"note": "Футбольный симулятор · до 4 игроков",
"cover": "fifa25.jpg"
},
{
"seed_id": "ufc-5",
"kind": "ps5",
"title": "UFC 5",
"note": "Файтинг · до 2 игроков",
"cover": "ufc5.jpg"
},
{
"seed_id": "mortal-kombat-1",
"kind": "ps5",
"title": "Mortal Kombat 1",
"note": "Файтинг · до 2 игроков",
"cover": "mk1.jpg"
},
{
"seed_id": "move-or-die",
"kind": "ps5",
"title": "Move or Die",
"note": "Кооперативная аркада · до 4 игроков",
"cover": "mod.jpg"
},
{
"seed_id": "overcooked",
"kind": "ps5",
"title": "Overcooked",
"note": "Кооперативная аркада · до 4 игроков",
"cover": "over.jpg"
},
{
"seed_id": "god-of-war-ragnarok",
"kind": "ps5",
"title": "God of War Ragnarök",
"note": "Экшен-приключение · до 1 игрока",
"cover": "gowr.jpg"
},
{
"seed_id": "god-of-war",
"kind": "ps5",
"title": "God of War",
"note": "Экшен-приключение · до 1 игрока",
"cover": "gow.jpg"
},
{
"seed_id": "spider-man-remastered",
"kind": "ps5",
"title": "Marvel's Spider-Man Remastered",
"note": "Экшен-приключение · до 1 игрока",
"cover": "smr.jpg"
},
{
"seed_id": "board-mafia",
"kind": "board",
"title": "Мафия",
"note": "Компанейские · 6–16 игроков · 40–90 мин · простая · хит",
"cover": null,
"difficulty": 1,
"icon": "eye",
"accent": "#b026ff"
},
{
"seed_id": "board-bunker",
"kind": "board",
"title": "Бункер",
"note": "Компанейские · 4–16 игроков · 40–60 мин · простая · хит",
"cover": null,
"difficulty": 1,
"icon": "skull",
"accent": "#00f0ff"
},
{
"seed_id": "board-cards",
"kind": "board",
"title": "Игральные карты",
"note": "Карточные · 2–8 игроков · 10–60 мин · простая · хит",
"cover": null,
"difficulty": 1,
"icon": "spade",
"accent": "#ff4d6d"
},
{
"seed_id": "board-chess",
"kind": "board",
"title": "Шахматы",
"note": "Классика · 2–2 игроков · 20–60 мин · сложная",
"cover": null,
"difficulty": 3,
"icon": "crown",
"accent": "#e0e0e0"
},
{
"seed_id": "board-backgammon",
"kind": "board",
"title": "Нарды",
"note": "Классика · 2–2 игроков · 20–40 мин · средняя · хит",
"cover": null,
"difficulty": 2,
"icon": "dices",
"accent": "#f4a261"
},
{
"seed_id": "board-domino",
"kind": "board",
"title": "Домино",
"note": "Классика · 2–4 игроков · 15–30 мин · простая",
"cover": null,
"difficulty": 1,
"icon": "grid",
"accent": "#ffd23d"
},
{
"seed_id": "board-checkers",
"kind": "board",
"title": "Шашки",
"note": "Классика · 2–2 игроков · 10–30 мин · простая",
"cover": null,
"difficulty": 1,
"icon": "circle",
"accent": "#4dff88"
},
{
"seed_id": "board-uno",
"kind": "board",
"title": "UNO",
"note": "Карточные · 2–10 игроков · 15–30 мин · простая · хит",
"cover": null,
"difficulty": 1,
"icon": "hash",
"accent": "#ff3636"
},
{
"seed_id": "board-monopoly",
"kind": "board",
"title": "Монополия",
"note": "Стратегия · 2–6 игроков · 60–120 мин · средняя",
"cover": null,
"difficulty": 2,
"icon": "landmark",
"accent": "#2ec27e"
},
{
"seed_id": "board-jenga",
"kind": "board",
"title": "Дженга",
"note": "Компанейские · 2–8 игроков · 10–20 мин · простая",
"cover": null,
"difficulty": 1,
"icon": "layout",
"accent": "#d4af37"
},
{
"seed_id": "board-alias",
"kind": "board",
"title": "Alias",
"note": "Компанейские · 4–12 игроков · 30–60 мин · простая",
"cover": null,
"difficulty": 1,
"icon": "brain",
"accent": "#ff8a00"
},
{
"seed_id": "board-imaginarium",
"kind": "board",
"title": "Имаджинариум",
"note": "Компанейские · 4–7 игроков · 30–60 мин · простая",
"cover": null,
"difficulty": 1,
"icon": "puzzle",
"accent": "#c77dff"
},
{
"seed_id": "board-crocodile",
"kind": "board",
"title": "Крокодил",
"note": "Компанейские · 4–16 игроков · 20–60 мин · простая",
"cover": null,
"difficulty": 1,
"icon": "users",
"accent": "#00c2a8"
},
{
"seed_id": "board-poker",
"kind": "board",
"title": "Покерный набор",
"note": "Карточные · 2–9 игроков · 60–180 мин · средняя",
"cover": null,
"difficulty": 2,
"icon": "club",
"accent": "#1fa37a"
},
{
"seed_id": "board-exploding-kittens",
"kind": "board",
"title": "Взрывные котята",
"note": "Карточные · 2–5 игроков · 15 мин · простая",
"cover": null,
"difficulty": 1,
"icon": "ghost",
"accent": "#ff6b9d"
},
{
"seed_id": "board-catan",
"kind": "board",
"title": "Колонизаторы (Catan)",
"note": "Стратегия · 3–4 игроков · 60–90 мин · средняя",
"cover": null,
"difficulty": 2,
"icon": "castle",
"accent": "#e9c46a"
},
{
"seed_id": "board-dixit",
"kind": "board",
"title": "Диксит",
"note": "Компанейские · 3–6 игроков · 30 мин · простая",
"cover": null,
"difficulty": 1,
"icon": "heart",
"accent": "#f77f00"
},
{
"seed_id": "board-codenames",
"kind": "board",
"title": "Кодовые имена",
"note": "На логику · 4–8 игроков · 15–30 мин · средняя",
"cover": null,
"difficulty": 2,
"icon": "target",
"accent": "#4895ef"
}
]
+359
View File
@@ -0,0 +1,359 @@
[
{
"id": "blackberry-mint",
"name": "Ежевика-мята",
"category": "АВТОРСКИЙ ЧАЙ",
"price": 350,
"portion": "900 мл"
},
{
"id": "raspberry-mint",
"name": "Малина-мята",
"category": "АВТОРСКИЙ ЧАЙ",
"price": 350,
"portion": "900 мл"
},
{
"id": "seabuckthorn-orange",
"name": "Облепиха-апельсин",
"category": "АВТОРСКИЙ ЧАЙ",
"price": 350,
"portion": "900 мл"
},
{
"id": "seabuckthorn-cinnamon",
"name": "Облепиха-корица",
"category": "АВТОРСКИЙ ЧАЙ",
"price": 350,
"portion": "900 мл"
},
{
"id": "forest-berries",
"name": "Лесные ягоды",
"category": "АВТОРСКИЙ ЧАЙ",
"price": 350,
"portion": "900 мл"
},
{
"id": "mango-seabuckthorn",
"name": "Манго-облепиха",
"category": "АВТОРСКИЙ ЧАЙ",
"price": 350,
"portion": "900 мл"
},
{
"id": "raspberry-ginger",
"name": "Малина-имбирь",
"category": "АВТОРСКИЙ ЧАЙ",
"price": 350,
"portion": "900 мл"
},
{
"id": "bud",
"name": "Bud",
"category": "ПИВНАЯ КАРТА",
"price": 250,
"portion": "0,44 л"
},
{
"id": "kozel-dark",
"name": "Kozel темный",
"category": "ПИВНАЯ КАРТА",
"price": 220,
"portion": "0,45 л"
},
{
"id": "stary-melnik-light",
"name": "Старый мельник светлое",
"category": "ПИВНАЯ КАРТА",
"price": 200,
"portion": "0,45 л"
},
{
"id": "krone-blanche",
"name": "Krone Blanche",
"category": "ПИВНАЯ КАРТА",
"price": 220,
"portion": "0,45 л"
},
{
"id": "erzmann",
"name": "Erzmann",
"category": "ПИВНАЯ КАРТА",
"price": 220,
"portion": "0,45 л"
},
{
"id": "el-capulco",
"name": "El Capulco",
"category": "ПИВНАЯ КАРТА",
"price": 250,
"portion": "0,45 л"
},
{
"id": "stella",
"name": "Stella",
"category": "ПИВНАЯ КАРТА",
"price": 250,
"portion": "0,44 л"
},
{
"id": "kozel-light",
"name": "Kozel светлый",
"category": "ПИВНАЯ КАРТА",
"price": 220,
"portion": "0,45 л"
},
{
"id": "stary-melnik-dark",
"name": "Старый мельник темное",
"category": "ПИВНАЯ КАРТА",
"price": 200,
"portion": "1 шт"
},
{
"id": "pablos-lime",
"name": "Pablo's с соком лайма",
"category": "ПИВНАЯ КАРТА",
"price": 200,
"portion": "1 шт"
},
{
"id": "redds",
"name": "Redd's",
"category": "ПИВНАЯ КАРТА",
"price": 200,
"portion": "1 шт"
},
{
"id": "spaten",
"name": "Spatten",
"category": "ПИВНАЯ КАРТА",
"price": 250,
"portion": "0,45 л"
},
{
"id": "hoegaarden",
"name": "Hoegaarden",
"category": "ПИВНАЯ КАРТА",
"price": 220,
"portion": "0,45 л"
},
{
"id": "corona-extra",
"name": "Corona Extra",
"category": "ПИВНАЯ КАРТА",
"price": 250,
"portion": "1 шт"
},
{
"id": "onion-rings",
"name": "Луковые кольца",
"category": "ЗАКУСКИ",
"price": 220,
"portion": "8 шт"
},
{
"id": "fries",
"name": "Картофель Фри",
"category": "ЗАКУСКИ",
"price": 210,
"portion": "130 гр"
},
{
"id": "buffalo-wings",
"name": "Крылышки Баффало",
"category": "ЗАКУСКИ",
"price": 350,
"portion": "6 шт"
},
{
"id": "cheese-sticks",
"name": "Сырные палочки",
"category": "ЗАКУСКИ",
"price": 310,
"portion": "6 шт"
},
{
"id": "strips",
"name": "Стрипсы",
"category": "ЗАКУСКИ",
"price": 320,
"portion": "6 шт"
},
{
"id": "nuggets",
"name": "Нагетсы",
"category": "ЗАКУСКИ",
"price": 320,
"portion": "8 шт"
},
{
"id": "black-tea",
"name": "Черный чай",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 250,
"portion": "900 мл"
},
{
"id": "black-tea-thyme",
"name": "Черный чай с чабрецом",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 250,
"portion": "900 мл"
},
{
"id": "milk-oolong",
"name": "Молочный Улун",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 350,
"portion": "900 мл"
},
{
"id": "americano",
"name": "Американо",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 150,
"portion": "150 мл"
},
{
"id": "cappuccino",
"name": "Капучино",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 200,
"portion": "200 мл"
},
{
"id": "cheesecake-ny",
"name": "Чизкейк Нью-Йорк",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 250,
"portion": "120 гр"
},
{
"id": "red-velvet",
"name": "Торт Красный бархат",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 250,
"portion": "120 гр"
},
{
"id": "medovik",
"name": "Торт Медовик",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 250,
"portion": "120 гр"
},
{
"id": "cheesecake-strawberry",
"name": "Чизкейк Нью-Йорк клубничный",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 250,
"portion": "120 гр"
},
{
"id": "cheesecake-raspberry",
"name": "Чизкейк Нью-Йорк Малина",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 250,
"portion": "120 гр"
},
{
"id": "alpen-gold",
"name": "Alpen Gold",
"category": "СНЕКИ И СЛАДОСТИ",
"price": 210,
"portion": null
},
{
"id": "milka",
"name": "Milka",
"category": "СНЕКИ И СЛАДОСТИ",
"price": 230,
"portion": null
},
{
"id": "leys",
"name": "Чипсы Leys",
"category": "СНЕКИ И СЛАДОСТИ",
"price": 210,
"portion": "90 гр"
},
{
"id": "korochki",
"name": "3 Корочки",
"category": "СНЕКИ И СЛАДОСТИ",
"price": 150,
"portion": "60 гр"
},
{
"id": "hrustim",
"name": "Хрустим team",
"category": "СНЕКИ И СЛАДОСТИ",
"price": 100,
"portion": "40 гр"
},
{
"id": "natakhtari-pear",
"name": "НАТАХТАРИ ГРУША",
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
"price": 200,
"portion": "1 шт"
},
{
"id": "strawberry-kiwi",
"name": "Клубника Киви",
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
"price": 150,
"portion": "1 шт"
},
{
"id": "feijoa",
"name": "Фейхоа",
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
"price": 150,
"portion": "1 шт"
},
{
"id": "natakhtari-feijoa",
"name": "НАТАХТАРИ ФЕЙХОА",
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
"price": 200,
"portion": "1 шт"
},
{
"id": "natakhtari-saperavi",
"name": "НАТАХТАРИ САПЕРАВИ",
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
"price": 200,
"portion": "1 шт"
},
{
"id": "burn-original",
"name": "BURN Оригинальный",
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
"price": 250,
"portion": "1 шт"
},
{
"id": "adrenaline-rush",
"name": "Adrenaline RUSH Абсолютная ЭНЕРГИЯ",
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
"price": 250,
"portion": "1 шт"
},
{
"id": "burn-apple-kiwi",
"name": "BURN Яблоко Киви",
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
"price": 250,
"portion": "1 шт"
},
{
"id": "burn-mango",
"name": "BURN Со вкусом МАНГО",
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
"price": 250,
"portion": "1 шт"
}
]
+556
View File
@@ -0,0 +1,556 @@
[
{
"id": "showcase-1",
"name": "Кристальный лайм",
"note": "Лайм держит основу, Помело добавляет характер — свежесть до утра.",
"strength": 1,
"cooling": 0,
"flavor_tags": [
"Цитрус"
],
"price": "1 400 ₽",
"featured": true
},
{
"id": "showcase-2",
"name": "Морозная клубника",
"note": "Клубника держит основу, Лайм добавляет характер — свежесть до утра.",
"strength": 1,
"cooling": 0,
"flavor_tags": [
"Ягоды",
"Цитрус"
],
"price": "1 400 ₽",
"featured": true
},
{
"id": "showcase-3",
"name": "Морозное помело",
"note": "Помело держит основу, Лайм добавляет характер — прохлада с первой затяжки.",
"strength": 1,
"cooling": 2,
"flavor_tags": [
"Цитрус",
"лёд"
],
"price": "1 400 ₽",
"featured": true
},
{
"id": "showcase-4",
"name": "Морозная клубника · с холодком",
"note": "Клубника держит основу, Лайм добавляет характер — прохлада с первой затяжки.",
"strength": 1,
"cooling": 2,
"flavor_tags": [
"Ягоды",
"Цитрус",
"лёд"
],
"price": "1 400 ₽",
"featured": true
},
{
"id": "showcase-5",
"name": "Морозная груша",
"note": "Груша держит основу, Яблоко добавляет характер — свежесть до утра.",
"strength": 3,
"cooling": 0,
"flavor_tags": [
"Цитрус",
"Фрукты"
],
"price": "1 400 ₽",
"featured": true
},
{
"id": "showcase-6",
"name": "Кристальная клубника",
"note": "Клубника держит основу, Груша добавляет характер — ледяной и чистый финиш.",
"strength": 3,
"cooling": 0,
"flavor_tags": [
"Ягоды",
"Цитрус",
"Фрукты"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-7",
"name": "Морозная груша · с холодком",
"note": "Груша держит основу, Клубника добавляет характер — свежесть до утра.",
"strength": 3,
"cooling": 2,
"flavor_tags": [
"Цитрус",
"Ягоды",
"лёд"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-8",
"name": "Полярное яблоко",
"note": "Яблоко держит основу, Клубника добавляет характер — прохлада с первой затяжки.",
"strength": 3,
"cooling": 2,
"flavor_tags": [
"Фрукты",
"Ягоды",
"лёд"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-9",
"name": "Полярная голубика",
"note": "Голубика держит основу, Фисташковое мороженое добавляет характер — свежесть до утра.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Ягоды",
"Десерты",
"Пряности"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-10",
"name": "Кристальная медовая дыня",
"note": "Медовая дыня держит основу, Ультракислый лимон добавляет характер — свежесть до утра.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Фрукты",
"Цитрус"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-11",
"name": "Ледяной ультракислый лимон",
"note": "Ультракислый лимон держит основу, Медовая дыня добавляет характер — ледяной и чистый финиш.",
"strength": 5,
"cooling": 2,
"flavor_tags": [
"Цитрус",
"Фрукты",
"лёд"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-12",
"name": "Ледяная медовая дыня",
"note": "Медовая дыня держит основу, Сочный ананас добавляет характер — свежесть до утра.",
"strength": 5,
"cooling": 2,
"flavor_tags": [
"Фрукты",
"Тропики",
"лёд"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-13",
"name": "Сливочная манго-ромашка",
"note": "Манго-ромашка держит основу, Помело добавляет характер — мягкий десертный дым.",
"strength": 1,
"cooling": 0,
"flavor_tags": [
"Тропики",
"Цитрус",
"Ягоды"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-14",
"name": "Сливочная клубника",
"note": "Клубника держит основу, Помело добавляет характер — мягкий десертный дым.",
"strength": 1,
"cooling": 0,
"flavor_tags": [
"Ягоды",
"Цитрус"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-15",
"name": "Сливочная клубника · с холодком",
"note": "Клубника держит основу, Помело добавляет характер — мягкий десертный дым.",
"strength": 1,
"cooling": 2,
"flavor_tags": [
"Ягоды",
"Цитрус",
"лёд"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-16",
"name": "Бархатная манго-ромашка",
"note": "Манго-ромашка держит основу, Лайм добавляет характер — мягкий десертный дым.",
"strength": 1,
"cooling": 2,
"flavor_tags": [
"Тропики",
"Цитрус",
"лёд"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-17",
"name": "Бархатная клубника",
"note": "Клубника держит основу, Груша добавляет характер — мягкий десертный дым.",
"strength": 3,
"cooling": 0,
"flavor_tags": [
"Ягоды",
"Цитрус"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-18",
"name": "Вельветовое печенье",
"note": "Печенье держит основу, Яблоко добавляет характер — для долгих разговоров.",
"strength": 3,
"cooling": 0,
"flavor_tags": [
"Десерты",
"Фрукты"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-19",
"name": "Вельветовое печенье · с холодком",
"note": "Печенье держит основу, Клубника добавляет характер — мягкий десертный дым.",
"strength": 3,
"cooling": 2,
"flavor_tags": [
"Десерты",
"Ягоды",
"лёд"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-20",
"name": "Ночной шоколадный брауни",
"note": "Шоколадный брауни держит основу, Базилик добавляет характер — густое сладкое послевкусие.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Десерты",
"Пряности"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-21",
"name": "Сливочный сочный ананас",
"note": "Сочный ананас держит основу, Медовая дыня добавляет характер — для долгих разговоров.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Тропики",
"Фрукты",
"Пряности"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-22",
"name": "Вельветовый шоколадный брауни",
"note": "Шоколадный брауни держит основу, Фисташковое мороженое добавляет характер — мягкий десертный дым.",
"strength": 5,
"cooling": 2,
"flavor_tags": [
"Десерты",
"лёд"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-23",
"name": "Вельветовый банан",
"note": "Банан держит основу, Фисташковое мороженое добавляет характер — для долгих разговоров.",
"strength": 5,
"cooling": 2,
"flavor_tags": [
"Тропики",
"Десерты",
"лёд"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-24",
"name": "Солнечная манго-ромашка · клубника",
"note": "Манго-ромашка держит основу, Клубника добавляет характер — волна спелых фруктов.",
"strength": 1,
"cooling": 0,
"flavor_tags": [
"Тропики",
"Ягоды",
"Цитрус"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-25",
"name": "Закатная манго-ромашка",
"note": "Манго-ромашка держит основу, Лайм добавляет характер — как закат у бара.",
"strength": 1,
"cooling": 0,
"flavor_tags": [
"Тропики",
"Цитрус"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-26",
"name": "Тропическая манго-ромашка · клубника",
"note": "Манго-ромашка держит основу, Клубника добавляет характер — волна спелых фруктов.",
"strength": 1,
"cooling": 2,
"flavor_tags": [
"Тропики",
"Ягоды",
"лёд"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-27",
"name": "Тропическая манго-ромашка · помело",
"note": "Манго-ромашка держит основу, Помело добавляет характер — волна спелых фруктов.",
"strength": 1,
"cooling": 2,
"flavor_tags": [
"Тропики",
"Цитрус",
"лёд"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-28",
"name": "Закатное яблоко",
"note": "Яблоко держит основу, Клубника добавляет характер — волна спелых фруктов.",
"strength": 3,
"cooling": 0,
"flavor_tags": [
"Фрукты",
"Ягоды"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-29",
"name": "Закатное яблоко · с холодком",
"note": "Яблоко держит основу, Груша добавляет характер — волна спелых фруктов.",
"strength": 3,
"cooling": 2,
"flavor_tags": [
"Фрукты",
"Цитрус",
"лёд"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-30",
"name": "Тропическое сочное манго",
"note": "Сочное манго держит основу, Базилик добавляет характер — волна спелых фруктов.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Тропики",
"Пряности",
"Ягоды"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-31",
"name": "Спелое сочное манго",
"note": "Сочное манго держит основу, Медовая дыня добавляет характер — сочный тропический характер.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Тропики",
"Фрукты",
"Цитрус"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-32",
"name": "Спелый сочный ананас",
"note": "Сочный ананас держит основу, Фисташковое мороженое добавляет характер — сочный тропический характер.",
"strength": 5,
"cooling": 2,
"flavor_tags": [
"Тропики",
"Десерты",
"лёд"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-33",
"name": "Солнечное сочное манго",
"note": "Сочное манго держит основу, Фисташковое мороженое добавляет характер — как закат у бара.",
"strength": 5,
"cooling": 2,
"flavor_tags": [
"Тропики",
"Десерты",
"лёд"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-34",
"name": "Ягодная клубника",
"note": "Клубника держит основу, Лайм добавляет характер — сочная ягодная кислинка.",
"strength": 1,
"cooling": 0,
"flavor_tags": [
"Ягоды",
"Цитрус"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-35",
"name": "Полночный виноград",
"note": "Белый виноград держит основу, Голубика добавляет характер — сочная ягодная кислинка.",
"strength": 1,
"cooling": 2,
"flavor_tags": [
"Ягоды"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-36",
"name": "Ягодная голубика",
"note": "Голубика держит основу, Медовая дыня добавляет характер — спелые ягоды в каждом вдохе.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Ягоды",
"Фрукты"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-37",
"name": "Ягодный белый виноград",
"note": "Белый виноград держит основу, Сочный ананас добавляет характер — сочная ягодная кислинка.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Ягоды",
"Тропики"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-38",
"name": "Пурпурный белый виноград",
"note": "Белый виноград держит основу, Голубика добавляет характер — спелые ягоды в каждом вдохе.",
"strength": 5,
"cooling": 2,
"flavor_tags": [
"Ягоды",
"лёд"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-39",
"name": "Ягодная голубика · с холодком",
"note": "Голубика держит основу, Медовая дыня добавляет характер — яркая ягодная волна.",
"strength": 5,
"cooling": 2,
"flavor_tags": [
"Ягоды",
"Фрукты",
"лёд"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-40",
"name": "Искрящийся ультракислый лимон",
"note": "Ультракислый лимон держит основу, Базилик добавляет характер — звонкая кислинка на выдохе.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Цитрус",
"Пряности"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-41",
"name": "Искрящийся ультракислый лимон · медовая дыня",
"note": "Ультракислый лимон держит основу, Медовая дыня добавляет характер — бодрая цитрусовая искра.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Цитрус",
"Фрукты",
"Ягоды"
],
"price": "1 800 ₽",
"featured": false
}
]
+706
View File
@@ -0,0 +1,706 @@
[
{
"id": "add-north",
"brand": "Добавка",
"flavor": "Север",
"note": "Бустер",
"strength": 1,
"cooling": true
},
{
"id": "add-cooling",
"brand": "Добавка",
"flavor": "Холодок",
"note": null,
"strength": 1,
"cooling": true
},
{
"id": "sarma-drunk-pear",
"brand": "Сарма",
"flavor": "Дачная груша",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "sarma-corn-paprika",
"brand": "Сарма",
"flavor": "Кукуруза-паприка",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "north-elderberry",
"brand": "Северный",
"flavor": "Бузина",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "north-feijoa",
"brand": "Северный",
"flavor": "Финдеперцовый фейхоа",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "north-flower",
"brand": "Северный",
"flavor": "Цветочный",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "north-sorbet",
"brand": "Северный",
"flavor": "Sorbet",
"note": "Фруктовый",
"strength": 3,
"cooling": false
},
{
"id": "hooligan-flash",
"brand": "Хулиган",
"flavor": "«Флеш»",
"note": "Медовая малина",
"strength": 2,
"cooling": false
},
{
"id": "hooligan-grapefruit",
"brand": "Хулиган",
"flavor": "Грейпфрутовый сок",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "wood-berry-ninja",
"brand": "Хулиган / WOOD",
"flavor": "Берри Ниндзя",
"note": "Лесные ягоды",
"strength": 2,
"cooling": false
},
{
"id": "wood-begemot",
"brand": "Хулиган / WOOD",
"flavor": "Begemot",
"note": "Бергамот-мандарин",
"strength": 2,
"cooling": false
},
{
"id": "wood-boo",
"brand": "Хулиган / WOOD",
"flavor": "Boo",
"note": "Яблоко-гранат",
"strength": 2,
"cooling": false
},
{
"id": "wood-old",
"brand": "Хулиган / WOOD",
"flavor": "Old",
"note": "Лимон с мёдом",
"strength": 2,
"cooling": false
},
{
"id": "wood-sir-hard",
"brand": "Хулиган / WOOD",
"flavor": "Sir Hard",
"note": "Воздушный рис",
"strength": 3,
"cooling": false
},
{
"id": "banger-black-white",
"brand": "Banger",
"flavor": "Black and White",
"note": "Печенье Oreo",
"strength": 2,
"cooling": false
},
{
"id": "banger-cola-bella",
"brand": "Banger",
"flavor": "Cola Bella",
"note": "Ванильная кола",
"strength": 2,
"cooling": false
},
{
"id": "banger-cream-soda",
"brand": "Banger",
"flavor": "Cream Soda",
"note": "Крем-сода",
"strength": 2,
"cooling": false
},
{
"id": "banger-grapefruit",
"brand": "Banger",
"flavor": "Grapefruit",
"note": "Грейпфрут",
"strength": 2,
"cooling": false
},
{
"id": "banger-green-day",
"brand": "Banger",
"flavor": "Green Day",
"note": "Яблоко, киви, кислинка",
"strength": 2,
"cooling": false
},
{
"id": "banger-sexy",
"brand": "Banger",
"flavor": "Sexy",
"note": "Грейпфрут, клубника, малина",
"strength": 2,
"cooling": false
},
{
"id": "bb-brownie",
"brand": "Blackburn",
"flavor": "Брауни",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-grape-lollipop",
"brand": "Blackburn",
"flavor": "Виноградный чупа-чупс",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-fir",
"brand": "Blackburn",
"flavor": "Ёлка",
"note": null,
"strength": 3,
"cooling": true
},
{
"id": "bb-cola",
"brand": "Blackburn",
"flavor": "Классическая кола",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-red-orange",
"brand": "Blackburn",
"flavor": "Красный апельсин",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-forest-raspberry",
"brand": "Blackburn",
"flavor": "Лесная малина",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-honey-melon",
"brand": "Blackburn",
"flavor": "Медовая дыня",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-juicy-mango",
"brand": "Blackburn",
"flavor": "Сочное манго",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-berry-sorbet",
"brand": "Blackburn",
"flavor": "Ягодный сорбет",
"note": "С грейпфрутом",
"strength": 3,
"cooling": false
},
{
"id": "bb-after-8",
"brand": "Blackburn",
"flavor": "After 8",
"note": "Шоколад-мята",
"strength": 3,
"cooling": true
},
{
"id": "bb-ananas-shock",
"brand": "Blackburn",
"flavor": "Ananas Shock",
"note": "Кислый ананас",
"strength": 3,
"cooling": false
},
{
"id": "bb-apple-shock",
"brand": "Blackburn",
"flavor": "Apple Shock",
"note": "Кислое зелёное яблоко",
"strength": 3,
"cooling": false
},
{
"id": "bb-bananini",
"brand": "Blackburn",
"flavor": "Bananini",
"note": "Банан",
"strength": 3,
"cooling": false
},
{
"id": "bb-basilic",
"brand": "Blackburn",
"flavor": "Basilic",
"note": "Базилик",
"strength": 3,
"cooling": false
},
{
"id": "bb-blackberry-lemonade",
"brand": "Blackburn",
"flavor": "Blackberry Lemonade",
"note": "Ежевичный лимонад",
"strength": 3,
"cooling": false
},
{
"id": "bb-blueberry",
"brand": "Blackburn",
"flavor": "Blueberry",
"note": "Голубика",
"strength": 3,
"cooling": false
},
{
"id": "bb-kiwi-stoner",
"brand": "Blackburn",
"flavor": "Kiwi Stoner",
"note": "Смузи из киви",
"strength": 3,
"cooling": false
},
{
"id": "bb-lemon-shock",
"brand": "Blackburn",
"flavor": "Lemon Shock",
"note": "Ультракислый лимон",
"strength": 3,
"cooling": false
},
{
"id": "bb-lulo",
"brand": "Blackburn",
"flavor": "Lulo",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-overdose",
"brand": "Blackburn",
"flavor": "Overdose",
"note": "Лимон-лайм",
"strength": 3,
"cooling": false
},
{
"id": "bb-pear-lemonade",
"brand": "Blackburn",
"flavor": "Pear Lemonade",
"note": "Грушевый лимонад",
"strength": 3,
"cooling": false
},
{
"id": "bb-pina-colada",
"brand": "Blackburn",
"flavor": "Pina Colada",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-pineapple",
"brand": "Blackburn",
"flavor": "Pineapple",
"note": "Сочный ананас",
"strength": 3,
"cooling": false
},
{
"id": "bb-pistachio-ice",
"brand": "Blackburn",
"flavor": "Pistachio Ice Snow",
"note": "Фисташковое мороженое",
"strength": 3,
"cooling": true
},
{
"id": "bb-pomelo",
"brand": "Blackburn",
"flavor": "Pomelo",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-real-pf",
"brand": "Blackburn",
"flavor": "Real P.F.",
"note": "Маракуйя",
"strength": 3,
"cooling": false
},
{
"id": "bb-red-energy",
"brand": "Blackburn",
"flavor": "Red Energy",
"note": "Энергетик",
"strength": 3,
"cooling": false
},
{
"id": "bb-white-grape",
"brand": "Blackburn",
"flavor": "White Grape",
"note": "Белый виноград",
"strength": 3,
"cooling": false
},
{
"id": "bliss-strawberry",
"brand": "Bliss",
"flavor": "Клубника",
"note": null,
"strength": 1,
"cooling": false
},
{
"id": "bliss-lime",
"brand": "Bliss",
"flavor": "Лайм",
"note": null,
"strength": 1,
"cooling": false
},
{
"id": "bliss-mango-chamomile",
"brand": "Bliss",
"flavor": "Манго-ромашка",
"note": null,
"strength": 1,
"cooling": false
},
{
"id": "bliss-peach-yogurt",
"brand": "Bliss",
"flavor": "Персик-йогурт",
"note": null,
"strength": 1,
"cooling": false
},
{
"id": "bliss-love-shot",
"brand": "Bliss",
"flavor": "Love Shot",
"note": "Маракуйя, арбуз, лимон, холодок",
"strength": 1,
"cooling": true
},
{
"id": "chabacco-strawberry-milksake",
"brand": "Chabacco",
"flavor": "Mix",
"note": "Клубничный милкшейк",
"strength": 2,
"cooling": false
},
{
"id": "ds-alpine-trip",
"brand": "Darkside",
"flavor": "Алтайский трип",
"note": "Хвоя, фейхоа, эвкалипт",
"strength": 2,
"cooling": true
},
{
"id": "deus-base",
"brand": "DEUS",
"flavor": "Base",
"note": "Табачный лист",
"strength": 2,
"cooling": false
},
{
"id": "deus-blackberry-wine",
"brand": "DEUS",
"flavor": "Blackberry Wine",
"note": "Ежевичное вино",
"strength": 2,
"cooling": false
},
{
"id": "deus-blueberry-yogurt",
"brand": "DEUS",
"flavor": "Blueberry Yogurt",
"note": "Черничный йогурт",
"strength": 2,
"cooling": false
},
{
"id": "deus-champagne",
"brand": "DEUS",
"flavor": "Champagne",
"note": "Игристое вино",
"strength": 2,
"cooling": false
},
{
"id": "deus-cherry-jelly",
"brand": "DEUS",
"flavor": "Cherry Jelly",
"note": "Вишнёвое желе",
"strength": 2,
"cooling": false
},
{
"id": "deus-guinness",
"brand": "DEUS",
"flavor": "Guinness",
"note": "Тёмное пиво",
"strength": 2,
"cooling": false
},
{
"id": "deus-peonies",
"brand": "DEUS",
"flavor": "Peonies",
"note": "Пионы",
"strength": 2,
"cooling": false
},
{
"id": "deus-pumpkin-pie",
"brand": "DEUS",
"flavor": "Pumpkin Pie",
"note": "Тыквенный пирог с миндалём",
"strength": 2,
"cooling": false
},
{
"id": "deus-violet-zephyr",
"brand": "DEUS",
"flavor": "Violet Zephyr",
"note": "Фиалковый зефир",
"strength": 2,
"cooling": false
},
{
"id": "hupreme-guava",
"brand": "Hupreme",
"flavor": "Гуава",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "hupreme-gooseberry",
"brand": "Hupreme",
"flavor": "Крыжовник",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "hupreme-milk-tea",
"brand": "Hupreme",
"flavor": "Чай с молоком",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "jent-blue-agave",
"brand": "JENT Alcohol Line",
"flavor": "Blue Agave",
"note": "«Текила Бум»",
"strength": 2,
"cooling": false
},
{
"id": "jent-cuba-libre",
"brand": "JENT Alcohol Line",
"flavor": "Cuba Libre",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "jent-havana",
"brand": "JENT Alcohol Line",
"flavor": "Havana 290",
"note": "Мохито",
"strength": 2,
"cooling": true
},
{
"id": "joy-kiwichanga",
"brand": "Joy Kiwichanga",
"flavor": "Киви, фейхоа",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "muassel-cinnamon-gum",
"brand": "Muassel",
"flavor": "Жвачка с корицей",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "od-black-banano",
"brand": "Overdose",
"flavor": "Black Banano",
"note": "Амбровый банан",
"strength": 3,
"cooling": false
},
{
"id": "od-frustyle",
"brand": "Overdose",
"flavor": "Frustyle",
"note": "Кактус-лайм",
"strength": 3,
"cooling": false
},
{
"id": "od-gin-cucumber",
"brand": "Overdose",
"flavor": "Gin Cucumber",
"note": "Огуречный джин",
"strength": 3,
"cooling": false
},
{
"id": "od-lost-futura",
"brand": "Overdose",
"flavor": "Lost Futura",
"note": "Мускусная вишня",
"strength": 3,
"cooling": false
},
{
"id": "uc-pear-tarragon",
"brand": "Undercoal",
"flavor": "Груша, лимон и тархун",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "uc-cactus-kiwi",
"brand": "Undercoal",
"flavor": "Кактус, киви и абсент",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "uc-cranberry",
"brand": "Undercoal",
"flavor": "Клюква",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "uc-coconut-shake",
"brand": "Undercoal",
"flavor": "Кокосовый шейк",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "uc-mango-rose",
"brand": "Undercoal",
"flavor": "Манго, маракуйя, личи и роза",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "uc-tropical-juice",
"brand": "Undercoal",
"flavor": "Тропический сок",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "uc-milky-oolong",
"brand": "Undercoal",
"flavor": "Чай «Молочный улун»",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "uc-caribbean-rum",
"brand": "Undercoal",
"flavor": "Caribbean Rum",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "uc-cinnamon-roll",
"brand": "Undercoal",
"flavor": "Cinnamon Roll",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "uc-cookie",
"brand": "Undercoal",
"flavor": "Cookie",
"note": "Печенье",
"strength": 2,
"cooling": false
},
{
"id": "uc-pinkman",
"brand": "Undercoal",
"flavor": "Pinkman",
"note": "Клубника, малина, грейпфрут",
"strength": 2,
"cooling": false
}
]
+2503
View File
File diff suppressed because it is too large. Load diff
+62
View File
@@ -0,0 +1,62 @@
"""Разовая утилита: задать ВСЕМ действующим учёткам CRM один пароль.
Использование:
python set_all_passwords.py neon2026
Обновляет dev-базу SQLite (crm/data/crm.db) и, если контейнер neon-db
запущен, базу PostgreSQL в docker-стеке. Только для тестового стенда:
на живом сервере пароли меняют в панели (свой — кнопка 🔑, чужой —
владелец в разделе «Сотрудники»). После смены пароля в .env не забудь:
он действует только при ПЕРВОМ создании учётки.
"""
import hashlib
import secrets
import sqlite3
import subprocess
import sys
from pathlib import Path
def hash_password(password: str) -> str:
# тот же формат, что storage.hash_password: pbkdf2_sha256$iter$salt$hex
salt = secrets.token_hex(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), 200_000)
return f"pbkdf2_sha256$200000${salt}${digest.hex()}"
def main() -> None:
if len(sys.argv) != 2 or len(sys.argv[1]) < 8:
print("Использование: python set_all_passwords.py <пароль-минимум-8-символов>")
sys.exit(2)
password = sys.argv[1]
new_hash = hash_password(password)
# 1) dev-база SQLite рядом со скриптом
sqlite_path = Path(__file__).resolve().parent / "data" / "crm.db"
if sqlite_path.exists():
with sqlite3.connect(sqlite_path) as conn:
cur = conn.execute(
"UPDATE crm_users SET password_hash = ? WHERE NOT deleted", (new_hash,)
)
print(f"SQLite ({sqlite_path.name}): пароль изменён у {cur.rowcount} учёток")
else:
print(f"SQLite не найдена ({sqlite_path}) — пропускаю")
# 2) PostgreSQL в docker-стеке (контейнер neon-db)
sql = f"UPDATE crm_users SET password_hash = '{new_hash}' WHERE NOT deleted;"
try:
proc = subprocess.run(
["docker", "exec", "neon-db", "psql", "-U", "neon", "-d", "neon_crm", "-c", sql],
capture_output=True, text=True, timeout=30,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
print("docker или контейнер neon-db недоступен — postgres-базу не трогал")
return
if proc.returncode == 0:
print("PostgreSQL (neon-db): пароль изменён у всех действующих учёток")
else:
print(f"postgres UPDATE не прошёл:\n{proc.stderr.strip()}")
if __name__ == "__main__":
main()
+3164
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff