5 Commits

Author SHA1 Message Date
21ae8ba501 fix: prefer active registry credentials
All checks were successful
CI/CD Pipeline / Code Quality Checks (pull_request) Successful in 3m9s
CI/CD Pipeline / Run Tests (pull_request) Successful in 3m14s
CI/CD Pipeline / Build and Push Dev Images (pull_request) Has been skipped
CI/CD Pipeline / Deploy Dev via Compose (pull_request) Has been skipped
2026-07-22 14:46:35 +02:00
avm
0b39d9d98c Merge pull request 'Fix exchange package imports' (#10) from feature/exchange-upload-fix-20260722 into main
Some checks failed
Deploy Customer Main / Build, Push, Deploy (push) Failing after 38s
CI/CD Pipeline / Run Tests (push) Successful in 2m16s
CI/CD Pipeline / Code Quality Checks (push) Successful in 2m42s
CI/CD Pipeline / Build and Push Dev Images (push) Has been skipped
CI/CD Pipeline / Deploy Dev via Compose (push) Has been skipped
2026-07-22 15:42:41 +03:00
db09d6e4b5 style: format exchange import regression test
All checks were successful
CI/CD Pipeline / Code Quality Checks (pull_request) Successful in 3m45s
CI/CD Pipeline / Run Tests (pull_request) Successful in 4m0s
CI/CD Pipeline / Build and Push Dev Images (pull_request) Has been skipped
CI/CD Pipeline / Deploy Dev via Compose (pull_request) Has been skipped
2026-07-22 14:28:42 +02:00
dec81f6e7c fix: harden state corp exchange imports 2026-07-22 14:27:38 +02:00
avm
bafe04dd40 Merge pull request 'Merge dev into main for customer release' (#9) from feature/customer-release-20260719 into main
Some checks failed
CI/CD Pipeline / Run Tests (push) Successful in 3m50s
CI/CD Pipeline / Code Quality Checks (push) Successful in 4m11s
CI/CD Pipeline / Build and Push Dev Images (push) Has been skipped
CI/CD Pipeline / Deploy Dev via Compose (push) Has been skipped
Deploy Customer Main / Build, Push, Deploy (push) Failing after 5m18s
2026-07-19 21:37:49 +03:00
8 changed files with 152 additions and 5 deletions

View File

@@ -57,8 +57,8 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
registry_user="${REGISTRY_USER:-${REGISTRY_USERNAME:-${GITHUB_ACTOR:-}}}" registry_user="${REGISTRY_USERNAME:-${REGISTRY_USER:-${GITHUB_ACTOR:-}}}"
registry_password="${REGISTRY_TOKEN:-${REGISTRY_PASSWORD:-${GITEA_TOKEN:-}}}" registry_password="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-${GITEA_TOKEN:-}}}"
home_dir="${HOME:-/root}" home_dir="${HOME:-/root}"
if [ -z "${registry_user}" ]; then if [ -z "${registry_user}" ]; then
@@ -98,8 +98,8 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
registry_user="${REGISTRY_USER:-${REGISTRY_USERNAME:-${GITHUB_ACTOR:-}}}" registry_user="${REGISTRY_USERNAME:-${REGISTRY_USER:-${GITHUB_ACTOR:-}}}"
registry_password="${REGISTRY_TOKEN:-${REGISTRY_PASSWORD:-${GITEA_TOKEN:-}}}" registry_password="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-${GITEA_TOKEN:-}}}"
sha_short="$(printf '%s' "${GITHUB_SHA}" | cut -c1-12)" sha_short="$(printf '%s' "${GITHUB_SHA}" | cut -c1-12)"
registry_path="${CUSTOMER_REGISTRY_HOST}/${CUSTOMER_REGISTRY_NAMESPACE}" registry_path="${CUSTOMER_REGISTRY_HOST}/${CUSTOMER_REGISTRY_NAMESPACE}"
web_ref="${registry_path}/${CUSTOMER_WEB_IMAGE}" web_ref="${registry_path}/${CUSTOMER_WEB_IMAGE}"

View File

@@ -131,6 +131,18 @@ uv sync --dev
Можно через `make services-up` или любым удобным способом. Можно через `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. Экспорт переменных ### 3. Экспорт переменных
```bash ```bash

View File

@@ -41,6 +41,15 @@ def _check_db(timeout_seconds: int) -> tuple[bool, str]:
with conn.cursor() as cursor: with conn.cursor() as cursor:
cursor.execute("SELECT 1") cursor.execute("SELECT 1")
cursor.fetchone() 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" return True, "OK"
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
target = f"{params['host']}:{params['port']}/{params['dbname']}" target = f"{params['host']}:{params['port']}/{params['dbname']}"

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import json import json
import logging
import struct import struct
import uuid import uuid
import zlib import zlib
@@ -41,6 +42,8 @@ from django.db import transaction
from django.db.models import Q from django.db.models import Q
from django.utils import timezone from django.utils import timezone
logger = logging.getLogger(__name__)
class ExchangeImportError(ValueError): class ExchangeImportError(ValueError):
"""Exchange package validation or import error.""" """Exchange package validation or import error."""
@@ -282,6 +285,13 @@ class ExchangePackageImportService:
) )
raise raise
except Exception as exc: # noqa: BLE001 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 = "Не удалось импортировать пакет обмена" error_message = "Не удалось импортировать пакет обмена"
cls._create_failed_record( cls._create_failed_record(
decoded=decoded, decoded=decoded,

View File

@@ -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="предмет закупки"),
),
]

View File

@@ -134,7 +134,7 @@ class PublicProcurement(UUIDPrimaryKeyMixin, TimestampMixin, models.Model):
execution_end_date = models.DateField( execution_end_date = models.DateField(
_("дата окончания исполнения"), null=True, blank=True _("дата окончания исполнения"), null=True, blank=True
) )
purchase_name = models.CharField(_("предмет закупки"), max_length=500) purchase_name = models.TextField(_("предмет закупки"))
class Meta: class Meta:
ordering = ["-contract_date", "purchase_number"] ordering = ["-contract_date", "purchase_number"]

View File

@@ -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)

View File

@@ -11,6 +11,7 @@ import zlib
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from io import BytesIO from io import BytesIO
from unittest.mock import patch
from zipfile import ZIP_DEFLATED, ZipFile from zipfile import ZIP_DEFLATED, ZipFile
from apps.exchange.models import ExchangeDeliveryChannel, ExchangePackageImport 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.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.management import call_command from django.core.management import call_command
from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from rest_framework import status from rest_framework import status
@@ -531,6 +533,67 @@ class ExchangePackageApiTest(APITestCase):
self.assertEqual(ExchangePackageImport.objects.count(), 0) self.assertEqual(ExchangePackageImport.objects.count(), 0)
self.assertEqual(Organization.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): def test_upload_rejects_legacy_schema_version(self):
archive = build_exchange_archive( archive = build_exchange_archive(
package_id="pkg-legacy-schema-version", package_id="pkg-legacy-schema-version",