labull-framework 3.0.0__tar.gz → 3.0.2__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 (70) hide show
  1. {labull_framework-3.0.0/labull_framework.egg-info → labull_framework-3.0.2}/PKG-INFO +1 -1
  2. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/admin.py +1 -1
  3. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/auth/backend.py +4 -3
  4. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/auth/bootstrap.py +32 -34
  5. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/auth/views.py +5 -7
  6. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/flow.py +25 -55
  7. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/devkit.py +2 -2
  8. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/dispatch.py +1 -4
  9. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/sdk.py +2 -4
  10. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/types/workflow.py +2 -3
  11. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/views.py +20 -20
  12. labull_framework-3.0.2/labull_framework/migrations/0007_no_builtin_permissions.py +116 -0
  13. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/models.py +13 -12
  14. labull_framework-3.0.2/labull_framework/permissions.py +51 -0
  15. {labull_framework-3.0.0 → labull_framework-3.0.2/labull_framework.egg-info}/PKG-INFO +1 -1
  16. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework.egg-info/SOURCES.txt +1 -0
  17. {labull_framework-3.0.0 → labull_framework-3.0.2}/pyproject.toml +1 -1
  18. labull_framework-3.0.0/labull_framework/permissions.py +0 -101
  19. {labull_framework-3.0.0 → labull_framework-3.0.2}/LICENSE +0 -0
  20. {labull_framework-3.0.0 → labull_framework-3.0.2}/MANIFEST.in +0 -0
  21. {labull_framework-3.0.0 → labull_framework-3.0.2}/README-pypi.md +0 -0
  22. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/__init__.py +0 -0
  23. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/apps.py +0 -0
  24. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/auth/__init__.py +0 -0
  25. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/auth/sso.py +0 -0
  26. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/canvas/__init__.py +0 -0
  27. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/canvas/data.py +0 -0
  28. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/canvas/gitinfo.py +0 -0
  29. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/canvas/shape_ast.py +0 -0
  30. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/canvas/static/canvas.css +0 -0
  31. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/canvas/static/canvas.js +0 -0
  32. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/canvas/static/page.html +0 -0
  33. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/canvas/views.py +0 -0
  34. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/env.py +0 -0
  35. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/jsonio.py +0 -0
  36. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/__init__.py +0 -0
  37. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/blockcheck.py +0 -0
  38. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/blockpackage.py +0 -0
  39. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/blocktypes.py +0 -0
  40. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/deepcheck.py +0 -0
  41. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/export.py +0 -0
  42. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/health.py +0 -0
  43. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/manifest.py +0 -0
  44. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/parallel.py +0 -0
  45. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/registry.py +0 -0
  46. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/requires.py +0 -0
  47. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/service.py +0 -0
  48. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/subproc.py +0 -0
  49. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/types/__init__.py +0 -0
  50. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/types/plain.py +0 -0
  51. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/magic_block/validate.py +0 -0
  52. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/management/__init__.py +0 -0
  53. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/management/commands/__init__.py +0 -0
  54. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/management/commands/labull_blocktype.py +0 -0
  55. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/management/commands/labull_contract.py +0 -0
  56. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/management/commands/labull_requires.py +0 -0
  57. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/migrations/0001_initial.py +0 -0
  58. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/migrations/0002_remove_magicblock_prev_files_and_more.py +0 -0
  59. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/migrations/0003_magicblock_tags.py +0 -0
  60. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/migrations/0004_magicblock_detail.py +0 -0
  61. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/migrations/0005_labullrole.py +0 -0
  62. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/migrations/0006_default_groups_permissions.py +0 -0
  63. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/migrations/__init__.py +0 -0
  64. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/settings.py +0 -0
  65. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/urls.py +0 -0
  66. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework/virtual.py +0 -0
  67. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework.egg-info/dependency_links.txt +0 -0
  68. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework.egg-info/requires.txt +0 -0
  69. {labull_framework-3.0.0 → labull_framework-3.0.2}/labull_framework.egg-info/top_level.txt +0 -0
  70. {labull_framework-3.0.0 → labull_framework-3.0.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 3.0.0
3
+ Version: 3.0.2
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
@@ -23,7 +23,7 @@ class UserAdmin(admin.ModelAdmin):
23
23
 
24
24
  ⚠️ `username` 装的是 Authing 的 `sub`(登录时同步进来的),所以它**只读** ——
25
25
  改它等于把这一行与那个身份切开。
26
- 🔴 **"是不是平台管理员" = 有没有 `labull_framework.edit_magic_block`**(`permissions.is_admin()`)——
26
+ 🔴 **"能不能编辑块" = 有没有 `labull_framework.edit_magic_block`**(`permissions.EDIT_PERMISSION`)——
27
27
  直接给在这个人身上、或者他所在的某个权限包里都算。这里不摆 `is_staff`:
28
28
  那一列只留给 Django 自己的 `/admin`,平台不读它。
29
29
  🔴 `groups` / `user_permissions` 两栏就是全部授权入口(组是权限包,权限码才是契约)。
@@ -5,9 +5,10 @@
5
5
  所以这个类**不碰会话**、也**不校验 token**(验签是 `sso.py` 的事,视图验完才把 claims 送进来)。
6
6
 
7
7
  🔴 **权限一个字都不给**:Django 有 `is_staff` / `is_superuser` / `Group` / `Permission`,
8
- 登录这件事只负责"你是谁";"你能干什么"只看 **`Permission`**(`app_label.codename`),
9
- 组只是权限包。新建的用户布尔位是 `is_staff=False` / `is_superuser=False` / `is_active=True`
10
- (`is_staff` 只留给 Django 自己的 `/admin`;平台的管理员看 `edit_magic_block` 这个权限)。
8
+ 登录这件事只负责"你是谁";业务能力只看 **`Permission`**(`app_label.codename`),组只是权限包。
9
+ 新建的用户布尔位是 `is_staff=False` / `is_superuser=False` / `is_active=True` ——
10
+ `is_staff` 就是"能不能编辑魔法块"那一档(`BOOTSTRAP_ADMIN_WORK_IDS` 同步它),
11
+ 框架不为此预置任何 permission。
11
12
  🔴 **新用户不自动进任何组**(框架不装 `post_save` / `user_logged_in`):新来的人默认属于哪个组是
12
13
  **项目的数据/策略**,由项目自己决定(示例项目给了一份可复制的实现:
13
14
  `backend/example/canvasdemo/signals.py`)。老用户的组与权限**不动**(归管理员)。
@@ -1,21 +1,19 @@
1
1
  """启动时按 `BOOTSTRAP_ADMIN_WORK_IDS` **重设**谁是管理员 —— **这是"管理员名单"的唯一入口**。
2
2
 
3
- 🔴 判据是**声明式**的:那份清单**就是**管理员名单。每次启动都拿它和**那个装管理员权限的组**
4
- (`edit_magic_block` 所在的权限包)对一遍 —— 名单里的人加进那个组,
5
- **名单之外的(以及没有工号的)一律摘掉**。于是"某人离职 / 不该再当管理员"这件事只需要从 `.env`
6
- 里删掉一个工号 + 重启,不必记得去点。
7
- 🔴 "是不是管理员"的唯一判据是 **`has_perm('labull_framework.edit_magic_block')`**
8
- (`permissions.is_admin()`)—— **不是**组名、**不是** Django 的 `is_staff`(那一列只留给
9
- Django 自己的 `/admin`)。这里之所以要找一个"组",只是因为**写**的时候要有个落点;
10
- 找法按**权限**找(`permissions.admin_group()`),所以项目把那个组改名也不影响这份名单。
11
- 🔴 **没配 / 是空 ⇒ 把所有人的管理员权限摘掉**("没配"的语义就是"管理员一个都没有")。
3
+ 🔴 判据是**声明式**的:那份清单**就是**管理员名单。每次启动都拿它和 **`is_staff`** 对一遍 ——
4
+ 名单里的人 `is_staff=True`,**名单之外的(以及没有工号的)一律 `False`**。
5
+ 于是"某人离职 / 不该再当管理员"这件事只需要从 `.env` 里删掉一个工号 + 重启,不必记得去点。
6
+ 🔴 **"能不能编辑魔法块"就是 Django 的 `is_staff` 这一列**(框架不为这件事造 permission:
7
+ `permissions` 里一个框架自带的权限都没有)。也不是某个组名 —— 组只是权限包。
8
+ 🔴 **没配 / 是空 ⇒ 把所有非超管的 `is_staff` 关掉**("没配"的语义就是"管理员一个都没有")。
12
9
  ⚠️ 这是刻意选的 **fail-closed**:`.env` 少写一行、部署时忘了带这个变量,后果是**一个管理员都没有**
13
10
  —— 而不是"上一个被删掉的人悄悄留着权限"。要救回来:把那一行补回去重启。
14
- ⚠️ 反过来说:**它不碰 `is_superuser` / 直接给某个人的权限**(那些是另一层,不归这份名单管)。
11
+ ⚠️ 反过来说:**它不碰 `is_superuser`**(那是 Django 的另一层,拥有全部权限,不归这份名单管)——
12
+ 超管默认也是 `is_staff=True`,摘掉他等于把 `/admin` 也锁了,所以那一类人**跳过**。
15
13
  🔴 清单超过 `MAX_WORK_IDS` 时**只取前 200 个**(防误粘)—— 于是第 201 个之后的人会被停掉,
16
14
  所以那一条 warning 必须显式说出来(不然症状是"名单明明写了,他就是进不去")。
17
15
  🔴 框架**不装 `post_save`**:这份名单只在启动 / 迁移之后同步一次。名单里新加的人**下一次重启**
18
- 才上岗("登录时顺手加组"那条路 3.0.0 起没有了 —— 组归管理员/项目策略)。
16
+ 才上岗("登录时顺手改身份"那条路 3.0.0 起就没有了)。
19
17
  """
20
18
 
21
19
  from __future__ import annotations
@@ -24,7 +22,6 @@ import logging
24
22
 
25
23
  from ..env import read_env
26
24
  from ..models import User
27
- from ..permissions import ADMIN_PERMISSION, admin_group
28
25
 
29
26
  logger = logging.getLogger(__name__)
30
27
 
@@ -47,14 +44,15 @@ def configured_admin_work_ids() -> list[str]:
47
44
 
48
45
 
49
46
  def sync_admin_work_ids() -> int:
50
- """把"那个装管理员权限的组"的成员重设成"只有名单里的人",回这次**真的**改了几个人(上岗 + 下岗)。
47
+ """把 `is_staff` 重设成"只有名单里的人",回这次**真的**改了几个人(上岗 + 下岗)。
51
48
 
52
49
  🔴 **永不抛**:这是启动路径,库连不上 / 表还没迁移都只该在日志里留一句 ——
53
50
  重设管理员失败没有理由让整个后端起不来。
54
- 🔴 只动**那一个组**:别的组一个字都不碰 —— 那些是项目自己的权限包。
55
- 🔴 判据是**名单**,不是"库里现在谁在组里":`work_id` 为 `NULL` 的行永远不在名单里
51
+ 🔴 只动**`is_staff` 这一列**,别的列一个字都不碰(`save(update_fields=['is_staff'])`)。
52
+ 🔴 判据是**名单**,不是"库里现在谁是 `is_staff`":`work_id` 为 `NULL` 的行永远不在名单里
56
53
  (SQL 的 `NULL IN (…)` / `NULL NOT IN (…)` 都是 unknown),所以"没有工号的人"会被摘掉 ——
57
54
  这是对的(认人只认工号),但也意味着**没有工号的人不可能当管理员**。
55
+ 🔴 **超管跳过**:`is_superuser` 的人不归这份名单管(摘掉他的 `is_staff` 会把 `/admin` 一起锁了)。
58
56
  """
59
57
  work_ids = _raw_work_ids()
60
58
  if len(work_ids) > MAX_WORK_IDS:
@@ -67,33 +65,33 @@ def sync_admin_work_ids() -> int:
67
65
  if not work_ids:
68
66
  logger.warning(
69
67
  'BOOTSTRAP_ADMIN_WORK_IDS 没配 / 是空的:按"管理员一个都没有"处理,'
70
- '会把所有人的管理员权限摘掉。要指定管理员就在 .env 里写一行 '
68
+ '会把所有非超管的 is_staff 关掉。要指定管理员就在 .env 里写一行 '
71
69
  'BOOTSTRAP_ADMIN_WORK_IDS=<工号>[,<工号>…]。'
72
70
  )
73
71
 
74
- group = admin_group()
75
- if group is None:
76
- # 迁移会建"平台管理员"那个权限包;走到这里说明库还没迁移(或权限行还没建出来),
77
- # 不在启动路径上制造第二次失败 —— 下一次启动/迁移之后它就有了。
78
- logger.error('还没有装 %s 的组(权限包):跳过管理员名单同步', ADMIN_PERMISSION)
79
- return 0
80
-
81
72
  try:
82
- wanted = list(User.objects.filter(work_id__in=work_ids))
83
- wanted_ids = {user.pk for user in wanted}
84
- current_ids = set(group.user_set.values_list('pk', flat=True))
85
- added = sorted(wanted_ids - current_ids)
86
- removed = sorted(current_ids - wanted_ids)
87
- if added:
88
- group.user_set.add(*User.objects.filter(pk__in=added))
89
- if removed:
90
- group.user_set.remove(*User.objects.filter(pk__in=removed))
73
+ listed = set(work_ids)
74
+ # 名单里的(非超管)要打开;名单之外的(非超管)要关掉。超管两类都跳过(见 docstring)。
75
+ added = [
76
+ user for user in User.objects.filter(work_id__in=work_ids, is_superuser=False)
77
+ if not user.is_staff
78
+ ]
79
+ for user in added:
80
+ user.is_staff = True
81
+ user.save(update_fields=['is_staff'])
82
+ removed = [
83
+ user
84
+ for user in User.objects.filter(is_staff=True, is_superuser=False).exclude(work_id__in=work_ids)
85
+ ]
86
+ for user in removed:
87
+ user.is_staff = False
88
+ user.save(update_fields=['is_staff'])
91
89
  except Exception as error: # noqa: BLE001 —— 见 docstring:启动路径永不抛
92
90
  logger.error('按 BOOTSTRAP_ADMIN_WORK_IDS 同步管理员失败(不影响启动):%s', error)
93
91
  return 0
94
92
  if added or removed:
95
93
  logger.info(
96
- '按 BOOTSTRAP_ADMIN_WORK_IDS 同步管理员「%s」:加进 %s 个、摘掉 %s 个(名单里有 %s 个工号)',
97
- group.name, len(added), len(removed), len(work_ids),
94
+ '按 BOOTSTRAP_ADMIN_WORK_IDS 同步管理员:打开 %s 个 is_staff、关掉 %s 个(名单里有 %s 个工号)',
95
+ len(added), len(removed), len(listed),
98
96
  )
99
97
  return len(added) + len(removed)
@@ -133,19 +133,17 @@ def user_to_dict(user) -> dict:
133
133
  而模型是 `snake_case` ⇒ 两种形状之间的转换只该有**这一处**。
134
134
  ⚠️ `work_id` 可空就原样回 `None`,不兜成空串(空串会被当成"有个空工号")。
135
135
  ⚠️ 时间只有一处实现(`jsonio.iso_utc`):别再写第二份 `isoformat().replace(…)`。
136
- 🔴 `isAdmin` = **有没有 `labull_framework.edit_magic_block`**(`permissions.is_admin()`)——
137
- 不是 Django 的 `is_staff`、也不是某个组名。
138
- 🔴 `groups` / `permissions` 是 Django 原生的那一套:**权限是契约**(`app_label.codename`),
139
- 组只是权限包(`groups` 只给人看/排查,前端不要拿组名做判断)。
136
+ 🔴 `isStaff` 就是 Django 原生的 `is_staff`:**"能不能编辑魔法块"看它**(框架不为此造 permission,
137
+ 所以 `permissions` 里一个框架自带的权限都没有)。没有 `isAdmin` 这种派生位。
138
+ 🔴 `permissions` 是**项目自己**声明的权限(`app_label.codename`,直接给的 + 经组给的);
139
+ `groups` 只是权限包的名字(只给人看/排查),**不要**拿组名做判断。
140
140
  """
141
- from ..permissions import is_admin
142
-
143
141
  return {
144
142
  'id': user.pk,
145
143
  'username': user.get_username(),
146
144
  'name': user.name,
147
145
  'workId': user.work_id,
148
- 'isAdmin': is_admin(user),
146
+ 'isStaff': bool(user.is_staff),
149
147
  'isSuperuser': user.is_superuser,
150
148
  'isActive': user.is_active,
151
149
  'groups': [group.name for group in user.groups.all()],
@@ -23,13 +23,7 @@ from dataclasses import dataclass, field
23
23
  from typing import Any, Callable, Iterable, Mapping
24
24
 
25
25
  from .magic_block.sdk import MagicBackendHTTPError
26
- from .permissions import (
27
- ADMIN_PERMISSION,
28
- PERMISSION_PATTERN,
29
- is_admin,
30
- permissions_of,
31
- split_permission,
32
- )
26
+ from .permissions import PERMISSION_PATTERN, permissions_of, split_permission
33
27
 
34
28
  # ──────────────────────────── 平台认的那些常量(唯一真源) ────────────────────────────
35
29
 
@@ -48,19 +42,19 @@ HANDLER_KINDS: tuple[str, ...] = ('fk', 'm2m')
48
42
  MOVE_KINDS: tuple[str, ...] = ('submit', 'confirm', 'reject', 'revoke', 'approve')
49
43
 
50
44
  #: `candidates` 的规则串认哪几种(D10 的白名单)。语法与运行期求值**只有一处**(见 `_rule_of()`)。
51
- #: 🔴 `admin` = `has_perm('labull_framework.edit_magic_block')`(**不是**组名、不是 `is_staff`);
52
- #: `permission:*` 是 3.0.0 起的业务写法(2.0.x 的 `role:*` / `roles:*` / `applicant_or_roles:*` 已删)。
45
+ #: 🔴 认人只有两类:单据上的人(`applicant` / `biz:` …)与**项目的权限**(`permission:*`)。
46
+ #: 「管理员」**不再是一条规则**(`admin` 已删):要"只给某些人办"就在项目里声明一条业务权限,
47
+ #: 写成 `permission:<app_label>.<codename>` —— 平台自己的 `is_staff` 只用于**前端抽屉显隐**。
53
48
  CANDIDATE_RULES: tuple[str, ...] = (
54
49
  '',
55
50
  'applicant',
56
51
  'creator_or_buyers',
57
52
  'reviewer',
58
53
  'payer',
59
- 'admin',
60
54
  'biz:<字段>[,<字段>…]',
61
- 'permission:<app_label.codename>',
62
- 'permissions:<app_label.codename,…>',
63
- 'applicant_or_permissions:<app_label.codename,…>',
55
+ 'permission:<app_label>.<codename>',
56
+ 'permissions:<app_label>.<codename,…>',
57
+ 'applicant_or_permissions:<app_label>.<codename,…>',
64
58
  )
65
59
 
66
60
  #: 流程类型的形状(它是对外键、也是跨块比对的主键,所以只许小写字母开头、后面字母数字下划线)。
@@ -230,10 +224,6 @@ class Flow:
230
224
  #: ⚠️ 跨块比对**不比它**(与钩子同理):两份副本各写一个 resolver 不会被报出来 ——
231
225
  #: 声明仍是"该谁办"的唯一出处,它只许实现声明里那条规则,不许拿来开副本差异。
232
226
  resolve: Callable[..., Any] | None = None
233
- #: 管理员旁路:True 时 `advance` / `save` 的候选人闸门对**平台管理员**放行
234
- #: (= `has_perm('labull_framework.edit_magic_block')`)。默认 False。
235
- #: ⚠️ 它只旁路**候选人闸门**,不旁路 `confirm_permissions`("谁的点击才算数"仍按权限判)。
236
- admin_bypass: bool = False
237
227
 
238
228
  # ── 构造时判(D1–D13 / D18 / D19 / D21 / D22 / D24 / D25) ──
239
229
 
@@ -571,7 +561,7 @@ class Flow:
571
561
  以及**项目的** permission 清单(`auth_permission` 是整个项目一份,不属于任何一条流程的骨架)。
572
562
  比的就是"流程骨架":状态码、动作码、`to`、`kind`、`candidates`、必填意见、解绑、
573
563
  `confirm_permissions`、中文名、`ui` / `ui_by` / `moves_by` / 表单名 / `editable`,以及
574
- `handler_kind`、`admin_bypass`。
564
+ `handler_kind`。
575
565
  """
576
566
  return {
577
567
  'type': self.type,
@@ -581,7 +571,6 @@ class Flow:
581
571
  'terminal': list(self.terminal),
582
572
  'view_field': self.view_field,
583
573
  'handler_kind': self.handler_kind,
584
- 'admin_bypass': bool(self.admin_bypass),
585
574
  'steps': [
586
575
  {
587
576
  'code': step.code,
@@ -615,8 +604,10 @@ class Flow:
615
604
 
616
605
  # ──────────────────────────── 规则串(语法与求值只有一处) ────────────────────────────
617
606
  #
618
- # 认人只认 **permission**(`app_label.codename`):`is_admin()` / `permissions_of()` /
619
- # `ADMIN_PERMISSION` 都在 `labull_framework/permissions.py`(那一份是"谁能做什么"的唯一真源)。
607
+ # 认人只认 **permission**(`app_label.codename`):`permissions_of()` / `split_permission()` /
608
+ # `PERMISSION_PATTERN` 都在 `labull_framework/permissions.py`(那是"项目有哪些权限"的唯一读法)。
609
+ # 🔴 没有 `is_admin()` / `has_perm()` 那类包装:判断一律写成 `<权限码> in permissions_of(user)`。
610
+ # 🔴 这里**不认 `is_staff`**:平台管理员那一档只用于前端抽屉显隐,跟流程的"谁能办"无关。
620
611
 
621
612
 
622
613
  def _permission_table() -> set[str] | None:
@@ -673,7 +664,7 @@ def _rule_of(rule: str) -> dict[str, Any] | None:
673
664
  text = str(rule or '').strip()
674
665
  if text == '':
675
666
  return {'kind': '', 'fields': (), 'codes': []}
676
- if text in ('applicant', 'creator_or_buyers', 'reviewer', 'payer', 'admin'):
667
+ if text in ('applicant', 'creator_or_buyers', 'reviewer', 'payer'):
677
668
  return {'kind': text, 'fields': (), 'codes': []}
678
669
  if text.startswith('biz:'):
679
670
  # `biz:<字段>[,<字段>…]`:一步的候选人可以是单据上**指名的几个人**(并集)。
@@ -751,13 +742,9 @@ class Candidates:
751
742
  ids: frozenset[str] = frozenset()
752
743
  #: 按**权限**点名的人(`permission:` / `permissions:` 那几条规则;`app_label.codename`)。
753
744
  permissions: frozenset[str] = frozenset()
754
- #: 只认**平台管理员**(规则串 `admin` = `edit_magic_block` 那个权限)。
755
- admin_only: bool = False
756
745
 
757
746
  def allows(self, user: Any) -> bool:
758
747
  """这个人能不能办(`ctx.user` 或 Django 的 User 实例都行)。"""
759
- if self.admin_only:
760
- return is_admin(user)
761
748
  if _user_id(user) in self.ids:
762
749
  return True
763
750
  return bool(permissions_of(user) & set(self.permissions))
@@ -772,11 +759,8 @@ def _candidates_of_rule(flow: Flow, step: Step, rule_text: str, row: Any) -> Can
772
759
  kind = rule['kind']
773
760
  ids: set[str] = set()
774
761
  permissions: set[str] = set()
775
- admin_only = False
776
762
  if kind == '':
777
763
  pass
778
- elif kind == 'admin':
779
- admin_only = True
780
764
  elif kind in ('applicant', 'reviewer', 'payer'):
781
765
  ids.add(_user_id(_row_value(row, _column_of(flow, kind))))
782
766
  elif kind == 'creator_or_buyers':
@@ -797,7 +781,7 @@ def _candidates_of_rule(flow: Flow, step: Step, rule_text: str, row: Any) -> Can
797
781
  ids.add(_user_id(_row_value(row, _column_of(flow, 'applicant'))))
798
782
  permissions |= set(rule['codes'])
799
783
  ids.discard('')
800
- return Candidates(ids=frozenset(ids), permissions=frozenset(permissions), admin_only=admin_only)
784
+ return Candidates(ids=frozenset(ids), permissions=frozenset(permissions))
801
785
 
802
786
 
803
787
  def candidates_of(flow: Flow, step: Step, row: Any) -> Candidates:
@@ -929,15 +913,6 @@ def _visible_moves(flow: Flow, step: Step, row: Any) -> tuple[Move, ...]:
929
913
  return tuple(move for move in step.moves if move.code in codes)
930
914
 
931
915
 
932
- def _is_admin_bypass(flow: Flow, user: Any) -> bool:
933
- """管理员旁路(`Flow.admin_bypass`):只放行**候选人闸门**,不放行 `confirm_permissions`。
934
-
935
- 🔴 "管理员" = `has_perm('labull_framework.edit_magic_block')`(`permissions.is_admin()`),
936
- 不是 Django 的 `is_staff`、也不是某个组名。
937
- """
938
- return bool(flow.admin_bypass and user is not None and is_admin(user))
939
-
940
-
941
916
  def _is_confirm_permission(flow: Flow, move: Move, user: Any) -> bool:
942
917
  """这个人的点击算不算数(`confirm_permissions` 为空 ⇒ 都算数;**命中任意一个**即可)。
943
918
 
@@ -951,11 +926,9 @@ def _is_confirm_permission(flow: Flow, move: Move, user: Any) -> bool:
951
926
 
952
927
 
953
928
  def _allowed(flow: Flow, user: Any, candidates: Candidates) -> bool:
954
- """按钮亮不亮:**候选人**(或管理员旁路)才亮;`confirm_permissions` 不参与这一位(见 `traceOnly`)。"""
929
+ """按钮亮不亮:**候选人**才亮;`confirm_permissions` 不参与这一位(见 `traceOnly`)。"""
955
930
  if user is None:
956
931
  return True
957
- if _is_admin_bypass(flow, user):
958
- return True
959
932
  return candidates.allows(user)
960
933
 
961
934
 
@@ -1043,10 +1016,9 @@ def _now_text() -> str:
1043
1016
 
1044
1017
 
1045
1018
  def _ids_of(found: Candidates) -> list[str]:
1046
- """候选人 → 用户主键(`permissions` / `admin_only` 那两种要查一次人:m2m 的处理人集合要用这一份)。
1019
+ """候选人 → 用户主键(`permissions` 那一类要查一次人:m2m 的处理人集合要用这一份)。
1047
1020
 
1048
- 🔴 语义与 `Candidates.allows()` 对齐:`admin_only` 时只看**持有 `edit_magic_block` 的人**
1049
- (忽略 permissions),否则"点名的 id ∪ 持有这些 permission 的人"。
1021
+ 🔴 语义与 `Candidates.allows()` 对齐:**点名的 id ∪ 持有这些 permission 的人**。
1050
1022
  🔴 持有 = **直接给的**(`user_permissions`)**或经组给的**(`groups__permissions`)——
1051
1023
  两路都要算:少一路的症状是"候选人里有他、待办却没挂给他"。
1052
1024
  """
@@ -1056,8 +1028,6 @@ def _ids_of(found: Candidates) -> list[str]:
1056
1028
 
1057
1029
  users = get_user_model().objects.all()
1058
1030
  wanted = set(found.permissions)
1059
- if found.admin_only:
1060
- wanted.add(ADMIN_PERMISSION)
1061
1031
  if wanted:
1062
1032
  query = Q()
1063
1033
  for permission in sorted(wanted):
@@ -1113,8 +1083,8 @@ def _refresh_handler(flow: Flow, row: Any, step: Step, *, candidates: Candidates
1113
1083
  """刷「当前处理人」那一列(终态清空),回它的文本形式。
1114
1084
 
1115
1085
  🔴 `fk`(默认):只有**恰好一个人**能办时才写得进去(那一列是一个外键,不是列表);
1116
- 多人候选(`permissions:` / `admin`)时它留空 —— 待办靠"候选人规则"查,不靠这一列。
1117
- 🔴 `m2m`:终态 `.clear()`;非终态 `.set(全部候选人 id)`(`permissions` / `admin_only` 先展开成人)。
1086
+ 多人候选(`permissions:` / `applicant_or_permissions:`)时它留空 —— 待办靠"候选人规则"查,不靠这一列。
1087
+ 🔴 `m2m`:终态 `.clear()`;非终态 `.set(全部候选人 id)`(`permissions` 先展开成人)。
1118
1088
  两者**不是同一个东西**,不许互相兜底。
1119
1089
  """
1120
1090
  column = str(flow.handler_field or '')
@@ -1136,7 +1106,7 @@ def _refresh_handler(flow: Flow, row: Any, step: Step, *, candidates: Candidates
1136
1106
  value = ''
1137
1107
  if not step.terminal:
1138
1108
  found = candidates if candidates is not None else assignees_of(flow, step, row)
1139
- if not found.admin_only and len(found.ids) == 1 and not found.permissions:
1109
+ if len(found.ids) == 1 and not found.permissions:
1140
1110
  value = next(iter(found.ids))
1141
1111
  if hasattr(row, f'{column}_id'):
1142
1112
  setattr(row, f'{column}_id', int(value) if value.isdigit() else None if not value else value)
@@ -1185,7 +1155,7 @@ def advance(flow: Flow, row: Any, action: str, params: Mapping[str, Any] | None
1185
1155
  """**推进状态的唯一口子**:块里一行 `return advance(FLOW, row, params['action'], params, ctx)`。
1186
1156
 
1187
1157
  平台做完全部杂活(块里一行都不写):参数校验 → 事务 + 行锁 → 状态合法性(中文话术)→
1188
- 动作集(`moves_by`)→ 候选人求值 + 权限闸门(`admin_bypass`)→ `confirm_permissions` 语义
1158
+ 动作集(`moves_by`)→ 候选人求值 + 权限闸门 → `confirm_permissions` 语义
1189
1159
  (不匹配的人点 = **只留痕不推进**,不是报错)→ 必填意见 → 业务钩子(`Reject` 转 400 中文原话)→
1190
1160
  写状态与日志(**同一次 save**)→ 刷处理人 / 解绑 → 推进**之后**的副作用(`Move.after`)→
1191
1161
  拼 `capabilities` / `actions` / 时间轴 / `step`。
@@ -1237,10 +1207,10 @@ def advance(flow: Flow, row: Any, action: str, params: Mapping[str, Any] | None
1237
1207
  f'当前节点({from_step.label})对这类单据没有动作 {action} —— '
1238
1208
  f'这类单据能做的是 {names}'
1239
1209
  )
1240
- # ② 候选人 + 权限闸门(话术说清"这一步该谁办";admin_bypass 只旁路这一道)
1210
+ # ② 候选人 + 权限闸门(话术说清"这一步该谁办")
1241
1211
  can_do = _candidates(flow, from_step, row)
1242
- if user is not None and not _is_admin_bypass(flow, user) and not can_do.allows(user):
1243
- who = '管理员' if can_do.admin_only else (
1212
+ if user is not None and not can_do.allows(user):
1213
+ who = (
1244
1214
  '指定的人' if can_do.ids and not can_do.permissions else (
1245
1215
  '有这些权限的人:' + ' / '.join(sorted(can_do.permissions)) if can_do.permissions else '没有人'))
1246
1216
  raise errors.Forbidden(f'这一步该{who}办 —— 你不在其中(当前状态:{from_step.label})')
@@ -1308,7 +1278,7 @@ def save(flow: Flow, row: Any, params: Mapping[str, Any] | None = None, ctx: Any
1308
1278
  if step.form is None:
1309
1279
  raise errors.BadRequest(f'当前状态「{step.label}」没有"保存不推进"这回事(报文里 hasForm=false)')
1310
1280
  can_do = _candidates(flow, step, row)
1311
- if user is not None and not _is_admin_bypass(flow, user) and not can_do.allows(user):
1281
+ if user is not None and not can_do.allows(user):
1312
1282
  raise errors.Forbidden(f'这一步该办的人不是你(当前状态:{step.label}),不能改它的内容')
1313
1283
  editable = list(step.editable)
1314
1284
  unknown = [key for key in values if key not in editable and key not in ('comment',)]
@@ -741,7 +741,7 @@ EXAMPLE_FILES: tuple[tuple[str, str], ...] = (
741
741
  #: ⚠️ **每种类型都必须有一个样例**(缺样例算导出失败项,见 `example_files()`):它们是块作者
742
742
  #: 照着抄的唯一一份 —— 抄到一份过期的就等于教人写错。
743
743
  EXAMPLE_FLOW_FILES: tuple[tuple[str, str], ...] = (
744
- ('README.md', '# 流程块(`type: workflow`)—— 这一类块怎么写\n\n这一份是**类型级**的说明,同一份正文铺在两个地方:\n`workspace/workflow/README.md`(你放流程块的那一层)与 `example/workflow/README.md`(样例那一层)。\n\n`example/workflow/workspace/报销流程-<uuid>/` 里那一份是**完整样例**:整个文件夹搬进\n`workspace/workflow/` 就是一个块(搬之前把文件夹名末尾的 uuid 与清单里的 `id` 换成你自己的)。\n\n它比普通块多两个文件:\n\n| 文件 | 是什么 |\n|---|---|\n| `flow.py` | **这条流程的唯一权威**:状态表 / 动作 / 谁能办 / 每个节点的内容 key / 业务钩子 |\n| `flow.js` | 前端**内容渲染器登记表**(`MagicSteps.register(\'<ui>\', fn)`),与 `flow.py` 双向对齐 |\n\n`flow.open.yaml` 是导出时铺的**说明性草稿**(从 `flow.py` 生成,给人对照用)—— 它不进清单、不影响保存。\n\n## 三处"读一眼就懂"的地方\n\n1. **状态表**:`flow.py` 里那一个 `Flow(...)`,加节点就是加一个 `Step(...)`;\n2. **推进**:块后端一行 `advance(flow_of(\'reimbursement\'), row, params[\'action\'], params, ctx)` ——\n 状态校验 / 权限闸门 / 必填意见 / 日志 / 刷处理人 / 报文全在平台;\n3. **内容**:`index.open.html` 的壳不变,只有 `#slot` 随报文里的 `step.ui` 换。\n\n## 这一版多给几种表达力(样例 `flow.py` 里能看到前两条)\n\n| 写法 | 解决什么 |\n|---|---|\n| `permission:canvasdemo.review_reimbursement` / `confirm_permissions=(\'canvasdemo.pay_reimbursement\',)` | 按**权限**认人(权限声明在模型的 `Meta.permissions` 里,`migrate` 之后就有;组只是权限包,`flow.py` 不认组名) |\n| `Step(..., moves_by={\'accounting\': (\'approve\', \'reject\')})` | 同一个状态、**不同单据类型给不同动作集**(列由 `view_field` 给) |\n| `candidates=\'biz:owner,approver\'` | 一步的候选人是**单据上指名的几个人**(多字段取并集) |\n| `Step(..., assignees=\'biz:applicant\')` | **谁能点**(`candidates`)与**待办挂给谁**(`assignees`)分开;不写 = 跟 `candidates` 一样 |\n| `Step(..., legacy=True)` | 登记只存在于**历史数据**里的终态(不参与 D7;存量行打开不 500) |\n| `Move(..., after=...)` | 推进**之后**的副作用(写生效时间 / 解绑关联单据),同一次事务里 |\n| `Flow(handler_kind=\'m2m\')` | 处理人是一组人时(多对多),推进后写入**全部**候选人 |\n| `Flow(log_entry=...)` | 项目已有自己的日志形状时,替换平台默认的那一条 |\n| `Flow(admin_bypass=True)` | 管理员旁路:候选人闸门对**平台管理员**放行(= 有 `labull_framework.edit_magic_block` 权限;默认不放行) |\n| `Flow(resolve=...)` | 候选人求值的书面出口(⚠️ 跨块比对不比它:两份副本各写一个不会被报出来) |\n\n⚠️ 这是示例:业务表是 `canvasdemo.Reimbursement`(示例项目里那张),换项目就换 `flow.py` / `backend_open.py` 里那两处 import。\n'),
744
+ ('README.md', '# 流程块(`type: workflow`)—— 这一类块怎么写\n\n这一份是**类型级**的说明,同一份正文铺在两个地方:\n`workspace/workflow/README.md`(你放流程块的那一层)与 `example/workflow/README.md`(样例那一层)。\n\n`example/workflow/workspace/报销流程-<uuid>/` 里那一份是**完整样例**:整个文件夹搬进\n`workspace/workflow/` 就是一个块(搬之前把文件夹名末尾的 uuid 与清单里的 `id` 换成你自己的)。\n\n它比普通块多两个文件:\n\n| 文件 | 是什么 |\n|---|---|\n| `flow.py` | **这条流程的唯一权威**:状态表 / 动作 / 谁能办 / 每个节点的内容 key / 业务钩子 |\n| `flow.js` | 前端**内容渲染器登记表**(`MagicSteps.register(\'<ui>\', fn)`),与 `flow.py` 双向对齐 |\n\n`flow.open.yaml` 是导出时铺的**说明性草稿**(从 `flow.py` 生成,给人对照用)—— 它不进清单、不影响保存。\n\n## 三处"读一眼就懂"的地方\n\n1. **状态表**:`flow.py` 里那一个 `Flow(...)`,加节点就是加一个 `Step(...)`;\n2. **推进**:块后端一行 `advance(flow_of(\'reimbursement\'), row, params[\'action\'], params, ctx)` ——\n 状态校验 / 权限闸门 / 必填意见 / 日志 / 刷处理人 / 报文全在平台;\n3. **内容**:`index.open.html` 的壳不变,只有 `#slot` 随报文里的 `step.ui` 换。\n\n## 这一版多给几种表达力(样例 `flow.py` 里能看到前两条)\n\n| 写法 | 解决什么 |\n|---|---|\n| `permission:canvasdemo.review_reimbursement` / `confirm_permissions=(\'canvasdemo.pay_reimbursement\',)` | 按**权限**认人(权限声明在模型的 `Meta.permissions` 里,`migrate` 之后就有;组只是权限包,`flow.py` 不认组名) |\n| `Step(..., moves_by={\'accounting\': (\'approve\', \'reject\')})` | 同一个状态、**不同单据类型给不同动作集**(列由 `view_field` 给) |\n| `candidates=\'biz:owner,approver\'` | 一步的候选人是**单据上指名的几个人**(多字段取并集) |\n| `Step(..., assignees=\'biz:applicant\')` | **谁能点**(`candidates`)与**待办挂给谁**(`assignees`)分开;不写 = 跟 `candidates` 一样 |\n| `Step(..., legacy=True)` | 登记只存在于**历史数据**里的终态(不参与 D7;存量行打开不 500) |\n| `Move(..., after=...)` | 推进**之后**的副作用(写生效时间 / 解绑关联单据),同一次事务里 |\n| `Flow(handler_kind=\'m2m\')` | 处理人是一组人时(多对多),推进后写入**全部**候选人 |\n| `Flow(log_entry=...)` | 项目已有自己的日志形状时,替换平台默认的那一条 |\n| `Flow(resolve=...)` | 候选人求值的书面出口(⚠️ 跨块比对不比它:两份副本各写一个不会被报出来) |\n\n⚠️ 这是示例:业务表是 `canvasdemo.Reimbursement`(示例项目里那张),换项目就换 `flow.py` / `backend_open.py` 里那两处 import。\n'),
745
745
  ('README.open.md', "# 报销流程\n\n一张报销单从**草稿**到**付款**:申请人填、提交,财务 / 出纳(或申请人自己)审批,通过则付款,退回要写意见。\n\n- **谁能用**:填单人(申请人)与财务 / 出纳(权限 `canvasdemo.review_reimbursement` / `canvasdemo.pay_reimbursement`,\n 由示例项目在自己的 `Reimbursement.Meta.permissions` 里声明、`migrate` 之后就有)。\n- **怎么用**:左边「新建一张草稿单」→ 改事由与金额 →「保存」→「提交」→「通过 / 退回 / 撤回重填」。\n- **边界**:这是**示例**,业务表是 `canvasdemo.Reimbursement`(换项目就换 `flow.py` 与 `backend_open.py` 里那两处 import);\n 钱的真实动作(付款流水、发票)不在这里。\n\n## 这个例子示范了什么(流程块的要点)\n\n| 要点 | 在哪一行 |\n|---|---|\n| 流程表只有一处 | `flow.py` 的 `FLOW = register(Flow(...))` |\n| 一个 `ui` 被多个节点复用 | `draft` 与 `rejected` 都用 `ui='bill'` |\n| 一个 `ui_by` 分叉 | `pending` 的 `ui_by={'accounting': 'approve-zero'}`(列是 `view_field='bill_type'`) |\n| 一个 `moves_by` 动作集分叉 | `pending` 的 `moves_by={'accounting': ('approve', 'reject')}` —— 记账报销没有「撤回重填」 |\n| 「谁能点」与「待办挂给谁」分开 | `pending` 的 `candidates='applicant_or_permissions:canvasdemo.review_reimbursement,canvasdemo.pay_reimbursement'` + `assignees='biz:applicant'` |\n| 按**权限**认人(不认组名) | 权限码在示例项目的 `Reimbursement.Meta.permissions` 里;组只是权限包(`财务` / `出纳` 两个包由 `canvasdemo/0009` 建好) |\n| 谁的点击才算数 | `pending` 的「通过」写了 `confirm_permissions=('canvasdemo.pay_reimbursement',)` —— 没有那个权限的人点了只留痕、不推进 |\n| 必填意见的退回 + 两个终态 | `Move('reject', …, requires_comment=True)`、`paid` / `rejected` |\n| 「保存不推进」 | `draft` 的 `form=keep_title` + `editable=('title','amount')` |\n| 前端按 key 渲染 | `flow.js` 里四行 `MagicSteps.register(...)`,`app.js` 里那四个渲染器 |\n\n🔴 前端登记的 `ui` 与 `flow.py` 用到的 `ui` 是**双向**判据(D16/D17):少一个会在保存时被拦(白屏之前先被拦),\n多一个也会被拦(死代码)。\n"),
746
746
  ('app.css', '/* 报销流程示例块的样式(宿主会把这份内联进 iframe)。\n *\n * 设计口径(与仓库里那些真实业务块一致):\n * · 只用主题给的类与 token:`.labull-card` / `.labull-subject-title` / `.labull-steps` / `--sp-*`;\n * · 底色用 `--sp-color-surface`(那一层浅灰蓝),内容是白卡片 —— 与宿主那一页同一个观感;\n * · 步骤条 `.labull-steps` 一行样式都不写:桌面横向、窄屏自动纵向、≤480px 节点自动缩到 28px(主题里)。\n *\n * 🔴 手机端这一份是**真的**媒体查询,不是"缩一缩":块 iframe 的宽度就是手机宽度,\n * `@media (max-width: 768px)` 在这里照常生效(与 `app.css` 那一档同一个断点)。\n */\nbody {\n margin: 0;\n padding: 16px 20px 24px;\n font: var(--sp-font-md, 14px) / var(--sp-line-height-body, 1.5) var(--sp-font-family, system-ui, sans-serif);\n color: var(--sp-color-text, #2e3440);\n background: var(--sp-color-surface, #eceff4);\n}\n\n.wf { display: flex; flex-direction: column; gap: var(--sp-space-3, 12px); }\n\n/* ── 抬头 ─────────────────────────────────────────────────────────────── */\n.wf-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 10px; min-height: 36px; }\n.wf-head .labull-subject-title { display: inline-flex; align-items: center; gap: 6px; margin: 0; font-size: var(--sp-font-lg, 18px); }\n.wf-no { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: var(--sp-font-md, 14px); color: var(--sp-color-text, #2e3440); }\n.wf-muted { color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); }\n\n/* 状态药丸:颜色按状态走(不是按文字猜),与表单里那几处一致 */\n.wf-pill {\n display: inline-flex;\n align-items: center;\n height: 22px;\n padding: 0 8px;\n border-radius: var(--sp-radius-pill, 999px);\n font-size: var(--sp-font-xs, 12px);\n font-weight: var(--sp-font-weight-strong, 600);\n background: var(--sp-color-active, #d8e1eb);\n color: var(--sp-color-primary, #5e81ac);\n}\n.wf-pill[data-status=\'pending\'] { background: #fdf1dc; color: var(--sp-color-warn, #d98b00); }\n.wf-pill[data-status=\'paid\'] { background: #dff3e6; color: var(--sp-color-success, #2e9e5b); }\n.wf-pill[data-status=\'rejected\'] { background: #fbe5e6; color: var(--sp-color-danger, #bf616a); }\n\n/* ── 步骤条那一张卡片 ─────────────────────────────────────────────────── */\n.wf-steps { padding: 6px 12px; }\n.wf-steps #steps { width: 100%; }\n\n/* ── 内容槽 + 卡片里的排版 ───────────────────────────────────────────── */\n.wf-slot { display: flex; flex-direction: column; gap: var(--sp-space-3, 12px); }\n.wf-card-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }\n.wf-card-head .pi { color: var(--sp-color-primary, #5e81ac); }\n.wf-card-title { margin: 0; font-size: var(--sp-font-md, 14px); font-weight: var(--sp-font-weight-strong, 600); }\n\n/* 一行「标签 + 值/控件」:只读的标签贴着内容(`max-content`),输入框那几行标签列定宽、控件那列最多 220px\n (与示例抬头那种"一眼扫完"的密度一致;长文本仍然在值那一列折行,不把整行撑开)。 */\n.wf-kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); align-items: center; gap: 4px 12px; }\n.wf-kv.wf-field { grid-template-columns: 64px minmax(0, 220px); gap: 8px 12px; }\n.wf-kv-label { color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); white-space: nowrap; }\n.wf-kv-value { min-width: 0; overflow-wrap: anywhere; }\n.wf-kv-value .p-inputtext { width: 100%; box-sizing: border-box; }\n.wf-kv-value .wf-num { font-variant-numeric: tabular-nums; }\n\n.wf-hint { margin: 0; color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); }\n.wf-note {\n margin: 0;\n padding: 8px 10px;\n border-left: 3px solid var(--sp-color-primary, #5e81ac);\n border-radius: var(--sp-radius-sm, 4px);\n background: var(--sp-color-active, #d8e1eb);\n font-size: var(--sp-font-sm, 13px);\n}\n.wf-empty { color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); }\n\n/* ── 动作条 + 提示 ───────────────────────────────────────────────────── */\n.wf-actions { display: flex; flex-direction: column; gap: 6px; }\n.wf-actions #actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; min-height: 36px; }\n.wf-actions #actions:empty::before { content: \'这一步没有可点的动作\'; color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); }\n.wf-tip { margin: 0; min-height: 18px; color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); }\n.wf-tip.is-bad { color: var(--sp-color-danger, #bf616a); }\n\n/* ── 切换单子 ────────────────────────────────────────────────────────── */\n.wf-bills { color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); }\n.wf-bills > summary { cursor: pointer; width: fit-content; padding: 2px 0; }\n.wf-billlist { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0 0; }\n.wf-billlist button {\n padding: 4px 10px;\n border: var(--sp-border-width, 1px) solid var(--sp-color-border, #e5e9f0);\n border-radius: var(--sp-radius-sm, 4px);\n background: var(--sp-color-surface-raised, #fff);\n color: var(--sp-color-text, #2e3440);\n font-size: var(--sp-font-sm, 13px);\n cursor: pointer;\n}\n.wf-billlist button:hover { border-color: var(--sp-color-primary, #5e81ac); color: var(--sp-color-primary, #5e81ac); }\n\n/* ── 手机:抬头收成两行、卡片贴边、动作条铺满(手指点得到) ───────────── */\n@media (max-width: 768px) {\n body { padding: 10px 12px 16px; }\n .wf { gap: 10px; }\n .wf-head { gap: 6px 8px; }\n .wf-head .labull-subject-title { font-size: var(--sp-font-md, 14px); }\n .wf-head .wf-muted { width: 100%; order: 4; }\n .wf-head #create { margin-left: auto; }\n .wf-steps { padding: 4px 8px; }\n /* 只有"输入框那几行"要定宽标签;只读的键值仍然贴着内容走 */\n .wf-kv.wf-field { grid-template-columns: 56px minmax(0, 1fr); }\n .wf-actions #actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }\n .wf-actions #actions .p-button { width: 100%; min-height: 40px; justify-content: center; }\n .wf-billlist { flex-direction: column; }\n .wf-billlist button { width: 100%; min-height: 40px; text-align: left; }\n}\n\n/* 更窄(≤480):节点那一圈交给主题缩,这里只把内边距再收一点 */\n@media (max-width: 480px) {\n body { padding: 8px 10px 14px; }\n .wf-kv.wf-field { grid-template-columns: 48px minmax(0, 1fr); gap: 6px 10px; }\n}\n'),
747
747
  ('app.js', '/* 报销流程的驱动 + 四个内容渲染器(**壳不变,只有中间那个内容槽随节点换**)。\n *\n * 🔴 壳(抬头 / 步骤条 / 动作条)永远是同一个:变的只有 `#slot` 里的内容,\n * 而且"换哪一块"由报文里的 `step.ui` 决定 —— 前端**不许**自己映射 status→UI。\n * 🔴 渲染器签名是 `(slot, payload)`:`payload` 就是后端那份报文\n * (`step` / `steps` / `capabilities` / `actions` / `timeline`)。\n * 🔴 这里**不画弹窗**:要人填意见时用 `magicBlock.dialog(...)`,由宿主在真视口里画\n * (块 iframe 的 `position: fixed` 基准是"这一块有多高",自己画的浮层会被摆到看不见的地方)。\n */\n;(function () {\n var api = magicBackendApi.ReimbursementBlock\n var current = null\n\n function esc(value) {\n return String(value == null ? \'\' : value).replace(/[&<>"]/g, function (ch) {\n return { \'&\': \'&amp;\', \'<\': \'&lt;\', \'>\': \'&gt;\', \'"\': \'&quot;\' }[ch]\n })\n }\n\n /** 一张卡片:`head` 是抬头(可带 `?` 右边的东西),`body` 是正文 HTML。 */\n function card(title, icon, body, extra) {\n return (\n \'<section class="labull-card wf-card">\' +\n \'<div class="labull-card__head wf-card-head">\' +\n \'<i class="pi \' + icon + \'"></i><h3 class="labull-card__title wf-card-title">\' + esc(title) + \'</h3>\' +\n (extra || \'\') +\n \'</div>\' +\n \'<div class="labull-card__body">\' + body + \'</div>\' +\n \'</section>\'\n )\n }\n\n /** 一行「标签 + 值」(只读)。 */\n function kv(label, value, extraClass) {\n return (\n \'<div class="wf-kv">\' +\n \'<span class="wf-kv-label">\' + esc(label) + \'</span>\' +\n \'<span class="wf-kv-value \' + (extraClass || \'\') + \'">\' + (value == null || value === \'\' ? \'—\' : esc(value)) + \'</span>\' +\n \'</div>\'\n )\n }\n\n /** 一行「标签 + 输入框」(可编辑的那几列,key 要进 `step.editable` 白名单)。 */\n function field(label, key, value) {\n return (\n \'<div class="wf-kv wf-field">\' +\n \'<span class="wf-kv-label"><label for="wf-\' + esc(key) + \'">\' + esc(label) + \'</label></span>\' +\n \'<span class="wf-kv-value">\' +\n \'<input class="p-inputtext" id="wf-\' + esc(key) + \'" data-field="\' + esc(key) + \'" value="\' + esc(value) + \'">\' +\n \'</span></div>\'\n )\n }\n\n /** 内容 key `bill`:草稿(可编辑 + 保存)与已退回(只读)**共用**这一块。 */\n function bill(slot, payload) {\n var editable = payload.step.editable.length > 0\n var body = editable\n ? field(\'事由\', \'title\', payload.bill.title) + field(\'金额\', \'amount\', payload.bill.amount)\n : kv(\'事由\', payload.bill.title) + kv(\'金额\', payload.bill.amount, \'wf-num\')\n body += \'<p class="wf-hint">\' + (editable\n ? \'改完点下面的「保存」:只保存、不推进(状态还是草稿)。\'\n : \'这张单已经结束,不能再改。\') + \'</p>\'\n slot.innerHTML = card(\'基本信息\', \'pi-info-circle\', body)\n }\n\n /** 内容 key `approve`:待审批(采购报销走这一块)。 */\n function approve(slot, payload) {\n var body =\n kv(\'申请人\', payload.bill.applicant) +\n kv(\'单据类型\', payload.bill.billType === \'accounting\' ? \'记账报销\' : \'采购报销\') +\n kv(\'金额\', payload.bill.amount, \'wf-num\') +\n \'<p class="wf-hint">核对发票与金额后点「通过」;退回要写一句意见(那一步声明了必填意见)。</p>\'\n slot.innerHTML = card(\'审批\', \'pi-check-circle\', body)\n }\n\n /** 内容 key `approve-zero`:同一个节点的另一个内容(`ui_by` 按单据类型分叉,记账报销零输入)。 */\n function approveZero(slot, payload) {\n var body =\n kv(\'单据类型\', \'记账报销\') +\n kv(\'金额\', payload.bill.amount, \'wf-num\') +\n \'<p class="wf-note">记账报销:金额由财务复核,这里<strong>不需要填任何东西</strong>。</p>\' +\n \'<p class="wf-hint">同一个「待审批」节点、两种单据类型是两块不同的内容 —— 这就是 ui + ui_by 的用处。</p>\'\n slot.innerHTML = card(\'审批(记账)\', \'pi-calculator\', body)\n }\n\n /** 内容 key `done`:已付款(终态)。 */\n function done(slot, payload) {\n slot.innerHTML = card(\'结果\', \'pi-check\', kv(\'状态\', payload.step.label) + kv(\'金额\', payload.bill.amount, \'wf-num\') +\n \'<p class="wf-hint">这张单已经处理完,不会再出现在任何人的待办里。</p>\')\n }\n\n window.Renderers = { bill: bill, approve: approve, approveZero: approveZero, done: done }\n\n // ──────────────────────────── 壳(数据 / 渲染 / 动作) ────────────────────────────\n\n function tip(text, bad) {\n var node = document.getElementById(\'tip\')\n node.textContent = text || \'\'\n node.className = \'wf-tip\' + (bad ? \' is-bad\' : \'\')\n }\n\n function paint(data) {\n current = data\n document.getElementById(\'no\').textContent = \'#\' + data.bill.no\n var status = document.getElementById(\'status\')\n status.hidden = false\n status.dataset.status = data.step.code\n status.textContent = data.step.label\n document.getElementById(\'handler\').textContent = data.bill.handler ? \'处理人:\' + data.bill.handler : \'\'\n MagicSteps.renderBar(document.getElementById(\'steps\'), data)\n var slot = document.getElementById(\'slot\')\n slot.innerHTML = \'\'\n MagicSteps.mount(slot, data)\n paintActions(data)\n }\n\n function paintActions(data) {\n var box = document.getElementById(\'actions\')\n box.innerHTML = \'\'\n data.actions.forEach(function (item) {\n if (!item.allowed) return\n var button = document.createElement(\'button\')\n button.type = \'button\'\n button.className = \'p-button p-button-sm\' + (item.kind === \'reject\' ? \' p-button-danger\' : \'\')\n button.textContent = item.label\n button.onclick = function () {\n act(item).catch(function (error) { tip(error.message, true) })\n }\n box.appendChild(button)\n })\n if (data.capabilities.hasForm) {\n var saveButton = document.createElement(\'button\')\n saveButton.type = \'button\'\n saveButton.className = \'p-button p-button-sm p-button-text\'\n saveButton.textContent = \'保存\'\n saveButton.onclick = function () {\n save().catch(function (error) { tip(error.message, true) })\n }\n box.appendChild(saveButton)\n }\n }\n\n /** 推进:要意见的先弹宿主那个窗(`magicBlock.dialog` 由**宿主页面**渲染,位置一定对)。 */\n async function act(item) {\n var params = { id: current.bill.id, action: item.action }\n if (item.needsComment) {\n var answer = await magicBlock.dialog({\n title: \'退回这张单\',\n body: \'退回必须写一句意见(流程图里那一步声明了 requires_comment)。\',\n fields: [{ name: \'comment\', label: \'意见\', type: \'textarea\', required: true }],\n buttons: [{ id: \'ok\', label: \'退回\', primary: true, danger: true }, { id: \'cancel\', label: \'再想想\' }]\n })\n if (answer.button !== \'ok\') return\n params.comment = answer.values.comment\n }\n var data = await api.act(params)\n if (data.applied === false) tip(\'这一下不算数(没有那个权限):只留了一条痕,状态没有推进。\')\n else tip(\'\')\n await open(current.bill.id)\n }\n\n /** 「保存不推进」:只把 `step.editable` 那几列递上去(白名单在平台那一侧再判一次)。 */\n async function save() {\n var params = { id: current.bill.id }\n current.step.editable.forEach(function (name) {\n var input = document.querySelector(\'#slot [data-field="\' + name + \'"]\')\n if (input) params[name] = input.value\n })\n await api.save(params)\n tip(\'已保存(状态没变)\')\n await open(current.bill.id)\n }\n\n async function open(id) {\n paint(await api.detail({ id: id }))\n tip(\'\')\n }\n\n async function list() {\n var data = await api.list({})\n var box = document.getElementById(\'bills\')\n box.innerHTML = \'\'\n data.bills.forEach(function (row) {\n var button = document.createElement(\'button\')\n button.type = \'button\'\n button.textContent = row.no + \' · \' + row.title + \' · \' + row.status\n button.onclick = function () { open(row.id).catch(function (error) { tip(error.message, true) }) }\n box.appendChild(button)\n })\n if (data.bills.length) await open(data.bills[0].id)\n else {\n document.getElementById(\'steps\').innerHTML = \'\'\n document.getElementById(\'slot\').innerHTML =\n \'<section class="labull-card wf-card"><p class="wf-empty">还没有单子:点右上角「新建一张草稿单」,\' +\n \'就能看到「草稿 → 待审批 → 已付款 / 已退回」这条流程。</p></section>\'\n document.getElementById(\'actions\').innerHTML = \'\'\n document.getElementById(\'status\').hidden = true\n tip(\'\')\n }\n }\n\n document.getElementById(\'create\').onclick = function () {\n tip(\'\')\n api.create({}).then(function () { return list() }).catch(function (error) { tip(error.message, true) })\n }\n\n list().catch(function (error) { tip(\'读不出来:\' + error.message, true) })\n})()\n'),
@@ -1565,7 +1565,7 @@ MagicSteps.renderBar(document.getElementById('steps'), payload) // 步骤条
1565
1565
 
1566
1566
  ### 3. "权限"这个词在这个项目里指哪两类
1567
1567
 
1568
- 1. **平台级能力**:`ctx.user.is_admin`(平台管理员 = 有 `labull_framework.edit_magic_block`)、`ctx.user.permissions`(Django 的 `app_label.codename`,**这才是判据**);`ctx.user.groups` 只是权限包的名字(只给人看);后端路由可以声明 `@MagicBackendAPI(permission='app_label.codename')`。
1568
+ 1. **平台级能力**:`ctx.user.permissions`(Django 的 `app_label.codename` —— **判据只有它**,而且里面**没有框架自带的权限**);`ctx.user.groups` 只是权限包的名字(只给人看);没有 `is_staff` / `is_admin` 这种派生位(平台管理员那一档只用于**前端抽屉显隐**);后端路由可以声明 `@MagicBackendAPI(permission='app_label.codename')`。
1569
1569
  2. **Django 模型级 CRUD**(`auth_permission` 里那几组 `add_/change_/delete_/view_`):**通常不是**需求说的那个"权限"。拿它当业务权限目录 = **越界**(见规矩 1)。
1570
1570
 
1571
1571
  ### 4. 开工前先读四样(**3 分钟,省几小时**)
@@ -134,15 +134,12 @@ def user_of(user: Any) -> BlockUser:
134
134
  🔴 匿名用户也映射得出来:块里的判据不用先判空。
135
135
  """
136
136
  if user is None or not getattr(user, 'is_authenticated', False):
137
- return BlockUser(id='', username='', work_id='', name='', is_admin=False)
138
- from ..permissions import is_admin
139
-
137
+ return BlockUser(id='', username='', work_id='', name='')
140
138
  return BlockUser(
141
139
  id=str(user.pk or ''),
142
140
  username=str(getattr(user, 'get_username', lambda: '')() or ''),
143
141
  work_id=str(getattr(user, 'work_id', '') or ''),
144
142
  name=str(getattr(user, 'name', '') or ''),
145
- is_admin=is_admin(user),
146
143
  groups=_names(user.groups.values_list('name', flat=True)),
147
144
  permissions=_names(sorted(user.get_all_permissions())),
148
145
  )
@@ -310,6 +310,8 @@ class BlockUser:
310
310
  块里判权限用 `'app.codename' in ctx.user.permissions` 就够,不必碰 ORM。
311
311
  🔴 **权限才是契约,组只是权限包**:判"能不能做"永远看 `permissions`,**不要**看 `groups`
312
312
  里的组名(组名只是标签,项目随时可能改名)。
313
+ 🔴 这里**没有** `is_staff` / `is_admin` 这类派生位:平台管理员那一档只用于**前端抽屉显隐**
314
+ (`/auth/me.isStaff`),跟块里"谁能做业务动作"无关 —— 业务授权只看 `permissions`。
313
315
  """
314
316
 
315
317
  #: 本地用户主键(Django 的 `pk`)。
@@ -320,10 +322,6 @@ class BlockUser:
320
322
  work_id: str
321
323
  #: 显示名(拿不到就是空串 —— 不编造)。
322
324
  name: str
323
- #: **平台管理员** = `has_perm('labull_framework.edit_magic_block')`(直接给的 / 经组给的都算)。
324
- #: 🔴 它**不是** Django 的 `is_staff`(那一列只留给 Django 自己的 `/admin`),
325
- #: 也**不是**某个组名(组只是权限包)。
326
- is_admin: bool
327
325
  #: 所在组的名字(只给人看 / 排查;**不要**拿组名当判据)。
328
326
  groups: tuple[str, ...] = ()
329
327
  #: 直接权限 + 组权限的 codename(`app_label.codename` 形式)。
@@ -10,7 +10,7 @@
10
10
  | D16 | `flow.py` 用到的每个 `ui`,前端必须登记过 | 本文件(扫块包里的 `flow.js`) |
11
11
  | D17 | 前端登记的每个 `ui`,`flow.py` 必须用到 | 同上(**双向**是重点:缺了白屏、多了死代码) |
12
12
  | D18 / D19 | `form` ⇔ `editable`;`ui_by` 的键是列值、值是合法 `ui`;用了 `ui_by` / `moves_by` 必须写 `view_field` | `flow.py`(构造时判,与 D1–D13 一起) |
13
- | D20 | 同一 `flow_type` 的副本之间 `ui` / `form` / `handler_kind` / `admin_bypass` / `moves_by` / `assignees` / `legacy` 一致 | 本文件的 `cross()`(跨块,见 `blocktypes.cross_check`) |
13
+ | D20 | 同一 `flow_type` 的副本之间 `ui` / `form` / `handler_kind` / `moves_by` / `assignees` / `legacy` 一致 | 本文件的 `cross()`(跨块,见 `blocktypes.cross_check`) |
14
14
  | D21 | `moves_by` 的动作码必须是本节点声明过的;`view_field` 不许出现在该步的 `editable` 里 | `flow.py` |
15
15
  | D22 | `handler_kind` 只认 `fk` / `m2m`(m2m 必须有 `handler_field`) | `flow.py` |
16
16
  | D24 | `assignees` 非空时 `candidates` 也必须非空(不能只有待办、没有闸门) | `flow.py` |
@@ -255,7 +255,7 @@ def cross(items: list[tuple[str, dict[str, Any]]]) -> list[tuple[str, str]]:
255
255
  🔴 第一个是**权威**(按块标签排序,所以"谁是权威"与加载顺序无关)。
256
256
  🔴 比的是流程骨架:状态码 / 动作码 / `to` / `kind` / `candidates` / 必填意见 / 解绑 /
257
257
  `confirm_permissions` / 中文名 / `ui` / `ui_by` / `moves_by` / `assignees` / `legacy` / `form` / `editable`,
258
- 以及 `handler_kind` / `admin_bypass`。**不比**注释、docstring、钩子对象,也不比**项目的**
258
+ 以及 `handler_kind`。**不比**注释、docstring、钩子对象,也不比**项目的**
259
259
  permission 清单(`auth_permission` 是整个项目一份,不属于任何一条流程的骨架)。
260
260
  🔴 报错要逐条列出差异(哪个块、哪个字段、各自的值)并给两种改法:改一致,或者**统一成一份**
261
261
  (派生机制刻意不做:那一份差异一旦允许"显式声明",就又多了一处要说两遍的事实)。
@@ -289,7 +289,6 @@ def _diff(here: dict[str, Any], authority: dict[str, Any]) -> list[str]:
289
289
  ('initial', 'initial'),
290
290
  ('terminal', 'terminal'),
291
291
  ('handler_kind', 'handler_kind'),
292
- ('admin_bypass', 'admin_bypass'),
293
292
  ):
294
293
  if here.get(key) != authority.get(key):
295
294
  diffs.append(f'{label} 这里是 {here.get(key)!r},权威副本里是 {authority.get(key)!r}')