From 01f0c6f002e4d53e215dff1a127e3286c2f38aa1 Mon Sep 17 00:00:00 2001 From: lokses Date: Fri, 24 Jul 2026 10:29:40 +0300 Subject: [PATCH] =?UTF-8?q?Carbonio=20CE:=20=D1=85=D1=80=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F=20?= =?UTF-8?q?=D1=8F=D1=89=D0=B8=D0=BA=D0=B0=20=D0=B0=D0=BF=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D0=B8=20(=D1=88=D0=B8=D1=84=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вариант А — пароль ящика хранится обратимо-зашифрованным, чтобы показать заведующему для входа: - Pharmacy.carbonio_password (EncryptedCharField) — шифруется в БД - sync_service при создании ящика генерирует пароль, отдаёт его в Carbonio и сохраняет свою зашифрованную копию; на update-пути пароль не трогается - Пароль виден в админке аптеки (readonly, расшифрованный) — вкладка Carbonio - 2 теста (мок CarbonioService): пароль сохранён, совпадает с отданным в Carbonio, в БД лежит шифртекстом; update не перетирает пароль Co-Authored-By: Claude Opus 4.8 --- apps/locations/admin.py | 7 +- .../0009_pharmacy_carbonio_password.py | 19 ++++++ apps/locations/models/pharmacies.py | 7 ++ apps/locations/tests.py | 64 ++++++++++++++++++- apps/mail_connector/services/sync_service.py | 13 ++-- 5 files changed, 100 insertions(+), 10 deletions(-) create mode 100644 apps/locations/migrations/0009_pharmacy_carbonio_password.py diff --git a/apps/locations/admin.py b/apps/locations/admin.py index 91c9b5e..cdb4ff0 100644 --- a/apps/locations/admin.py +++ b/apps/locations/admin.py @@ -267,7 +267,7 @@ class PharmacyAdmin(ModelAdmin, ImportExportModelAdmin): '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', + 'carbonio_sync_error', 'carbonio_password', ] fieldsets = ( @@ -309,8 +309,9 @@ class PharmacyAdmin(ModelAdmin, ImportExportModelAdmin): (_('Почта Carbonio'), { 'classes': ('tab',), 'fields': ( - 'carbonio_sync_enabled', 'carbonio_email', 'carbonio_account_id', - 'carbonio_sync_status', 'carbonio_last_sync', 'carbonio_sync_error', + 'carbonio_sync_enabled', 'carbonio_email', 'carbonio_password', + 'carbonio_account_id', 'carbonio_sync_status', 'carbonio_last_sync', + 'carbonio_sync_error', ), 'description': _('Пусто в «Почта Carbonio» — адрес сгенерируется как ' '<префикс организации><номер>@домен. Можно задать вручную.'), diff --git a/apps/locations/migrations/0009_pharmacy_carbonio_password.py b/apps/locations/migrations/0009_pharmacy_carbonio_password.py new file mode 100644 index 0000000..554a2c1 --- /dev/null +++ b/apps/locations/migrations/0009_pharmacy_carbonio_password.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.3 on 2026-07-24 07:28 + +import core.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('locations', '0008_pharmacy_carbonio_account_id_pharmacy_carbonio_email_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='pharmacy', + name='carbonio_password', + field=core.models.fields.EncryptedCharField(blank=True, help_text='Хранится зашифрованным; показывается для входа заведующему.', max_length=512, verbose_name='Пароль ящика Carbonio'), + ), + ] diff --git a/apps/locations/models/pharmacies.py b/apps/locations/models/pharmacies.py index bf0d316..3f3511c 100644 --- a/apps/locations/models/pharmacies.py +++ b/apps/locations/models/pharmacies.py @@ -3,6 +3,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from core.utils.validators import validate_ukrainian_phone +from core.models.fields import EncryptedCharField from django.utils import timezone from .base import Location @@ -157,6 +158,12 @@ class Pharmacy(Location): '<префикс организации><номер>@домен. Можно задать вручную.'), ) carbonio_account_id = models.CharField(_('ID аккаунта Carbonio'), max_length=100, blank=True) + # Пароль ящика хранится зашифрованным (обратимо), чтобы показать заведующему + # для входа. max_length с запасом под шифртекст. + carbonio_password = EncryptedCharField( + _('Пароль ящика Carbonio'), max_length=512, blank=True, + help_text=_('Хранится зашифрованным; показывается для входа заведующему.'), + ) carbonio_sync_enabled = models.BooleanField( _('Синхронизация с Carbonio'), default=False, help_text=_('Управлять почтовым ящиком аптеки в Carbonio'), diff --git a/apps/locations/tests.py b/apps/locations/tests.py index 4df352a..932e60d 100644 --- a/apps/locations/tests.py +++ b/apps/locations/tests.py @@ -1,5 +1,7 @@ +from unittest import mock + from django.core.exceptions import ValidationError -from django.db import IntegrityError, transaction +from django.db import IntegrityError, connection, transaction from django.test import TestCase from core.models import Directory, DirectoryType @@ -73,3 +75,63 @@ class CarbonioEmailTests(TestCase): self._pharmacy(self.aks, 16) self._pharmacy(self.tira, 17) self.assertEqual(Pharmacy.objects.filter(carbonio_email='').count(), 2) + + +class CarbonioPasswordSyncTests(TestCase): + """sync_pharmacy_to_carbonio с замоканным CarbonioService: пароль ящика + сохраняется у нас зашифрованным и совпадает с отданным в Carbonio.""" + + @classmethod + def setUpTestData(cls): + org_type = DirectoryType.objects.create(code='organization', name='Организация') + region_type = DirectoryType.objects.create(code='region', name='Регион') + cls.region = Directory.objects.create(directory_type=region_type, code='od', name='Одесса') + cls.aks = Directory.objects.create( + directory_type=org_type, code='akc', name='АКС', extra_data={'mail_prefix': 'a'}) + + def _pharmacy(self, number, **kw): + return Pharmacy.objects.create( + organization=self.aks, location_region=self.region, + pharmacy_number=number, location_type='pharmacy', **kw) + + def test_create_stores_encrypted_password(self): + from apps.mail_connector.services.sync_service import sync_pharmacy_to_carbonio + p = self._pharmacy(20) # синхронизация выключена, адрес пуст + fake = mock.Mock() + fake.get_account.return_value = None # аккаунта ещё нет → create + fake.create_account.return_value = 'acc-123' + with mock.patch( + 'apps.mail_connector.services.sync_service.CarbonioService', + return_value=fake): + self.assertTrue(sync_pharmacy_to_carbonio(p)) + + p.refresh_from_db() + self.assertEqual(p.carbonio_email, 'a20@aks-apteka.com.ua') + self.assertEqual(p.carbonio_account_id, 'acc-123') + self.assertEqual(p.carbonio_sync_status, 'synced') + self.assertTrue(p.carbonio_password) + # тот же пароль ушёл в Carbonio, что и сохранён у нас + self.assertEqual(fake.create_account.call_args.args[1], p.carbonio_password) + + # в БД пароль лежит зашифрованным + table, pk = Pharmacy._meta.db_table, Pharmacy._meta.pk.column + with connection.cursor() as cur: + cur.execute(f'SELECT carbonio_password FROM {table} WHERE {pk}=%s', [p.pk]) + raw = cur.fetchone()[0] + self.assertTrue(raw.startswith('gAAAAA')) + + def test_update_path_does_not_touch_password(self): + from apps.mail_connector.services.sync_service import sync_pharmacy_to_carbonio + p = self._pharmacy(21, carbonio_email='a21@aks-apteka.com.ua') + fake = mock.Mock() + fake.get_account.return_value = {'id': 'acc-existing'} # аккаунт уже есть → update + with mock.patch( + 'apps.mail_connector.services.sync_service.CarbonioService', + return_value=fake): + self.assertTrue(sync_pharmacy_to_carbonio(p)) + + p.refresh_from_db() + self.assertEqual(p.carbonio_account_id, 'acc-existing') + self.assertEqual(p.carbonio_password, '') # на update пароль не трогаем + fake.create_account.assert_not_called() + fake.set_account_status.assert_called_once_with('acc-existing', 'active') diff --git a/apps/mail_connector/services/sync_service.py b/apps/mail_connector/services/sync_service.py index c2ab61c..16f6269 100644 --- a/apps/mail_connector/services/sync_service.py +++ b/apps/mail_connector/services/sync_service.py @@ -63,11 +63,12 @@ def sync_pharmacy_to_carbonio(pharmacy, password: str | None = None) -> bool: service.set_account_status(account_id, 'active') action = 'update' else: + # Пароль храним у себя зашифрованным (обратимо) — чтобы показать + # заведующему для входа. В Carbonio уходит открытым при создании. + new_password = password or generate_random_password() account_id = service.create_account( - pharmacy.carbonio_email, - password or generate_random_password(), - _display_name(pharmacy), - ) + pharmacy.carbonio_email, new_password, _display_name(pharmacy)) + pharmacy.carbonio_password = new_password action = 'create' pharmacy.carbonio_account_id = account_id @@ -75,8 +76,8 @@ def sync_pharmacy_to_carbonio(pharmacy, password: str | None = None) -> bool: 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']) + 'carbonio_email', 'carbonio_account_id', 'carbonio_password', + '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