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