2504 lines
126 KiB
Python
2504 lines
126 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
НЕОН CRM — HTTP-сервер (stdlib) + REST API поверх PostgreSQL.
|
||
|
||
Публичные эндпоинты (для сайта neon-bar86.ru):
|
||
POST /api/orders — новый заказ из корзины сайта
|
||
GET /api/stoplist — стоп-лист (позиции меню и бренды табака)
|
||
GET /api/catalog — позиции, добавленные владельцем («новинки»)
|
||
GET /api/track/<код> — статус заказа по коду отслеживания
|
||
GET /api/healthz — проверка живости для Docker
|
||
|
||
Остальное требует сессию (X-Auth-Token из POST /api/login).
|
||
Запуск: python server.py (порт из CRM_PORT, по умолчанию 47613)
|
||
"""
|
||
|
||
import glob
|
||
import io
|
||
import ipaddress
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
import threading
|
||
import time
|
||
from collections import defaultdict, deque
|
||
from datetime import date, datetime, time as dtime, timedelta, timezone
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from pathlib import Path
|
||
from urllib.parse import parse_qs, unquote, urlsplit
|
||
|
||
import notes_api
|
||
import storage
|
||
from storage import clean_guest_text # гигиена текста — общая с notes_api (NF-14)
|
||
|
||
ROOT = Path(__file__).resolve().parent
|
||
PORT = int(os.environ.get("CRM_PORT", "47613"))
|
||
|
||
ORDER_RE = re.compile(r"^/api/orders/(\d+)$")
|
||
USER_RE = re.compile(r"^/api/users/([A-Za-z0-9_.-]+)$")
|
||
MIX_RE = re.compile(r"^/api/mixes/([A-Za-z0-9_.-]+)$")
|
||
MIX_SITE_RE = re.compile(r"^/api/mixes-site/([A-Za-z0-9_.-]+)$")
|
||
MENU_PHOTO_RE = re.compile(r"^/api/menu/([A-Za-z0-9_.:%-]+)/photo$")
|
||
TRACK_RE = re.compile(r"^/api/track/([A-Za-z0-9]+)$")
|
||
TRACK_CANCEL_RE = re.compile(r"^/api/track/([A-Za-z0-9]+)/cancel$")
|
||
# сколько минут гость может отменить случайный заказ по коду (пока «Новый»)
|
||
GUEST_CANCEL_MINUTES = 10
|
||
HALL_RES_RE = re.compile(r"^/api/halls/reservations/(\d+)$")
|
||
HALL_DEF_RE = re.compile(r"^/api/halls/([^/]+)$")
|
||
RESERVE_TIME_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
|
||
# id позиции меню и id вкуса табака — ASCII-слаги сайта; unquote ниже оставлен
|
||
# на случай кириллических id (например, старых брендов в ссылках)
|
||
MENU_RE = re.compile(r"^/api/menu/([A-Za-z0-9_.:%-]+)$")
|
||
TOBACCO_RE = re.compile(r"^/api/tobacco/([^/]+)$")
|
||
GAME_RE = re.compile(r"^/api/games/(\d+)$")
|
||
GAME_PHOTO_RE = re.compile(r"^/api/games/(\d+)/photo$")
|
||
GAME_KINDS = ("ps5", "board")
|
||
# логотипы настолок — ключи иконок каталога сайта (BoardGamesPage.iconMap)
|
||
GAME_ICONS = ("eye", "skull", "spade", "crown", "dices", "grid", "circle", "hash",
|
||
"landmark", "layout", "brain", "puzzle", "users", "club", "ghost",
|
||
"castle", "heart", "target")
|
||
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||
ORDER_NUM_RESET_RE = re.compile(r"^/api/orders/numbering/reset$")
|
||
# доски заметок — маршруты и хендлеры в notes_api.py
|
||
BOARDS_RE = notes_api.BOARD_ID_RE
|
||
BOARD_NOTES_RE = notes_api.BOARD_NOTES_RE
|
||
BOARD_ACTIVITY_RE = notes_api.BOARD_ACTIVITY_RE
|
||
NOTE_RE = notes_api.NOTE_ID_RE
|
||
|
||
# Роли с правами администратора (владелец умеет всё то же и больше)
|
||
ADMIN_ROLES = ("owner", "admin")
|
||
STAFF_ROLES = ("owner", "admin", "waiter", "hookah")
|
||
# роли смены: выходят на смену и управляют заказами только на смене
|
||
SHIFT_ROLES = ("waiter", "hookah")
|
||
# миксами для сайта ведают владелец и кальянщик (полный цикл: просмотр,
|
||
# создание, удаление)
|
||
MIX_ROLES = ("owner", "hookah")
|
||
CREATABLE_ROLES = ("waiter", "hookah", "admin") # кого владелец может создавать
|
||
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||
LOGIN_RE = re.compile(r"^[A-Za-z0-9_.-]{3,40}$")
|
||
PHOTO_MAX_BYTES = 6 * 1024 * 1024
|
||
|
||
|
||
def image_extension(data: bytes) -> str | None:
|
||
"""Определяем реальный формат по сигнатуре — доверять клиенту нельзя."""
|
||
if len(data) < 12:
|
||
return None
|
||
if data[:3] == b"\xff\xd8\xff":
|
||
return "jpg"
|
||
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
||
return "png"
|
||
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||
return "webp"
|
||
if data[4:8] == b"ftyp": # ISO-BMFF: avif/heic
|
||
return "avif"
|
||
if data[:3] == b"GIF":
|
||
return "gif"
|
||
return None
|
||
|
||
|
||
def _xlsx_bytes(sheets: list[dict]) -> bytes:
|
||
"""Простой xlsx на stdlib (zip + XML), без внешних зависимостей и без
|
||
оформления (по решению владельца): sheets — [{name, rows}]; строка —
|
||
{cells: [...]}. Первая строка каждого листа — жирная шапка."""
|
||
import zipfile
|
||
from xml.sax.saxutils import escape
|
||
|
||
def col_letter(n: int) -> str: # 1 -> A, 27 -> AA
|
||
s = ""
|
||
while n:
|
||
n, r = divmod(n - 1, 26)
|
||
s = chr(65 + r) + s
|
||
return s
|
||
|
||
def cell(ref: str, value, bold: bool) -> str:
|
||
if value is None or value == "":
|
||
return ""
|
||
style = ' s="1"' if bold else ""
|
||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||
return f'<c r="{ref}"{style}><v>{value}</v></c>'
|
||
text = str(value)
|
||
# защита от formula injection: текст гостя, начинающийся с =+-@,
|
||
# Excel исполнил бы как формулу при открытии отчёта — гасим апострофом
|
||
if text[0] in "=+-@":
|
||
text = "'" + text
|
||
return f'<c r="{ref}" t="inlineStr"{style}><is><t>{escape(text)}</t></is></c>'
|
||
|
||
sheet_parts: list[dict] = []
|
||
for sheet in sheets:
|
||
rows = sheet["rows"]
|
||
n_cols = max((len(r.get("cells", [])) for r in rows), default=1)
|
||
cols = "".join(
|
||
f'<col min="{i}" max="{i}" width="26" customWidth="1"/>'
|
||
for i in range(1, n_cols + 1)
|
||
)
|
||
data_rows: list[str] = []
|
||
for ri, row in enumerate(rows, 1):
|
||
cells = "".join(
|
||
cell(f"{col_letter(ci)}{ri}", v, ri == 1)
|
||
for ci, v in enumerate(row.get("cells", []), 1)
|
||
)
|
||
data_rows.append(f'<row r="{ri}">{cells}</row>')
|
||
sheet_parts.append({
|
||
"name": sheet["name"],
|
||
"xml": ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||
f"<cols>{cols}</cols>"
|
||
f'<sheetData>{"".join(data_rows)}</sheetData></worksheet>'),
|
||
})
|
||
|
||
ns_main = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||
ns_rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||
ns_pack = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||
|
||
workbook = (
|
||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||
f'<workbook xmlns="{ns_main}" xmlns:r="{ns_rel}"><sheets>'
|
||
+ "".join(
|
||
f'<sheet name="{escape(s["name"])}" sheetId="{i + 1}" r:id="rId{i + 1}"/>'
|
||
for i, s in enumerate(sheet_parts)
|
||
)
|
||
+ "</sheets></workbook>"
|
||
)
|
||
workbook_rels = (
|
||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||
f'<Relationships xmlns="{ns_pack}">'
|
||
+ "".join(
|
||
f'<Relationship Id="rId{i + 1}" Type="{ns_rel}/worksheet"'
|
||
f' Target="worksheets/sheet{i + 1}.xml"/>'
|
||
for i in range(len(sheet_parts))
|
||
)
|
||
+ f'<Relationship Id="rId{len(sheet_parts) + 1}" Type="{ns_rel}/styles" Target="styles.xml"/>'
|
||
+ "</Relationships>"
|
||
)
|
||
content_types = (
|
||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||
'<Default Extension="xml" ContentType="application/xml"/>'
|
||
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
||
+ "".join(
|
||
f'<Override PartName="/xl/worksheets/sheet{i + 1}.xml"'
|
||
' ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
||
for i in range(len(sheet_parts))
|
||
)
|
||
+ '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
|
||
+ "</Types>"
|
||
)
|
||
root_rels = (
|
||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||
f'<Relationships xmlns="{ns_pack}">'
|
||
f'<Relationship Id="rId1" Type="{ns_rel}/officeDocument" Target="xl/workbook.xml"/>'
|
||
"</Relationships>"
|
||
)
|
||
styles = (
|
||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||
f'<styleSheet xmlns="{ns_main}">'
|
||
'<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font>'
|
||
'<font><b/><sz val="11"/><name val="Calibri"/></font></fonts>'
|
||
'<fills count="1"><fill><patternFill patternType="none"/></fill></fills>'
|
||
'<borders count="1"><border/></borders>'
|
||
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
|
||
'<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
|
||
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>'
|
||
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
|
||
"</styleSheet>"
|
||
)
|
||
|
||
buf = io.BytesIO()
|
||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||
z.writestr("[Content_Types].xml", content_types)
|
||
z.writestr("_rels/.rels", root_rels)
|
||
z.writestr("xl/workbook.xml", workbook)
|
||
z.writestr("xl/_rels/workbook.xml.rels", workbook_rels)
|
||
z.writestr("xl/styles.xml", styles)
|
||
for i, s in enumerate(sheet_parts):
|
||
z.writestr(f"xl/worksheets/sheet{i + 1}.xml", s["xml"])
|
||
return buf.getvalue()
|
||
|
||
|
||
# удобные конструкторы строк для листов
|
||
def _xl_title(text): return {"style": "title", "cells": [text]}
|
||
def _xl_header(*cells): return {"style": "header", "cells": list(cells)}
|
||
def _xl_row(*cells): return {"style": "row", "cells": list(cells)}
|
||
def _xl_total(*cells): return {"style": "total", "cells": list(cells)}
|
||
_XL_SPACER = {"style": "spacer", "cells": []}
|
||
|
||
|
||
def _summary_sheet(report: dict, staff: list[dict], period: dict) -> dict:
|
||
"""Лист «Сводка»: показатели, источники с итогом «Всего (сайт + зал)»."""
|
||
t = report["totals"]
|
||
dur_min = (round(t["avg_duration_seconds"] / 60, 1)
|
||
if t.get("avg_duration_seconds") is not None else None)
|
||
rows = [
|
||
_xl_title(f"Отчёт по заказам — лаундж НЕОН · {period['from']} — {period['to']}"),
|
||
_XL_SPACER,
|
||
_xl_header("Показатель", "Значение"),
|
||
_xl_row("Выручка (выданные заказы)", t["revenue"]),
|
||
_xl_row("Заказов всего (все статусы)", t["orders"]),
|
||
_xl_row("Выдано", t["done"]),
|
||
_xl_row("Средний чек", t["avg_check"]),
|
||
_xl_row("Отменено", t["cancelled"]),
|
||
_xl_row("Сумма отменённых", t["cancelled_amount"]),
|
||
_xl_row("Средняя подача, минут", dur_min),
|
||
_XL_SPACER,
|
||
_xl_header("Источник", "Заказов", "Выручка"),
|
||
]
|
||
src = report.get("by_source", {})
|
||
orders_sum = 0
|
||
revenue_sum = 0
|
||
for key, label in (("site", "Сайт"), ("hall", "Из зала")):
|
||
s = src.get(key, {"orders": 0, "revenue": 0})
|
||
orders_sum += s["orders"]
|
||
revenue_sum += s["revenue"]
|
||
rows.append(_xl_row(label, s["orders"], s["revenue"]))
|
||
rows.append(_xl_total("Всего (сайт + зал)", orders_sum, revenue_sum))
|
||
return {"name": "Сводка", "rows": rows, "money": [1, 2], "freeze": False}
|
||
|
||
|
||
def _days_sheet(report: dict) -> dict:
|
||
rows = [_xl_header("День", "Заказов", "Выручка", "Отменено")]
|
||
rows += [_xl_row(d["day"], d["orders"], d["revenue"], d["cancelled"])
|
||
for d in report["by_day"]]
|
||
return {"name": "По дням", "rows": rows, "money": [2], "freeze": True}
|
||
|
||
|
||
def _hours_sheet(report: dict) -> dict:
|
||
rows = [_xl_header("Час", "Заказов", "Выручка")]
|
||
rows += [_xl_row(f"{h['hour']:02d}:00–{h['hour'] + 1:02d}:00", h["orders"], h["revenue"])
|
||
for h in report.get("by_hour", []) if h["orders"] or h["revenue"]]
|
||
if len(rows) == 1:
|
||
rows.append(_xl_row("нет заказов", None, None))
|
||
return {"name": "По часам", "rows": rows, "money": [2], "freeze": True}
|
||
|
||
|
||
def _top_sheet(report: dict) -> dict:
|
||
rows = [_xl_header("№", "Позиция", "Штук", "Выручка")]
|
||
rows += [_xl_row(i + 1, item["name"], item["qty"], item["revenue"])
|
||
for i, item in enumerate(report["top_items"])]
|
||
if len(rows) == 1:
|
||
rows.append(_xl_row("нет продаж", None, None, None))
|
||
return {"name": "Топ позиций", "rows": rows, "money": [3], "freeze": True}
|
||
|
||
|
||
def _tables_sheet(report: dict) -> dict:
|
||
rows = [_xl_header("Стол / зона", "Заказов", "Выручка")]
|
||
rows += [_xl_row(r["table"], r["orders"], r["revenue"])
|
||
for r in report.get("top_tables", [])]
|
||
if len(rows) == 1:
|
||
rows.append(_xl_row("нет заказов", None, None))
|
||
return {"name": "Столы", "rows": rows, "money": [2], "freeze": True}
|
||
|
||
|
||
def _staff_sheet(staff: list[dict]) -> dict:
|
||
role_label = {"owner": "Владелец", "admin": "Администратор",
|
||
"waiter": "Официант", "hookah": "Кальянщик"}
|
||
rows = [_xl_header("Сотрудник", "Роль", "Заказов", "Выдано", "Отменено", "Выручка",
|
||
"Средний чек", "Средняя подача, мин", "С сайта", "Из зала",
|
||
"Смен", "Часов")]
|
||
for s in staff:
|
||
rows.append(_xl_row(
|
||
s["name"], role_label.get(s["role"], s["role"]),
|
||
s["orders_total"], s["orders_done"], s["orders_cancelled"],
|
||
s["revenue"],
|
||
s["avg_check"] if s["avg_check"] is not None else None,
|
||
(round(s["avg_duration_seconds"] / 60, 1)
|
||
if s["avg_duration_seconds"] is not None else None),
|
||
s["site_orders"], s["hall_orders"],
|
||
s["shifts"], round(s["hours_seconds"] / 3600, 1),
|
||
))
|
||
return {"name": "Сотрудники", "rows": rows,
|
||
"money": [5, 6], "freeze": True}
|
||
|
||
|
||
def _day_sheet(d_report: dict, d_staff: list[dict], day) -> dict:
|
||
"""Лист одного дня для режима «по дням»: метрики, источники с итогом,
|
||
топ позиций и сотрудники этого дня."""
|
||
rows = [_xl_title(day.strftime("%d.%m.%Y")), _XL_SPACER]
|
||
t = d_report["totals"]
|
||
dur_min = (round(t["avg_duration_seconds"] / 60, 1)
|
||
if t.get("avg_duration_seconds") is not None else None)
|
||
rows += [
|
||
_xl_header("Показатель", "Значение"),
|
||
_xl_row("Выручка", t["revenue"]),
|
||
_xl_row("Заказов всего", t["orders"]),
|
||
_xl_row("Выдано", t["done"]),
|
||
_xl_row("Средний чек", t["avg_check"]),
|
||
_xl_row("Отменено", t["cancelled"]),
|
||
_xl_row("Средняя подача, минут", dur_min),
|
||
_XL_SPACER,
|
||
_xl_header("Источник", "Заказов", "Выручка"),
|
||
]
|
||
src = d_report.get("by_source", {})
|
||
orders_sum = 0
|
||
revenue_sum = 0
|
||
for key, label in (("site", "Сайт"), ("hall", "Из зала")):
|
||
s = src.get(key, {"orders": 0, "revenue": 0})
|
||
orders_sum += s["orders"]
|
||
revenue_sum += s["revenue"]
|
||
rows.append(_xl_row(label, s["orders"], s["revenue"]))
|
||
rows.append(_xl_total("Всего (сайт + зал)", orders_sum, revenue_sum))
|
||
rows.append(_XL_SPACER)
|
||
rows.append(_xl_header("Позиция", "Штук", "Выручка"))
|
||
if d_report["top_items"]:
|
||
rows += [_xl_row(i["name"], i["qty"], i["revenue"]) for i in d_report["top_items"]]
|
||
else:
|
||
rows.append(_xl_row("нет продаж", None, None))
|
||
rows.append(_XL_SPACER)
|
||
role_label = {"owner": "Владелец", "admin": "Администратор",
|
||
"waiter": "Официант", "hookah": "Кальянщик"}
|
||
rows.append(_xl_header("Сотрудник", "Роль", "Заказов", "Выдано", "Выручка", "Смен", "Часов"))
|
||
staff_rows = [
|
||
_xl_row(s["name"], role_label.get(s["role"], s["role"]),
|
||
s["orders_total"], s["orders_done"], s["revenue"],
|
||
s["shifts"], round(s["hours_seconds"] / 3600, 1))
|
||
for s in d_staff
|
||
]
|
||
rows += staff_rows if staff_rows else [_xl_row("нет данных", None, None, None, None, None, None)]
|
||
return {"name": day.strftime("%d.%m"), "rows": rows, "money": [1, 2], "freeze": False}
|
||
|
||
|
||
def _report_xlsx(report: dict, staff: list[dict], period: dict) -> list[dict]:
|
||
"""Листы обычного Excel-отчёта."""
|
||
return [
|
||
_summary_sheet(report, staff, period),
|
||
_days_sheet(report),
|
||
_hours_sheet(report),
|
||
_top_sheet(report),
|
||
_tables_sheet(report),
|
||
_staff_sheet(staff),
|
||
]
|
||
|
||
|
||
# ---------------------------------------------------------------- rate limit
|
||
|
||
def _is_trusted_peer(ip: str) -> bool:
|
||
"""Пиры, чьему X-Forwarded-For можно верить: loopback, приватные подсети
|
||
(docker-сеть, LAN с реверс-прокси) и unspecified. Публичному клиенту XFF
|
||
не доверяем — он мог подделать заголовок."""
|
||
try:
|
||
addr = ipaddress.ip_address(ip)
|
||
except ValueError:
|
||
return False
|
||
return addr.is_loopback or addr.is_private or addr.is_unspecified
|
||
|
||
|
||
class RateLimiter:
|
||
"""Скользящий лимит на ключ (обычно IP). Страница отслеживания опрашивает
|
||
сервер чаще заказов, поэтому у неё свой, мягкий лимит."""
|
||
|
||
window = 300
|
||
limit = 30
|
||
|
||
def __init__(self, window: int | None = None, limit: int | None = None) -> None:
|
||
if window is not None:
|
||
self.window = window
|
||
if limit is not None:
|
||
self.limit = limit
|
||
self._hits: dict[str, deque[float]] = defaultdict(deque)
|
||
self._lock = threading.Lock()
|
||
|
||
def allow(self, key: str) -> bool:
|
||
now = time.monotonic()
|
||
with self._lock:
|
||
q = self._hits[key]
|
||
while q and q[0] < now - self.window:
|
||
q.popleft()
|
||
if len(q) >= self.limit:
|
||
return False
|
||
q.append(now)
|
||
if len(self._hits) > 10_000: # не копим ключи вечных ботов
|
||
stale = now - self.window
|
||
# удаляем на месте: переприсваивание dict ломало контракт
|
||
# defaultdict, и каждый новый ключ падал с KeyError навсегда (F-5)
|
||
for k in [k for k, v in self._hits.items() if not v or v[-1] <= stale]:
|
||
del self._hits[k]
|
||
return True
|
||
|
||
|
||
limiter = RateLimiter()
|
||
track_limiter = RateLimiter(window=60, limit=90) # опрос статуса раз в 15 с — с запасом
|
||
# публичные GET каталога/стоп-листа/залов без лимита выедали пул БД при флуде
|
||
# (NF-04): сайт опрашивает их раз в минуту-десять, 120/мин с IP — с запасом.
|
||
# /api/healthz НЕ лимитируем: docker-healthcheck стучится каждые 30 с.
|
||
public_get_limiter = RateLimiter(window=60, limit=120)
|
||
# глобальный тормоз на перебор кодов отслеживания (F-8): 31^6 комбинаций при
|
||
# 90/мин с одного IP перебираются долго, но ботнет с ротацией IP — нет.
|
||
# 5000 просмотров в час на ВЕСЬ сайт с запасом покрывает живых гостей.
|
||
track_global_limiter = RateLimiter(window=3600, limit=5000)
|
||
# глобальный тормоз на фальшивые заказы: даже распределённый флуд не должен
|
||
# заваливать бар и БД. По умолчанию 200 заказов за 5 минут на ВЕСЬ сайт —
|
||
# живой пятничный поток в это не попадает, атака упирается в 503.
|
||
FLOOD_WINDOW = 300
|
||
FLOOD_LIMIT = int(os.environ.get("CRM_ORDERS_FLOOD_LIMIT", "200") or "200")
|
||
flood_limiter = RateLimiter(window=FLOOD_WINDOW, limit=FLOOD_LIMIT)
|
||
|
||
|
||
# ----------------------------- расписание бара -----------------------------
|
||
# Бар живёт по фиксированному UTC+5 (без перевода часов). Онлайн-заказы
|
||
# принимаются, когда бар открыт, и «под открытие»: не раньше, чем за час до
|
||
# 19:00. Днём, пока бар закрыт, заказать нельзя: персонал не получает
|
||
# заказов на закрытую смену, а гость не ждёт перед пустым залом.
|
||
|
||
BAR_TZ = timezone(timedelta(hours=5))
|
||
BAR_OPEN_HOUR = 19 # все сессии начинаются в 19:00 барного времени
|
||
PREORDER_WINDOW = timedelta(hours=1)
|
||
BAR_CLOSED_WEEKDAY = 0 # понедельник — сессий не открываем
|
||
# до скольки утра работает «хвост» сессии, по дню недели (Python: 0 = пн).
|
||
# Хвост после полуночи относится к сессии предыдущего дня.
|
||
BAR_TAIL_HOURS = {
|
||
0: 1, # пн: только хвост воскресенья до 01:00
|
||
1: 0, # вт: ночного хвоста нет — в пн бар не работал
|
||
2: 1, # ср: хвост вторника до 01:00
|
||
3: 1, # чт
|
||
4: 1, # пт: хвост четверга до 01:00
|
||
5: 3, # сб: хвост пятницы до 03:00
|
||
6: 3, # вс: хвост субботы до 03:00
|
||
}
|
||
WEEKDAY_WHEN = ("в понедельник", "во вторник", "в среду", "в четверг", "в пятницу", "в субботу", "в воскресенье")
|
||
|
||
|
||
def bar_now() -> datetime:
|
||
"""Текущее барное время (UTC+5), независимо от пояса сервера."""
|
||
return datetime.now(BAR_TZ)
|
||
|
||
|
||
def bar_is_open(now: datetime) -> bool:
|
||
"""Открыт ли бар в момент now (передавать барное время)."""
|
||
hour = now.hour + now.minute / 60
|
||
if hour >= BAR_OPEN_HOUR:
|
||
return now.weekday() != BAR_CLOSED_WEEKDAY # в пн вечерней сессии нет
|
||
return hour < BAR_TAIL_HOURS[now.weekday()]
|
||
|
||
|
||
def next_bar_opening(now: datetime) -> datetime | None:
|
||
"""Ближайшее открытие (19:00) строго после now, кроме понедельника."""
|
||
base = now.replace(hour=BAR_OPEN_HOUR, minute=0, second=0, microsecond=0)
|
||
for d in range(8):
|
||
cand = base + timedelta(days=d)
|
||
if cand.weekday() == BAR_CLOSED_WEEKDAY:
|
||
continue
|
||
if cand > now:
|
||
return cand
|
||
return None
|
||
|
||
|
||
def bar_orders_gate() -> dict | None:
|
||
"""None — заказы принимаем; dict — почему нельзя (бар закрыт).
|
||
|
||
Разрешаем заказ, когда бар открыт или до открытия осталось не больше часа
|
||
(«заказ к открытию»). Днём, пока бар закрыт, — отказываем с понятным
|
||
текстом: когда снова откроемся."""
|
||
now = bar_now()
|
||
if bar_is_open(now):
|
||
return None
|
||
opening = next_bar_opening(now)
|
||
if opening is None:
|
||
return None # расписание всегда найдёт открытие; не блокируем на всякий случай
|
||
if opening - now <= PREORDER_WINDOW:
|
||
return None
|
||
if opening.date() == now.date():
|
||
when = "сегодня"
|
||
elif opening.date() == now.date() + timedelta(days=1):
|
||
when = "завтра"
|
||
else:
|
||
when = WEEKDAY_WHEN[opening.weekday()]
|
||
return {
|
||
"code": "bar_closed",
|
||
# "error" — человекочитаемый текст: его показывает корзина сайта
|
||
"error": (
|
||
f"Бар закрыт — онлайн-заказы открываются за час до работы: {when} в "
|
||
f"{BAR_OPEN_HOUR:02d}:00. Позвоните нам: +7 (912) 901-72-17"
|
||
),
|
||
"opens_at": opening.isoformat(),
|
||
}
|
||
|
||
|
||
# Гигиена свободного текста гостя (clean_guest_text) живёт в storage.py —
|
||
# её применяют и публичный заказ, и ручной заказ персонала (NF-14).
|
||
|
||
|
||
class PayloadDedup:
|
||
"""Короткая память «такой заказ уже был» (один IP + тот же стол/состав).
|
||
Гасит случайный повтор (двойной клик, повторная отправка формы) и
|
||
простейшие спам-циклы с фиксированным payload — раньше, чем заказ
|
||
попадёт в канбан персонала."""
|
||
|
||
window = 60
|
||
|
||
def __init__(self, window: int | None = None) -> None:
|
||
if window is not None:
|
||
self.window = window
|
||
self._seen: dict[str, float] = {}
|
||
self._lock = threading.Lock()
|
||
|
||
def seen(self, key: str) -> bool:
|
||
now = time.monotonic()
|
||
with self._lock:
|
||
last = self._seen.get(key)
|
||
self._seen[key] = now
|
||
if len(self._seen) > 10_000:
|
||
stale = now - self.window
|
||
self._seen = {k: v for k, v in self._seen.items() if v > stale}
|
||
return last is not None and now - last < self.window
|
||
|
||
|
||
order_dedup = PayloadDedup()
|
||
|
||
|
||
class _CatalogPatchAbort(Exception):
|
||
"""NF-07: мульти-полевой PATCH каталога выполняется в одной транзакции;
|
||
этот отказ (seed/not found) откатывает уже применённые ops и уносит
|
||
готовый ответ для клиента."""
|
||
|
||
def __init__(self, payload: dict, code: int):
|
||
super().__init__(payload.get("error") or "patch aborted")
|
||
self.payload = payload
|
||
self.code = code
|
||
|
||
|
||
class LoginLockout:
|
||
"""Анти-брутфорс одной учётки (F-2) без DoS по доступности (NF-05):
|
||
каждая недавняя неудача удваивает паузу перед следующей попыткой входа
|
||
в этот логин — 2, 4, 8… секунд до потолка (cap). Стоимость перебора
|
||
растёт экспоненциально, но корректный пароль входит после паузы: чужие
|
||
промахи больше не выключают вход сотруднику на 15 минут, как раньше.
|
||
|
||
Пауза считается ДО проверки пароля и зависит только от истории неудач
|
||
этого логина — ответы для существующих и несуществующих логинов
|
||
неразличимы (внутри login_user ещё и dummy-хэш для отсутствующих).
|
||
Успешный вход сбрасывает счётчик; состояние в памяти — рестарт снимает
|
||
задержки, но лимитеры по IP продолжают работать."""
|
||
|
||
window = 900 # окно учёта неудач — 15 минут (как у прежнего жёсткого лока)
|
||
base = 2.0 # первая неудача в окне -> +2 с к следующей попытке
|
||
cap = 30.0 # потолок паузы одной попытки
|
||
|
||
def __init__(self) -> None:
|
||
self._fails: dict[str, list[float]] = {}
|
||
self._lock = threading.Lock()
|
||
|
||
def _recent(self, login: str, now: float) -> list[float]:
|
||
"""Свежие неудачи логина + чистка вечных ключей (NF-06) — та же
|
||
схема, что в RateLimiter._hits (F-5): при >10 000 логинов выметаем
|
||
опустевшие и протухшие, не ломая контракт defaultdict-подобного
|
||
доступа."""
|
||
fails = [t for t in self._fails.get(login, []) if now - t < self.window]
|
||
self._fails[login] = fails
|
||
if len(self._fails) > 10_000:
|
||
stale = now - self.window
|
||
for k in [k for k, v in self._fails.items() if not v or v[-1] <= stale]:
|
||
del self._fails[k]
|
||
return fails
|
||
|
||
def delay(self, login: str) -> float:
|
||
"""Сколько секунд ждать перед попыткой входа (0 — можно сразу)."""
|
||
now = time.monotonic()
|
||
with self._lock:
|
||
fails = self._recent(login, now)
|
||
if not fails:
|
||
return 0.0
|
||
return min(self.cap, self.base * (2 ** len(fails)))
|
||
|
||
def fail(self, login: str) -> None:
|
||
now = time.monotonic()
|
||
with self._lock:
|
||
fails = self._recent(login, now)
|
||
fails.append(now)
|
||
|
||
def reset(self, login: str) -> None:
|
||
with self._lock:
|
||
self._fails.pop(login, None)
|
||
|
||
|
||
login_lockout = LoginLockout()
|
||
|
||
|
||
# ---------------------------------------------------------------- handler
|
||
|
||
class Handler(notes_api.NotesRoutes, BaseHTTPRequestHandler):
|
||
server_version = "NeonCRM/1.0"
|
||
protocol_version = "HTTP/1.1"
|
||
|
||
# ---------------- служебное ----------------
|
||
|
||
def _drain_body(self):
|
||
"""Гарантия чистоты keep-alive: если обработчик ответил, не прочитав
|
||
тело запроса (ранний 401/403/404), соединение закрывается — иначе
|
||
оставшееся тело сервер принял бы за следующий запрос (десинхронизация
|
||
потока, «Unsupported method (\'{"...}\')»). Читаем флаг, который ставят
|
||
_read_body/_read_binary фактическим чтением тела."""
|
||
if getattr(self, "_body_consumed", False):
|
||
return
|
||
try:
|
||
length = int(self.headers.get("Content-Length") or 0)
|
||
except ValueError:
|
||
length = 0
|
||
if length > 0:
|
||
self.close_connection = True
|
||
|
||
def _json(self, obj, code=200, cors=False):
|
||
self._drain_body()
|
||
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||
self.send_response(code)
|
||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.send_header("X-Content-Type-Options", "nosniff")
|
||
if cors:
|
||
self.send_header("Access-Control-Allow-Origin", "*")
|
||
self.end_headers()
|
||
try:
|
||
self.wfile.write(body)
|
||
except (BrokenPipeError, ConnectionResetError):
|
||
pass # клиент оборвал соединение — писать некуда, это не 500
|
||
|
||
def _read_body(self) -> dict | None:
|
||
try:
|
||
length = int(self.headers.get("Content-Length") or 0)
|
||
except ValueError:
|
||
return None
|
||
if length <= 0 or length > 1_000_000:
|
||
return None # слишком большое тело не читаем: _json закроет соединение
|
||
try:
|
||
data = json.loads(self.rfile.read(length).decode("utf-8"))
|
||
self._body_consumed = True
|
||
return data if isinstance(data, dict) else None
|
||
except (ValueError, UnicodeDecodeError):
|
||
self._body_consumed = True
|
||
return None
|
||
|
||
def _read_binary(self, limit: int) -> bytes | None:
|
||
try:
|
||
length = int(self.headers.get("Content-Length") or 0)
|
||
except ValueError:
|
||
return None
|
||
if length <= 0 or length > limit:
|
||
return None # слишком большое тело не читаем: _json закроет соединение
|
||
data = self.rfile.read(length)
|
||
self._body_consumed = True
|
||
return data
|
||
|
||
def _send_file(self, data: bytes, filename: str, ctype: str):
|
||
"""Файл на скачивание (ASCII-имя): Excel-отчёты и т.п."""
|
||
self._drain_body() # NF-13: непрочитанное тело ломает keep-alive
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", ctype)
|
||
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
|
||
self.send_header("Content-Length", str(len(data)))
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.send_header("X-Content-Type-Options", "nosniff")
|
||
self.end_headers()
|
||
try:
|
||
self.wfile.write(data)
|
||
except (BrokenPipeError, ConnectionResetError):
|
||
pass
|
||
|
||
def _auth(self) -> dict | None:
|
||
return storage.session_user(self.headers.get("X-Auth-Token", ""))
|
||
|
||
def _client_ip(self) -> str:
|
||
# За nginx (сайт) все гости приходят с одного IP контейнера: берём
|
||
# реальный клиент из X-Forwarded-For — но ТОЛЬКО если соединение
|
||
# пришло от доверенного (приватного/loopback) пира (F-7). Прямой
|
||
# клиент из интернета может слать любой XFF: доверять ему нельзя,
|
||
# иначе ротацией заголовка сбрасываются лимитеры брутфорса.
|
||
forwarded = self.headers.get("X-Forwarded-For", "")
|
||
peer = str(self.client_address[0] or "")
|
||
if forwarded and _is_trusted_peer(peer):
|
||
first = forwarded.split(",")[0].strip()
|
||
if first:
|
||
return first
|
||
return peer or "?"
|
||
|
||
def log_message(self, fmt, *args):
|
||
sys.stdout.write("%s - %s\n" % (self.address_string(), fmt % args))
|
||
sys.stdout.flush()
|
||
|
||
def log_error(self, fmt, *args):
|
||
self.log_message(fmt, *args)
|
||
|
||
# ---------------- статика ----------------
|
||
|
||
def serve_static(self, path: str):
|
||
self._drain_body() # NF-13: GET со странным телом не должен рвать keep-alive
|
||
if path == "/":
|
||
path = "/index.html"
|
||
# F-3: сегменты подъёма и обратные слеши отклоняем ДО whitelist:
|
||
# whitelist проверяет сырой префикс, а Path.resolve() сворачивает
|
||
# ../ внутрь ROOT, из-за чего /js/../server.py проходил как «/js/…»
|
||
segments = path.lstrip("/").split("/")
|
||
if any(s in ("", ".", "..") or "\\" in s for s in segments):
|
||
self.send_error(404)
|
||
return
|
||
# whitelist: панель отдаёт только свои ассеты и медиа. Всё остальное
|
||
# (server.py, storage.py, seed/*.json, …) наружу не утекает.
|
||
allowed = (
|
||
path == "/index.html"
|
||
or path.startswith("/js/")
|
||
or path.startswith("/css/")
|
||
or path.startswith("/media/")
|
||
)
|
||
if not allowed:
|
||
self.send_error(404)
|
||
return
|
||
file_path = (ROOT / path.lstrip("/")).resolve()
|
||
# is_relative_to(ROOT) — защита в глубину поверх отказа от ../
|
||
if not file_path.is_relative_to(ROOT) or not file_path.is_file():
|
||
self.send_error(404)
|
||
return
|
||
|
||
import mimetypes
|
||
|
||
ctype = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
||
body = file_path.read_bytes()
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", ctype + ("; charset=utf-8" if ctype.startswith("text/") else ""))
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.send_header("X-Content-Type-Options", "nosniff")
|
||
self.send_header("X-Frame-Options", "DENY")
|
||
self.send_header("Referrer-Policy", "same-origin")
|
||
# CSP панели: скрипты только свои (app.js внешний), стили — свои и
|
||
# Google Fonts; инлайн-скриптам в панели места нет, так что даже
|
||
# случайно пропущенный sink не исполнит внедрённый код
|
||
self.send_header(
|
||
"Content-Security-Policy",
|
||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
|
||
" https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com;"
|
||
" img-src 'self' data:; connect-src 'self'; object-src 'none';"
|
||
" base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||
)
|
||
if path.startswith("/css/") or path.startswith("/js/"):
|
||
self.send_header("Cache-Control", "no-cache")
|
||
else:
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.end_headers()
|
||
try:
|
||
self.wfile.write(body)
|
||
except (BrokenPipeError, ConnectionResetError):
|
||
pass
|
||
|
||
# ---------------- маршруты ----------------
|
||
|
||
def do_GET(self):
|
||
path = urlsplit(self.path).path
|
||
try:
|
||
# анонимный DoS-флуд публичными GET (NF-04) гасим до обращения к
|
||
# БД: catalog/stoplist/halls-names — самые «дорогие» запросы сайта
|
||
if path in ("/api/catalog", "/api/stoplist", "/api/halls/names") \
|
||
and not public_get_limiter.allow("public:" + self._client_ip()):
|
||
self._json({"error": "too many requests"}, 429, cors=True)
|
||
return
|
||
if path == "/api/healthz":
|
||
with storage.db() as conn:
|
||
conn.execute("SELECT 1")
|
||
self._json({"ok": True})
|
||
elif path == "/api/stoplist":
|
||
self._json(storage.stoplist(), cors=True)
|
||
elif path == "/api/catalog":
|
||
self._json(storage.custom_catalog(), cors=True)
|
||
elif path == "/api/orders":
|
||
if not self._require_auth():
|
||
return
|
||
user = self._auth()
|
||
if user and user["role"] in SHIFT_ROLES:
|
||
# официант/кальянщик видят заказы (с телефоном и комментарием
|
||
# гостя) только во время смены — вне смены список закрыт (F-14)
|
||
if storage.get_open_shift(user["login"]) is None:
|
||
self._json({"error": "Смена не активна — список заказов доступен только на смене."}, 403)
|
||
return
|
||
self._json(storage.list_orders())
|
||
elif path == "/api/menu":
|
||
if not self._require_auth():
|
||
return
|
||
self._json(storage.list_menu())
|
||
elif path == "/api/tobacco":
|
||
if not self._require_auth():
|
||
return
|
||
self._json(storage.list_tobacco())
|
||
elif path == "/api/mixes":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
elif user["role"] not in MIX_ROLES:
|
||
self._json({"error": "owner or hookah only"}, 403)
|
||
else:
|
||
self._json(storage.list_mixes())
|
||
elif path == "/api/users":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
elif user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
else:
|
||
self._json(storage.list_users())
|
||
elif path == "/api/reports/orders/excel":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
elif user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
else:
|
||
self._handle_report_excel()
|
||
elif path == "/api/reports/staff":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
elif user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
else:
|
||
self._handle_staff_report()
|
||
elif path == "/api/reports/orders":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
else:
|
||
self._handle_report(user)
|
||
elif path == "/api/shifts/current":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
return
|
||
if user["role"] in SHIFT_ROLES:
|
||
mine = storage.get_open_shift(user["login"])
|
||
self._json({"on_shift": [mine] if mine else [], "staff": []})
|
||
else:
|
||
on_shift = storage.on_shift()
|
||
busy = {r["login"] for r in on_shift}
|
||
staff = [{**u, "on_shift": u["login"] in busy}
|
||
for u in storage.staff_shift_roles()]
|
||
self._json({"on_shift": on_shift, "staff": staff})
|
||
elif path == "/api/shifts/counts":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
elif user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
else:
|
||
self._json(storage.shift_counts())
|
||
elif path == "/api/session":
|
||
user = self._auth()
|
||
self._json(user or {}, 200 if user else 401)
|
||
elif path == "/api/halls/names":
|
||
# публичный список активных столов/залов — корзина сайта
|
||
# строит из него выбор столика
|
||
self._json({"tables": storage.hall_names()}, cors=True)
|
||
elif path == "/api/halls":
|
||
if not self._require_auth():
|
||
return
|
||
self._json(storage.hall_status())
|
||
elif path == "/api/games":
|
||
if not self._require_auth():
|
||
return
|
||
all_flag = (parse_qs(urlsplit(self.path).query).get("all") or ["0"])[0] == "1"
|
||
user = self._auth()
|
||
# корзину удалённых игр видим только владелец (админ — без редактирования)
|
||
include_deleted = bool(all_flag) and user and user["role"] == "owner"
|
||
self._json({"games": storage.games_list(include_deleted)})
|
||
elif path == "/api/mixes/site":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
elif user["role"] not in MIX_ROLES:
|
||
self._json({"error": "owner or hookah only"}, 403)
|
||
else:
|
||
self._json({"site_mixes": storage.site_mixes_edited()})
|
||
elif path == "/api/boards":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
else:
|
||
self._handle_boards_get(user)
|
||
elif BOARD_NOTES_RE.match(path):
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
else:
|
||
self._handle_board_notes_get(user, int(BOARD_NOTES_RE.match(path).group(1)))
|
||
elif BOARD_ACTIVITY_RE.match(path):
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
else:
|
||
self._handle_board_activity(user, int(BOARD_ACTIVITY_RE.match(path).group(1)))
|
||
elif TRACK_RE.match(path):
|
||
self._handle_track(TRACK_RE.match(path).group(1))
|
||
elif path.startswith("/api/") or path == "/api":
|
||
# неизвестный API-путь — JSON 404, а не HTML-заглушка статики
|
||
self._json({"error": "not found"}, 404)
|
||
else:
|
||
self.serve_static(path)
|
||
except Exception:
|
||
self._server_error()
|
||
|
||
def do_POST(self):
|
||
path = urlsplit(self.path).path
|
||
try:
|
||
if path == "/api/login":
|
||
self._handle_login()
|
||
elif path == "/api/orders":
|
||
self._handle_public_order()
|
||
elif ORDER_NUM_RESET_RE.match(path):
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
return
|
||
if user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
return
|
||
result = storage.reset_order_numbering()
|
||
if not result:
|
||
self._json(
|
||
{"error": "сбросить нумерацию дважды за одну неделю нельзя —"
|
||
" подождите следующую неделю нумерации (вторник)"},
|
||
409,
|
||
)
|
||
return
|
||
self._json(result, 201)
|
||
elif path == "/api/orders/manual":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
return
|
||
self._handle_manual_order(user)
|
||
elif path == "/api/logout":
|
||
if not self._require_auth():
|
||
return
|
||
storage.logout_user(self.headers.get("X-Auth-Token", ""))
|
||
self._json({"ok": True})
|
||
elif path == "/api/users":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
elif user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
else:
|
||
self._handle_user_create()
|
||
elif path == "/api/users/password":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
elif user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
else:
|
||
self._handle_user_password()
|
||
elif path == "/api/menu":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
elif user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
else:
|
||
self._handle_menu_create()
|
||
elif path == "/api/tobacco":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
elif user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
else:
|
||
self._handle_tobacco_create()
|
||
elif path == "/api/mixes":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
elif user["role"] not in MIX_ROLES:
|
||
self._json({"error": "owner or hookah only"}, 403)
|
||
else:
|
||
self._handle_mix_create()
|
||
elif MENU_PHOTO_RE.match(path):
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
elif user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
else:
|
||
self._handle_menu_photo(unquote(MENU_PHOTO_RE.match(path).group(1)))
|
||
elif GAME_PHOTO_RE.match(path):
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
return
|
||
# правка: библиотеку игр ведёт только владелец
|
||
# (админ видит раздел, официант и кальянщик его не видят)
|
||
if user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
return
|
||
self._handle_game_photo(int(GAME_PHOTO_RE.match(path).group(1)))
|
||
elif path == "/api/games":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
elif user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
else:
|
||
self._handle_game_create(user)
|
||
elif path == "/api/boards":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
else:
|
||
self._handle_board_create(user)
|
||
elif BOARD_NOTES_RE.match(path):
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
else:
|
||
self._handle_note_create(user, int(BOARD_NOTES_RE.match(path).group(1)))
|
||
elif path in ("/api/shifts/clock-in", "/api/shifts/clock-out"):
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
return
|
||
if user["role"] not in SHIFT_ROLES:
|
||
self._json({"error": "смены ведутся для официантов и кальянщиков"}, 400)
|
||
return
|
||
if path.endswith("clock-in"):
|
||
shift, error = storage.clock_in(user["login"])
|
||
self._json(shift or {"error": error}, 201 if shift else 400)
|
||
else:
|
||
shift = storage.clock_out(user["login"])
|
||
self._json(shift or {"error": "активной смены нет"}, 200 if shift else 400)
|
||
elif path == "/api/shifts/manage":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
return
|
||
if user["role"] not in ADMIN_ROLES:
|
||
self._json({"error": "admin only"}, 403)
|
||
return
|
||
self._handle_shift_manage()
|
||
elif path == "/api/halls":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
elif user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
else:
|
||
self._handle_hall_create()
|
||
elif path == "/api/halls/manage":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
return
|
||
if user["role"] not in ADMIN_ROLES:
|
||
self._json({"error": "admin only"}, 403)
|
||
return
|
||
self._handle_hall_manage(user)
|
||
elif path == "/api/halls/reserve":
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
return
|
||
if user["role"] not in ADMIN_ROLES:
|
||
self._json({"error": "admin only"}, 403)
|
||
return
|
||
self._handle_hall_reserve(user)
|
||
elif TRACK_CANCEL_RE.match(path):
|
||
self._handle_track_cancel(TRACK_CANCEL_RE.match(path).group(1))
|
||
else:
|
||
self._json({"error": "not found"}, 404)
|
||
except Exception:
|
||
self._server_error()
|
||
|
||
def do_PATCH(self):
|
||
path = urlsplit(self.path).path
|
||
try:
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
return
|
||
|
||
if path == "/api/session/password":
|
||
self._handle_password_change(user)
|
||
return
|
||
|
||
m = ORDER_RE.match(path)
|
||
if m:
|
||
self._handle_order_patch(int(m.group(1)))
|
||
return
|
||
|
||
m = MENU_RE.match(path)
|
||
if m:
|
||
self._handle_catalog_patch("menu", unquote(m.group(1)), user)
|
||
return
|
||
|
||
m = TOBACCO_RE.match(path)
|
||
if m:
|
||
self._handle_catalog_patch("tobacco", unquote(m.group(1)), user)
|
||
return
|
||
|
||
m = MIX_RE.match(path)
|
||
if m:
|
||
# миксы ведут владелец и кальянщик — те же роли, что на создании
|
||
if user["role"] not in MIX_ROLES:
|
||
self._json({"error": "owner or hookah only"}, 403)
|
||
return
|
||
self._handle_mix_patch(m.group(1))
|
||
return
|
||
|
||
m = MIX_SITE_RE.match(path)
|
||
if m:
|
||
# витринные миксы сайта правит только владелец
|
||
if user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
return
|
||
self._handle_site_mix_patch(m.group(1))
|
||
return
|
||
|
||
m = BOARDS_RE.match(path)
|
||
if m:
|
||
self._handle_board_patch(user, int(m.group(1)))
|
||
return
|
||
|
||
m = NOTE_RE.match(path)
|
||
if m:
|
||
self._handle_note_patch(user, int(m.group(1)))
|
||
return
|
||
|
||
m = GAME_RE.match(path)
|
||
if m:
|
||
# правка: библиотеку игр редактирует только владелец
|
||
if user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
return
|
||
self._handle_game_patch(user, int(m.group(1)))
|
||
return
|
||
|
||
self._json({"error": "not found"}, 404)
|
||
except Exception:
|
||
self._server_error()
|
||
|
||
def do_DELETE(self):
|
||
path = urlsplit(self.path).path
|
||
try:
|
||
user = self._auth()
|
||
if not user:
|
||
self._json({"error": "unauthorized"}, 401)
|
||
return
|
||
|
||
def owner_only():
|
||
if user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
return False
|
||
return True
|
||
|
||
m = USER_RE.match(path)
|
||
if m:
|
||
if not owner_only():
|
||
return
|
||
removed = storage.delete_user(unquote(m.group(1)))
|
||
if not removed:
|
||
self._json({"error": "нельзя удалить: пользователь не найден или это владелец"}, 400)
|
||
return
|
||
self._json({"ok": True})
|
||
return
|
||
|
||
m = MENU_RE.match(path)
|
||
if m:
|
||
if not owner_only():
|
||
return
|
||
item_id = unquote(m.group(1))
|
||
if (parse_qs(urlsplit(self.path).query).get("purge") or ["0"])[0] == "1":
|
||
# безвозвратное удаление: только кастомные позиции —
|
||
# стартовые позиции сайта сид пересоздаёт при рестарте
|
||
result = storage.purge_menu_item(item_id)
|
||
if result == "seed":
|
||
self._json({
|
||
"error": "стартовые позиции сайта удалить навсегда нельзя —"
|
||
" при перезапуске их восстановит сид;"
|
||
" доступно только обычное удаление",
|
||
}, 400)
|
||
return
|
||
if result is False:
|
||
self._json({"error": "not found"}, 404)
|
||
return
|
||
self._json({"ok": True, "purged": item_id})
|
||
return
|
||
updated = storage.set_menu_deleted(item_id, True)
|
||
self._json(updated or {"error": "not found"}, 200 if updated else 404)
|
||
return
|
||
|
||
m = TOBACCO_RE.match(path)
|
||
if m:
|
||
if not owner_only():
|
||
return
|
||
flavor_id = unquote(m.group(1))
|
||
if (parse_qs(urlsplit(self.path).query).get("purge") or ["0"])[0] == "1":
|
||
# безвозвратное удаление: только кастомные вкусы —
|
||
# стартовые вкусы сайта сид пересоздаёт при рестарте
|
||
result = storage.purge_tobacco_item(flavor_id)
|
||
if result == "seed":
|
||
self._json({
|
||
"error": "стартовые вкусы сайта удалить навсегда нельзя —"
|
||
" при перезапуске их восстановит сид;"
|
||
" доступно только обычное удаление",
|
||
}, 400)
|
||
return
|
||
if result is False:
|
||
self._json({"error": "not found"}, 404)
|
||
return
|
||
self._json({"ok": True, "purged": flavor_id})
|
||
return
|
||
updated = storage.set_tobacco_deleted(flavor_id, True)
|
||
self._json(updated or {"error": "not found"}, 200 if updated else 404)
|
||
return
|
||
|
||
m = MIX_RE.match(path)
|
||
if m:
|
||
# правка: миксы ведут владелец и кальянщик
|
||
if user["role"] not in MIX_ROLES:
|
||
self._json({"error": "owner or hookah only"}, 403)
|
||
return
|
||
mix_id = m.group(1)
|
||
if (parse_qs(urlsplit(self.path).query).get("purge") or ["0"])[0] == "1":
|
||
# безвозвратное удаление — только владелец; в mix_recipes
|
||
# только миксы владельца, сид их не восстанавливает
|
||
if user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
return
|
||
result = storage.purge_mix_item(mix_id)
|
||
if result is False:
|
||
self._json({"error": "not found"}, 404)
|
||
return
|
||
self._json({"ok": True, "purged": mix_id})
|
||
return
|
||
updated = storage.set_mix_deleted(mix_id, True)
|
||
self._json(updated or {"error": "not found"}, 200 if updated else 404)
|
||
return
|
||
|
||
m = MIX_SITE_RE.match(path)
|
||
if m:
|
||
# сброс правки витринного микса — вернуть исходный вид
|
||
if user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
return
|
||
if not storage.reset_site_mix(m.group(1)):
|
||
self._json({"error": "у этого микса нет правок — он в исходном виде"}, 400)
|
||
return
|
||
self._json({"ok": True, "reset": m.group(1)})
|
||
return
|
||
|
||
m = BOARDS_RE.match(path)
|
||
if m:
|
||
self._handle_board_delete(user, int(m.group(1)))
|
||
return
|
||
|
||
m = HALL_RES_RE.match(path)
|
||
if m:
|
||
if user["role"] not in ADMIN_ROLES:
|
||
self._json({"error": "admin only"}, 403)
|
||
return
|
||
self._handle_hall_reserve_cancel(user, int(m.group(1)))
|
||
return
|
||
|
||
m = HALL_DEF_RE.match(path)
|
||
if m:
|
||
if not owner_only():
|
||
return
|
||
if not storage.hall_delete(unquote(m.group(1)).strip()):
|
||
self._json({"error": "стол или зал не найден"}, 404)
|
||
return
|
||
self._json({"ok": True})
|
||
return
|
||
|
||
m = GAME_RE.match(path)
|
||
if m:
|
||
# правка: библиотеку игр удаляет только владелец
|
||
if user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
return
|
||
self._handle_game_delete(int(m.group(1)))
|
||
return
|
||
|
||
m = NOTE_RE.match(path)
|
||
if m:
|
||
self._handle_note_delete(user, int(m.group(1)))
|
||
return
|
||
|
||
self._json({"error": "not found"}, 404)
|
||
except Exception:
|
||
self._server_error()
|
||
|
||
def do_HEAD(self):
|
||
# мониторинги часто стучатся HEAD-ом: отдаём тот же вердикт, что и GET
|
||
self._drain_body() # NF-13
|
||
path = urlsplit(self.path).path
|
||
if path != "/api/healthz":
|
||
self.send_error(404)
|
||
return
|
||
try:
|
||
with storage.db() as conn:
|
||
conn.execute("SELECT 1")
|
||
self.send_response(200)
|
||
except Exception:
|
||
self.send_response(500)
|
||
self.send_header("Content-Length", "0")
|
||
self.end_headers()
|
||
|
||
def do_OPTIONS(self):
|
||
self._drain_body() # NF-13
|
||
self.send_response(204)
|
||
self.send_header("Access-Control-Allow-Origin", "*")
|
||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||
self.send_header("Content-Length", "0")
|
||
self.end_headers()
|
||
|
||
# ---------------- обработчики ----------------
|
||
|
||
def _require_auth(self) -> bool:
|
||
if self._auth():
|
||
return True
|
||
self._json({"error": "unauthorized"}, 401)
|
||
return False
|
||
|
||
def _server_error(self):
|
||
import traceback
|
||
|
||
exc = sys.exc_info()[1]
|
||
# sqlite-режим не реализует часть разделов (F-11): отвечаем честным
|
||
# 501 с текстом вместо 500 «internal error»
|
||
if isinstance(exc, storage.PostgresOnlyFeature):
|
||
self._json({"error": str(exc)}, 501)
|
||
return
|
||
traceback.print_exc()
|
||
try:
|
||
self._json({"error": "internal error"}, 500)
|
||
except Exception:
|
||
pass
|
||
|
||
def _handle_login(self):
|
||
body = self._read_body() or {}
|
||
login = str(body.get("login") or "")[:60]
|
||
password = str(body.get("password") or "")
|
||
if not login or not password:
|
||
self._json({"error": "login and password required"}, 400)
|
||
return
|
||
# пауза от брутфорса: не больше 30 попыток входа с IP за 5 минут
|
||
if not limiter.allow("login:" + self._client_ip()):
|
||
self._json({"error": "too many attempts, wait a minute"}, 429)
|
||
return
|
||
# прогрессивная задержка перебора конкретной учётки (NF-05): чек ДО
|
||
# проверки пароля — ждать приходится и промаху, и попаданию, поэтому
|
||
# перебор дорожает экспоненциально, а сотрудник с верным паролем
|
||
# входит (раньше 5 чужих промахов закрывали логин на 15 минут).
|
||
# Задержка зависит только от истории неудач логина — существующие и
|
||
# несуществующие логины неотличимы.
|
||
pause = login_lockout.delay(login)
|
||
if pause > 0:
|
||
time.sleep(pause)
|
||
session = storage.login_user(login, password)
|
||
if session is None:
|
||
login_lockout.fail(login)
|
||
time.sleep(0.3) # одинаково тормозим и промах, и попадание
|
||
self._json({"error": "неверный логин или пароль"}, 401)
|
||
return
|
||
login_lockout.reset(login)
|
||
self._json(session)
|
||
|
||
def _handle_password_change(self, user: dict):
|
||
"""Смена пароля своей учётки (F-16): текущий пароль обязателен, новый —
|
||
минимум 8 символов. Все прочие сессии этой учётки гасятся: украденный
|
||
токен перестаёт работать сразу после смены пароля."""
|
||
body = self._read_body() or {}
|
||
current = str(body.get("current_password") or "")
|
||
new = str(body.get("new_password") or "")
|
||
if not current or not new:
|
||
self._json({"error": "current_password и new_password required"}, 400)
|
||
return
|
||
if len(new) < 8:
|
||
self._json({"error": "новый пароль — минимум 8 символов"}, 400)
|
||
return
|
||
if not storage.change_password(user["login"], current, new,
|
||
keep_token=self.headers.get("X-Auth-Token", "")):
|
||
self._json({"error": "текущий пароль неверен"}, 400)
|
||
return
|
||
self._json({"ok": True})
|
||
|
||
def _handle_public_order(self):
|
||
# 1) глобальный тормоз на флуд фальшивыми заказами (распределённый
|
||
# тоже: лимит общий на весь сайт) — иначе атака упирается в БД
|
||
if not flood_limiter.allow("site"):
|
||
self._json(
|
||
{"error": "Онлайн-заказы временно недоступны — позвоните в бар, мы на месте"},
|
||
503, cors=True,
|
||
)
|
||
return
|
||
# 2) обычный лимит на одного гостя (IP берётся из XFF за nginx)
|
||
if not limiter.allow("order:" + self._client_ip()):
|
||
self._json({"error": "too many orders, try later"}, 429, cors=True)
|
||
return
|
||
# 3) «заказ по времени»: пока бар закрыт и до открытия больше часа,
|
||
# заказы не принимаются (проверка на сервере — её не обойти)
|
||
gate = bar_orders_gate()
|
||
if gate is not None:
|
||
self._json(gate, 403, cors=True)
|
||
return
|
||
body = self._read_body()
|
||
if not body or not isinstance(body.get("items"), list) or not body["items"]:
|
||
self._json({"error": "items required"}, 400, cors=True)
|
||
return
|
||
# 4) honeypot: скрытое поле формы, которое человек не видит и не
|
||
# заполняет. Отвечаем боту как успех — заказ не создаём.
|
||
if str(body.get("hp_check") or "").strip():
|
||
self._json({"ok": True}, 200, cors=True)
|
||
return
|
||
|
||
# 5) строгая проверка состава: id, название и цена — ТОЛЬКО из каталога
|
||
# CRM. Клиентские price/name игнорируются, неизвестные и стоповые
|
||
# позиции отклоняют весь заказ: состав, сумму и количество нельзя
|
||
# подменить прямым запросом к API (как это делает корзина сайта).
|
||
raw_items = body["items"]
|
||
if len(raw_items) > 30:
|
||
self._json(
|
||
{"error": "В заказе больше 30 позиций — разбейте его на два заказа"},
|
||
400, cors=True,
|
||
)
|
||
return
|
||
catalog = {m["id"]: m for m in storage.list_menu() if not m["deleted"]}
|
||
items = []
|
||
for i in raw_items:
|
||
if not isinstance(i, dict):
|
||
self._json(
|
||
{"error": "Некорректный состав заказа — обновите страницу и соберите заказ заново"},
|
||
400, cors=True,
|
||
)
|
||
return
|
||
item_id = str(i.get("id") or "").strip()[:64]
|
||
entry = catalog.get(item_id)
|
||
if entry is None:
|
||
self._json(
|
||
{"error": "Меню изменилось — обновите корзину и отправьте заказ ещё раз"},
|
||
400, cors=True,
|
||
)
|
||
return
|
||
if entry["stopped"]:
|
||
self._json(
|
||
{"error": f"«{entry['name']}» только что ушла в стоп-лист — уберите её из корзины"
|
||
" и отправьте заказ снова"},
|
||
400, cors=True,
|
||
)
|
||
return
|
||
try:
|
||
qty = min(50, max(1, int(i.get("qty", 1))))
|
||
except (TypeError, ValueError, OverflowError): # NF-08: qty 1e400
|
||
self._json(
|
||
{"error": "Некорректное количество — обновите корзину и попробуйте снова"},
|
||
400, cors=True,
|
||
)
|
||
return
|
||
name = entry["name"] + (f" ({entry['portion']})" if entry["portion"] else "")
|
||
items.append({"id": item_id, "name": name, "qty": qty, "price": entry["price"]})
|
||
if not items:
|
||
self._json({"error": "no valid items"}, 400, cors=True)
|
||
return
|
||
|
||
# стол обязателен и должен существовать: без него персонал не знает,
|
||
# куда нести заказ, а выдуманный стол засоряет канбан и отчёты.
|
||
# Пустой справочник залов — fail-closed: лучше отказаться от заказа
|
||
# с внятной ошибкой, чем принять произвольный table_name (F-9).
|
||
table = clean_guest_text(str(body.get("table_name") or ""), 60).strip()
|
||
if not table:
|
||
self._json({"error": "выберите столик в корзине — без него заказ не принять"}, 400, cors=True)
|
||
return
|
||
halls = storage.hall_names()
|
||
if not halls:
|
||
self._json(
|
||
{"error": "Онлайн-заказы временно недоступны — позвоните в бар: +7 (912) 901-72-17"},
|
||
503, cors=True,
|
||
)
|
||
return
|
||
if table not in halls:
|
||
self._json({"error": "Такого столика нет — выберите столик из списка в корзине"}, 400, cors=True)
|
||
return
|
||
guest_name = (clean_guest_text(str(body.get("guest_name") or ""), 60).strip() or None) if body.get("guest_name") else None
|
||
guest_phone = (re.sub(r"[^\d+]", "", str(body.get("guest_phone") or ""))[:20] or None) if body.get("guest_phone") else None
|
||
comment = (clean_guest_text(str(body.get("comment") or ""), 300).strip() or None) if body.get("comment") else None
|
||
# 4) дедупликация: тот же гость с тем же составом за последнюю минуту —
|
||
# почти наверняка случайный повтор (двойной клик / повторная отправка)
|
||
dedup_key = self._client_ip() + "|" + table + "|" + (guest_phone or "") + "|" + \
|
||
";".join(sorted(f"{i['id']}:{i['qty']}" for i in items))
|
||
if order_dedup.seen(dedup_key):
|
||
self._json(
|
||
{"error": "Такой заказ вы уже отправили минуту назад — проверьте код отслеживания."
|
||
" Нужен ещё один — отправьте через минуту или позовите официанта"},
|
||
409, cors=True,
|
||
)
|
||
return
|
||
order = storage.create_order(table, items, source="site",
|
||
guest_name=guest_name, guest_phone=guest_phone,
|
||
comment=comment)
|
||
self._json(order, 201, cors=True)
|
||
|
||
def _handle_track_cancel(self, raw_code: str):
|
||
"""Гость отменяет СЛУЧАЙНО отправленный заказ по коду отслеживания.
|
||
|
||
Разрешено только пока заказ «Новый» и ему меньше 10 минут — дальше
|
||
бар уже готовит, звоните персоналу. Код — единственный секрет гостя."""
|
||
if not track_limiter.allow("track:" + self._client_ip()):
|
||
self._json({"error": "too many requests"}, 429, cors=True)
|
||
return
|
||
if not track_global_limiter.allow("track-all"):
|
||
self._json({"error": "сервис статуса перегружен — попробуйте позже"}, 503, cors=True)
|
||
return
|
||
order = storage.find_order_by_code(raw_code.upper())
|
||
if order is None:
|
||
self._json({"error": "order not found"}, 404, cors=True)
|
||
return
|
||
if order["status"] == "cancelled":
|
||
self._json({"error": "Этот заказ уже отменён"}, 409, cors=True)
|
||
return
|
||
if order["status"] != "new":
|
||
self._json(
|
||
{"error": "Заказ уже в работе — отменить нельзя, позвоните в бар: +7 (912) 901-72-17"},
|
||
409, cors=True,
|
||
)
|
||
return
|
||
try:
|
||
created = datetime.fromisoformat(order["created_at"])
|
||
too_late = datetime.now(created.tzinfo) - created > timedelta(minutes=GUEST_CANCEL_MINUTES)
|
||
except (ValueError, TypeError):
|
||
too_late = True
|
||
if too_late:
|
||
self._json(
|
||
{"error": "Прошло больше 10 минут — заказ уже готовят, позвоните в бар: +7 (912) 901-72-17"},
|
||
409, cors=True,
|
||
)
|
||
return
|
||
# атомарный предикат: гонка с PATCH персонала невозможна — если заказ
|
||
# ушёл из «Новый» между проверкой и отменой, UPDATE не тронет ни строку
|
||
updated = storage.cancel_order_as_guest(order["id"])
|
||
if updated is None:
|
||
self._json(
|
||
{"error": "Заказ уже в работе — отменить нельзя, позвоните в бар: +7 (912) 901-72-17"},
|
||
409, cors=True,
|
||
)
|
||
return
|
||
self._json({"ok": True, "number": updated["number"], "status": updated["status"]}, 200, cors=True)
|
||
|
||
def _handle_order_patch(self, order_id: int):
|
||
body = self._read_body() or {}
|
||
status = body.get("status")
|
||
if status not in storage.STATUSES:
|
||
self._json({"error": "bad status"}, 400)
|
||
return
|
||
user = self._auth()
|
||
# официант и кальянщик управляют заказами только во время своей смены
|
||
if user and user.get("role") in SHIFT_ROLES and storage.get_open_shift(user["login"]) is None:
|
||
self._json({"error": "Смена не активна — управление заблокировано. Выйдите на смену."}, 403)
|
||
return
|
||
waiter = body.get("waiter")
|
||
if waiter is not None:
|
||
waiter = str(waiter)[:60].strip() or None
|
||
if waiter is not None:
|
||
# NF-03: сменные роли приписывают заказ только себе — чужое
|
||
# (пусть и существующее) имя переезжает выручку в отчётах
|
||
# задним числом; владелец/админ может любое валидное имя
|
||
if user and user.get("role") in SHIFT_ROLES and waiter != user["name"]:
|
||
self._json({"error": "заказ можно приписать только себе — выберите своё имя"}, 403)
|
||
return
|
||
# атрибуция — только реальному сотруднику (F-15)
|
||
if not storage.is_known_staff_name(waiter):
|
||
self._json({"error": "такого сотрудника нет в CRM — выберите имя из списка"}, 400)
|
||
return
|
||
updated = storage.update_order(order_id, status, waiter, actor=user)
|
||
if updated is None:
|
||
self._json({"error": "order not found"}, 404)
|
||
return
|
||
if updated == "bad_transition":
|
||
# NF-03: произвольные переходы закрыты — из терминальных статусов
|
||
# («Выдан»/«Отменён») и назад ведёт только владелец/админ
|
||
self._json({"error": "такой переход статуса недоступен вашей роли — "
|
||
"отмена выданного и возврат отменённого делает владелец/админ"}, 403)
|
||
return
|
||
self._json(updated)
|
||
|
||
def _handle_manual_order(self, user: dict):
|
||
"""Ручной заказ из зала (владелец/админ/официант/кальянщик).
|
||
Цена и названия позиций берутся из каталога CRM по id."""
|
||
if user.get("role") in SHIFT_ROLES and storage.get_open_shift(user["login"]) is None:
|
||
self._json({"error": "Смена не активна — управление заблокировано. Выйдите на смену."}, 403)
|
||
return
|
||
body = self._read_body() or {}
|
||
table = clean_guest_text(str(body.get("table") or ""), 60).strip()
|
||
raw = body.get("items")
|
||
if not table:
|
||
self._json({"error": "выберите стол или зал"}, 400)
|
||
return
|
||
# NF-14: staff-путь валидирует стол как публичный (F-9) — произвольный
|
||
# текст не попадёт в канбан и отчёт «Столы»; пустой справочник
|
||
# залов — fail-closed
|
||
if table not in storage.hall_names():
|
||
self._json({"error": "такого стола или зала нет — выберите из справочника"}, 400)
|
||
return
|
||
if not isinstance(raw, list) or not raw:
|
||
self._json({"error": "добавьте хотя бы одну позицию"}, 400)
|
||
return
|
||
|
||
catalog = {m["id"]: m for m in storage.list_menu()}
|
||
items = []
|
||
for i in raw[:30]:
|
||
if not isinstance(i, dict):
|
||
continue
|
||
item_id = str(i.get("id") or "")[:64]
|
||
entry = catalog.get(item_id)
|
||
if entry is None or entry["deleted"]:
|
||
continue # неизвестные/удалённые позиции игнорируем
|
||
try:
|
||
qty = min(50, max(1, int(i.get("qty", 1))))
|
||
except (TypeError, ValueError, OverflowError): # NF-08: qty 1e400
|
||
continue
|
||
items.append({"id": item_id, "name": entry["name"], "qty": qty, "price": entry["price"]})
|
||
if not items:
|
||
self._json({"error": "нет доступных позиций — обновите список"}, 400)
|
||
return
|
||
comment = (clean_guest_text(str(body.get("comment") or ""), 300).strip() or None) if body.get("comment") else None
|
||
order = storage.create_order(table, items, source="hall", comment=comment)
|
||
self._json(order, 201)
|
||
|
||
def _handle_shift_manage(self):
|
||
"""Владелец/админ вручную записывает или снимает официанта/кальянщика."""
|
||
body = self._read_body() or {}
|
||
login = str(body.get("login") or "").strip()
|
||
action = str(body.get("action") or "")
|
||
if not login or action not in ("in", "out"):
|
||
self._json({"error": "login и action (in|out) required"}, 400)
|
||
return
|
||
target = next((u for u in storage.staff_shift_roles() if u["login"] == login), None)
|
||
if target is None:
|
||
self._json({"error": "смены ведутся для официантов и кальянщиков"}, 400)
|
||
return
|
||
if action == "in":
|
||
if storage.get_open_shift(login) is not None:
|
||
self._json({"error": f"{target['name']} уже на смене"}, 400)
|
||
return
|
||
self._json(storage.admin_clock_in(login), 201)
|
||
return
|
||
shift = storage.clock_out(login)
|
||
self._json(shift or {"error": "нет активной смены"}, 200 if shift else 400)
|
||
|
||
def _handle_track(self, raw_code: str):
|
||
"""Публичный статус заказа по коду — для страницы «Отследить заказ».
|
||
|
||
Отдаём только то, что нужно гостю: статус, стол, сумму и метки времени;
|
||
без телефона, имени и комментариев.
|
||
"""
|
||
if not track_limiter.allow("track:" + self._client_ip()):
|
||
self._json({"error": "too many requests"}, 429, cors=True)
|
||
return
|
||
# перебор кодов гасим и глобально — ротацией IP его не обойти (F-8)
|
||
if not track_global_limiter.allow("track-all"):
|
||
self._json({"error": "сервис статуса перегружен — попробуйте позже"}, 503, cors=True)
|
||
return
|
||
order = storage.find_order_by_code(raw_code.upper())
|
||
if order is None:
|
||
self._json({"error": "order not found"}, 404, cors=True)
|
||
return
|
||
self._json({
|
||
"id": order["id"],
|
||
# недельный номер заказа — тот, что гость видел при создании
|
||
"number": order["number"],
|
||
"table": order["table"],
|
||
"status": order["status"],
|
||
"price": order["price"],
|
||
"created_at": order["created_at"],
|
||
"accepted_at": order["accepted_at"],
|
||
"delivered_at": order["delivered_at"],
|
||
}, cors=True)
|
||
|
||
def _handle_hall_create(self):
|
||
"""Новый стол/зал (владелец): {name, sub?}. Имя уникально,
|
||
подпись — произвольная («основной зал», «до 8 гостей»…)."""
|
||
body = self._read_body() or {}
|
||
name = str(body.get("name") or "").strip()[:60]
|
||
sub = (str(body.get("sub") or "").strip()[:60] or None) if body.get("sub") else None
|
||
if not name:
|
||
self._json({"error": "укажите название стола или зала"}, 400)
|
||
return
|
||
created = storage.hall_create(name, sub)
|
||
if created is None:
|
||
self._json({"error": "стол или зал с таким названием уже есть"}, 409)
|
||
return
|
||
self._json(created, 201)
|
||
|
||
def _handle_hall_manage(self, user: dict):
|
||
"""Занять/освободить стол или зону (админ/владелец).
|
||
|
||
body: {name, busy: bool, minutes?: int} — minutes задаёт таймер
|
||
освобождения; без него стол занят до ручного освобождения.
|
||
"""
|
||
body = self._read_body() or {}
|
||
name = str(body.get("name") or "").strip()
|
||
if not any(h["name"] == name for h in storage.halls_active()):
|
||
self._json({"error": "неизвестный стол или зона"}, 400)
|
||
return
|
||
busy = body.get("busy")
|
||
if not isinstance(busy, bool):
|
||
self._json({"error": "busy (bool) required"}, 400)
|
||
return
|
||
minutes = None
|
||
if busy and body.get("minutes") is not None:
|
||
try:
|
||
minutes = int(body["minutes"])
|
||
except (TypeError, ValueError, OverflowError): # NF-08: 1e400
|
||
self._json({"error": "minutes — целое число минут"}, 400)
|
||
return
|
||
if not 5 <= minutes <= 720:
|
||
self._json({"error": "таймер: от 5 до 720 минут"}, 400)
|
||
return
|
||
self._json(storage.set_hall_busy(name, busy, minutes, user["name"]))
|
||
|
||
def _handle_hall_reserve(self, user: dict):
|
||
"""Бронь стола/зоны: body {name, from: 'ЧЧ:ММ', until: 'ЧЧ:ММ', guest?}.
|
||
|
||
Время «раньше сейчас» означает следующие сутки (вечерняя бронь через
|
||
полночь); окно короче 15 минут или длиннее суток не принимается.
|
||
До начала брони стол можно занять как обычно — бронь лишь помечает
|
||
стол, фронт ведёт отсчёт и напоминает персоналу за 20 минут.
|
||
"""
|
||
body = self._read_body() or {}
|
||
name = str(body.get("name") or "").strip()
|
||
if not any(h["name"] == name for h in storage.halls_active()):
|
||
self._json({"error": "неизвестный стол или зона"}, 400)
|
||
return
|
||
m_from = RESERVE_TIME_RE.match(str(body.get("from") or "").strip())
|
||
m_until = RESERVE_TIME_RE.match(str(body.get("until") or "").strip())
|
||
if not m_from or not m_until:
|
||
self._json({"error": "время брони в формате ЧЧ:ММ, например 21:00"}, 400)
|
||
return
|
||
tz = datetime.now().astimezone().tzinfo or timezone.utc
|
||
now = datetime.now(tz)
|
||
dt_from = now.replace(hour=int(m_from.group(1)), minute=int(m_from.group(2)),
|
||
second=0, microsecond=0)
|
||
if dt_from < now - timedelta(minutes=1):
|
||
dt_from += timedelta(days=1)
|
||
dt_until = now.replace(hour=int(m_until.group(1)), minute=int(m_until.group(2)),
|
||
second=0, microsecond=0)
|
||
# until должен быть первой меткой СТРОГО после from: и случай «01:30
|
||
# после 23:00», и случай, когда оба времени уже прошли сегодня
|
||
while dt_until <= dt_from:
|
||
dt_until += timedelta(days=1)
|
||
if not timedelta(minutes=15) <= dt_until - dt_from <= timedelta(hours=24):
|
||
self._json({"error": "бронь — от 15 минут до суток"}, 400)
|
||
return
|
||
if dt_from > now + timedelta(days=30):
|
||
self._json({"error": "бронь не дальше чем на 30 дней"}, 400)
|
||
return
|
||
guest = (str(body.get("guest") or "").strip()[:60] or None) if body.get("guest") else None
|
||
reservation = storage.hall_reserve(name, dt_from, dt_until, guest, user["name"])
|
||
if reservation is None:
|
||
self._json({"error": "на этот стол в это время уже есть бронь"}, 409)
|
||
return
|
||
self._json({"reservation": reservation}, 201)
|
||
|
||
def _handle_hall_reserve_cancel(self, user: dict, res_id: int):
|
||
if not storage.hall_reserve_cancel(res_id):
|
||
self._json({"error": "бронь не найдена или уже отменена"}, 404)
|
||
return
|
||
self._json({"ok": True})
|
||
|
||
def _handle_menu_photo(self, item_id: str):
|
||
"""Загрузка фото позиции (owner). Формат проверяем по сигнатуре файла."""
|
||
# id строго в формате слага сайта: unquote раскрывает %2F→../ и
|
||
# %2A→*, что дало бы запись вне media/ или glob-удаление всех фото
|
||
if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", item_id):
|
||
self._json({"error": "некорректный id позиции"}, 400)
|
||
return
|
||
data = self._read_binary(PHOTO_MAX_BYTES)
|
||
if data is None:
|
||
self._json({"error": "файл не передан или больше 6 МБ"}, 400)
|
||
return
|
||
ext = image_extension(data)
|
||
if ext is None:
|
||
self._json({"error": "поддерживаются изображения avif, jpg, png, webp, gif"}, 400)
|
||
return
|
||
media = storage.MEDIA_DIR
|
||
media.mkdir(parents=True, exist_ok=True)
|
||
# убрать старые версии с другим расширением
|
||
for old in media.glob(glob.escape(item_id) + ".*"):
|
||
old.unlink()
|
||
target = media / f"{item_id}.{ext}"
|
||
target.write_bytes(data)
|
||
self._json({"id": item_id, "image": f"/media/{target.name}"}, 201)
|
||
|
||
def _handle_game_photo(self, game_id: int):
|
||
"""Обложка игры (админ/владелец) — та же проверка по сигнатуре файла,
|
||
файлы живут в /media/games/. Игра должна существовать."""
|
||
game = storage.game_get(game_id)
|
||
if game is None:
|
||
self._json({"error": "игра не найдена"}, 404)
|
||
return
|
||
data = self._read_binary(PHOTO_MAX_BYTES)
|
||
if data is None:
|
||
self._json({"error": "файл не передан или больше 6 МБ"}, 400)
|
||
return
|
||
ext = image_extension(data)
|
||
if ext is None:
|
||
self._json({"error": "поддерживаются изображения avif, jpg, png, webp, gif"}, 400)
|
||
return
|
||
media = storage.GAMES_MEDIA_DIR
|
||
media.mkdir(parents=True, exist_ok=True)
|
||
for old in media.glob(glob.escape(str(game_id)) + ".*"):
|
||
old.unlink()
|
||
target = media / f"{game_id}.{ext}"
|
||
target.write_bytes(data)
|
||
self._json({"id": game_id, "image": f"/media/games/{target.name}"}, 201)
|
||
|
||
def _handle_game_create(self, user: dict):
|
||
"""Новая игра: {kind: 'ps5'|'board', title, note?, difficulty?, icon?, accent?}."""
|
||
body = self._read_body() or {}
|
||
kind = str(body.get("kind") or "").strip()
|
||
title = str(body.get("title") or "").strip()[:120]
|
||
note = (str(body.get("note") or "").strip()[:160] or None) if body.get("note") else None
|
||
if kind not in GAME_KINDS:
|
||
self._json({"error": "вид игры: ps5 или board"}, 400)
|
||
return
|
||
if not title:
|
||
self._json({"error": "укажите название игры"}, 400)
|
||
return
|
||
fields = self._parse_game_fields(body, kind)
|
||
if not isinstance(fields, dict):
|
||
self._json({"error": fields}, 400)
|
||
return
|
||
game = storage.game_create(kind, title, note, user["name"], **fields)
|
||
self._json({"game": game}, 201)
|
||
|
||
def _parse_game_fields(self, body: dict, kind: str):
|
||
"""Общие поля настолок: difficulty/icon/accent. Для PS5 не принимаются.
|
||
Возвращает dict полей либо строку с ошибкой."""
|
||
fields: dict = {}
|
||
if kind != "board":
|
||
return fields
|
||
diff = body.get("difficulty")
|
||
if diff is not None:
|
||
try:
|
||
diff = int(diff)
|
||
except (TypeError, ValueError, OverflowError): # NF-08: 1e400
|
||
return "сложность: 1, 2 или 3"
|
||
if diff not in (1, 2, 3):
|
||
return "сложность: 1, 2 или 3"
|
||
fields["difficulty"] = diff
|
||
icon = body.get("icon")
|
||
if icon is not None:
|
||
icon = str(icon).strip()
|
||
if icon and icon not in GAME_ICONS:
|
||
return "неизвестная иконка"
|
||
fields["icon"] = icon or None
|
||
accent = body.get("accent")
|
||
if accent is not None:
|
||
accent = str(accent).strip()
|
||
if accent and not HEX_COLOR_RE.match(accent):
|
||
return "цвет в формате #rrggbb"
|
||
fields["accent"] = accent.lower() or None
|
||
return fields
|
||
|
||
def _handle_game_patch(self, user: dict, game_id: int):
|
||
"""Правка игры: {deleted?: bool, note?, difficulty?, icon?, accent?}."""
|
||
# тело читаем до раннего 404: непрочитанное тело PATCH ломает
|
||
# следующий запрос keep-alive-клиента (десинхронизация потока)
|
||
body = self._read_body() or {}
|
||
game = storage.game_get(game_id)
|
||
if game is None:
|
||
self._json({"error": "игра не найдена"}, 404)
|
||
return
|
||
if not body:
|
||
self._json({"error": "нечего изменять"}, 400)
|
||
return
|
||
fields: dict = {}
|
||
if "deleted" in body:
|
||
if not isinstance(body["deleted"], bool):
|
||
self._json({"error": "deleted (bool) required"}, 400)
|
||
return
|
||
if body["deleted"]:
|
||
if not storage.game_delete(game_id):
|
||
self._json({"error": "игра не найдена или уже удалена"}, 404)
|
||
return
|
||
self._json({"ok": True})
|
||
return
|
||
updated = storage.game_set_deleted(game_id, False)
|
||
if updated is None:
|
||
self._json({"error": "игра не найдена"}, 404)
|
||
return
|
||
self._json({"game": updated})
|
||
return
|
||
else:
|
||
fields = self._parse_game_fields(body, game["kind"])
|
||
if not isinstance(fields, dict):
|
||
self._json({"error": fields}, 400)
|
||
return
|
||
if "note" in body:
|
||
fields["note"] = (str(body["note"] or "").strip()[:160] or None)
|
||
updated = storage.game_update(game_id, fields)
|
||
if updated is None:
|
||
self._json({"error": "игра не найдена"}, 404)
|
||
return
|
||
self._json({"game": updated})
|
||
|
||
def _handle_game_delete(self, game_id: int):
|
||
"""Удаление игры → корзина (мягко): сид сайта не пересоздаст её,
|
||
вернуть можно через PATCH {deleted: false}. Обложка сохраняется."""
|
||
if not storage.game_delete(game_id):
|
||
self._json({"error": "игра не найдена или уже удалена"}, 404)
|
||
return
|
||
self._json({"ok": True})
|
||
|
||
def _handle_catalog_patch(self, kind: str, item_id: str, user: dict):
|
||
"""Стоп-лист (админ/владелец), флаг deleted — удаление/восстановление
|
||
(только владелец), подсветка (админ/владелец), поштучный учёт
|
||
stock/stock_add (только владелец, только меню) и правка карточки
|
||
(имя/категория/цена/порция, только владелец и только CRM-позиции).
|
||
kind: 'menu' | 'tobacco'.
|
||
|
||
Двухфазный (F-18): права и валидность КАЖДОГО поля проверяются до
|
||
первой записи — мульти-полевой PATCH либо применяется целиком,
|
||
либо отвечает ошибкой, не оставляя половину изменений в БД."""
|
||
body = self._read_body() or {}
|
||
if not body:
|
||
self._json({"error": "stopped, deleted, accent, stock, stock_add или поля правки required"}, 400)
|
||
return
|
||
role = user.get("role")
|
||
ops = []
|
||
|
||
if "stopped" in body:
|
||
# стоп-лист ведут админ/владелец по всему каталогу, кальянщик —
|
||
# только по табаку (вкусы для кальянов)
|
||
if role not in ADMIN_ROLES and \
|
||
not (kind == "tobacco" and role == "hookah"):
|
||
self._json({"error": "admin only"}, 403)
|
||
return
|
||
if not isinstance(body["stopped"], bool):
|
||
self._json({"error": "stopped (bool) required"}, 400)
|
||
return
|
||
ops.append(lambda conn: storage.set_menu_stopped(item_id, body["stopped"], conn=conn) if kind == "menu"
|
||
else storage.set_tobacco_stopped(item_id, body["stopped"], conn=conn))
|
||
|
||
if "deleted" in body:
|
||
if role != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
return
|
||
if not isinstance(body["deleted"], bool):
|
||
self._json({"error": "deleted (bool) required"}, 400)
|
||
return
|
||
ops.append(lambda conn: storage.set_menu_deleted(item_id, body["deleted"], conn=conn) if kind == "menu"
|
||
else storage.set_tobacco_deleted(item_id, body["deleted"], conn=conn))
|
||
|
||
if "accent" in body and kind == "menu":
|
||
if role not in ADMIN_ROLES:
|
||
self._json({"error": "admin only"}, 403)
|
||
return
|
||
accent = body["accent"]
|
||
if accent is not None:
|
||
accent = str(accent).strip()
|
||
if not HEX_COLOR_RE.match(accent):
|
||
self._json({"error": "цвет подсветки в формате #rrggbb"}, 400)
|
||
return
|
||
accent = accent.lower()
|
||
ops.append(lambda conn: storage.set_menu_accent(item_id, accent or None, conn=conn))
|
||
|
||
# поштучный учёт (пиво/энергетики банками): stock — задать остаток
|
||
# абсолютно (None — выключить учёт), stock_add — приход N штук
|
||
if "stock" in body or "stock_add" in body:
|
||
if kind != "menu":
|
||
self._json({"error": "поштучный учёт ведётся только по позициям меню"}, 400)
|
||
return
|
||
if role != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
return
|
||
if "stock" in body and "stock_add" in body:
|
||
self._json({"error": "отправьте либо stock, либо stock_add"}, 400)
|
||
return
|
||
if "stock" in body:
|
||
raw = body["stock"]
|
||
if raw is None:
|
||
ops.append(lambda conn: storage.set_menu_stock(item_id, new_stock=None, conn=conn))
|
||
else:
|
||
try:
|
||
qty = int(raw)
|
||
except (TypeError, ValueError, OverflowError): # NF-08: 1e400
|
||
self._json({"error": "stock — целое число штук или null"}, 400)
|
||
return
|
||
if not 0 <= qty <= 100_000:
|
||
self._json({"error": "stock — от 0 до 100000"}, 400)
|
||
return
|
||
ops.append(lambda conn: storage.set_menu_stock(item_id, new_stock=qty, conn=conn))
|
||
else:
|
||
try:
|
||
add = int(body["stock_add"])
|
||
except (TypeError, ValueError, OverflowError): # NF-08
|
||
self._json({"error": "stock_add — целое число штук"}, 400)
|
||
return
|
||
if not 1 <= add <= 100_000:
|
||
self._json({"error": "stock_add — приход от 1 до 100000 штук"}, 400)
|
||
return
|
||
ops.append(lambda conn: storage.set_menu_stock(item_id, add=add, conn=conn))
|
||
|
||
# правка карточки позиции: имя/категория/цена/порция (только владелец,
|
||
# только позиции, добавленные через CRM, — стартовые задаёт код сайта)
|
||
edit_keys = [k for k in ("name", "category", "price", "portion") if k in body]
|
||
if edit_keys:
|
||
if kind != "menu":
|
||
self._json({"error": "название и цена редактируются только у позиций меню"}, 400)
|
||
return
|
||
if role != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
return
|
||
fields: dict = {}
|
||
if "name" in body:
|
||
name = str(body["name"] or "").strip()[:120]
|
||
if not name:
|
||
self._json({"error": "укажите название позиции"}, 400)
|
||
return
|
||
fields["name"] = name
|
||
if "category" in body:
|
||
category = str(body["category"] or "").strip()[:60]
|
||
if category not in storage.menu_categories():
|
||
self._json({"error": "выберите существующую категорию: " + ", ".join(storage.menu_categories())}, 400)
|
||
return
|
||
fields["category"] = category
|
||
if "price" in body:
|
||
try:
|
||
fields["price"] = min(1_000_000, max(0, int(body["price"])))
|
||
except (TypeError, ValueError, OverflowError): # NF-08: 1e400
|
||
self._json({"error": "цена — целое число"}, 400)
|
||
return
|
||
if "portion" in body:
|
||
portion = str(body["portion"] or "").strip()[:40]
|
||
fields["portion"] = portion or None
|
||
ops.append(lambda conn: storage.update_menu_item(item_id, fields, conn=conn))
|
||
|
||
# --- фаза 2: применяем в ОДНОЙ транзакции (NF-07): все проверки
|
||
# пройдены, но и записи атомарны — отказ любого op (seed / not found)
|
||
# откатывает уже применённые, «400 на втором поле» больше не оставляет
|
||
# закоммиченный первый стоп/удаление
|
||
updated = None
|
||
try:
|
||
with storage.db() as conn:
|
||
for op in ops:
|
||
updated = op(conn)
|
||
if updated == "seed":
|
||
raise _CatalogPatchAbort({
|
||
"error": "стартовые позиции сайта заданы в его коде — править"
|
||
" можно только позиции, добавленные через CRM",
|
||
}, 400)
|
||
if updated is None:
|
||
raise _CatalogPatchAbort({"error": "not found"}, 404)
|
||
except _CatalogPatchAbort as abort:
|
||
self._json(abort.payload, abort.code)
|
||
return
|
||
if updated is None: # тело было, но ни один известный ключ не совпал
|
||
self._json({"error": "not found"}, 404)
|
||
return
|
||
self._json(updated)
|
||
|
||
# ---------------- владелец: сотрудники, позиции, отчёты ----------------
|
||
|
||
def _handle_user_create(self):
|
||
body = self._read_body() or {}
|
||
login = str(body.get("login") or "").strip()
|
||
password = str(body.get("password") or "")
|
||
name = str(body.get("name") or "").strip()
|
||
role = str(body.get("role") or "")
|
||
if not LOGIN_RE.match(login):
|
||
self._json({"error": "логин: 3–40 символов, латиница, цифры, . _ -"}, 400)
|
||
return
|
||
if len(password) < 8:
|
||
self._json({"error": "пароль минимум 8 символов"}, 400)
|
||
return
|
||
if not name:
|
||
self._json({"error": "укажите имя сотрудника"}, 400)
|
||
return
|
||
if role not in CREATABLE_ROLES:
|
||
self._json({"error": "роль: waiter, hookah или admin"}, 400)
|
||
return
|
||
created = storage.create_user(login, password, name, role)
|
||
if created is None:
|
||
self._json({"error": "такой логин уже занят"}, 409)
|
||
return
|
||
if created == "name_taken":
|
||
self._json({"error": "сотрудник с таким именем уже есть — атрибуция заказов"
|
||
" требует уникальных имён"}, 409)
|
||
return
|
||
self._json(created, 201)
|
||
|
||
def _handle_user_password(self):
|
||
"""Сброс пароля сотрудника владельцем: текущий пароль сотрудника не
|
||
нужен, его сессии гасятся (см. storage.reset_password). Владельца
|
||
через этот эндпоинт менять нельзя — только сам, кнопкой 🔑."""
|
||
body = self._read_body() or {}
|
||
login = str(body.get("login") or "").strip()
|
||
new = str(body.get("new_password") or "")
|
||
if not login or not new:
|
||
self._json({"error": "login и new_password required"}, 400)
|
||
return
|
||
if len(new) < 8:
|
||
self._json({"error": "новый пароль — минимум 8 символов"}, 400)
|
||
return
|
||
targets = [u for u in storage.list_users() if u["login"] == login]
|
||
if not targets:
|
||
self._json({"error": "сотрудник не найден"}, 404)
|
||
return
|
||
if targets[0]["role"] == "owner":
|
||
self._json({"error": "пароль владельца меняется только им самим (кнопка 🔑)"}, 400)
|
||
return
|
||
if not storage.reset_password(login, new):
|
||
self._json({"error": "сотрудник не найден"}, 404)
|
||
return
|
||
self._json({"ok": True})
|
||
|
||
def _handle_menu_create(self):
|
||
body = self._read_body() or {}
|
||
name = str(body.get("name") or "").strip()[:120]
|
||
category = str(body.get("category") or "").strip()[:60]
|
||
portion = (str(body.get("portion") or "").strip()[:40] or None) if body.get("portion") else None
|
||
try:
|
||
price = min(1_000_000, max(0, int(body.get("price", 0))))
|
||
except (TypeError, ValueError, OverflowError): # NF-08: 1e400
|
||
self._json({"error": "цена — целое число"}, 400)
|
||
return
|
||
# поштучный учёт: необязательное начальное количество в штуках
|
||
raw_stock = body.get("stock")
|
||
stock = None
|
||
if raw_stock not in (None, ""):
|
||
try:
|
||
stock = int(raw_stock)
|
||
except (TypeError, ValueError, OverflowError): # NF-08: 1e400
|
||
self._json({"error": "количество — целое число штук"}, 400)
|
||
return
|
||
if not 0 <= stock <= 100_000:
|
||
self._json({"error": "количество — от 0 до 100000 штук"}, 400)
|
||
return
|
||
if not name:
|
||
self._json({"error": "укажите название позиции"}, 400)
|
||
return
|
||
# категория должна существовать — иначе сайт не знает, куда поставить позицию
|
||
if category not in storage.menu_categories():
|
||
self._json({"error": "выберите существующую категорию: " + ", ".join(storage.menu_categories())}, 400)
|
||
return
|
||
self._json(storage.create_custom_menu_item(name, category, price, portion, stock), 201)
|
||
|
||
def _handle_tobacco_create(self):
|
||
body = self._read_body() or {}
|
||
brand = str(body.get("brand") or "").strip()[:60]
|
||
flavor = str(body.get("flavor") or "").strip()[:120]
|
||
note = (str(body.get("note") or "").strip()[:160] or None) if body.get("note") else None
|
||
try:
|
||
strength = min(3, max(1, int(body.get("strength", 2))))
|
||
except (TypeError, ValueError, OverflowError): # NF-08: 1e400
|
||
self._json({"error": "крепость — число от 1 до 3"}, 400)
|
||
return
|
||
cooling = bool(body.get("cooling"))
|
||
if not brand:
|
||
self._json({"error": "укажите бренд"}, 400)
|
||
return
|
||
if not flavor:
|
||
self._json({"error": "укажите название вкуса"}, 400)
|
||
return
|
||
self._json(storage.create_custom_tobacco(brand, flavor, note, strength, cooling), 201)
|
||
|
||
def _parse_mix_payload(self, body: dict) -> dict | None:
|
||
"""Общая валидация полей микса для создания и правки.
|
||
None — ошибка уже отправлена гостю; иначе словарь полей."""
|
||
name = str(body.get("name") or "").strip()[:120]
|
||
note = (str(body.get("note") or "").strip()[:200] or None) if body.get("note") else None
|
||
if not name:
|
||
self._json({"error": "укажите название микса"}, 400)
|
||
return None
|
||
raw_ids = body.get("flavor_ids")
|
||
if not isinstance(raw_ids, list):
|
||
self._json({"error": "выберите вкусы микса"}, 400)
|
||
return None
|
||
flavor_ids: list[str] = []
|
||
for x in raw_ids:
|
||
fid = str(x or "").strip()[:64]
|
||
if fid and fid not in flavor_ids:
|
||
flavor_ids.append(fid)
|
||
if not 2 <= len(flavor_ids) <= 5:
|
||
self._json({"error": "микс — это 2–5 вкусов"}, 400)
|
||
return None
|
||
|
||
def opt_int(key, lo, hi):
|
||
"""Необязательное целое в диапазоне; None — поле не задано."""
|
||
if body.get(key) in (None, ""):
|
||
return None
|
||
try:
|
||
val = int(body[key])
|
||
except (TypeError, ValueError, OverflowError):
|
||
return None # мусор (включая 1e400, NF-08) трактуем как «не задано»
|
||
return val if lo <= val <= hi else None
|
||
|
||
# теги для AI-генератора: максимум 6, каждый 1–20 видимых символов
|
||
tags: list[str] = []
|
||
raw_tags = body.get("tags")
|
||
if isinstance(raw_tags, list):
|
||
for x in raw_tags[:6]:
|
||
tag = str(x or "").strip()[:20]
|
||
if tag and tag not in tags:
|
||
tags.append(tag)
|
||
|
||
return {
|
||
"name": name, "note": note, "flavor_ids": flavor_ids,
|
||
"strength": opt_int("strength", 1, 5), "cooling": opt_int("cooling", 0, 3),
|
||
"price": opt_int("price", 0, 1_000_000), "tags": tags,
|
||
}
|
||
|
||
def _handle_mix_create(self):
|
||
"""Микс от владельца или кальянщика: название + вкусы справочника
|
||
табаков, без долей.
|
||
|
||
Состав видят гости на странице «Миксы НЕОНа», пропорции делает
|
||
кальянщик. Валидируем: имя, 2–5 существующих вкусов, заметка ≤200.
|
||
Расширенная информация (всё необязательное): крепость 1–5,
|
||
холодок 0–3, цена ₽, теги — сайт покажет их в карточке микса и
|
||
по тегам подмешает микс в AI-генерацию.
|
||
"""
|
||
fields = self._parse_mix_payload(self._read_body() or {})
|
||
if fields is None:
|
||
return
|
||
created = storage.create_custom_mix(**fields)
|
||
if created is None:
|
||
self._json({"error": "выберите минимум два вкуса из справочника табаков"}, 400)
|
||
return
|
||
self._json(created, 201)
|
||
|
||
def _handle_mix_patch(self, mix_id: str):
|
||
"""Правка микса (владелец/кальянщик): тот же набор полей, что и
|
||
при создании — название, состав и расширенная информация."""
|
||
fields = self._parse_mix_payload(self._read_body() or {})
|
||
if fields is None:
|
||
return
|
||
updated = storage.update_mix(mix_id, **fields)
|
||
if updated == "not_found":
|
||
self._json({"error": "микс не найден"}, 404)
|
||
return
|
||
if updated in ("bad_flavors", None):
|
||
self._json({"error": "выберите минимум два вкуса из справочника табаков"}, 400)
|
||
return
|
||
self._json(updated)
|
||
|
||
def _handle_site_mix_patch(self, mix_id: str):
|
||
"""Правка витринного микса сайта (только владелец).
|
||
|
||
Форма присылает полный снимок полей: название, заметку (пустая строка
|
||
= убрать описание), крепость/холодок/цену и теги. Состав опционален:
|
||
flavor_ids [] — оставить исходный состав витрины, иначе 2–5 вкусов
|
||
из справочника табаков. Снимок сохраняется в site_mix_edits, сайт
|
||
применяет его к своей витрине через /api/catalog; DELETE по тому же
|
||
адресу возвращает микс к исходному виду."""
|
||
# тело читаем до любых ранних выходов: непрочитанное тело PATCH
|
||
# остаётся в сокете и ломает следующий запрос keep-alive-клиента
|
||
body = self._read_body() or {}
|
||
seed_ids = {m["id"] for m in storage.site_mixes()}
|
||
if mix_id not in seed_ids:
|
||
self._json({"error": "витринный микс с таким id не найден"}, 404)
|
||
return
|
||
name = str(body.get("name") or "").strip()[:120]
|
||
if not name:
|
||
self._json({"error": "укажите название микса"}, 400)
|
||
return
|
||
|
||
def opt_int(key, lo, hi):
|
||
if body.get(key) in (None, ""):
|
||
return None
|
||
try:
|
||
val = int(body[key])
|
||
except (TypeError, ValueError, OverflowError): # NF-08: 1e400
|
||
return None
|
||
return val if lo <= val <= hi else None
|
||
|
||
strength = opt_int("strength", 1, 5)
|
||
cooling = opt_int("cooling", 0, 3)
|
||
price = opt_int("price", 0, 1_000_000)
|
||
if strength is None or cooling is None or price is None:
|
||
self._json({"error": "заполните крепость (1–5), холодок (0–3) и цену"}, 400)
|
||
return
|
||
|
||
tags: list[str] = []
|
||
raw_tags = body.get("tags")
|
||
if isinstance(raw_tags, list):
|
||
for x in raw_tags[:6]:
|
||
tag = str(x or "").strip()[:20]
|
||
if tag and tag not in tags:
|
||
tags.append(tag)
|
||
|
||
raw_ids = body.get("flavor_ids")
|
||
if raw_ids is None or raw_ids == []:
|
||
flavors = None # состав не менялся — сайт оставит исходный
|
||
elif isinstance(raw_ids, list):
|
||
flavor_ids: list[str] = []
|
||
for x in raw_ids:
|
||
fid = str(x or "").strip()[:64]
|
||
if fid and fid not in flavor_ids:
|
||
flavor_ids.append(fid)
|
||
if len(flavor_ids) == 1:
|
||
self._json({"error": "микс — это 2–5 вкусов; очистите состав, чтобы оставить исходный"}, 400)
|
||
return
|
||
rows = storage.list_tobacco()
|
||
by_id = {t["id"]: t for t in rows if not t.get("deleted")}
|
||
flavors = [
|
||
{"id": t["id"], "brand": t["brand"], "flavor": t["flavor"]}
|
||
for t in (by_id.get(i) for i in flavor_ids) if t
|
||
]
|
||
if len(flavors) < 2:
|
||
self._json({"error": "выберите минимум два вкуса из справочника табаков"}, 400)
|
||
return
|
||
else:
|
||
self._json({"error": "flavor_ids — список вкусов"}, 400)
|
||
return
|
||
|
||
storage.update_site_mix(mix_id, {
|
||
"name": name,
|
||
"note": str(body.get("note") or "").strip()[:200],
|
||
"strength": strength, "cooling": cooling, "price": price,
|
||
"tags": tags, "flavors": flavors,
|
||
})
|
||
self._json({"ok": True, "id": mix_id})
|
||
|
||
def _report_period(self, qs: dict):
|
||
"""Период отчёта из query: ?from=ГГГГ-ММ-ДД&to=ГГГГ-ММ-ДД или ?all=1
|
||
(от самого раннего заказа по сегодня). Возвращает (day_from, day_to)
|
||
или None — ответ об ошибке уже отправлен."""
|
||
tz = datetime.now().astimezone().tzinfo or timezone.utc
|
||
if (qs.get("all") or ["0"])[0] == "1":
|
||
# «за всё время»: от самого раннего заказа по сегодня включительно
|
||
first = storage.first_order_dt()
|
||
day_from = first.astimezone(tz).date() if first else datetime.now(tz).date()
|
||
return day_from, datetime.now(tz).date()
|
||
raw_from = (qs.get("from") or [""])[0]
|
||
raw_to = (qs.get("to") or [""])[0]
|
||
if not DATE_RE.match(raw_from) or not DATE_RE.match(raw_to):
|
||
self._json({"error": "период: from=ГГГГ-ММ-ДД&to=ГГГГ-ММ-ДД"}, 400)
|
||
return None
|
||
try:
|
||
day_from = date.fromisoformat(raw_from)
|
||
day_to = date.fromisoformat(raw_to)
|
||
except ValueError:
|
||
self._json({"error": "неверная дата"}, 400)
|
||
return None
|
||
if day_to < day_from:
|
||
self._json({"error": "конец периода раньше начала"}, 400)
|
||
return None
|
||
return day_from, day_to
|
||
|
||
def _report_utc_range(self, days):
|
||
"""(day_from, day_to) → (dt_from, dt_to) UTC; конец — полночь
|
||
следующего дня, границы включительные."""
|
||
tz = datetime.now().astimezone().tzinfo or timezone.utc
|
||
day_from, day_to = days
|
||
dt_from = datetime.combine(day_from, dtime(0, 0), tzinfo=tz).astimezone(timezone.utc)
|
||
dt_to = datetime.combine(day_to + timedelta(days=1), dtime(0, 0), tzinfo=tz).astimezone(timezone.utc)
|
||
return dt_from, dt_to
|
||
|
||
def _handle_report(self, user: dict):
|
||
if user["role"] != "owner":
|
||
self._json({"error": "owner only"}, 403)
|
||
return
|
||
days = self._report_period(parse_qs(urlsplit(self.path).query))
|
||
if days is None:
|
||
return
|
||
dt_from, dt_to = self._report_utc_range(days)
|
||
report = storage.orders_report(dt_from, dt_to)
|
||
report["period"] = {"from": days[0].isoformat(), "to": days[1].isoformat()}
|
||
self._json(report)
|
||
|
||
def _handle_staff_report(self):
|
||
"""Статистика сотрудников за период: заказы, выручка, средний чек,
|
||
средняя подача, смены и часы (см. storage.staff_report)."""
|
||
days = self._report_period(parse_qs(urlsplit(self.path).query))
|
||
if days is None:
|
||
return
|
||
dt_from, dt_to = self._report_utc_range(days)
|
||
staff = storage.staff_report(dt_from, dt_to)
|
||
self._json({
|
||
"period": {"from": days[0].isoformat(), "to": days[1].isoformat()},
|
||
"staff": staff,
|
||
})
|
||
|
||
def _handle_report_excel(self):
|
||
"""Выгрузка отчёта в xlsx (stdlib zip+xml, без зависимостей):
|
||
сводка с итогом «Всего (сайт + зал)», по дням, по часам, топ
|
||
позиций, столы и сотрудники. by_day=1 — отдельный лист на каждый
|
||
день периода (лимит 31 день)."""
|
||
qs = parse_qs(urlsplit(self.path).query)
|
||
days = self._report_period(qs)
|
||
if days is None:
|
||
return
|
||
dt_from, dt_to = self._report_utc_range(days)
|
||
report = storage.orders_report(dt_from, dt_to)
|
||
staff = storage.staff_report(dt_from, dt_to)
|
||
period = {"from": days[0].isoformat(), "to": days[1].isoformat()}
|
||
if (qs.get("by_day") or ["0"])[0] == "1":
|
||
span = (days[1] - days[0]).days + 1
|
||
if span > 31:
|
||
self._json({"error": "разбивка по дням — период не длиннее 31 дня"}, 400)
|
||
return
|
||
sheets = [_summary_sheet(report, staff, period)]
|
||
day = days[0]
|
||
while day <= days[1]:
|
||
d_from, d_to = self._report_utc_range((day, day))
|
||
sheets.append(_day_sheet(storage.orders_report(d_from, d_to),
|
||
storage.staff_report(d_from, d_to), day))
|
||
day += timedelta(days=1)
|
||
name = f"neon-report-days_{period['from']}_{period['to']}.xlsx"
|
||
else:
|
||
sheets = _report_xlsx(report, staff, period)
|
||
name = f"neon-report_{period['from']}_{period['to']}.xlsx"
|
||
self._send_file(
|
||
_xlsx_bytes(sheets), name,
|
||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------- запуск
|
||
|
||
def main() -> None:
|
||
import mimetypes
|
||
|
||
mimetypes.add_type("image/avif", ".avif")
|
||
mimetypes.add_type("image/webp", ".webp")
|
||
|
||
storage.init_pool()
|
||
storage.init_schema()
|
||
|
||
# F-6: seed-файлы проверяем до старта — битый или отсутствующий роняет
|
||
# запуск с внятной ошибкой вместо тихой чистки каталога при сидинге
|
||
storage.validate_seeds()
|
||
|
||
# проверяем ФАКТ: у каких учёток пароль всё ещё дефолтный. Смена
|
||
# пароля в .env на уже созданной базе не применяется (сид делает
|
||
# ON CONFLICT DO NOTHING), поэтому переменную окружения не проверяем —
|
||
# сверяем хэши со всеми известными дефолтами (F-13). По умолчанию —
|
||
# отказ старта: с дефолтными паролями панель доступна всему интернету.
|
||
weak = storage.weak_default_logins()
|
||
if weak:
|
||
message = (
|
||
"[security] Учётки с паролем по умолчанию: " + ", ".join(weak) + ".\n"
|
||
"Смените пароли каждому сотруднику (панель: кнопка «Сменить пароль»)\n"
|
||
"или пересоздайте базу с новыми значениями в .env.\n"
|
||
"Запуск с дефолтными паролями запрещён; для dev-стенда явно задайте\n"
|
||
"переменную окружения CRM_ALLOW_DEFAULT_PASSWORDS=1."
|
||
)
|
||
if os.environ.get("CRM_ALLOW_DEFAULT_PASSWORDS", "").strip() == "1":
|
||
print(message, file=sys.stderr)
|
||
else:
|
||
sys.exit(message)
|
||
|
||
# NF-12: бинд задаётся переменной CRM_BIND. В контейнере 0.0.0.0 —
|
||
# снаружи порт всё равно закрыт (compose публикует только 127.0.0.1),
|
||
# а локальный dev-стенд (dev-crm.bat) ставит 127.0.0.1, чтобы панель
|
||
# со слабыми dev-настройками не светилась в LAN.
|
||
bind = os.environ.get("CRM_BIND", "0.0.0.0").strip() or "0.0.0.0"
|
||
server = ThreadingHTTPServer((bind, PORT), Handler)
|
||
print(f"NEON CRM -> http://{bind}:{PORT} ({storage.BACKEND}, таблицы готовы)")
|
||
try:
|
||
server.serve_forever()
|
||
except KeyboardInterrupt:
|
||
pass
|
||
finally:
|
||
server.server_close()
|
||
storage.close_pool()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|