Files
mostovik-backend/src/apps/parsers/serializers.py
Aleksandr Meshchriakov 6d1ec2e55c
All checks were successful
CI/CD Pipeline / Quality Gate (push) Successful in 29s
CI/CD Pipeline / Build and Push Images (push) Successful in 10s
CI/CD Pipeline / Internal Notify (push) Successful in 0s
CI/CD Pipeline / Deploy Dev in Dokploy (push) Successful in 1s
fix(parsers): align vacancy sources and procurement counters
2026-05-14 14:45:58 +02:00

1049 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Сериализаторы для приложения парсеров."""
import ipaddress
import socket
from urllib.parse import urlsplit
from apps.parsers.clients.common.structured import (
MAX_FILE_SIZE_BYTES,
SUPPORTED_EXCEL_EXTENSIONS,
SUPPORTED_ZIP_EXTENSIONS,
)
from apps.parsers.fns_upload import FNS_XLSX_FILENAME_RE
from apps.parsers.models import (
FinancialReport,
FinancialReportLine,
GenericParserRecord,
IndustrialCertificateRecord,
IndustrialProductRecord,
InspectionRecord,
ManufacturerRecord,
ParserLoadLog,
ParsingSettings,
ProcurementRecord,
)
from apps.parsers.organization_enrichment import (
ORGANIZATION_ENRICHMENT_CONTEXT_KEY,
OrganizationFieldSet,
build_canonical_organization_enrichment,
enrich_organization_fields,
)
from rest_framework import serializers
BLOCKED_FILE_HOSTS = {"localhost", "localhost.localdomain"}
BLOCKED_FILE_HOST_SUFFIXES = (".localhost", ".local", ".internal")
SUPPORTED_UPLOAD_EXTENSIONS = tuple(
sorted({*SUPPORTED_EXCEL_EXTENSIONS, *SUPPORTED_ZIP_EXTENSIONS, ".zip"})
)
def _is_blocked_ip(address: str) -> bool:
ip = ipaddress.ip_address(address)
return (
ip.is_loopback
or ip.is_private
or ip.is_link_local
or ip.is_multicast
or ip.is_reserved
or ip.is_unspecified
)
def _validate_public_file_host(host: str) -> None:
normalized_host = host.strip("[]").rstrip(".").lower()
if normalized_host in BLOCKED_FILE_HOSTS or normalized_host.endswith(
BLOCKED_FILE_HOST_SUFFIXES
):
raise serializers.ValidationError("file_url host is not allowed")
try:
if _is_blocked_ip(normalized_host):
raise serializers.ValidationError("file_url IP address is not allowed")
return
except ValueError:
pass
try:
address_info = socket.getaddrinfo(
normalized_host,
443,
type=socket.SOCK_STREAM,
)
except socket.gaierror as exc:
raise serializers.ValidationError("file_url host cannot be resolved") from exc
for item in address_info:
if _is_blocked_ip(item[4][0]):
raise serializers.ValidationError("file_url host resolves to private IP")
# =============================================================================
# Минпромторг - Сертификаты промышленного производства
# =============================================================================
class CanonicalOrganizationListSerializer(serializers.ListSerializer):
"""Adds one canonical organization lookup for the current serialized page."""
def to_representation(self, data):
items = list(data.all() if hasattr(data, "all") else data)
field_sets = getattr(self.child, "canonical_organization_fields", ())
self.context[
ORGANIZATION_ENRICHMENT_CONTEXT_KEY
] = build_canonical_organization_enrichment(items, field_sets)
return super().to_representation(items)
class CanonicalOrganizationEnrichmentMixin:
"""Fill blank v1 organization fields while preserving existing contract."""
canonical_organization_fields: tuple[OrganizationFieldSet, ...] = ()
def to_representation(self, instance):
data = super().to_representation(instance)
if not self.canonical_organization_fields:
return data
enrichment = self.context.get(ORGANIZATION_ENRICHMENT_CONTEXT_KEY)
if enrichment is None:
enrichment = build_canonical_organization_enrichment(
[instance],
self.canonical_organization_fields,
)
return enrich_organization_fields(
data,
source=instance,
field_sets=self.canonical_organization_fields,
enrichment=enrichment,
)
class IndustrialCertificateSerializer(
CanonicalOrganizationEnrichmentMixin,
serializers.ModelSerializer,
):
"""
Сертификат промышленного производства РФ.
Данные загружаются из Минпромторга.
"""
canonical_organization_fields = (
OrganizationFieldSet(
name="organisation_name",
inn="inn",
ogrn="ogrn",
),
)
class Meta:
model = IndustrialCertificateRecord
list_serializer_class = CanonicalOrganizationListSerializer
fields = [
"id",
"load_batch",
"issue_date",
"issue_date_normalized",
"certificate_number",
"expiry_date",
"expiry_date_normalized",
"certificate_file_url",
"organisation_name",
"inn",
"ogrn",
"registry_organization",
"created_at",
"updated_at",
]
read_only_fields = fields
# =============================================================================
# Минпромторг - Реестр производителей
# =============================================================================
class ManufacturerSerializer(
CanonicalOrganizationEnrichmentMixin,
serializers.ModelSerializer,
):
"""
Производитель из реестра Минпромторга.
Данные загружаются из Минпромторга.
"""
canonical_organization_fields = (
OrganizationFieldSet(
name="full_legal_name",
inn="inn",
ogrn="ogrn",
),
)
class Meta:
model = ManufacturerRecord
list_serializer_class = CanonicalOrganizationListSerializer
fields = [
"id",
"load_batch",
"full_legal_name",
"inn",
"ogrn",
"address",
"registry_organization",
"created_at",
"updated_at",
]
read_only_fields = fields
class IndustrialProductSerializer(
CanonicalOrganizationEnrichmentMixin,
serializers.ModelSerializer,
):
"""
Промышленная продукция из реестра Минпромторга.
Данные загружаются из Минпромторга.
"""
canonical_organization_fields = (
OrganizationFieldSet(
name="full_organisation_name",
inn="inn",
ogrn="ogrn",
),
)
class Meta:
model = IndustrialProductRecord
list_serializer_class = CanonicalOrganizationListSerializer
fields = [
"id",
"load_batch",
"full_organisation_name",
"ogrn",
"inn",
"registry_number",
"product_name",
"product_model",
"okpd2_code",
"tnved_code",
"regulatory_document",
"registry_organization",
"created_at",
"updated_at",
]
read_only_fields = fields
# =============================================================================
# Единый реестр проверок (proverki.gov.ru)
# =============================================================================
class InspectionSerializer(
CanonicalOrganizationEnrichmentMixin,
serializers.ModelSerializer,
):
"""
Проверка из Единого реестра проверок.
Поддерживает ФЗ-294 и ФЗ-248.
"""
canonical_organization_fields = (
OrganizationFieldSet(
name="organisation_name",
inn="inn",
ogrn="ogrn",
),
)
class Meta:
model = InspectionRecord
list_serializer_class = CanonicalOrganizationListSerializer
fields = [
"id",
"load_batch",
"registration_number",
"inn",
"ogrn",
"organisation_name",
"control_authority",
"inspection_type",
"inspection_form",
"start_date",
"start_date_normalized",
"end_date",
"end_date_normalized",
"status",
"legal_basis",
"result",
"is_federal_law_248",
"data_year",
"data_month",
"registry_organization",
"created_at",
"updated_at",
]
read_only_fields = fields
# =============================================================================
# Государственные закупки (zakupki.gov.ru)
# =============================================================================
class ProcurementSerializer(
CanonicalOrganizationEnrichmentMixin,
serializers.ModelSerializer,
):
"""
Государственная закупка из ЕИС zakupki.gov.ru.
Поддерживает 44-ФЗ и 223-ФЗ.
"""
canonical_organization_fields = (
OrganizationFieldSet(
name="customer_name",
inn="customer_inn",
kpp="customer_kpp",
ogrn="customer_ogrn",
),
)
class Meta:
model = ProcurementRecord
list_serializer_class = CanonicalOrganizationListSerializer
fields = [
"id",
"load_batch",
"purchase_number",
"purchase_name",
"customer_inn",
"customer_kpp",
"customer_ogrn",
"customer_name",
"max_price",
"max_price_amount",
"currency_code",
"placement_method",
"publish_date",
"publish_date_normalized",
"end_date",
"end_date_normalized",
"status",
"law_type",
"purchase_object_info",
"href",
"region_code",
"data_year",
"data_month",
"registry_organization",
"created_at",
"updated_at",
]
read_only_fields = fields
# =============================================================================
# ФНС - Бухгалтерская отчетность
# =============================================================================
class FinancialReportLineSerializer(serializers.ModelSerializer):
"""Строка финансового отчета."""
class Meta:
model = FinancialReportLine
fields = [
"id",
"form_code",
"line_code",
"line_name",
"year",
"period_start",
"period_end",
]
class FinancialReportSerializer(serializers.ModelSerializer):
"""
Финансовый отчет ФНС.
Данные загружаются из Excel файлов.
"""
lines_count = serializers.SerializerMethodField()
class Meta:
model = FinancialReport
fields = [
"id",
"external_id",
"ogrn",
"registry_organization",
"file_name",
"file_hash",
"load_batch",
"status",
"source",
"error_message",
"created_at",
"updated_at",
"lines_count",
]
read_only_fields = fields
def get_lines_count(self, obj) -> int:
return getattr(obj, "lines_count", obj.lines.count())
class FinancialReportDetailSerializer(FinancialReportSerializer):
"""Финансовый отчет с детализацией строк."""
lines = FinancialReportLineSerializer(many=True, read_only=True)
class Meta(FinancialReportSerializer.Meta):
fields = FinancialReportSerializer.Meta.fields + ["lines"]
class FNSFileUploadSerializer(serializers.Serializer):
"""
Сериализатор для загрузки файлов FNS.
Принимает список Excel файлов в формате fin_{id}_{ogrn}.xlsx
"""
file = serializers.FileField(
required=False,
help_text="Одиночный файл для загрузки (fin_*.xlsx)",
)
files = serializers.ListField(
child=serializers.FileField(),
required=False,
allow_empty=False,
help_text="Список файлов для загрузки (fin_*.xlsx)",
)
def validate(self, attrs):
files = attrs.get("files")
single_file = attrs.get("file")
if single_file and files:
raise serializers.ValidationError(
{"file": "Используйте либо file, либо files."}
)
if single_file:
files = [single_file]
if not files:
raise serializers.ValidationError(
{"file": "Нужно передать file или files."}
)
attrs["files"] = self._validate_uploaded_files(files)
return attrs
def _validate_uploaded_files(self, files):
"""Валидация файлов."""
for file in files:
if not FNS_XLSX_FILENAME_RE.match(file.name):
raise serializers.ValidationError(
f"Неверный формат имени файла: {file.name}. "
"Ожидается: fin_{{id}}_{{ogrn}}.xlsx"
)
return files
class FNSZipUploadSerializer(serializers.Serializer):
"""Сериализатор для загрузки ZIP архива с FNS Excel файлами."""
file = serializers.FileField(help_text="ZIP архив с файлами fin_*.xlsx")
def validate_file(self, value):
if not value.name.lower().endswith(".zip"):
raise serializers.ValidationError("Поддерживаются только ZIP архивы")
return value
class FNSServerPathUploadSerializer(serializers.Serializer):
"""Сериализатор для запуска обработки файла, уже лежащего на сервере."""
server_path = serializers.CharField(
help_text="Абсолютный путь к ZIP/XLSX внутри FNS watch directory",
)
class FNSFileUploadSuccessSerializer(serializers.Serializer):
"""Ответ одиночной загрузки FNS в формате frontend."""
success = serializers.BooleanField(read_only=True)
message = serializers.CharField(read_only=True)
class ParsingSettingsSerializer(serializers.ModelSerializer):
"""Настройки периодичности обновления источников парсинга."""
class Meta:
model = ParsingSettings
fields = [
"manufacturers_and_products",
"public_procurements",
"defense_unreliable_suppliers",
"planned_inspections",
"arbitration_cases",
"bankruptcy_procedures",
"information_security_registries",
]
class ParserSourceSerializer(serializers.Serializer):
"""Описание доступного parser source для dashboard/API."""
key = serializers.CharField()
source = serializers.CharField()
title = serializers.CharField()
agency = serializers.CharField()
data_scope = serializers.CharField()
task_name = serializers.CharField()
is_existing = serializers.BooleanField()
requires_file_url = serializers.BooleanField()
mode = serializers.CharField()
status = serializers.CharField()
owner = serializers.CharField(allow_blank=True)
upstream_url = serializers.URLField(allow_blank=True)
access_method = serializers.CharField()
parser_strategy = serializers.CharField()
source_notes = serializers.CharField(allow_blank=True)
supports_file_upload = serializers.BooleanField()
api_route = serializers.CharField(allow_blank=True)
result_list_url = serializers.CharField()
result_detail_url = serializers.CharField()
upload_url = serializers.CharField(allow_blank=True)
class ParserRunRequestSerializer(serializers.Serializer):
"""Параметры запуска Celery-задачи парсера."""
file_url = serializers.URLField(required=False, allow_blank=True)
file_path = serializers.CharField(required=False, allow_blank=True)
proxies = serializers.ListField(
child=serializers.CharField(),
required=False,
allow_empty=True,
)
vacancy_sources = serializers.ListField(
child=serializers.ChoiceField(choices=["trudvsem", "hh", "superjob"]),
required=False,
allow_empty=False,
)
registry_organizations_only = serializers.BooleanField(required=False)
registry_organization_limit = serializers.IntegerField(required=False, min_value=1)
year = serializers.IntegerField(required=False, min_value=2000, max_value=2100)
month = serializers.IntegerField(required=False, min_value=1, max_value=12)
limit = serializers.IntegerField(required=False, min_value=1, max_value=1000)
offset = serializers.IntegerField(required=False, min_value=0)
max_months_per_law = serializers.IntegerField(
required=False,
min_value=1,
max_value=36,
)
start_year = serializers.IntegerField(
required=False,
min_value=2000,
max_value=2100,
)
start_month = serializers.IntegerField(required=False, min_value=1, max_value=12)
include_fz294 = serializers.BooleanField(required=False)
include_fz248 = serializers.BooleanField(required=False)
current_year = serializers.IntegerField(
required=False,
min_value=2000,
max_value=2100,
)
current_month = serializers.IntegerField(required=False, min_value=1, max_value=12)
region_code = serializers.CharField(required=False, allow_blank=True)
law_type = serializers.CharField(required=False, allow_blank=True)
company_inn = serializers.CharField(required=False, allow_blank=True)
text = serializers.CharField(required=False, allow_blank=True)
def validate_file_url(self, value: str) -> str:
"""Разрешить worker-download только с публичных HTTPS URL."""
if not value:
return value
parsed = urlsplit(value)
if parsed.scheme != "https":
raise serializers.ValidationError("file_url must use https")
if parsed.username or parsed.password:
raise serializers.ValidationError("file_url credentials are not allowed")
if not parsed.hostname:
raise serializers.ValidationError("file_url host is required")
_validate_public_file_host(parsed.hostname)
return value
def validate(self, attrs):
attrs = super().validate(attrs)
if ("start_year" in attrs) != ("start_month" in attrs):
raise serializers.ValidationError(
{
"start_month": (
"start_year and start_month must be provided together"
)
}
)
return attrs
class ParserUploadRequestSerializer(serializers.Serializer):
"""Файл ручной загрузки реестра или финансовой выгрузки."""
file = serializers.FileField()
def validate_file(self, value):
file_name = (value.name or "").lower()
if not file_name.endswith(SUPPORTED_UPLOAD_EXTENSIONS):
extensions = ", ".join(SUPPORTED_UPLOAD_EXTENSIONS)
raise serializers.ValidationError(
f"Unsupported file extension. Allowed: {extensions}"
)
if value.size > MAX_FILE_SIZE_BYTES:
raise serializers.ValidationError(
f"File exceeds size limit: {MAX_FILE_SIZE_BYTES} bytes"
)
return value
class ParserScheduleRequestSerializer(ParserRunRequestSerializer):
"""Параметры создания/обновления периодической Celery-задачи парсера."""
SCHEDULE_TYPES = ("interval", "crontab")
PERIODS = ("seconds", "minutes", "hours", "days")
source_key = serializers.CharField(required=False)
name = serializers.CharField(required=False, allow_blank=True, max_length=200)
enabled = serializers.BooleanField(required=False, default=True)
schedule_type = serializers.ChoiceField(
choices=SCHEDULE_TYPES,
required=False,
default="interval",
)
every = serializers.IntegerField(required=False, min_value=1)
period = serializers.ChoiceField(choices=PERIODS, required=False, default="hours")
minute = serializers.CharField(required=False, allow_blank=True, default="0")
hour = serializers.CharField(required=False, allow_blank=True, default="*")
day_of_week = serializers.CharField(required=False, allow_blank=True, default="*")
day_of_month = serializers.CharField(required=False, allow_blank=True, default="*")
month_of_year = serializers.CharField(required=False, allow_blank=True, default="*")
def validate(self, attrs):
attrs = super().validate(attrs)
if attrs.get("schedule_type", "interval") == "interval" and not attrs.get(
"every"
):
raise serializers.ValidationError(
{"every": "Required for interval schedule"}
)
return attrs
class ParserScheduleSerializer(serializers.Serializer):
"""Описание периодической задачи парсера."""
id = serializers.IntegerField()
name = serializers.CharField()
source_key = serializers.CharField()
source = serializers.CharField()
title = serializers.CharField()
task_name = serializers.CharField()
enabled = serializers.BooleanField()
schedule_type = serializers.CharField()
schedule = serializers.DictField()
params = serializers.DictField()
last_run_at = serializers.DateTimeField(allow_null=True)
total_run_count = serializers.IntegerField()
date_changed = serializers.DateTimeField()
class ParserListQuerySerializer(serializers.Serializer):
"""Общие query-параметры списков парсеров."""
limit = serializers.IntegerField(
required=False,
default=50,
min_value=1,
max_value=200,
)
class ParserResultQuerySerializer(serializers.Serializer):
"""Query-параметры per-source result endpoints."""
page = serializers.IntegerField(required=False, default=1, min_value=1)
page_size = serializers.IntegerField(
required=False,
default=20,
min_value=1,
max_value=100,
)
limit = serializers.IntegerField(required=False, min_value=1, max_value=100)
id = serializers.IntegerField(required=False, min_value=1)
source = serializers.CharField(required=False, allow_blank=True)
external_id = serializers.CharField(required=False, allow_blank=True)
inn = serializers.CharField(required=False, allow_blank=True)
ogrn = serializers.CharField(required=False, allow_blank=True)
load_batch = serializers.IntegerField(required=False, min_value=1)
batch_id = serializers.IntegerField(required=False, min_value=1)
status = serializers.CharField(required=False, allow_blank=True)
record_date = serializers.CharField(required=False, allow_blank=True)
search = serializers.CharField(required=False, allow_blank=True)
ordering = serializers.CharField(required=False, allow_blank=True)
include_payload = serializers.BooleanField(required=False, default=True)
def validate(self, attrs):
attrs = super().validate(attrs)
if attrs.get("limit"):
attrs["page"] = 1
attrs["page_size"] = attrs["limit"]
if attrs.get("batch_id") and not attrs.get("load_batch"):
attrs["load_batch"] = attrs["batch_id"]
return attrs
class ParserRunResponseSerializer(serializers.Serializer):
"""Ответ API на запуск задачи."""
task_id = serializers.CharField()
source = serializers.CharField()
task_name = serializers.CharField()
# =============================================================================
# Служебные модели
# =============================================================================
class ParserLoadLogSerializer(serializers.ModelSerializer):
"""
Лог загрузки парсера.
Информация о каждой загрузке данных из внешнего источника.
"""
source_display = serializers.CharField(source="get_source_display", read_only=True)
organizations_count = serializers.SerializerMethodField()
class Meta:
model = ParserLoadLog
fields = [
"id",
"batch_id",
"source",
"source_display",
"records_count",
"organizations_count",
"status",
"error_message",
"created_at",
"updated_at",
]
read_only_fields = fields
def get_organizations_count(self, obj) -> int:
if obj.source == ParserLoadLog.Source.FNS_REPORTS:
return (
FinancialReport.objects.filter(load_batch=obj.batch_id)
.exclude(ogrn="")
.values("ogrn")
.distinct()
.count()
)
if obj.source == ParserLoadLog.Source.INDUSTRIAL:
return (
IndustrialCertificateRecord.objects.filter(load_batch=obj.batch_id)
.exclude(inn="")
.values("inn")
.distinct()
.count()
)
if obj.source == ParserLoadLog.Source.INDUSTRIAL_PRODUCTS:
return (
IndustrialProductRecord.objects.filter(load_batch=obj.batch_id)
.exclude(inn="")
.values("inn")
.distinct()
.count()
)
if obj.source == ParserLoadLog.Source.MANUFACTURES:
return (
ManufacturerRecord.objects.filter(load_batch=obj.batch_id)
.exclude(inn="")
.values("inn")
.distinct()
.count()
)
if obj.source == ParserLoadLog.Source.INSPECTIONS:
return (
InspectionRecord.objects.filter(load_batch=obj.batch_id)
.exclude(inn="")
.values("inn")
.distinct()
.count()
)
if obj.source == ParserLoadLog.Source.PROCUREMENTS:
return (
ProcurementRecord.objects.filter(load_batch=obj.batch_id)
.exclude(customer_inn="")
.values("customer_inn")
.distinct()
.count()
)
return 0
class GenericParserRecordSerializer(
CanonicalOrganizationEnrichmentMixin,
serializers.ModelSerializer,
):
"""Сериализатор универсальных записей новых источников."""
canonical_organization_fields = (
OrganizationFieldSet(
name="organisation_name",
inn="inn",
ogrn="ogrn",
),
)
class Meta:
model = GenericParserRecord
list_serializer_class = CanonicalOrganizationListSerializer
fields = [
"id",
"load_batch",
"source",
"external_id",
"inn",
"ogrn",
"organisation_name",
"title",
"record_date",
"amount",
"status",
"url",
"payload",
"registry_organization",
"created_at",
"updated_at",
]
read_only_fields = fields
class ParserResultRecordSerializer(serializers.Serializer):
"""Унифицированная запись результата конкретного источника."""
id = serializers.IntegerField()
load_batch = serializers.IntegerField()
source = serializers.CharField()
vacancy_source = serializers.CharField(allow_blank=True, required=False)
external_id = serializers.CharField(allow_blank=True)
inn = serializers.CharField(allow_blank=True)
ogrn = serializers.CharField(allow_blank=True)
organisation_name = serializers.CharField(allow_blank=True)
title = serializers.CharField(allow_blank=True)
record_date = serializers.CharField(allow_blank=True)
amount = serializers.DecimalField(
max_digits=20,
decimal_places=2,
allow_null=True,
)
status = serializers.CharField(allow_blank=True)
url = serializers.CharField(allow_blank=True)
payload = serializers.DictField()
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
class ParserLoadLogListSerializer(serializers.Serializer):
"""Строка списка логов в frontend-friendly формате."""
id = serializers.IntegerField(read_only=True)
batch_id = serializers.IntegerField(read_only=True)
source = serializers.CharField(read_only=True)
source_label = serializers.CharField(read_only=True, allow_null=True)
records_count = serializers.IntegerField(read_only=True)
organizations_count = serializers.IntegerField(read_only=True)
status = serializers.CharField(read_only=True)
status_label = serializers.CharField(read_only=True)
error_message = serializers.CharField(read_only=True, allow_blank=True)
created_at = serializers.DateTimeField(read_only=True)
updated_at = serializers.DateTimeField(read_only=True)
class ParserLoadLogPageSerializer(serializers.Serializer):
"""Пагинированный ответ списка логов для frontend."""
count = serializers.IntegerField(read_only=True)
next = serializers.CharField(read_only=True, allow_null=True)
previous = serializers.CharField(read_only=True, allow_null=True)
results = ParserLoadLogListSerializer(many=True, read_only=True)
class SourceCardRefreshParamSerializer(serializers.Serializer):
"""Описание параметра ручного обновления карточки источника."""
name = serializers.CharField(read_only=True)
label = serializers.CharField(read_only=True)
description = serializers.CharField(read_only=True)
required = serializers.BooleanField(read_only=True)
type = serializers.CharField(read_only=True)
default = serializers.JSONField(read_only=True)
class SourceCardLoadSerializer(serializers.Serializer):
"""Сводка по последней загрузке источника."""
batch_id = serializers.IntegerField(read_only=True)
source = serializers.CharField(read_only=True)
source_display = serializers.CharField(read_only=True)
records_count = serializers.IntegerField(read_only=True)
status = serializers.CharField(read_only=True)
error_message = serializers.CharField(read_only=True)
created_at = serializers.DateTimeField(read_only=True)
updated_at = serializers.DateTimeField(read_only=True)
class SourceCardTaskSerializer(serializers.Serializer):
"""Краткая информация по фоновой задаче карточки."""
task_id = serializers.CharField(read_only=True)
task_name = serializers.CharField(read_only=True)
status = serializers.CharField(read_only=True)
progress = serializers.IntegerField(read_only=True)
progress_message = serializers.CharField(read_only=True)
started_at = serializers.DateTimeField(read_only=True)
created_at = serializers.DateTimeField(read_only=True)
meta = serializers.JSONField(read_only=True)
class SourceCardItemSerializer(serializers.Serializer):
"""Подисточник внутри агрегированной карточки."""
code = serializers.CharField(read_only=True)
title = serializers.CharField(read_only=True)
description = serializers.CharField(read_only=True)
parser_source = serializers.CharField(read_only=True, allow_null=True)
parser_source_display = serializers.CharField(read_only=True, allow_null=True)
records_count = serializers.IntegerField(read_only=True)
organizations_count = serializers.IntegerField(read_only=True)
last_updated_at = serializers.DateTimeField(read_only=True, allow_null=True)
latest_load = SourceCardLoadSerializer(read_only=True, allow_null=True)
latest_success_load = SourceCardLoadSerializer(read_only=True, allow_null=True)
class SourceCardSerializer(serializers.Serializer):
"""Serializer for frontend source cards list."""
slug = serializers.CharField(read_only=True)
title = serializers.CharField(read_only=True)
description = serializers.CharField(read_only=True)
order = serializers.IntegerField(read_only=True)
is_available = serializers.BooleanField(read_only=True)
status = serializers.CharField(read_only=True)
status_label = serializers.CharField(read_only=True)
progress = serializers.IntegerField(read_only=True)
records_count = serializers.IntegerField(read_only=True)
organizations_count = serializers.IntegerField(read_only=True)
last_updated_at = serializers.DateTimeField(read_only=True, allow_null=True)
next_update_at = serializers.DateTimeField(read_only=True, allow_null=True)
error_message = serializers.CharField(read_only=True)
task_names = serializers.ListField(child=serializers.CharField(), read_only=True)
refresh_requires_params = serializers.BooleanField(read_only=True)
refresh_params = SourceCardRefreshParamSerializer(many=True, read_only=True)
class SourceTaskStatusSerializer(serializers.Serializer):
"""Табличная строка статуса источника для экрана задач парсинга."""
row_number = serializers.IntegerField(read_only=True)
slug = serializers.CharField(read_only=True)
source = serializers.CharField(read_only=True)
status = serializers.CharField(read_only=True)
status_label = serializers.CharField(read_only=True)
actualized_at = serializers.DateTimeField(read_only=True, allow_null=True)
next_update_at = serializers.DateTimeField(read_only=True, allow_null=True)
records_count = serializers.IntegerField(read_only=True)
organizations_count = serializers.IntegerField(read_only=True)
progress = serializers.IntegerField(read_only=True)
error_message = serializers.CharField(read_only=True)
active_tasks = SourceCardTaskSerializer(many=True, read_only=True)
class SourceCardDetailSerializer(SourceCardSerializer):
"""Detailed serializer for a single frontend source card."""
active_tasks = SourceCardTaskSerializer(many=True, read_only=True)
source_items = SourceCardItemSerializer(many=True, read_only=True)
latest_load = SourceCardLoadSerializer(read_only=True, allow_null=True)
latest_success_load = SourceCardLoadSerializer(read_only=True, allow_null=True)
class SourceCardRefreshRequestSerializer(serializers.Serializer):
"""Request body for manual card refresh."""
params = serializers.DictField(required=False)
class SourceCardRefreshTaskSerializer(serializers.Serializer):
"""Queued task info after refresh start."""
task_id = serializers.CharField(read_only=True)
task_name = serializers.CharField(read_only=True)
class SourceCardRefreshResponseSerializer(serializers.Serializer):
"""Response serializer for manual card refresh."""
source_card = serializers.CharField(read_only=True)
status = serializers.CharField(read_only=True)
requested_at = serializers.DateTimeField(read_only=True)
tasks = SourceCardRefreshTaskSerializer(many=True, read_only=True)
class FrontendApiResponseSerializer(serializers.Serializer):
"""Общая форма success/data/errors/meta для frontend sources API."""
success = serializers.BooleanField(read_only=True)
errors = serializers.JSONField(read_only=True, allow_null=True)
meta = serializers.JSONField(read_only=True, allow_null=True)
class SourceCardListResponseSerializer(FrontendApiResponseSerializer):
"""Envelope для списка карточек источников."""
data = SourceCardSerializer(many=True, read_only=True)
class SourceTaskStatusListResponseSerializer(FrontendApiResponseSerializer):
"""Envelope для списка статусов источников."""
data = SourceTaskStatusSerializer(many=True, read_only=True)
class SourceCardDetailResponseSerializer(FrontendApiResponseSerializer):
"""Envelope для детальной карточки источника."""
data = SourceCardDetailSerializer(read_only=True)
class SourceCardRefreshFrontendResponseSerializer(serializers.Serializer):
"""Минимальный ответ запуска обновления карточки по md."""
task_id = serializers.CharField(read_only=True, allow_null=True)
status = serializers.CharField(read_only=True)