19bc2874aa
- 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>
153 lines
6.6 KiB
Python
153 lines
6.6 KiB
Python
"""Клиент 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()
|