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
+11 -7
View File
@@ -47,14 +47,12 @@ def test_full_flow():
"weekdays": [0, 1, 2, 3, 4, 5, 6], "start_time": "00:00:00", "end_time": "23:59:00"}).json()
night = c.post("/api/schedules", headers=adm, json={"name": "Выходной", "kind": "weekly",
"weekdays": []}).json()
tech = c.post("/api/positions", headers=adm, json={"name": "Слесарь", "kind": "technician",
"schedule_id": sch["id"]}).json()
tech2 = c.post("/api/positions", headers=adm, json={"name": "Слесарь (выходной)", "kind": "technician",
"schedule_id": night["id"]}).json()
# одну должность занимают сменщики: график у каждого свой
tech = c.post("/api/positions", headers=adm, json={"name": "Слесарь", "kind": "technician"}).json()
eng = c.post("/api/positions", headers=adm, json={"name": "Инженер", "kind": "engineer"}).json()
for name, pos in (("ivan", tech), ("petr", tech2), ("eng", eng)):
for name, pos, sid in (("ivan", tech, sch["id"]), ("petr", tech, night["id"]), ("eng", eng, None)):
r = c.post("/api/users", headers=adm, json={"username": name, "password": "Pass1234567",
"position_id": pos["id"]})
"position_id": pos["id"], "schedule_id": sid})
assert r.status_code == 201, r.text
ivan, petr, engh = login("ivan", "Pass1234567"), login("petr", "Pass1234567"), login("eng", "Pass1234567")
@@ -80,7 +78,13 @@ def test_full_flow():
assert tpl["next_run"]
order_id = c.post(f"/api/order-templates/{tpl['id']}/run-now", headers=engh).json()["order_id"]
# видимость: Иван (на смене) видит, Пётр (выходной) — нет
# видимость: Иван (на смене) видит, его сменщик Пётр (выходной) — нет; уведомление — только Ивану
from app import push
from app.db import SessionLocal
with SessionLocal() as db:
ids_ = {u["username"]: u["id"] for u in c.get("/api/users", headers=adm).json()}
assert push.users_for_assignment(db, None, tech["id"]) == [ids_["ivan"]]
assert c.get("/api/me", headers=petr).json()["on_shift"] is False
assert [o["id"] for o in c.get("/api/orders", headers=ivan).json()] == [order_id]
assert c.get("/api/orders", headers=petr).json() == []
assert c.get(f"/api/orders/{order_id}", headers=petr).status_code == 404
+56
View File
@@ -0,0 +1,56 @@
"""Автонастройка: значения из окружения и сервисов во внутренней сети Docker; сохранённое в интерфейсе важнее."""
import os
import tempfile
os.environ.setdefault("DATABASE_URL", os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "ac.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())
from fastapi.testclient import TestClient # noqa: E402
from app import assistant, autoconf, settings_store as ss # noqa: E402
from app.db import SessionLocal # noqa: E402
from app.main import app # noqa: E402
V1 = "/api/v1"
def test_autoconf_from_docker_network_and_env(monkeypatch):
running = {"nextcloud", "onlyoffice", "ollama"}
monkeypatch.setattr(autoconf, "resolves", lambda host: host in running)
monkeypatch.setenv("NC_USER", "ncadmin")
monkeypatch.setenv("NC_PASSWORD", "nc-secret-pass")
monkeypatch.setenv("OLLAMA_MODEL", "qwen2.5:3b")
monkeypatch.delenv("NC_URL", raising=False)
with TestClient(app) as c:
adm = {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": "admin", "password": "admin"}).json()["access_token"]}
cfg = c.get(f"{V1}/docs/config", headers=adm).json()
assert cfg["nc_url"] == "http://nextcloud" and cfg["nc_user"] == "ncadmin" and cfg["nc_password_set"]
assert cfg["onlyoffice_url"] == "http://onlyoffice" and cfg["portal_internal_url"] == "http://app:8000"
assert "nc-secret" not in str(cfg)
assert c.get(f"{V1}/me", headers=adm).json()["docs"] is True
a = c.get(f"{V1}/assistant/config", headers=adm).json()
assert a["base_url"] == "http://ollama:11434/v1" and a["model"] == "qwen2.5:3b" and not a["enabled"]
# локальная модель в сети Docker включается без ключа
assert c.put(f"{V1}/assistant/config", headers=adm, json={"enabled": True}).status_code == 200
with SessionLocal() as db:
assert assistant.ready(db)
c.put(f"{V1}/assistant/config", headers=adm, json={"enabled": False})
# значение из интерфейса важнее автоматического
c.put(f"{V1}/docs/config", headers=adm, json={"nc_url": "https://cloud.example.ru"})
assert c.get(f"{V1}/docs/config", headers=adm).json()["nc_url"] == "https://cloud.example.ru"
with SessionLocal() as db:
ss.set_(db, "nc_url", "")
db.commit()
# сервисов нет — пусто, Nextcloud не подключён
running.clear()
monkeypatch.delenv("NC_USER")
with SessionLocal() as db:
assert ss.get(db, "nc_url") == "" and ss.get(db, "onlyoffice_url") == "" and ss.get(db, "portal_internal_url") == ""
def test_is_local():
assert autoconf.is_local("http://ollama:11434/v1") and autoconf.is_local("http://localhost:11434/v1")
assert autoconf.is_local("http://192.168.1.50:11434/v1") and autoconf.is_local("http://10.0.0.5/v1")
assert not autoconf.is_local("https://api.openai.com/v1") and not autoconf.is_local("http://8.8.8.8/v1") and not autoconf.is_local("")
+191
View File
@@ -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
+115
View File
@@ -0,0 +1,115 @@
"""График у сотрудника (сменщики на одной должности), автоочистка старых фото с каталогом keep/, мониторинг сервера."""
import os
import tempfile
from datetime import timedelta
os.environ.setdefault("DATABASE_URL", os.environ.get("TEST_DATABASE_URL") or "sqlite:///" + os.path.join(tempfile.mkdtemp(), "tw.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())
from fastapi.testclient import TestClient # noqa: E402
from app import config, logic, photo_cleanup, settings_store as ss # noqa: E402
from app.db import SessionLocal # noqa: E402
from app.main import app # noqa: E402
from app.models import Photo, Position, TaskPhoto, User # noqa: E402
V1 = "/api/v1"
PW = "Pass1234567"
PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 64
def test_schedule_moves_from_position_to_user():
with TestClient(app) as c:
adm = {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": "admin", "password": "admin"}).json()["access_token"]}
off = c.post(f"{V1}/schedules", headers=adm, json={"name": "TW-выходной", "kind": "weekly", "weekdays": []}).json()
assert c.post(f"{V1}/users", headers=adm, json={"username": "tw_bad", "password": PW, "schedule_id": 999999}).status_code == 422
# старые данные: график задан должности — однократный перенос ставит его сотрудникам без своего графика
with SessionLocal() as db:
p = Position(name="TW-Дежурный", kind="technician", schedule_id=off["id"])
db.add(p)
db.flush()
db.add_all([User(username="tw_old1", password_hash="x", position_id=p.id), User(username="tw_old2", password_hash="x", position_id=p.id)])
ss.set_(db, "schedule_per_user", "0")
db.commit()
assert logic.schedules_to_users(db) >= 2
assert logic.schedules_to_users(db) == 0 # повторно не выполняется
u1 = db.query(User).filter_by(username="tw_old1").one()
assert u1.schedule_id == off["id"] and not logic.user_on_shift(u1)
u1.schedule_id = None # свой график убрали — всегда на смене, график должности больше не влияет
db.commit()
db.refresh(u1)
assert logic.user_on_shift(u1)
r = c.get(f"{V1}/users", headers=adm).json()
assert next(u for u in r if u["username"] == "tw_old2")["schedule_id"] == off["id"]
def test_photo_cleanup_keeps_marked_photos():
with TestClient(app) as c:
adm = {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": "admin", "password": "admin"}).json()["access_token"]}
cl = c.post(f"{V1}/checklists", headers=adm, json={"name": "TW-фото", "items": [
{"text": "Общий вид", "answer_type": "status", "photo": "optional"},
{"text": "Табличка (хранить)", "answer_type": "status", "photo": "optional", "photo_keep": True}]}).json()
assert [i["photo_keep"] for i in cl["items"]] == [False, True]
pos = c.post(f"{V1}/positions", headers=adm, json={"name": "TW-слесарь", "kind": "technician"}).json()
tech = c.post(f"{V1}/users", headers=adm, json={"username": "tw_tech", "password": PW, "position_id": pos["id"]}).json()
th = {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": "tw_tech", "password": PW}).json()["access_token"]}
o = c.post(f"{V1}/orders", headers=adm, json={"title": "TW-наряд", "checklist_id": cl["id"], "assignee_user_id": tech["id"]}).json()
a, b = o["items"]
assert b["photo_keep"] is True
for it in (a, b):
r = c.post(f"{V1}/orders/{o['id']}/items/{it['id']}/photos", headers={**th, "Content-Type": "image/png"}, content=PNG)
assert r.status_code in (200, 201), r.text
t = c.post(f"{V1}/tasks", headers=adm, json={"title": "TW-задание", "photo_keep": True}).json()
assert t["photo_keep"] is True
c.post(f"{V1}/tasks/{t['id']}/photos", headers={**adm, "Content-Type": "image/png"}, content=PNG)
t2 = c.post(f"{V1}/tasks", headers=adm, json={"title": "TW-задание 2"}).json()
c.post(f"{V1}/tasks/{t2['id']}/photos", headers={**adm, "Content-Type": "image/png"}, content=PNG)
with SessionLocal() as db:
root = ss.photo_root(db)
ph = {p.item_id: p for p in db.query(Photo).filter(Photo.order_id == o["id"])}
assert ph[b["id"]].path.startswith("keep/") and not ph[a["id"]].path.startswith("keep/")
tp = {p.task_id: p for p in db.query(TaskPhoto).filter(TaskPhoto.task_id.in_([t["id"], t2["id"]]))}
assert tp[t["id"]].path.startswith("keep/") and not tp[t2["id"]].path.startswith("keep/")
files = [root / ph[a["id"]].path, root / ph[b["id"]].path, root / "media" / "tasks" / tp[t["id"]].path, root / "media" / "tasks" / tp[t2["id"]].path]
assert all(f.is_file() for f in files)
# всё «состарим» на два года
old = config.now() - timedelta(days=730)
for p in list(ph.values()) + list(tp.values()):
p.created_at = old
db.commit()
# выключено по умолчанию — ничего не удаляется
assert c.get(f"{V1}/photo-cleanup", headers=adm).json()["enabled"] is False
assert c.put(f"{V1}/settings", headers=adm, json={"photo_keep_days": 365}).status_code == 200
prev = c.get(f"{V1}/photo-cleanup", headers=adm).json()
assert prev["enabled"] and prev["photos"] >= 2
assert all(f.is_file() for f in files) # предпросмотр ничего не удаляет
r = c.post(f"{V1}/photo-cleanup", headers=adm).json()
assert r["photos"] >= 2
assert not files[0].exists() and files[1].is_file() and files[2].is_file() and not files[3].exists()
with SessionLocal() as db:
assert not photo_cleanup.due(db)
# фото «хранить» по-прежнему открывается
o2 = c.get(f"{V1}/orders/{o['id']}", headers=adm).json()
assert [len(i["photos"]) for i in o2["items"]] == [0, 1]
assert c.get(f"{V1}/photos/{o2['items'][1]['photos'][0]['id']}", headers=adm).status_code == 200
tid = c.get(f"{V1}/tasks/{t['id']}", headers=adm).json()["photos"][0]
assert c.get(f"{V1}/task-photos/{tid}", headers=adm).status_code == 200
s = c.get(f"{V1}/settings", headers=adm).json()
assert s["photo_keep_days"] == "365" and s["photo_keep_stats"]["files"] >= 2
c.put(f"{V1}/settings", headers=adm, json={"photo_keep_days": 0})
def test_server_monitor():
with TestClient(app) as c:
adm = {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": "admin", "password": "admin"}).json()["access_token"]}
c.post(f"{V1}/users", headers=adm, json={"username": "tw_adm", "password": PW, "is_admin": True})
adm2 = {"Authorization": "Bearer " + c.post(f"{V1}/auth/login", json={"username": "tw_adm", "password": PW}).json()["access_token"]}
assert c.get(f"{V1}/server/monitor", headers=adm2).status_code == 403 # только администратор портала
m = c.get(f"{V1}/server/monitor", headers=adm).json()
assert m["cpu"] is None or 0 <= m["cpu"] <= 100
assert m["memory"] is None or 0 < m["memory"]["used"] <= m["memory"]["total"]
assert m["disks"] and all(0 < d["used"] <= d["total"] for d in m["disks"])
+5 -2
View File
@@ -77,7 +77,8 @@ def env():
# ---------------------------------------------------------------- аутентификация
PUBLIC = {("POST", "/auth/login"), ("POST", "/auth/refresh"), ("POST", "/auth/logout"),
("GET", "/health"), ("GET", "/demo/status"),
("GET", "/public/report-options"), ("POST", "/public/incidents"), ("GET", "/app/version")} # публичная форма инцидента: без входа, с лимитом
("GET", "/public/report-options"), ("POST", "/public/incidents"), ("GET", "/app/version"),
("GET", "/docs/raw/{token}")} # исходник для ONLYOFFICE: только по короткой подписанной ссылке (проверка — test_scope_docs) # публичная форма инцидента: без входа, с лимитом
def test_every_api_route_requires_auth(env):
@@ -392,7 +393,9 @@ def test_stored_photo_name_is_server_generated(env):
from app.models import Photo
with SessionLocal() as s:
for p in s.query(Photo).all():
assert ".." not in p.path and not p.path.startswith(("/", "\\")) and len(p.path.split("/")) == 3
parts = p.path.split("/")
parts = parts[1:] if parts[0] == "keep" else parts # keep/ — фото «хранить более года»
assert ".." not in p.path and not p.path.startswith(("/", "\\")) and len(parts) == 3
def test_static_path_traversal_blocked(env):