diff --git a/README.md b/README.md index ae8f916..9bf0c12 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,18 @@ uv sync --dev Можно через `make services-up` или любым удобным способом. +База State Corp должна быть создана в кодировке `UTF8`. Если шаблоны внешнего +PostgreSQL-кластера используют `SQL_ASCII`, кодировку нужно задать явно: + +```bash +createdb --owner=state_corp_app --encoding=UTF8 \ + --locale=C.utf8 --template=template0 state_corp +``` + +Контейнеры web/Celery/migrate проверяют кодировку при запуске и не стартуют на +не-UTF8 базе, чтобы ограничения `varchar` не зависели от байтового представления +кириллицы. + ### 3. Экспорт переменных ```bash diff --git a/src/apps/core/startup_checks.py b/src/apps/core/startup_checks.py index 4053990..05c9607 100644 --- a/src/apps/core/startup_checks.py +++ b/src/apps/core/startup_checks.py @@ -41,6 +41,15 @@ def _check_db(timeout_seconds: int) -> tuple[bool, str]: with conn.cursor() as cursor: cursor.execute("SELECT 1") cursor.fetchone() + cursor.execute("SHOW server_encoding") + encoding = str(cursor.fetchone()[0]).upper() + if encoding != "UTF8": + target = f"{params['host']}:{params['port']}/{params['dbname']}" + return ( + False, + f"{target} (database encoding {encoding} is unsupported; " + "UTF8 is required)", + ) return True, "OK" except Exception as exc: # noqa: BLE001 target = f"{params['host']}:{params['port']}/{params['dbname']}" diff --git a/src/apps/exchange/services.py b/src/apps/exchange/services.py index ca0773b..50bcfe9 100644 --- a/src/apps/exchange/services.py +++ b/src/apps/exchange/services.py @@ -5,6 +5,7 @@ from __future__ import annotations import base64 import hashlib import json +import logging import struct import uuid import zlib @@ -41,6 +42,8 @@ from django.db import transaction from django.db.models import Q from django.utils import timezone +logger = logging.getLogger(__name__) + class ExchangeImportError(ValueError): """Exchange package validation or import error.""" @@ -282,6 +285,13 @@ class ExchangePackageImportService: ) raise except Exception as exc: # noqa: BLE001 + logger.exception( + "Unexpected exchange package import failure " + "(package_id=%s, package_hash=%s, delivery_channel=%s)", + package_id, + decoded.package_hash, + delivery_channel, + ) error_message = "Не удалось импортировать пакет обмена" cls._create_failed_record( decoded=decoded, diff --git a/src/apps/external_data/migrations/0005_public_procurement_purchase_name_text.py b/src/apps/external_data/migrations/0005_public_procurement_purchase_name_text.py new file mode 100644 index 0000000..99b2575 --- /dev/null +++ b/src/apps/external_data/migrations/0005_public_procurement_purchase_name_text.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("external_data", "0004_auto_20260527_1928"), + ] + + operations = [ + migrations.AlterField( + model_name="publicprocurement", + name="purchase_name", + field=models.TextField(verbose_name="предмет закупки"), + ), + ] diff --git a/src/apps/external_data/models.py b/src/apps/external_data/models.py index c8e2c6d..3228dc7 100644 --- a/src/apps/external_data/models.py +++ b/src/apps/external_data/models.py @@ -134,7 +134,7 @@ class PublicProcurement(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): execution_end_date = models.DateField( _("дата окончания исполнения"), null=True, blank=True ) - purchase_name = models.CharField(_("предмет закупки"), max_length=500) + purchase_name = models.TextField(_("предмет закупки")) class Meta: ordering = ["-contract_date", "purchase_number"] diff --git a/tests/apps/core/test_startup_checks.py b/tests/apps/core/test_startup_checks.py new file mode 100644 index 0000000..9720d9a --- /dev/null +++ b/tests/apps/core/test_startup_checks.py @@ -0,0 +1,38 @@ +"""Tests for fail-fast dependency checks.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from apps.core.startup_checks import _check_db +from django.test import SimpleTestCase + + +class DatabaseStartupCheckTest(SimpleTestCase): + """Require a Unicode-safe PostgreSQL database before startup.""" + + @staticmethod + def _connection_with_encoding(encoding: str) -> MagicMock: + connection = MagicMock() + cursor = connection.cursor.return_value.__enter__.return_value + cursor.fetchone.side_effect = [(1,), (encoding,)] + return connection + + @patch("apps.core.startup_checks.psycopg2.connect") + def test_check_db_accepts_utf8_database(self, connect): + connect.return_value = self._connection_with_encoding("UTF8") + + ok, message = _check_db(timeout_seconds=3) + + self.assertTrue(ok) + self.assertEqual(message, "OK") + + @patch("apps.core.startup_checks.psycopg2.connect") + def test_check_db_rejects_non_utf8_database(self, connect): + connect.return_value = self._connection_with_encoding("SQL_ASCII") + + ok, message = _check_db(timeout_seconds=3) + + self.assertFalse(ok) + self.assertIn("database encoding SQL_ASCII is unsupported", message) + self.assertIn("UTF8 is required", message) diff --git a/tests/apps/exchange/test_api.py b/tests/apps/exchange/test_api.py index 10366cb..8b5f162 100644 --- a/tests/apps/exchange/test_api.py +++ b/tests/apps/exchange/test_api.py @@ -11,6 +11,7 @@ import zlib from datetime import date from decimal import Decimal from io import BytesIO +from unittest.mock import patch from zipfile import ZIP_DEFLATED, ZipFile from apps.exchange.models import ExchangeDeliveryChannel, ExchangePackageImport @@ -35,6 +36,7 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM from django.conf import settings from django.core.files.uploadedfile import SimpleUploadedFile from django.core.management import call_command +from django.db import models from django.urls import reverse from django.utils.crypto import get_random_string from rest_framework import status @@ -531,6 +533,67 @@ class ExchangePackageApiTest(APITestCase): self.assertEqual(ExchangePackageImport.objects.count(), 0) self.assertEqual(Organization.objects.count(), 0) + def test_upload_logs_unexpected_import_error_with_package_context(self): + archive = build_exchange_archive( + package_id="pkg-unexpected-import-error", + data=build_exchange_payload(), + ) + + with patch.object( + ExchangePackageImportService, + "_import_payload", + side_effect=RuntimeError("database failure"), + ), patch("apps.exchange.services.logger.exception") as log_exception: + response = self.client.post( + self.url, + {"file": archive}, + format="multipart", + HTTP_X_EXCHANGE_TOKEN=TEST_TOKEN, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + {"file": ["Не удалось импортировать пакет обмена"]}, + ) + package_import = ExchangePackageImport.objects.get( + package_id="pkg-unexpected-import-error" + ) + log_exception.assert_called_once_with( + "Unexpected exchange package import failure " + "(package_id=%s, package_hash=%s, delivery_channel=%s)", + "pkg-unexpected-import-error", + package_import.package_hash, + ExchangeDeliveryChannel.API, + ) + + def test_upload_preserves_long_public_procurement_name(self): + payload = build_exchange_payload() + long_purchase_name = ("Поставка специализированного оборудования " * 16).strip() + payload["public_procurements"][0]["purchase_name"] = long_purchase_name + archive = build_exchange_archive( + package_id="pkg-long-public-procurement-name", + data=payload, + ) + + response = self.client.post( + self.url, + {"file": archive}, + format="multipart", + HTTP_X_EXCHANGE_TOKEN=TEST_TOKEN, + ) + + self.assertGreater(len(long_purchase_name), 500) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + PublicProcurement.objects.get(purchase_number="purchase-001").purchase_name, + long_purchase_name, + ) + self.assertIsInstance( + PublicProcurement._meta.get_field("purchase_name"), + models.TextField, + ) + def test_upload_rejects_legacy_schema_version(self): archive = build_exchange_archive( package_id="pkg-legacy-schema-version",