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 3a6e1f444c
commit 0f239d5015
44 changed files with 2131 additions and 94 deletions
+71
View File
@@ -0,0 +1,71 @@
"""Автонастройка из окружения и внутренней сети Docker.
Пока администратор сам не заполнил поле в настройках, значение берётся:
1) из переменной окружения (docker-compose.yml передаёт их из .env), иначе
2) из внутренней сети Docker: если рядом запущен сервис с известным именем (nextcloud, onlyoffice, ollama — профили
docker-compose.yml), подставляется его внутренний адрес.
Значение, сохранённое в интерфейсе, всегда важнее. Имена сервисов проверяются через DNS Docker с кэшем на минуту."""
import ipaddress
import os
import socket
import time
from urllib.parse import urlsplit
CACHE_SEC = 60
_seen: dict[str, tuple[float, bool]] = {}
# настройка -> переменная окружения
ENV = {
"nc_url": "NC_URL", "nc_user": "NC_USER", "nc_password": "NC_PASSWORD", "nc_root": "NC_ROOT",
"onlyoffice_url": "ONLYOFFICE_URL", "onlyoffice_jwt": "ONLYOFFICE_JWT", "portal_internal_url": "PORTAL_INTERNAL_URL",
"assistant_provider": "ASSISTANT_PROVIDER", "assistant_base_url": "ASSISTANT_BASE_URL",
"assistant_model": "ASSISTANT_MODEL", "assistant_api_key": "ASSISTANT_API_KEY",
}
# настройка -> (имя сервиса в сети Docker, адрес, который подставить)
SERVICES = {
"nc_url": ("nextcloud", "http://nextcloud"),
"onlyoffice_url": ("onlyoffice", "http://onlyoffice"),
"assistant_base_url": ("ollama", "http://ollama:11434/v1"),
}
def resolves(host: str) -> bool:
now = time.monotonic()
hit = _seen.get(host)
if hit and now - hit[0] < CACHE_SEC:
return hit[1]
try:
socket.getaddrinfo(host, None)
ok = True
except OSError:
ok = False
_seen[host] = (now, ok)
return ok
def default(key: str) -> str | None:
"""Значение по умолчанию из окружения или сети Docker; None — автонастройки для ключа нет."""
env = ENV.get(key)
if env and os.getenv(env):
return os.getenv(env, "").strip()
svc = SERVICES.get(key)
if svc and resolves(svc[0]):
return svc[1]
if key == "assistant_model" and resolves("ollama"):
return os.getenv("OLLAMA_MODEL", "qwen2.5:7b")
if key == "portal_internal_url" and resolves("onlyoffice"):
return "http://app:8000" # по этому адресу ONLYOFFICE забирает документы у портала
return None
def is_local(url: str) -> bool:
"""Адрес модели внутри своей сети (ключ API не нужен): localhost, имя сервиса Docker без точки, частный IP."""
host = (urlsplit(url or "").hostname or "").lower()
if not host:
return False
if host in ("localhost", "127.0.0.1", "::1") or "." not in host:
return True
try:
return ipaddress.ip_address(host).is_private
except ValueError:
return False