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:
@@ -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)
|
||||
Reference in New Issue
Block a user