Carbonio CE: хранение пароля ящика аптеки (шифрованно)
Вариант А — пароль ящика хранится обратимо-зашифрованным, чтобы показать заведующему для входа: - Pharmacy.carbonio_password (EncryptedCharField) — шифруется в БД - sync_service при создании ящика генерирует пароль, отдаёт его в Carbonio и сохраняет свою зашифрованную копию; на update-пути пароль не трогается - Пароль виден в админке аптеки (readonly, расшифрованный) — вкладка Carbonio - 2 теста (мок CarbonioService): пароль сохранён, совпадает с отданным в Carbonio, в БД лежит шифртекстом; update не перетирает пароль Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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» — адрес сгенерируется как '
|
||||
'<префикс организации><номер>@домен. Можно задать вручную.'),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
|
||||
+63
-1
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user