"""Суточные задания — простые поручения на день (не наряд, без чек-листа). Статусы: назначен → в работе → приостановлен / выполнен / отменён. Переход в «приостановлен», «выполнен» и «отменён» обязательно с комментарием. К заданию можно привязать машину или её узел и приложить фото. Инженер, модератор и администратор ставят задания кому угодно; остальные — только себе.""" 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)