582 lines
21 KiB
Python
582 lines
21 KiB
Python
"""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)
|