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.
- {labull_framework-3.0.1/labull_framework.egg-info → labull_framework-3.0.2}/PKG-INFO +1 -1
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/auth/backend.py +4 -3
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/auth/bootstrap.py +32 -34
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/auth/views.py +5 -4
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/flow.py +24 -58
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/devkit.py +2 -2
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/sdk.py +3 -2
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/types/workflow.py +2 -3
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/views.py +7 -9
- labull_framework-3.0.2/labull_framework/migrations/0007_no_builtin_permissions.py +116 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/models.py +11 -11
- labull_framework-3.0.2/labull_framework/permissions.py +51 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2/labull_framework.egg-info}/PKG-INFO +1 -1
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework.egg-info/SOURCES.txt +1 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/pyproject.toml +1 -1
- labull_framework-3.0.1/labull_framework/permissions.py +0 -86
- {labull_framework-3.0.1 → labull_framework-3.0.2}/LICENSE +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/MANIFEST.in +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/README-pypi.md +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/__init__.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/admin.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/apps.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/auth/__init__.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/auth/sso.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/canvas/__init__.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/canvas/data.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/canvas/gitinfo.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/canvas/shape_ast.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/canvas/static/canvas.css +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/canvas/static/canvas.js +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/canvas/static/page.html +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/canvas/views.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/env.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/jsonio.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/__init__.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/blockcheck.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/blockpackage.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/blocktypes.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/deepcheck.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/dispatch.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/export.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/health.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/manifest.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/parallel.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/registry.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/requires.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/service.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/subproc.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/types/__init__.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/types/plain.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/validate.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/management/__init__.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/management/commands/__init__.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/management/commands/labull_blocktype.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/management/commands/labull_contract.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/management/commands/labull_requires.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/migrations/0001_initial.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/migrations/0002_remove_magicblock_prev_files_and_more.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/migrations/0003_magicblock_tags.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/migrations/0004_magicblock_detail.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/migrations/0005_labullrole.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/migrations/0006_default_groups_permissions.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/migrations/__init__.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/settings.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/urls.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/virtual.py +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework.egg-info/dependency_links.txt +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework.egg-info/requires.txt +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework.egg-info/top_level.txt +0 -0
- {labull_framework-3.0.1 → labull_framework-3.0.2}/setup.cfg +0 -0
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
所以这个类**不碰会话**、也**不校验 token**(验签是 `sso.py` 的事,视图验完才把 claims 送进来)。
|
|
6
6
|
|
|
7
7
|
🔴 **权限一个字都不给**:Django 有 `is_staff` / `is_superuser` / `Group` / `Permission`,
|
|
8
|
-
登录这件事只负责"你是谁"
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
才上岗("
|
|
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
|
-
🔴 判据是**名单**,不是"
|
|
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
|
-
'
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
97
|
-
|
|
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
|
-
🔴
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
#: 🔴 `
|
|
51
|
-
#: `
|
|
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
|
|
61
|
-
'permissions:<app_label
|
|
62
|
-
'applicant_or_permissions:<app_label
|
|
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
|
|
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()` / `
|
|
618
|
-
# `
|
|
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'
|
|
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)
|
|
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
|
-
"""
|
|
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`
|
|
1019
|
+
"""候选人 → 用户主键(`permissions` 那一类要查一次人:m2m 的处理人集合要用这一份)。
|
|
1051
1020
|
|
|
1052
|
-
🔴 语义与 `Candidates.allows()`
|
|
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:` / `
|
|
1121
|
-
🔴 `m2m`:终态 `.clear()`;非终态 `.set(全部候选人 id)`(`permissions`
|
|
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
|
|
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`)→ 候选人求值 +
|
|
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
|
-
# ② 候选人 + 权限闸门(话术说清"这一步该谁办"
|
|
1210
|
+
# ② 候选人 + 权限闸门(话术说清"这一步该谁办")
|
|
1245
1211
|
can_do = _candidates(flow, from_step, row)
|
|
1246
|
-
if user is not None and not
|
|
1247
|
-
who =
|
|
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
|
|
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(
|
|
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 { \'&\': \'&\', \'<\': \'<\', \'>\': \'>\', \'"\': \'"\' }[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` ——
|
|
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
|
-
|
|
312
|
+
里的组名(组名只是标签,项目随时可能改名)。
|
|
313
|
+
🔴 这里**没有** `is_staff` / `is_admin` 这类派生位:平台管理员那一档只用于**前端抽屉显隐**
|
|
314
|
+
(`/auth/me.isStaff`),跟块里"谁能做业务动作"无关 —— 业务授权只看 `permissions`。
|
|
314
315
|
"""
|
|
315
316
|
|
|
316
317
|
#: 本地用户主键(Django 的 `pk`)。
|
{labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/types/workflow.py
RENAMED
|
@@ -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` / `
|
|
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
|
|
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
|
-
🔴
|
|
5
|
-
|
|
6
|
-
`
|
|
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
|
-
🔴 判据就一句:**`
|
|
41
|
-
|
|
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
|
|
55
|
-
return problem('
|
|
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
|
-
"
|
|
23
|
-
|
|
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
|
-
#: `
|
|
98
|
-
#:
|
|
99
|
-
|
|
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 ())}
|
|
@@ -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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/blockcheck.py
RENAMED
|
File without changes
|
{labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/blockpackage.py
RENAMED
|
File without changes
|
{labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/blocktypes.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/types/__init__.py
RENAMED
|
File without changes
|
{labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/magic_block/types/plain.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/management/commands/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/migrations/0001_initial.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework/migrations/0005_labullrole.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{labull_framework-3.0.1 → labull_framework-3.0.2}/labull_framework.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|