Files
tech-service-portal/app/routers/tasks.py
T
darkorion e67c175f80 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

272 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Суточные задания — простые поручения на день (не наряд, без чек-листа).
Статусы: назначен → в работе → приостановлен / выполнен / отменён. Переход в «приостановлен», «выполнен» и «отменён»
обязательно с комментарием. К заданию можно привязать машину или её узел и приложить фото.
Инженер, модератор и администратор ставят задания кому угодно; остальные — только себе."""
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, photo_keep=bool(t.photo_keep), 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, keep=t.photo_keep)
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)