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: настройки (секреты не утекают), проводник и привязка, права на просмотр, просмотр Office
|
||||
через ONLYOFFICE, индекс и поиск для опции AI «Работа с документацией». Nextcloud и ONLYOFFICE имитируются."""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "dc.db").replace("\\", "/"))
|
||||
os.environ.setdefault("SCHEDULER_INTERVAL", "3600")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-pytest-0123456789abcdef")
|
||||
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
|
||||
|
||||
import httpx # noqa: E402
|
||||
import jwt # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from app import assistant as A, docs_index as DI, nextcloud as nc # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
|
||||
V1 = "/api/v1"
|
||||
PW = "Pass1234567"
|
||||
DAV = "/remote.php/dav/files/svc"
|
||||
|
||||
|
||||
def docx(text: str) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as z:
|
||||
z.writestr("word/document.xml", f'<w:document xmlns:w="w"><w:body><w:p><w:r><w:t>{text}</w:t></w:r></w:p></w:body></w:document>')
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
TREE = { # путь в учётной записи → содержимое (None — папка)
|
||||
"Документы": None,
|
||||
"Документы/DC-Компрессор": None,
|
||||
"Документы/DC-Компрессор/Паспорт.pdf": b"%PDF-1.4 fake",
|
||||
"Документы/DC-Компрессор/notes.txt": "Давление масла в норме 4 бар".encode(),
|
||||
"Документы/DC-Компрессор/Схемы": None,
|
||||
"Документы/DC-Компрессор/Схемы/Электросхема.docx": docx("Схема подключения двигателя М1, пускатель КМ2"),
|
||||
"Документы/DC-Компрессор/.скрытый": b"x",
|
||||
"Документы/DC-Пресс": None,
|
||||
}
|
||||
|
||||
|
||||
def multistatus(paths: list[str]) -> bytes:
|
||||
rows = []
|
||||
for p in paths:
|
||||
body = TREE[p]
|
||||
rt = "<d:collection/>" if body is None else ""
|
||||
rows.append(f"<d:response><d:href>{quote(DAV + '/' + p)}{'/' if body is None else ''}</d:href><d:propstat><d:prop>"
|
||||
f"<d:resourcetype>{rt}</d:resourcetype><d:getcontentlength>{len(body or b'')}</d:getcontentlength>"
|
||||
f"<d:getlastmodified>Mon, 12 Oct 2026 10:00:00 GMT</d:getlastmodified><d:getetag>\"e{len(body or b'')}\"</d:getetag>"
|
||||
f"</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>")
|
||||
return ('<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">' + "".join(rows) + "</d:multistatus>").encode()
|
||||
|
||||
|
||||
def nextcloud(request: httpx.Request) -> httpx.Response:
|
||||
if request.headers.get("authorization") != httpx.BasicAuth("svc", "app-pass-1234")._auth_header:
|
||||
return httpx.Response(401)
|
||||
path = unquote(request.url.path)
|
||||
assert path.startswith(DAV)
|
||||
rel = path[len(DAV):].strip("/")
|
||||
if rel not in TREE:
|
||||
return httpx.Response(404)
|
||||
if request.method == "PROPFIND":
|
||||
kids = [p for p in TREE if p.startswith(rel + "/") and "/" not in p[len(rel) + 1:]] if request.headers["depth"] == "1" else []
|
||||
return httpx.Response(207, content=multistatus([rel] + kids))
|
||||
return httpx.Response(200, content=TREE[rel])
|
||||
|
||||
|
||||
converted = []
|
||||
|
||||
|
||||
def onlyoffice(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/healthcheck":
|
||||
return httpx.Response(200, text="true")
|
||||
if request.url.path == "/ConvertService.ashx":
|
||||
body = json.loads(request.content)
|
||||
assert jwt.decode(body["token"], "oo-secret", algorithms=["HS256"])["url"] == body["url"]
|
||||
assert body["url"].startswith("http://portal.lan:8000/api/v1/docs/raw/") and body["outputtype"] == "pdf"
|
||||
src = nc.check_raw_token(body["url"].rsplit("/", 1)[1]).read_bytes() # то, что скачал бы ONLYOFFICE
|
||||
converted.append((body, src))
|
||||
return httpx.Response(200, json={"endConvert": True, "fileUrl": "http://oo.lan/cache/out.pdf", "percent": 100})
|
||||
if request.url.path == "/cache/out.pdf":
|
||||
return httpx.Response(200, content=b"%PDF-1.7 converted")
|
||||
return httpx.Response(404)
|
||||
|
||||
|
||||
def test_docs(monkeypatch):
|
||||
monkeypatch.setattr(nc, "_client", lambda c: httpx.Client(transport=httpx.MockTransport(nextcloud), auth=(c.user, c.password)))
|
||||
monkeypatch.setattr(nc, "_oo_client", lambda timeout: httpx.Client(transport=httpx.MockTransport(onlyoffice)))
|
||||
with TestClient(app) as c:
|
||||
login = lambda u, p=PW: {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": u, "password": p}).json()["access_token"]}
|
||||
adm = login("admin", "admin")
|
||||
d1 = c.post(f"{V1}/departments", headers=adm, json={"name": "DC-Компрессорная"}).json()
|
||||
d2 = c.post(f"{V1}/departments", headers=adm, json={"name": "DC-Прессовый"}).json()
|
||||
comp = c.post(f"{V1}/equipment", headers=adm, json={"name": "DC-Компрессор", "department_id": d1["id"]}).json()
|
||||
c.post(f"{V1}/users", headers=adm, json={"username": "dc_mod", "full_name": "Модератор", "password": PW, "is_moderator": True})
|
||||
c.post(f"{V1}/users", headers=adm, json={"username": "dc_t1", "full_name": "Техник компрессорной", "password": PW, "department_id": d1["id"]})
|
||||
c.post(f"{V1}/users", headers=adm, json={"username": "dc_t2", "full_name": "Техник прессового", "password": PW, "department_id": d2["id"]})
|
||||
mod, t1, t2 = login("dc_mod"), login("dc_t1"), login("dc_t2")
|
||||
|
||||
# настройки — только администратор портала, секреты не возвращаются
|
||||
assert c.put(f"{V1}/docs/config", headers=mod, json={"nc_url": "http://nc.lan"}).status_code == 403
|
||||
assert c.get(f"{V1}/docs/browse", headers=mod).status_code == 422 # ещё не настроено
|
||||
assert c.put(f"{V1}/docs/config", headers=adm, json={"nc_url": "ftp://x"}).status_code == 422
|
||||
assert c.put(f"{V1}/docs/config", headers=adm, json={"nc_root": "a/../b"}).status_code == 422
|
||||
r = c.put(f"{V1}/docs/config", headers=adm, json={"nc_url": "http://nc.lan/", "nc_user": "svc", "nc_password": "app-pass-1234",
|
||||
"nc_root": "/Документы/", "onlyoffice_url": "http://oo.lan", "onlyoffice_jwt": "oo-secret",
|
||||
"portal_internal_url": "http://portal.lan:8000"})
|
||||
assert r.status_code == 200 and r.json()["nc_password_set"] and r.json()["onlyoffice_jwt_set"] and r.json()["nc_root"] == "Документы"
|
||||
assert "app-pass" not in r.text and "oo-secret" not in r.text
|
||||
assert "app-pass" not in c.get(f"{V1}/settings", headers=adm).text
|
||||
t = c.post(f"{V1}/docs/test", headers=adm).json()
|
||||
assert "2 папок" in t["nextcloud"] and t["onlyoffice"] == "работает"
|
||||
|
||||
# проводник и привязка: администратор или модератор, техник — нет
|
||||
assert c.get(f"{V1}/docs/browse", headers=t1).status_code == 403
|
||||
b = c.get(f"{V1}/docs/browse", headers=mod).json()
|
||||
assert [x["name"] for x in b["items"]] == ["DC-Компрессор", "DC-Пресс"]
|
||||
assert [x["name"] for x in c.get(f"{V1}/docs/browse", headers=mod, params={"path": "DC-Компрессор"}).json()["items"]] == ["Схемы"]
|
||||
assert c.get(f"{V1}/docs/browse", headers=mod, params={"path": "../etc"}).status_code == 422
|
||||
assert c.put(f"{V1}/equipment/{comp['id']}/docs", headers=t1, json={"path": "DC-Компрессор"}).status_code == 403
|
||||
assert c.put(f"{V1}/equipment/{comp['id']}/docs", headers=mod, json={"path": "Нет такой"}).status_code == 404
|
||||
assert c.put(f"{V1}/equipment/{comp['id']}/docs", headers=mod, json={"path": "DC-Компрессор"}).json()["docs_path"] == "DC-Компрессор"
|
||||
assert c.get(f"{V1}/docs/browse", headers=mod).json()["items"][0]["linked"] == ["DC-Компрессор"]
|
||||
|
||||
# просмотр: кто видит машину; скрытые файлы не показываются; выйти из папки машины нельзя
|
||||
assert c.get(f"{V1}/equipment/{comp['id']}/docs", headers=t2).status_code == 404
|
||||
ls = c.get(f"{V1}/equipment/{comp['id']}/docs", headers=t1).json()
|
||||
assert [(x["name"], x["kind"]) for x in ls["items"]] == [("Схемы", "dir"), ("notes.txt", "text"), ("Паспорт.pdf", "pdf")]
|
||||
assert ls["items"][2]["path"] == "Паспорт.pdf"
|
||||
sub = c.get(f"{V1}/equipment/{comp['id']}/docs", headers=t1, params={"sub": "Схемы"}).json()
|
||||
assert sub["items"][0]["path"] == "Схемы/Электросхема.docx" and sub["items"][0]["kind"] == "office"
|
||||
assert c.get(f"{V1}/equipment/{comp['id']}/docs/file", headers=t1, params={"path": "../DC-Пресс"}).status_code == 422
|
||||
f = c.get(f"{V1}/equipment/{comp['id']}/docs/file", headers=t1, params={"path": "Паспорт.pdf", "view": 1})
|
||||
assert f.status_code == 200 and f.headers["content-type"] == "application/pdf" and f.content == b"%PDF-1.4 fake"
|
||||
assert f.headers["content-disposition"].startswith("inline") and f.headers["x-content-type-options"] == "nosniff"
|
||||
f = c.get(f"{V1}/equipment/{comp['id']}/docs/file", headers=t1, params={"path": "Схемы/Электросхема.docx"})
|
||||
assert f.headers["content-disposition"].startswith("attachment") and f.content == TREE["Документы/DC-Компрессор/Схемы/Электросхема.docx"]
|
||||
# Office → PDF через ONLYOFFICE: исходник отдаётся по подписанной ссылке, результат кэшируется
|
||||
f = c.get(f"{V1}/equipment/{comp['id']}/docs/file", headers=t1, params={"path": "Схемы/Электросхема.docx", "view": 1})
|
||||
assert f.status_code == 200, f.text
|
||||
assert f.content == b"%PDF-1.7 converted" and converted[0][1] == TREE["Документы/DC-Компрессор/Схемы/Электросхема.docx"]
|
||||
c.get(f"{V1}/equipment/{comp['id']}/docs/file", headers=t1, params={"path": "Схемы/Электросхема.docx", "view": 1})
|
||||
assert len(converted) == 1
|
||||
raw = converted[0][0]["url"].split("/api/v1")[1]
|
||||
assert c.get(V1 + raw).content == converted[0][1]
|
||||
assert c.get(V1 + raw[:-3] + "abc").status_code == 404
|
||||
|
||||
# опция AI выключена — поиска нет; включается только администратором портала
|
||||
assert c.get(f"{V1}/docs/search", headers=t1, params={"q": "схема"}).status_code == 404
|
||||
assert c.post(f"{V1}/docs/reindex", headers=adm).status_code == 422
|
||||
assert c.put(f"{V1}/docs/config", headers=adm, json={"docs_ai": True}).json()["docs_ai"] is True
|
||||
assert c.post(f"{V1}/docs/reindex", headers=mod).status_code == 403
|
||||
assert c.post(f"{V1}/docs/reindex", headers=adm).status_code == 202
|
||||
for _ in range(100):
|
||||
time.sleep(0.05)
|
||||
if not DI.state["running"] and c.get(f"{V1}/docs/config", headers=adm).json()["index"]["files"]:
|
||||
break
|
||||
idx = c.get(f"{V1}/docs/config", headers=adm).json()["index"]
|
||||
assert idx["files"] == 3 and idx["linked"] == 1, idx
|
||||
hits = c.get(f"{V1}/docs/search", headers=t1, params={"q": "схема двигателя"}).json()
|
||||
assert hits[0]["path"] == "Схемы/Электросхема.docx" and "двигателя м1" in hits[0]["snippet"]
|
||||
assert c.get(f"{V1}/docs/search", headers=t1, params={"q": "давление масла"}).json()[0]["name"] == "notes.txt"
|
||||
assert c.get(f"{V1}/docs/search", headers=t2, params={"q": "схема двигателя"}).json() == []
|
||||
|
||||
# помощник: инструменты документации есть только при включённой опции
|
||||
c.put(f"{V1}/assistant/config", headers=adm, json={"enabled": True, "provider": "openai", "model": "m", "api_key": "sk-test-0000",
|
||||
"base_url": "https://llm.example/v1"})
|
||||
calls = []
|
||||
|
||||
async def fake(cfg, system, messages):
|
||||
calls.append((system, messages[-1]["content"]))
|
||||
if messages[-1]["content"].startswith("РЕЗУЛЬТАТ"):
|
||||
return "Смотрите [Электросхема.docx](#/docs/1?p=Схемы/Электросхема.docx)"
|
||||
return 'CALL search_docs {"q": "схема двигателя"}'
|
||||
monkeypatch.setattr(A, "complete", fake)
|
||||
r = c.post(f"{V1}/assistant/chat", headers=t1, json={"messages": [{"role": "user", "content": "Где схема двигателя компрессора?"}]}).json()
|
||||
assert r["tools"] == ["search_docs"] and "search_docs" in calls[0][0] and "Электросхема.docx" in calls[1][1]
|
||||
c.put(f"{V1}/docs/config", headers=adm, json={"docs_ai": False})
|
||||
c.post(f"{V1}/assistant/chat", headers=t1, json={"messages": [{"role": "user", "content": "Где схема?"}]})
|
||||
assert "search_docs" not in calls[-2][0] and "нет инструмента" in calls[-1][1]
|
||||
c.put(f"{V1}/assistant/config", headers=adm, json={"enabled": False, "api_key": "", "model": "", "base_url": ""}) # как было
|
||||
|
||||
# отвязка чистит индекс
|
||||
assert c.put(f"{V1}/equipment/{comp['id']}/docs", headers=adm, json={"path": ""}).json()["docs_path"] == ""
|
||||
assert c.get(f"{V1}/docs/config", headers=adm).json()["index"]["files"] == 0
|
||||
assert c.get(f"{V1}/equipment/{comp['id']}/docs", headers=t1).status_code == 404
|
||||
Reference in New Issue
Block a user