- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели) - автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов во внутренней сети Docker; значения из интерфейса важнее - локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API - .env.example, docs/DEPLOY.md (раздел 7а), README
272 lines
14 KiB
Python
272 lines
14 KiB
Python
"""Суточные задания — простые поручения на день (не наряд, без чек-листа).
|
||
|
||
Статусы: назначен → в работе → приостановлен / выполнен / отменён. Переход в «приостановлен», «выполнен» и «отменён»
|
||
обязательно с комментарием. К заданию можно привязать машину или её узел и приложить фото.
|
||
Инженер, модератор и администратор ставят задания кому угодно; остальные — только себе."""
|
||
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)
|