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
This commit is contained in:
darkorion
2026-10-13 13:27:20 +03:00
parent 6b8fca4f5a
commit e67c175f80
44 changed files with 2131 additions and 94 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ def put_config(data: ConfigIn, db: Session = Depends(get_db), _: User = Depends(
ss.set_(db, "assistant_" + k, ("1" if v else "0") if isinstance(v, bool) else str(v).strip())
commit(db)
c = A.cfg(db)
if c["enabled"] and (not c["model"] or not (c["api_key"] or "localhost" in c["base_url"] or "127.0.0.1" in c["base_url"])):
if c["enabled"] and (not c["model"] or not (c["api_key"] or A.autoconf.is_local(c["base_url"]))):
ss.set_(db, "assistant_enabled", "0")
commit(db)
raise HTTPException(422, "Для включения укажите модель и ключ API (без ключа — только локальная модель)")
+3
View File
@@ -190,6 +190,7 @@ def me(db: Session = Depends(get_db), u: User = Depends(current_user)):
out.crane_map = scope.crane_map_enabled(db)
from ..security import is_engineer, is_manager
out.reliability = ss.get(db, "reliability") != "0" and (is_manager(u) or is_engineer(u))
out.docs = bool(ss.get(db, "nc_url") and ss.get(db, "nc_user") and ss.get(db, "nc_password")) # документация из Nextcloud подключена
return out
@@ -357,6 +358,8 @@ def check_user_refs(db: Session, fields: dict):
raise HTTPException(422, "Отдел не найден")
if fields.get("position_id") and not db.get(Position, fields["position_id"]):
raise HTTPException(422, "Должность не найдена")
if fields.get("schedule_id") and not db.get(ShiftSchedule, fields["schedule_id"]):
raise HTTPException(422, "График не найден")
def guard_user_edit(me: User, target: User | None, fields: dict):
+191
View File
@@ -0,0 +1,191 @@
"""Документация оборудования (Nextcloud): настройки и проверка (администратор портала), проводник для привязки папок
(администратор и модератор), просмотр документов машины (кто видит машину), индекс и поиск для AI-помощника."""
import asyncio
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from .. import docs_index as DI, nextcloud as nc, scope, settings_store as ss
from ..db import SessionLocal, get_db
from ..models import DocIndex, Equipment, User
from ..security import current_user, require_manager, require_portal_admin
from .catalog import commit, get_or_404
router = APIRouter(tags=["docs"])
SECRETS = ("nc_password", "onlyoffice_jwt")
NOSNIFF = {"X-Content-Type-Options": "nosniff", "Cache-Control": "private, max-age=300"}
class DocsConfigIn(BaseModel):
nc_url: Optional[str] = Field(default=None, max_length=300)
nc_user: Optional[str] = Field(default=None, max_length=200)
nc_password: Optional[str] = Field(default=None, max_length=500) # пустая строка — удалить; null — не менять
nc_root: Optional[str] = Field(default=None, max_length=500)
onlyoffice_url: Optional[str] = Field(default=None, max_length=300)
onlyoffice_jwt: Optional[str] = Field(default=None, max_length=500)
portal_internal_url: Optional[str] = Field(default=None, max_length=300)
docs_ai: Optional[bool] = None
class LinkIn(BaseModel):
path: str = Field(default="", max_length=500) # пусто — отвязать
def public_cfg(db: Session) -> dict:
out = {k: ss.get(db, k) for k in ("nc_url", "nc_user", "nc_root", "onlyoffice_url", "portal_internal_url", "docs_index_at")}
out.update({k + "_set": bool(ss.get(db, k)) for k in SECRETS})
out["docs_ai"] = DI.enabled(db)
out["index"] = {**DI.state, "files": db.query(DocIndex).count(), "linked": db.query(Equipment).filter(Equipment.docs_path != "").count()}
return out
def _fail(e: nc.DocsError):
msg = str(e)
code = 502 if any(w in msg for w in ("недоступен", "ответил", "отклонил", "не отдал", "не смог")) else 404 if "не найден" in msg else 422
raise HTTPException(code, msg)
@router.get("/docs/config")
def get_config(db: Session = Depends(get_db), _: User = Depends(require_portal_admin)):
return public_cfg(db)
@router.put("/docs/config")
def put_config(data: DocsConfigIn, db: Session = Depends(get_db), _: User = Depends(require_portal_admin)):
d = data.model_dump(exclude_unset=True)
for k in ("nc_url", "onlyoffice_url", "portal_internal_url"):
if d.get(k) and not d[k].strip().lower().startswith(("https://", "http://")):
raise HTTPException(422, "Адрес должен начинаться с http:// или https://")
try:
if d.get("nc_root") is not None:
d["nc_root"] = nc.clean(d["nc_root"])
except nc.DocsError as e:
raise HTTPException(422, str(e))
for k, v in d.items():
if v is None:
continue
if isinstance(v, bool):
v = "1" if v else "0"
ss.set_(db, k, v.strip().rstrip("/") if k.endswith("url") else v.strip())
commit(db)
return public_cfg(db)
@router.post("/docs/test")
def test(db: Session = Depends(get_db), _: User = Depends(require_portal_admin)):
try:
return nc.check(nc.conf(db))
except nc.DocsError as e:
_fail(e)
@router.get("/docs/browse")
def browse(path: str = "", db: Session = Depends(get_db), _: User = Depends(require_manager)):
"""Проводник по документации для привязки папок к машинам: только папки и сколько в папке файлов."""
try:
rel = nc.clean(path)
items = nc.listdir(nc.conf(db), rel)
except nc.DocsError as e:
_fail(e)
linked: dict[str, list[str]] = {}
for name, p in db.query(Equipment.name, Equipment.docs_path).filter(Equipment.docs_path != ""):
linked.setdefault(p, []).append(name)
return {"path": rel, "items": [{**x, "linked": linked.get(x["path"], [])} for x in items if x["is_dir"]],
"files": sum(not x["is_dir"] for x in items)}
@router.put("/equipment/{id}/docs")
def link(id: int, data: LinkIn, db: Session = Depends(get_db), _: User = Depends(require_manager)):
eq = get_or_404(db, Equipment, id)
try:
rel = nc.clean(data.path)
if rel:
nc.listdir(nc.conf(db), rel) # папка должна существовать
except nc.DocsError as e:
_fail(e)
if rel != eq.docs_path:
db.query(DocIndex).filter(DocIndex.equipment_id == id).delete()
eq.docs_path = rel
commit(db)
return {"id": eq.id, "docs_path": eq.docs_path}
def _equipment(db: Session, u: User, id: int) -> Equipment:
eq = get_or_404(db, Equipment, id)
if not scope.user_sees(db, u, eq):
raise HTTPException(404, "Не найдено")
if not eq.docs_path:
raise HTTPException(404, "К машине не привязана папка документации")
return eq
@router.get("/equipment/{id}/docs")
def equipment_docs(id: int, sub: str = "", db: Session = Depends(get_db), u: User = Depends(current_user)):
"""Содержимое папки документации машины (sub — вложенная папка)."""
eq = _equipment(db, u, id)
base = nc.clean(eq.docs_path)
try:
sub = nc.clean(sub)
items = nc.listdir(nc.conf(db), nc.join(base, sub))
except nc.DocsError as e:
_fail(e)
return {"equipment_id": eq.id, "folder": base.rsplit("/", 1)[-1], "sub": sub,
"items": [{**x, "path": x["path"][len(base):].strip("/")} for x in items]}
@router.get("/equipment/{id}/docs/file")
def equipment_file(id: int, path: str = Query(min_length=1, max_length=1000), view: bool = False,
db: Session = Depends(get_db), u: User = Depends(current_user)):
"""Файл документации. view=1 — для просмотра: PDF, картинки и текст как есть, Office — PDF через ONLYOFFICE."""
eq = _equipment(db, u, id)
c = nc.conf(db)
try:
info = nc.stat(c, nc.join(eq.docs_path, nc.clean(path, allow_empty=False)))
if info["is_dir"]:
raise nc.DocsError("Это папка, а не файл")
k = info["kind"]
if view and k == "office":
return FileResponse(nc.to_pdf(c, info), media_type="application/pdf", headers=NOSNIFF,
filename=info["name"].rsplit(".", 1)[0] + ".pdf", content_disposition_type="inline")
f = nc.fetch(c, info)
except nc.DocsError as e:
_fail(e)
inline = view and k in ("pdf", "image", "text")
media = {"pdf": "application/pdf", "text": "text/plain; charset=utf-8"}.get(k) or nc.IMAGES.get(nc.ext_of(info["name"]))
return FileResponse(f, media_type=media if inline else "application/octet-stream", filename=info["name"], headers=NOSNIFF,
content_disposition_type="inline" if inline else "attachment")
@router.get("/docs/raw/{token}", include_in_schema=False)
def raw(token: str):
"""Исходник для ONLYOFFICE по короткой подписанной ссылке (без входа)."""
try:
f = nc.check_raw_token(token)
except nc.DocsError as e:
raise HTTPException(404, str(e))
return FileResponse(f, media_type="application/octet-stream", headers={"X-Content-Type-Options": "nosniff"})
# ---------- индекс и поиск (опция «Работа с документацией») ----------
def _rebuild_bg():
with SessionLocal() as db:
DI.rebuild(db)
@router.post("/docs/reindex", status_code=202)
async def reindex(db: Session = Depends(get_db), _: User = Depends(require_portal_admin)):
if not DI.enabled(db):
raise HTTPException(422, "Сначала включите «Работа с документацией»")
if not DI.state["running"]:
asyncio.get_running_loop().run_in_executor(None, _rebuild_bg)
return {"started": True}
@router.get("/docs/search")
def search(q: str = Query(min_length=2, max_length=200), db: Session = Depends(get_db), u: User = Depends(current_user)):
if not DI.enabled(db):
raise HTTPException(404, "Работа с документацией выключена")
return DI.search(db, u, q)
+26 -4
View File
@@ -9,10 +9,10 @@ from fastapi.responses import FileResponse
from sqlalchemy import select, func
from sqlalchemy.orm import Session
from .. import schemas as S, config, logic, push, settings_store as ss
from .. import schemas as S, config, logic, photo_cleanup, push, settings_store as ss, sysinfo
from ..db import get_db
from ..models import Comment, Photo, Order, OrderItem, User, PushSubscription, DeviceToken
from ..security import current_user, require_admin, is_manager, is_portal_admin
from ..security import current_user, require_admin, require_portal_admin, is_manager, is_portal_admin
from .catalog import commit
from .orders import load, exec_guard, start, names, can_execute, to_out
@@ -94,7 +94,7 @@ async def upload_photo(id: int, item_id: int, request: Request, db: Session = De
raise HTTPException(415, "Допустимы JPEG, PNG, WebP")
ct, ext = kind
root = ss.photo_root(db)
rel = Path(f"{config.now():%Y/%m}") / (ss.new_name() + ext)
rel = Path("keep" if item.photo_keep else "") / f"{config.now():%Y/%m}" / (ss.new_name() + ext) # keep/ — не удаляются очисткой
(root / rel).parent.mkdir(parents=True, exist_ok=True)
(root / rel).write_bytes(data)
db.add(Photo(order_id=o.id, item_id=item.id, path=rel.as_posix(), content_type=ct, size=len(data),
@@ -142,8 +142,30 @@ def dir_stats(root: Path) -> dict:
return {"files": len(files), "bytes": sum(x.stat().st_size for x in files), "disk_free": free}
def keep_stats(root: Path) -> dict:
a, b = dir_stats(root / "keep"), dir_stats(root / "media" / "tasks" / "keep")
return {"files": a["files"] + b["files"], "bytes": a["bytes"] + b["bytes"]}
@router.get("/photo-cleanup")
def cleanup_preview(db: Session = Depends(get_db), _: User = Depends(require_portal_admin)):
"""Сколько фото удалит очистка сейчас (ничего не удаляет)."""
return {**photo_cleanup.run(db, dry=True), "last_run": ss.get(db, "photo_cleanup_at") or None}
@router.post("/photo-cleanup")
def cleanup_now(db: Session = Depends(get_db), _: User = Depends(require_portal_admin)):
return photo_cleanup.run(db)
@router.get("/server/monitor")
def monitor(db: Session = Depends(get_db), _: User = Depends(require_portal_admin)):
"""Мониторинг сервера: загрузка процессора, память, место на дисках (данные портала и фото)."""
return {**sysinfo.snapshot({"Данные портала": Path(config.DATA_DIR), "Фото": ss.photo_root(db)}), "now": config.iso(config.now())}
def settings_payload(db: Session) -> dict:
return {**ss.all_editable(db), "photo_stats": dir_stats(ss.photo_root(db)),
return {**ss.all_editable(db), "photo_stats": dir_stats(ss.photo_root(db)), "photo_keep_stats": keep_stats(ss.photo_root(db)),
"vapid_public": push.public_key(db), "now": config.iso(config.now()),
"fcm_enabled": push.fcm_enabled(db), "timezone": str(config.TZ)}
+1 -1
View File
@@ -137,7 +137,7 @@ def user_card(id: int, db: Session = Depends(get_db), me: User = Depends(current
"""ФИО, фото, должность, график (ближайшие смены), что умеет, текущие задачи и наряды, выданный инвентарь."""
u = get_or_404(db, User, id)
now = config.now()
sch = u.position.schedule if u.position else None
sch = u.schedule
shifts = []
if sch:
for i in range(14):
+2 -2
View File
@@ -86,7 +86,7 @@ def to_out(db: Session, t: DailyTask, u: User, names: dict, with_events: bool =
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),
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),
@@ -241,7 +241,7 @@ async def add_photo(id: int, request: Request, db: Session = Depends(get_db), 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)
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)