- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели) - автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов во внутренней сети Docker; значения из интерфейса важнее - локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API - .env.example, docs/DEPLOY.md (раздел 7а), README
206 lines
10 KiB
Python
206 lines
10 KiB
Python
"""Портал: иконки оборудования, карта завода (оборудование и зоны складов), карточка сотрудника."""
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from sqlalchemy import or_, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .. import config, logic, media, schemas as S, scope, settings_store as ss
|
|
from ..db import get_db
|
|
from ..models import DailyTask, Equipment, Order, Stock, User, Warehouse
|
|
from ..security import current_user, require_admin, require_engineer
|
|
from .catalog import commit, get_or_404, user_out
|
|
from .incidents import equipment_health
|
|
from .inventory import can_see_stock, stock_rows, wh_out
|
|
|
|
router = APIRouter(tags=["portal"])
|
|
ICONS_DIR = Path(__file__).resolve().parent.parent / "static" / "icons"
|
|
ICON_TITLES = {
|
|
"pump": "Насос", "motor": "Электродвигатель", "compressor": "Компрессор", "conveyor": "Конвейер",
|
|
"boiler": "Котёл", "fan": "Вентилятор", "crane": "Кран", "press": "Пресс", "tank": "Ёмкость",
|
|
"valve": "Задвижка", "generator": "Генератор", "transformer": "Трансформатор", "lathe": "Станок",
|
|
"forklift": "Погрузчик", "panel": "Электрощит", "hvac": "Кондиционер", "mixer": "Мешалка", "furnace": "Печь",
|
|
"gear": "Редуктор", "robot": "Робот",
|
|
}
|
|
|
|
|
|
# ---------- иконки оборудования ----------
|
|
@router.get("/icons")
|
|
def builtin_icons(_: User = Depends(current_user)):
|
|
"""Встроенный набор иконок: [{id: "builtin:pump", name, url}]"""
|
|
out = []
|
|
for f in sorted(ICONS_DIR.glob("*.svg")):
|
|
out.append({"id": "builtin:" + f.stem, "name": ICON_TITLES.get(f.stem, f.stem), "url": f"/static/icons/{f.name}"})
|
|
return out
|
|
|
|
|
|
@router.put("/equipment/{id}/icon", response_model=S.EquipmentOut)
|
|
async def upload_icon(id: int, request: Request, db: Session = Depends(get_db), _: User = Depends(require_engineer)):
|
|
"""Загрузить своё фото/картинку как иконку. Тело — сам файл (JPEG/PNG/WebP), до 2 МБ."""
|
|
eq = get_or_404(db, Equipment, id)
|
|
data, _ct, ext = await media.read_image(db, request, max_mb=2)
|
|
old = eq.icon
|
|
eq.icon = "file:" + media.save(db, "equipment", data, ext)
|
|
commit(db)
|
|
if old.startswith("file:"):
|
|
media.remove(db, "equipment", old[5:])
|
|
return eq
|
|
|
|
|
|
@router.get("/equipment/{id}/icon")
|
|
def get_icon(id: int, db: Session = Depends(get_db), _: User = Depends(current_user)):
|
|
eq = get_or_404(db, Equipment, id)
|
|
if not eq.icon.startswith("file:"):
|
|
raise HTTPException(404, "Своя картинка не загружена")
|
|
return media.respond(db, "equipment", eq.icon[5:])
|
|
|
|
|
|
# ---------- карта завода ----------
|
|
MapKind = Literal["plant", "crane"]
|
|
|
|
|
|
def map_image_key(db: Session, kind: str) -> str:
|
|
"""Ключ настройки с картинкой: у карты кранов своя картинка, если загружена, иначе — карта завода."""
|
|
return "crane_map_image" if kind == "crane" and ss.get(db, "crane_map_image") else "map_image"
|
|
|
|
|
|
@router.get("/map")
|
|
def plant_map(kind: MapKind = "plant", db: Session = Depends(get_db), u: User = Depends(current_user)):
|
|
"""Всё для отрисовки карты: есть ли картинка, оборудование с координатами и состоянием, зоны складов.
|
|
kind=crane — отдельная карта кранов (если включена в настройках); тогда краны на карте завода не показываются."""
|
|
cranes = scope.crane_map_enabled(db)
|
|
if kind == "crane" and not cranes:
|
|
raise HTTPException(404, "Карта кранов выключена в настройках")
|
|
health = equipment_health(db)
|
|
stmt = select(Equipment).where(Equipment.is_active.is_(True)).order_by(Equipment.name)
|
|
if cranes:
|
|
stmt = stmt.where(Equipment.is_crane.is_(kind == "crane"))
|
|
cond = scope.equipment_filter(db, u) # оборудование чужих отделов не показываем
|
|
if cond is not None:
|
|
stmt = stmt.where(cond)
|
|
eqs = db.scalars(stmt).all()
|
|
key = map_image_key(db, kind)
|
|
return {
|
|
"kind": kind, "crane_map": cranes, "own_image": key == "crane_map_image",
|
|
"has_image": bool(ss.get(db, key)),
|
|
"image_version": ss.get(db, key)[:8],
|
|
"equipment": [{"id": e.id, "name": e.name, "icon": e.icon, "x": e.map_x, "y": e.map_y,
|
|
**health.get(e.id, {"state": "ok", "open_incidents": 0, "due": 0, "soon": 0})} for e in eqs],
|
|
"warehouses": [wh_out(db, w).model_dump() for w in db.scalars(select(Warehouse).where(Warehouse.is_active.is_(True))
|
|
.order_by(Warehouse.name)).all()],
|
|
"can_edit_equipment": bool(u.is_admin),
|
|
"can_edit_warehouses": bool(u.is_admin or u.is_storekeeper),
|
|
"can_see_stock": can_see_stock(u),
|
|
}
|
|
|
|
|
|
@router.get("/map/image")
|
|
def map_image(kind: MapKind = "plant", db: Session = Depends(get_db), _: User = Depends(current_user)):
|
|
return media.respond(db, "map", ss.get(db, map_image_key(db, kind)))
|
|
|
|
|
|
@router.put("/map/image", status_code=204)
|
|
async def upload_map(request: Request, kind: MapKind = "plant", db: Session = Depends(get_db), _: User = Depends(require_admin)):
|
|
"""Картинка карты завода (план цеха, схема территории) или карты кранов (kind=crane). Только администратор, до 20 МБ."""
|
|
data, _ct, ext = await media.read_image(db, request, max_mb=20)
|
|
key = "crane_map_image" if kind == "crane" else "map_image"
|
|
old = ss.get(db, key)
|
|
ss.set_(db, key, media.save(db, "map", data, ext))
|
|
commit(db)
|
|
if old:
|
|
media.remove(db, "map", old)
|
|
|
|
|
|
@router.put("/equipment/{id}/map", response_model=S.EquipmentOut)
|
|
def place_marker(id: int, data: S.MapMarkerIn, db: Session = Depends(get_db), _: User = Depends(require_admin)):
|
|
"""Поставить иконку оборудования на карту (x, y в % от картинки) или убрать (null)."""
|
|
eq = get_or_404(db, Equipment, id)
|
|
if (data.x is None) != (data.y is None):
|
|
raise HTTPException(422, "Нужны обе координаты или ни одной")
|
|
eq.map_x, eq.map_y = data.x, data.y
|
|
commit(db)
|
|
return eq
|
|
|
|
|
|
# ---------- карточка сотрудника ----------
|
|
def can_edit_profile(me: User, target: User) -> bool:
|
|
if me.id == target.id or me.is_admin:
|
|
return True
|
|
return me.is_moderator and not target.is_admin
|
|
|
|
|
|
@router.get("/users/{id}/card")
|
|
def user_card(id: int, db: Session = Depends(get_db), me: User = Depends(current_user)):
|
|
"""ФИО, фото, должность, график (ближайшие смены), что умеет, текущие задачи и наряды, выданный инвентарь."""
|
|
u = get_or_404(db, User, id)
|
|
now = config.now()
|
|
sch = u.schedule
|
|
shifts = []
|
|
if sch:
|
|
for i in range(14):
|
|
sh = logic.shift_starting(sch, now.date() + timedelta(days=i))
|
|
if sh and sh[1] > now:
|
|
shifts.append({"start": config.iso(sh[0]), "end": config.iso(sh[1]), "label": sh[2]})
|
|
if len(shifts) >= 7:
|
|
break
|
|
tasks = db.scalars(select(DailyTask).where(DailyTask.assignee_id == id, DailyTask.status.not_in(("done", "cancelled")))
|
|
.order_by(DailyTask.day, DailyTask.id)).all()
|
|
orders = db.scalars(select(Order).where(Order.status.in_(("new", "in_progress", "review")),
|
|
or_(Order.executor_id == id, Order.assignee_user_id == id))
|
|
.order_by(Order.due_at, Order.id).limit(50)).all()
|
|
show_inv = me.id == id or can_see_stock(me)
|
|
return {
|
|
"user": user_out(u).model_dump(mode="json"),
|
|
"schedule": {"name": sch.name if sch else None, "shifts": shifts,
|
|
"always": u.position is not None and sch is None},
|
|
"tasks": [{"id": t.id, "title": t.title, "day": t.day.isoformat(), "status": t.status} for t in tasks],
|
|
"orders": [{"id": o.id, "title": o.title, "status": o.status, "due_at": config.iso(o.due_at)} for o in orders],
|
|
"inventory": [r.model_dump(mode="json") for r in stock_rows(db, select(Stock).where(Stock.user_id == id))] if show_inv else None,
|
|
"can_edit": can_edit_profile(me, u),
|
|
}
|
|
|
|
|
|
@router.patch("/users/{id}/profile", response_model=S.UserOut)
|
|
def update_profile(id: int, data: S.ProfileIn, db: Session = Depends(get_db), me: User = Depends(current_user)):
|
|
"""«Что умею» и телефон: сам сотрудник, модератор или администратор."""
|
|
u = get_or_404(db, User, id)
|
|
if not can_edit_profile(me, u):
|
|
raise HTTPException(403, "Нет прав изменять карточку")
|
|
for k, v in data.model_dump(exclude_unset=True).items():
|
|
if v is not None:
|
|
setattr(u, k, v.strip())
|
|
commit(db)
|
|
return user_out(u)
|
|
|
|
|
|
@router.put("/users/{id}/photo", response_model=S.UserOut)
|
|
async def upload_user_photo(id: int, request: Request, db: Session = Depends(get_db), me: User = Depends(current_user)):
|
|
u = get_or_404(db, User, id)
|
|
if not can_edit_profile(me, u):
|
|
raise HTTPException(403, "Нет прав изменять карточку")
|
|
data, _ct, ext = await media.read_image(db, request, max_mb=5)
|
|
old = u.photo
|
|
u.photo = media.save(db, "users", data, ext)
|
|
commit(db)
|
|
media.remove(db, "users", old)
|
|
return user_out(u)
|
|
|
|
|
|
@router.delete("/users/{id}/photo", response_model=S.UserOut)
|
|
def delete_user_photo(id: int, db: Session = Depends(get_db), me: User = Depends(current_user)):
|
|
u = get_or_404(db, User, id)
|
|
if not can_edit_profile(me, u):
|
|
raise HTTPException(403, "Нет прав изменять карточку")
|
|
media.remove(db, "users", u.photo)
|
|
u.photo = ""
|
|
commit(db)
|
|
return user_out(u)
|
|
|
|
|
|
@router.get("/users/{id}/photo")
|
|
def get_user_photo(id: int, db: Session = Depends(get_db), _: User = Depends(current_user)):
|
|
return media.respond(db, "users", get_or_404(db, User, id).photo)
|