forked from erp-dev/erp
feat: mdy staging api filters
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
需求:
|
需求:
|
||||||
- 默认按 -ctime 排序
|
- 默认按 -ctime 排序
|
||||||
- 支持 raw__62d52f4b8d2972284492dd0e(设计编号)过滤
|
- 支持按“内部字段名”的 query param 过滤(对前端隐藏 JSONField 实现细节)
|
||||||
- 支持 LimitOffset 分页
|
- 支持 LimitOffset 分页
|
||||||
- 返回时把 raw / related 两个 JSONField 做“可读化/碾平”
|
- 返回时把 raw / related 两个 JSONField 做“可读化/碾平”
|
||||||
"""
|
"""
|
||||||
@@ -11,21 +11,41 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from django.core.exceptions import FieldError
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.cache import cache_page
|
from django.views.decorators.cache import cache_page
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
|
from django.db.models import Q
|
||||||
from rest_framework import serializers, viewsets
|
from rest_framework import serializers, viewsets
|
||||||
from rest_framework.permissions import DjangoModelPermissions
|
from rest_framework.permissions import DjangoModelPermissions
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
from flower.viewsets import LimitedReadOnlyModelViewSet
|
from flower.viewsets import LimitedReadOnlyModelViewSet
|
||||||
from api_v1.models import MDYPlateOrderStaging
|
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
|
from flower.utils.mingdaoyun.relations import flatten_row_by_field_definitions, normalize_mdy_value
|
||||||
|
|
||||||
|
|
||||||
_PLATE_ORDER_DESIGN_NO_CONTROL_ID = "62d52f4b8d2972284492dd0e"
|
_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):
|
class MDYPlateOrderStagingSerializer(serializers.ModelSerializer):
|
||||||
"""把 raw/related 输出为扁平的可读结构。"""
|
"""把 raw/related 输出为扁平的可读结构。"""
|
||||||
@@ -94,19 +114,96 @@ class MDYPlateOrderStagingViewSet(LimitedReadOnlyModelViewSet):
|
|||||||
# permission_classes = [DjangoModelPermissions]
|
# permission_classes = [DjangoModelPermissions]
|
||||||
|
|
||||||
def get_queryset(self):
|
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 为空的放最后,避免老数据(未回填)跑到前面
|
# 默认 -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):
|
def filter_queryset(self, queryset):
|
||||||
qs = super().filter_queryset(queryset)
|
qs = super().filter_queryset(queryset)
|
||||||
design_no = self.request.query_params.get(f"raw__{_PLATE_ORDER_DESIGN_NO_CONTROL_ID}")
|
|
||||||
if design_no:
|
# 对前端暴露的过滤参数:只允许使用 “内部字段名”,像普通 query param 一样传递:
|
||||||
qs = qs.filter(**{f"raw__{_PLATE_ORDER_DESIGN_NO_CONTROL_ID}": design_no})
|
# - <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
|
return qs
|
||||||
|
|||||||
@@ -87,9 +87,9 @@ class MDYPlateOrderStagingAPITestCase(TestCase):
|
|||||||
self.assertIn("related", first)
|
self.assertIn("related", first)
|
||||||
self.assertEqual(first["related"], [])
|
self.assertEqual(first["related"], [])
|
||||||
|
|
||||||
def test_filter_by_raw_design_no(self):
|
def test_filter_by_design_no(self):
|
||||||
resp = self.client.get(
|
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)
|
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]["raw"]["设计编号"], "A001")
|
||||||
self.assertEqual(payload["results"][0]["related"][0]["source"], "drawing")
|
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)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
- **只读**:仅提供 `list/retrieve`(不支持创建/更新/删除)
|
- **只读**:仅提供 `list/retrieve`(不支持创建/更新/删除)
|
||||||
- **分页**:LimitOffset(`limit` / `offset`)
|
- **分页**:LimitOffset(`limit` / `offset`)
|
||||||
- **默认排序**:按 `ctime` 倒序(`-ctime`),并将 `ctime = null` 的记录排在最后
|
- **默认排序**:按 `ctime` 倒序(`-ctime`),并将 `ctime = null` 的记录排在最后
|
||||||
- **过滤**:支持按明道云字段 `62d52f4b8d2972284492dd0e`(设计编号/订单id)精确匹配
|
- **过滤**:支持按“内部字段名”的普通 query param 过滤(对前端隐藏 JSONField 实现细节)
|
||||||
- **返回结构**:`raw` 与 `related` 两个 JSONField 在输出时会做“可读化/碾平”处理
|
- **返回结构**:`raw` 与 `related` 两个 JSONField 在输出时会做“可读化/碾平”处理
|
||||||
|
|
||||||
### Endpoint
|
### Endpoint
|
||||||
@@ -23,12 +23,15 @@
|
|||||||
- **offset**:偏移量
|
- **offset**:偏移量
|
||||||
|
|
||||||
#### 过滤
|
#### 过滤
|
||||||
- **raw__62d52f4b8d2972284492dd0e**:设计编号/订单id(精确匹配)
|
- **design_no**:设计编号(精确匹配)
|
||||||
|
- **style_name__icontains**:款号名称(模糊匹配)
|
||||||
|
|
||||||
|
> 该接口会拒绝任何以 `raw__` / `related__` 开头的参数(避免前端绕过白名单)。
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/v1/mdy-plate-order-staging/?limit=20&offset=0&raw__62d52f4b8d2972284492dd0e=82724
|
GET /api/v1/mdy-plate-order-staging/?limit=20&offset=0&design_no=82724
|
||||||
```
|
```
|
||||||
|
|
||||||
### 默认排序规则
|
### 默认排序规则
|
||||||
@@ -43,7 +46,7 @@ GET /api/v1/mdy-plate-order-staging/?limit=20&offset=0&raw__62d52f4b8d2972284492
|
|||||||
- **ctime**:明道云系统字段 ctime(模型字段,便于排序)
|
- **ctime**:明道云系统字段 ctime(模型字段,便于排序)
|
||||||
- **utime**:明道云系统字段 utime(模型字段)
|
- **utime**:明道云系统字段 utime(模型字段)
|
||||||
- **created_at / updated_at**:本地记录创建/更新时间(ModelBase)
|
- **created_at / updated_at**:本地记录创建/更新时间(ModelBase)
|
||||||
- **design_no**:便捷字段,等价于 `raw["62d52f4b8d2972284492dd0e"]`
|
- **design_no**:便捷字段,等价于 `raw` 中的设计编号值
|
||||||
- **raw**:对 `MDYPlateOrderStaging.raw` 的“可读化/碾平”输出
|
- **raw**:对 `MDYPlateOrderStaging.raw` 的“可读化/碾平”输出
|
||||||
- **related**:对 `MDYPlateOrderStaging.related` 的“碾平”输出(list[dict])
|
- **related**:对 `MDYPlateOrderStaging.related` 的“碾平”输出(list[dict])
|
||||||
|
|
||||||
@@ -103,7 +106,7 @@ GET /api/v1/mdy-plate-order-staging/?limit=20&offset=0&raw__62d52f4b8d2972284492
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 性能说明
|
### 性能说明
|
||||||
- 过滤字段 `raw__62d52f4b8d2972284492dd0e` 对应 PostgreSQL 表达式索引(见迁移 `api_v1/migrations/0006_mdy_plate_order_staging_raw_id_index.py`),用于加速按设计编号检索。
|
- `design_no` 过滤对应 PostgreSQL 表达式索引(见迁移 `api_v1/migrations/0006_mdy_plate_order_staging_raw_id_index.py`),用于加速按设计编号检索。
|
||||||
|
|
||||||
### 实现位置
|
### 实现位置
|
||||||
- View/Serializer:`api_v1/views/mingdaoyun/plate_order_staging.py`
|
- View/Serializer:`api_v1/views/mingdaoyun/plate_order_staging.py`
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ class LimitedLimitOffsetPagination(LimitOffsetPagination):
|
|||||||
- 最大每页返回 100 条
|
- 最大每页返回 100 条
|
||||||
- 防止前端请求过多数据
|
- 防止前端请求过多数据
|
||||||
"""
|
"""
|
||||||
default_limit = 100
|
default_limit = 300
|
||||||
max_limit = 100
|
max_limit = 300
|
||||||
|
|
||||||
|
|
||||||
class LimitedModelViewSet(viewsets.ModelViewSet):
|
class LimitedModelViewSet(viewsets.ModelViewSet):
|
||||||
|
|||||||
Reference in New Issue
Block a user