Files
tech-service-portal/tests/test_scope_tweaks.py
T
darkorion e67c175f80 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
2026-10-13 13:27:20 +03:00

116 lines
7.8 KiB
Python

"""График у сотрудника (сменщики на одной должности), автоочистка старых фото с каталогом 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"])