forked from erp-dev/erp
feat: mdy staging api filters
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
需求:
|
||||
- 默认按 -ctime 排序
|
||||
- 支持 raw__62d52f4b8d2972284492dd0e(设计编号)过滤
|
||||
- 支持按“内部字段名”的 query param 过滤(对前端隐藏 JSONField 实现细节)
|
||||
- 支持 LimitOffset 分页
|
||||
- 返回时把 raw / related 两个 JSONField 做“可读化/碾平”
|
||||
"""
|
||||
@@ -11,21 +11,41 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.core.exceptions import FieldError
|
||||
from django.core.cache import cache
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_page
|
||||
from django.db.models import F
|
||||
from django.db.models import Q
|
||||
from rest_framework import serializers, viewsets
|
||||
from rest_framework.permissions import DjangoModelPermissions
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from flower.viewsets import LimitedReadOnlyModelViewSet
|
||||
from api_v1.models import MDYPlateOrderStaging
|
||||
from flower.utils.mingdaoyun.mappings import plate_order_field_definitions
|
||||
from flower.utils.mingdaoyun.mappings import plate_order_field_definitions, plate_order_type_map
|
||||
from flower.utils.mingdaoyun.relations import flatten_row_by_field_definitions, normalize_mdy_value
|
||||
|
||||
|
||||
_PLATE_ORDER_DESIGN_NO_CONTROL_ID = "62d52f4b8d2972284492dd0e"
|
||||
|
||||
_PUBLIC_RAW_FIELDS: set[str] = set(plate_order_type_map.keys()) - {"created_at", "rowid"}
|
||||
|
||||
# 允许的 lookup(尽量收敛能力,降低风险)
|
||||
_ALLOWED_LOOKUPS: set[str] = {"exact", "icontains", "isnull"}
|
||||
|
||||
|
||||
def _parse_bool(value: str | None) -> bool:
|
||||
s = (value or "").strip().lower()
|
||||
return s in {"1", "true", "yes", "y", "t"}
|
||||
|
||||
|
||||
def _coerce_value_for_lookup(lookup: str, raw_value: str) -> Any:
|
||||
lookup = (lookup or "exact").lower()
|
||||
if lookup == "isnull":
|
||||
return _parse_bool(raw_value)
|
||||
return raw_value
|
||||
|
||||
|
||||
class MDYPlateOrderStagingSerializer(serializers.ModelSerializer):
|
||||
"""把 raw/related 输出为扁平的可读结构。"""
|
||||
@@ -94,19 +114,96 @@ class MDYPlateOrderStagingViewSet(LimitedReadOnlyModelViewSet):
|
||||
# permission_classes = [DjangoModelPermissions]
|
||||
|
||||
def get_queryset(self):
|
||||
qs = MDYPlateOrderStaging.objects.all()
|
||||
|
||||
# 支持按 raw 中的“设计编号/订单id”过滤(按你要求的 query param 名)
|
||||
design_no = self.request.query_params.get(f"raw__{_PLATE_ORDER_DESIGN_NO_CONTROL_ID}")
|
||||
if design_no:
|
||||
qs = qs.filter(**{f"raw__{_PLATE_ORDER_DESIGN_NO_CONTROL_ID}": design_no})
|
||||
|
||||
# 默认 -ctime;ctime 为空的放最后,避免老数据(未回填)跑到前面
|
||||
return qs.order_by(F("ctime").desc(nulls_last=True), F("id").desc())
|
||||
return MDYPlateOrderStaging.objects.all().order_by(F("ctime").desc(nulls_last=True), F("id").desc())
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
qs = super().filter_queryset(queryset)
|
||||
design_no = self.request.query_params.get(f"raw__{_PLATE_ORDER_DESIGN_NO_CONTROL_ID}")
|
||||
if design_no:
|
||||
qs = qs.filter(**{f"raw__{_PLATE_ORDER_DESIGN_NO_CONTROL_ID}": design_no})
|
||||
|
||||
# 对前端暴露的过滤参数:只允许使用 “内部字段名”,像普通 query param 一样传递:
|
||||
# - <field>=...
|
||||
# - <field>__icontains=...
|
||||
# - <field>__isnull=true/false
|
||||
#
|
||||
# 额外支持:
|
||||
# - mdy_rowid=...(精确匹配模型字段)
|
||||
# - related_source=...(按关联表来源筛选,隐藏 related__contains 实现)
|
||||
#
|
||||
# 安全策略:
|
||||
# - 禁止直接使用 raw__/related__ 前缀(避免绕过白名单)
|
||||
# - 未在白名单内的过滤参数一律 400
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
for key, values in self.request.query_params.lists():
|
||||
if key in {"limit", "offset", "format"}:
|
||||
continue
|
||||
if not values:
|
||||
continue
|
||||
|
||||
# 明确禁止内部实现细节参数(即使存在也不处理)
|
||||
if key.startswith("raw__") or key.startswith("related__"):
|
||||
errors[key] = "not allowed"
|
||||
continue
|
||||
|
||||
# related_source:对外的“关联来源”过滤(等价于 related__contains=[{"source": "..."}])
|
||||
if key == "related_source":
|
||||
q = Q()
|
||||
for raw_value in values:
|
||||
if raw_value is None or str(raw_value).strip() == "":
|
||||
continue
|
||||
q |= Q(related__contains=[{"source": str(raw_value)}])
|
||||
if q:
|
||||
qs = qs.filter(q)
|
||||
continue
|
||||
|
||||
# mdy_rowid:模型字段过滤
|
||||
if key == "mdy_rowid":
|
||||
q = Q()
|
||||
for raw_value in values:
|
||||
q |= Q(mdy_rowid=str(raw_value))
|
||||
qs = qs.filter(q)
|
||||
continue
|
||||
|
||||
# raw 字段过滤:仅允许 internal_field(plate_order_type_map 的 key)
|
||||
parts = key.split("__")
|
||||
base = parts[0]
|
||||
if len(parts) == 1:
|
||||
lookup = "exact"
|
||||
elif len(parts) == 2:
|
||||
lookup = parts[1]
|
||||
else:
|
||||
errors[key] = "unsupported lookup"
|
||||
continue
|
||||
|
||||
lookup = (lookup or "exact").lower()
|
||||
if lookup not in _ALLOWED_LOOKUPS:
|
||||
errors[key] = "unsupported lookup"
|
||||
continue
|
||||
|
||||
if base not in _PUBLIC_RAW_FIELDS:
|
||||
errors[key] = "unsupported field"
|
||||
continue
|
||||
|
||||
control_id = plate_order_type_map.get(base)
|
||||
if not control_id:
|
||||
errors[key] = "unsupported field"
|
||||
continue
|
||||
|
||||
mapped_key = f"raw__{control_id}"
|
||||
if lookup != "exact":
|
||||
mapped_key += f"__{lookup}"
|
||||
|
||||
q = Q()
|
||||
for raw_value in values:
|
||||
coerced = _coerce_value_for_lookup(lookup, raw_value)
|
||||
q |= Q(**{mapped_key: coerced})
|
||||
|
||||
try:
|
||||
qs = qs.filter(q)
|
||||
except FieldError as exc:
|
||||
errors[key] = str(exc)
|
||||
|
||||
if errors:
|
||||
raise ValidationError({"detail": "invalid filters", "filters": errors})
|
||||
|
||||
return qs
|
||||
|
||||
@@ -87,9 +87,9 @@ class MDYPlateOrderStagingAPITestCase(TestCase):
|
||||
self.assertIn("related", first)
|
||||
self.assertEqual(first["related"], [])
|
||||
|
||||
def test_filter_by_raw_design_no(self):
|
||||
def test_filter_by_design_no(self):
|
||||
resp = self.client.get(
|
||||
"/api/v1/mdy-plate-order-staging/?limit=10&offset=0&raw__62d52f4b8d2972284492dd0e=A001"
|
||||
"/api/v1/mdy-plate-order-staging/?limit=10&offset=0&design_no=A001"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
@@ -99,3 +99,29 @@ class MDYPlateOrderStagingAPITestCase(TestCase):
|
||||
|
||||
self.assertEqual(payload["results"][0]["raw"]["设计编号"], "A001")
|
||||
self.assertEqual(payload["results"][0]["related"][0]["source"], "drawing")
|
||||
|
||||
def test_filter_by_style_name_icontains(self):
|
||||
# 支持 <internal_field>__icontains=...(不暴露 raw__ 前缀)
|
||||
resp = self.client.get(
|
||||
"/api/v1/mdy-plate-order-staging/?limit=10&offset=0&style_name__icontains=款号"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
payload = resp.json()
|
||||
self.assertEqual(payload["count"], 2)
|
||||
|
||||
def test_filter_by_related_source(self):
|
||||
# related_source 是对外参数(隐藏 related__contains 实现)
|
||||
resp = self.client.get(
|
||||
"/api/v1/mdy-plate-order-staging/?limit=10&offset=0&related_source=drawing"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
payload = resp.json()
|
||||
self.assertEqual(payload["count"], 1)
|
||||
self.assertEqual(payload["results"][0]["mdy_rowid"], "row1")
|
||||
|
||||
def test_rejects_raw_prefix_params(self):
|
||||
# 对外 API 禁止直接使用 raw__/related__ 前缀
|
||||
resp = self.client.get(
|
||||
"/api/v1/mdy-plate-order-staging/?limit=10&offset=0&raw__design_no=A001"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
Reference in New Issue
Block a user