"""Документация оборудования (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)