Add state-corp exchange package sender
All checks were successful
All checks were successful
This commit is contained in:
1
src/apps/exchange/management/__init__.py
Normal file
1
src/apps/exchange/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Management package for exchange app."""
|
||||||
1
src/apps/exchange/management/commands/__init__.py
Normal file
1
src/apps/exchange/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Exchange management commands."""
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Export encrypted package for manual delivery to state-corp."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from apps.core.management.commands.base import BaseAppCommand
|
||||||
|
from apps.exchange.state_corp_services import StateCorpExchangeService
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseAppCommand):
|
||||||
|
"""Build package and save it to disk."""
|
||||||
|
|
||||||
|
help = "Экспортирует зашифрованный пакет обмена для state-corp в .zip файл"
|
||||||
|
|
||||||
|
def add_arguments(self, parser) -> None:
|
||||||
|
super().add_arguments(parser)
|
||||||
|
parser.add_argument("output_path", type=str, help="Куда сохранить .zip пакет")
|
||||||
|
parser.add_argument(
|
||||||
|
"--package-id", type=str, default="", help="Явный package_id"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--organization-inn",
|
||||||
|
action="append",
|
||||||
|
dest="organization_inns",
|
||||||
|
default=[],
|
||||||
|
help="Ограничить экспорт конкретными ИНН. Параметр можно повторять.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute_command(self, *args, **options) -> str:
|
||||||
|
package = StateCorpExchangeService.build_package(
|
||||||
|
package_id=options["package_id"] or None,
|
||||||
|
organization_inns=options["organization_inns"],
|
||||||
|
)
|
||||||
|
output_path = StateCorpExchangeService.save_package(
|
||||||
|
package=package,
|
||||||
|
output_path=options["output_path"],
|
||||||
|
)
|
||||||
|
rendered = json.dumps(
|
||||||
|
{
|
||||||
|
"package_id": package.package_id,
|
||||||
|
"archive_name": package.archive_name,
|
||||||
|
"output_path": str(output_path),
|
||||||
|
"payload_counts": package.payload_counts,
|
||||||
|
"produced_at": package.produced_at,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
self.log_success(rendered)
|
||||||
|
return rendered
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Push encrypted package to state-corp exchange endpoint."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from apps.core.management.commands.base import BaseAppCommand
|
||||||
|
from apps.exchange.state_corp_services import StateCorpExchangeService
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseAppCommand):
|
||||||
|
"""Build and send package to state-corp."""
|
||||||
|
|
||||||
|
help = "Собирает и отправляет пакет обмена в state-corp dev endpoint"
|
||||||
|
|
||||||
|
def add_arguments(self, parser) -> None:
|
||||||
|
super().add_arguments(parser)
|
||||||
|
parser.add_argument(
|
||||||
|
"--package-id", type=str, default="", help="Явный package_id"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--target-url",
|
||||||
|
type=str,
|
||||||
|
default="",
|
||||||
|
help="Override для STATE_CORP_EXCHANGE_URL",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--token",
|
||||||
|
type=str,
|
||||||
|
default="",
|
||||||
|
help="Override для STATE_CORP_EXCHANGE_TOKEN",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--save-package",
|
||||||
|
type=str,
|
||||||
|
default="",
|
||||||
|
help="Дополнительно сохранить сформированный пакет в файл",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--organization-inn",
|
||||||
|
action="append",
|
||||||
|
dest="organization_inns",
|
||||||
|
default=[],
|
||||||
|
help="Ограничить экспорт конкретными ИНН. Параметр можно повторять.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute_command(self, *args, **options) -> str:
|
||||||
|
package = StateCorpExchangeService.build_package(
|
||||||
|
package_id=options["package_id"] or None,
|
||||||
|
organization_inns=options["organization_inns"],
|
||||||
|
)
|
||||||
|
saved_path = None
|
||||||
|
if options["save_package"]:
|
||||||
|
saved_path = StateCorpExchangeService.save_package(
|
||||||
|
package=package,
|
||||||
|
output_path=options["save_package"],
|
||||||
|
)
|
||||||
|
result = StateCorpExchangeService.send_package(
|
||||||
|
package=package,
|
||||||
|
target_url=options["target_url"] or None,
|
||||||
|
token=options["token"] or None,
|
||||||
|
)
|
||||||
|
rendered = json.dumps(
|
||||||
|
{
|
||||||
|
**result,
|
||||||
|
"payload_counts": package.payload_counts,
|
||||||
|
"saved_path": str(saved_path) if saved_path else None,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
self.log_success(rendered)
|
||||||
|
return rendered
|
||||||
388
src/apps/exchange/state_corp_services.py
Normal file
388
src/apps/exchange/state_corp_services.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
"""Build and deliver exchange packages for state-corp-backend."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
from zipfile import ZIP_DEFLATED, ZipFile
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from apps.parsers.models import (
|
||||||
|
IndustrialProductRecord,
|
||||||
|
InspectionRecord,
|
||||||
|
ProcurementRecord,
|
||||||
|
)
|
||||||
|
from apps.registers.models import Organization
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class StateCorpExchangeError(ValueError):
|
||||||
|
"""Raised when building or sending state-corp exchange packages fails."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StateCorpExchangePackage:
|
||||||
|
"""Built archive ready for upload or manual delivery."""
|
||||||
|
|
||||||
|
package_id: str
|
||||||
|
archive_name: str
|
||||||
|
bin_name: str
|
||||||
|
archive_bytes: bytes
|
||||||
|
payload_counts: dict[str, int]
|
||||||
|
produced_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class StateCorpExchangeService:
|
||||||
|
"""Create and send packages compatible with state-corp exchange import."""
|
||||||
|
|
||||||
|
MAGIC = b"EXCH"
|
||||||
|
AAD = b"state-corp-exchange-v1"
|
||||||
|
PAYLOAD_FORMAT = "state-corp-exchange-payload"
|
||||||
|
BIN_FORMAT = "state-corp-exchange-bin"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build_package(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
package_id: str | None = None,
|
||||||
|
source_system: str = "mostovik",
|
||||||
|
organization_inns: list[str] | None = None,
|
||||||
|
) -> StateCorpExchangePackage:
|
||||||
|
"""Build encrypted archive for state-corp import."""
|
||||||
|
produced_at = timezone.now()
|
||||||
|
normalized_inns = cls._normalize_inn_list(organization_inns)
|
||||||
|
organizations = cls._get_organizations(normalized_inns)
|
||||||
|
allowed_inns = {str(item.mn_inn) for item in organizations}
|
||||||
|
data = {
|
||||||
|
"organizations": cls._serialize_organizations(organizations),
|
||||||
|
"industrial_products": cls._serialize_industrial_products(allowed_inns),
|
||||||
|
"prosecutor_checks": cls._serialize_prosecutor_checks(allowed_inns),
|
||||||
|
"public_procurements": cls._serialize_public_procurements(allowed_inns),
|
||||||
|
"arbitration_cases": [],
|
||||||
|
}
|
||||||
|
payload_counts = {key: len(value) for key, value in data.items()}
|
||||||
|
package_id = package_id or cls._build_package_id()
|
||||||
|
payload = {
|
||||||
|
"format": cls.PAYLOAD_FORMAT,
|
||||||
|
"schema_version": 1,
|
||||||
|
"manifest": {
|
||||||
|
"package_id": package_id,
|
||||||
|
"source_system": source_system,
|
||||||
|
"produced_at": produced_at.isoformat(),
|
||||||
|
"sections": [key for key, items in data.items() if items],
|
||||||
|
},
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
archive_name, bin_name, archive_bytes = cls._render_archive(
|
||||||
|
package_id=package_id,
|
||||||
|
payload=payload,
|
||||||
|
produced_at=produced_at,
|
||||||
|
)
|
||||||
|
return StateCorpExchangePackage(
|
||||||
|
package_id=package_id,
|
||||||
|
archive_name=archive_name,
|
||||||
|
bin_name=bin_name,
|
||||||
|
archive_bytes=archive_bytes,
|
||||||
|
payload_counts=payload_counts,
|
||||||
|
produced_at=produced_at.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def save_package(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
package: StateCorpExchangePackage,
|
||||||
|
output_path: str | Path,
|
||||||
|
) -> Path:
|
||||||
|
"""Persist package to disk for manual transfer."""
|
||||||
|
target_path = Path(output_path).expanduser().resolve()
|
||||||
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target_path.write_bytes(package.archive_bytes)
|
||||||
|
return target_path
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def send_package(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
package: StateCorpExchangePackage,
|
||||||
|
target_url: str | None = None,
|
||||||
|
token: str | None = None,
|
||||||
|
timeout_seconds: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Send package to state-corp dev endpoint."""
|
||||||
|
resolved_url = str(target_url or settings.STATE_CORP_EXCHANGE_URL).strip()
|
||||||
|
resolved_token = str(token or settings.STATE_CORP_EXCHANGE_TOKEN).strip()
|
||||||
|
timeout_seconds = timeout_seconds or int(
|
||||||
|
getattr(settings, "STATE_CORP_EXCHANGE_TIMEOUT_SECONDS", 60)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not resolved_url:
|
||||||
|
raise StateCorpExchangeError("STATE_CORP_EXCHANGE_URL не настроен")
|
||||||
|
if not resolved_token:
|
||||||
|
raise StateCorpExchangeError("STATE_CORP_EXCHANGE_TOKEN не настроен")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
resolved_url,
|
||||||
|
files={
|
||||||
|
"file": (
|
||||||
|
package.archive_name,
|
||||||
|
package.archive_bytes,
|
||||||
|
"application/zip",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
headers={"X-Exchange-Token": resolved_token},
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise StateCorpExchangeError(
|
||||||
|
f"Не удалось отправить пакет в state-corp: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError:
|
||||||
|
body = {"raw": response.text}
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise StateCorpExchangeError(
|
||||||
|
f"state-corp вернул HTTP {response.status_code}: {body}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"response": body,
|
||||||
|
"package_id": package.package_id,
|
||||||
|
"archive_name": package.archive_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_package_id(cls) -> str:
|
||||||
|
return f"mostovik-{timezone.now():%Y%m%d%H%M%S}-{uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _render_archive(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
package_id: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
produced_at,
|
||||||
|
) -> tuple[str, str, bytes]:
|
||||||
|
token = str(settings.STATE_CORP_EXCHANGE_TOKEN or "").strip()
|
||||||
|
if not token:
|
||||||
|
raise StateCorpExchangeError("STATE_CORP_EXCHANGE_TOKEN не настроен")
|
||||||
|
|
||||||
|
payload_bytes = json.dumps(
|
||||||
|
payload,
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
compressed_payload = zlib.compress(payload_bytes, level=9)
|
||||||
|
nonce = secrets.token_bytes(12)
|
||||||
|
encrypted_payload = AESGCM(
|
||||||
|
hashlib.sha256(token.encode("utf-8")).digest()
|
||||||
|
).encrypt(
|
||||||
|
nonce,
|
||||||
|
compressed_payload,
|
||||||
|
cls.AAD,
|
||||||
|
)
|
||||||
|
header = {
|
||||||
|
"format": cls.BIN_FORMAT,
|
||||||
|
"version": 1,
|
||||||
|
"key_id": settings.STATE_CORP_EXCHANGE_KEY_ID,
|
||||||
|
"nonce": cls._b64url(nonce),
|
||||||
|
"aad": cls._b64url(cls.AAD),
|
||||||
|
"package_id": package_id,
|
||||||
|
"schema_version": 1,
|
||||||
|
"plaintext_sha256": hashlib.sha256(payload_bytes).hexdigest(),
|
||||||
|
"compressed_sha256": hashlib.sha256(compressed_payload).hexdigest(),
|
||||||
|
"ciphertext_sha256": hashlib.sha256(encrypted_payload).hexdigest(),
|
||||||
|
}
|
||||||
|
header_bytes = json.dumps(
|
||||||
|
header,
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
bin_bytes = (
|
||||||
|
cls.MAGIC
|
||||||
|
+ bytes([1])
|
||||||
|
+ struct.pack(">I", len(header_bytes))
|
||||||
|
+ header_bytes
|
||||||
|
+ encrypted_payload
|
||||||
|
)
|
||||||
|
|
||||||
|
timestamp = produced_at.strftime("%Y%m%d_%H%M%S")
|
||||||
|
archive_name = f"state_corp_exchange_{timestamp}.zip"
|
||||||
|
bin_name = f"state_corp_exchange_{timestamp}.bin"
|
||||||
|
|
||||||
|
archive_bytes = BytesIO()
|
||||||
|
with ZipFile(archive_bytes, "w", compression=ZIP_DEFLATED) as archive:
|
||||||
|
archive.writestr(bin_name, bin_bytes)
|
||||||
|
archive.writestr(
|
||||||
|
f"{bin_name}.sha256",
|
||||||
|
f"{hashlib.sha256(bin_bytes).hexdigest()} {bin_name}\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
return archive_name, bin_name, archive_bytes.getvalue()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_organizations(cls, organization_inns: list[str]) -> list[Organization]:
|
||||||
|
queryset = Organization.objects.all().order_by("id")
|
||||||
|
if organization_inns:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
mn_inn__in=[int(item) for item in organization_inns]
|
||||||
|
)
|
||||||
|
return list(queryset)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _serialize_organizations(
|
||||||
|
cls,
|
||||||
|
organizations: list[Organization],
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"inn": str(item.mn_inn),
|
||||||
|
"name": item.pn_name,
|
||||||
|
"ogrn": str(item.mn_ogrn),
|
||||||
|
"kpp": str(item.in_kpp or ""),
|
||||||
|
"okpo": item.mn_okpo,
|
||||||
|
}
|
||||||
|
for item in organizations
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _serialize_industrial_products(
|
||||||
|
cls,
|
||||||
|
allowed_inns: set[str],
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
queryset = IndustrialProductRecord.objects.filter(
|
||||||
|
inn__in=allowed_inns
|
||||||
|
).order_by("id")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"organization_inn": cls._digits(record.inn),
|
||||||
|
"product_name": record.product_name,
|
||||||
|
"product_class": record.product_model
|
||||||
|
or record.regulatory_document
|
||||||
|
or "Промышленная продукция",
|
||||||
|
"okpd2_code": record.okpd2_code,
|
||||||
|
"tnved_code": record.tnved_code,
|
||||||
|
"registry_number": record.registry_number,
|
||||||
|
}
|
||||||
|
for record in queryset
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _serialize_prosecutor_checks(
|
||||||
|
cls,
|
||||||
|
allowed_inns: set[str],
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
items: list[dict[str, str]] = []
|
||||||
|
queryset = InspectionRecord.objects.filter(inn__in=allowed_inns).order_by("id")
|
||||||
|
for record in queryset:
|
||||||
|
start_date = cls._coerce_date(
|
||||||
|
record.start_date_normalized, record.start_date
|
||||||
|
)
|
||||||
|
if start_date is None:
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"organization_inn": cls._digits(record.inn),
|
||||||
|
"registration_number": record.registration_number,
|
||||||
|
"law_type": record.legal_basis
|
||||||
|
or ("248-ФЗ" if record.is_federal_law_248 else "294-ФЗ"),
|
||||||
|
"control_authority": record.control_authority,
|
||||||
|
"prosecutor_office": "",
|
||||||
|
"start_date": start_date.isoformat(),
|
||||||
|
"status": record.status,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _serialize_public_procurements(
|
||||||
|
cls,
|
||||||
|
allowed_inns: set[str],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
queryset = ProcurementRecord.objects.filter(
|
||||||
|
customer_inn__in=allowed_inns
|
||||||
|
).order_by("id")
|
||||||
|
for record in queryset:
|
||||||
|
contract_date = cls._coerce_date(
|
||||||
|
record.publish_date_normalized,
|
||||||
|
record.publish_date,
|
||||||
|
)
|
||||||
|
if contract_date is None:
|
||||||
|
continue
|
||||||
|
execution_end_date = cls._coerce_date(
|
||||||
|
record.end_date_normalized,
|
||||||
|
record.end_date,
|
||||||
|
)
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"organization_inn": cls._digits(record.customer_inn),
|
||||||
|
"purchase_number": record.purchase_number,
|
||||||
|
"law_type": record.law_type,
|
||||||
|
"status": record.status,
|
||||||
|
"contract_amount": cls._serialize_decimal(record.max_price_amount),
|
||||||
|
"contract_date": contract_date.isoformat(),
|
||||||
|
"execution_start_date": contract_date.isoformat(),
|
||||||
|
"execution_end_date": (
|
||||||
|
execution_end_date.isoformat() if execution_end_date else None
|
||||||
|
),
|
||||||
|
"purchase_name": record.purchase_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_inn_list(organization_inns: list[str] | None) -> list[str]:
|
||||||
|
normalized_items: list[str] = []
|
||||||
|
for item in organization_inns or []:
|
||||||
|
normalized = "".join(char for char in str(item).strip() if char.isdigit())
|
||||||
|
if normalized:
|
||||||
|
normalized_items.append(normalized)
|
||||||
|
return normalized_items
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _digits(value: str | int) -> str:
|
||||||
|
return "".join(char for char in str(value).strip() if char.isdigit())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _b64url(data: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _coerce_date(
|
||||||
|
cls, normalized: date | None, raw_value: str | None
|
||||||
|
) -> date | None:
|
||||||
|
if normalized is not None:
|
||||||
|
return normalized
|
||||||
|
raw_text = str(raw_value or "").strip()
|
||||||
|
if not raw_text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(raw_text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _serialize_decimal(value: Decimal | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return format(value, "f")
|
||||||
@@ -217,6 +217,14 @@ PROXY_TOOLS_SYNC_INTERVAL_SECONDS = int(
|
|||||||
)
|
)
|
||||||
BACKUP_ENCRYPTION_KEY = os.getenv("BACKUP_ENCRYPTION_KEY", "")
|
BACKUP_ENCRYPTION_KEY = os.getenv("BACKUP_ENCRYPTION_KEY", "")
|
||||||
BACKUP_KEY_ID = os.getenv("BACKUP_KEY_ID", "default")
|
BACKUP_KEY_ID = os.getenv("BACKUP_KEY_ID", "default")
|
||||||
|
STATE_CORP_EXCHANGE_URL = os.getenv("STATE_CORP_EXCHANGE_URL", "").strip()
|
||||||
|
STATE_CORP_EXCHANGE_TOKEN = os.getenv("STATE_CORP_EXCHANGE_TOKEN", "").strip()
|
||||||
|
STATE_CORP_EXCHANGE_KEY_ID = os.getenv(
|
||||||
|
"STATE_CORP_EXCHANGE_KEY_ID", "state-corp-shared-token"
|
||||||
|
).strip()
|
||||||
|
STATE_CORP_EXCHANGE_TIMEOUT_SECONDS = int(
|
||||||
|
os.getenv("STATE_CORP_EXCHANGE_TIMEOUT_SECONDS", "60")
|
||||||
|
)
|
||||||
BACKUP_EXPORT_DIRECTORY = os.getenv(
|
BACKUP_EXPORT_DIRECTORY = os.getenv(
|
||||||
"BACKUP_EXPORT_DIRECTORY",
|
"BACKUP_EXPORT_DIRECTORY",
|
||||||
str(PROJECT_ROOT / "media" / "backups"),
|
str(PROJECT_ROOT / "media" / "backups"),
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ SECRET_KEY = "django-insecure-development-key-mostovik-2024"
|
|||||||
DEBUG = True
|
DEBUG = True
|
||||||
ALLOWED_HOSTS = ["*"]
|
ALLOWED_HOSTS = ["*"]
|
||||||
OPENAPI_USE_ENGLISH_TAGS = True
|
OPENAPI_USE_ENGLISH_TAGS = True
|
||||||
|
STATE_CORP_EXCHANGE_URL = os.getenv(
|
||||||
|
"STATE_CORP_EXCHANGE_URL",
|
||||||
|
"http://127.0.0.1:8001/api/v1/exchange/packages/upload/",
|
||||||
|
)
|
||||||
|
STATE_CORP_EXCHANGE_TOKEN = os.getenv(
|
||||||
|
"STATE_CORP_EXCHANGE_TOKEN",
|
||||||
|
"state-corp-dev-exchange-token-v1",
|
||||||
|
)
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
SIMPLE_JWT["SIGNING_KEY"] = SECRET_KEY
|
SIMPLE_JWT["SIGNING_KEY"] = SECRET_KEY
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from .base import *
|
|||||||
SECRET_KEY = "django-insecure-test-key-only-for-testing"
|
SECRET_KEY = "django-insecure-test-key-only-for-testing"
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
STARTUP_CHECKS_ENABLED = False
|
STARTUP_CHECKS_ENABLED = False
|
||||||
|
STATE_CORP_EXCHANGE_URL = "http://state-corp.test/api/v1/exchange/packages/upload/"
|
||||||
|
STATE_CORP_EXCHANGE_TOKEN = "state-corp-test-exchange-token"
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
SIMPLE_JWT["SIGNING_KEY"] = SECRET_KEY
|
SIMPLE_JWT["SIGNING_KEY"] = SECRET_KEY
|
||||||
|
|||||||
146
tests/apps/exchange/test_state_corp_services.py
Normal file
146
tests/apps/exchange/test_state_corp_services.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""Tests for state-corp package build and delivery from mostovik."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
from io import BytesIO
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
from zipfile import ZipFile
|
||||||
|
|
||||||
|
from apps.exchange.state_corp_services import StateCorpExchangeService
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
|
from tests.apps.parsers.factories import (
|
||||||
|
IndustrialProductRecordFactory,
|
||||||
|
InspectionRecordFactory,
|
||||||
|
ProcurementRecordFactory,
|
||||||
|
)
|
||||||
|
from tests.apps.registers.factories import OrganizationFactory
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_decode(value: str) -> bytes:
|
||||||
|
normalized = value + ("=" * (-len(value) % 4))
|
||||||
|
return base64.urlsafe_b64decode(normalized.encode("ascii"))
|
||||||
|
|
||||||
|
|
||||||
|
TEST_STATE_CORP_TOKEN = "state-corp-test-exchange-token" # noqa: S105
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
STATE_CORP_EXCHANGE_TOKEN=TEST_STATE_CORP_TOKEN,
|
||||||
|
STATE_CORP_EXCHANGE_URL="http://state-corp.test/api/v1/exchange/packages/upload/",
|
||||||
|
)
|
||||||
|
class StateCorpExchangeServiceTest(TestCase):
|
||||||
|
"""Verify package compatibility with state-corp receiver contract."""
|
||||||
|
|
||||||
|
def test_build_package_contains_expected_payload(self):
|
||||||
|
organization = OrganizationFactory.create(
|
||||||
|
mn_inn=7707083893,
|
||||||
|
mn_ogrn=1027700132195,
|
||||||
|
pn_name="АО Альфа",
|
||||||
|
in_kpp=770701001,
|
||||||
|
mn_okpo="12345678",
|
||||||
|
)
|
||||||
|
IndustrialProductRecordFactory.create(
|
||||||
|
inn=str(organization.mn_inn),
|
||||||
|
ogrn=str(organization.mn_ogrn),
|
||||||
|
full_organisation_name=organization.pn_name,
|
||||||
|
registry_number="prod-001",
|
||||||
|
product_name="Система связи М-1",
|
||||||
|
product_model="Связь",
|
||||||
|
registry_organization=organization,
|
||||||
|
)
|
||||||
|
InspectionRecordFactory.create(
|
||||||
|
inn=str(organization.mn_inn),
|
||||||
|
ogrn=str(organization.mn_ogrn),
|
||||||
|
organisation_name=organization.pn_name,
|
||||||
|
registration_number="insp-001",
|
||||||
|
control_authority="Минпромторг",
|
||||||
|
legal_basis="294-ФЗ",
|
||||||
|
start_date="2026-03-10",
|
||||||
|
start_date_normalized="2026-03-10",
|
||||||
|
status="active",
|
||||||
|
registry_organization=organization,
|
||||||
|
)
|
||||||
|
ProcurementRecordFactory.create(
|
||||||
|
customer_inn=str(organization.mn_inn),
|
||||||
|
customer_ogrn=str(organization.mn_ogrn),
|
||||||
|
customer_name=organization.pn_name,
|
||||||
|
purchase_number="purchase-001",
|
||||||
|
purchase_name="Поставка оборудования",
|
||||||
|
law_type="223-ФЗ",
|
||||||
|
status="executing",
|
||||||
|
publish_date="2026-02-15",
|
||||||
|
publish_date_normalized="2026-02-15",
|
||||||
|
end_date="2026-11-30",
|
||||||
|
end_date_normalized="2026-11-30",
|
||||||
|
max_price_amount="4500000.75",
|
||||||
|
registry_organization=organization,
|
||||||
|
)
|
||||||
|
|
||||||
|
package = StateCorpExchangeService.build_package()
|
||||||
|
self.assertEqual(package.payload_counts["organizations"], 1)
|
||||||
|
self.assertEqual(package.payload_counts["industrial_products"], 1)
|
||||||
|
self.assertEqual(package.payload_counts["prosecutor_checks"], 1)
|
||||||
|
self.assertEqual(package.payload_counts["public_procurements"], 1)
|
||||||
|
|
||||||
|
with ZipFile(BytesIO(package.archive_bytes)) as archive:
|
||||||
|
bin_names = [name for name in archive.namelist() if name.endswith(".bin")]
|
||||||
|
self.assertEqual(len(bin_names), 1)
|
||||||
|
bin_bytes = archive.read(bin_names[0])
|
||||||
|
|
||||||
|
header_size = struct.unpack(">I", bin_bytes[5:9])[0]
|
||||||
|
header_end = 9 + header_size
|
||||||
|
header = json.loads(bin_bytes[9:header_end].decode("utf-8"))
|
||||||
|
encrypted_payload = bin_bytes[header_end:]
|
||||||
|
raw_key = hashlib.sha256(TEST_STATE_CORP_TOKEN.encode("utf-8")).digest()
|
||||||
|
compressed_payload = AESGCM(raw_key).decrypt(
|
||||||
|
_b64url_decode(header["nonce"]),
|
||||||
|
encrypted_payload,
|
||||||
|
_b64url_decode(header["aad"]),
|
||||||
|
)
|
||||||
|
payload = json.loads(zlib.decompress(compressed_payload).decode("utf-8"))
|
||||||
|
|
||||||
|
self.assertEqual(payload["format"], StateCorpExchangeService.PAYLOAD_FORMAT)
|
||||||
|
self.assertEqual(payload["manifest"]["source_system"], "mostovik")
|
||||||
|
self.assertEqual(payload["data"]["organizations"][0]["inn"], "7707083893")
|
||||||
|
self.assertEqual(
|
||||||
|
payload["data"]["industrial_products"][0]["registry_number"],
|
||||||
|
"prod-001",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
payload["data"]["prosecutor_checks"][0]["registration_number"],
|
||||||
|
"insp-001",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
payload["data"]["public_procurements"][0]["purchase_number"],
|
||||||
|
"purchase-001",
|
||||||
|
)
|
||||||
|
self.assertEqual(payload["data"]["arbitration_cases"], [])
|
||||||
|
|
||||||
|
@patch("apps.exchange.state_corp_services.requests.post")
|
||||||
|
def test_send_package_posts_multipart_archive(self, post_mock):
|
||||||
|
response_mock = Mock(status_code=201)
|
||||||
|
response_mock.json.return_value = {
|
||||||
|
"message": "ok",
|
||||||
|
"result": {"duplicate": False},
|
||||||
|
}
|
||||||
|
post_mock.return_value = response_mock
|
||||||
|
|
||||||
|
package = StateCorpExchangeService.build_package()
|
||||||
|
result = StateCorpExchangeService.send_package(package=package)
|
||||||
|
|
||||||
|
self.assertEqual(result["status_code"], 201)
|
||||||
|
self.assertEqual(result["response"]["result"]["duplicate"], False)
|
||||||
|
post_mock.assert_called_once()
|
||||||
|
_, kwargs = post_mock.call_args
|
||||||
|
self.assertEqual(
|
||||||
|
kwargs["headers"]["X-Exchange-Token"],
|
||||||
|
TEST_STATE_CORP_TOKEN,
|
||||||
|
)
|
||||||
|
self.assertEqual(kwargs["files"]["file"][0], package.archive_name)
|
||||||
Reference in New Issue
Block a user