Files
darkorion 0f239d5015 feat: документация из Nextcloud, график у сотрудника, автоочистка фото, мониторинг, итог мастера, фильтр по местам; доп. сервисы в docker-compose
- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели)
- автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов
  во внутренней сети Docker; значения из интерфейса важнее
- локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API
- .env.example, docs/DEPLOY.md (раздел 7а), README
2026-10-13 13:27:20 +03:00

520 lines
24 KiB
Python

"""Авторизация и справочники: пользователи, должности, графики, места, оборудование, чек-листы, шаблоны нарядов."""
import time
from datetime import timedelta
from fastapi.encoders import jsonable_encoder
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .. import schemas as S, logic, config, push, mode, demo, scope, settings_store as ss
from ..db import get_db, session_for
from ..models import (RefreshToken, User, Position, Department, ShiftSchedule, Location, Equipment, Checklist, ChecklistItem,
OrderTemplate)
from ..security import (current_user, require_manager, require_engineer, hash_password, verify_password, is_portal_admin, is_root,
make_token, new_refresh_token, hash_refresh)
router = APIRouter()
def commit(db: Session):
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(409, "Конфликт данных: дубликат или запись используется")
def get_or_404(db: Session, model, id: int):
obj = db.get(model, id)
if not obj:
raise HTTPException(404, "Не найдено")
return obj
# ---------- auth ----------
_fails: dict[str, list[float]] = {} # ключ -> время неудачных попыток
MAX_TRACKED = 5000 # предел размера словаря (защита памяти от перебора случайных логинов)
def _prune(t: float):
for k in [k for k, v in _fails.items() if not v or t - v[-1] >= config.LOGIN_WINDOW_SEC]:
del _fails[k]
while len(_fails) > MAX_TRACKED: # всё ещё много — выбрасываем самые старые записи
_fails.pop(next(iter(_fails)))
def _check_rate(key: str, limit: int):
t = time.time()
if len(_fails) > MAX_TRACKED:
_prune(t)
lst = [x for x in _fails.get(key, []) if t - x < config.LOGIN_WINDOW_SEC]
if lst:
_fails[key] = lst
else:
_fails.pop(key, None)
if len(lst) >= limit:
raise HTTPException(429, "Слишком много неудачных попыток входа, попробуйте позже",
headers={"Retry-After": str(config.LOGIN_WINDOW_SEC)})
def _login_keys(ip: str, username: str) -> list[tuple[str, int]]:
"""Три корзины: пара «адрес+логин» (основная блокировка), весь адрес (перебор по многим логинам) и логин
целиком (мягкий общий предел, выше основного). Так посторонний не может заблокировать вход настоящему
пользователю с другого адреса, но перебор с одного адреса и распределённый перебор по одному логину ограничены."""
user = username.lower()[:64]
return [(f"ipuser:{ip}|{user}", config.LOGIN_MAX_FAILS),
(f"ip:{ip}", config.LOGIN_MAX_FAILS * 6),
(f"user:{user}", config.LOGIN_MAX_FAILS * 4)]
_DUMMY_HASH = hash_password("dummy-password-for-timing")
def issue_tokens(db: Session, u: User, device: str = "", access: str | None = None) -> S.TokenOut:
"""Refresh-токен хранится в той БД, где лежит пользователь u; access — по умолчанию для той же БД."""
raw, h = new_refresh_token()
db.add(RefreshToken(user_id=u.id, token_hash=h, device=device[:100],
expires_at=config.now() + timedelta(days=config.REFRESH_TTL_DAYS)))
db.commit()
if access is None:
access = make_token(u, demo=bool(db.info.get("demo")), rid=None)
return S.TokenOut(access_token=access, refresh_token=raw, expires_in=config.ACCESS_TTL_MIN * 60)
def tokens_for_real(db: Session, u: User, device: str = "") -> S.TokenOut:
"""Сессия пользователя боевой БД. Если он сейчас в демо — access-токен ведёт в демо-БД (к его копии там),
refresh остаётся в боевой БД: при выходе из демо следующее обновление токена вернёт его к боевым данным."""
if mode.user_in_demo(u.id):
return issue_tokens(db, u, device, access=make_token(demo.demo_user_id_for(u), demo=True, rid=u.id))
return issue_tokens(db, u, device, access=make_token(u))
def revoke_all(db: Session, user_id: int):
db.execute(update(RefreshToken).where(RefreshToken.user_id == user_id).values(revoked=True))
@router.post("/auth/login", response_model=S.TokenOut, tags=["auth"])
def login(data: S.LoginIn, request: Request, db: Session = Depends(get_db)):
ip = request.client.host if request.client else "?"
keys = _login_keys(ip, data.username)
for k, limit in keys:
_check_rate(k, limit)
u = db.scalar(select(User).where(User.username == data.username))
if not u:
verify_password(data.password, _DUMMY_HASH) # одинаковое время ответа для существующих и несуществующих логинов
if not u or not u.is_active or not verify_password(data.password, u.password_hash):
# демонстрационные учётные записи (есть только в демо-БД) — пока демо-БД существует
if mode.State.active:
with session_for(True) as ds:
du = ds.scalar(select(User).where(User.username == data.username))
if du and du.is_active and verify_password(data.password, du.password_hash) and not (u and u.is_active):
_fails.pop(keys[0][0], None)
return issue_tokens(ds, du, data.device)
for k, _ in keys:
_fails.setdefault(k, []).append(time.time())
raise HTTPException(401, "Неверный логин или пароль")
_fails.pop(keys[0][0], None) # успешный вход сбрасывает только пару «адрес+логин»
return tokens_for_real(db, u, data.device)
@router.post("/auth/refresh", response_model=S.TokenOut, tags=["auth"])
def refresh(data: S.RefreshIn, db: Session = Depends(get_db)):
"""Обмен refresh-токена на новую пару (ротация). Повторное использование старого токена отзывает все сессии."""
if db.info.get("demo"): # обновление всегда начинаем с боевой БД
db.close()
db = session_for(False)
h = hash_refresh(data.refresh_token)
rt = db.scalar(select(RefreshToken).where(RefreshToken.token_hash == h))
if not rt and mode.State.active: # сессия демонстрационной учётной записи
with session_for(True) as ds:
drt = ds.scalar(select(RefreshToken).where(RefreshToken.token_hash == h))
if drt:
return _rotate(ds, drt, data.device)
if not rt:
raise HTTPException(401, "Недействительный refresh-токен")
return _rotate(db, rt, data.device)
def _rotate(db: Session, rt: RefreshToken, device: str) -> S.TokenOut:
if rt.revoked:
revoke_all(db, rt.user_id) # возможная кража токена
db.commit()
raise HTTPException(401, "Сессия отозвана, войдите заново")
u = db.get(User, rt.user_id)
if rt.expires_at < config.now() or not u or not u.is_active:
raise HTTPException(401, "Сессия истекла, войдите заново")
rt.revoked = True
if db.info.get("demo"):
return issue_tokens(db, u, device or rt.device)
return tokens_for_real(db, u, device or rt.device)
@router.post("/auth/logout", status_code=204, tags=["auth"])
def logout(data: S.RefreshIn, db: Session = Depends(get_db)):
h = hash_refresh(data.refresh_token)
for demo_db in ((False, True) if mode.State.active else (False,)):
with session_for(demo_db) as s:
rt = s.scalar(select(RefreshToken).where(RefreshToken.token_hash == h))
if rt:
rt.revoked = True
s.commit()
return
@router.post("/auth/change-password", response_model=S.TokenOut, tags=["auth"])
def change_password(data: S.PasswordIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
"""Смена своего пароля; все остальные сессии завершаются, возвращается новая пара токенов."""
if db.info.get("demo"):
raise HTTPException(409, "В демонстрационном режиме пароль не меняется — выйдите из демо")
if not verify_password(data.old_password, u.password_hash):
raise HTTPException(403, "Старый пароль неверен")
u.password_hash = hash_password(data.new_password)
revoke_all(db, u.id)
return issue_tokens(db, u)
def user_out(u: User) -> S.UserOut:
out = S.UserOut.model_validate(u)
out.on_shift = logic.user_on_shift(u)
out.has_photo = bool(u.photo)
out.demo_mode = mode.is_demo_request()
out.portal_admin, out.is_root = is_portal_admin(u), is_root(u)
return out
@router.get("/me", response_model=S.UserOut)
def me(db: Session = Depends(get_db), u: User = Depends(current_user)):
out = user_out(u)
out.crane_map = scope.crane_map_enabled(db)
from ..security import is_engineer, is_manager
out.reliability = ss.get(db, "reliability") != "0" and (is_manager(u) or is_engineer(u))
out.docs = bool(ss.get(db, "nc_url") and ss.get(db, "nc_user") and ss.get(db, "nc_password")) # документация из Nextcloud подключена
return out
# ---------- generic CRUD ----------
def crud(path: str, Model, In, Out, write_dep, prep=None, scope=None):
"""scope(db, user) — условие отбора для списка (например, оборудование только своего отдела) или None."""
Patch = S.partial(In)
@router.get(path, response_model=list[Out], name=f"list_{path}")
def list_(db: Session = Depends(get_db), u: User = Depends(current_user)):
stmt = select(Model).order_by(Model.id)
cond = scope(db, u) if scope else None
return db.scalars(stmt if cond is None else stmt.where(cond)).all()
@router.get(path + "/{id}", response_model=Out, name=f"get_{path}")
def get_(id: int, db: Session = Depends(get_db), _: User = Depends(current_user)):
return get_or_404(db, Model, id)
@router.post(path, response_model=Out, status_code=201, name=f"create_{path}")
def create_(data: In, db: Session = Depends(get_db), _: User = Depends(write_dep)):
d = data.model_dump()
obj = Model(**(prep(d, None) if prep else d))
db.add(obj)
commit(db)
return obj
@router.patch(path + "/{id}", response_model=Out, name=f"update_{path}")
def update_(id: int, data: Patch, db: Session = Depends(get_db), _: User = Depends(write_dep)):
obj = get_or_404(db, Model, id)
ch = data.model_dump(exclude_unset=True)
for k, v in (prep(ch, obj) if prep else ch).items():
setattr(obj, k, v)
commit(db)
return obj
@router.delete(path + "/{id}", status_code=204, name=f"delete_{path}")
def delete_(id: int, db: Session = Depends(get_db), _: User = Depends(write_dep)):
db.delete(get_or_404(db, Model, id))
commit(db)
def prep_schedule(d: dict, cur: ShiftSchedule | None) -> dict:
"""Проверка графика и приведение шагов цикла к JSON."""
kind = d.get("kind", cur.kind if cur else "weekly")
if "pattern" in d and d["pattern"] is not None:
d["pattern"] = jsonable_encoder(d["pattern"])
pattern = d.get("pattern", cur.pattern if cur else None)
if kind == "pattern":
if not pattern or not any(x["type"] == "work" for x in pattern):
raise HTTPException(422, "В цикле должна быть хотя бы одна рабочая смена")
if kind in ("cycle", "pattern") and not d.get("cycle_start") and not (cur and cur.cycle_start):
d["cycle_start"] = config.now().date()
return d
crud("/schedules", ShiftSchedule, S.ScheduleIn, S.ScheduleOut, require_manager, prep_schedule)
@router.post("/schedules/preview", response_model=list[S.PreviewDay], tags=["schedules"])
def preview_schedule(data: S.SchedulePreviewIn, _: User = Depends(require_manager)):
"""Календарь графика на N дней (для конструктора; сохранять график не нужно)."""
d = prep_schedule(data.schedule.model_dump(), None)
sch = ShiftSchedule(**d)
start = data.from_date or config.now().date()
out = []
for i in range(data.days):
day = start + timedelta(days=i)
sh = logic.shift_starting(sch, day)
out.append(S.PreviewDay(date=day, weekday=day.weekday(), work=bool(sh),
start=sh[0] if sh else None, end=sh[1] if sh else None,
label=sh[2] if sh else ""))
return out
crud("/positions", Position, S.PositionIn, S.PositionOut, require_manager)
# ---------- отделы ----------
def dep_out(db: Session, d: Department) -> S.DepartmentOut:
o = S.DepartmentOut.model_validate(d)
o.members = db.query(User).filter(User.department_id == d.id, User.is_active.is_(True)).count()
return o
def check_dep(db: Session, d: Department):
if d.head_user_id and not db.get(User, d.head_user_id):
raise HTTPException(422, "Руководитель не найден")
seen, p = {d.id}, d.parent_id
while p: # отдел не может входить сам в себя (в том числе через цепочку)
if p in seen:
raise HTTPException(422, "Отдел не может входить сам в себя")
seen.add(p)
par = db.get(Department, p)
if not par:
raise HTTPException(422, "Вышестоящий отдел не найден")
p = par.parent_id
@router.get("/departments", response_model=list[S.DepartmentOut], tags=["departments"])
def list_departments(db: Session = Depends(get_db), _: User = Depends(current_user)):
return [dep_out(db, d) for d in db.scalars(select(Department).order_by(Department.name)).all()]
@router.get("/departments/{id}", response_model=S.DepartmentOut, tags=["departments"])
def get_department(id: int, db: Session = Depends(get_db), _: User = Depends(current_user)):
return dep_out(db, get_or_404(db, Department, id))
@router.post("/departments", response_model=S.DepartmentOut, status_code=201, tags=["departments"])
def create_department(data: S.DepartmentIn, db: Session = Depends(get_db), _: User = Depends(require_manager)):
d = Department(**data.model_dump())
db.add(d)
try:
db.flush()
except IntegrityError:
db.rollback()
raise HTTPException(409, "Отдел с таким названием уже есть")
check_dep(db, d)
commit(db)
return dep_out(db, d)
@router.patch("/departments/{id}", response_model=S.DepartmentOut, tags=["departments"])
def update_department(id: int, data: S.partial(S.DepartmentIn), db: Session = Depends(get_db), _: User = Depends(require_manager)):
d = get_or_404(db, Department, id)
for k, v in data.model_dump(exclude_unset=True).items():
if v is None and k == "name":
continue
setattr(d, k, v)
check_dep(db, d)
commit(db)
return dep_out(db, d)
@router.delete("/departments/{id}", status_code=204, tags=["departments"])
def delete_department(id: int, db: Session = Depends(get_db), _: User = Depends(require_manager)):
"""Сотрудники удаляемого отдела остаются без отдела, вложенные отделы поднимаются на уровень выше."""
d = get_or_404(db, Department, id)
db.execute(update(User).where(User.department_id == id).values(department_id=None))
db.execute(update(Equipment).where(Equipment.department_id == id).values(department_id=d.parent_id))
db.execute(update(Department).where(Department.parent_id == id).values(parent_id=d.parent_id))
db.delete(d)
commit(db)
crud("/locations", Location, S.LocationIn, S.LocationOut, require_engineer)
def prep_equipment(d: dict, cur: Equipment | None) -> dict:
"""PATCH-схема создаётся без валидаторов — иконку проверяем здесь."""
if d.get("icon") and not S.ICON_RE.fullmatch(d["icon"]):
raise HTTPException(422, "Недопустимая иконка")
if "icon" in d and d["icon"] is None:
d["icon"] = ""
return d
crud("/equipment", Equipment, S.EquipmentIn, S.EquipmentOut, require_engineer, prep_equipment, scope.equipment_filter)
# ---------- users ----------
@router.get("/users", response_model=list[S.UserOut])
def list_users(db: Session = Depends(get_db), _: User = Depends(current_user)):
return [user_out(u) for u in db.scalars(select(User).order_by(User.id)).all()]
def check_user_refs(db: Session, fields: dict):
if fields.get("department_id") and not db.get(Department, fields["department_id"]):
raise HTTPException(422, "Отдел не найден")
if fields.get("position_id") and not db.get(Position, fields["position_id"]):
raise HTTPException(422, "Должность не найдена")
if fields.get("schedule_id") and not db.get(ShiftSchedule, fields["schedule_id"]):
raise HTTPException(422, "График не найден")
def guard_user_edit(me: User, target: User | None, fields: dict):
"""Модератор не может трогать администраторов и выдавать права администратора/модератора.
Права администратора портала выдаёт и снимает только администратор по умолчанию (admin)."""
want = fields.get("portal_admin")
if want is not None and (bool(want) if target is None else bool(target.portal_admin) != bool(want)):
if not is_root(me):
raise HTTPException(403, "Права администратора портала выдаёт только администратор по умолчанию (admin)")
if target is not None and is_root(target) and (fields.get("is_admin") is False or fields.get("is_active") is False):
raise HTTPException(403, "Администратора по умолчанию нельзя лишить прав или отключить")
if me.is_admin:
return
if target is not None and (target.is_admin or (target.is_moderator and target.id != me.id)):
raise HTTPException(403, "Модератор не может изменять администраторов и других модераторов")
if fields.get("is_admin") or fields.get("is_moderator"):
raise HTTPException(403, "Права администратора и модератора выдаёт только администратор")
@router.post("/users", response_model=S.UserOut, status_code=201)
def create_user(data: S.UserIn, db: Session = Depends(get_db), me: User = Depends(require_manager)):
d = data.model_dump()
guard_user_edit(me, None, d)
check_user_refs(db, d)
d["password_hash"] = hash_password(d.pop("password"))
u = User(**d)
db.add(u)
commit(db)
db.refresh(u)
return user_out(u)
@router.patch("/users/{id}", response_model=S.UserOut)
def update_user(id: int, data: S.UserPatch, db: Session = Depends(get_db), me: User = Depends(require_manager)):
u = get_or_404(db, User, id)
changes = data.model_dump(exclude_unset=True)
guard_user_edit(me, u, changes)
check_user_refs(db, changes)
for k, v in changes.items():
if k == "password":
if v:
u.password_hash = hash_password(v)
revoke_all(db, u.id)
else:
setattr(u, k, v)
commit(db)
db.refresh(u)
return user_out(u)
@router.delete("/users/{id}", status_code=204)
def delete_user(id: int, db: Session = Depends(get_db), me: User = Depends(require_manager)):
if id == me.id:
raise HTTPException(400, "Нельзя удалить себя")
u = get_or_404(db, User, id)
guard_user_edit(me, u, {})
db.delete(u)
commit(db)
# ---------- checklists ----------
def fill_items(cl: Checklist, items: list[S.ChecklistItemIn]):
cl.items.clear()
for i, it in enumerate(items):
d = it.model_dump()
if d["answer_type"] != "choice":
d["options"] = None
elif not d["options"]:
raise HTTPException(422, f"Пункт «{it.text}»: для списка нужны варианты ответа")
cl.items.append(ChecklistItem(position=i, **d))
@router.get("/checklists", response_model=list[S.ChecklistOut])
def list_checklists(db: Session = Depends(get_db), _: User = Depends(current_user)):
return db.scalars(select(Checklist).order_by(Checklist.id)).all()
@router.get("/checklists/{id}", response_model=S.ChecklistOut)
def get_checklist(id: int, db: Session = Depends(get_db), _: User = Depends(current_user)):
return get_or_404(db, Checklist, id)
@router.post("/checklists", response_model=S.ChecklistOut, status_code=201)
def create_checklist(data: S.ChecklistIn, db: Session = Depends(get_db), _: User = Depends(require_engineer)):
cl = Checklist(name=data.name, description=data.description, kind=data.kind, only_due=data.only_due)
fill_items(cl, data.items)
db.add(cl)
commit(db)
return cl
@router.put("/checklists/{id}", response_model=S.ChecklistOut)
def update_checklist(id: int, data: S.ChecklistIn, db: Session = Depends(get_db),
_: User = Depends(require_engineer)):
cl = get_or_404(db, Checklist, id)
cl.name, cl.description, cl.kind, cl.only_due = data.name, data.description, data.kind, data.only_due
fill_items(cl, data.items)
commit(db)
return cl
@router.delete("/checklists/{id}", status_code=204)
def delete_checklist(id: int, db: Session = Depends(get_db), _: User = Depends(require_engineer)):
db.delete(get_or_404(db, Checklist, id))
commit(db)
# ---------- шаблоны нарядов ----------
@router.get("/order-templates", response_model=list[S.OrderTemplateOut])
def list_templates(db: Session = Depends(get_db), _: User = Depends(require_engineer)):
return db.scalars(select(OrderTemplate).order_by(OrderTemplate.id)).all()
@router.post("/order-templates", response_model=S.OrderTemplateOut, status_code=201)
def create_template(data: S.OrderTemplateIn, db: Session = Depends(get_db), _: User = Depends(require_engineer)):
d = data.model_dump()
cl = db.get(Checklist, d["checklist_id"])
if cl and cl.kind == "regular" and not d.get("equipment_id"):
raise HTTPException(422, "Для регламентного обслуживания выберите оборудование")
d["start_date"] = d["start_date"] or config.now().date()
t = OrderTemplate(**d)
logic.recalc_next_run(t)
db.add(t)
commit(db)
return t
@router.patch("/order-templates/{id}", response_model=S.OrderTemplateOut)
def update_template(id: int, data: S.partial(S.OrderTemplateIn), db: Session = Depends(get_db),
_: User = Depends(require_engineer)):
t = get_or_404(db, OrderTemplate, id)
for k, v in data.model_dump(exclude_unset=True).items():
if k == "start_date" and v is None:
continue
setattr(t, k, v)
logic.recalc_next_run(t)
commit(db)
return t
@router.delete("/order-templates/{id}", status_code=204)
def delete_template(id: int, db: Session = Depends(get_db), _: User = Depends(require_engineer)):
db.delete(get_or_404(db, OrderTemplate, id))
commit(db)
@router.post("/order-templates/{id}/run-now", status_code=201)
def run_now(id: int, db: Session = Depends(get_db), _: User = Depends(require_engineer)):
"""Создать наряд из шаблона прямо сейчас (не сдвигая расписание)."""
t = get_or_404(db, OrderTemplate, id)
try:
o = logic.run_template(db, t)
except ValueError as e:
raise HTTPException(422, str(e))
commit(db)
push.notify_assigned(db, o)
return {"order_id": o.id}