Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android
This commit is contained in:
@@ -0,0 +1,516 @@
|
||||
"""Авторизация и справочники: пользователи, должности, графики, места, оборудование, чек-листы, шаблоны нарядов."""
|
||||
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))
|
||||
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, "Должность не найдена")
|
||||
|
||||
|
||||
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}
|
||||
Reference in New Issue
Block a user