labull-framework 3.0.3__tar.gz → 3.0.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {labull_framework-3.0.3/labull_framework.egg-info → labull_framework-3.0.4}/PKG-INFO +1 -1
- labull_framework-3.0.4/labull_framework/access.py +479 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/apps.py +5 -0
- labull_framework-3.0.4/labull_framework/checks.py +25 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/env.py +20 -1
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/flow.py +18 -21
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/blockcheck.py +28 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/devkit.py +108 -17
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/health.py +7 -1
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/manifest.py +5 -5
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/requires.py +134 -1
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/sdk.py +7 -2
- labull_framework-3.0.4/labull_framework/management/commands/labull_access.py +69 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/management/commands/labull_requires.py +6 -5
- labull_framework-3.0.4/labull_framework/permissions.py +114 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4/labull_framework.egg-info}/PKG-INFO +1 -1
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework.egg-info/SOURCES.txt +3 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/pyproject.toml +1 -1
- labull_framework-3.0.3/labull_framework/permissions.py +0 -51
- {labull_framework-3.0.3 → labull_framework-3.0.4}/LICENSE +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/MANIFEST.in +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/README-pypi.md +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/__init__.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/admin.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/auth/__init__.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/auth/backend.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/auth/bootstrap.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/auth/sso.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/auth/views.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/canvas/__init__.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/canvas/data.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/canvas/gitinfo.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/canvas/shape_ast.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/canvas/static/canvas.css +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/canvas/static/canvas.js +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/canvas/static/page.html +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/canvas/views.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/jsonio.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/__init__.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/blockpackage.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/blocktypes.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/deepcheck.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/dispatch.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/export.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/parallel.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/registry.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/service.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/subproc.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/types/__init__.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/types/plain.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/types/workflow.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/validate.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/magic_block/views.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/management/__init__.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/management/commands/__init__.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/management/commands/labull_blocktype.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/management/commands/labull_contract.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/migrations/0001_initial.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/migrations/0002_remove_magicblock_prev_files_and_more.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/migrations/0003_magicblock_tags.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/migrations/0004_magicblock_detail.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/migrations/0005_labullrole.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/migrations/0006_default_groups_permissions.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/migrations/0007_no_builtin_permissions.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/migrations/__init__.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/models.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/settings.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/urls.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework/virtual.py +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework.egg-info/dependency_links.txt +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework.egg-info/requires.txt +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/labull_framework.egg-info/top_level.txt +0 -0
- {labull_framework-3.0.3 → labull_framework-3.0.4}/setup.cfg +0 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""权限码与权限包(组)的**声明 → 落库**那一半 —— 声明住在环境变量里。
|
|
2
|
+
|
|
3
|
+
两个变量(格式就是契约,改它要连文档 / 示例 / 判据一起改):
|
|
4
|
+
|
|
5
|
+
LABULL_PERMISSIONS=订单.models=approve:可以审批订单,close:可以关单;…
|
|
6
|
+
└ 模型(app_label.Model)┘ └ codename:中文名 ┘
|
|
7
|
+
|
|
8
|
+
LABULL_GROUPS=财务=orders.approve,orders.close;出纳=orders.close
|
|
9
|
+
|
|
10
|
+
🔴 **为什么 permission 的条目里要写模型**:`Permission` 的行主键是
|
|
11
|
+
`(content_type, codename)`,而 content_type 只能从一个**真实模型**拿到;
|
|
12
|
+
环境变量里只有 `app_label.codename` 的话,要么造一个伪模型(admin 里会多一行幽灵,
|
|
13
|
+
`remove_stale_contenttypes` 还会把它当垃圾清掉),要么就没有 content_type。
|
|
14
|
+
写模型名是最小的一处额外信息,换来的是 content_type 正确、与 `Meta.permissions` 同一行。
|
|
15
|
+
🔴 权限码本身是 `app_label.codename`(与 `flow.py` 的 `permission:`、`has_perm()` 同一份口径):
|
|
16
|
+
模型那一段只用来定位 content_type,**不进码**。
|
|
17
|
+
🔴 同步**只增不删**:少一个组 / 少一个权限就"从库里删掉"是不做的 ——
|
|
18
|
+
删组会级联掉 `auth_user_groups` 里人的授权,那是人手动配的数据,不是派生值。
|
|
19
|
+
要删只能人自己在库里删(这是用户明确要的语义)。
|
|
20
|
+
🔴 `sync()` **永不抛**:它是启动路径上的一件事(与 `auth/bootstrap.py` 同一个模式),
|
|
21
|
+
库连不上 / 表还没迁移都只该进日志与报告。
|
|
22
|
+
🔴 组名只是**标签**:代码里任何判据都不许读组名(判权永远看 permission)。
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import re
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from .env import read_env
|
|
33
|
+
from .permissions import PERMISSION_PATTERN
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
#: 权限声明(`app_label.Model=codename:中文名[,…]`;多个模型用 `;` 分隔)。
|
|
38
|
+
PERMISSIONS_ENV = 'LABULL_PERMISSIONS'
|
|
39
|
+
|
|
40
|
+
#: 权限包(组)声明(`组名=app_label.codename[,…]`;多个包用 `;` 分隔)。
|
|
41
|
+
GROUPS_ENV = 'LABULL_GROUPS'
|
|
42
|
+
|
|
43
|
+
#: 一个标识符(app_label / 模型名 / codename 共用这一份判据)。
|
|
44
|
+
_IDENT = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
|
|
45
|
+
|
|
46
|
+
#: Django `Permission.codename` / `Group.name` 的上限(照抄模型那两列,抄错就会在写库时炸)。
|
|
47
|
+
CODENAME_MAX = 100
|
|
48
|
+
NAME_MAX = 255
|
|
49
|
+
GROUP_NAME_MAX = 150
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AccessError(ValueError):
|
|
53
|
+
"""声明格式不对 —— 报文是中文原话,直接给人看(不走 `%` 模板)。"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class PermissionSpec:
|
|
58
|
+
"""一条权限声明:`app_label.Model=codename:中文名`。"""
|
|
59
|
+
|
|
60
|
+
app_label: str
|
|
61
|
+
model: str
|
|
62
|
+
codename: str
|
|
63
|
+
name: str
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def code(self) -> str:
|
|
67
|
+
"""它对外暴露的那个码(`app_label.codename`)—— 与 `Meta.permissions` / `flow` 同一份。"""
|
|
68
|
+
return f'{self.app_label}.{self.codename}'
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class GroupSpec:
|
|
73
|
+
"""一个权限包:组名(只是标签)+ 它打包的那几个权限码。"""
|
|
74
|
+
|
|
75
|
+
name: str
|
|
76
|
+
permissions: tuple[str, ...] = ()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Report:
|
|
81
|
+
"""一次同步的结论(命令打印它、启动日志挑着打;**空报告 = 什么都没发生**)。"""
|
|
82
|
+
|
|
83
|
+
errors: list[str] = field(default_factory=list)
|
|
84
|
+
created_permissions: list[str] = field(default_factory=list)
|
|
85
|
+
permission_name_differs: list[str] = field(default_factory=list)
|
|
86
|
+
missing_models: list[str] = field(default_factory=list)
|
|
87
|
+
created_groups: list[str] = field(default_factory=list)
|
|
88
|
+
added: list[str] = field(default_factory=list)
|
|
89
|
+
missing: list[str] = field(default_factory=list)
|
|
90
|
+
dry_run: bool = False
|
|
91
|
+
|
|
92
|
+
def changed(self) -> bool:
|
|
93
|
+
return bool(
|
|
94
|
+
self.created_permissions or self.created_groups or self.added or self.missing
|
|
95
|
+
or self.missing_models or self.permission_name_differs or self.errors
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def lines(self, *, verbose: bool = False) -> list[str]:
|
|
99
|
+
"""报告 → 中文逐行(命令与启动日志共用这一份,别在调用点各拼一套)。
|
|
100
|
+
|
|
101
|
+
:param verbose: 连"名字不一致"这种**每次启动都会出现**的偏差也打(只给命令用;
|
|
102
|
+
启动日志打它等于每次开机刷一行,久了就没人看了)。
|
|
103
|
+
"""
|
|
104
|
+
prefix = '(dry-run,没有写库)' if self.dry_run else ''
|
|
105
|
+
out: list[str] = []
|
|
106
|
+
for error in self.errors:
|
|
107
|
+
out.append(f'✗ {error}')
|
|
108
|
+
for code in self.created_permissions:
|
|
109
|
+
out.append(f'{prefix}新建权限 {code}')
|
|
110
|
+
if verbose:
|
|
111
|
+
for code in self.permission_name_differs:
|
|
112
|
+
out.append(f'{prefix}权限 {code} 已有行但中文名与声明不一致(**不改**:删改只能人来)')
|
|
113
|
+
for code in self.missing_models:
|
|
114
|
+
out.append(f'{prefix}✗ 声明里的模型不存在:{code}(权限行建不了)')
|
|
115
|
+
for name in self.created_groups:
|
|
116
|
+
out.append(f'{prefix}新建权限包 {name}')
|
|
117
|
+
for item in self.added:
|
|
118
|
+
out.append(f'{prefix}把 {item} 放进权限包')
|
|
119
|
+
for item in self.missing:
|
|
120
|
+
out.append(f'{prefix}✗ 权限包要的码不存在:{item}(先在模型 Meta.permissions / '
|
|
121
|
+
f'{PERMISSIONS_ENV} 里声明,再 migrate)')
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _split(raw: str) -> list[str]:
|
|
126
|
+
"""按 `;` 切条目(空条目忽略 —— 结尾多一个 `;` 是手滑,不是错误)。"""
|
|
127
|
+
return [item.strip() for item in str(raw or '').split(';') if item.strip()]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _split_codes(raw: str) -> list[str]:
|
|
131
|
+
"""按 `,` 切权限码(空段**报错**:`a,,b` 多半是手滑,跳过会让一个码悄悄没进包)。"""
|
|
132
|
+
items = [item.strip() for item in str(raw or '').split(',')]
|
|
133
|
+
if any(not item for item in items):
|
|
134
|
+
raise AccessError(f'权限码列表里有空段({raw!r})—— 用逗号分隔,别留空项')
|
|
135
|
+
return items
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse_permissions(raw: str) -> tuple[PermissionSpec, ...]:
|
|
139
|
+
"""`LABULL_PERMISSIONS` 的值 → 声明(格式不对抛 `AccessError`,中文原话)。"""
|
|
140
|
+
specs: list[PermissionSpec] = []
|
|
141
|
+
seen: set[str] = set()
|
|
142
|
+
for entry in _split(raw):
|
|
143
|
+
if '=' not in entry:
|
|
144
|
+
raise AccessError(
|
|
145
|
+
f'`{PERMISSIONS_ENV}` 的条目 {entry!r} 不是 `app_label.Model=codename:中文名`'
|
|
146
|
+
f'(少了那个 `=`)'
|
|
147
|
+
)
|
|
148
|
+
target, codes = entry.split('=', 1)
|
|
149
|
+
target = target.strip()
|
|
150
|
+
model_parts = target.split('.')
|
|
151
|
+
if len(model_parts) != 2 or not all(_IDENT.match(part) for part in model_parts):
|
|
152
|
+
raise AccessError(
|
|
153
|
+
f'`{PERMISSIONS_ENV}` 里的 {target!r} 不是 `app_label.Model` 的形状 —— '
|
|
154
|
+
f'要写**真实的模型**(权限行必须挂在一个 content_type 上),例如 '
|
|
155
|
+
f'`canvasdemo.Reimbursement`'
|
|
156
|
+
)
|
|
157
|
+
app_label, model = model_parts
|
|
158
|
+
for item in _split_codes(codes):
|
|
159
|
+
if ':' not in item:
|
|
160
|
+
raise AccessError(
|
|
161
|
+
f'`{PERMISSIONS_ENV}` 里 {target} 的 {item!r} 少了中文名 —— '
|
|
162
|
+
f'写法是 `codename:中文名`(管理员在组里看到的就是它)'
|
|
163
|
+
)
|
|
164
|
+
codename, name = item.split(':', 1)
|
|
165
|
+
codename, name = codename.strip(), name.strip()
|
|
166
|
+
if not _IDENT.match(codename) or len(codename) > CODENAME_MAX:
|
|
167
|
+
raise AccessError(
|
|
168
|
+
f'`{PERMISSIONS_ENV}` 里的 codename {codename!r} 不是标识符'
|
|
169
|
+
f'(字母/数字/下划线,≤{CODENAME_MAX})'
|
|
170
|
+
)
|
|
171
|
+
if not name or len(name) > NAME_MAX:
|
|
172
|
+
raise AccessError(
|
|
173
|
+
f'`{PERMISSIONS_ENV}` 里 {target}.{codename} 的中文名要写、且 ≤{NAME_MAX} 字'
|
|
174
|
+
)
|
|
175
|
+
code = f'{app_label}.{codename}'
|
|
176
|
+
if code in seen:
|
|
177
|
+
raise AccessError(
|
|
178
|
+
f'`{PERMISSIONS_ENV}` 里权限码 {code} 声明了两次(同一个码只能出现一次)'
|
|
179
|
+
)
|
|
180
|
+
seen.add(code)
|
|
181
|
+
specs.append(PermissionSpec(app_label=app_label, model=model, codename=codename, name=name))
|
|
182
|
+
return tuple(specs)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parse_groups(raw: str) -> tuple[GroupSpec, ...]:
|
|
186
|
+
"""`LABULL_GROUPS` 的值 → 声明(格式不对抛 `AccessError`,中文原话)。"""
|
|
187
|
+
specs: list[GroupSpec] = []
|
|
188
|
+
seen: set[str] = set()
|
|
189
|
+
for entry in _split(raw):
|
|
190
|
+
if '=' not in entry:
|
|
191
|
+
raise AccessError(
|
|
192
|
+
f'`{GROUPS_ENV}` 的条目 {entry!r} 不是 `组名=app_label.codename[,…]`(少了那个 `=`)'
|
|
193
|
+
)
|
|
194
|
+
name, codes = entry.split('=', 1)
|
|
195
|
+
name = name.strip()
|
|
196
|
+
if not name or len(name) > GROUP_NAME_MAX:
|
|
197
|
+
raise AccessError(
|
|
198
|
+
f'`{GROUPS_ENV}` 里的组名 {name!r} 要写、且 ≤{GROUP_NAME_MAX} 字'
|
|
199
|
+
f'(Django `Group.name` 的上限)'
|
|
200
|
+
)
|
|
201
|
+
if name in seen:
|
|
202
|
+
raise AccessError(
|
|
203
|
+
f'`{GROUPS_ENV}` 里权限包「{name}」声明了两次 —— 一个包的所有码写在同一个条目里'
|
|
204
|
+
)
|
|
205
|
+
seen.add(name)
|
|
206
|
+
codes = codes.strip()
|
|
207
|
+
if codes:
|
|
208
|
+
items = _split_codes(codes)
|
|
209
|
+
for code in items:
|
|
210
|
+
if not PERMISSION_PATTERN.match(code):
|
|
211
|
+
raise AccessError(
|
|
212
|
+
f'`{GROUPS_ENV}` 里「{name}」的 {code!r} 不是 `app_label.codename` 的形状 —— '
|
|
213
|
+
f'环境变量里没有"当前 app"这个概念,要写全(例如 canvasdemo.review_reimbursement)'
|
|
214
|
+
)
|
|
215
|
+
specs.append(GroupSpec(name=name, permissions=tuple(items)))
|
|
216
|
+
else:
|
|
217
|
+
# 空包是合法的(先建组、权限后补);只是它一个码都不带。
|
|
218
|
+
specs.append(GroupSpec(name=name, permissions=()))
|
|
219
|
+
return tuple(specs)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def declared_permissions() -> tuple[PermissionSpec, ...]:
|
|
223
|
+
"""环境里声明的权限(格式错抛 `AccessError`;没配就是空)。"""
|
|
224
|
+
return parse_permissions(read_env(PERMISSIONS_ENV))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def declared_groups() -> tuple[GroupSpec, ...]:
|
|
228
|
+
"""环境里声明的权限包(格式错抛 `AccessError`;没配就是空)。"""
|
|
229
|
+
return parse_groups(read_env(GROUPS_ENV))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def declared_permission_codes() -> set[str]:
|
|
233
|
+
"""声明的权限码(**不碰数据库**;格式坏掉就当空 —— 判据那一层另有地方报它)。"""
|
|
234
|
+
try:
|
|
235
|
+
return {spec.code for spec in declared_permissions()}
|
|
236
|
+
except AccessError:
|
|
237
|
+
return set()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def declaration_errors() -> list[str]:
|
|
241
|
+
"""两个变量各自的格式问题(**不碰数据库**:系统检查用,`migrate` / `runserver` 就能看见)。"""
|
|
242
|
+
errors: list[str] = []
|
|
243
|
+
for env_name, parser in ((PERMISSIONS_ENV, parse_permissions), (GROUPS_ENV, parse_groups)):
|
|
244
|
+
raw = read_env(env_name)
|
|
245
|
+
if not raw:
|
|
246
|
+
continue
|
|
247
|
+
try:
|
|
248
|
+
parser(raw)
|
|
249
|
+
except AccessError as exc:
|
|
250
|
+
errors.append(str(exc))
|
|
251
|
+
return errors
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def known_group_names() -> set[str] | None:
|
|
255
|
+
"""项目里"有哪些组"(声明的 ∪ 库里现有的);取不到任何一处 ⇒ `None`(调用方跳过判据)。
|
|
256
|
+
|
|
257
|
+
🔴 为什么要并上库里的:组是**只增不删**的 —— 没在 `LABULL_GROUPS` 里声明的组
|
|
258
|
+
(管理员手建的)照样合法;把"没声明"当成"不存在"会把它误判掉。
|
|
259
|
+
"""
|
|
260
|
+
names: set[str] = set()
|
|
261
|
+
known = False
|
|
262
|
+
if read_env(GROUPS_ENV).strip():
|
|
263
|
+
try:
|
|
264
|
+
names.update(spec.name for spec in declared_groups())
|
|
265
|
+
known = True
|
|
266
|
+
except AccessError:
|
|
267
|
+
pass
|
|
268
|
+
try:
|
|
269
|
+
from django.contrib.auth.models import Group
|
|
270
|
+
|
|
271
|
+
names.update(Group.objects.values_list('name', flat=True))
|
|
272
|
+
known = True
|
|
273
|
+
except Exception: # noqa: BLE001 —— 没装 Django / 还没 migrate 都是正常情况
|
|
274
|
+
pass
|
|
275
|
+
return names if known else None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def permissions_catalog() -> list[dict[str, str]]:
|
|
279
|
+
"""项目里**声明过的业务权限**(按码排序):`Meta.permissions` ∪ `.env` 的 `LABULL_PERMISSIONS`。
|
|
280
|
+
|
|
281
|
+
每条:`{'code', 'app_label', 'model', 'name', 'source'}`。
|
|
282
|
+
|
|
283
|
+
🔴 Django 自动生成的 `add_/change_/delete_/view_` **不在里面**:那是模型级 CRUD,
|
|
284
|
+
不是业务权限(拿它当业务权限目录是越界,见 `devkit` 的硬规矩第 1 条)。
|
|
285
|
+
🔴 不读数据库(导出不该依赖"这台机器上库迁移到哪一步了"):真源是代码与环境变量。
|
|
286
|
+
"""
|
|
287
|
+
rows: dict[str, dict[str, str]] = {}
|
|
288
|
+
try:
|
|
289
|
+
from django.apps import apps
|
|
290
|
+
|
|
291
|
+
for model in apps.get_models():
|
|
292
|
+
meta = model._meta
|
|
293
|
+
for codename, name in meta.permissions:
|
|
294
|
+
code = f'{meta.app_label}.{codename}'
|
|
295
|
+
rows.setdefault(code, {
|
|
296
|
+
'code': code,
|
|
297
|
+
'app_label': meta.app_label,
|
|
298
|
+
'model': str(meta.object_name or meta.model_name),
|
|
299
|
+
'name': str(name),
|
|
300
|
+
'source': 'Meta.permissions',
|
|
301
|
+
})
|
|
302
|
+
except Exception: # noqa: BLE001 —— 没有 Django 时就只剩环境变量那一半
|
|
303
|
+
pass
|
|
304
|
+
try:
|
|
305
|
+
for spec in declared_permissions():
|
|
306
|
+
# 环境变量那一份**覆盖**同名码的展示信息(它是显式声明的,人写的那一份为准)。
|
|
307
|
+
rows[spec.code] = {
|
|
308
|
+
'code': spec.code,
|
|
309
|
+
'app_label': spec.app_label,
|
|
310
|
+
'model': spec.model,
|
|
311
|
+
'name': spec.name,
|
|
312
|
+
'source': PERMISSIONS_ENV,
|
|
313
|
+
}
|
|
314
|
+
except AccessError:
|
|
315
|
+
pass
|
|
316
|
+
return [rows[code] for code in sorted(rows)]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def groups_catalog() -> list[dict[str, Any]]:
|
|
320
|
+
"""环境里声明的权限包:`[{'name': 组名, 'permissions': [码, …]}, …]`(没配就是空)。
|
|
321
|
+
|
|
322
|
+
🔴 只读**声明**、不读库:导出那一刻铺给块作者的是"这个项目声明了哪些包";
|
|
323
|
+
库里手建的组不进来(它们是操作动作,不是契约)。
|
|
324
|
+
"""
|
|
325
|
+
try:
|
|
326
|
+
return [
|
|
327
|
+
{'name': spec.name, 'permissions': list(spec.permissions)}
|
|
328
|
+
for spec in declared_groups()
|
|
329
|
+
]
|
|
330
|
+
except AccessError:
|
|
331
|
+
return []
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _model_class(app_label: str, model: str) -> Any:
|
|
335
|
+
"""声明里那个模型(拿不到就 `None`)。"""
|
|
336
|
+
try:
|
|
337
|
+
from django.apps import apps
|
|
338
|
+
|
|
339
|
+
return apps.get_model(app_label, model)
|
|
340
|
+
except Exception: # noqa: BLE001 —— 见调用处:报进 `missing_models`,不抛
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def sync(*, dry_run: bool = False) -> Report:
|
|
345
|
+
"""把声明同步进库(**只增不删、幂等、永不抛**)。回一份 `Report`。
|
|
346
|
+
|
|
347
|
+
🔴 顺序是契约:**先权限行、后组**(组要拿权限行去塞 M2M)。
|
|
348
|
+
🔴 库还没迁移 / 连不上 ⇒ 结论进 `errors`,不抛:这是启动路径上的一件事。
|
|
349
|
+
"""
|
|
350
|
+
report = Report(dry_run=dry_run)
|
|
351
|
+
try:
|
|
352
|
+
permission_specs = declared_permissions()
|
|
353
|
+
group_specs = declared_groups()
|
|
354
|
+
except AccessError as exc:
|
|
355
|
+
report.errors.append(str(exc))
|
|
356
|
+
return report
|
|
357
|
+
if not permission_specs and not group_specs:
|
|
358
|
+
return report
|
|
359
|
+
try:
|
|
360
|
+
from django.contrib.auth.models import Group, Permission
|
|
361
|
+
from django.contrib.contenttypes.models import ContentType
|
|
362
|
+
except Exception as exc: # noqa: BLE001 —— 没装 Django 时调用方本来就不该调它
|
|
363
|
+
report.errors.append(f'同步权限声明失败({type(exc).__name__}: {exc})')
|
|
364
|
+
return report
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
# ── ① 权限行:先建出来(组要拿它去塞 M2M) ──
|
|
368
|
+
rows: dict[str, Any] = {}
|
|
369
|
+
#: dry-run 下"这一次会建出来"的那些码:组那一段要把它们当成"可用",
|
|
370
|
+
#: 否则一次全新环境的 dry-run 会把每个码都报成 missing(那是假错)。
|
|
371
|
+
planned: set[str] = set()
|
|
372
|
+
for spec in permission_specs:
|
|
373
|
+
existing = Permission.objects.filter(
|
|
374
|
+
content_type__app_label=spec.app_label, codename=spec.codename,
|
|
375
|
+
).first()
|
|
376
|
+
if existing is not None:
|
|
377
|
+
rows[spec.code] = existing
|
|
378
|
+
if existing.name != spec.name:
|
|
379
|
+
report.permission_name_differs.append(spec.code)
|
|
380
|
+
continue
|
|
381
|
+
model_cls = _model_class(spec.app_label, spec.model)
|
|
382
|
+
if model_cls is None:
|
|
383
|
+
report.missing_models.append(f'{spec.app_label}.{spec.model}')
|
|
384
|
+
continue
|
|
385
|
+
if dry_run:
|
|
386
|
+
report.created_permissions.append(spec.code)
|
|
387
|
+
planned.add(spec.code)
|
|
388
|
+
continue
|
|
389
|
+
content_type = ContentType.objects.get_for_model(model_cls)
|
|
390
|
+
permission, _created = Permission.objects.get_or_create(
|
|
391
|
+
content_type=content_type, codename=spec.codename, defaults={'name': spec.name},
|
|
392
|
+
)
|
|
393
|
+
rows[spec.code] = permission
|
|
394
|
+
if _created:
|
|
395
|
+
report.created_permissions.append(spec.code)
|
|
396
|
+
elif permission.name != spec.name:
|
|
397
|
+
report.permission_name_differs.append(spec.code)
|
|
398
|
+
|
|
399
|
+
# ── ② 组:缺就建;权限只 add 缺的那些 ──
|
|
400
|
+
for spec in group_specs:
|
|
401
|
+
group = Group.objects.filter(name=spec.name).first()
|
|
402
|
+
if group is None:
|
|
403
|
+
if dry_run:
|
|
404
|
+
report.created_groups.append(spec.name)
|
|
405
|
+
else:
|
|
406
|
+
group, created = Group.objects.get_or_create(name=spec.name)
|
|
407
|
+
if created:
|
|
408
|
+
report.created_groups.append(spec.name)
|
|
409
|
+
held = set() if group is None else set(group.permissions.values_list('id', flat=True))
|
|
410
|
+
for code in spec.permissions:
|
|
411
|
+
permission = rows.get(code)
|
|
412
|
+
if permission is None and code in planned:
|
|
413
|
+
# dry-run:这个码这一次会被建出来,组那一边的计划里就该有它。
|
|
414
|
+
report.added.append(f'{spec.name} ← {code}')
|
|
415
|
+
continue
|
|
416
|
+
if permission is None:
|
|
417
|
+
app_label, _, codename = code.partition('.')
|
|
418
|
+
permission = Permission.objects.filter(
|
|
419
|
+
content_type__app_label=app_label, codename=codename,
|
|
420
|
+
).first()
|
|
421
|
+
if permission is None:
|
|
422
|
+
report.missing.append(f'{spec.name} ← {code}')
|
|
423
|
+
continue
|
|
424
|
+
if permission.id in held:
|
|
425
|
+
continue
|
|
426
|
+
report.added.append(f'{spec.name} ← {code}')
|
|
427
|
+
if not dry_run and group is not None:
|
|
428
|
+
group.permissions.add(permission)
|
|
429
|
+
held.add(permission.id)
|
|
430
|
+
except Exception as exc: # noqa: BLE001 —— 启动路径:失败只进报告与日志
|
|
431
|
+
report.errors.append(f'同步权限声明失败({type(exc).__name__}: {exc})')
|
|
432
|
+
return report
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def log_report(report: Report) -> None:
|
|
436
|
+
"""把一份报告写进日志(启动路径用;空报告**一行都不打**)。"""
|
|
437
|
+
for line in report.lines():
|
|
438
|
+
logger.info(line)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ──────────────────────────── 两个触发点(与 `magic_block/health.py` 同一个模式) ────────────────────────────
|
|
442
|
+
#
|
|
443
|
+
# 🔴 `startup_once()`(wsgi.py 那一行)是**保证点**:部署总是先 `migrate` 再起服务,
|
|
444
|
+
# 所以第一个请求进来之前组一定已经对过账。
|
|
445
|
+
# 🔴 `post_migrate` 那一条**不是保证**,它是"`migrate` 之后顺手补一次":
|
|
446
|
+
# `post_migrate` 按每个 AppConfig 各发一次,而接收器按**连接顺序**被调 ——
|
|
447
|
+
# `labull_framework` 若排在 `INSTALLED_APPS` 前面,它的接收器就比
|
|
448
|
+
# `django.contrib.auth` 的 `create_permissions` **先连上**,同一次发信号里先被调
|
|
449
|
+
# (全新库第一次发信号时 `auth_permission` 还是空表)。所以这里**不按 sender 过滤**:
|
|
450
|
+
# 每次发信号都试一次,成功(没有缺码 / 没有格式错)就上进程内 latch。
|
|
451
|
+
|
|
452
|
+
#: 那个接收器的 `dispatch_uid`(幂等:`ready()` 可能被跑两次)。
|
|
453
|
+
_HOOK_ID = 'labull_framework.access'
|
|
454
|
+
|
|
455
|
+
#: `post_migrate` 这一路"已经成功对过账"的进程内 latch。
|
|
456
|
+
_SYNCED = False
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def install_hook() -> None:
|
|
460
|
+
"""把"迁移之后对一次账"挂到 `post_migrate` 上(幂等;**不写 `sender=`**,理由见上)。"""
|
|
461
|
+
from django.db.models.signals import post_migrate
|
|
462
|
+
|
|
463
|
+
post_migrate.connect(_on_post_migrate, dispatch_uid=_HOOK_ID)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _on_post_migrate(sender: object, **kwargs: object) -> None:
|
|
467
|
+
"""`post_migrate` 接收器:能对账就对上,对不上(权限行还没建)就等下一次发信号。"""
|
|
468
|
+
global _SYNCED
|
|
469
|
+
if _SYNCED:
|
|
470
|
+
return
|
|
471
|
+
try:
|
|
472
|
+
report = sync()
|
|
473
|
+
log_report(report)
|
|
474
|
+
if report.errors or report.missing_models or report.missing:
|
|
475
|
+
return
|
|
476
|
+
except Exception as exc: # noqa: BLE001 —— 迁移路径上的失败不该带崩 `migrate`
|
|
477
|
+
logger.error('迁移之后同步权限声明失败(不影响迁移):%s: %s', type(exc).__name__, exc)
|
|
478
|
+
return
|
|
479
|
+
_SYNCED = True
|
|
@@ -30,6 +30,11 @@ class LabullConfig(AppConfig):
|
|
|
30
30
|
def ready(self) -> None:
|
|
31
31
|
# 延迟 import:此刻 app 注册表已经就绪,但避免模块级 import 造成加载顺序问题。
|
|
32
32
|
# 钩子是"库自己正常工作"的一部分(体检库里存着的块),**不是**对使用者项目的检查。
|
|
33
|
+
from . import access, checks # noqa: F401 —— `checks` 的副作用是注册系统检查
|
|
33
34
|
from .magic_block import health
|
|
34
35
|
|
|
35
36
|
health.install_hook()
|
|
37
|
+
# 授权声明(LABULL_PERMISSIONS / LABULL_GROUPS)的"迁移之后对一次账":
|
|
38
|
+
# 与体检那个钩子同一个模式(`post_migrate` + 进程内 latch),但**不按 sender 过滤**
|
|
39
|
+
# (理由见 access.py 顶上那一段"两个触发点")。
|
|
40
|
+
access.install_hook()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""平台自己的**系统检查**(`manage.py check` / `migrate` / `runserver` 都会跑它)。
|
|
2
|
+
|
|
3
|
+
现在只有一条:`LABULL_PERMISSIONS` / `LABULL_GROUPS` 的**格式**。
|
|
4
|
+
|
|
5
|
+
🔴 为什么要有它:那两个变量的格式是契约(`组名=app_label.codename` 之类),写错了
|
|
6
|
+
`access.sync()` 只会把一句错误放进日志 —— 而启动日志没人看。做成系统检查之后,
|
|
7
|
+
`migrate` / `runserver` 的输出里就会带一行 Warning,照它改就行。
|
|
8
|
+
🔴 它**只判格式、不碰数据库**(系统检查跑在 `migrate` 之前,那时表还不存在):
|
|
9
|
+
"声明了但库里没有这个码"那件事归 `manage.py labull_access --check`。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from django.core.checks import Warning, register
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@register()
|
|
18
|
+
def access_declarations(app_configs=None, **kwargs) -> list[Warning]:
|
|
19
|
+
"""`LABULL_PERMISSIONS` / `LABULL_GROUPS` 格式不对 ⇒ 各报一条 Warning(中文原话)。"""
|
|
20
|
+
from . import access
|
|
21
|
+
|
|
22
|
+
return [
|
|
23
|
+
Warning(text, id='labull.W001')
|
|
24
|
+
for text in access.declaration_errors()
|
|
25
|
+
]
|
|
@@ -158,7 +158,8 @@ ENV_ITEMS: tuple[EnvItem, ...] = (
|
|
|
158
158
|
# ── 平台开关 ──
|
|
159
159
|
EnvItem(
|
|
160
160
|
name='BOOTSTRAP_ADMIN_WORK_IDS',
|
|
161
|
-
brief='
|
|
161
|
+
brief='管理员名单:启动时把这些人置为 **Django 的 `is_staff`**("能不能编辑魔法块"就看这一列),'
|
|
162
|
+
'名单之外的一律摘掉(逗号分隔,上限 200;没配就是没有管理员)',
|
|
162
163
|
hint='BOOTSTRAP_ADMIN_WORK_IDS=1001',
|
|
163
164
|
group='平台开关',
|
|
164
165
|
),
|
|
@@ -223,6 +224,24 @@ ENV_ITEMS: tuple[EnvItem, ...] = (
|
|
|
223
224
|
hint='LABULL_BLOCK_WORKERS=2',
|
|
224
225
|
group='平台开关',
|
|
225
226
|
),
|
|
227
|
+
# ── 授权(权限码 / 权限包):**声明在这里,落库由框架同步** ──
|
|
228
|
+
# 🔴 为什么是环境变量而不是迁移:迁移里的 `RunPython` 跑在 `create_permissions` **之前**,
|
|
229
|
+
# 要拿权限行去塞组就得自己再建一遍权限行(那是第二份真源)。这里只在启动时对一次账。
|
|
230
|
+
# 🔴 同步**只增不删**:要删组 / 删权限,只能人自己在库里删(删组会级联掉人的授权)。
|
|
231
|
+
EnvItem(
|
|
232
|
+
name='LABULL_PERMISSIONS',
|
|
233
|
+
brief='权限声明:`app_label.Model=codename:中文名[,codename:中文名…]`,多个模型用 `;` 分隔'
|
|
234
|
+
'(模型那一段只用来定位 content_type,对外暴露的码仍是 `app_label.codename`)',
|
|
235
|
+
hint='LABULL_PERMISSIONS=canvasdemo.Reimbursement=review_reimbursement:可以审批报销单',
|
|
236
|
+
group='授权(权限码 / 权限包)',
|
|
237
|
+
),
|
|
238
|
+
EnvItem(
|
|
239
|
+
name='LABULL_GROUPS',
|
|
240
|
+
brief='权限包(组)声明:`组名=app_label.codename[,app_label.codename…]`,多个包用 `;` 分隔'
|
|
241
|
+
'(组名只是标签;启动时把组与它的权限同步进库,只增不删)',
|
|
242
|
+
hint='LABULL_GROUPS=财务=canvasdemo.review_reimbursement,canvasdemo.pay_reimbursement',
|
|
243
|
+
group='授权(权限码 / 权限包)',
|
|
244
|
+
),
|
|
226
245
|
# ── 块的配置面 ──
|
|
227
246
|
EnvItem(
|
|
228
247
|
name=f'{BLOCK_CONFIG_PREFIX}*',
|
|
@@ -23,7 +23,13 @@ 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
|
|
26
|
+
from .permissions import (
|
|
27
|
+
PERMISSION_PATTERN,
|
|
28
|
+
permission_list_text,
|
|
29
|
+
permissions_of,
|
|
30
|
+
project_permissions,
|
|
31
|
+
split_permission,
|
|
32
|
+
)
|
|
27
33
|
|
|
28
34
|
# ──────────────────────────── 平台认的那些常量(唯一真源) ────────────────────────────
|
|
29
35
|
|
|
@@ -613,27 +619,16 @@ class Flow:
|
|
|
613
619
|
def _permission_table() -> set[str] | None:
|
|
614
620
|
"""项目的 permission 清单(`app_label.codename`);**读不到就回 `None`**。
|
|
615
621
|
|
|
616
|
-
🔴 `
|
|
622
|
+
🔴 真源是 `permissions.project_permissions()`(**只有那一处**):`LABULL_PERMISSIONS` 的声明
|
|
623
|
+
∪ app registry 的 `Meta.permissions` ∪ `auth_permission` 现有的行。
|
|
624
|
+
也就是说:**不需要先 migrate** 就能判(`Meta.permissions` 是代码);也算上环境变量里
|
|
625
|
+
声明、还没落库的那些码。
|
|
626
|
+
🔴 `None` 的两种正常来源:块作者本地没有项目上下文 / 三处都读不到。
|
|
617
627
|
那时 D10 / D13 只判语法、不判白名单 —— 不许把"读不到"当成"一条都没有"
|
|
618
628
|
(那会把一条干净的流程判成非法)。
|
|
619
629
|
🔴 表在、但一条都没有 ⇒ 回**空集**(不是 `None`):那是"项目确实还没声明任何权限",该拒。
|
|
620
630
|
"""
|
|
621
|
-
|
|
622
|
-
from django.contrib.auth.models import Permission
|
|
623
|
-
|
|
624
|
-
rows = Permission.objects.values_list('content_type__app_label', 'codename')
|
|
625
|
-
return {f'{app_label}.{codename}' for app_label, codename in rows}
|
|
626
|
-
except Exception: # noqa: BLE001 —— 见 docstring:表还没迁移是正常情况
|
|
627
|
-
return None
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
def _permission_list_text(table: Iterable[str], limit: int = 40) -> str:
|
|
631
|
-
"""一句"当前项目有哪些 permission"(太长就截断:报文要能读,不能糊一屏)。"""
|
|
632
|
-
names = sorted(str(item) for item in table)
|
|
633
|
-
if not names:
|
|
634
|
-
return '(一条都没有)'
|
|
635
|
-
shown = ' / '.join(names[:limit])
|
|
636
|
-
return shown if len(names) <= limit else f'{shown} …(共 {len(names)} 个)'
|
|
631
|
+
return project_permissions()
|
|
637
632
|
|
|
638
633
|
|
|
639
634
|
def _check_permissions(subject: str, codes: Iterable[str], table: set[str] | None) -> None:
|
|
@@ -646,12 +641,14 @@ def _check_permissions(subject: str, codes: Iterable[str], table: set[str] | Non
|
|
|
646
641
|
if not table:
|
|
647
642
|
raise FlowError(
|
|
648
643
|
f'{subject} 用了 permission {" / ".join(unknown)} —— 本项目现在**一条 permission 都没有**。'
|
|
649
|
-
f'
|
|
650
|
-
f'
|
|
644
|
+
f'在模型的 `Meta.permissions` 里加一条(例如 (\'approve\', \'可以审批\')),'
|
|
645
|
+
f'或在 `.env` 的 LABULL_PERMISSIONS 里声明一条,之后就有 `<app_label>.approve` 这个码;'
|
|
646
|
+
f'flow.py 里只写 permission:<app_label>.approve。'
|
|
651
647
|
)
|
|
652
648
|
raise FlowError(
|
|
653
649
|
f'{subject} 里有不认识的 permission {" / ".join(unknown)} —— 当前项目有的是 '
|
|
654
|
-
f'{
|
|
650
|
+
f'{permission_list_text(table)}(在模型的 `Meta.permissions` 里声明,'
|
|
651
|
+
f'或在 `.env` 的 LABULL_PERMISSIONS 里声明)'
|
|
655
652
|
)
|
|
656
653
|
|
|
657
654
|
|