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:
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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, 'открытия ящика')
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user