Files
ACORE/apps/nextcloud_connector/nextcloud_service.py
T
lokses 19bc2874aa Nextcloud connector: скелет провижининга Group Folders (Фаза 2)
- ConnectorConfig: тип 'nextcloud' (host/username/token, шифруется)
- NextcloudService: OCS-клиент Group Folders (create/ACL/quota, идемпотентный ensure_folder)
- NextcloudSyncService: отделы → 03_Отделы/* ↔ nc-dept-<код>; аптеки → 02_Аптеки/* ↔ nc-svc
- Сигналы Directory/Pharmacy с гейтом на включённый коннектор (по умолч. no-op)
- Celery-задачи + команда nextcloud_sync (dry-run по умолчанию, --apply)
- NextcloudSyncLog + admin; тесты (пути, гейт, идемпотентность ensure_folder)

Членство юзеров идёт через AD/LDAP, коннектор ведёт только папки+ACL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 11:09:49 +03:00

153 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Клиент Nextcloud OCS для управления Group Folders.
Настройки берутся из ConnectorConfig(connector_type='nextcloud'):
host — хост Nextcloud (без схемы), напр. cloud.company.local
port — опционально
use_ssl — https/http
username — логин сервисного/админ-аккаунта Nextcloud
token — app password этого аккаунта (предпочтительно; шифруется)
password — альтернатива token (шифруется)
extra_config:
verify_ssl — bool, проверять TLS-сертификат (по умолчанию True)
timeout — сек, таймаут HTTP (по умолчанию 30)
Членство пользователей в Nextcloud НЕ управляется отсюда — доменные юзеры
приходят через LDAP/AD, аптечные учёток не имеют вовсе (доступ через портал).
Задача клиента — только Group Folders: создание, ACL по группам, квоты.
"""
import logging
import httpx
logger = logging.getLogger(__name__)
class NextcloudConfigError(Exception):
"""Коннектор не настроен или отключён."""
class NextcloudServiceError(Exception):
"""Ошибка при вызове Nextcloud OCS API."""
class NextcloudService:
# Битовая маска прав Group Folders (совпадает с Nextcloud permissions).
PERM_READ = 1
PERM_UPDATE = 2
PERM_CREATE = 4
PERM_DELETE = 8
PERM_SHARE = 16
PERM_RW = PERM_READ | PERM_UPDATE | PERM_CREATE | PERM_DELETE # 15 — чтение+запись без шаринга
PERM_RO = PERM_READ # 1 — только чтение
def __init__(self):
from core.models import ConnectorConfig
cfg = ConnectorConfig.objects.filter(connector_type='nextcloud').first()
if not cfg:
raise NextcloudConfigError(
'Настройки коннектора Nextcloud не найдены. '
'Добавьте запись в Core → Настройки коннекторов.'
)
if not cfg.is_enabled:
raise NextcloudConfigError('Коннектор Nextcloud отключён.')
secret = cfg.token or cfg.password
if not cfg.host or not cfg.username or not secret:
raise NextcloudConfigError(
'Для коннектора Nextcloud нужны host, username и token (app password) '
'сервисного аккаунта.'
)
extra = cfg.extra_config or {}
scheme = 'https' if cfg.use_ssl else 'http'
port = f':{cfg.port}' if cfg.port else ''
self.base = f'{scheme}://{cfg.host}{port}'
self.folders_url = f'{self.base}/ocs/v2.php/apps/groupfolders/folders'
verify = bool(extra.get('verify_ssl', True))
self._client = httpx.Client(
auth=(cfg.username, secret),
verify=verify,
timeout=extra.get('timeout', 30),
headers={'OCS-APIRequest': 'true', 'Accept': 'application/json'},
)
# --- низкоуровневый вызов OCS ---
def _req(self, method: str, url: str, data: dict | None = None):
try:
resp = self._client.request(method, url, data=data or {})
resp.raise_for_status()
except httpx.HTTPError as e:
raise NextcloudServiceError(f'{method} {url}: {type(e).__name__}: {e}') from e
try:
payload = resp.json()
except ValueError as e:
raise NextcloudServiceError(f'{method} {url}: ответ не JSON') from e
ocs = payload.get('ocs', {})
meta = ocs.get('meta', {})
if meta.get('status') != 'ok':
raise NextcloudServiceError(
f'{method} {url}: OCS {meta.get("statuscode")} {meta.get("message")}'
)
return ocs.get('data')
# --- Group Folders ---
def list_folders(self) -> dict[str, int]:
"""{mount_point: folder_id} по всем Group Folders."""
data = self._req('GET', self.folders_url)
items = data.values() if isinstance(data, dict) else (data or [])
result: dict[str, int] = {}
for f in items:
mp = f.get('mount_point') or f.get('mountpoint')
if mp is not None:
result[mp] = int(f['id'])
return result
def create_folder(self, mountpoint: str) -> int:
data = self._req('POST', self.folders_url, data={'mountpoint': mountpoint})
return int(data['id'])
def add_group(self, folder_id: int, group: str) -> None:
self._req('POST', f'{self.folders_url}/{folder_id}/groups', data={'group': group})
def set_group_permissions(self, folder_id: int, group: str, permissions: int) -> None:
self._req('POST', f'{self.folders_url}/{folder_id}/groups/{group}',
data={'permissions': permissions})
def set_quota(self, folder_id: int, quota: int) -> None:
"""quota в байтах; -3 = без ограничения (константа Nextcloud)."""
self._req('POST', f'{self.folders_url}/{folder_id}/quota', data={'quota': quota})
# --- высокоуровневая идемпотентная операция ---
def ensure_folder(self, mountpoint: str, groups: dict[str, int],
quota: int | None = None,
folder_cache: dict[str, int] | None = None) -> tuple[int, bool]:
"""Гарантирует существование Group Folder с нужными ACL.
groups — {имя_группы: битовая_маска_прав}. Возвращает (folder_id, created).
folder_cache — заранее полученный list_folders(), чтобы не дёргать API на
каждую сущность при массовом прогоне.
"""
existing = folder_cache if folder_cache is not None else self.list_folders()
folder_id = existing.get(mountpoint)
created = folder_id is None
if created:
folder_id = self.create_folder(mountpoint)
if folder_cache is not None:
folder_cache[mountpoint] = folder_id
for group, perms in groups.items():
self.add_group(folder_id, group)
self.set_group_permissions(folder_id, group, perms)
if quota is not None:
self.set_quota(folder_id, quota)
return folder_id, created
def close(self):
self._client.close()