Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit c298f5a830
247 changed files with 23200 additions and 0 deletions
+445
View File
@@ -0,0 +1,445 @@
"""Демонстрационный режим.
Боевая БД в демо-режиме НЕ ОТКРЫВАЕТСЯ: сервер переключается на отдельный файл 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()
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)