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

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit 67e411eea8
246 changed files with 23152 additions and 0 deletions
+271
View File
@@ -0,0 +1,271 @@
"""Суточные задания — простые поручения на день (не наряд, без чек-листа).
Статусы: назначен → в работе → приостановлен / выполнен / отменён. Переход в «приостановлен», «выполнен» и «отменён»
обязательно с комментарием. К заданию можно привязать машину или её узел и приложить фото.
Инженер, модератор и администратор ставят задания кому угодно; остальные — только себе."""
from datetime import date
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from .. import config, media, push, schemas as S
from ..db import get_db
from ..models import DailyTask, Equipment, ServiceUnit, TaskEvent, TaskPhoto, User
from ..security import current_user, is_engineer, is_manager
from .catalog import commit, get_or_404
from .inventory import user_names
router = APIRouter(tags=["tasks"])
STATUS_RU = {"assigned": "Назначен", "in_progress": "В работе", "paused": "Приостановлен", "done": "Выполнен", "cancelled": "Отменён"}
FINAL = ("done", "cancelled")
NEED_COMMENT = ("paused", "done", "cancelled")
ALLOWED = {
"assigned": {"in_progress", "paused", "done", "cancelled"},
"in_progress": {"paused", "done", "cancelled"},
"paused": {"in_progress", "done", "cancelled"},
"done": {"assigned"}, # вернуть в работу — только инженер
"cancelled": {"assigned"},
}
def is_boss(u: User) -> bool:
return is_manager(u) or is_engineer(u)
def can_see(u: User, t: DailyTask) -> bool:
return is_boss(u) or u.id in (t.assignee_id, t.created_by_id)
def can_edit(u: User, t: DailyTask) -> bool:
return t.status not in FINAL and (is_boss(u) or t.created_by_id == u.id)
def can_change(u: User, t: DailyTask) -> bool:
if t.status in FINAL:
return is_boss(u)
return is_boss(u) or u.id in (t.assignee_id, t.created_by_id)
def can_transfer(u: User, t: DailyTask) -> bool:
"""Передать задание другому: исполнитель, заказчик (кто поставил) или инженер/модератор/админ (проверяющие)."""
return t.status not in FINAL and (is_boss(u) or u.id in (t.assignee_id, t.created_by_id))
def load(db: Session, id: int, u: User) -> DailyTask:
t = db.get(DailyTask, id)
if not t or not can_see(u, t):
raise HTTPException(404, "Задание не найдено")
return t
def check_links(db: Session, d: dict, cur: DailyTask | None = None):
eq_id = d.get("equipment_id", cur.equipment_id if cur else None)
unit_id = d.get("unit_id", cur.unit_id if cur else None)
if eq_id:
get_or_404(db, Equipment, eq_id)
if unit_id:
su = db.get(ServiceUnit, unit_id)
if not su:
raise HTTPException(422, "Узел не найден")
if eq_id and su.equipment_id != eq_id:
raise HTTPException(422, "Узел не относится к выбранной машине")
d["equipment_id"] = su.equipment_id
def to_out(db: Session, t: DailyTask, u: User, names: dict, with_events: bool = False) -> S.TaskOut:
eq = db.get(Equipment, t.equipment_id) if t.equipment_id else None
su = db.get(ServiceUnit, t.unit_id) if t.unit_id else None
evs = db.scalars(select(TaskEvent).where(TaskEvent.task_id == t.id).order_by(TaskEvent.at, TaskEvent.id)).all()
last = next((e.comment for e in reversed(evs) if e.comment), "")
photos = [p for p, in db.execute(select(TaskPhoto.id).where(TaskPhoto.task_id == t.id).order_by(TaskPhoto.id))]
return S.TaskOut(
id=t.id, title=t.title, description=t.description, day=t.day, assignee_id=t.assignee_id,
assignee_name=names.get(t.assignee_id), created_by_id=t.created_by_id, created_by_name=names.get(t.created_by_id),
equipment_id=t.equipment_id, equipment_name=eq.name if eq else None, unit_id=t.unit_id,
unit_name=su.name if su else None, status=t.status, created_at=t.created_at, updated_at=t.updated_at,
finished_at=t.finished_at, labor_hours=t.labor_hours, last_comment=last, photos=photos, can_change=can_change(u, t), can_edit=can_edit(u, t),
can_transfer=can_transfer(u, t),
events=[S.TaskEventOut(at=e.at, user_name=names.get(e.user_id), from_status=e.from_status, to_status=e.to_status,
comment=e.comment, kind=e.kind or "status", from_user_name=names.get(e.from_user_id),
to_user_name=names.get(e.to_user_id)) for e in evs] if with_events else None)
@router.get("/tasks", response_model=list[S.TaskOut])
def list_tasks(day: Optional[date] = None, date_from: Optional[date] = Query(None, alias="from"),
date_to: Optional[date] = Query(None, alias="to"), assignee_id: Optional[int] = None,
equipment_id: Optional[int] = None, status: Optional[S.TaskStatus] = None, mine: bool = False,
created_by_me: bool = False,
active: bool = False, limit: int = Query(300, ge=1, le=2000),
db: Session = Depends(get_db), u: User = Depends(current_user)):
stmt = select(DailyTask).order_by(DailyTask.day.desc(), DailyTask.id.desc()).limit(limit)
if mine or not is_boss(u):
stmt = stmt.where(or_(DailyTask.assignee_id == u.id, DailyTask.created_by_id == u.id) if not mine
else DailyTask.assignee_id == u.id)
if created_by_me:
stmt = stmt.where(DailyTask.created_by_id == u.id)
if day:
stmt = stmt.where(DailyTask.day == day)
if date_from:
stmt = stmt.where(DailyTask.day >= date_from)
if date_to:
stmt = stmt.where(DailyTask.day <= date_to)
if assignee_id:
stmt = stmt.where(DailyTask.assignee_id == assignee_id)
if equipment_id:
stmt = stmt.where(DailyTask.equipment_id == equipment_id)
if status:
stmt = stmt.where(DailyTask.status == status)
if active:
stmt = stmt.where(DailyTask.status.not_in(FINAL))
names = user_names(db)
return [to_out(db, t, u, names) for t in db.scalars(stmt).all()]
@router.get("/tasks/{id}", response_model=S.TaskOut)
def get_task(id: int, db: Session = Depends(get_db), u: User = Depends(current_user)):
return to_out(db, load(db, id, u), u, user_names(db), with_events=True)
@router.post("/tasks", response_model=S.TaskOut, status_code=201)
def create_task(data: S.TaskIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
d = data.model_dump()
d["assignee_id"] = d["assignee_id"] or u.id
if d["assignee_id"] != u.id and not is_boss(u):
raise HTTPException(403, "Ставить задания другим может инженер, модератор или администратор")
a = get_or_404(db, User, d["assignee_id"])
if not a.is_active:
raise HTTPException(422, "Сотрудник отключён")
check_links(db, d)
d["day"] = d["day"] or config.now().date()
t = DailyTask(**d, created_by_id=u.id)
db.add(t)
db.flush()
db.add(TaskEvent(task_id=t.id, user_id=u.id, from_status="", to_status="assigned"))
commit(db)
if t.assignee_id != u.id:
push.notify([t.assignee_id], "Новое задание", t.title[:120], f"/#/task/{t.id}")
return to_out(db, t, u, user_names(db), with_events=True)
@router.patch("/tasks/{id}", response_model=S.TaskOut)
def update_task(id: int, data: S.TaskPatch, db: Session = Depends(get_db), u: User = Depends(current_user)):
t = load(db, id, u)
if not can_edit(u, t):
raise HTTPException(403, "Изменять задание может его автор или инженер, пока оно не завершено")
d = data.model_dump(exclude_unset=True)
if d.get("assignee_id") and d["assignee_id"] != t.assignee_id:
if not is_boss(u):
raise HTTPException(403, "Переназначить задание может инженер, модератор или администратор")
get_or_404(db, User, d["assignee_id"])
if "unit_id" in d or "equipment_id" in d:
if d.get("equipment_id") is None and "equipment_id" in d:
d["unit_id"] = None
check_links(db, d, t)
for k, v in d.items():
if v is None and k in ("title", "day", "assignee_id", "description"):
continue
setattr(t, k, v)
t.updated_at = config.now()
commit(db)
return to_out(db, t, u, user_names(db), with_events=True)
@router.post("/tasks/{id}/status", response_model=S.TaskOut)
def change_status(id: int, data: S.TaskStatusIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
t = load(db, id, u)
if not can_change(u, t):
raise HTTPException(403, "Нет прав менять статус этого задания")
if data.status == t.status:
raise HTTPException(409, "Задание уже в этом статусе")
if data.status not in ALLOWED[t.status]:
raise HTTPException(422, f"Нельзя перевести из «{STATUS_RU[t.status]}» в «{STATUS_RU[data.status]}»")
comment = data.comment.strip()
if (data.status in NEED_COMMENT or t.status in FINAL) and not comment:
raise HTTPException(422, f"Для статуса «{STATUS_RU[data.status]}» нужен комментарий")
db.add(TaskEvent(task_id=t.id, user_id=u.id, from_status=t.status, to_status=data.status, comment=comment))
t.status, t.updated_at = data.status, config.now()
t.finished_at = config.now() if data.status in FINAL else None
commit(db)
who = u.full_name or u.username
notify = {t.assignee_id, t.created_by_id} - {u.id, None}
push.notify(list(notify), f"Задание: {STATUS_RU[t.status]}", f"{who}: {t.title[:100]}", f"/#/task/{t.id}")
return to_out(db, t, u, user_names(db), with_events=True)
@router.post("/tasks/{id}/transfer", response_model=S.TaskOut)
def transfer(id: int, data: S.TaskTransferIn, db: Session = Depends(get_db), u: User = Depends(current_user)):
"""Передать задание другому сотруднику. Комментарий обязателен; начатое задание снова становится «назначен»."""
t = load(db, id, u)
if not can_transfer(u, t):
raise HTTPException(403, "Передать задание может исполнитель, тот, кто его поставил, или инженер (пока оно не завершено)")
if data.assignee_id == t.assignee_id:
raise HTTPException(409, "Задание уже у этого сотрудника")
to = get_or_404(db, User, data.assignee_id)
if not to.is_active:
raise HTTPException(422, "Сотрудник отключён")
comment = data.comment.strip()
if not comment:
raise HTTPException(422, "Укажите причину передачи")
old = t.assignee_id
db.add(TaskEvent(task_id=t.id, user_id=u.id, kind="transfer", from_status=t.status, to_status="assigned", comment=comment,
from_user_id=old, to_user_id=to.id))
t.assignee_id, t.status, t.updated_at = to.id, "assigned", config.now()
commit(db)
who = u.full_name or u.username
push.notify([to.id], "Вам передано задание", f"{who}: {t.title[:100]}", f"/#/task/{t.id}")
push.notify(list({old, t.created_by_id} - {u.id, to.id, None}), "Задание передано",
f"{t.title[:80]} → {to.full_name or to.username}", f"/#/task/{t.id}")
return to_out(db, t, u, user_names(db), with_events=True)
@router.delete("/tasks/{id}", status_code=204)
def delete_task(id: int, db: Session = Depends(get_db), u: User = Depends(current_user)):
t = load(db, id, u)
if not (is_boss(u) or (t.created_by_id == u.id and t.status == "assigned")):
raise HTTPException(403, "Удалить можно только своё ещё не начатое задание")
for p in db.scalars(select(TaskPhoto).where(TaskPhoto.task_id == t.id)).all():
media.remove(db, "tasks", p.path)
db.delete(p)
db.execute(TaskEvent.__table__.delete().where(TaskEvent.task_id == t.id))
db.delete(t)
commit(db)
# ---------- фото ----------
@router.post("/tasks/{id}/photos", response_model=S.TaskOut, status_code=201)
async def add_photo(id: int, request: Request, db: Session = Depends(get_db), u: User = Depends(current_user)):
t = load(db, id, u)
if not (is_boss(u) or u.id in (t.assignee_id, t.created_by_id)):
raise HTTPException(403, "Нет прав добавлять фото")
data, ct, ext = await media.read_image(db, request)
name = media.save(db, "tasks", data, ext)
db.add(TaskPhoto(task_id=t.id, path=name, content_type=ct, size=len(data), user_id=u.id))
t.updated_at = config.now()
commit(db)
return to_out(db, t, u, user_names(db), with_events=True)
def photo_for(db: Session, pid: int, u: User) -> TaskPhoto:
p = db.get(TaskPhoto, pid)
if not p:
raise HTTPException(404, "Фото не найдено")
load(db, p.task_id, u)
return p
@router.get("/task-photos/{pid}")
def get_photo(pid: int, db: Session = Depends(get_db), u: User = Depends(current_user)):
return media.respond(db, "tasks", photo_for(db, pid, u).path)
@router.delete("/task-photos/{pid}", status_code=204)
def delete_photo(pid: int, db: Session = Depends(get_db), u: User = Depends(current_user)):
p = photo_for(db, pid, u)
if not (is_boss(u) or p.user_id == u.id):
raise HTTPException(403, "Удалить можно только своё фото")
media.remove(db, "tasks", p.path)
db.delete(p)
commit(db)