fastapirestframework-tortoise 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. fastapirestframework/__init__.py +7 -0
  2. fastapirestframework/admin/__init__.py +29 -0
  3. fastapirestframework/admin/config.py +225 -0
  4. fastapirestframework/admin/resource.py +387 -0
  5. fastapirestframework/admin/router.py +134 -0
  6. fastapirestframework/admin/static/THIRD_PARTY_NOTICES.txt +1052 -0
  7. fastapirestframework/admin/static/assets/index-DctvyDns.js +19 -0
  8. fastapirestframework/admin/static/assets/index-dF2qgzmE.css +1 -0
  9. fastapirestframework/admin/static/index.html +13 -0
  10. fastapirestframework/admin/views.py +659 -0
  11. fastapirestframework/authentication.py +117 -0
  12. fastapirestframework/decorators.py +129 -0
  13. fastapirestframework/exceptions.py +107 -0
  14. fastapirestframework/fields.py +144 -0
  15. fastapirestframework/filters.py +272 -0
  16. fastapirestframework/generics.py +181 -0
  17. fastapirestframework/integrations.py +42 -0
  18. fastapirestframework/mixins.py +86 -0
  19. fastapirestframework/pagination.py +214 -0
  20. fastapirestframework/permissions.py +50 -0
  21. fastapirestframework/py.typed +0 -0
  22. fastapirestframework/query.py +23 -0
  23. fastapirestframework/responses.py +27 -0
  24. fastapirestframework/routers.py +347 -0
  25. fastapirestframework/schema.py +102 -0
  26. fastapirestframework/serializers.py +462 -0
  27. fastapirestframework/settings.py +83 -0
  28. fastapirestframework/throttling.py +194 -0
  29. fastapirestframework/views.py +260 -0
  30. fastapirestframework/viewsets.py +48 -0
  31. fastapirestframework_tortoise-0.0.1.dist-info/METADATA +8 -0
  32. fastapirestframework_tortoise-0.0.1.dist-info/RECORD +33 -0
  33. fastapirestframework_tortoise-0.0.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,7 @@
1
+ """FRF 提供声明式 Serializer、ViewSet 和标准 CRUD 路由。"""
2
+
3
+ from .settings import FRFSettings, configure, get_settings
4
+
5
+ __all__: list[str] = ["FRFSettings", "configure", "get_settings"]
6
+
7
+ __version__: str = "0.0.1"
@@ -0,0 +1,29 @@
1
+ """模型驱动的 Vue 3 管理后台;第一版显式无需登录。"""
2
+
3
+ from .config import (
4
+ PAGE_SIZES,
5
+ ActionResult,
6
+ FieldOptions,
7
+ Fieldset,
8
+ FilterSpec,
9
+ InlineModelAdmin,
10
+ ModelAdmin,
11
+ SimpleListFilter,
12
+ action,
13
+ display,
14
+ )
15
+ from .router import AdminRouter
16
+
17
+ __all__ = [
18
+ "PAGE_SIZES",
19
+ "AdminRouter",
20
+ "FieldOptions",
21
+ "ModelAdmin",
22
+ "ActionResult",
23
+ "Fieldset",
24
+ "FilterSpec",
25
+ "InlineModelAdmin",
26
+ "SimpleListFilter",
27
+ "action",
28
+ "display",
29
+ ]
@@ -0,0 +1,225 @@
1
+ """后台配置、声明装饰器与可继承业务钩子。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping, Sequence
6
+ from dataclasses import MISSING, dataclass, field
7
+ from dataclasses import fields as dataclass_fields
8
+ from typing import Any, ClassVar, Literal, TypeVar
9
+
10
+ from fastapi import Request
11
+ from tortoise.backends.base.client import BaseDBAsyncClient
12
+ from tortoise.models import Model
13
+ from tortoise.queryset import QuerySet
14
+
15
+ PAGE_SIZES: tuple[int, ...] = (10, 20, 100, 200, 500, 1000)
16
+ F = TypeVar("F", bound=Callable[..., object])
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class FieldOptions:
21
+ """展示覆盖不改变模型的数据校验规则。"""
22
+
23
+ label: str | None = None
24
+ help_text: str | None = None
25
+ placeholder: str | None = None
26
+ widget: Literal["text", "textarea", "number", "date", "datetime-local"] | None = None
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Fieldset:
31
+ """表单分组,字段只能来自当前有效公开字段。"""
32
+
33
+ title: str
34
+ fields: tuple[str, ...]
35
+ collapsed: bool = False
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class InlineModelAdmin:
40
+ """通过正向外键绑定父模型的子记录编辑;不支持嵌套 Inline。"""
41
+
42
+ model: type[Model]
43
+ fk_name: str
44
+ fields: tuple[str, ...] = ()
45
+ label: str | None = None
46
+
47
+
48
+ class SimpleListFilter:
49
+ """自定义离散筛选器;返回查询必须继续限制传入的数据范围。"""
50
+
51
+ title: ClassVar[str] = ""
52
+ parameter_name: ClassVar[str] = ""
53
+
54
+ def lookups(self, request: Request) -> Sequence[tuple[str, str]]:
55
+ """返回机器值和展示标签,不隐式查询数据库。"""
56
+ return ()
57
+
58
+ def queryset(self, request: Request, queryset: QuerySet[Model], value: str) -> QuerySet[Model]:
59
+ """在已限定数据范围的查询上追加筛选。"""
60
+ return queryset
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class FilterSpec:
65
+ """显式声明精确值、区间、空值或自定义筛选,不接收任意 ORM 表达式。"""
66
+
67
+ name: str
68
+ field: str | None = None
69
+ label: str | None = None
70
+ kind: Literal["exact", "range", "isnull", "custom"] = "exact"
71
+ filter_class: type[SimpleListFilter] | None = None
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Display:
76
+ """计算列只读;排序和预加载路径由开发者显式声明。"""
77
+
78
+ description: str
79
+ ordering: str | None = None
80
+ prefetch_related: tuple[str, ...] = ()
81
+
82
+
83
+ def display(
84
+ *, description: str, ordering: str | None = None, prefetch_related: Sequence[str] = ()
85
+ ) -> Callable[[F], F]:
86
+ """标记同步或异步计算列,保留原函数类型。"""
87
+
88
+ def decorate(function: F) -> F:
89
+ setattr(function, "_admin_display", Display(description, ordering, tuple(prefetch_related)))
90
+ return function
91
+
92
+ return decorate
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class Action:
97
+ """批量行为参数引用本模型公开字段,使用相同的模型校验规则。"""
98
+
99
+ description: str
100
+ fields: tuple[str, ...] = ()
101
+ confirmation: str = "确认对选中的记录执行此操作?"
102
+
103
+
104
+ def action(
105
+ *,
106
+ description: str,
107
+ fields: Sequence[str] = (),
108
+ confirmation: str = "确认对选中的记录执行此操作?",
109
+ ) -> Callable[[F], F]:
110
+ """标记异步批量行为;整个行为处于框架提供的单一事务中。"""
111
+
112
+ def decorate(function: F) -> F:
113
+ setattr(function, "_admin_action", Action(description, tuple(fields), confirmation))
114
+ return function
115
+
116
+ return decorate
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class ActionResult:
121
+ """返回实际影响条数和面向操作者的反馈。"""
122
+
123
+ affected: int
124
+ message: str
125
+
126
+
127
+ @dataclass(frozen=True, init=False)
128
+ class ModelAdmin:
129
+ """支持类属性继承和参数覆盖;钩子实例属于请求,不跨请求共享业务状态。"""
130
+
131
+ model: ClassVar[type[Model]]
132
+ label: str | None = None
133
+ # 导航图标使用 Element Plus 导出名称;前端对未知名称回退到 Grid。
134
+ icon: str = "Grid"
135
+ list_display: tuple[str, ...] = ()
136
+ fields: tuple[str, ...] = ()
137
+ readonly_fields: tuple[str, ...] = ()
138
+ search_fields: tuple[str, ...] = ()
139
+ list_filter: tuple[str | FilterSpec, ...] = ()
140
+ ordering: tuple[str, ...] = ()
141
+ label_field: str | None = None
142
+ page_size: int = 20
143
+ field_options: Mapping[str, FieldOptions] = field(default_factory=dict)
144
+ fieldsets: tuple[Fieldset, ...] = ()
145
+ list_editable: tuple[str, ...] = ()
146
+ actions: tuple[str, ...] = ("delete_selected",)
147
+ inlines: tuple[InlineModelAdmin, ...] = ()
148
+
149
+ def __init__(self, **options: Any) -> None:
150
+ # 默认 dataclass 构造器会覆盖子类类属性;动态类型仅限此声明边界。
151
+ declared = {item.name: item for item in dataclass_fields(ModelAdmin)}
152
+ if options.keys() - declared.keys():
153
+ raise TypeError(f"未知后台配置:{options.keys() - declared.keys()}")
154
+ for name, item in declared.items():
155
+ value: object = options.get(name, getattr(type(self), name, MISSING))
156
+ if value is MISSING:
157
+ value = (
158
+ item.default_factory() if item.default_factory is not MISSING else item.default
159
+ )
160
+ object.__setattr__(self, name, value)
161
+
162
+ def get_queryset(self, request: Request) -> QuerySet[Model]:
163
+ """普通方法返回未求值查询,不误等候 QuerySet。"""
164
+ return self.model.all()
165
+
166
+ def get_fields(self, request: Request, obj: Model | None = None) -> tuple[str, ...] | None:
167
+ """按新增/编辑上下文调整表单字段,不扩大静态字段白名单。"""
168
+ return self.fields or None
169
+
170
+ def get_readonly_fields(self, request: Request, obj: Model | None = None) -> tuple[str, ...]:
171
+ """动态只读同时约束前端与后端。"""
172
+ return self.readonly_fields
173
+
174
+ def get_fieldsets(self, request: Request, obj: Model | None = None) -> tuple[Fieldset, ...]:
175
+ """按请求返回字段分组。"""
176
+ return self.fieldsets
177
+
178
+ async def validate(
179
+ self,
180
+ request: Request,
181
+ data: dict[str, object],
182
+ obj: Model | None,
183
+ connection: BaseDBAsyncClient,
184
+ ) -> dict[str, object]:
185
+ """结构校验后执行业务验证,抛出 FRF ValidationError 回滚事务。"""
186
+ return data
187
+
188
+ async def save_model(
189
+ self,
190
+ request: Request,
191
+ obj: Model | None,
192
+ data: dict[str, object],
193
+ connection: BaseDBAsyncClient,
194
+ ) -> Model:
195
+ """保存已转换的标量及外键底层值;覆盖时沿用传入连接。"""
196
+ if obj is None:
197
+ obj = self.model(**data)
198
+ await obj.save(using_db=connection, force_create=True)
199
+ elif data:
200
+ obj.update_from_dict(data)
201
+ await obj.save(using_db=connection, update_fields=list(data))
202
+ return obj
203
+
204
+ async def save_related(
205
+ self,
206
+ request: Request,
207
+ obj: Model,
208
+ relations: Mapping[str, list[Model]],
209
+ connection: BaseDBAsyncClient,
210
+ ) -> None:
211
+ """主表保存后替换多对多,省略的关系不出现在参数中。"""
212
+ for name, items in relations.items():
213
+ manager: Any = getattr(obj, name)
214
+ await manager.clear(using_db=connection)
215
+ if items:
216
+ await manager.add(*items, using_db=connection)
217
+
218
+ async def delete_model(
219
+ self,
220
+ request: Request,
221
+ obj: Model,
222
+ connection: BaseDBAsyncClient,
223
+ ) -> None:
224
+ """保护引用检查后删除,覆盖时沿用事务连接。"""
225
+ await obj.delete(using_db=connection)
@@ -0,0 +1,387 @@
1
+ """后台模型适配:白名单元数据、结构校验与关联展示。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from dataclasses import replace
7
+ from enum import Enum
8
+ from types import GenericAlias, MappingProxyType
9
+ from typing import Any
10
+
11
+ from fastapi import Request
12
+ from fastapi.encoders import jsonable_encoder
13
+ from pydantic import BaseModel
14
+ from pydantic import ValidationError as PydanticValidationError
15
+ from tortoise import fields
16
+ from tortoise.fields.relational import ForeignKeyFieldInstance, ManyToManyFieldInstance
17
+ from tortoise.models import Model
18
+
19
+ from ..exceptions import ConfigurationError, ValidationError
20
+ from ..fields import FieldSpec, model_fields
21
+ from ..schema import SchemaMode, build_schema
22
+ from .config import PAGE_SIZES, Action, Display, FieldOptions, FilterSpec, ModelAdmin
23
+
24
+
25
+ class AdminResource:
26
+ """模型注册的配置快照;关系在 ORM 初始化后解析,不在导入时连接数据库。"""
27
+
28
+ def __init__(self, model: type[Model], slug: str, config: ModelAdmin) -> None:
29
+ self.model: type[Model] = model
30
+ self.slug: str = slug
31
+ self.config: ModelAdmin = replace(
32
+ config,
33
+ list_display=tuple(config.list_display),
34
+ fields=tuple(config.fields),
35
+ readonly_fields=tuple(config.readonly_fields),
36
+ search_fields=tuple(config.search_fields),
37
+ list_filter=tuple(config.list_filter),
38
+ ordering=tuple(config.ordering),
39
+ actions=tuple(config.actions),
40
+ fieldsets=tuple(
41
+ replace(group, fields=tuple(group.fields)) for group in config.fieldsets
42
+ ),
43
+ list_editable=tuple(config.list_editable),
44
+ inlines=tuple(
45
+ replace(inline, fields=tuple(inline.fields)) for inline in config.inlines
46
+ ),
47
+ field_options=MappingProxyType(dict(config.field_options)),
48
+ )
49
+ object.__setattr__(self.config, "model", model)
50
+ if not isinstance(config.icon, str):
51
+ raise ConfigurationError("icon 必须为 Element Plus 图标名称字符串")
52
+ if config.page_size not in PAGE_SIZES:
53
+ raise ConfigurationError(f"page_size 必须为 {PAGE_SIZES} 之一")
54
+ self._schemas: dict[SchemaMode, type[BaseModel]] = {}
55
+ self._owner: type[object] = type(f"Admin_{model.__name__}_{slug}", (), {})
56
+
57
+ @property
58
+ def displays(self) -> dict[str, Display]:
59
+ """仅允许显式装饰的方法作为输出列。"""
60
+ result: dict[str, Display] = {}
61
+ for name in self.config.list_display:
62
+ metadata: object = getattr(getattr(self.config, name, None), "_admin_display", None)
63
+ if isinstance(metadata, Display):
64
+ result[name] = metadata
65
+ return result
66
+
67
+ @property
68
+ def filters(self) -> dict[str, FilterSpec]:
69
+ """旧字符串声明继续表示精确筛选。"""
70
+ result: dict[str, FilterSpec] = {}
71
+ for item in self.config.list_filter:
72
+ spec: FilterSpec = FilterSpec(item) if isinstance(item, str) else item
73
+ if not spec.name.isidentifier() or spec.name in result:
74
+ raise ConfigurationError("筛选名称必须是唯一标识符")
75
+ result[spec.name] = spec
76
+ return result
77
+
78
+ def actions_metadata(self) -> list[dict[str, object]]:
79
+ """客户端只能选择已注册行为,不能调用任意方法名。"""
80
+ result: list[dict[str, object]] = []
81
+ for name in self.config.actions:
82
+ if name == "delete_selected":
83
+ result.append(
84
+ {
85
+ "name": name,
86
+ "label": "批量删除",
87
+ "fields": (),
88
+ "confirmation": "确认删除选中记录?任一失败将整批回滚。",
89
+ }
90
+ )
91
+ continue
92
+ method = getattr(self.config, name, None)
93
+ option: object = getattr(method, "_admin_action", None)
94
+ if not isinstance(option, Action):
95
+ raise ConfigurationError(f"行为 {name} 必须使用 admin.action 声明")
96
+ if not inspect.iscoroutinefunction(method):
97
+ raise ConfigurationError("自定义行为必须使用 async def")
98
+ if not set(option.fields) <= self.schema("partial_update").model_fields.keys():
99
+ raise ConfigurationError("行为参数必须为可写的公开模型字段")
100
+ result.append(
101
+ {
102
+ "name": name,
103
+ "label": option.description,
104
+ "fields": option.fields,
105
+ "confirmation": option.confirmation,
106
+ }
107
+ )
108
+ return result
109
+
110
+ def for_form(self, request: Request, obj: Model | None = None) -> AdminResource:
111
+ """动态字段仅缩小静态白名单,Schema 缓存只属于本请求资源。"""
112
+ declared = self.config.get_fields(request, obj)
113
+ names: tuple[str, ...] = tuple(self.specs()) if declared is None else tuple(declared)
114
+ names = tuple(dict.fromkeys((self.model._meta.pk_attr, *names)))
115
+ if not set(names) <= self.specs().keys():
116
+ raise ConfigurationError("动态字段不得扩大静态 fields 白名单")
117
+ readonly: tuple[str, ...] = tuple(self.config.get_readonly_fields(request, obj))
118
+ if not set(readonly) <= self.specs().keys():
119
+ raise ConfigurationError("动态只读配置包含未知字段")
120
+ return AdminResource(
121
+ self.model,
122
+ self.slug,
123
+ replace(
124
+ self.config,
125
+ fields=names,
126
+ readonly_fields=tuple(n for n in readonly if n in names),
127
+ fieldsets=tuple(self.config.get_fieldsets(request, obj)),
128
+ list_display=(),
129
+ search_fields=(),
130
+ list_filter=(),
131
+ ordering=(),
132
+ list_editable=(),
133
+ actions=(),
134
+ inlines=(),
135
+ label_field=None,
136
+ field_options={n: v for n, v in self.config.field_options.items() if n in names},
137
+ ),
138
+ )
139
+
140
+ @property
141
+ def relations(
142
+ self,
143
+ ) -> dict[str, ForeignKeyFieldInstance[Model] | ManyToManyFieldInstance[Model]]:
144
+ """仅支持正向 FK/O2O 与正向 M2M;反向集合不自动变成可写表单。"""
145
+ return {
146
+ name: field
147
+ for name, field in self.model._meta.fields_map.items()
148
+ if isinstance(field, ForeignKeyFieldInstance)
149
+ or (isinstance(field, ManyToManyFieldInstance) and not field._generated)
150
+ }
151
+
152
+ def specs(self) -> dict[str, FieldSpec]:
153
+ """关联输入使用目标主键,避免把关联对象当作客户端任意嵌套数据。"""
154
+ result: dict[str, FieldSpec] = model_fields(self.model)
155
+ for name, relation in self.relations.items():
156
+ target: type[Model] = relation.related_model
157
+ if target is None:
158
+ raise ConfigurationError("请由应用先初始化 Tortoise 模型关系")
159
+ if isinstance(relation, ForeignKeyFieldInstance):
160
+ if relation.to_field not in (None, target._meta.pk_attr):
161
+ raise ConfigurationError("admin 当前仅支持引用主键的关联")
162
+ result.pop(relation.source_field or f"{name}_id", None)
163
+ target_spec: FieldSpec | None = model_fields(target).get(target._meta.pk_attr)
164
+ if target_spec is None:
165
+ raise ConfigurationError("关联模型主键类型尚不支持")
166
+ many: bool = isinstance(relation, ManyToManyFieldInstance)
167
+ annotation: Any = (
168
+ GenericAlias(list, target_spec.annotation) if many else target_spec.annotation
169
+ )
170
+ result[name] = FieldSpec(
171
+ annotation,
172
+ list
173
+ if many
174
+ else (relation.default if relation.default is not None or relation.null else ...),
175
+ relation.null if not many else False,
176
+ False,
177
+ {},
178
+ relation.description,
179
+ )
180
+ selected: tuple[str, ...] = self.config.fields or tuple(result)
181
+ if len(set(selected)) != len(selected) or not set(selected) <= result.keys():
182
+ raise ConfigurationError("fields 包含重复、反向关联或不支持的字段")
183
+ # 主键始终用于详情寻址,但未加入白名单的其他字段绝不输出。
184
+ selected = tuple(dict.fromkeys((self.model._meta.pk_attr, *selected)))
185
+ return {name: result[name] for name in selected}
186
+
187
+ def validate_config(self) -> None:
188
+ """统一检查查询与展示白名单,不接受任意 ORM 表达式。"""
189
+ specs: dict[str, FieldSpec] = self.specs()
190
+ if len(set(self.config.list_display)) != len(self.config.list_display):
191
+ raise ConfigurationError("list_display 不能包含重复列")
192
+ for names in (
193
+ self.config.readonly_fields,
194
+ tuple(self.config.field_options),
195
+ ):
196
+ if len(set(names)) != len(names) or not set(names) <= specs.keys():
197
+ raise ConfigurationError("后台配置字段必须在 fields 内")
198
+ if not set(self.config.list_display) <= specs.keys() | self.displays.keys():
199
+ raise ConfigurationError("list_display 包含未知字段或未装饰方法")
200
+ if not set(self.config.list_editable) <= specs.keys() & set(self.config.list_display):
201
+ raise ConfigurationError("list_editable 必须为列表中公开的模型字段")
202
+ if any(
203
+ n in self.config.readonly_fields or specs[n].generated or specs[n].primary_key
204
+ for n in self.config.list_editable
205
+ ):
206
+ raise ConfigurationError("只读、生成字段和主键不能直接编辑")
207
+ scalars: set[str] = specs.keys() - self.relations.keys()
208
+ if not {name.removeprefix("-") for name in self.config.ordering} <= scalars:
209
+ raise ConfigurationError("ordering 只支持已公开的本层标量字段")
210
+ for name in self.config.search_fields:
211
+ if name not in scalars or not isinstance(
212
+ self.model._meta.fields_map[name], (fields.CharField, fields.TextField)
213
+ ):
214
+ raise ConfigurationError("search_fields 只支持已公开的文本字段")
215
+ if self.config.label_field is not None and self.config.label_field not in scalars:
216
+ raise ConfigurationError("label_field 必须是已公开的本层标量字段")
217
+ for spec in self.filters.values():
218
+ name = spec.field or spec.name
219
+ if spec.kind == "custom":
220
+ if spec.filter_class is None:
221
+ raise ConfigurationError("自定义筛选需要 filter_class")
222
+ continue
223
+ if name not in specs:
224
+ raise ConfigurationError("筛选字段必须已公开")
225
+ if name in scalars and isinstance(self.model._meta.fields_map[name], fields.JSONField):
226
+ raise ConfigurationError("admin 暂不支持 JSON 筛选")
227
+ if spec.kind == "range" and not isinstance(
228
+ self.model._meta.fields_map[name],
229
+ (
230
+ fields.DateField,
231
+ fields.DatetimeField,
232
+ fields.IntField,
233
+ fields.FloatField,
234
+ fields.DecimalField,
235
+ ),
236
+ ):
237
+ raise ConfigurationError("区间筛选只用于日期和数值字段")
238
+ for name, option in self.config.field_options.items():
239
+ if option.widget is not None and name in self.relations:
240
+ raise ConfigurationError("关联字段使用关系选择器,不支持标量 widget 覆盖")
241
+ grouped: list[str] = [n for group in self.config.fieldsets for n in group.fields]
242
+ if len(grouped) != len(set(grouped)) or not set(grouped) <= specs.keys():
243
+ raise ConfigurationError("fieldsets 字段重复或不在有效表单中")
244
+
245
+ def schema(self, mode: SchemaMode) -> type[BaseModel]:
246
+ """复用 FRF 四类 Schema 的输入规则,PATCH 省略与 null 保持区分。"""
247
+ if mode not in self._schemas:
248
+ self.validate_config()
249
+ specs: dict[str, FieldSpec] = self.specs()
250
+ readonly: set[str] = set(self.config.readonly_fields)
251
+ readonly.update(name for name, spec in specs.items() if spec.generated)
252
+ self._schemas[mode] = build_schema(self._owner, mode, specs, readonly, set())
253
+ return self._schemas[mode]
254
+
255
+ def parse(self, data: dict[str, object], mode: SchemaMode) -> dict[str, object]:
256
+ """输入错误转换为公开字段错误,绝不将未校验的键交给 ORM。"""
257
+ try:
258
+ parsed: BaseModel = self.schema(mode).model_validate(data)
259
+ except PydanticValidationError as error:
260
+ errors: dict[str, list[str]] = {}
261
+ for item in error.errors(include_input=False, include_context=False):
262
+ key: str = ".".join(str(part) for part in item["loc"])
263
+ errors.setdefault(key, []).append(item["msg"])
264
+ raise ValidationError(errors) from error
265
+ return parsed.model_dump(exclude_unset=mode != "create")
266
+
267
+ def label(self, instance: Model) -> str:
268
+ """优先使用配置的可读字段,其次采用常见名称字段,最后使用主键。"""
269
+ names: tuple[str, ...] = (
270
+ (self.config.label_field,) if self.config.label_field else ("name", "username", "title")
271
+ )
272
+ for name in names:
273
+ if name in self.specs() and name not in self.relations:
274
+ value: object = getattr(instance, name)
275
+ if value is not None:
276
+ return str(value)
277
+ return str(instance.pk)
278
+
279
+ def metadata(self, request: Request | None = None) -> dict[str, object]:
280
+ """前端所需的标签、控件与输入约束来自同一份 Schema。"""
281
+ self.validate_config()
282
+ specs: dict[str, FieldSpec] = self.specs()
283
+ create_schema: type[BaseModel] = self.schema("create")
284
+ descriptors: list[dict[str, object]] = []
285
+ for name, spec in specs.items():
286
+ field = self.model._meta.fields_map[name]
287
+ option: FieldOptions = self.config.field_options.get(name, FieldOptions())
288
+ readonly: bool = name not in create_schema.model_fields
289
+ kind: str = "text"
290
+ if name in self.relations:
291
+ kind = "many" if isinstance(field, ManyToManyFieldInstance) else "relation"
292
+ elif isinstance(field, fields.BooleanField):
293
+ kind = "boolean"
294
+ elif isinstance(field, fields.JSONField):
295
+ kind = "json"
296
+ elif isinstance(field, fields.DatetimeField):
297
+ kind = "datetime-local"
298
+ elif isinstance(field, fields.DateField):
299
+ kind = "date"
300
+ elif isinstance(field, (fields.IntField, fields.FloatField, fields.DecimalField)):
301
+ kind = "number"
302
+ elif isinstance(field, fields.TextField):
303
+ kind = "textarea"
304
+ choices: list[dict[str, object]] = []
305
+ if isinstance(spec.annotation, type) and issubclass(spec.annotation, Enum):
306
+ kind = "choice"
307
+ choices = [{"value": item.value, "label": item.name} for item in spec.annotation]
308
+ required: bool = not readonly and create_schema.model_fields[name].is_required()
309
+ descriptor: dict[str, object] = {
310
+ "name": name,
311
+ "label": option.label or ("ID" if spec.primary_key else name),
312
+ "kind": option.widget or kind,
313
+ "readonly": readonly,
314
+ "required": required,
315
+ "nullable": spec.nullable,
316
+ "primary_key": spec.primary_key,
317
+ "help_text": option.help_text or spec.description or "",
318
+ "placeholder": option.placeholder or "",
319
+ "choices": choices,
320
+ "max_length": spec.constraints.get("max_length"),
321
+ }
322
+ if spec.default is not ... and not callable(spec.default):
323
+ descriptor["default"] = jsonable_encoder(spec.default)
324
+ descriptors.append(descriptor)
325
+ columns: list[dict[str, object]] = []
326
+ for name in self.config.list_display or tuple(specs):
327
+ display: Display | None = self.displays.get(name)
328
+ columns.append(
329
+ {
330
+ "name": name,
331
+ "label": display.description
332
+ if display
333
+ else next(item["label"] for item in descriptors if item["name"] == name),
334
+ "ordering": display.ordering
335
+ if display
336
+ else (
337
+ name
338
+ if name in {n.removeprefix("-") for n in self.config.ordering}
339
+ else None
340
+ ),
341
+ }
342
+ )
343
+ filters: list[dict[str, object]] = []
344
+ for filter_spec in self.filters.values():
345
+ filter_choices: list[dict[str, str]] = []
346
+ if filter_spec.filter_class is not None and request is not None:
347
+ filter_choices = [
348
+ {"value": v, "label": label}
349
+ for v, label in filter_spec.filter_class().lookups(request)
350
+ ]
351
+ filters.append(
352
+ {
353
+ "name": filter_spec.name,
354
+ "field": filter_spec.field or filter_spec.name,
355
+ "label": filter_spec.label
356
+ or self.config.field_options.get(
357
+ filter_spec.field or filter_spec.name, FieldOptions()
358
+ ).label
359
+ or (
360
+ filter_spec.filter_class.title
361
+ if filter_spec.filter_class
362
+ else filter_spec.name
363
+ ),
364
+ "kind": filter_spec.kind,
365
+ "choices": filter_choices,
366
+ }
367
+ )
368
+ return {
369
+ "slug": self.slug,
370
+ "label": self.config.label or self.model.__name__,
371
+ "icon": self.config.icon,
372
+ "pk_field": self.model._meta.pk_attr,
373
+ "fields": descriptors,
374
+ "list_display": self.config.list_display or tuple(specs),
375
+ "search_fields": self.config.search_fields,
376
+ "list_filter": tuple(self.filters),
377
+ "filters": filters,
378
+ "ordering": self.config.ordering,
379
+ "page_size": self.config.page_size,
380
+ "columns": columns,
381
+ "list_editable": self.config.list_editable,
382
+ "actions": self.actions_metadata(),
383
+ "fieldsets": [
384
+ {"title": group.title, "fields": group.fields, "collapsed": group.collapsed}
385
+ for group in self.config.fieldsets
386
+ ],
387
+ }