Merge dev into main for customer release #39

Merged
avm merged 21 commits from feature/customer-release-20260811 into main 2026-08-11 13:30:24 +03:00
6 changed files with 165 additions and 46 deletions
Showing only changes of commit 1c2dd985d4 - Show all commits

View File

@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@@ -18,6 +20,8 @@ MAX_PAGE_SIZE = 100
MAX_PAGES = 100 MAX_PAGES = 100
MAX_RECORDS = 10_000 MAX_RECORDS = 10_000
logger = logging.getLogger(__name__)
class GispProductsClientError(HTTPClientError): class GispProductsClientError(HTTPClientError):
"""Ошибка клиента реестра продукции ГИСП.""" """Ошибка клиента реестра продукции ГИСП."""
@@ -44,6 +48,8 @@ class GispProductsClient:
max_records: int = DEFAULT_MAX_RECORDS max_records: int = DEFAULT_MAX_RECORDS
proxies: list[str] | None = None proxies: list[str] | None = None
timeout: int = 120 timeout: int = 120
max_retries: int = 2
retry_backoff_seconds: float = 1.0
http_adapter: BaseAdapter | None = None http_adapter: BaseAdapter | None = None
_http_client: BaseHTTPClient | None = field(default=None, repr=False) _http_client: BaseHTTPClient | None = field(default=None, repr=False)
@@ -54,6 +60,10 @@ class GispProductsClient:
raise ValueError(f"max_pages must be between 1 and {MAX_PAGES}") raise ValueError(f"max_pages must be between 1 and {MAX_PAGES}")
if not 1 <= self.max_records <= MAX_RECORDS: if not 1 <= self.max_records <= MAX_RECORDS:
raise ValueError(f"max_records must be between 1 and {MAX_RECORDS}") raise ValueError(f"max_records must be between 1 and {MAX_RECORDS}")
if self.max_retries < 0:
raise ValueError("max_retries must be non-negative")
if self.retry_backoff_seconds < 0:
raise ValueError("retry_backoff_seconds must be non-negative")
@property @property
def http_client(self) -> BaseHTTPClient: def http_client(self) -> BaseHTTPClient:
@@ -129,16 +139,33 @@ class GispProductsClient:
take: int, take: int,
previous_total_count: int | None, previous_total_count: int | None,
) -> tuple[list[Any], int | None]: ) -> tuple[list[Any], int | None]:
data = self.http_client.post_json( payload = {
PRODUCTS_ENDPOINT,
payload={
"opt": { "opt": {
"skip": offset, "skip": offset,
"take": take, "take": take,
"requireTotalCount": True, "requireTotalCount": True,
} }
}, }
for attempt in range(self.max_retries + 1):
try:
data = self.http_client.post_json(
PRODUCTS_ENDPOINT,
payload=payload,
) )
break
except HTTPClientError as exc:
transient = exc.status_code is None or exc.status_code >= 500
if not transient or attempt >= self.max_retries:
raise
delay = self.retry_backoff_seconds * (2**attempt)
logger.warning(
"GISP request failed temporarily (attempt %d/%d): %s",
attempt + 1,
self.max_retries + 1,
exc,
)
if delay:
time.sleep(delay)
if data.get("ok") is False: if data.get("ok") is False:
raise GispProductsClientError("GISP products API returned ok=false") raise GispProductsClientError("GISP products API returned ok=false")
items = data.get("items") items = data.get("items")

View File

@@ -693,7 +693,7 @@ class ProverkiClient:
return inspections return inspections
def _parse_xml_streaming( def _parse_xml_streaming( # noqa: C901
self, self,
content: bytes, content: bytes,
progress_callback: Callable[[int, str], None] | None = None, progress_callback: Callable[[int, str], None] | None = None,
@@ -704,9 +704,39 @@ class ProverkiClient:
Использует iterparse для обработки файла по элементам, Использует iterparse для обработки файла по элементам,
не загружая весь файл в память. не загружая весь файл в память.
""" """
inspections = [] import io
# Декодируем и создаём поток inspections = []
target_tags = {"INSPECTION", "inspection", "check", "КНМ"}
count = 0
def parse_stream(xml_stream: io.BytesIO | io.StringIO) -> None:
nonlocal count
for _event, elem in ET.iterparse(xml_stream, events=["end"]): # noqa: S314
tag_name = elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag
if tag_name not in target_tags:
continue
inspection = self._parse_xml_record(elem)
if inspection:
inspections.append(inspection)
count += 1
if count % 10000 == 0:
logger.info("Streaming parsed %d inspections...", count)
elem.clear()
try:
# ElementTree сам учитывает encoding из XML declaration. Для больших
# файлов это исключает несколько полноразмерных Unicode-копий XML.
parse_stream(io.BytesIO(content))
except ET.ParseError as e:
if inspections:
logger.error("XML streaming parse error at %d records: %s", count, e)
logger.info(
"Returning %d successfully parsed records", len(inspections)
)
elif len(content) <= self.STREAMING_THRESHOLD_BYTES:
# Совместимость с небольшими файлами без корректной декларации
# кодировки и с историческим поведением очистки XML.
for encoding in ["utf-8", "windows-1251", "cp1251"]: for encoding in ["utf-8", "windows-1251", "cp1251"]:
try: try:
xml_str = content.decode(encoding) xml_str = content.decode(encoding)
@@ -715,41 +745,12 @@ class ProverkiClient:
continue continue
else: else:
xml_str = content.decode("utf-8", errors="replace") xml_str = content.decode("utf-8", errors="replace")
xml_str = self._sanitize_xml(xml_str)
# Используем iterparse для потоковой обработки
import io
xml_stream = io.StringIO(xml_str)
# Определяем теги, которые нас интересуют
target_tags = {"INSPECTION", "inspection", "check", "КНМ"}
count = 0
try: try:
for _event, elem in ET.iterparse(xml_stream, events=["end"]): # noqa: S314 parse_stream(io.StringIO(self._sanitize_xml(xml_str)))
# Извлекаем имя тега без namespace except ET.ParseError as fallback_error:
tag_name = elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag raise ProverkiClientError(
f"Failed to parse XML: {fallback_error}"
if tag_name in target_tags: ) from fallback_error
inspection = self._parse_xml_record(elem)
if inspection:
inspections.append(inspection)
count += 1
if count % 10000 == 0:
logger.info("Streaming parsed %d inspections...", count)
# Очищаем элемент для освобождения памяти
elem.clear()
except ET.ParseError as e:
logger.error("XML streaming parse error at %d records: %s", count, e)
if inspections:
logger.info(
"Returning %d successfully parsed records", len(inspections)
)
else: else:
raise ProverkiClientError(f"Failed to parse XML: {e}") from e raise ProverkiClientError(f"Failed to parse XML: {e}") from e

View File

@@ -9,8 +9,10 @@ import csv
import json import json
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from datetime import timedelta
from apps.core.filters import BaseFilterSet from apps.core.filters import BaseFilterSet
from apps.core.models import JobStatus
from apps.core.openapi import CommonResponses, ErrorResponses, swagger_tag from apps.core.openapi import CommonResponses, ErrorResponses, swagger_tag
from apps.core.response import api_error_response, api_response from apps.core.response import api_error_response, api_response
from apps.core.serializers import BackgroundJobListSerializer from apps.core.serializers import BackgroundJobListSerializer
@@ -72,6 +74,7 @@ from django.core.paginator import Paginator
from django.db.models import CharField, Count, Q from django.db.models import CharField, Count, Q
from django.db.models.functions import Cast, Lower from django.db.models.functions import Cast, Lower
from django.http import HttpResponse from django.http import HttpResponse
from django.utils import timezone
from django.utils.text import get_valid_filename from django.utils.text import get_valid_filename
from django_celery_beat.models import CrontabSchedule, IntervalSchedule, PeriodicTask from django_celery_beat.models import CrontabSchedule, IntervalSchedule, PeriodicTask
from django_filters import rest_framework as filters from django_filters import rest_framework as filters
@@ -2656,6 +2659,33 @@ class ParserRunView(APIView):
task_kwargs = build_task_kwargs( task_kwargs = build_task_kwargs(
canonical_source_key, serializer.validated_data, request.user.id canonical_source_key, serializer.validated_data, request.user.id
) )
now = timezone.now()
active_job = (
BackgroundJobService.get_queryset()
.filter(task_name=descriptor.task_name)
.filter(
Q(
status=JobStatus.PENDING,
created_at__gte=now - timedelta(hours=24),
)
| Q(
status__in=[JobStatus.STARTED, JobStatus.RETRY],
updated_at__gte=now - timedelta(hours=4),
)
)
.order_by("-created_at")
.first()
)
if active_job is not None:
return api_response(
{
"task_id": active_job.task_id,
"source": descriptor.source,
"task_name": descriptor.task_name,
"already_running": True,
},
status_code=status.HTTP_202_ACCEPTED,
)
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
BackgroundJobService.create_job( BackgroundJobService.create_job(
task_id=task_id, task_id=task_id,

View File

@@ -49,6 +49,35 @@ class GispProductsClientTest(SimpleTestCase):
): ):
GispProductsClient(max_records=10_001) GispProductsClient(max_records=10_001)
def test_retries_transient_server_error(self):
calls = 0
def handle_page(_request, _body: bytes) -> Response:
nonlocal calls
calls += 1
if calls == 1:
return Response(status=504, body=b"gateway timeout")
return Response(
body=b'{"ok":true,"total_count":1,"items":[]}',
headers={"Content-Type": "application/json"},
)
with HTTPTestServer() as server:
server.add_route("POST", "/pp719v2/pub/prod/b/", handle_page)
client = GispProductsClient(
base_url=server.base_url,
max_pages=1,
retry_backoff_seconds=0,
http_adapter=server.adapter,
)
with self.assertRaisesMessage(
GispProductsClientError,
"empty page before total_count",
):
client.fetch_products()
self.assertEqual(calls, 2)
def test_fetch_products_follows_skip_pagination_and_maps_items(self): def test_fetch_products_follows_skip_pagination_and_maps_items(self):
requests: list[dict] = [] requests: list[dict] = []

View File

@@ -9,6 +9,7 @@ import tempfile
import types import types
from asyncio import events as asyncio_events from asyncio import events as asyncio_events
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from xml.etree import ElementPath as element_path from xml.etree import ElementPath as element_path
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
@@ -563,6 +564,18 @@ class ProverkiParseXMLTest(SimpleTestCase):
inspections = client._parse_xml_content(xml) inspections = client._parse_xml_content(xml)
self.assertEqual(len(inspections), 1) self.assertEqual(len(inspections), 1)
def test_large_xml_streaming_does_not_build_sanitized_string(self):
xml = _xml_with_tag("INSPECTION", _inspection_attrs())
client = ProverkiClient()
client.STREAMING_THRESHOLD_BYTES = 1
with patch.object(
client,
"_sanitize_xml",
side_effect=AssertionError("large XML must stay as bytes"),
):
inspections = client._parse_xml_content(xml)
self.assertEqual(len(inspections), 1)
def test_parse_xml_record_missing_fields_returns_none(self): def test_parse_xml_record_missing_fields_returns_none(self):
element = ET.fromstring("<INSPECTION />") # noqa: S314 element = ET.fromstring("<INSPECTION />") # noqa: S314
client = ProverkiClient() client = ProverkiClient()

View File

@@ -11,7 +11,7 @@ import zipfile
from datetime import date from datetime import date
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from apps.core.models import BackgroundJob from apps.core.models import BackgroundJob, JobStatus
from apps.parsers.models import ( from apps.parsers.models import (
FinancialReport, FinancialReport,
FinancialReportLine, FinancialReportLine,
@@ -1428,3 +1428,22 @@ class ParsersViewSetTest(APITestCase):
queued_task_id = apply_async_mock.call_args.kwargs["task_id"] queued_task_id = apply_async_mock.call_args.kwargs["task_id"]
job = BackgroundJob.objects.get(task_id=queued_task_id) job = BackgroundJob.objects.get(task_id=queued_task_id)
self.assertEqual(job.meta["source_key"], "mpt_products") self.assertEqual(job.meta["source_key"], "mpt_products")
def test_run_parser_reuses_fresh_active_job(self):
self.client.force_authenticate(self.user)
existing = BackgroundJob.objects.create(
task_id="active-products",
task_name="apps.parsers.tasks.parse_industrial_products",
status=JobStatus.STARTED,
)
url = reverse("api_v1:parsers:run-parser", args=["industrial_products"])
with patch(
"apps.parsers.views.tasks.parse_industrial_products.apply_async"
) as apply_async_mock:
response = self.client.post(url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
self.assertEqual(response.data["data"]["task_id"], existing.task_id)
self.assertTrue(response.data["data"]["already_running"])
apply_async_mock.assert_not_called()