labull-framework 0.1.4.2__tar.gz → 0.1.4.4__tar.gz

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 (47) hide show
  1. {labull_framework-0.1.4.2/labull_framework.egg-info → labull_framework-0.1.4.4}/PKG-INFO +1 -1
  2. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/canvas/data.py +93 -8
  3. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/canvas/static/canvas.js +9 -5
  4. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/canvas/static/page.html +2 -2
  5. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/magic_block/devkit.py +101 -6
  6. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/magic_block/export.py +23 -5
  7. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/settings.py +15 -2
  8. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4/labull_framework.egg-info}/PKG-INFO +1 -1
  9. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/pyproject.toml +1 -1
  10. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/LICENSE +0 -0
  11. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/MANIFEST.in +0 -0
  12. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/README-pypi.md +0 -0
  13. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/__init__.py +0 -0
  14. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/admin.py +0 -0
  15. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/apps.py +0 -0
  16. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/auth/__init__.py +0 -0
  17. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/auth/backend.py +0 -0
  18. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/auth/bootstrap.py +0 -0
  19. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/auth/sso.py +0 -0
  20. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/auth/views.py +0 -0
  21. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/canvas/__init__.py +0 -0
  22. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/canvas/gitinfo.py +0 -0
  23. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/canvas/static/canvas.css +0 -0
  24. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/canvas/views.py +0 -0
  25. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/env.py +0 -0
  26. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/jsonio.py +0 -0
  27. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/magic_block/__init__.py +0 -0
  28. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/magic_block/deepcheck.py +0 -0
  29. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/magic_block/dispatch.py +0 -0
  30. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/magic_block/health.py +0 -0
  31. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/magic_block/registry.py +0 -0
  32. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/magic_block/sdk.py +0 -0
  33. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/magic_block/service.py +0 -0
  34. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/magic_block/validate.py +0 -0
  35. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/magic_block/views.py +0 -0
  36. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/management/__init__.py +0 -0
  37. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/management/commands/__init__.py +0 -0
  38. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/management/commands/labull_contract.py +0 -0
  39. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/migrations/0001_initial.py +0 -0
  40. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/migrations/__init__.py +0 -0
  41. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/models.py +0 -0
  42. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework/urls.py +0 -0
  43. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework.egg-info/SOURCES.txt +0 -0
  44. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework.egg-info/dependency_links.txt +0 -0
  45. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework.egg-info/requires.txt +0 -0
  46. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/labull_framework.egg-info/top_level.txt +0 -0
  47. {labull_framework-0.1.4.2 → labull_framework-0.1.4.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 0.1.4.2
3
+ Version: 0.1.4.4
4
4
  Summary: Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks
5
5
  Author: Labull Framework
6
6
  License-Expression: MIT
@@ -156,8 +156,11 @@ def _file_of(field: Any) -> dict[str, Any] | None:
156
156
  }
157
157
 
158
158
 
159
- def _shape_field(field: Any, model: Any) -> dict[str, Any]:
160
- """一个字段在画布上的全部信息(**唯一一处**拼这个字典)。"""
159
+ def _shape_field(field: Any, model: Any, enums: dict[str, dict[str, Any]] | None = None) -> dict[str, Any]:
160
+ """一个字段在画布上的全部信息(**唯一一处**拼这个字典)。
161
+
162
+ :param enums: `_enums_of(model)` 那一份(类名 → 中文名 / 成员);不给就只有取值,没有类名。
163
+ """
161
164
  kind = _kind_of(field)
162
165
  item: dict[str, Any] = {
163
166
  'name': field.name,
@@ -171,6 +174,10 @@ def _shape_field(field: Any, model: Any) -> dict[str, Any]:
171
174
  'brief': _brief(field),
172
175
  'briefLine': _brief_line(field),
173
176
  'enum': _enum_of(field),
177
+ # 🔴 这个字段用的是**哪个枚举类**(`Order.Status`),认不出就是 None。
178
+ # 取值那一半在 `enum` 里,类名这一半只能这么补:字段上没有任何回指(见 `_choice_items`)。
179
+ # 导出(`devkit._field_lines`)靠它写出 `Order.Status.OPEN` 那种真能用的写法。
180
+ 'enumRef': _enum_ref(field, enums or {}),
174
181
  'file': _file_of(field),
175
182
  # 默认值也要能看(`default=Status.OPEN` 这类信息在页面上很有用)。
176
183
  'default': _default_of(field),
@@ -248,6 +255,9 @@ def shape() -> dict[str, list[dict[str, Any]]]:
248
255
  label = str(model._meta.app_label)
249
256
  fields: list[dict[str, Any]] = []
250
257
  relations: list[dict[str, Any]] = []
258
+ # 🔴 枚举类**先扫一遍**:每个带 choices 的字段要在这一步认出"它用的是哪个类"(`enumRef`)。
259
+ # 放在字段循环之前是判据:`_shape_field` 拿它当参数,顺序反了就只能再扫一遍模型。
260
+ enums = _enums_of(model)
251
261
  for field in model._meta.get_fields():
252
262
  if getattr(field, 'many_to_many', False):
253
263
  target = field.related_model
@@ -286,7 +296,7 @@ def shape() -> dict[str, list[dict[str, Any]]]:
286
296
  relations.append(
287
297
  _relation_of(field, kind, '1', target, column=str(field.attname))
288
298
  )
289
- fields.append(_shape_field(field, model))
299
+ fields.append(_shape_field(field, model, enums))
290
300
  tables.append(
291
301
  {
292
302
  'name': model.__name__,
@@ -295,6 +305,10 @@ def shape() -> dict[str, list[dict[str, Any]]]:
295
305
  'verbose': _verbose(model),
296
306
  'brief': _brief(model),
297
307
  'briefLine': _brief_line(model),
308
+ # 🔴 这张表**自己定义**的那几个枚举类(类名 → 中文名 / 成员)。
309
+ # 它是**只增不减**的信息:画布不读它,导出(`devkit`)靠它写出 `Order.Status.OPEN`
310
+ # 那种写法。见 `_enums_of()` 顶上那段(为什么必须扫模型类体)。
311
+ 'enums': enums,
298
312
  'fields': fields,
299
313
  'relations': relations,
300
314
  }
@@ -358,16 +372,87 @@ def _type_of(field: Any) -> str:
358
372
  return 'text'
359
373
 
360
374
 
375
+ def _choice_items(field: Any) -> list[tuple[Any, Any]]:
376
+ """一个带 choices 的字段:`[(取值, 中文名)]`(**只有这一处**做那个归一化)。
377
+
378
+ 🔴 Django 已经把 choices 归一化过了:`TextChoices` 那份「取值 → 中文名」在 `field.choices` 上
379
+ 只剩一个**普通列表**(实测 `[('open', '进行中'), …]`,连元素都不是 `ChoiceItem`)——
380
+ **回指枚举类的那条线索在字段上根本不存在**,类名与成员名只能回到模型类体上去扫(见 `_enums_of`)。
381
+ 🔴 归一化收成这一份:字段那一层与类那一层要比"取值是不是同一批",两边各做一遍就会出现
382
+ "看着一样、比不相等"(`'1'` 与 `1`、`ChoiceItem` 与元组)—— 而那种漂没人会立刻发现。
383
+ """
384
+ out: list[tuple[Any, Any]] = []
385
+ for item in list(getattr(field, 'choices', None) or []):
386
+ if isinstance(item, (tuple, list)) and len(item) == 2:
387
+ out.append((item[0], item[1]))
388
+ return out
389
+
390
+
361
391
  def _enum_of(field: Any) -> dict[str, Any] | None:
362
- """枚举列:`TextChoices` 在 `_meta` 里只剩「取值 → 中文名」这两样。"""
363
- choices = list(getattr(field, 'choices', None) or [])
364
- if not choices:
392
+ """枚举列:取值与它们的中文名(`TextChoices` 在 `_meta` 里只剩这两样)。"""
393
+ items = _choice_items(field)
394
+ if not items:
365
395
  return None
366
- values = [str(value) for value, _ in choices]
367
- labels = {str(value): str(label) for value, label in choices}
396
+ values = [str(value) for value, _label in items]
397
+ labels = {str(value): str(label) for value, label in items}
368
398
  return {'values': values, 'labels': labels}
369
399
 
370
400
 
401
+ def _enum_ref(field: Any, enums: dict[str, dict[str, Any]]) -> str | None:
402
+ """这个字段用的是模型上哪个枚举类(`Order.Status` ⇒ 回 `'Order.Status'`);认不出就 `None`。
403
+
404
+ 🔴 为什么要它:作者在块里写的是 `Order.Status.OPEN`(类属性),而字段那一层只剩「取值 → 中文名」——
405
+ 导出里不写类名,作者就只能照值写 `Order.Status.open`,而那种写法在**保存时的引用深检**里
406
+ 会被判 `missing`(`deepcheck._resolve()` 是按类属性往下走的)—— 导出与深检因此对不上。
407
+ 🔴 匹配只按**取值那一批**(`_choice_items` 那一份归一化):字段可以只取枚举的一个子集,
408
+ 所以"字段取值 ⊆ 枚举成员"也算命中 —— 但**两个类都能容下时必须都不认**(回 `None`):
409
+ 挑一个猜错的类比不写更坏(作者会照着一个假的类名写)。
410
+ """
411
+ values = [str(value) for value, _label in _choice_items(field)]
412
+ if not values:
413
+ return None
414
+ wanted = set(values)
415
+ hits = [
416
+ name
417
+ for name, item in enums.items()
418
+ if wanted <= {str(member['value']) for member in item['members']}
419
+ ]
420
+ return hits[0] if len(hits) == 1 else None
421
+
422
+
423
+ def _enum_name(value: Any) -> str:
424
+ """枚举类的名字(Django 的 `QualifiedName` 是 `str` 的子类 ⇒ 这里给的就是 `Order.Status`)。"""
425
+ return str(getattr(value, '__qualname__', None) or getattr(value, '__name__', '') or '').strip()
426
+
427
+
428
+ def _enums_of(model: Any) -> dict[str, dict[str, Any]]:
429
+ """这张表的类体里定义的那几个枚举类:`{'Order.Status': {'labels', 'members'}}`。
430
+
431
+ 🔴 **必须扫模型类体**(`vars(model)`):`field` 上没有任何属性回指那个枚举类(见 `_choice_items`),
432
+ 而块作者在 `backend_open.py` 里用的正是类属性(`Order.Status.OPEN`)—— 导出要写的就是这个名字。
433
+ 🔴 判据用 `models.Choices`(基类),**不是**猜 `__members__`:`TextChoices` / `IntegerChoices`
434
+ 都是它的子类,而写死的 `choices` 列表(`[('a', '甲')]`)**不是** —— 那种字段照旧只有取值。
435
+ 🔴 用 `vars()` 而不是 `dir()`:`dir()` 会把 django 塞给类的那些东西一起列出来,而 `vars()` 就是
436
+ 这个模型**自己**写了什么(继承来的枚举类不属于这张表)。
437
+ 🔴 **全部收进来**,包括一个字段都没用到的:导出是"清单",不是"用到了才给"(少写一格信息
438
+ 比多写一格更坏 —— 作者是拿它当字典查的)。
439
+ """
440
+ from django.db import models
441
+
442
+ out: dict[str, dict[str, Any]] = {}
443
+ for value in vars(model).values():
444
+ if not isinstance(value, type) or not issubclass(value, models.Choices) or value is models.Choices:
445
+ continue
446
+ name = _enum_name(value)
447
+ if not name:
448
+ continue
449
+ out[name] = {
450
+ 'labels': {str(member.value): str(member.label) for member in value},
451
+ 'members': [{'name': str(member.name), 'value': str(member.value)} for member in value],
452
+ }
453
+ return out
454
+
455
+
371
456
  def payload() -> dict[str, Any]:
372
457
  """画布整包:当前形状 + 与 git 那份比出来的三态 + 头部那行 git 信息 + 布局。
373
458
 
@@ -387,9 +387,13 @@ function svgEl(tag, attrs = {}) {
387
387
  */
388
388
  function boundaryLayer(bounds) {
389
389
  const group = svgEl('g', { class: 'boundary-layer' });
390
- // 🔴 边界与总线要**向上下无限延展**(用户明确要的:「左侧边界应该是向上也是无限延展」):
391
- // 上下各多加 `BOUNDARY_SPAN`,拖到哪、缩到多小,它们都还在 —— 不跟着内容高度走。
392
- const top = -BOUNDARY_SPAN;
390
+ /* 🔴 边界 / 总线 / 那块淡色底:**只向下延展,不向上**(用户明确要的:
391
+ * 「向上其实不需要无限延展……只要向下和向右延展即可」)。
392
+ * 上端钉在**内容顶端之上一点点**(`bounds.top` 里已经留了标签的位置)——
393
+ * 上面本来就没有卡片,再画上去只是一条没意义的虚线加一大块灰底。
394
+ * 下端仍留 `BOUNDARY_SPAN`:往下是真会继续长的(表一张张往下排),
395
+ * 拖下去、缩下去时它不能"中途断掉"。 */
396
+ const top = bounds ? bounds.top : 0;
393
397
  const bottom = (bounds ? bounds.top + bounds.height : canvas.clientHeight || 900) + BOUNDARY_SPAN;
394
398
  // 边界**左边**那一块:没有内容、也过不去,铺一层很淡的底把它标出来。
395
399
  group.append(svgEl('rect', {
@@ -401,8 +405,8 @@ function boundaryLayer(bounds) {
401
405
  group.append(svgEl('line', {
402
406
  x1: RAIL_X, y1: top, x2: RAIL_X, y2: bottom, class: 'boundary-rail',
403
407
  }));
404
- // ⚠️ 标签**不能**跟着 `top` 跑到 -20000 去:它们钉在内容最上面那张卡片之上(一眼能找到边界)。
405
- const labelY = (bounds ? bounds.top : 0) + 16;
408
+ // 两个标签钉在**上端**(也就是内容最上面那张卡片之上),一眼能看到边界是什么。
409
+ const labelY = top + 16;
406
410
  const edge = svgEl('text', { x: BOUNDARY_X + 8, y: labelY, class: 'boundary-label' });
407
411
  edge.textContent = '边界(左边没有内容)';
408
412
  const rail = svgEl('text', { x: RAIL_X + 8, y: labelY + 16, class: 'boundary-label is-rail' });
@@ -7,7 +7,7 @@
7
7
  <!-- ⚠️ `?v=` 是**给浏览器的缓存换钥匙**:这两个静态件在"这一页是公开的"之后尤其容易被缓存住,
8
8
  改了正文而人不刷新(或刷新了也还是旧的),症状是"代码明明改了、页面却没变"(真踩过)。
9
9
  改这两个文件时把版本号往上加一位。 -->
10
- <link rel="stylesheet" href="canvas/asset/canvas.css?v=48">
10
+ <link rel="stylesheet" href="canvas/asset/canvas.css?v=49">
11
11
  </head>
12
12
  <body>
13
13
  <header class="bar">
@@ -52,6 +52,6 @@
52
52
  拖动表可排版(拖完自动存);空白处按住拖 = 平移,Ctrl + 滚轮 = 缩放;
53
53
  点表标题展开备注与关联,点字段行展开枚举取值 / 文件去向 / 关联目标,点线上的关联键名看它的备注。
54
54
  </p>
55
- <script src="canvas/asset/canvas.js?v=48"></script>
55
+ <script src="canvas/asset/canvas.js?v=49"></script>
56
56
  </body>
57
57
  </html>
@@ -158,8 +158,54 @@ def _api_lines() -> list[str]:
158
158
  ]
159
159
 
160
160
 
161
+ def _enum_short(text: Any) -> str:
162
+ """枚举引用的**最后一段**(`Order.Status` ⇒ `Status`)。
163
+
164
+ 🔴 类名要写在 `class Order:` 的类体里(`Status = Status`),写成 `Order.Status = Order.Status`
165
+ 的话那个类属性就变成一个字符串注解了 —— 而作者真正用的是 `Order.Status.OPEN`。
166
+ """
167
+ return str(text or '').split('.')[-1]
168
+
169
+
170
+ def _enum_classes(table: dict[str, Any], used: set[str]) -> list[str]:
171
+ """这张表自己定义的那几个枚举类(**类体**写法,作者照它写 `Order.Status.OPEN`)。
172
+
173
+ 🔴 为什么非要有这一块:字段那一行只剩「取值 → 中文名」,**类名与成员名一个都没有** ——
174
+ 作者照值写 `Order.Status.open`,而保存时的引用深检(`deepcheck._resolve()`)是按类属性
175
+ 往下走的,当场判 `missing`。导出与深检必须说同一件事。
176
+ 🔴 `used` 是"真的有字段在用它"的那几个:一个都没用到的也照写(导出是清单,不是用到了才给),
177
+ 但那几行挂一句说明,免得人以为漏了。
178
+ """
179
+ out: list[str] = []
180
+ for name, item in (table.get('enums') or {}).items():
181
+ short = _enum_short(name)
182
+ members = item.get('members') or []
183
+ labels = item.get('labels') or {}
184
+ if not short or not members:
185
+ continue
186
+ out.append(f' class {short}:')
187
+ if name not in used:
188
+ out.append(' # ⚠️ 这张表里现在没有字段在用它(照旧写出来:它是可以用到的取值集合)')
189
+ for member in members:
190
+ member_name = str(member.get('name') or '')
191
+ if not member_name:
192
+ continue
193
+ value = str(member.get('value'))
194
+ label = _flat(labels.get(value)) or value
195
+ out.append(f' {member_name} = {value!r} # {label}')
196
+ out.append('')
197
+ if out:
198
+ out.insert(0, ' # ── 枚举:这些取值按**类属性**用(`Order.Status.OPEN`),不是 `Order.Status.open` ──')
199
+ return out
200
+
201
+
161
202
  def _field_lines(field: dict[str, Any]) -> str:
162
- """一个模型字段那一行(类型 + 一行备注)。关联字段的类型是目标模型的名字。"""
203
+ """一个模型字段那一行(类型 + 一行备注)。关联字段的类型是目标模型的名字。
204
+
205
+ 🔴 枚举字段这一行的判据:**类型写 `str`(库里就是一列 varchar),可用取值写成类属性**。
206
+ 注释里给全名(`Order.Status`,有的话)与「成员名=取值=中文名」—— 作者照着写就同时过
207
+ 编辑器的补全与保存时的引用深检。
208
+ """
163
209
  kind = str(field.get('kind') or '')
164
210
  name = str(field.get('name') or '')
165
211
  if not name:
@@ -188,7 +234,19 @@ def _field_lines(field: dict[str, Any]) -> str:
188
234
  values = enum.get('values') or []
189
235
  if values:
190
236
  labels = enum.get('labels') or {}
191
- notes.append('取值:' + ' / '.join(f'{value}={_flat(labels.get(value)) or value}' for value in values))
237
+ # 🔴 成员名(`OPEN`)与取值(`open`)是**两件事**:值能当字符串存,而代码里要用的是成员名。
238
+ # 形状里有 `enumRef`(类名)与那张表的 `enums`(成员表)时,就把成员名一起摆出来。
239
+ enum_ref = str(field.get('enumRef') or '')
240
+ members = ((field.get('_enums') or {}).get(enum_ref) or {}).get('members') or []
241
+ by_value = {str(member.get('value')): str(member.get('name') or '') for member in members}
242
+ short = _enum_short(enum_ref)
243
+ where = f'{short}.' if short else ''
244
+ notes.append(
245
+ '取值:'
246
+ + ' / '.join(
247
+ f'{where}{by_value.get(value) or value}={_flat(labels.get(value)) or value}' for value in values
248
+ )
249
+ )
192
250
  comment = f' # {" · ".join(notes)}' if notes else ''
193
251
  return f' {name}: {annotation}{comment}'
194
252
 
@@ -214,7 +272,7 @@ def _relation_lines(table: dict[str, Any]) -> list[str]:
214
272
 
215
273
 
216
274
  def _table_block(table: dict[str, Any]) -> list[str]:
217
- """一张表那一块(类名 / 备注 / 真实 import / 字段 / 关联)。"""
275
+ """一张表那一块(类名 / 备注 / 真实 import / 枚举类 / 字段 / 关联)。"""
218
276
  name = str(table.get('name') or '')
219
277
  module = str(table.get('module') or '')
220
278
  if not name:
@@ -224,8 +282,14 @@ def _table_block(table: dict[str, Any]) -> list[str]:
224
282
  out = [f'class {name}:', f' """{brief}({where})"""' if brief else f' """{where}"""']
225
283
  if module:
226
284
  out.append(f' # 一般这样 import:from {module}.models import {name}')
227
- for field in table.get('fields') or []:
228
- line = _field_lines(field if isinstance(field, dict) else {})
285
+ enums = table.get('enums') or {}
286
+ fields = [field for field in (table.get('fields') or []) if isinstance(field, dict)]
287
+ # 🔴 枚举类与字段那一半**必须对上**:`_field_lines` 要拿成员表把「取值 → 成员名」写全,
288
+ # 而成员表在 table 那一层。传进去比让每个字段自己去找一份可靠(也就不会有第二份匹配逻辑)。
289
+ used = {str(field.get('enumRef') or '') for field in fields if field.get('enumRef')}
290
+ out.extend(_enum_classes(table, used))
291
+ for field in fields:
292
+ line = _field_lines({**field, '_enums': enums})
229
293
  if line:
230
294
  out.append(line)
231
295
  out.extend(_relation_lines(table))
@@ -233,6 +297,27 @@ def _table_block(table: dict[str, Any]) -> list[str]:
233
297
  return out
234
298
 
235
299
 
300
+ # ──────────────────────────── 开发包里的那个例子(`.example`) ────────────────────────────
301
+ #
302
+ # 🔴 这些正文是**生成**的:真源是仓库里的 `backend/example/idcard-ocr-example/`(能直接用编辑器看、
303
+ # 也真跑过),`scratch/build-idcard-example.py` 把它逐字抄成下面的常量。手抄一份的下场是"两边迟早漂";
304
+ # 而漂了没人会立刻发现 —— 所以 `tools/sdk-check-py.mjs` 里有一条**对拍**判据盯着这件事。
305
+ #
306
+ # 🔴 它不是"用到了才给":作者是拿它当**抄写模板**的(改名成 `workspace/<可读名>-<uuid>/` 就是一个块),
307
+ # 所以它必须是一份**完整、能存进库、能跑通**的包,而不是一段片段。
308
+
309
+ #: 例子在开发目录里的那一层(与 `workspace/` 平级)。
310
+ EXAMPLE_DIR = 'example'
311
+
312
+ #: 例子包里那几份文件(名字顺序就是铺出来给人看的顺序)。
313
+ EXAMPLE_FILES: tuple[tuple[str, str], ...] = (
314
+ ('README.md', '# 例子:身份证 OCR → 客户档案\n\n这个目录是开发包里的**示例**,不是某个块的代码。它演示一条完整的活:\n**前端选中一张证件照 → 交给块的后端 → 后端调阿里云 OCR → 结果回前端确认 → 存进客户档案**。\n\n- 想用它:把整个目录改名成 `workspace/<可读名>-<你自己的块 uuid>/`,把清单里的 `id` 换成那个 uuid,\n 然后回管理台「读回改动」→ 勾上它 → 保存。**什么都不用先装**(除了下面那三行配置)。\n- 它对应 `orders` 那张客户表的四列:`id_number` / `gender` / `birthday` / `address`\n (`Customer` 上那四列本来就是给身份证 OCR 留的落点)。\n\n## 一、要配的三项(缺一项块会点名说缺哪个)\n\n块**只能**读带 `BLOCK_` 前缀的配置(前缀会被剥掉,所以块里写 `ctx.config[\'OCR_ENDPOINT\']`):\n\n```dotenv\nBLOCK_OCR_ENDPOINT=ocr-api.cn-hangzhou.aliyuncs.com\nBLOCK_OCR_ACCESS_KEY_ID=<RAM 用户的 AccessKey ID>\nBLOCK_OCR_ACCESS_KEY_SECRET=<RAM 用户的 AccessKey Secret>\n```\n\n- 这三项加在**项目**的 `.env` 里(不是块的文件夹里),改完要重启服务。\n- 那个 RAM 用户要**有 OCR 的权限**,并且阿里云控制台上要**开通文字识别服务** —— 没开通时\n 报的是 `Forbidden` 之类,看着像"密钥不对",其实是服务没开。\n- 块里**永远拿不到**别的环境变量:只有 `BLOCK_` 这一族会进 `ctx.config`。\n\n依赖:后端要用阿里云 OCR 的官方 Python SDK(`pip install alibabacloud_ocr_api20210707`)。\n\n## 二、为什么用官方 SDK,而不是自己拼签名\n\n这一条是**实测**出来的,值得记一笔:手写 RPC 签名(HMAC-SHA1 + percent-编码)我可以让它\n**通过鉴权**,但**图片传不进去** —— 同一账号、同一张图:\n\n| 调用方式 | 服务端的回答 | 意思 |\n|---|---|---|\n| 手写签名 + base64 字符串 | `imageUrlOrBodyEmpty` | 服务端**根本没读到图** |\n| 官方 SDK,`body=<字节>` | `illegalImageContent` | 读到了,只是那张图不是身份证 |\n\n差异在请求类里:`RecognizeIdcardRequest.body` 是**小写**名字、而且是**二进制流**,不是 base64 字符串。\n自己拼那一套还要同时算对"签名用的编码"与"正文用的编码"两份(它们**不是同一份**,我在这上面来回错了三次),\n任何一个参数改一下都可能悄悄失效 —— 不值当。**照 SDK 的形状写**。\n\n## 三、两个刻意的设计\n\n1. **探针不会真的去打一次 OCR。** 保存与体检时每个 handler 会被真的跑一遍(默认空参数),\n 所以 `recognize()` 的第一句就是"没给我图片 ⇒ BadRequest"。少了这一句,每存一次块都会白打一次\n OCR,打不通还会把块判成"体检不通过"。\n2. **识别与落库分成两个接口。** `recognize()` 只读、`save_customer()` 才写(而且声明了权限)。\n OCR 会认错字,让人**看一眼再存**比"识别完直接落库"稳。\n\n## 四、改造成你自己的\n\n| 想改什么 | 改哪里 |\n|---|---|\n| 换成别的证件 / 别的 OCR 接口 | `backend_open.py` 的 `recognize_idcard()` 与 `parse_idcard()` |\n| 落进别的表 / 别的列 | `save_customer()` 里那张 `Customer` 与那几个键 |\n| 图片不走 base64(比如先传对象存储) | 前端把上传拿到的 URL 传过来,后端改用 SDK 的 `url=...` 参数 |\n| 出生日期要真的当日期用 | `birthday` 现在是"OCR 怎么念就怎么存"的文本;转 `DateField` 要自己处理各种念法 |\n'),
315
+ ('index.open.html', '<!doctype html>\n<!-- 身份证 OCR 案例块的前端:选一张身份证照片 → 识别 → 看一眼 → 存进客户档案。\n 🔴 这块 iframe 是不透明源的(没有 allow-same-origin)⇒ 拿不到宿主的登录态、读不到 cookie。\n 要当前用户 / 跳页 / 调自己的后端,一律走宿主注入的那座桥(`magicBlock` / `magicBackendApi`)。\n 🔴 图片走 base64 而不是 multipart 上传:桥那一步就是把参数当 JSON 请求正文送过去的,\n 没有 multipart 这条路(示例里也不该假装有)。 -->\n<html lang="zh">\n<head>\n <meta charset="utf-8">\n <title>身份证 OCR 建档</title>\n <style>\n body { margin: 0; padding: 12px; font: 14px/1.7 system-ui, sans-serif; color: #1f2937; }\n fieldset { margin: 0 0 10px; padding: 8px 10px; border: 1px solid #e5e7eb; border-radius: 8px; }\n legend { padding: 0 4px; color: #6b7280; }\n label { display: inline-block; min-width: 4.5em; color: #6b7280; }\n input[type=text] { width: min(420px, 70vw); padding: 3px 6px; border: 1px solid #d1d5db; border-radius: 6px; }\n button { padding: 3px 10px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; cursor: pointer; }\n button[disabled] { opacity: .5; cursor: default; }\n .muted { color: #6b7280; }\n .bad { color: #b91c1c; }\n img { max-height: 90px; border-radius: 6px; outline: 1px solid #e5e7eb; }\n </style>\n</head>\n<body>\n <p class="muted" id="who">正在确认身份…</p>\n\n <fieldset>\n <legend>① 选一张身份证正面</legend>\n <input type="file" id="shot" accept="image/*">\n <img id="thumb" alt="" hidden>\n <button id="run" disabled>识别</button>\n <span class="muted" id="stage"></span>\n </fieldset>\n\n <fieldset id="result" hidden>\n <legend>② 识别结果(**看一眼再存**)</legend>\n <p><label>姓名</label><input type="text" id="f-name"></p>\n <p><label>身份证号</label><input type="text" id="f-id"></p>\n <p><label>性别</label><input type="text" id="f-sex"></p>\n <p><label>出生</label><input type="text" id="f-birth"></p>\n <p><label>住址</label><input type="text" id="f-addr"></p>\n <button id="save">存进客户档案</button>\n <span class="muted" id="saved"></span>\n </fieldset>\n\n <p class="bad" id="error"></p>\n\n <script>\n /* 🔴 桥有两种用法都在这里:\n · `magicBackendApi.<类>.<方法>` → 打到你自己的 backend_open.py(路由名 = 类名.方法名)\n · `magicBlock.getMe()` → 当前用户(认人只认它,别从请求参数里取) */\n const byId = (id) => document.getElementById(id)\n const FIELDS = { name: \'f-name\', id_number: \'f-id\', gender: \'f-sex\', birthday: \'f-birth\', address: \'f-addr\' }\n let picked = \'\'\n\n function say(text, bad) {\n byId(\'error\').textContent = bad ? text : \'\'\n byId(\'stage\').textContent = bad ? \'\' : text\n }\n\n /* 把选中的文件读成 base64(**不带** `data:image/png;base64,` 那一段前缀:\n 后端要的是能被 b64decode 解得开的那部分)。 */\n function readAsBase64(file) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader()\n reader.onerror = () => reject(new Error(\'这个文件读不出来\'))\n reader.onload = () => resolve(String(reader.result).split(\',\')[1] || \'\')\n reader.readAsDataURL(file)\n })\n }\n\n byId(\'shot\').onchange = async (event) => {\n const file = event.target.files && event.target.files[0]\n byId(\'result\').hidden = true\n byId(\'saved\').textContent = \'\'\n if (!file) { picked = \'\'; byId(\'run\').disabled = true; return }\n try {\n picked = await readAsBase64(file)\n byId(\'thumb\').src = \'data:image/*;base64,\' + picked\n byId(\'thumb\').hidden = false\n byId(\'run\').disabled = false\n say(\'选好了,点「识别」\')\n } catch (error) {\n say(error.message, true)\n }\n }\n\n byId(\'run\').onclick = async () => {\n byId(\'run\').disabled = true\n say(\'识别中…(服务端在调 OCR)\')\n try {\n const data = await magicBackendApi.IdCardOcrBlock.recognize({ image: picked })\n for (const [key, id] of Object.entries(FIELDS)) byId(id).value = data.fields[key] || \'\'\n byId(\'result\').hidden = false\n say(\'识别完了:确认无误再存(识别可能出错,所以不自动落库)\')\n } catch (error) {\n /* 失败是 rejected Promise,`message` 就是后端抛的中文原话。 */\n say(error.message, true)\n } finally {\n byId(\'run\').disabled = false\n }\n }\n\n byId(\'save\').onclick = async () => {\n const fields = {}\n for (const [key, id] of Object.entries(FIELDS)) fields[key] = byId(id).value\n byId(\'save\').disabled = true\n try {\n const data = await magicBackendApi.IdCardOcrBlock.save_customer({ fields })\n byId(\'saved\').textContent = data.created ? `已新建档案 #${data.id}` : `已更新档案 #${data.id}`\n } catch (error) {\n say(error.message, true)\n } finally {\n byId(\'save\').disabled = false\n }\n }\n\n magicBlock.getMe()\n .then((me) => { byId(\'who\').textContent = \'当前用户:\' + (me.name || me.id) })\n .catch((error) => { byId(\'who\').textContent = \'\'; say(error.message, true) })\n </script>\n</body>\n</html>\n'),
316
+ ('magic-block.open.yaml', '# 身份证 OCR 案例 —— 把一张证件照变成一条客户档案\n#\n# 这个目录是**示例**,不是某个块的代码:\n# ① 把它改名成 `workspace/<可读名>-<你自己的块 uuid>/`;\n# ② 把下面的 id 换成那个 uuid(要与页面里挂载时写死的那个一致);\n# ③ 回管理台「读回改动」→ 勾上它 → 保存。\n#\n# 🔴 四个键全部必填,只有这四个:id / module / name / brief\nid: 2f7c1a44-6b1e-4f3d-9a10-5c8e7d2b9042\nmodule: orders\nname: 身份证 OCR 建档\nbrief: 扫一张身份证正面,识别出姓名/证件号/性别/出生/住址,确认后存进客户档案。\n'),
317
+ ('backend_open.py', '"""身份证 OCR 案例块的后端:图片 → 阿里云 OCR → 客户档案。\n\n🔴 这是 magic block 开发包里的**示例**(导出时铺在开发目录根上的 `example/`):\n 它不是某个块的代码,是"照它抄一个自己的块"用的。抄法与配置见同目录的 `README.md`。\n\n🔴 **为什么长成这个样子**(块的 `backend_open.py` 有三条硬判据,撞了两遍才知道):\n ① **模块级只许 import / class / docstring / `__all__`** —— 赋值与函数一律不行;\n ② **类体里只许方法**,而**每个方法都必须是路由**:带 `@MagicBackendAPI`、同步、签名恰好\n `(self, ctx, params)`。连 `__init__` 都过不了装饰器那一关(判据是先查有没有装饰器),\n 所以**类变量与 `__init__` 都别写**;\n ③ 于是:**常量写在用到它的方法体里**,辅助逻辑写成方法体里的**嵌套函数**(那里面随便写)。\n 看着有点重复,但这是唯一真能存进库的形状 —— 别跟判据较劲。\n\n🔴 **为什么用官方 SDK 而不是手写签名**(实测过,很值得记一笔):\n 手写 RPC 签名(HMAC-SHA1 + percent-编码)能过鉴权,但**图片传不进去** —— 同一账号、同一张图:\n · 手写签名 + base64 字符串 ⇒ `imageUrlOrBodyEmpty`(服务端根本没读到图);\n · 官方 SDK 且 `body=<字节>` ⇒ `illegalImageContent`(读到了,只是那张图不是身份证)。\n 差异在请求类里:`RecognizeIdcardRequest.body` 是**小写**名字、且是**二进制流**。\n 自己拼还要同时算对"签名用的编码"与"正文用的编码"两份(它们不是同一份),不值得。\n\n🔴 **探针不会把它跑成一次真调用**:保存 / 体检时每个 handler 会被真的跑一遍(默认空参数),\n 所以 `recognize()` 第一句就是"没给我图片 ⇒ BadRequest"。少了这一句,每存一次块都会白打一次\n OCR,而且打不通还会把块判成"体检不通过"。\n"""\n\nfrom labull_framework.magic_block.sdk import MagicBackendAPI, errors\n\n\nclass IdCardOcrBlock:\n """身份证 OCR 块:识别 → 落进客户档案。"""\n\n @MagicBackendAPI()\n def recognize(self, ctx, params):\n """识别一张身份证正面:`params[\'image\']` 是 base64(前端 `FileReader` 直接给的那种)。\n\n 只**读**:不写库,让用户在前端看一眼识别结果再决定存不存。\n """\n import base64\n import binascii\n import json\n\n # 🔴 常量写在方法体里(模块级与类体里都不许有赋值,见文件顶上第 ② 条)。\n # 这三项就是 `BLOCK_` 前缀那一族:`BLOCK_OCR_ENDPOINT` ⇒ `ctx.config[\'OCR_ENDPOINT\']`。\n keys = (\'OCR_ENDPOINT\', \'OCR_ACCESS_KEY_ID\', \'OCR_ACCESS_KEY_SECRET\')\n\n def config_of():\n """从 `ctx.config` 取三项配置;缺哪一项就**点名**哪一项(不猜、不静默)。"""\n missing = [key for key in keys if not str(ctx.config.get(key) or \'\').strip()]\n if missing:\n raise errors.BadRequest(\n \'这个块要用 OCR,但服务器上没配:\'\n + \' / \'.join(f\'BLOCK_{key}\' for key in missing)\n + \'(三个一起配;前缀 BLOCK_ 会被剥掉当键进 ctx.config)\'\n )\n return {key: str(ctx.config[key]).strip() for key in keys}\n\n def call_ocr(image):\n """调一次「身份证正面识别」,回服务端原始报文(`Data` 是一段 JSON 字符串)。\n\n 🔴 `body` 要传**字节**(二进制流),不是 base64 —— 整个案例最容易写错的一处。\n 🔴 SDK 的失败是**异常**(`ClientException`,报文里带着服务端的 Code 与 request id),\n 而平台只认 `errors.*` 那六个:不在这里翻一下,用户看到的就是一句干巴巴的\n "服务端出错"(500),而真正有用的那句(图片太小 / 没开服务 / 鉴权失败)被丢了。\n """\n from alibabacloud_ocr_api20210707.client import Client\n from alibabacloud_ocr_api20210707 import models as ocr_models\n from alibabacloud_tea_openapi import models as open_api_models\n\n config = config_of()\n client = Client(open_api_models.Config(\n access_key_id=config[\'OCR_ACCESS_KEY_ID\'],\n access_key_secret=config[\'OCR_ACCESS_KEY_SECRET\'],\n endpoint=config[\'OCR_ENDPOINT\'],\n ))\n try:\n response = client.recognize_idcard(ocr_models.RecognizeIdcardRequest(body=image))\n except Exception as exc: # noqa: BLE001 —— 这一层就是要**把所有失败都翻成人话**\n raise errors.BadRequest(f\'OCR 调用失败:{exc}\')\n return (response.body.to_map() or {}) if hasattr(response.body, \'to_map\') else {}\n\n image_base64 = str(params.get(\'image\') or \'\')\n if not image_base64:\n # 🔴 这一句同时管两件事:参数校验,以及"探针不会真的去打 OCR"(见文件顶上那段)。\n raise errors.BadRequest(\'没有给我图片:params.image 要放 base64 的内容\')\n try:\n image = base64.b64decode(image_base64, validate=True)\n except (binascii.Error, ValueError):\n raise errors.BadRequest(\'params.image 不是合法的 base64\')\n if not image:\n raise errors.BadRequest(\'params.image 解出来是空的\')\n\n payload = call_ocr(image)\n code = str(payload.get(\'Code\') or \'\')\n if code:\n # 服务端的原话原样带出去:阿里云报的错(没开服务 / 图片不对)比我们编的准。\n raise errors.BadRequest(f\'OCR 说:{payload.get("Message") or code}({code})\')\n\n raw = payload.get(\'Data\') or \'{}\'\n try:\n data = json.loads(raw) if isinstance(raw, str) else (raw or {})\n except ValueError:\n raise errors.BadRequest(\'OCR 回来了,但 Data 不是 JSON:\' + str(raw)[:200])\n # 🔴 **OCR 怎么念就怎么存**:出生日期原样给前端(可能是「2000年5月21日」),\n # 这里不解析成日期 —— `Customer.birthday` 本来就是文本列。\n return {\n \'fields\': {\n \'name\': str(data.get(\'Name\') or \'\').strip(),\n \'id_number\': str(data.get(\'IdNumber\') or \'\').strip(),\n \'gender\': str(data.get(\'Sex\') or \'\').strip(),\n \'birthday\': str(data.get(\'BirthDate\') or \'\').strip(),\n \'address\': str(data.get(\'Address\') or \'\').strip(),\n },\n \'raw\': payload,\n }\n\n @MagicBackendAPI(permission=\'orders.add_customer\')\n def save_customer(self, ctx, params):\n """把识别出来的几列存进客户档案:按身份证号找人,有就更新、没有就新建。\n\n 🔴 声明了权限:写业务表不该"任何登录用户都能调"。\n """\n from orders.models import Customer\n\n fields = params.get(\'fields\') if isinstance(params.get(\'fields\'), dict) else {}\n # 🔴 只认这几列:块传上来的东西一律当**不可信**(前端能改,请求也能手拼)。\n values = {\n \'name\': str(fields.get(\'name\') or \'\').strip(),\n \'id_number\': str(fields.get(\'id_number\') or \'\').strip(),\n \'gender\': str(fields.get(\'gender\') or \'\').strip(),\n \'birthday\': str(fields.get(\'birthday\') or \'\').strip(),\n \'address\': str(fields.get(\'address\') or \'\').strip(),\n }\n if not values[\'name\']:\n raise errors.BadRequest(\'至少要有个名字才能建档\')\n\n id_number = values[\'id_number\']\n row = Customer.objects.filter(id_number=id_number).first() if id_number else None\n if row is None:\n row = Customer.objects.create(**values)\n created = True\n else:\n for key, value in values.items():\n setattr(row, key, value)\n row.save(update_fields=list(values))\n created = False\n # 🔴 回报里给主键与"新建还是更新":前端据此说一句人话(不必再查一遍)。\n return {\'id\': row.pk, \'created\': created, \'name\': row.name}\n'),
318
+ )
319
+
320
+
236
321
  def backend_lock_pyi(tables: list[dict[str, Any]] | None = None, items: tuple[EnvItem, ...] = ENV_ITEMS) -> str:
237
322
  """`backend.lock.pyi` 的正文:后端能 import 的库 + `ctx` + 环境变量 + 项目里的模型。
238
323
 
@@ -246,7 +331,7 @@ def backend_lock_pyi(tables: list[dict[str, Any]] | None = None, items: tuple[En
246
331
  ' ② 能抛的六个 HTTP 语义错误;',
247
332
  ' ③ 服务端交给 handler 的 ctx(就那 4 个键);',
248
333
  ' ④ 平台读哪些环境变量(**只有名字与说明,没有值**);',
249
- ' ⑤ 项目里现在有哪些模型、每个字段是什么、枚举有哪些取值。',
334
+ ' ⑤ 项目里现在有哪些模型、每个字段是什么、枚举有哪些**成员与取值**。',
250
335
  '',
251
336
  f'🔴 它不参与运行:块后端真正 import 的是 {sdk.LIB_SDK},模型走每张表下面那行注释的路径。',
252
337
  '🔴 块包必备件:' + ' / '.join((BLOCK_ENTRY, BLOCK_MANIFEST)) + f';块自己的后端是 {BLOCK_BACKEND_FILE}(可选)。',
@@ -296,6 +381,7 @@ def backend_lock_pyi(tables: list[dict[str, Any]] | None = None, items: tuple[En
296
381
  '# 没配的键**不存在**(先判 `"OCR_KEY" in ctx.config`),别的变量一个都不给块。',
297
382
  '',
298
383
  '# ── ⑤ 项目里的模型(照 .labull/contract.json 那份模型形状生成) ──',
384
+ '# 枚举按**类属性**用(`Order.Status.OPEN`):成员名不是取值 —— `Order.Status.open` 不存在。',
299
385
  ]
300
386
  )
301
387
  if not tables:
@@ -319,6 +405,8 @@ def dev_readme(blocks: int) -> str:
319
405
  '| 路径 | 是什么 |\n'
320
406
  '|---|---|\n'
321
407
  '| `workspace/<可读名>-<块 uuid>/` | 一个块一个文件夹,包里就是它的全部文件 |\n'
408
+ f'| `{EXAMPLE_DIR}/` | **生成物**:一个能照着抄的**完整例子**(身份证 OCR → 客户档案)。把它改名成 '
409
+ f'`workspace/<可读名>-<你的块 uuid>/` 就是一个块 |\n'
322
410
  f'| `{GUIDE}` | **生成物**:怎么改一个块(精简版开发说明,先看这一份) |\n'
323
411
  f'| `{PAGES_LOCK}` | **生成物**:Page 枚举(前端能跳哪些页面)+ 块里能调的接口 |\n'
324
412
  f'| `{THEME}` | **生成物**:这个应用正在用的 labull 主题 + 控件样式(宿主会注入每个块,这一份给你查类名/token) |\n'
@@ -447,5 +535,12 @@ def dev_guide() -> str:
447
535
  '## 改完怎么回库\n'
448
536
  '\n'
449
537
  '管理台 → 选**同一个文件夹** → 「读回改动」(只读,先给你一份"改了哪些文件"的清单)→ 勾上要存的块 → 保存。\n'
538
+ '\n'
539
+ '## 想先抄一个现成的\n'
540
+ '\n'
541
+ f'开发目录里那份 `{EXAMPLE_DIR}/` 是一个**完整、能存进库、能跑通**的例子(身份证 OCR → 客户档案):\n'
542
+ '前端选图 → 后端调 OCR → 结果回前端确认 → 落进客户表。\n'
543
+ '用法:整个目录改名成 `workspace/<可读名>-<你的块 uuid>/`、把清单里 `id` 换成那个 uuid,回管理台读回即可。\n'
544
+ f'⚠️ 它和别的那几份一样是**生成物**(下次导出会整个覆盖),而且**不会**被"读回改动"扫到。\n'
450
545
  )
451
546
 
@@ -74,9 +74,15 @@ MAX_DEPTH = 3
74
74
  MAX_FILE_BYTES = 512 * 1024
75
75
  MAX_BUNDLE_CHARS = 8 * 1024 * 1024
76
76
 
77
- #: 爬目录时跳过的目录名(依赖 / 构建产物 / 缓存)。
77
+ #: 爬目录时跳过的目录名(依赖 / 构建产物 / 缓存,以及平台自己铺的那个例子)。
78
+ #:
79
+ #: 🔴 `example` 必须在里面:它是导出的**生成物**(一个能照着抄的完整块),
80
+ #: 里面有 `index.open.html` + `magic-block.open.yaml` ⇒ 扫描器会把它当**块候选**,
81
+ #: 于是每次"读回改动"都多一条"这个 uuid 库里没有对应的块"的告警、`folders` 计数也不对(真踩过)。
82
+ #: 浏览器那一侧用的就是这份名单(`scan_rules()['skipDirs']`),所以这里加一处就够。
78
83
  SKIP_DIRS: frozenset[str] = frozenset(
79
- {'.git', '.venv', 'venv', '__pycache__', 'node_modules', 'dist', 'build', '.idea', '.vscode'}
84
+ {'.git', '.venv', 'venv', '__pycache__', 'node_modules', 'dist', 'build', '.idea', '.vscode',
85
+ devkit.EXAMPLE_DIR}
80
86
  )
81
87
 
82
88
  #: 模块名那一段的形状(与清单里 `module` 的规矩同一份)。
@@ -244,6 +250,13 @@ def package_files(rows: Iterable[Any], tables: list[dict[str, Any]] | None = Non
244
250
  files[devkit.GUIDE] = devkit.dev_guide()
245
251
  files[devkit.REQUIREMENTS] = requirements_text()
246
252
  files[devkit.README] = devkit.dev_readme(blocks)
253
+ # 🔴 一个**能照着抄的完整例子**(身份证 OCR → 客户档案):正文的真源是 `devkit.EXAMPLE_FILES`
254
+ # (生成物,与仓库里的 `backend/example/idcard-ocr-example/` 逐字一致,有对拍判据盯着)。
255
+ # 它铺在 `example/` 这一层 —— 与 `workspace/` **平级**,所以它不会被当成一个块;
256
+ # 而"读回改动"那一趟要按 `SKIP_DIRS` / `platformFiles` 跳过它(否则每次读回都会多一条
257
+ # "这个 uuid 库里没有"的告警 —— 真踩过)。
258
+ for name, body in devkit.EXAMPLE_FILES:
259
+ files[f'{devkit.EXAMPLE_DIR}/{name}'] = body
247
260
  return files
248
261
 
249
262
 
@@ -349,6 +362,10 @@ def scan_folders(upload: dict, include_missing: bool = False) -> dict:
349
362
 
350
363
  🔴 这里**看不到对方的盘**(也不需要):递上来的就是事实。配对 / 比对 / 能不能存仍然只有
351
364
  `_judge()` 这一处判据 —— 换一个入口不换规矩。
365
+ 🔴 **第二道防线**:浏览器是照 `scan_rules()['skipDirs']` 爬的,但它可能是**旧的一页**(脚本已经
366
+ 缓存住了)⇒ 名单里新加的那一层它照样会递上来。所以这里也认一次 `SKIP_DIRS`(**只看路径的
367
+ 第一段**:跳过名单是按"目录名"匹配的,`example/foo` 这种不跳)。不认的话,那些文件夹会被
368
+ 当成"库里没有的块",每次读回都平白多一条告警(真踩过)。
352
369
  """
353
370
  given = upload if isinstance(upload, dict) else {}
354
371
  collect = _Collect()
@@ -363,12 +380,13 @@ def scan_folders(upload: dict, include_missing: bool = False) -> dict:
363
380
  if not isinstance(item, dict):
364
381
  continue
365
382
  where = _posix(str(item.get('where') or ''))
383
+ segments = [seg for seg in where.split('/') if seg]
384
+ if segments and segments[0] in SKIP_DIRS:
385
+ continue
366
386
  raw = item.get('files')
367
387
  files = {str(name): str(body) for name, body in raw.items()} if isinstance(raw, dict) else {}
368
388
  collect.files += len(files)
369
- collect.folders.append(
370
- {'abs': where, 'where': where, 'segments': [seg for seg in where.split('/') if seg], 'files': files}
371
- )
389
+ collect.folders.append({'abs': where, 'where': where, 'segments': segments, 'files': files})
372
390
  return _judge(str(given.get('root') or ''), collect, include_missing)
373
391
 
374
392
 
@@ -42,6 +42,17 @@ PLATFORM_APPS: tuple[str, ...] = (
42
42
  #: 认证后端那一个:OIDC 不是 Django 认识的密码后端,所以由我们自己提供一个。
43
43
  AUTH_BACKEND = 'labull_framework.auth.backend.OidcBackend'
44
44
 
45
+ #: **权限**后端那一个:只管"你能干什么",不管登录。
46
+ #:
47
+ #: 🔴 少了它,`user.has_perm(...)` 对**除超管以外的所有人恒为 False** —— 因为权限判据走的是
48
+ #: `AUTHENTICATION_BACKENDS` 里**实现了 `has_perm` / `get_all_permissions` 的那个后端**,
49
+ #: 而 `OidcBackend` 只回答"你是谁"(它自己的文档就是这么写的:权限一个字都不给,交给 admin)。
50
+ #: 真踩过:块路由那句 `@MagicBackendAPI(permission='orders.add_customer')` 因此变成"只许超管",
51
+ #: staff 即使被显式授了权也一律 403;`ctx.user.permissions` 也永远是空表。
52
+ #: 🔴 它排在 `OidcBackend` **后面**:登录仍然只认 OIDC(`ModelBackend.authenticate()` 要
53
+ #: `username` + `password`,我们没有密码这一路,它只会回 `None`);权限则归它算。
54
+ PERMISSION_BACKEND = 'django.contrib.auth.backends.ModelBackend'
55
+
45
56
  #: 跨源允许的方法与头(前端在另一个端口上,写操作还带自定义 CSRF 头 ⇒ 浏览器必发预检)。
46
57
  #:
47
58
  #: 🔴 **`x-labull-csrf` 必须在这里**(就是下面 `CSRF_HEADER_NAME` 对应那一枚)。两处是一对:
@@ -176,8 +187,10 @@ def apply_platform_settings(ns: dict) -> None:
176
187
  middleware.append(entry)
177
188
  ns['MIDDLEWARE'] = middleware
178
189
 
179
- # 🔴 认证后端只有我们那一个:OIDC 是系统唯一的登录方式,密码登录那条路不存在。
180
- ns['AUTHENTICATION_BACKENDS'] = [AUTH_BACKEND]
190
+ # 🔴 登录只有 OIDC 那一条(`OidcBackend`),权限交给 `ModelBackend` —— **两个都要**:
191
+ # 只挂 `OidcBackend` 时 `has_perm()` 对非超管恒为 False(组与权限形同虚设),
192
+ # 只挂 `ModelBackend` 时 OIDC 登不进来。理由与真踩过的症状见顶上 `PERMISSION_BACKEND`。
193
+ ns['AUTHENTICATION_BACKENDS'] = [AUTH_BACKEND, PERMISSION_BACKEND]
181
194
 
182
195
  # 🔴 会话与 CSRF 的**名字是跨语言契约**:前端按字面名读 cookie、按字面名发头
183
196
  # (`frontend/auth/src/index.ts` 的 `CSRF_COOKIE` / `CSRF_HEADER`)。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 0.1.4.2
3
+ Version: 0.1.4.4
4
4
  Summary: Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks
5
5
  Author: Labull Framework
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "labull-framework"
7
- version = "0.1.4.2"
7
+ version = "0.1.4.4"
8
8
  description = "Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks"
9
9
  readme = "README-pypi.md"
10
10
  requires-python = ">=3.10"