labull-framework 3.0.1__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.1/labull_framework.egg-info → labull_framework-3.0.2}/PKG-INFO +1 -1
  2. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/auth/backend.py +4 -3
  3. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/auth/bootstrap.py +32 -34
  4. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/auth/views.py +5 -4
  5. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/flow.py +24 -58
  6. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/devkit.py +2 -2
  7. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/sdk.py +3 -2
  8. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/types/workflow.py +2 -3
  9. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/views.py +7 -9
  10. labull_framework-3.0.2/labull_framework/migrations/0007_no_builtin_permissions.py +116 -0
  11. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/models.py +11 -11
  12. labull_framework-3.0.2/labull_framework/permissions.py +51 -0
  13. {labull_framework-3.0.1 → labull_framework-3.0.2/labull_framework.egg-info}/PKG-INFO +1 -1
  14. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework.egg-info/SOURCES.txt +1 -0
  15. {labull_framework-3.0.1 → labull_framework-3.0.2}/pyproject.toml +1 -1
  16. labull_framework-3.0.1/labull_framework/permissions.py +0 -86
  17. {labull_framework-3.0.1 → labull_framework-3.0.2}/LICENSE +0 -0
  18. {labull_framework-3.0.1 → labull_framework-3.0.2}/MANIFEST.in +0 -0
  19. {labull_framework-3.0.1 → labull_framework-3.0.2}/README-pypi.md +0 -0
  20. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/__init__.py +0 -0
  21. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/admin.py +0 -0
  22. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/apps.py +0 -0
  23. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/auth/__init__.py +0 -0
  24. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/auth/sso.py +0 -0
  25. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/canvas/__init__.py +0 -0
  26. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/canvas/data.py +0 -0
  27. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/canvas/gitinfo.py +0 -0
  28. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/canvas/shape_ast.py +0 -0
  29. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/canvas/static/canvas.css +0 -0
  30. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/canvas/static/canvas.js +0 -0
  31. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/canvas/static/page.html +0 -0
  32. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/canvas/views.py +0 -0
  33. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/env.py +0 -0
  34. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/jsonio.py +0 -0
  35. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/__init__.py +0 -0
  36. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/blockcheck.py +0 -0
  37. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/blockpackage.py +0 -0
  38. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/blocktypes.py +0 -0
  39. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/deepcheck.py +0 -0
  40. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/dispatch.py +0 -0
  41. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/export.py +0 -0
  42. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/health.py +0 -0
  43. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/manifest.py +0 -0
  44. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/parallel.py +0 -0
  45. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/registry.py +0 -0
  46. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/requires.py +0 -0
  47. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/service.py +0 -0
  48. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/subproc.py +0 -0
  49. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/types/__init__.py +0 -0
  50. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/types/plain.py +0 -0
  51. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/validate.py +0 -0
  52. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/management/__init__.py +0 -0
  53. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/management/commands/__init__.py +0 -0
  54. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/management/commands/labull_blocktype.py +0 -0
  55. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/management/commands/labull_contract.py +0 -0
  56. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/management/commands/labull_requires.py +0 -0
  57. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/migrations/0001_initial.py +0 -0
  58. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/migrations/0002_remove_magicblock_prev_files_and_more.py +0 -0
  59. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/migrations/0003_magicblock_tags.py +0 -0
  60. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/migrations/0004_magicblock_detail.py +0 -0
  61. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/migrations/0005_labullrole.py +0 -0
  62. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/migrations/0006_default_groups_permissions.py +0 -0
  63. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/migrations/__init__.py +0 -0
  64. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/settings.py +0 -0
  65. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/urls.py +0 -0
  66. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/virtual.py +0 -0
  67. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework.egg-info/dependency_links.txt +0 -0
  68. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework.egg-info/requires.txt +0 -0
  69. {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework.egg-info/top_level.txt +0 -0
  70. {labull_framework-3.0.1 → 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.1
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
@@ -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
- 🔴 "算不算管理员"的唯一判据是 **有没有 `labull_framework.edit_magic_block` 权限**
8
- (`EDIT_PERMISSION in permissions_of(user)`)—— **不是**组名、**不是** Django 的 `is_staff`
9
- (那一列只留给 Django 自己的 `/admin`)。这里之所以要找一个"组",只是因为**写**的时候要有个落点;
10
- 找法按**权限**找(`permissions.group_holding(EDIT_PERMISSION)`),所以项目把那个组改名也不影响这份名单。
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 EDIT_PERMISSION, group_holding
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 = group_holding(EDIT_PERMISSION)
75
- if group is None:
76
- # 迁移会建"平台管理员"那个权限包;走到这里说明库还没迁移(或权限行还没建出来),
77
- # 不在启动路径上制造第二次失败 —— 下一次启动/迁移之后它就有了。
78
- logger.error('还没有装 %s 的组(权限包):跳过管理员名单同步', EDIT_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,16 +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` 这类派生位**:能做什么只看 `permissions`(`app_label.codename`,
137
- 直接给的 + 经组给的)。前端要判"能不能编辑块"就查
138
- `permissions.includes('labull_framework.edit_magic_block')`。
139
- 🔴 `groups` 只是权限包的名字(只给人看/排查),**不要**拿组名做判断。
136
+ 🔴 `isStaff` 就是 Django 原生的 `is_staff`:**"能不能编辑魔法块"看它**(框架不为此造 permission,
137
+ 所以 `permissions` 里一个框架自带的权限都没有)。没有 `isAdmin` 这种派生位。
138
+ 🔴 `permissions` 是**项目自己**声明的权限(`app_label.codename`,直接给的 + 经组给的);
139
+ `groups` 只是权限包的名字(只给人看/排查),**不要**拿组名做判断。
140
140
  """
141
141
  return {
142
142
  'id': user.pk,
143
143
  'username': user.get_username(),
144
144
  'name': user.name,
145
145
  'workId': user.work_id,
146
+ 'isStaff': bool(user.is_staff),
146
147
  'isSuperuser': user.is_superuser,
147
148
  'isActive': user.is_active,
148
149
  'groups': [group.name for group in user.groups.all()],
@@ -23,12 +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
- EDIT_PERMISSION,
28
- PERMISSION_PATTERN,
29
- permissions_of,
30
- split_permission,
31
- )
26
+ from .permissions import PERMISSION_PATTERN, permissions_of, split_permission
32
27
 
33
28
  # ──────────────────────────── 平台认的那些常量(唯一真源) ────────────────────────────
34
29
 
@@ -47,19 +42,19 @@ HANDLER_KINDS: tuple[str, ...] = ('fk', 'm2m')
47
42
  MOVE_KINDS: tuple[str, ...] = ('submit', 'confirm', 'reject', 'revoke', 'approve')
48
43
 
49
44
  #: `candidates` 的规则串认哪几种(D10 的白名单)。语法与运行期求值**只有一处**(见 `_rule_of()`)。
50
- #: 🔴 `admin` = `has_perm('labull_framework.edit_magic_block')`(**不是**组名、不是 `is_staff`);
51
- #: `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` 只用于**前端抽屉显隐**。
52
48
  CANDIDATE_RULES: tuple[str, ...] = (
53
49
  '',
54
50
  'applicant',
55
51
  'creator_or_buyers',
56
52
  'reviewer',
57
53
  'payer',
58
- 'admin',
59
54
  'biz:<字段>[,<字段>…]',
60
- 'permission:<app_label.codename>',
61
- 'permissions:<app_label.codename,…>',
62
- 'applicant_or_permissions:<app_label.codename,…>',
55
+ 'permission:<app_label>.<codename>',
56
+ 'permissions:<app_label>.<codename,…>',
57
+ 'applicant_or_permissions:<app_label>.<codename,…>',
63
58
  )
64
59
 
65
60
  #: 流程类型的形状(它是对外键、也是跨块比对的主键,所以只许小写字母开头、后面字母数字下划线)。
@@ -229,10 +224,6 @@ class Flow:
229
224
  #: ⚠️ 跨块比对**不比它**(与钩子同理):两份副本各写一个 resolver 不会被报出来 ——
230
225
  #: 声明仍是"该谁办"的唯一出处,它只许实现声明里那条规则,不许拿来开副本差异。
231
226
  resolve: Callable[..., Any] | None = None
232
- #: 管理员旁路:True 时 `advance` / `save` 的候选人闸门对**平台管理员**放行
233
- #: (= `has_perm('labull_framework.edit_magic_block')`)。默认 False。
234
- #: ⚠️ 它只旁路**候选人闸门**,不旁路 `confirm_permissions`("谁的点击才算数"仍按权限判)。
235
- admin_bypass: bool = False
236
227
 
237
228
  # ── 构造时判(D1–D13 / D18 / D19 / D21 / D22 / D24 / D25) ──
238
229
 
@@ -570,7 +561,7 @@ class Flow:
570
561
  以及**项目的** permission 清单(`auth_permission` 是整个项目一份,不属于任何一条流程的骨架)。
571
562
  比的就是"流程骨架":状态码、动作码、`to`、`kind`、`candidates`、必填意见、解绑、
572
563
  `confirm_permissions`、中文名、`ui` / `ui_by` / `moves_by` / 表单名 / `editable`,以及
573
- `handler_kind`、`admin_bypass`。
564
+ `handler_kind`。
574
565
  """
575
566
  return {
576
567
  'type': self.type,
@@ -580,7 +571,6 @@ class Flow:
580
571
  'terminal': list(self.terminal),
581
572
  'view_field': self.view_field,
582
573
  'handler_kind': self.handler_kind,
583
- 'admin_bypass': bool(self.admin_bypass),
584
574
  'steps': [
585
575
  {
586
576
  'code': step.code,
@@ -614,9 +604,10 @@ class Flow:
614
604
 
615
605
  # ──────────────────────────── 规则串(语法与求值只有一处) ────────────────────────────
616
606
  #
617
- # 认人只认 **permission**(`app_label.codename`):`permissions_of()` / `EDIT_PERMISSION` /
618
- # `split_permission()` 都在 `labull_framework/permissions.py`(那一份是"谁能做什么"的唯一真源)。
607
+ # 认人只认 **permission**(`app_label.codename`):`permissions_of()` / `split_permission()` /
608
+ # `PERMISSION_PATTERN` 都在 `labull_framework/permissions.py`(那是"项目有哪些权限"的唯一读法)。
619
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 EDIT_PERMISSION in permissions_of(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,19 +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
- 🔴 "能旁路的人" = **有 `labull_framework.edit_magic_block` 权限的人**(`EDIT_PERMISSION`),
936
- 不是 Django 的 `is_staff`、也不是某个组名。
937
- """
938
- return bool(
939
- flow.admin_bypass
940
- and user is not None
941
- and EDIT_PERMISSION in permissions_of(user)
942
- )
943
-
944
-
945
916
  def _is_confirm_permission(flow: Flow, move: Move, user: Any) -> bool:
946
917
  """这个人的点击算不算数(`confirm_permissions` 为空 ⇒ 都算数;**命中任意一个**即可)。
947
918
 
@@ -955,11 +926,9 @@ def _is_confirm_permission(flow: Flow, move: Move, user: Any) -> bool:
955
926
 
956
927
 
957
928
  def _allowed(flow: Flow, user: Any, candidates: Candidates) -> bool:
958
- """按钮亮不亮:**候选人**(或管理员旁路)才亮;`confirm_permissions` 不参与这一位(见 `traceOnly`)。"""
929
+ """按钮亮不亮:**候选人**才亮;`confirm_permissions` 不参与这一位(见 `traceOnly`)。"""
959
930
  if user is None:
960
931
  return True
961
- if _is_admin_bypass(flow, user):
962
- return True
963
932
  return candidates.allows(user)
964
933
 
965
934
 
@@ -1047,10 +1016,9 @@ def _now_text() -> str:
1047
1016
 
1048
1017
 
1049
1018
  def _ids_of(found: Candidates) -> list[str]:
1050
- """候选人 → 用户主键(`permissions` / `admin_only` 那两种要查一次人:m2m 的处理人集合要用这一份)。
1019
+ """候选人 → 用户主键(`permissions` 那一类要查一次人:m2m 的处理人集合要用这一份)。
1051
1020
 
1052
- 🔴 语义与 `Candidates.allows()` 对齐:`admin_only` 时只看**持有 `edit_magic_block` 的人**
1053
- (忽略 permissions),否则"点名的 id ∪ 持有这些 permission 的人"。
1021
+ 🔴 语义与 `Candidates.allows()` 对齐:**点名的 id ∪ 持有这些 permission 的人**。
1054
1022
  🔴 持有 = **直接给的**(`user_permissions`)**或经组给的**(`groups__permissions`)——
1055
1023
  两路都要算:少一路的症状是"候选人里有他、待办却没挂给他"。
1056
1024
  """
@@ -1060,8 +1028,6 @@ def _ids_of(found: Candidates) -> list[str]:
1060
1028
 
1061
1029
  users = get_user_model().objects.all()
1062
1030
  wanted = set(found.permissions)
1063
- if found.admin_only:
1064
- wanted.add(EDIT_PERMISSION)
1065
1031
  if wanted:
1066
1032
  query = Q()
1067
1033
  for permission in sorted(wanted):
@@ -1117,8 +1083,8 @@ def _refresh_handler(flow: Flow, row: Any, step: Step, *, candidates: Candidates
1117
1083
  """刷「当前处理人」那一列(终态清空),回它的文本形式。
1118
1084
 
1119
1085
  🔴 `fk`(默认):只有**恰好一个人**能办时才写得进去(那一列是一个外键,不是列表);
1120
- 多人候选(`permissions:` / `admin`)时它留空 —— 待办靠"候选人规则"查,不靠这一列。
1121
- 🔴 `m2m`:终态 `.clear()`;非终态 `.set(全部候选人 id)`(`permissions` / `admin_only` 先展开成人)。
1086
+ 多人候选(`permissions:` / `applicant_or_permissions:`)时它留空 —— 待办靠"候选人规则"查,不靠这一列。
1087
+ 🔴 `m2m`:终态 `.clear()`;非终态 `.set(全部候选人 id)`(`permissions` 先展开成人)。
1122
1088
  两者**不是同一个东西**,不许互相兜底。
1123
1089
  """
1124
1090
  column = str(flow.handler_field or '')
@@ -1140,7 +1106,7 @@ def _refresh_handler(flow: Flow, row: Any, step: Step, *, candidates: Candidates
1140
1106
  value = ''
1141
1107
  if not step.terminal:
1142
1108
  found = candidates if candidates is not None else assignees_of(flow, step, row)
1143
- if not found.admin_only and len(found.ids) == 1 and not found.permissions:
1109
+ if len(found.ids) == 1 and not found.permissions:
1144
1110
  value = next(iter(found.ids))
1145
1111
  if hasattr(row, f'{column}_id'):
1146
1112
  setattr(row, f'{column}_id', int(value) if value.isdigit() else None if not value else value)
@@ -1189,7 +1155,7 @@ def advance(flow: Flow, row: Any, action: str, params: Mapping[str, Any] | None
1189
1155
  """**推进状态的唯一口子**:块里一行 `return advance(FLOW, row, params['action'], params, ctx)`。
1190
1156
 
1191
1157
  平台做完全部杂活(块里一行都不写):参数校验 → 事务 + 行锁 → 状态合法性(中文话术)→
1192
- 动作集(`moves_by`)→ 候选人求值 + 权限闸门(`admin_bypass`)→ `confirm_permissions` 语义
1158
+ 动作集(`moves_by`)→ 候选人求值 + 权限闸门 → `confirm_permissions` 语义
1193
1159
  (不匹配的人点 = **只留痕不推进**,不是报错)→ 必填意见 → 业务钩子(`Reject` 转 400 中文原话)→
1194
1160
  写状态与日志(**同一次 save**)→ 刷处理人 / 解绑 → 推进**之后**的副作用(`Move.after`)→
1195
1161
  拼 `capabilities` / `actions` / 时间轴 / `step`。
@@ -1241,10 +1207,10 @@ def advance(flow: Flow, row: Any, action: str, params: Mapping[str, Any] | None
1241
1207
  f'当前节点({from_step.label})对这类单据没有动作 {action} —— '
1242
1208
  f'这类单据能做的是 {names}'
1243
1209
  )
1244
- # ② 候选人 + 权限闸门(话术说清"这一步该谁办";admin_bypass 只旁路这一道)
1210
+ # ② 候选人 + 权限闸门(话术说清"这一步该谁办")
1245
1211
  can_do = _candidates(flow, from_step, row)
1246
- if user is not None and not _is_admin_bypass(flow, user) and not can_do.allows(user):
1247
- who = '管理员' if can_do.admin_only else (
1212
+ if user is not None and not can_do.allows(user):
1213
+ who = (
1248
1214
  '指定的人' if can_do.ids and not can_do.permissions else (
1249
1215
  '有这些权限的人:' + ' / '.join(sorted(can_do.permissions)) if can_do.permissions else '没有人'))
1250
1216
  raise errors.Forbidden(f'这一步该{who}办 —— 你不在其中(当前状态:{from_step.label})')
@@ -1312,7 +1278,7 @@ def save(flow: Flow, row: Any, params: Mapping[str, Any] | None = None, ctx: Any
1312
1278
  if step.form is None:
1313
1279
  raise errors.BadRequest(f'当前状态「{step.label}」没有"保存不推进"这回事(报文里 hasForm=false)')
1314
1280
  can_do = _candidates(flow, step, row)
1315
- 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):
1316
1282
  raise errors.Forbidden(f'这一步该办的人不是你(当前状态:{step.label}),不能改它的内容')
1317
1283
  editable = list(step.editable)
1318
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.permissions`(Django 的 `app_label.codename` —— **判据只有它**;要问"能不能编辑魔法块"就是 `'labull_framework.edit_magic_block' in ctx.user.permissions`,没有 `is_admin` 这种派生位);`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 分钟,省几小时**)
@@ -309,8 +309,9 @@ class BlockUser:
309
309
  🔴 `groups` / `permissions` 给的是**名字**(组名 / 权限 `app_label.codename`),不是 Django 对象:
310
310
  块里判权限用 `'app.codename' in ctx.user.permissions` 就够,不必碰 ORM。
311
311
  🔴 **权限才是契约,组只是权限包**:判"能不能做"永远看 `permissions`,**不要**看 `groups`
312
- 里的组名(组名只是标签,项目随时可能改名)。要问"这个人能不能编辑块"就是
313
- `'labull_framework.edit_magic_block' in ctx.user.permissions` —— 没有 `is_admin` 这种派生位。
312
+ 里的组名(组名只是标签,项目随时可能改名)。
313
+ 🔴 这里**没有** `is_staff` / `is_admin` 这类派生位:平台管理员那一档只用于**前端抽屉显隐**
314
+ (`/auth/me.isStaff`),跟块里"谁能做业务动作"无关 —— 业务授权只看 `permissions`。
314
315
  """
315
316
 
316
317
  #: 本地用户主键(Django 的 `pk`)。
@@ -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}')
@@ -1,9 +1,9 @@
1
1
  """
2
2
  `/magic-blocks/*` 与 `/bx/*` 的全部视图(路由由 `urls.py` 按函数名指过来)。
3
3
 
4
- 🔴 权限用**权限码**(`permissions.EDIT_PERMISSION` = `labull_framework.edit_magic_block`,
5
- **写死在框架里**;**不是** Django 的 `is_staff`、也不是某个组名):写口与管理员读口都要求
6
- `EDIT_PERMISSION in permissions_of(user)`(**自己写 `edit_only`**:
4
+ 🔴 **"能不能编辑魔法块"看 Django 原生的 `is_staff`**(框架不为这件事造 permission ——
5
+ `permissions` 里一个框架自带的权限都没有;也不是某个组名):写口与管理员读口都要求
6
+ `user.is_staff`(**自己写 `edit_only`**:
7
7
  `@staff_member_required` 对未登录用户会 302 到 admin 登录页,而这里要的是 401 + JSON);
8
8
  `status` 那条口已经没了:任何登录用户能读的**只有 `render` 一条**,而且**永远不许**改成回整行 / 回全量 `files`。
9
9
  🔴 所有回话一律走 `jsonio`(一处出口:编码政策与错误形状都在那里)。
@@ -37,22 +37,20 @@ from .service import (
37
37
  def edit_only(view: Callable[..., HttpResponse]) -> Callable[..., HttpResponse]:
38
38
  """编辑守卫(抽屉 / 控制台那几口):没登录 ⇒ **401 + JSON**;登录了但没有编辑权限 ⇒ **403 + JSON**。
39
39
 
40
- 🔴 判据就一句:**`labull_framework.edit_magic_block in permissions_of(user)`**
41
- (直接给的 / 经权限包给的都算)—— 这个码**写死在框架里**(`permissions.EDIT_PERMISSION`),
42
- 它就是"能不能编辑魔法块"那一档;不是 Django 的 `is_staff`、也不是某个组名。
40
+ 🔴 判据就一句:**`user.is_staff`**(Django 原生那一列)—— 它就是"能不能编辑魔法块"那一档;
41
+ 框架**不为此造 permission**(所以 `permissions` 里没有框架自带的东西),也不是某个组名。
43
42
  🔴 两条判据分开报:一个是"你是谁",一个是"你能不能" —— 合成一条会让前端不知道该去登录还是找管理员。
44
43
  🔴 **不返回 302**:这几条是接口,前端要看状态码分支(302 会被 `fetch` 当成功跟着跳,最后拿到一页 HTML)。
45
44
  """
46
45
  from ..jsonio import no_identity
47
- from ..permissions import EDIT_PERMISSION, permissions_of
48
46
 
49
47
  @wraps(view)
50
48
  def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
51
49
  user = getattr(request, 'user', None)
52
50
  if user is None or not getattr(user, 'is_authenticated', False):
53
51
  return no_identity()
54
- if EDIT_PERMISSION not in permissions_of(user):
55
- return problem('这条接口只给能编辑魔法块的人(需要 labull_framework.edit_magic_block 权限)', 403)
52
+ if not getattr(user, 'is_staff', False):
53
+ return problem('这条接口只给能编辑魔法块的人(Django 的 is_staff)', 403)
56
54
  return view(request, *args, **kwargs)
57
55
 
58
56
  return wrapper
@@ -0,0 +1,116 @@
1
+ """3.0.2:框架**不预置任何 permission** —— 三张平台表的权限行全删。
2
+
3
+ 为什么:
4
+ · 框架里"能编辑魔法块"那一档不再造 permission(它是**前端抽屉的显隐**:看 Django 原生的
5
+ `is_staff`;后端接口那一道不该拿它当授权判据,见 `magic_block/views.py`);
6
+ · 于是 `/auth/me.permissions` / `ctx.user.permissions` 里**只剩项目自己声明的权限**
7
+ (各业务模型的 `Meta.permissions`)—— 框架自带的一个都不剩。
8
+
9
+ 这一步做两件事:
10
+ ① `AlterModelOptions`:三张平台表都 `default_permissions = ()`(Django 不再自动生成
11
+ `add_/change_/delete_/view_` 那四个),也不再声明 `Meta.permissions`;
12
+ ② `RunPython`:把 `labull_framework` 那几个**已经存在的**权限行删掉
13
+ (`auth_permission` 与各组的 M2M 行会跟着级联;组本身留着 —— 它们是空权限包,
14
+ 项目可以往里填自己的权限)。
15
+
16
+ ⚠️ 两个默认组(`平台管理员` / `普通用户`)**不删**:里面可能已经有项目自己加的人 / 权限;
17
+ 这一版起它们只是"空权限包",删不删由项目决定。
18
+ """
19
+
20
+ from django.db import migrations
21
+
22
+ #: 3.0.0 / 3.0.1 里框架自己那几行(回滚时按这份重建;名字与当年逐字一致)。
23
+ BUILTIN_PERMISSIONS = (
24
+ ('edit_magic_block', '可以编辑 / 保存 / 删除 / 体检魔法块'),
25
+ ('manage_users', '可以查看 / 编辑用户所属组与直接权限'),
26
+ ('view_magicblock', '可以查看魔法块'),
27
+ ('add_magicblock', 'Can add 功能块'),
28
+ ('change_magicblock', 'Can change 功能块'),
29
+ ('delete_magicblock', 'Can delete 功能块'),
30
+ ('view_magicblockversion', 'Can view 功能块版本'),
31
+ ('add_magicblockversion', 'Can add 功能块版本'),
32
+ ('change_magicblockversion', 'Can change 功能块版本'),
33
+ ('delete_magicblockversion', 'Can delete 功能块版本'),
34
+ ('view_user', 'Can view 用户'),
35
+ ('add_user', 'Can add 用户'),
36
+ ('change_user', 'Can change 用户'),
37
+ ('delete_user', 'Can delete 用户'),
38
+ )
39
+
40
+ #: 当年那两个默认组各自装了哪几个(回滚时按这份塞回去)。
41
+ DEFAULT_GROUPS = {
42
+ '平台管理员': ('edit_magic_block', 'manage_users', 'view_magicblock'),
43
+ '普通用户': ('view_magicblock',),
44
+ }
45
+
46
+
47
+ def drop_builtin_permissions(apps, schema_editor):
48
+ """把 `labull_framework` 的权限行全删(含 Django 自动生成的那些)。"""
49
+ Permission = apps.get_model('auth', 'Permission')
50
+ Permission.objects.filter(content_type__app_label='labull_framework').delete()
51
+
52
+
53
+ def restore_builtin_permissions(apps, schema_editor):
54
+ """回滚:把那几行权限重建出来,并按当年那份名单塞回两个默认组(组不在就建)。"""
55
+ ContentType = apps.get_model('contenttypes', 'ContentType')
56
+ Permission = apps.get_model('auth', 'Permission')
57
+ Group = apps.get_model('auth', 'Group')
58
+ content_type, _created = ContentType.objects.get_or_create(
59
+ app_label='labull_framework', model='magicblock',
60
+ )
61
+ version_type, _made = ContentType.objects.get_or_create(
62
+ app_label='labull_framework', model='magicblockversion',
63
+ )
64
+ user_type, _made2 = ContentType.objects.get_or_create(
65
+ app_label='labull_framework', model='user',
66
+ )
67
+ owner = {
68
+ 'magicblock': content_type,
69
+ 'magicblockversion': version_type,
70
+ 'user': user_type,
71
+ }
72
+ found = {}
73
+ for codename, name in BUILTIN_PERMISSIONS:
74
+ model = 'magicblock'
75
+ if codename.endswith('magicblockversion'):
76
+ model = 'magicblockversion'
77
+ elif codename.endswith('_user'):
78
+ model = 'user'
79
+ permission, _created = Permission.objects.get_or_create(
80
+ content_type=owner[model], codename=codename, defaults={'name': name},
81
+ )
82
+ found[codename] = permission
83
+ for group_name, codenames in DEFAULT_GROUPS.items():
84
+ group, _created = Group.objects.get_or_create(name=group_name)
85
+ for codename in codenames:
86
+ group.permissions.add(found[codename])
87
+
88
+
89
+ class Migration(migrations.Migration):
90
+
91
+ dependencies = [
92
+ ('auth', '0012_alter_user_first_name_max_length'),
93
+ ('contenttypes', '0002_remove_content_type_name'),
94
+ ('labull_framework', '0006_default_groups_permissions'),
95
+ ]
96
+
97
+ operations = [
98
+ migrations.AlterModelOptions(
99
+ name='magicblock',
100
+ options={'default_permissions': (), 'verbose_name': '功能块', 'verbose_name_plural': '功能块'},
101
+ ),
102
+ migrations.AlterModelOptions(
103
+ name='magicblockversion',
104
+ options={
105
+ 'default_permissions': (),
106
+ 'ordering': ['-number'],
107
+ 'verbose_name': '功能块版本',
108
+ 'verbose_name_plural': '功能块版本',
109
+ },
110
+ ),
111
+ migrations.AlterModelOptions(
112
+ name='user',
113
+ options={'default_permissions': (), 'verbose_name': '用户', 'verbose_name_plural': '用户'},
114
+ ),
115
+ migrations.RunPython(drop_builtin_permissions, restore_builtin_permissions),
116
+ ]
@@ -19,8 +19,8 @@ class User(AbstractUser):
19
19
  判断写成 `'app.codename' in permissions_of(user)`(**没有** `is_admin()` / `has_perm()` 这类包装)。
20
20
  授权只走 Django 原生的 `groups` / `user_permissions`(它们继承自 `PermissionsMixin`),
21
21
  `Group` 只是把一组权限**打包**(组名是标签,代码不按组名判断任何事)。
22
- "能不能编辑块" = 有没有 `labull_framework.edit_magic_block`(`permissions.EDIT_PERMISSION`,写死在框架里)——
23
- 平台**不读也不写** Django 的 `is_staff`(那一列只留给 Django 自己的 `/admin`)。
22
+ 🔴 **"能不能编辑魔法块"看 `is_staff`**(Django 原生那一列):框架**不**为这件事造 permission,
23
+ 所以 `permissions` 里一个框架自带的权限都没有。`BOOTSTRAP_ADMIN_WORK_IDS` 同步的就是 `is_staff`。
24
24
  🔴 新用户**不自动进任何组**:那是项目的数据/策略(框架不装 `post_save`)。
25
25
  """
26
26
 
@@ -36,6 +36,9 @@ class User(AbstractUser):
36
36
  db_table = 'labull_user'
37
37
  verbose_name = '用户'
38
38
  verbose_name_plural = '用户'
39
+ # 🔴 框架**不预置任何 permission**(连 Django 自动生成的 add/change/delete/view 也不要):
40
+ # `permissions` / `get_all_permissions()` 里只该有**项目自己**声明的东西。
41
+ default_permissions = ()
39
42
 
40
43
 
41
44
  class MagicBlock(models.Model):
@@ -93,15 +96,10 @@ class MagicBlock(models.Model):
93
96
  db_table = 'labull_magic_block'
94
97
  verbose_name = '功能块'
95
98
  verbose_name_plural = '功能块'
96
- #: 🔴 **平台自己的权限就住在这里**(它们是"能做什么"的契约,不是某个组名)。
97
- #: `edit_magic_block` = 平台管理员(控制台写口 / 管理台 / `Flow.admin_bypass`);
98
- #: `manage_users` = 看/改用户所属组与直接权限(示例块 `用户与用户组` 用它守门)。
99
- #: 另外 Django 仍会自动生成 `add_/change_/delete_/view_magicblock` 四个 —— `view_magicblock`
100
- #: 是默认组「普通用户」的那个只读入口。
101
- permissions = [
102
- ('edit_magic_block', '可以编辑 / 保存 / 删除 / 体检魔法块'),
103
- ('manage_users', '可以查看 / 编辑用户所属组与直接权限'),
104
- ]
99
+ #: 🔴 **框架不预置任何 permission**(连 Django 自动生成的那四个也不要):
100
+ #: 于是 `/auth/me.permissions` / `ctx.user.permissions` 里只剩**项目自己**的权限。
101
+ #: `is_staff` 只是给**前端抽屉**做显隐用的(platform 不拿它当授权判据)。
102
+ default_permissions = ()
105
103
 
106
104
  def __str__(self) -> str:
107
105
  return f'{self.module}/{self.name}'
@@ -134,6 +132,8 @@ class MagicBlockVersion(models.Model):
134
132
  db_table = 'labull_magic_block_version'
135
133
  verbose_name = '功能块版本'
136
134
  verbose_name_plural = '功能块版本'
135
+ # 🔴 同上:框架不预置 permission。
136
+ default_permissions = ()
137
137
  # 🔴 同一块里版本号唯一(并发上传靠它兜底:撞了就是 IntegrityError,而不是静默覆盖一版)。
138
138
  constraints = [models.UniqueConstraint(fields=['block', 'number'], name='labull_block_version_unique')]
139
139
  ordering = ['-number']
@@ -0,0 +1,51 @@
1
+ """平台权限的**读法**与形状判据 —— 框架自己**一个 permission 都不预置**。
2
+
3
+ 🔴 3.0.2 起框架里**没有任何自带权限**:`MagicBlock.Meta.default_permissions = ()`、也不声明
4
+ `Meta.permissions`。于是 `/auth/me.permissions` 与 `ctx.user.permissions` 里**只有项目自己**
5
+ 声明的权限(各模型 `Meta.permissions`)—— 组仍然只是权限包。
6
+ 🔴 **"能不能编辑魔法块(抽屉 / 控制台 / 管理台)"看 Django 原生的 `User.is_staff`**:
7
+ 那一列就是这一档的开关(`BOOTSTRAP_ADMIN_WORK_IDS` 启动时同步的也正是它)。
8
+ 不是某个 permission、不是某个组名、也不再有 `is_admin()` 这类派生位。
9
+ 🔴 这个模块只回答两件事:
10
+ · `permissions_of(user)` —— 这个人**有哪些权限**(流水 `permission:<app.codename>` 候选人要用);
11
+ · `PERMISSION_PATTERN` / `split_permission()` —— `app_label.codename` 的**形状判据**(唯一一处)。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from typing import Any
18
+
19
+ #: `app_label.codename` 的形状(`flow.py` 里 `permission:` 的码也用它 —— 一份判据两处用)。
20
+ PERMISSION_PATTERN = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$')
21
+
22
+
23
+ def split_permission(text: Any) -> tuple[str, str]:
24
+ """`'orders.approve'` → `('orders', 'approve')`;形状不对回 `('', '')`。
25
+
26
+ 🔴 判据只有 `PERMISSION_PATTERN` 一处:定义期(flow 的白名单)与运行期(查人)都走它。
27
+ """
28
+ value = str(text or '').strip()
29
+ if not PERMISSION_PATTERN.match(value):
30
+ return '', ''
31
+ app_label, codename = value.split('.', 1)
32
+ return app_label, codename
33
+
34
+
35
+ def permissions_of(user: Any) -> set[str]:
36
+ """这个人**有效**的权限(`app_label.codename`:直接给的 ∪ 经组给的)—— 授权的唯一读法。
37
+
38
+ 🔴 两种"人"都认:Django 的 `User`(`get_all_permissions()`)与块里的 `ctx.user`
39
+ (`BlockUser.permissions` 是现成的元组)—— 两处各写一份判据迟早会漂。
40
+ 🔴 判断一律写成 `'app.codename' in permissions_of(user)`(没有 `has_perm()` / `is_admin()`
41
+ 那种包装:多一层包装就多一处"两套解释")。
42
+ """
43
+ if user is None:
44
+ return set()
45
+ getter = getattr(user, 'get_all_permissions', None)
46
+ if callable(getter):
47
+ try:
48
+ return {str(item) for item in getter()}
49
+ except Exception: # noqa: BLE001 —— 读不出来就当没有(安全的那一侧)
50
+ return set()
51
+ return {str(item) for item in (getattr(user, 'permissions', None) or ())}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 3.0.1
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
@@ -63,4 +63,5 @@ labull_framework/migrations/0003_magicblock_tags.py
63
63
  labull_framework/migrations/0004_magicblock_detail.py
64
64
  labull_framework/migrations/0005_labullrole.py
65
65
  labull_framework/migrations/0006_default_groups_permissions.py
66
+ labull_framework/migrations/0007_no_builtin_permissions.py
66
67
  labull_framework/migrations/__init__.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "labull-framework"
7
- version = "3.0.1"
7
+ version = "3.0.2"
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"
@@ -1,86 +0,0 @@
1
- """平台权限的唯一真源:**`Permission`(`app_label.codename`)** —— 组只是权限包。
2
-
3
- 🔴 3.0.0 起**没有**"项目角色表"了(`LabullRole` / `User.roles` / `roles.py` 全删):
4
- 能做什么只看**这个人有哪些权限**(`permissions_of()`,直接给的 + 经组给的,Django 已经算好了)。
5
- `Group` 只把一组权限**打包**(方便加人);**组名是标签**,代码里不许按组名判断身份。
6
- 🔴 **没有 `is_admin()` / `has_perm()` 这类"判断方法"**:框架里所有授权判断都写成
7
- `<权限码> in permissions_of(user)` —— 一条判据、一个写法。你要问"这个人能不能编辑块",
8
- 就是问"他有没有 `labull_framework.edit_magic_block`"。
9
- 🔴 **`edit_magic_block` 这个码写死在框架里**(`EDIT_PERMISSION`):它就是"能不能打开抽屉 /
10
- 控制台去编辑魔法块"的那一档(写口、管理台、`Flow.admin_bypass`、示例块的守卫都按它判)。
11
- 它是平台自己的契约,不改名、不配环境变量。
12
- 🔴 框架**不装任何 `post_save` / `user_logged_in` 来给新用户加组**:新用户默认进哪个组是
13
- 项目的数据/策略(示例项目给了一份可复制的实现:`backend/example/canvasdemo/signals.py`)。
14
- 这个模块只回答"这个人现在有哪些权限"。
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- import re
20
- from typing import Any
21
-
22
- #: 平台自己的三个权限(与 `MagicBlock.Meta.permissions` **逐字同源**:那边是声明,这里是引用)。
23
- #: 🔴 只在这里写一次:`admin_only` 那个守卫 / 启动名单 / 示例块的守卫全都引用这几个常量。
24
- #: 🔴 `EDIT_PERMISSION` 是**写死**的:它就是"能不能编辑魔法块(抽屉 / 控制台)"那一档。
25
- EDIT_PERMISSION = 'labull_framework.edit_magic_block'
26
- MANAGE_USERS_PERMISSION = 'labull_framework.manage_users'
27
- VIEW_PERMISSION = 'labull_framework.view_magicblock'
28
-
29
- #: `app_label.codename` 的形状(`flow.py` 里 `permission:` 的码也用它 —— 一份判据两处用)。
30
- PERMISSION_PATTERN = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$')
31
-
32
-
33
- def split_permission(text: Any) -> tuple[str, str]:
34
- """`'orders.approve'` → `('orders', 'approve')`;形状不对回 `('', '')`。
35
-
36
- 🔴 判据只有 `PERMISSION_PATTERN` 一处:定义期(flow 的白名单)与运行期(查人)都走它。
37
- """
38
- value = str(text or '').strip()
39
- if not PERMISSION_PATTERN.match(value):
40
- return '', ''
41
- app_label, codename = value.split('.', 1)
42
- return app_label, codename
43
-
44
-
45
- def permissions_of(user: Any) -> set[str]:
46
- """这个人**有效**的权限(`app_label.codename`:直接给的 ∪ 经组给的)—— 授权的唯一读法。
47
-
48
- 🔴 两种"人"都认:Django 的 `User`(`get_all_permissions()`)与块里的 `ctx.user`
49
- (`BlockUser.permissions` 是现成的元组)—— 两处各写一份判据迟早会漂。
50
- 🔴 判断一律写成 `'app.codename' in permissions_of(user)`(没有 `is_admin()` / `has_perm()`
51
- 那种包装:多一层包装就多一处"两套解释")。
52
- """
53
- if user is None:
54
- return set()
55
- getter = getattr(user, 'get_all_permissions', None)
56
- if callable(getter):
57
- try:
58
- return {str(item) for item in getter()}
59
- except Exception: # noqa: BLE001 —— 读不出来就当没有(安全的那一侧)
60
- return set()
61
- return {str(item) for item in (getattr(user, 'permissions', None) or ())}
62
-
63
-
64
- def group_holding(permission: str) -> Any:
65
- """**持有这个权限的那个组**(启动时那份管理员名单加人的落点)。
66
-
67
- 🔴 按**权限**找、不按组名找:项目把那个组改名之后这份名单照样能用(组名只是标签)。
68
- 🔴 有多个组都持有它时取主键最小的那个(确定性):名单只往**一个**组里加人。
69
- ⚠️ 表还没迁移 / 还没有那个组 ⇒ `None`:调用方静默跳过,别在启动路径上抛。
70
- """
71
- app_label, codename = split_permission(permission)
72
- if not app_label:
73
- return None
74
- try:
75
- from django.contrib.auth.models import Group
76
-
77
- return (
78
- Group.objects.filter(
79
- permissions__content_type__app_label=app_label,
80
- permissions__codename=codename,
81
- )
82
- .order_by('pk')
83
- .first()
84
- )
85
- except Exception: # noqa: BLE001 —— 见 docstring:表还没迁移是正常情况
86
- return None