- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели) - автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов во внутренней сети Docker; значения из интерфейса важнее - локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API - .env.example, docs/DEPLOY.md (раздел 7а), README
449 lines
28 KiB
Python
449 lines
28 KiB
Python
"""Демонстрационный режим.
|
||
|
||
Боевая БД в демо-режиме НЕ ОТКРЫВАЕТСЯ: сервер переключается на отдельный файл SQLite с тестовыми данными.
|
||
При выключении демо-файл и демо-фото удаляются, сервер возвращается к боевой БД — она не менялась.
|
||
"""
|
||
import random
|
||
import shutil
|
||
import struct
|
||
import threading
|
||
import zlib
|
||
from datetime import datetime, date, time, timedelta
|
||
from pathlib import Path
|
||
|
||
from sqlalchemy.orm import Session, sessionmaker
|
||
|
||
from . import config, db as _db, demo_portal, logic, mode, push, settings_store as ss
|
||
from .migrate import ensure_schema
|
||
from .models import (MeterReading, ServiceEvent, ServiceUnit, Checklist, ChecklistItem, Comment, Equipment, Location, Order, OrderTemplate, Photo,
|
||
Position, ShiftSchedule, User)
|
||
from .security import hash_password
|
||
|
||
LOCK = threading.Lock()
|
||
DEMO_PASSWORD = "demo1234"
|
||
|
||
|
||
class DemoError(Exception):
|
||
pass
|
||
|
||
|
||
# ---------- служебное ----------
|
||
def _paths():
|
||
stamp = config.now().strftime("%Y%m%d_%H%M%S")
|
||
return f"demo_{stamp}.db", str(Path(config.DATA_DIR) / f"demo_photos_{stamp}")
|
||
|
||
|
||
def _engine_for(file: str):
|
||
return _db.make_engine("sqlite:///" + (Path(config.DATA_DIR) / file).resolve().as_posix())
|
||
|
||
|
||
def _cleanup(db_file: str, photo_dir: str):
|
||
"""Удалить демо-БД и демо-фото. Ошибки (например, файл ещё занят на Windows) не критичны — уберём при следующем запуске."""
|
||
for f in Path(config.DATA_DIR).glob(db_file + "*"):
|
||
try:
|
||
f.unlink(missing_ok=True)
|
||
except OSError:
|
||
pass
|
||
if photo_dir:
|
||
shutil.rmtree(photo_dir, ignore_errors=True)
|
||
|
||
|
||
def sweep_leftovers():
|
||
"""Удаляет демо-файлы, не относящиеся к текущему состоянию (остатки после сбоев)."""
|
||
keep = {mode.State.db_file, Path(mode.State.photo_dir).name} if mode.State.active else set()
|
||
found = list(Path(config.DATA_DIR).glob("demo_*.db*")) + list(Path(config.DATA_DIR).glob("demo_photos_*"))
|
||
for f in found: # флаг demo_mode.json сюда не попадает
|
||
if not any(f.name == k or f.name.startswith(k + "-") for k in keep if k):
|
||
try:
|
||
shutil.rmtree(f) if f.is_dir() else f.unlink()
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _png(seed: int, w: int = 240, h: int = 160) -> bytes:
|
||
"""Заглушка «фото»: градиент с шахматным узором (чтобы миниатюры различались)."""
|
||
rnd = random.Random(seed)
|
||
r0, g0, b0 = rnd.randint(40, 160), rnd.randint(40, 160), rnd.randint(40, 160)
|
||
raw = bytearray()
|
||
for y in range(h):
|
||
raw.append(0)
|
||
for x in range(w):
|
||
c = 28 if ((x // 24 + y // 24) % 2) else 0
|
||
raw += bytes(((r0 + x * 70 // w + c) % 256, (g0 + y * 70 // h + c) % 256, (b0 + (x + y) * 40 // (w + h) + c) % 256))
|
||
|
||
def chunk(t, d):
|
||
return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF)
|
||
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
||
+ chunk(b"IDAT", zlib.compress(bytes(raw), 6)) + chunk(b"IEND", b""))
|
||
|
||
|
||
# ---------- тестовый набор данных ----------
|
||
def seed(db: Session, admin: dict, photo_dir: str) -> None:
|
||
rnd = random.Random(2024)
|
||
now = config.now()
|
||
today = now.date()
|
||
pw = hash_password(DEMO_PASSWORD)
|
||
|
||
ss.set_(db, "photo_dir", photo_dir)
|
||
ss.set_(db, "max_photo_mb", "10")
|
||
# настоящий админ — с тем же id и паролем, чтобы он мог выключить демо
|
||
db.add(User(id=admin["id"], username=admin["username"], full_name=admin["full_name"] or "Администратор",
|
||
password_hash=admin["password_hash"], is_admin=True, is_active=True))
|
||
db.flush()
|
||
|
||
# --- графики ---
|
||
W = lambda d, a, b, label: {"type": "work", "days": d, "start_time": a, "end_time": b, "label": label}
|
||
O = lambda d, label="": {"type": "off", "days": d, "label": label}
|
||
sch_a = ShiftSchedule(name="Бригада А: 3 дня / 3 ночи", kind="pattern", cycle_start=today - timedelta(days=1), pattern=[
|
||
W(3, "08:00", "20:00", "день"), O(1, "отсыпной"), W(3, "20:00", "08:00", "ночь"), O(1, "отсыпной"), O(3, "выходные")])
|
||
sch_b = ShiftSchedule(name="Сутки через трое", kind="pattern", cycle_start=today, pattern=[
|
||
W(1, "08:00", "08:00", "сутки"), O(3, "выходные")])
|
||
sch_c = ShiftSchedule(name="Круглосуточное дежурство", kind="weekly", weekdays=[0, 1, 2, 3, 4, 5, 6],
|
||
start_time=time(0, 0), end_time=time(0, 0))
|
||
sch_d = ShiftSchedule(name="Пятидневка 08:00–17:00", kind="weekly", weekdays=[0, 1, 2, 3, 4],
|
||
start_time=time(8, 0), end_time=time(17, 0))
|
||
db.add_all([sch_a, sch_b, sch_c, sch_d])
|
||
db.flush()
|
||
|
||
# --- должности ---
|
||
pos = {}
|
||
for key, name, kind, sch in [("fitter", "Слесарь-ремонтник", "technician", sch_a), ("elec", "Электрик", "technician", sch_b),
|
||
("duty", "Дежурный механик", "technician", sch_c), ("chief", "Главный инженер", "engineer", None),
|
||
("eng", "Инженер по ТО", "engineer", sch_d)]:
|
||
pos[key] = Position(name=name, kind=kind, schedule_id=sch.id if sch else None)
|
||
db.add(pos[key])
|
||
db.flush()
|
||
|
||
# --- пользователи (пароль у всех demo1234) ---
|
||
spec = [("ivanov", "Иванов Сергей", "fitter", False), ("kozlov", "Козлов Андрей", "fitter", False),
|
||
("petrov", "Петров Алексей", "elec", False), ("sidorov", "Сидоров Дмитрий", "duty", False),
|
||
("morozova", "Морозова Елена", "chief", False), ("volkov", "Волков Игорь", "eng", False),
|
||
("kuznetsova", "Кузнецова Ольга", None, True), ("orlov", "Орлов Николай", "elec", False)]
|
||
users = {}
|
||
for uname, full, pk, moderator in spec:
|
||
users[uname] = User(username=uname, full_name=full, password_hash=pw, is_moderator=moderator,
|
||
position_id=pos[pk].id if pk else None)
|
||
db.add(users[uname])
|
||
db.flush()
|
||
|
||
# --- места и оборудование ---
|
||
loc = {}
|
||
for key, name, parent in [("shop1", "Цех №1", None), ("shop2", "Цех №2", None), ("boiler", "Котельная", None),
|
||
("pump", "Насосная станция", None), ("sub", "Подстанция", None), ("compr", "Компрессорная", "shop1")]:
|
||
loc[key] = Location(name=name, parent_id=loc[parent].id if parent else None)
|
||
db.add(loc[key])
|
||
db.flush()
|
||
eq = {}
|
||
for key, name, inv, lk in [("lathe", "Токарный станок 16К20", "ТС-101", "shop1"), ("mill", "Фрезерный станок 6Р82", "ФС-102", "shop1"),
|
||
("press", "Гидравлический пресс П-100", "ПГ-201", "shop2"), ("fan", "Вентилятор ВЦ-14", "ВН-601", "shop2"),
|
||
("crane", "Кран-балка 5 т", "КБ-701", "shop2"), ("comp", "Компрессор ВК-30", "КМ-301", "compr"),
|
||
("pump1", "Насос Н-1", "НС-401", "pump"), ("pump2", "Насос Н-2", "НС-402", "pump"),
|
||
("boiler", "Котёл ДКВР-10", "КТ-501", "boiler"), ("trans", "Трансформатор ТМ-630", "ТР-801", "sub"),
|
||
("crane2", "Кран мостовой 10 т", "КМ-702", "shop2"), ("crane3", "Кран козловой 20 т", "КК-703", "shop1")]:
|
||
eq[key] = Equipment(name=name, inventory_no=inv, location_id=loc[lk].id)
|
||
db.add(eq[key])
|
||
db.flush()
|
||
|
||
# --- чек-листы ---
|
||
def checklist(name, desc, items):
|
||
cl = Checklist(name=name, description=desc)
|
||
for i, it in enumerate(items):
|
||
t, kind = it[0], it[1]
|
||
kw = it[2] if len(it) > 2 else {}
|
||
cl.items.append(ChecklistItem(position=i, text=t, answer_type=kind, **kw))
|
||
db.add(cl)
|
||
db.flush()
|
||
return cl
|
||
cl_round = checklist("Ежесменный обход оборудования", "Визуальный осмотр и контроль параметров", [
|
||
("Визуальный осмотр, посторонних предметов нет", "status", {"photo": "optional"}),
|
||
("Уровень шума", "choice", {"options": ["норма", "повышенный", "критичный"]}),
|
||
("Температура корпуса", "number", {"unit": "°C", "min_value": 20, "max_value": 80}),
|
||
("Замечания", "text", {"required": False})])
|
||
cl_comp = checklist("ТО компрессора", "Плановое обслуживание воздушного компрессора", [
|
||
("Давление в ресивере", "number", {"unit": "бар", "min_value": 6, "max_value": 8}),
|
||
("Уровень масла", "choice", {"options": ["в норме", "ниже нормы", "доливка выполнена"]}),
|
||
("Воздушный фильтр проверен / очищен", "status", {"photo": "required"}),
|
||
("Слив конденсата выполнен", "status"), ("Замечания", "text", {"required": False})])
|
||
cl_pump = checklist("Проверка насосной станции", "Обход насосов и трубопроводов", [
|
||
("Давление на входе", "number", {"unit": "бар", "min_value": 1.5, "max_value": 3.5}),
|
||
("Давление на выходе", "number", {"unit": "бар", "min_value": 5, "max_value": 8}),
|
||
("Вибрация подшипника", "number", {"unit": "мм/с", "max_value": 4.5}),
|
||
("Течи и подтекания", "choice", {"options": ["нет", "есть, незначительные", "есть, требуют остановки"], "photo": "required"}),
|
||
("Замечания", "text", {"required": False})])
|
||
cl_elec = checklist("Электробезопасность: подстанция и щиты", "Осмотр электрооборудования", [
|
||
("Напряжение на вводе", "number", {"unit": "В", "min_value": 342, "max_value": 418}),
|
||
("Температура контактных соединений", "number", {"unit": "°C", "max_value": 70}),
|
||
("Состояние заземления", "choice", {"options": ["исправно", "требует обслуживания"]}),
|
||
("Фото щита", "status", {"photo": "optional"})])
|
||
|
||
# --- регламентное обслуживание: наработка, узлы и чек-лист ---
|
||
cl_reg = Checklist(name="Регламентное обслуживание", kind="regular",
|
||
description="Узлы берутся из карточки выбранной машины; после завершения наряда узлы сбрасываются")
|
||
cl_reg.items.append(ChecklistItem(position=0, text="Общий осмотр после обслуживания", answer_type="status", photo="optional"))
|
||
db.add(cl_reg)
|
||
meters = {"comp": 4180, "pump1": 12650, "pump2": 12420, "boiler": 8900, "lathe": 2300, "crane": 760}
|
||
for key, total in meters.items():
|
||
e, per_day = eq[key], total / 400
|
||
for d in (30, 20, 10, 0): # показания за месяц — по ним считается средняя наработка и прогноз
|
||
at = now - timedelta(days=d)
|
||
val = round(total - per_day * d * 0.4, 1)
|
||
db.add(MeterReading(equipment_id=e.id, hours=val, at=at, source="manual"))
|
||
if d == 0:
|
||
e.meter_hours, e.meter_updated_at = val, at
|
||
db.flush()
|
||
|
||
def unit(key, name, mode_, ago_days, ago_hours, **kw):
|
||
e = eq[key]
|
||
u = ServiceUnit(equipment_id=e.id, name=name, mode=mode_, last_service_at=now - timedelta(days=ago_days),
|
||
last_service_hours=max(0.0, e.meter_hours - ago_hours), created_at=now - timedelta(days=ago_days), **kw)
|
||
db.add(u)
|
||
db.flush()
|
||
db.add(ServiceEvent(unit_id=u.id, at=u.last_service_at, hours=u.last_service_hours, kind="initial", note="Начальное состояние"))
|
||
unit("comp", "Масло компрессора", "hours", 70, 470, interval_hours=500) # осталось ~30 м/ч — «Скоро»
|
||
unit("comp", "Воздушный фильтр", "both", 100, 600, interval_hours=1000, interval_value=3, interval_unit="month") # календарь просрочен
|
||
unit("comp", "Сепаратор масла", "hours", 200, 1500, interval_hours=4000) # в норме
|
||
unit("pump1", "Подшипники электродвигателя", "hours", 160, 7900, interval_hours=8000) # «Скоро»
|
||
unit("pump1", "Сальниковая набивка", "calendar", 40, 0, interval_value=2, interval_unit="month")
|
||
unit("pump2", "Подшипники электродвигателя", "hours", 90, 3800, interval_hours=8000)
|
||
unit("boiler", "Горелка: чистка форсунок", "calendar", 80, 0, interval_value=3, interval_unit="month")
|
||
unit("boiler", "Дымосос: смазка", "hours", 30, 700, interval_hours=750) # «Скоро»
|
||
unit("lathe", "Масло в шпиндельной бабке", "both", 330, 1900, interval_hours=2000, interval_value=12, interval_unit="month")
|
||
unit("crane", "Канат: осмотр и смазка", "calendar", 25, 0, interval_value=1, interval_unit="month")
|
||
|
||
# --- шаблоны (автозапуск) ---
|
||
def tpl(title, cl, e, lk, apos, rpos, **kw):
|
||
t = OrderTemplate(title=title, checklist_id=cl.id, equipment_id=eq[e].id, location_id=loc[lk].id,
|
||
assignee_position_id=pos[apos].id, reviewer_position_id=pos[rpos].id, start_date=today - timedelta(days=30),
|
||
is_active=True, **kw)
|
||
logic.recalc_next_run(t)
|
||
t.last_run = now - timedelta(hours=rnd.randint(2, 20))
|
||
db.add(t)
|
||
return t
|
||
tpl("Ежесменный обход: токарный станок", cl_round, "lathe", "shop1", "fitter", "eng", recurrence="daily", run_time=time(8, 0), due_hours=12)
|
||
tpl("Ежесменный обход: гидравлический пресс", cl_round, "press", "shop2", "fitter", "eng", recurrence="daily", run_time=time(8, 30), due_hours=12)
|
||
tpl("ТО компрессора (еженедельно)", cl_comp, "comp", "compr", "duty", "chief", recurrence="weekly", weekdays=[0], run_time=time(9, 0), due_hours=48)
|
||
tpl("Проверка насосной Н-1", cl_pump, "pump1", "pump", "duty", "eng", recurrence="daily", run_time=time(7, 0), due_hours=10)
|
||
tpl("Осмотр трансформатора (раз в 3 дня)", cl_elec, "trans", "sub", "elec", "chief", recurrence="interval", interval_days=3, run_time=time(10, 0), due_hours=24)
|
||
tpl("Ежемесячный осмотр кран-балки", cl_round, "crane", "shop2", "elec", "chief", recurrence="monthly", month_day=1, run_time=time(9, 0), due_hours=72)
|
||
|
||
# --- наряды ---
|
||
combos = [ # (чек-лист, оборудование, должность исполнителя, должность проверяющего, пользователи-исполнители)
|
||
(cl_round, "lathe", "fitter", "eng", ["ivanov", "kozlov"]), (cl_round, "mill", "fitter", "eng", ["ivanov", "kozlov"]),
|
||
(cl_round, "press", "fitter", "eng", ["ivanov", "kozlov"]), (cl_round, "fan", "fitter", "eng", ["ivanov", "kozlov"]),
|
||
(cl_comp, "comp", "duty", "chief", ["sidorov"]), (cl_pump, "pump1", "duty", "eng", ["sidorov"]),
|
||
(cl_pump, "pump2", "duty", "eng", ["sidorov"]), (cl_elec, "trans", "elec", "chief", ["petrov"])]
|
||
TITLES = {cl_round.id: "Ежесменный обход", cl_comp.id: "ТО компрессора", cl_pump.id: "Проверка насосной", cl_elec.id: "Осмотр электрооборудования"}
|
||
NOTES = ["Всё в норме", "Небольшой шум, наблюдаем", "Подтянули крепёж", "Заменили уплотнение", "Замечаний нет", "Требуется смазка"]
|
||
photo_root = Path(photo_dir)
|
||
photo_n = [0]
|
||
|
||
def answers(o: Order, until: datetime, start: datetime, full=True, bad_rate=0.1, partial=None):
|
||
items = o.items if full else o.items[:partial]
|
||
for k, it in enumerate(items):
|
||
at = start + (until - start) * (k + 1) / (len(o.items) + 1)
|
||
if it.answer_type == "status":
|
||
it.value = "done"
|
||
elif it.answer_type == "choice":
|
||
it.value = it.options[0] if rnd.random() > bad_rate else it.options[1]
|
||
elif it.answer_type == "number":
|
||
lo = it.min_value if it.min_value is not None else (it.max_value or 10) * 0.4
|
||
hi = it.max_value if it.max_value is not None else lo * 2
|
||
bad = rnd.random() < bad_rate
|
||
v = hi + (hi - lo) * rnd.uniform(0.05, 0.3) if bad else rnd.uniform(lo + (hi - lo) * 0.15, hi - (hi - lo) * 0.15)
|
||
it.value = f"{v:.1f}".rstrip("0").rstrip(".")
|
||
it.out_of_range = bad
|
||
else:
|
||
if not it.required and rnd.random() < 0.6:
|
||
continue
|
||
it.value = rnd.choice(NOTES)
|
||
it.answered_at = at
|
||
if it.photo == "required" or (it.photo == "optional" and rnd.random() < 0.3):
|
||
photo_n[0] += 1
|
||
rel = Path(f"{at:%Y/%m}") / f"demo_{photo_n[0]:03d}.png"
|
||
(photo_root / rel).parent.mkdir(parents=True, exist_ok=True)
|
||
data = _png(photo_n[0])
|
||
(photo_root / rel).write_bytes(data)
|
||
db.add(Photo(order_id=o.id, item_id=it.id, path=rel.as_posix(), content_type="image/png", size=len(data),
|
||
user_id=o.executor_id, created_at=at))
|
||
|
||
def make(combo, created: datetime, status: str, executor: str | None, due_h=24, **kw):
|
||
cl, e, apos, rpos, _ = combo
|
||
o = logic.create_order(db, title=f"{TITLES[cl.id]}: {eq[e].name}", checklist_id=cl.id, equipment_id=eq[e].id,
|
||
location_id=eq[e].location_id, assignee_position_id=pos[apos].id, reviewer_position_id=pos[rpos].id,
|
||
due_at=created + timedelta(hours=due_h), **kw)
|
||
o.created_at, o.status = created, status
|
||
if executor:
|
||
o.executor_id, o.executor_position_id = users[executor].id, users[executor].position_id
|
||
db.flush()
|
||
return o
|
||
|
||
reviewers = ["morozova", "volkov"]
|
||
done = []
|
||
for d in range(14, 0, -1): # история за 14 дней
|
||
day = datetime.combine(today - timedelta(days=d), time(8, 0))
|
||
for combo in rnd.sample(combos, rnd.randint(2, 4)):
|
||
ex = rnd.choice(combo[4])
|
||
created = day + timedelta(minutes=rnd.randint(0, 30))
|
||
started = created + timedelta(minutes=rnd.randint(10, 120))
|
||
finished = started + timedelta(minutes=rnd.randint(20, 90))
|
||
o = make(combo, created, "completed", ex)
|
||
o.started_at, o.submitted_at = started, finished
|
||
o.completed_at = finished + timedelta(minutes=rnd.randint(5, 240))
|
||
o.reviewed_by_id = users[rnd.choice(reviewers)].id
|
||
o.review_comment = rnd.choice(["", "", "Принято", "Принято, отклонение зафиксировано"])
|
||
db.flush()
|
||
answers(o, finished, started, bad_rate=0.08)
|
||
done.append(o)
|
||
|
||
# текущая картина: на проверке / в работе / новые / просроченные / отменён
|
||
review = []
|
||
for combo in (combos[4], combos[5], combos[0]):
|
||
ex = combo[4][0]
|
||
o = make(combo, now - timedelta(hours=rnd.randint(4, 7)), "review", ex)
|
||
o.started_at, o.submitted_at = o.created_at + timedelta(minutes=20), now - timedelta(minutes=rnd.randint(10, 90))
|
||
db.flush()
|
||
answers(o, o.submitted_at, o.started_at, bad_rate=0.25)
|
||
review.append(o)
|
||
inprog = []
|
||
for combo, part in ((combos[1], 2), (combos[6], 3), (combos[7], 1)):
|
||
o = make(combo, now - timedelta(hours=2), "in_progress", combo[4][0])
|
||
o.started_at = now - timedelta(minutes=50)
|
||
db.flush()
|
||
answers(o, now, o.started_at, full=False, partial=part, bad_rate=0.0)
|
||
inprog.append(o)
|
||
for combo in (combos[2], combos[3], combos[4]):
|
||
make(combo, now - timedelta(minutes=30), "new", None, due_h=8)
|
||
o = make(combos[7], now - timedelta(minutes=5), "new", None, due_h=6, assignee_user_id=users["petrov"].id)
|
||
o.assignee_position_id = None
|
||
for combo, st in ((combos[5], "new"), (combos[0], "in_progress")): # просроченные
|
||
o = make(combo, now - timedelta(days=1, hours=6), st, combo[4][0] if st == "in_progress" else None, due_h=10)
|
||
if st == "in_progress":
|
||
o.started_at = o.created_at + timedelta(hours=1)
|
||
make(combos[3], now - timedelta(days=2), "cancelled", None)
|
||
db.flush()
|
||
|
||
# --- комментарии ---
|
||
talk = [(review[0], "sidorov", "Давление на выходе у границы нормы, прошу посмотреть тренд."),
|
||
(review[0], "volkov", "Принял к проверке, посмотрю после обеда."),
|
||
(inprog[0], "ivanov", "Нужен ключ на 36 — подскажите, где взять."),
|
||
(inprog[0], "volkov", "Ключ в инструментальной, у кладовщика."),
|
||
(done[3], "kozlov", "Заменил уплотнение, течь устранена."),
|
||
(done[7], "morozova", "Отклонение по температуре согласовано, повторный замер завтра.")]
|
||
for k, (o, who, text) in enumerate(talk):
|
||
db.add(Comment(order_id=o.id, user_id=users[who].id, text=text, created_at=now - timedelta(minutes=90 - k * 10)))
|
||
db.flush()
|
||
# --- портал: склады, инструмент, заявки, инциденты, суточные задания, карта ---
|
||
demo_portal.seed(db, users=users, eq=eq, pos=pos, sch_store=sch_d, pw=pw, now=now, rnd=rnd)
|
||
db.commit()
|
||
logic.schedules_to_users(db) # графики демо-должностей — сотрудникам
|
||
|
||
|
||
def demo_accounts(db: Session) -> list[dict]:
|
||
out = []
|
||
for u in db.query(User).order_by(User.id):
|
||
if u.is_admin:
|
||
continue
|
||
role = "Модератор" if u.is_moderator else "Администратор склада" if u.is_storekeeper else ("Инженер" if u.position and u.position.kind == "engineer" else "Техник")
|
||
out.append({"id": u.id, "username": u.username, "full_name": u.full_name, "role": role,
|
||
"position": u.position.name if u.position else "", "on_shift": logic.user_on_shift(u)})
|
||
return out
|
||
|
||
|
||
# ---------- демо-БД и кто в ней работает ----------
|
||
def _build(owner: dict) -> None:
|
||
"""Создать новую демо-БД с тестовыми данными (owner — администратор, его копия получает тот же id и пароль)."""
|
||
db_file, photo_dir = _paths()
|
||
engine = _engine_for(db_file)
|
||
try:
|
||
ensure_schema(engine)
|
||
Path(photo_dir).mkdir(parents=True, exist_ok=True)
|
||
with sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)() as s:
|
||
seed(s, owner, photo_dir)
|
||
push.ensure_vapid(s)
|
||
except Exception:
|
||
engine.dispose()
|
||
_cleanup(db_file, photo_dir)
|
||
raise
|
||
old_file, old_photos = mode.State.db_file, mode.State.photo_dir
|
||
old_engine = _db.set_demo_engine(engine)
|
||
mode.State.active, mode.State.db_file, mode.State.photo_dir = True, db_file, photo_dir
|
||
mode.State.owner_id, mode.State.since = owner["id"], config.now().isoformat()
|
||
mode.State.epoch += 1 # прежние демо-токены (id в старой демо-БД) больше не годятся
|
||
mode.save()
|
||
if old_engine is not None:
|
||
old_engine.dispose()
|
||
if old_file:
|
||
_cleanup(old_file, old_photos)
|
||
sweep_leftovers()
|
||
|
||
|
||
def _owner_dict(u: User) -> dict:
|
||
return {"id": u.id, "username": u.username, "full_name": u.full_name, "password_hash": u.password_hash}
|
||
|
||
|
||
def enable_for(real_user: User, owner: User | None = None) -> None:
|
||
"""Включить демо пользователю боевой БД. Если демо-БД ещё нет — создать (owner — администратор, по чьей команде)."""
|
||
with LOCK:
|
||
if not mode.State.active:
|
||
_build(_owner_dict(owner or real_user))
|
||
mode.State.users.add(real_user.id)
|
||
mode.save()
|
||
|
||
|
||
def disable_for(real_id: int) -> None:
|
||
"""Вывести пользователя из демо: со следующего запроса он работает с боевыми данными."""
|
||
with LOCK:
|
||
mode.State.users.discard(real_id)
|
||
mode.save()
|
||
|
||
|
||
def demo_user_id_for(real_user: User) -> int:
|
||
"""Учётная запись этого человека в демо-БД: копия администратора-владельца (тот же id), иначе — по логину;
|
||
если такой нет — создаётся копия (логин, ФИО, пароль, роли)."""
|
||
with _db.session_for(True) as s:
|
||
if real_user.id == mode.State.owner_id and s.get(User, real_user.id):
|
||
return real_user.id
|
||
du = s.query(User).filter(User.username == real_user.username).first()
|
||
if du is None:
|
||
du = User(username=real_user.username, full_name=real_user.full_name, password_hash=real_user.password_hash,
|
||
is_admin=real_user.is_admin, is_moderator=real_user.is_moderator, is_storekeeper=real_user.is_storekeeper,
|
||
is_active=True)
|
||
s.add(du)
|
||
s.commit()
|
||
return du.id
|
||
|
||
|
||
def reset(owner: User) -> None:
|
||
"""Пересоздать демо-данные; кто был в демо — остаётся в демо (копии учётных записей создадутся заново)."""
|
||
with LOCK:
|
||
if not mode.State.active:
|
||
raise DemoError("Демонстрационный режим не включён")
|
||
_build(_owner_dict(owner))
|
||
|
||
|
||
def destroy() -> None:
|
||
"""Удалить демо-БД целиком; все, кто был в демо, возвращаются к боевым данным."""
|
||
with LOCK:
|
||
if not mode.State.active:
|
||
raise DemoError("Демонстрационный режим не включён")
|
||
db_file, photo_dir = mode.State.db_file, mode.State.photo_dir
|
||
ensure_schema(_db.real_engine)
|
||
old = _db.set_demo_engine(None)
|
||
mode.clear()
|
||
if old is not None:
|
||
old.dispose()
|
||
_cleanup(db_file, photo_dir)
|
||
sweep_leftovers()
|
||
|
||
|
||
def activate_on_startup() -> None:
|
||
"""При старте: если демо-БД была — подключаем её снова (для тех, кто в демо)."""
|
||
mode.load()
|
||
sweep_leftovers()
|
||
if mode.State.active:
|
||
e = _engine_for(mode.State.db_file)
|
||
ensure_schema(e)
|
||
_db.set_demo_engine(e)
|
||
with sessionmaker(bind=e)() as db: # демо-база прежней версии: графики должностей — сотрудникам
|
||
logic.schedules_to_users(db)
|