Carbonio CE: API-слой синхронизации ящиков аптек

Серверная часть интеграции по эталону AD-коннектора (драйвер — Pharmacy):

- carbonio_service.py: клиент admin API Carbonio/Zimbra (JSON-SOAP на 6071):
  auth + get/create/modify(status)/delete account, ping для read-only проверки
- services/sync_service.py: sync_pharmacy_to_carbonio (идемпотентно),
  deactivate_pharmacy_carbonio; логирование в CarbonioSyncLog
- signals.py: post_save/pre_save на Pharmacy — создание ящика при включённой
  синхронизации, закрытие/открытие при смене статуса аптеки; защита от рекурсии
  (account_id гасит повторный вызов), sync_enabled=False по умолчанию не трогает прод
- tasks.py: Celery-обёртки; CarbonioSyncLog + read-only админка
- команда sync_pharmacies_to_carbonio (dry-run по умолчанию, --apply)
- Carbonio-поля в админке Pharmacy; убран конфликт models.py vs models/

Без живого сервера деградирует мягко: CarbonioConfigError не роняет сохранение.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 10:02:35 +03:00
parent 3cbc019268
commit 3be444b05e
13 changed files with 515 additions and 3 deletions
+11
View File
@@ -266,6 +266,8 @@ class PharmacyAdmin(ModelAdmin, ImportExportModelAdmin):
readonly_fields = [
'address', 'employees_count_display', 'license_status_display',
'is_active', 'created_at', 'updated_at',
'carbonio_account_id', 'carbonio_sync_status', 'carbonio_last_sync',
'carbonio_sync_error',
]
fieldsets = (
@@ -304,6 +306,15 @@ class PharmacyAdmin(ModelAdmin, ImportExportModelAdmin):
'pharmacists_count', 'employees_count_display',
),
}),
(_('Почта Carbonio'), {
'classes': ('tab',),
'fields': (
'carbonio_sync_enabled', 'carbonio_email', 'carbonio_account_id',
'carbonio_sync_status', 'carbonio_last_sync', 'carbonio_sync_error',
),
'description': _('Пусто в «Почта Carbonio» — адрес сгенерируется как '
'<префикс организации><номер>@домен. Можно задать вручную.'),
}),
(_('Примечания'), {
'classes': ('tab',),
'fields': ('comments', 'created_at', 'updated_at'),
+19 -1
View File
@@ -1,3 +1,21 @@
from django.contrib import admin
from unfold.admin import ModelAdmin
# Register your models here.
from .models import CarbonioSyncLog
@admin.register(CarbonioSyncLog)
class CarbonioSyncLogAdmin(ModelAdmin):
list_display = ('pharmacy', 'action', 'status', 'created_at')
list_filter = ('action', 'status')
search_fields = ('pharmacy__pharmacy_number', 'pharmacy__carbonio_email', 'message')
date_hierarchy = 'created_at'
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
+6 -1
View File
@@ -2,5 +2,10 @@ from django.apps import AppConfig
class MailConnectorConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.mail_connector'
verbose_name = 'Carbonio CE Connector'
verbose_name = 'Carbonio CE Connector'
def ready(self):
"""Подключаем сигналы синхронизации Carbonio при инициализации приложения."""
import apps.mail_connector.signals # noqa
+143
View File
@@ -0,0 +1,143 @@
"""Клиент административного API Carbonio CE.
Carbonio CE — форк Zimbra, у него совместимый Admin SOAP API на порту 6071
(`/service/admin/soap`), понимающий JSON-конверты. Схема: AuthRequest → токен,
далее Create/Get/Modify/Delete AccountRequest с токеном в заголовке.
Настройки берутся из ConnectorConfig(connector_type='carbonio'):
host, port (6071), use_ssl, username/password (admin, пароль шифруется),
extra_config: {"mail_domain": "...", "verify_ssl": false, "timeout": 30}.
"""
import logging
import httpx
logger = logging.getLogger(__name__)
class CarbonioConfigError(Exception):
"""Коннектор Carbonio не настроен или отключён."""
class CarbonioError(Exception):
"""Ошибка вызова admin API Carbonio (SOAP Fault / сеть)."""
class CarbonioService:
ADMIN_NS = 'urn:zimbraAdmin'
CTX_NS = 'urn:zimbra'
def __init__(self):
from core.models import ConnectorConfig
cfg = ConnectorConfig.objects.filter(connector_type='carbonio').first()
if not cfg:
raise CarbonioConfigError(
'Настройки коннектора Carbonio не найдены. '
'Добавьте запись в Core → Настройки коннекторов.'
)
if not cfg.is_enabled:
raise CarbonioConfigError('Коннектор Carbonio отключён.')
if not cfg.host or not cfg.username or not cfg.password:
raise CarbonioConfigError(
'Для коннектора Carbonio нужны host, username и password admin-аккаунта.'
)
extra = cfg.extra_config or {}
self.domain = extra.get('mail_domain', 'aks-apteka.com.ua')
self.username = cfg.username
self.password = cfg.password
scheme = 'https' if cfg.use_ssl else 'http'
port = cfg.port or 6071
self.endpoint = f'{scheme}://{cfg.host}:{port}/service/admin/soap'
# На admin-порту Carbonio часто самоподписанный сертификат — по умолчанию
# проверку выключаем, но её можно включить через extra_config.verify_ssl.
verify = bool(extra.get('verify_ssl', False))
self._client = httpx.Client(verify=verify, timeout=extra.get('timeout', 30))
self._token = None
# --- низкоуровневый вызов ---
def _call(self, request: str, params: dict, authed: bool = True) -> dict:
envelope = {'Body': {request: {'_jsns': self.ADMIN_NS, **params}}}
if authed:
envelope['Header'] = {
'context': {'_jsns': self.CTX_NS, 'authToken': self._auth_token()}
}
try:
resp = self._client.post(self.endpoint, json=envelope)
data = resp.json()
except (httpx.HTTPError, ValueError) as e:
logger.error(f'Carbonio API {request}: сетевая ошибка — {e}')
raise CarbonioError(str(e)) from e
body = data.get('Body', {})
if 'Fault' in body:
reason = (body['Fault'].get('Reason') or {}).get('Text', 'неизвестная ошибка')
raise CarbonioError(f'{request}: {reason}')
return body.get(request.replace('Request', 'Response'), {})
def _auth_token(self) -> str:
if self._token:
return self._token
resp = self._call(
'AuthRequest',
{'name': self.username, 'password': self.password},
authed=False,
)
token = resp.get('authToken')
if isinstance(token, list):
token = token[0].get('_content')
elif isinstance(token, dict):
token = token.get('_content')
if not token:
raise CarbonioError('AuthRequest не вернул authToken')
self._token = token
return token
# --- высокоуровневые операции ---
def ping(self) -> bool:
"""Read-only проверка коннекта и кредов (только авторизация)."""
return bool(self._auth_token())
def get_account(self, email: str) -> dict | None:
"""Аккаунт по адресу или None, если не существует."""
try:
resp = self._call('GetAccountRequest',
{'account': {'by': 'name', '_content': email}})
except CarbonioError:
return None
accounts = resp.get('account')
if isinstance(accounts, list):
return accounts[0] if accounts else None
return accounts
def create_account(self, email: str, password: str, display_name: str = '') -> str:
"""Создать почтовый ящик, вернуть id аккаунта."""
attrs = []
if display_name:
attrs.append({'n': 'displayName', '_content': display_name})
resp = self._call('CreateAccountRequest',
{'name': email, 'password': password, 'a': attrs})
account = resp.get('account')
if isinstance(account, list):
account = account[0] if account else {}
account_id = (account or {}).get('id')
if not account_id:
raise CarbonioError('CreateAccountRequest не вернул id аккаунта')
return account_id
def set_account_status(self, account_id: str, status: str) -> None:
"""zimbraAccountStatus: active / closed / locked / maintenance."""
self._call('ModifyAccountRequest',
{'id': account_id,
'a': [{'n': 'zimbraAccountStatus', '_content': status}]})
def set_password(self, account_id: str, password: str) -> None:
self._call('SetPasswordRequest', {'id': account_id, 'newPassword': password})
def delete_account(self, account_id: str) -> None:
self._call('DeleteAccountRequest', {'id': account_id})
@@ -0,0 +1,62 @@
"""Массовая синхронизация почтовых ящиков аптек с Carbonio CE.
По умолчанию dry-run: показывает, что будет сделано, ничего не меняя.
Реальная синхронизация — только с флагом --apply.
"""
from django.core.management.base import BaseCommand, CommandError
class Command(BaseCommand):
help = 'Синхронизировать почтовые ящики аптек с Carbonio (по умолчанию dry-run)'
def add_arguments(self, parser):
parser.add_argument('--pharmacy-id', type=int, help='Только одна аптека по ID')
parser.add_argument('--all', action='store_true',
help='Все аптеки с включённой синхронизацией Carbonio')
parser.add_argument('--apply', action='store_true',
help='Реально выполнить (без флага — dry-run)')
def handle(self, *args, **options):
from apps.locations.models import Pharmacy
from apps.mail_connector.carbonio_service import CarbonioConfigError, CarbonioService
from apps.mail_connector.services.sync_service import sync_pharmacy_to_carbonio
if options['pharmacy_id']:
qs = Pharmacy.objects.filter(pk=options['pharmacy_id'])
elif options['all']:
qs = Pharmacy.objects.filter(carbonio_sync_enabled=True)
else:
raise CommandError('Укажите --pharmacy-id <ID> или --all')
total = qs.count()
if not total:
self.stdout.write(self.style.WARNING('Нет аптек, подходящих под условие'))
return
apply = options['apply']
self.stdout.write(
f'{"ПРИМЕНЕНИЕ" if apply else "DRY-RUN"}: аптек к обработке — {total}')
if apply:
# Ранняя проверка коннекта, чтобы не сыпать ошибками по каждой аптеке.
try:
CarbonioService().ping()
except CarbonioConfigError as e:
raise CommandError(str(e))
ok = failed = 0
for pharmacy in qs.iterator():
target = pharmacy.carbonio_email or pharmacy.build_carbonio_email() or '(нет адреса)'
if not apply:
self.stdout.write(f' [dry-run] аптека {pharmacy.pk} → {target}')
continue
if sync_pharmacy_to_carbonio(pharmacy):
ok += 1
self.stdout.write(self.style.SUCCESS(f' ✓ {pharmacy.pk} → {pharmacy.carbonio_email}'))
else:
failed += 1
self.stdout.write(self.style.ERROR(
f' ✗ {pharmacy.pk} → {pharmacy.carbonio_sync_error or "ошибка"}'))
if apply:
self.stdout.write(f'Готово: успешно {ok}, ошибок {failed}')
@@ -0,0 +1,35 @@
# Generated by Django 6.0.3 on 2026-07-24 06:59
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('locations', '0008_pharmacy_carbonio_account_id_pharmacy_carbonio_email_and_more'),
]
operations = [
migrations.CreateModel(
name='CarbonioSyncLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('action', models.CharField(choices=[('create', 'Создание'), ('update', 'Обновление'), ('activate', 'Включение'), ('deactivate', 'Отключение')], max_length=20, verbose_name='Действие')),
('status', models.CharField(choices=[('success', 'Успех'), ('error', 'Ошибка')], max_length=10, verbose_name='Статус')),
('message', models.TextField(blank=True, verbose_name='Сообщение')),
('pharmacy', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='carbonio_sync_logs', to='locations.pharmacy', verbose_name='Аптека')),
],
options={
'verbose_name': 'Запись синхронизации Carbonio',
'verbose_name_plural': 'Журнал синхронизации Carbonio',
'ordering': ['-created_at'],
},
),
]
+33 -1
View File
@@ -1,3 +1,35 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
# Create your models here.
from core.models import BaseModel
class CarbonioSyncLog(BaseModel):
"""Журнал операций синхронизации почтовых ящиков аптек с Carbonio CE."""
pharmacy = models.ForeignKey(
'locations.Pharmacy', on_delete=models.CASCADE,
related_name='carbonio_sync_logs', verbose_name=_('Аптека'),
)
action = models.CharField(
_('Действие'), max_length=20,
choices=[
('create', _('Создание')),
('update', _('Обновление')),
('activate', _('Включение')),
('deactivate', _('Отключение')),
],
)
status = models.CharField(
_('Статус'), max_length=10,
choices=[('success', _('Успех')), ('error', _('Ошибка'))],
)
message = models.TextField(_('Сообщение'), blank=True)
class Meta:
verbose_name = _('Запись синхронизации Carbonio')
verbose_name_plural = _('Журнал синхронизации Carbonio')
ordering = ['-created_at']
def __str__(self):
return f'{self.pharmacy} — {self.get_action_display()} — {self.get_status_display()}'
@@ -0,0 +1,111 @@
import logging
import secrets
import string
from django.utils import timezone
from apps.mail_connector.carbonio_service import (
CarbonioService, CarbonioConfigError, CarbonioError,
)
logger = logging.getLogger(__name__)
def generate_random_password(length: int = 16) -> str:
alphabet = string.ascii_letters + string.digits + '!@#$%*'
return ''.join(secrets.choice(alphabet) for _ in range(length))
def _log(pharmacy, action: str, status: str, message: str = '') -> None:
from apps.mail_connector.models import CarbonioSyncLog
CarbonioSyncLog.objects.create(
pharmacy=pharmacy, action=action, status=status, message=message)
def _display_name(pharmacy) -> str:
org = pharmacy.organization.name if pharmacy.organization_id else ''
return f'Аптека №{pharmacy.pharmacy_number} {org}'.strip()
def _mark_error(pharmacy, message: str) -> None:
pharmacy.carbonio_sync_status = 'error'
pharmacy.carbonio_sync_error = message
pharmacy.carbonio_last_sync = timezone.now()
pharmacy.save(update_fields=[
'carbonio_sync_status', 'carbonio_sync_error', 'carbonio_last_sync'])
def sync_pharmacy_to_carbonio(pharmacy, password: str | None = None) -> bool:
"""Создать (или переактивировать) почтовый ящик аптеки в Carbonio.
Требует carbonio_email; если он пуст — пробуем сгенерировать из префикса
организации. Идемпотентна: если аккаунт уже есть — просто активируем и
сохраняем его id, повторно не создаём.
"""
if not pharmacy.carbonio_email:
pharmacy.carbonio_email = pharmacy.build_carbonio_email()
if not pharmacy.carbonio_email:
_mark_error(pharmacy,
'Не задан адрес Carbonio и не удалось сгенерировать '
'(нет mail_prefix у организации).')
return False
try:
service = CarbonioService()
except CarbonioConfigError as e:
logger.error(f'Carbonio не настроен — синхронизация аптеки {pharmacy.pk} пропущена: {e}')
return False
try:
existing = service.get_account(pharmacy.carbonio_email)
if existing:
account_id = existing['id']
service.set_account_status(account_id, 'active')
action = 'update'
else:
account_id = service.create_account(
pharmacy.carbonio_email,
password or generate_random_password(),
_display_name(pharmacy),
)
action = 'create'
pharmacy.carbonio_account_id = account_id
pharmacy.carbonio_sync_status = 'synced'
pharmacy.carbonio_sync_error = ''
pharmacy.carbonio_last_sync = timezone.now()
pharmacy.save(update_fields=[
'carbonio_email', 'carbonio_account_id', 'carbonio_sync_status',
'carbonio_sync_error', 'carbonio_last_sync'])
_log(pharmacy, action, 'success', f'{pharmacy.carbonio_email} → {account_id}')
logger.info(f'Carbonio: ящик {pharmacy.carbonio_email} синхронизирован ({action})')
return True
except CarbonioError as e:
_mark_error(pharmacy, str(e))
_log(pharmacy, 'create', 'error', str(e))
logger.error(f'Carbonio: ошибка синхронизации аптеки {pharmacy.pk}: {e}')
return False
def deactivate_pharmacy_carbonio(pharmacy) -> bool:
"""Закрыть ящик аптеки (zimbraAccountStatus=closed) — например при закрытии аптеки."""
if not pharmacy.carbonio_account_id:
return False
try:
service = CarbonioService()
except CarbonioConfigError as e:
logger.error(f'Carbonio не настроен — деактивация аптеки {pharmacy.pk} пропущена: {e}')
return False
try:
service.set_account_status(pharmacy.carbonio_account_id, 'closed')
pharmacy.carbonio_sync_status = 'disabled'
pharmacy.carbonio_last_sync = timezone.now()
pharmacy.save(update_fields=['carbonio_sync_status', 'carbonio_last_sync'])
_log(pharmacy, 'deactivate', 'success', '')
logger.info(f'Carbonio: ящик аптеки {pharmacy.pk} закрыт')
return True
except CarbonioError as e:
_log(pharmacy, 'deactivate', 'error', str(e))
logger.error(f'Carbonio: ошибка деактивации аптеки {pharmacy.pk}: {e}')
return False
+64
View File
@@ -0,0 +1,64 @@
"""Сигналы синхронизации почтовых ящиков аптек с Carbonio CE.
Драйвер — модель Pharmacy (в отличие от AD/Mattermost, где драйвер Employee):
почта заводится на аптеку, а не на сотрудника. По умолчанию
Pharmacy.carbonio_sync_enabled=False, поэтому обычные сохранения аптек
сигналы не трогают — прод дёргается только для явно включённых аптек.
"""
import logging
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from apps.locations.models import Pharmacy
logger = logging.getLogger(__name__)
def _queue(task, pharmacy, action: str) -> None:
"""Ставим Celery-задачу; недоступный брокер не должен ронять сохранение."""
try:
task.delay(pharmacy.pk)
logger.info(f'Carbonio: поставлена задача {action} для аптеки {pharmacy.pk}')
except Exception as e:
logger.error(f'Carbonio: не удалось поставить задачу {action} для аптеки {pharmacy.pk}: {e}')
@receiver(pre_save, sender=Pharmacy)
def cache_pharmacy_carbonio_state(sender, instance, **kwargs):
"""Запоминаем прежний статус, чтобы в post_save увидеть закрытие/открытие."""
if not instance.pk:
instance._carbonio_pre_save_status = None
return
try:
old = Pharmacy.objects.values('status').get(pk=instance.pk)
instance._carbonio_pre_save_status = old['status']
except Pharmacy.DoesNotExist:
instance._carbonio_pre_save_status = None
@receiver(post_save, sender=Pharmacy)
def create_pharmacy_carbonio(sender, instance, created, **kwargs):
"""Заводим ящик при новой аптеке с включённой синхронизацией или когда
синхронизацию включили, а аккаунта ещё нет. Наличие account_id гасит
повторный вызов (после save() в sync_service) — рекурсии нет."""
if not instance.carbonio_sync_enabled or instance.carbonio_account_id:
return
from apps.mail_connector.tasks import sync_pharmacy_to_carbonio_task
_queue(sync_pharmacy_to_carbonio_task, instance, 'создания ящика')
@receiver(post_save, sender=Pharmacy)
def toggle_pharmacy_carbonio_on_status(sender, instance, created, **kwargs):
"""Закрываем ящик при закрытии аптеки, снова открываем — при возврате в open."""
if created or not instance.carbonio_account_id:
return
old_status = getattr(instance, '_carbonio_pre_save_status', None)
if old_status is None:
return
if old_status == 'open' and instance.status != 'open':
from apps.mail_connector.tasks import deactivate_pharmacy_carbonio_task
_queue(deactivate_pharmacy_carbonio_task, instance, 'закрытия ящика')
elif old_status != 'open' and instance.status == 'open' and instance.carbonio_sync_enabled:
from apps.mail_connector.tasks import sync_pharmacy_to_carbonio_task
_queue(sync_pharmacy_to_carbonio_task, instance, 'открытия ящика')
+31
View File
@@ -0,0 +1,31 @@
import logging
from celery import shared_task
logger = logging.getLogger(__name__)
@shared_task
def sync_pharmacy_to_carbonio_task(pharmacy_id):
"""Асинхронная синхронизация почтового ящика аптеки с Carbonio."""
from apps.locations.models import Pharmacy
from apps.mail_connector.services.sync_service import sync_pharmacy_to_carbonio
try:
pharmacy = Pharmacy.objects.get(pk=pharmacy_id)
except Pharmacy.DoesNotExist:
logger.error(f'Carbonio: аптека с ID {pharmacy_id} не найдена')
return
sync_pharmacy_to_carbonio(pharmacy)
@shared_task
def deactivate_pharmacy_carbonio_task(pharmacy_id):
"""Асинхронное закрытие почтового ящика аптеки в Carbonio."""
from apps.locations.models import Pharmacy
from apps.mail_connector.services.sync_service import deactivate_pharmacy_carbonio
try:
pharmacy = Pharmacy.objects.get(pk=pharmacy_id)
except Pharmacy.DoesNotExist:
logger.error(f'Carbonio: аптека с ID {pharmacy_id} не найдена')
return
deactivate_pharmacy_carbonio(pharmacy)