Files
mostovik-backend/src/organizations/views.py
Aleksandr Meshchriakov b8a18d6da4
Some checks failed
CI/CD Pipeline / Quality Gate (push) Failing after 14s
CI/CD Pipeline / Build and Push Images (push) Has been skipped
CI/CD Pipeline / Deploy Dev in Dokploy (push) Has been skipped
CI/CD Pipeline / Internal Notify (push) Successful in 0s
feat: migrate parser data to source records
2026-05-19 20:21:31 +02:00

582 lines
21 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.
"""Views for organization-centric API v2."""
from __future__ import annotations
import hashlib
import json
from typing import Any
from apps.core.openapi import swagger_tag
from django.conf import settings
from django.core.cache import cache
from django.db.models import CharField, Q
from django.db.models.functions import Cast
from django_filters import rest_framework as filters
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from registers.models import RegistryMembershipPeriod
from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet
from organizations.cache import (
DEFAULT_ORGANIZATION_API_CACHE_TIMEOUT_SECONDS,
ORGANIZATION_API_CACHE_PREFIX,
get_organization_api_cache_version,
)
from organizations.filters import OrganizationFilter
from organizations.models import (
Organization,
OrganizationSourceExtension,
OrganizationSourceRecord,
SourceGroup,
)
from organizations.serializers import (
OrganizationSerializer,
OrganizationSourceExtensionSerializer,
OrganizationSourceRecordListResponseSerializer,
OrganizationSourceRecordSerializer,
)
ORGANIZATIONS_TAG = swagger_tag("Организации", "Organizations")
FALSE_QUERY_VALUES = {"0", "false", "no", "off"}
def _query_parameter(
name: str,
*,
description: str,
param_type: str = openapi.TYPE_STRING,
default: str | int | bool | None = None,
enum: list[str] | None = None,
format_: str | None = None,
) -> openapi.Parameter:
return openapi.Parameter(
name=name,
in_=openapi.IN_QUERY,
type=param_type,
required=False,
description=description,
default=default,
enum=enum,
format=format_,
)
def _is_truthy_query_value(value: str) -> bool:
return value.strip().lower() not in FALSE_QUERY_VALUES
SOURCE_GROUP_VALUES = [choice.value for choice in SourceGroup]
ORGANIZATION_LIST_PARAMS = [
_query_parameter(
"page",
description="Номер страницы пагинации.",
param_type=openapi.TYPE_INTEGER,
default=1,
),
_query_parameter(
"page_size",
description="Размер страницы. Максимум 100.",
param_type=openapi.TYPE_INTEGER,
default=20,
),
_query_parameter(
"search",
description="Поиск по наименованию, ИНН, КПП, ОГРН, ОГРИП и основному идентификатору.",
),
_query_parameter(
"ordering",
description=(
"Сортировка по uid, name, inn, kpp, ogrn, ogrip или identity_status. "
"Префикс '-' включает обратный порядок."
),
),
_query_parameter("name", description="Фильтр по части полного наименования."),
_query_parameter("inn", description="Точный фильтр по ИНН."),
_query_parameter("kpp", description="Точный фильтр по КПП."),
_query_parameter("ogrn", description="Точный фильтр по ОГРН."),
_query_parameter("ogrip", description="Точный фильтр по ОГРИП."),
_query_parameter(
"identity_status",
description="Фильтр полноты реквизитов организации.",
enum=[choice.value for choice in Organization.IdentityStatus],
),
_query_parameter(
"registry",
description="UUID реестра. Возвращает организации из активного участия.",
format_=openapi.FORMAT_UUID,
),
_query_parameter(
"registry_name",
description="Фильтр по части наименования реестра.",
),
_query_parameter(
"has_registry",
description=(
"Фильтр наличия активного участия в любом реестре; по умолчанию true "
"для list endpoint, если параметр не передан."
),
param_type=openapi.TYPE_BOOLEAN,
default=True,
),
_query_parameter(
"source_group",
description="Фильтр по группе источников организации.",
enum=SOURCE_GROUP_VALUES,
),
*[
_query_parameter(
f"has_{source_group}",
description=f"Фильтр наличия группы источников {source_group}.",
param_type=openapi.TYPE_BOOLEAN,
)
for source_group in SOURCE_GROUP_VALUES
],
]
ORGANIZATION_DETAIL_PARAMS = [
openapi.Parameter(
name="uid",
in_=openapi.IN_PATH,
type=openapi.TYPE_STRING,
format=openapi.FORMAT_UUID,
required=True,
description="UID организации.",
),
]
SOURCE_EXTENSION_PATH_PARAMS = [
openapi.Parameter(
name="uid",
in_=openapi.IN_PATH,
type=openapi.TYPE_STRING,
format=openapi.FORMAT_UUID,
required=True,
description="UID расширения источника.",
),
]
SOURCE_RECORD_LIST_PARAMS = [
_query_parameter(
"source_group",
description="Фильтр по группе источников.",
enum=SOURCE_GROUP_VALUES,
),
_query_parameter("source", description="Фильтр по legacy source внутри группы."),
_query_parameter("record_type", description="Фильтр по типу записи."),
_query_parameter(
"has_registry",
description="Фильтр наличия активного участия организации записи в любом реестре.",
param_type=openapi.TYPE_BOOLEAN,
),
_query_parameter(
"organization",
description="UID организации.",
format_=openapi.FORMAT_UUID,
),
_query_parameter(
"search",
description=(
"Поиск по организации, реквизитам, заголовку, внешнему ID, "
"статусу, датам, URL и исходным данным записи."
),
),
_query_parameter(
"page", description="Номер страницы.", param_type=openapi.TYPE_INTEGER
),
_query_parameter(
"page_size",
description="Размер страницы. Максимум 100.",
param_type=openapi.TYPE_INTEGER,
),
]
class CachedReadOnlyMixin:
"""Cache successful GET list/retrieve responses by full request path."""
cache_timeout = DEFAULT_ORGANIZATION_API_CACHE_TIMEOUT_SECONDS
cache_key_prefix = ORGANIZATION_API_CACHE_PREFIX
def _cache_timeout(self) -> int:
return getattr(
settings,
"ORGANIZATIONS_API_CACHE_TIMEOUT_SECONDS",
self.cache_timeout,
)
def _build_cache_key(self, request) -> str:
user_marker = "anonymous"
if request.user and request.user.is_authenticated:
user_marker = "authenticated"
cache_version = get_organization_api_cache_version()
raw_key = (
f"v{cache_version}:{request.method}:"
f"{request.get_full_path()}:{user_marker}"
)
digest = hashlib.md5(raw_key.encode(), usedforsecurity=False).hexdigest()
return f"{self.cache_key_prefix}:v{cache_version}:{digest}"
def _cached_response(self, request, producer) -> Response:
cache_key = self._build_cache_key(request)
cached_data = cache.get(cache_key)
if cached_data is not None:
response = Response(cached_data)
response["X-Cache"] = "HIT"
return response
response = producer()
if 200 <= response.status_code < 300:
cache.set(cache_key, response.data, timeout=self._cache_timeout())
response["X-Cache"] = "MISS"
return response
class OrganizationViewSet(CachedReadOnlyMixin, ReadOnlyModelViewSet):
"""Read-only API for canonical organizations and source summaries."""
queryset = Organization.objects.order_by("name", "uid")
serializer_class = OrganizationSerializer
permission_classes = [IsAuthenticated]
lookup_field = "uid"
filter_backends = [
filters.DjangoFilterBackend,
SearchFilter,
OrderingFilter,
]
filterset_class = OrganizationFilter
search_fields = [
"name",
"inn",
"kpp",
"ogrn",
"ogrip",
"primary_identity",
]
ordering_fields = [
"name",
"inn",
"kpp",
"ogrn",
"ogrip",
"identity_status",
"uid",
]
ordering = ["name", "uid"]
def get_permissions(self):
if getattr(settings, "ORGANIZATIONS_V2_ALLOW_ANONYMOUS", False):
return [AllowAny()]
return super().get_permissions()
def get_queryset(self):
queryset = super().get_queryset().prefetch_related("source_extensions")
if self.action != "list" or "has_registry" in self.request.query_params:
return queryset
filterset = OrganizationFilter(
data={"has_registry": "true"},
queryset=queryset,
request=self.request,
)
if filterset.is_valid():
return filterset.qs
return queryset
@swagger_auto_schema(
tags=[ORGANIZATIONS_TAG],
operation_id="v2_organizations_list",
operation_summary="Список организаций",
operation_description=(
"Возвращает канонический справочник организаций API v2. "
"По умолчанию показывает только организации с активным участием "
"в реестрах; передайте has_registry=false, чтобы снять это ограничение. "
"Данные источников возвращаются компактным списком sources; детальные "
"записи доступны через endpoints расширений источников."
),
manual_parameters=ORGANIZATION_LIST_PARAMS,
responses={
200: openapi.Response(
"Пагинированный список организаций.",
OrganizationSerializer(many=True),
)
},
)
def list(self, request, *args: Any, **kwargs: Any) -> Response:
return self._cached_response(
request,
lambda: super(OrganizationViewSet, self).list(request, *args, **kwargs),
)
@swagger_auto_schema(
tags=[ORGANIZATIONS_TAG],
operation_id="v2_organizations_retrieve",
operation_summary="Карточка организации",
operation_description=(
"Возвращает одну организацию по UID с активными реестрами и компактными "
"группами источников."
),
manual_parameters=ORGANIZATION_DETAIL_PARAMS,
responses={
200: openapi.Response(
"Карточка организации.",
OrganizationSerializer,
),
404: "Организация не найдена",
},
)
def retrieve(self, request, *args: Any, **kwargs: Any) -> Response:
return self._cached_response(
request,
lambda: super(OrganizationViewSet, self).retrieve(request, *args, **kwargs),
)
@swagger_auto_schema(
tags=[ORGANIZATIONS_TAG],
operation_id="v2_organizations_sources",
operation_summary="Источники организации",
operation_description="Возвращает source extensions одной организации.",
responses={
200: OrganizationSourceExtensionSerializer(many=True),
404: "Организация не найдена",
},
)
@action(detail=True, methods=["get"])
def sources(self, request, *args: Any, **kwargs: Any) -> Response:
organization = self.get_object()
serializer = OrganizationSourceExtensionSerializer(
organization.source_extensions.all(),
many=True,
)
return Response(serializer.data)
class OrganizationSourceExtensionViewSet(ReadOnlyModelViewSet):
"""Read-only API for source extensions and their records."""
queryset = OrganizationSourceExtension.objects.select_related(
"organization"
).order_by(
"organization__name",
"source_group",
)
serializer_class = OrganizationSourceExtensionSerializer
permission_classes = [IsAuthenticated]
lookup_field = "uid"
filter_backends = [OrderingFilter]
ordering_fields = ["source_group", "records_count", "last_seen_at", "uid"]
ordering = ["source_group", "uid"]
def get_permissions(self):
if getattr(settings, "ORGANIZATIONS_V2_ALLOW_ANONYMOUS", False):
return [AllowAny()]
return super().get_permissions()
@swagger_auto_schema(
tags=[ORGANIZATIONS_TAG],
operation_id="v2_organization_sources_records",
operation_summary="Записи источника организации",
operation_description="Возвращает записи под конкретным source extension.",
manual_parameters=SOURCE_EXTENSION_PATH_PARAMS,
responses={
200: OrganizationSourceRecordListResponseSerializer,
404: "Источник не найден",
},
)
@action(detail=True, methods=["get"])
def records(self, request, *args: Any, **kwargs: Any) -> Response:
extension = self.get_object()
queryset = extension.records.prefetch_related("financial_lines").order_by(
"-created_at",
"-uid",
)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = OrganizationSourceRecordSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = OrganizationSourceRecordSerializer(queryset, many=True)
return Response(serializer.data)
class OrganizationSourceRecordViewSet(ReadOnlyModelViewSet):
"""Read-only flat API for source records across source extensions."""
queryset = (
OrganizationSourceRecord.objects.select_related(
"extension",
"extension__organization",
)
.prefetch_related("financial_lines")
.order_by("-created_at", "-uid")
)
serializer_class = OrganizationSourceRecordSerializer
permission_classes = [IsAuthenticated]
lookup_field = "uid"
filter_backends = [OrderingFilter]
search_fields = [
"title",
"external_id",
"record_type",
"source",
"record_date",
"status",
"url",
"legacy_model",
"legacy_pk",
"source_record_amount_text",
"source_record_load_batch_text",
"source_record_payload_text",
"extension__title",
"extension__source_group",
"extension__organization__name",
"extension__organization__inn",
"extension__organization__kpp",
"extension__organization__ogrn",
"extension__organization__ogrip",
]
ordering_fields = [
"created_at",
"updated_at",
"record_date",
"title",
"uid",
"extension__organization__name",
"extension__organization__inn",
"extension__organization__ogrn",
]
ordering = ["-created_at", "-uid"]
def get_permissions(self):
if getattr(settings, "ORGANIZATIONS_V2_ALLOW_ANONYMOUS", False):
return [AllowAny()]
return super().get_permissions()
def get_queryset(self):
queryset = super().get_queryset()
params = self.request.query_params
source_group = params.get("source_group")
source = params.get("source")
record_type = params.get("record_type")
organization = params.get("organization")
has_registry = params.get("has_registry")
search_terms = SearchFilter().get_search_terms(self.request)
if source_group:
queryset = queryset.filter(extension__source_group=source_group)
if source:
queryset = queryset.filter(source=source)
if record_type:
queryset = queryset.filter(record_type=record_type)
if organization:
queryset = queryset.filter(extension__organization_id=organization)
if has_registry is not None:
registry_query = self._registry_membership_query()
if _is_truthy_query_value(has_registry):
queryset = queryset.filter(registry_query)
else:
queryset = queryset.exclude(registry_query)
if search_terms:
queryset = self._filter_search_queryset(queryset, search_terms)
return queryset
@staticmethod
def _registry_membership_query():
(
inn_values,
ogrn_values,
) = OrganizationFilter._registry_identity_value_querysets()
return (
Q(extension__organization__inn__in=inn_values)
| Q(extension__organization__ogrn__in=ogrn_values)
| Q(extension__organization__ogrip__in=ogrn_values)
)
@classmethod
def _filter_search_queryset(cls, queryset, search_terms: list[str]):
queryset = queryset.annotate(
source_record_amount_text=Cast("amount", output_field=CharField()),
source_record_load_batch_text=Cast(
"load_batch",
output_field=CharField(),
),
source_record_payload_text=Cast("payload", output_field=CharField()),
)
for search_term in search_terms:
queryset = queryset.filter(cls._source_record_search_query(search_term))
return queryset
@classmethod
def _source_record_search_query(cls, search_term: str) -> Q:
query = Q()
for field_name in cls.search_fields:
query |= Q(**{f"{field_name}__icontains": search_term})
if field_name == "source_record_payload_text":
escaped_search_term = cls._json_escaped_search_term(search_term)
if escaped_search_term != search_term:
query |= Q(
**{f"{field_name}__icontains": escaped_search_term},
)
return query | cls._registry_search_query(search_term)
@staticmethod
def _json_escaped_search_term(search_term: str) -> str:
return json.dumps(search_term, ensure_ascii=True)[1:-1]
@staticmethod
def _registry_search_query(search_term: str) -> Q:
registry_membership = (
RegistryMembershipPeriod.objects.filter(
ended_at__isnull=True,
)
.order_by()
.annotate(
registry_inn_text=Cast(
"organization__mn_inn",
output_field=CharField(),
),
registry_kpp_text=Cast(
"organization__in_kpp",
output_field=CharField(),
),
registry_ogrn_text=Cast(
"organization__mn_ogrn",
output_field=CharField(),
),
)
.filter(
Q(organization__pn_name__icontains=search_term)
| Q(registry_inn_text__icontains=search_term)
| Q(registry_kpp_text__icontains=search_term)
| Q(registry_ogrn_text__icontains=search_term),
)
)
inn_values = registry_membership.values_list("registry_inn_text", flat=True)
ogrn_values = registry_membership.values_list("registry_ogrn_text", flat=True)
return (
Q(extension__organization__inn__in=inn_values)
| Q(extension__organization__ogrn__in=ogrn_values)
| Q(extension__organization__ogrip__in=ogrn_values)
)
@swagger_auto_schema(
tags=[ORGANIZATIONS_TAG],
operation_id="v2_organization_source_records_list",
operation_summary="Записи источников организаций",
operation_description=(
"Возвращает плоский пагинированный список записей источников с "
"данными организации и финансовыми строками при наличии."
),
manual_parameters=SOURCE_RECORD_LIST_PARAMS,
responses={200: OrganizationSourceRecordListResponseSerializer},
)
def list(self, request, *args: Any, **kwargs: Any) -> Response:
return super().list(request, *args, **kwargs)