labull-framework 3.0.3__tar.gz → 3.0.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {labull_framework-3.0.3/labull_framework.egg-info → labull_framework-3.0.5}/PKG-INFO +1 -1
  2. labull_framework-3.0.5/labull_framework/access.py +561 -0
  3. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/apps.py +5 -0
  4. labull_framework-3.0.5/labull_framework/checks.py +25 -0
  5. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/env.py +15 -1
  6. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/flow.py +18 -21
  7. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/blockcheck.py +28 -0
  8. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/devkit.py +108 -17
  9. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/health.py +7 -1
  10. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/manifest.py +5 -5
  11. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/requires.py +134 -1
  12. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/sdk.py +7 -2
  13. labull_framework-3.0.5/labull_framework/management/commands/labull_access.py +69 -0
  14. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/management/commands/labull_requires.py +6 -5
  15. labull_framework-3.0.5/labull_framework/permissions.py +114 -0
  16. {labull_framework-3.0.3 → labull_framework-3.0.5/labull_framework.egg-info}/PKG-INFO +1 -1
  17. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework.egg-info/SOURCES.txt +3 -0
  18. {labull_framework-3.0.3 → labull_framework-3.0.5}/pyproject.toml +1 -1
  19. labull_framework-3.0.3/labull_framework/permissions.py +0 -51
  20. {labull_framework-3.0.3 → labull_framework-3.0.5}/LICENSE +0 -0
  21. {labull_framework-3.0.3 → labull_framework-3.0.5}/MANIFEST.in +0 -0
  22. {labull_framework-3.0.3 → labull_framework-3.0.5}/README-pypi.md +0 -0
  23. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/__init__.py +0 -0
  24. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/admin.py +0 -0
  25. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/auth/__init__.py +0 -0
  26. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/auth/backend.py +0 -0
  27. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/auth/bootstrap.py +0 -0
  28. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/auth/sso.py +0 -0
  29. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/auth/views.py +0 -0
  30. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/canvas/__init__.py +0 -0
  31. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/canvas/data.py +0 -0
  32. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/canvas/gitinfo.py +0 -0
  33. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/canvas/shape_ast.py +0 -0
  34. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/canvas/static/canvas.css +0 -0
  35. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/canvas/static/canvas.js +0 -0
  36. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/canvas/static/page.html +0 -0
  37. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/canvas/views.py +0 -0
  38. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/jsonio.py +0 -0
  39. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/__init__.py +0 -0
  40. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/blockpackage.py +0 -0
  41. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/blocktypes.py +0 -0
  42. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/deepcheck.py +0 -0
  43. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/dispatch.py +0 -0
  44. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/export.py +0 -0
  45. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/parallel.py +0 -0
  46. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/registry.py +0 -0
  47. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/service.py +0 -0
  48. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/subproc.py +0 -0
  49. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/types/__init__.py +0 -0
  50. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/types/plain.py +0 -0
  51. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/types/workflow.py +0 -0
  52. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/validate.py +0 -0
  53. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/magic_block/views.py +0 -0
  54. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/management/__init__.py +0 -0
  55. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/management/commands/__init__.py +0 -0
  56. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/management/commands/labull_blocktype.py +0 -0
  57. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/management/commands/labull_contract.py +0 -0
  58. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/migrations/0001_initial.py +0 -0
  59. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/migrations/0002_remove_magicblock_prev_files_and_more.py +0 -0
  60. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/migrations/0003_magicblock_tags.py +0 -0
  61. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/migrations/0004_magicblock_detail.py +0 -0
  62. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/migrations/0005_labullrole.py +0 -0
  63. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/migrations/0006_default_groups_permissions.py +0 -0
  64. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/migrations/0007_no_builtin_permissions.py +0 -0
  65. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/migrations/__init__.py +0 -0
  66. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/models.py +0 -0
  67. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/settings.py +0 -0
  68. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/urls.py +0 -0
  69. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework/virtual.py +0 -0
  70. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework.egg-info/dependency_links.txt +0 -0
  71. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework.egg-info/requires.txt +0 -0
  72. {labull_framework-3.0.3 → labull_framework-3.0.5}/labull_framework.egg-info/top_level.txt +0 -0
  73. {labull_framework-3.0.3 → labull_framework-3.0.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 3.0.3
3
+ Version: 3.0.5
4
4
  Summary: Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks
5
5
  Author: Labull Framework
6
6
  License-Expression: MIT
@@ -0,0 +1,561 @@
1
+ """权限码与权限包(组)的**声明 → 落库**那一半 —— 声明住在项目里的一份 YAML 文件。
2
+
3
+ 默认位置:`<BASE_DIR>/labull-access.yaml`(可以用 `LABULL_ACCESS_FILE` 指到别的路径)。
4
+ 文件长这样(一份普通 YAML,**不是**环境变量里那种一行塞完的串):
5
+
6
+ ```yaml
7
+ # 权限(可选;模型 Meta.permissions 里已经声明过的不必重复)
8
+ permissions:
9
+ canvasdemo.Reimbursement:
10
+ review_reimbursement: 可以审批报销单
11
+ pay_reimbursement: 可以对报销单付款
12
+
13
+ # 权限包(组):组名 → 权限码
14
+ groups:
15
+ 财务:
16
+ - canvasdemo.review_reimbursement
17
+ - canvasdemo.pay_reimbursement
18
+ 出纳:
19
+ - canvasdemo.pay_reimbursement
20
+ ```
21
+
22
+ 🔴 **为什么 permissions 里要写模型**:`Permission` 的行主键是 `(content_type, codename)`,
23
+ 而 content_type 只能从一个**真实模型**拿到。写模型名换来的是 content_type 正确、
24
+ 与 `Meta.permissions` 指向同一行;对外暴露的码仍是 `app_label.codename`。
25
+ 🔴 **不引 PyYAML**:这个仓库不引第三方运行时依赖,所以这里只认上面这一种子集
26
+ (`键:` / `键: 值` / 缩进两格的映射或 `- 项` 列表),不认识的写法**当场报错**。
27
+ 🔴 同步**只增不删**:少一个组 / 少一个权限**不会**从库里删掉 —— 删组会级联掉
28
+ `auth_user_groups` 里人的授权,那是人手动配的数据。要删只能人自己在库里删。
29
+ 🔴 `sync()` **永不抛**:它是启动路径上的一件事(与 `auth/bootstrap.py` 同一个模式)。
30
+ 🔴 组名只是**标签**:代码里任何判据都不许读组名(判权永远看 permission)。
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import logging
36
+ import re
37
+ from dataclasses import dataclass, field
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+ from .env import read_env
42
+ from .permissions import PERMISSION_PATTERN
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ #: 声明文件的名字(默认落在项目的 `BASE_DIR` 下)。
47
+ ACCESS_FILE_NAME = 'labull-access.yaml'
48
+
49
+ #: 把声明文件指到别处(路径;相对路径按 `BASE_DIR` 解析)。
50
+ ACCESS_FILE_ENV = 'LABULL_ACCESS_FILE'
51
+
52
+ #: 一个标识符(app_label / 模型名 / codename 共用这一份判据)。
53
+ _IDENT = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
54
+
55
+ #: Django `Permission.codename` / `Permission.name` / `Group.name` 的上限(照抄模型那三列)。
56
+ CODENAME_MAX = 100
57
+ NAME_MAX = 255
58
+ GROUP_NAME_MAX = 150
59
+
60
+
61
+ class AccessError(ValueError):
62
+ """声明文件写得不对 —— 报文是中文原话,直接给人看(带行号)。"""
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class PermissionSpec:
67
+ """一条权限声明:`app_label.Model` → `codename: 中文名`。"""
68
+
69
+ app_label: str
70
+ model: str
71
+ codename: str
72
+ name: str
73
+
74
+ @property
75
+ def code(self) -> str:
76
+ """它对外暴露的那个码(`app_label.codename`)—— 与 `Meta.permissions` / `flow` 同一份。"""
77
+ return f'{self.app_label}.{self.codename}'
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class GroupSpec:
82
+ """一个权限包:组名(只是标签)+ 它打包的那几个权限码。"""
83
+
84
+ name: str
85
+ permissions: tuple[str, ...] = ()
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class Declaration:
90
+ """一份声明文件的全部内容(`permissions:` / `groups:` 都可以整块不写)。"""
91
+
92
+ permissions: tuple[PermissionSpec, ...] = ()
93
+ groups: tuple[GroupSpec, ...] = ()
94
+
95
+
96
+ @dataclass
97
+ class Report:
98
+ """一次同步的结论(命令打印它、启动日志挑着打;**空报告 = 什么都没发生**)。"""
99
+
100
+ errors: list[str] = field(default_factory=list)
101
+ created_permissions: list[str] = field(default_factory=list)
102
+ permission_name_differs: list[str] = field(default_factory=list)
103
+ missing_models: list[str] = field(default_factory=list)
104
+ created_groups: list[str] = field(default_factory=list)
105
+ added: list[str] = field(default_factory=list)
106
+ missing: list[str] = field(default_factory=list)
107
+ dry_run: bool = False
108
+
109
+ def changed(self) -> bool:
110
+ return bool(
111
+ self.created_permissions or self.created_groups or self.added or self.missing
112
+ or self.missing_models or self.permission_name_differs or self.errors
113
+ )
114
+
115
+ def lines(self, *, verbose: bool = False) -> list[str]:
116
+ """报告 → 中文逐行(命令与启动日志共用这一份,别在调用点各拼一套)。
117
+
118
+ :param verbose: 连"名字不一致"这种**每次启动都会出现**的偏差也打(只给命令用;
119
+ 启动日志打它等于每次开机刷一行,久了就没人看了)。
120
+ """
121
+ prefix = '(dry-run,没有写库)' if self.dry_run else ''
122
+ out: list[str] = []
123
+ for error in self.errors:
124
+ out.append(f'✗ {error}')
125
+ for code in self.created_permissions:
126
+ out.append(f'{prefix}新建权限 {code}')
127
+ if verbose:
128
+ for code in self.permission_name_differs:
129
+ out.append(f'{prefix}权限 {code} 已有行但中文名与声明不一致(**不改**:删改只能人来)')
130
+ for code in self.missing_models:
131
+ out.append(f'{prefix}✗ 声明里的模型不存在:{code}(权限行建不了)')
132
+ for name in self.created_groups:
133
+ out.append(f'{prefix}新建权限包 {name}')
134
+ for item in self.added:
135
+ out.append(f'{prefix}把 {item} 放进权限包')
136
+ for item in self.missing:
137
+ out.append(f'{prefix}✗ 权限包要的码不存在:{item}(先在模型的 Meta.permissions / '
138
+ f'{ACCESS_FILE_NAME} 的 permissions 里声明,再 migrate)')
139
+ return out
140
+
141
+
142
+ # ──────────────────────────── 读那一份 YAML(只认这一种子集) ────────────────────────────
143
+
144
+
145
+ def _strip_comment(line: str) -> str:
146
+ """去掉行尾注释(`#` 在引号里不算注释 —— 中文名里带 `#` 是合法的)。"""
147
+ quote = ''
148
+ for index, char in enumerate(line):
149
+ if quote:
150
+ if char == quote:
151
+ quote = ''
152
+ elif char in ('"', "'"):
153
+ quote = char
154
+ elif char == '#':
155
+ return line[:index]
156
+ return line
157
+
158
+
159
+ def _value(text: str) -> str:
160
+ """一个标量值(去掉一对引号;`''` / `""` 是合法的空串)。"""
161
+ value = text.strip()
162
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
163
+ return value[1:-1]
164
+ return value
165
+
166
+
167
+ def _error(line_no: int, message: str) -> AccessError:
168
+ return AccessError(f'{ACCESS_FILE_NAME} 第 {line_no} 行:{message}')
169
+
170
+
171
+ def parse_access(text: str) -> Declaration:
172
+ """把声明文件的正文读成 `Declaration`(格式不对抛 `AccessError`,带行号)。
173
+
174
+ 只认这一种子集(**不引 YAML 库**):
175
+
176
+ ```yaml
177
+ permissions:
178
+ app.Model:
179
+ codename: 中文名
180
+ groups:
181
+ 组名:
182
+ - app_label.codename
183
+ ```
184
+ """
185
+ permissions: list[PermissionSpec] = []
186
+ groups: list[GroupSpec] = []
187
+ seen_codes: set[str] = set()
188
+ seen_groups: set[str] = set()
189
+ section = ''
190
+ model: tuple[str, str] | None = None # (app_label, model)
191
+ group: str = '' # 当前正在收权限码的组名
192
+
193
+ for index, raw in enumerate(str(text or '').splitlines(), start=1):
194
+ line = _strip_comment(raw).rstrip()
195
+ if not line.strip():
196
+ continue
197
+ indent = len(line) - len(line.lstrip())
198
+ content = line.strip()
199
+
200
+ if indent == 0:
201
+ if not content.endswith(':'):
202
+ raise _error(index, f'顶层只许写 `permissions:` 或 `groups:`(读到 {content!r})')
203
+ key = content[:-1].strip()
204
+ if key not in ('permissions', 'groups'):
205
+ raise _error(index, f'顶层不认识的键 `{key}`:只有 permissions / groups 两块')
206
+ section = key
207
+ model = None
208
+ group = ''
209
+ continue
210
+
211
+ if not section:
212
+ raise _error(index, '这一行缩进了,但上面还没有 `permissions:` / `groups:` 那一行')
213
+
214
+ if section == 'permissions':
215
+ if indent == 2:
216
+ target = content[:-1].strip() if content.endswith(':') else ''
217
+ parts = target.split('.')
218
+ if len(parts) != 2 or not all(_IDENT.match(part) for part in parts):
219
+ raise _error(
220
+ index,
221
+ f'{content!r} 不是 `app_label.Model:` 的形状 —— 要写**真实的模型**'
222
+ f'(权限行必须挂在一个 content_type 上),例如 `canvasdemo.Reimbursement:`',
223
+ )
224
+ model = (parts[0], parts[1])
225
+ continue
226
+ if indent == 4:
227
+ if model is None:
228
+ raise _error(index, '这一行上面少了 `app_label.Model:` 那一行')
229
+ if ':' not in content:
230
+ raise _error(index, f'`{content}` 少了中文名(写法是 `codename: 中文名`)')
231
+ codename, name = content.split(':', 1)
232
+ codename, name = codename.strip(), _value(name)
233
+ if not _IDENT.match(codename) or len(codename) > CODENAME_MAX:
234
+ raise _error(
235
+ index,
236
+ f'codename `{codename}` 不是标识符(字母/数字/下划线,≤{CODENAME_MAX})',
237
+ )
238
+ if not name or len(name) > NAME_MAX:
239
+ raise _error(index, f'`{codename}` 的中文名要写、且 ≤{NAME_MAX} 字')
240
+ code = f'{model[0]}.{codename}'
241
+ if code in seen_codes:
242
+ raise _error(index, f'权限码 `{code}` 写了两次(同一个码只能出现一次)')
243
+ seen_codes.add(code)
244
+ permissions.append(PermissionSpec(
245
+ app_label=model[0], model=model[1], codename=codename, name=name,
246
+ ))
247
+ continue
248
+ raise _error(index, f'缩进不对(读到 {indent} 格):permissions 下只许两格 / 四格')
249
+
250
+ # section == 'groups'
251
+ if indent == 2:
252
+ if not content.endswith(':'):
253
+ raise _error(index, f'{content!r} 不是 `组名:` 的形状(组名后面要冒号、值缩进在下面)')
254
+ name = content[:-1].strip()
255
+ if not name or len(name) > GROUP_NAME_MAX:
256
+ raise _error(index, f'组名要写、且 ≤{GROUP_NAME_MAX} 字(Django `Group.name` 的上限)')
257
+ if name in seen_groups:
258
+ raise _error(index, f'权限包「{name}」写了两次')
259
+ seen_groups.add(name)
260
+ group = name
261
+ groups.append(GroupSpec(name=name, permissions=()))
262
+ continue
263
+ if indent == 4:
264
+ if not group:
265
+ raise _error(index, '这一行上面少了 `组名:` 那一行')
266
+ if not content.startswith('- '):
267
+ raise _error(index, f'{content!r} 要写成 `- app_label.codename`(列表项)')
268
+ code = content[2:].strip()
269
+ if not PERMISSION_PATTERN.match(code):
270
+ raise _error(
271
+ index,
272
+ f'`{code}` 不是 `app_label.codename` 的形状 —— 要写全'
273
+ f'(例如 canvasdemo.review_reimbursement)',
274
+ )
275
+ current = groups[-1]
276
+ groups[-1] = GroupSpec(name=current.name, permissions=(*current.permissions, code))
277
+ continue
278
+ raise _error(index, f'缩进不对(读到 {indent} 格):groups 下只许两格 / 四格')
279
+
280
+ return Declaration(permissions=tuple(permissions), groups=tuple(groups))
281
+
282
+
283
+ # ──────────────────────────── 文件在哪 ────────────────────────────
284
+
285
+
286
+ def _base_dir() -> Path | None:
287
+ """项目的 `BASE_DIR`(没设就退回当前工作目录 —— `manage.py` 通常就在项目根跑)。"""
288
+ try:
289
+ from django.conf import settings
290
+
291
+ base = getattr(settings, 'BASE_DIR', None)
292
+ if base:
293
+ return Path(base)
294
+ except Exception: # noqa: BLE001 —— 没配 Django 时退回 cwd
295
+ pass
296
+ try:
297
+ return Path.cwd()
298
+ except Exception: # noqa: BLE001
299
+ return None
300
+
301
+
302
+ def access_file() -> tuple[Path | None, bool]:
303
+ """声明文件在哪:`(路径, 是不是显式配的)`。
304
+
305
+ `LABULL_ACCESS_FILE` 配了就用它(相对路径按 `BASE_DIR` 解析);没配就用
306
+ `<BASE_DIR>/labull-access.yaml`。取不到任何基准目录 ⇒ `(None, False)`。
307
+ """
308
+ raw = read_env(ACCESS_FILE_ENV).strip()
309
+ base = _base_dir()
310
+ if raw:
311
+ path = Path(raw)
312
+ if not path.is_absolute() and base is not None:
313
+ path = base / path
314
+ return path, True
315
+ if base is not None:
316
+ return base / ACCESS_FILE_NAME, False
317
+ return None, False
318
+
319
+
320
+ def load() -> tuple[Declaration, list[str]]:
321
+ """读声明文件:`(声明, 错误清单)`。文件不在 ⇒ 空声明(默认位置)或一条错误(显式配的)。"""
322
+ path, explicit = access_file()
323
+ if path is None:
324
+ return Declaration(), []
325
+ if not path.is_file():
326
+ if explicit:
327
+ return Declaration(), [
328
+ f'{ACCESS_FILE_ENV} 指向 `{path}`,但那个文件不存在 —— 改对路径、或把这一项删掉'
329
+ ]
330
+ return Declaration(), []
331
+ try:
332
+ return parse_access(path.read_text(encoding='utf-8-sig')), []
333
+ except AccessError as exc:
334
+ return Declaration(), [str(exc)]
335
+ except Exception as exc: # noqa: BLE001 —— 读文件失败也只进错误清单(启动路径不抛)
336
+ return Declaration(), [f'读不了 `{path}`:{type(exc).__name__}: {exc}']
337
+
338
+
339
+ def declaration_errors() -> list[str]:
340
+ """声明文件的**格式**问题(不碰数据库:系统检查用,`migrate` / `runserver` 就能看见)。"""
341
+ return load()[1]
342
+
343
+
344
+ def declared_permission_codes() -> set[str]:
345
+ """声明文件里的权限码(**不碰数据库**;读不出来就当空 —— 判据那一层另有地方报它)。"""
346
+ return {spec.code for spec in load()[0].permissions}
347
+
348
+
349
+ def known_group_names() -> set[str] | None:
350
+ """项目里"有哪些组"(声明文件里的 ∪ 库里现有的);取不到任何一处 ⇒ `None`(调用方跳过判据)。
351
+
352
+ 🔴 为什么要并上库里的:组是**只增不删**的 —— 没在文件里声明的组(管理员手建的)照样合法;
353
+ 把"没声明"当成"不存在"会把它误判掉。
354
+ """
355
+ declaration, errors = load()
356
+ names = {spec.name for spec in declaration.groups}
357
+ known = bool(names) or not errors
358
+ try:
359
+ from django.contrib.auth.models import Group
360
+
361
+ names.update(Group.objects.values_list('name', flat=True))
362
+ known = True
363
+ except Exception: # noqa: BLE001 —— 没装 Django / 还没 migrate 都是正常情况
364
+ pass
365
+ return names if known else None
366
+
367
+
368
+ def permissions_catalog() -> list[dict[str, str]]:
369
+ """项目里**声明过的业务权限**(按码排序):`Meta.permissions` ∪ 声明文件的 `permissions:`。
370
+
371
+ 每条:`{'code', 'app_label', 'model', 'name', 'source'}`。
372
+
373
+ 🔴 Django 自动生成的 `add_/change_/delete_/view_` **不在里面**:那是模型级 CRUD,
374
+ 不是业务权限(拿它当业务权限目录是越界,见 `devkit` 的硬规矩第 1 条)。
375
+ 🔴 不读数据库(导出不该依赖"这台机器上库迁移到哪一步了"):真源是代码与声明文件。
376
+ """
377
+ rows: dict[str, dict[str, str]] = {}
378
+ try:
379
+ from django.apps import apps
380
+
381
+ for model in apps.get_models():
382
+ meta = model._meta
383
+ for codename, name in meta.permissions:
384
+ code = f'{meta.app_label}.{codename}'
385
+ rows.setdefault(code, {
386
+ 'code': code,
387
+ 'app_label': meta.app_label,
388
+ 'model': str(meta.object_name or meta.model_name),
389
+ 'name': str(name),
390
+ 'source': 'Meta.permissions',
391
+ })
392
+ except Exception: # noqa: BLE001 —— 没有 Django 时就只剩声明文件那一半
393
+ pass
394
+ for spec in load()[0].permissions:
395
+ # 声明文件那一份**覆盖**同名码的展示信息(它是显式声明的,人写的那一份为准)。
396
+ rows[spec.code] = {
397
+ 'code': spec.code,
398
+ 'app_label': spec.app_label,
399
+ 'model': spec.model,
400
+ 'name': spec.name,
401
+ 'source': ACCESS_FILE_NAME,
402
+ }
403
+ return [rows[code] for code in sorted(rows)]
404
+
405
+
406
+ def groups_catalog() -> list[dict[str, Any]]:
407
+ """声明文件里的权限包:`[{'name': 组名, 'permissions': [码, …]}, …]`(没配就是空)。
408
+
409
+ 🔴 只读**声明**、不读库:导出那一刻铺给块作者的是"这个项目声明了哪些包";
410
+ 库里手建的组不进来(它们是操作动作,不是契约)。
411
+ """
412
+ return [
413
+ {'name': spec.name, 'permissions': list(spec.permissions)}
414
+ for spec in load()[0].groups
415
+ ]
416
+
417
+
418
+ def _model_class(app_label: str, model: str) -> Any:
419
+ """声明里那个模型(拿不到就 `None`)。"""
420
+ try:
421
+ from django.apps import apps
422
+
423
+ return apps.get_model(app_label, model)
424
+ except Exception: # noqa: BLE001 —— 见调用处:报进 `missing_models`,不抛
425
+ return None
426
+
427
+
428
+ def sync(*, dry_run: bool = False) -> Report:
429
+ """把声明同步进库(**只增不删、幂等、永不抛**)。回一份 `Report`。
430
+
431
+ 🔴 顺序是契约:**先权限行、后组**(组要拿权限行去塞 M2M)。
432
+ 🔴 库还没迁移 / 连不上 ⇒ 结论进 `errors`,不抛:这是启动路径上的一件事。
433
+ """
434
+ report = Report(dry_run=dry_run)
435
+ declaration, errors = load()
436
+ report.errors.extend(errors)
437
+ if errors:
438
+ return report
439
+ if not declaration.permissions and not declaration.groups:
440
+ return report
441
+ try:
442
+ from django.contrib.auth.models import Group, Permission
443
+ from django.contrib.contenttypes.models import ContentType
444
+ except Exception as exc: # noqa: BLE001 —— 没装 Django 时调用方本来就不该调它
445
+ report.errors.append(f'同步权限声明失败({type(exc).__name__}: {exc})')
446
+ return report
447
+
448
+ try:
449
+ # ── ① 权限行:先建出来(组要拿它去塞 M2M) ──
450
+ rows: dict[str, Any] = {}
451
+ #: dry-run 下"这一次会建出来"的那些码:组那一段要把它们当成"可用",
452
+ #: 否则一次全新环境的 dry-run 会把每个码都报成 missing(那是假错)。
453
+ planned: set[str] = set()
454
+ for spec in declaration.permissions:
455
+ existing = Permission.objects.filter(
456
+ content_type__app_label=spec.app_label, codename=spec.codename,
457
+ ).first()
458
+ if existing is not None:
459
+ rows[spec.code] = existing
460
+ if existing.name != spec.name:
461
+ report.permission_name_differs.append(spec.code)
462
+ continue
463
+ model_cls = _model_class(spec.app_label, spec.model)
464
+ if model_cls is None:
465
+ report.missing_models.append(f'{spec.app_label}.{spec.model}')
466
+ continue
467
+ if dry_run:
468
+ report.created_permissions.append(spec.code)
469
+ planned.add(spec.code)
470
+ continue
471
+ content_type = ContentType.objects.get_for_model(model_cls)
472
+ permission, _created = Permission.objects.get_or_create(
473
+ content_type=content_type, codename=spec.codename, defaults={'name': spec.name},
474
+ )
475
+ rows[spec.code] = permission
476
+ if _created:
477
+ report.created_permissions.append(spec.code)
478
+ elif permission.name != spec.name:
479
+ report.permission_name_differs.append(spec.code)
480
+
481
+ # ── ② 组:缺就建;权限只 add 缺的那些 ──
482
+ for spec in declaration.groups:
483
+ group = Group.objects.filter(name=spec.name).first()
484
+ if group is None:
485
+ if dry_run:
486
+ report.created_groups.append(spec.name)
487
+ else:
488
+ group, created = Group.objects.get_or_create(name=spec.name)
489
+ if created:
490
+ report.created_groups.append(spec.name)
491
+ held = set() if group is None else set(group.permissions.values_list('id', flat=True))
492
+ for code in spec.permissions:
493
+ permission = rows.get(code)
494
+ if permission is None and code in planned:
495
+ # dry-run:这个码这一次会被建出来,组那一边的计划里就该有它。
496
+ report.added.append(f'{spec.name} ← {code}')
497
+ continue
498
+ if permission is None:
499
+ app_label, _, codename = code.partition('.')
500
+ permission = Permission.objects.filter(
501
+ content_type__app_label=app_label, codename=codename,
502
+ ).first()
503
+ if permission is None:
504
+ report.missing.append(f'{spec.name} ← {code}')
505
+ continue
506
+ if permission.id in held:
507
+ continue
508
+ report.added.append(f'{spec.name} ← {code}')
509
+ if not dry_run and group is not None:
510
+ group.permissions.add(permission)
511
+ held.add(permission.id)
512
+ except Exception as exc: # noqa: BLE001 —— 启动路径:失败只进报告与日志
513
+ report.errors.append(f'同步权限声明失败({type(exc).__name__}: {exc})')
514
+ return report
515
+
516
+
517
+ def log_report(report: Report) -> None:
518
+ """把一份报告写进日志(启动路径用;空报告**一行都不打**)。"""
519
+ for line in report.lines():
520
+ logger.info(line)
521
+
522
+
523
+ # ──────────────────────────── 两个触发点(与 `magic_block/health.py` 同一个模式) ────────────────────────────
524
+ #
525
+ # 🔴 `startup_once()`(wsgi.py 那一行)是**保证点**:部署总是先 `migrate` 再起服务,
526
+ # 所以第一个请求进来之前组一定已经对过账。
527
+ # 🔴 `post_migrate` 那一条**不是保证**,它是"`migrate` 之后顺手补一次":
528
+ # `post_migrate` 按每个 AppConfig 各发一次,而接收器按**连接顺序**被调 ——
529
+ # `labull_framework` 若排在 `INSTALLED_APPS` 前面,它的接收器就比
530
+ # `django.contrib.auth` 的 `create_permissions` **先连上**,同一次发信号里先被调
531
+ # (全新库第一次发信号时 `auth_permission` 还是空表)。所以这里**不按 sender 过滤**:
532
+ # 每次发信号都试一次,成功(没有缺码 / 没有格式错)就上进程内 latch。
533
+
534
+ #: 那个接收器的 `dispatch_uid`(幂等:`ready()` 可能被跑两次)。
535
+ _HOOK_ID = 'labull_framework.access'
536
+
537
+ #: `post_migrate` 这一路"已经成功对过账"的进程内 latch。
538
+ _SYNCED = False
539
+
540
+
541
+ def install_hook() -> None:
542
+ """把"迁移之后对一次账"挂到 `post_migrate` 上(幂等;**不写 `sender=`**,理由见上)。"""
543
+ from django.db.models.signals import post_migrate
544
+
545
+ post_migrate.connect(_on_post_migrate, dispatch_uid=_HOOK_ID)
546
+
547
+
548
+ def _on_post_migrate(sender: object, **kwargs: object) -> None:
549
+ """`post_migrate` 接收器:能对账就对上,对不上(权限行还没建)就等下一次发信号。"""
550
+ global _SYNCED
551
+ if _SYNCED:
552
+ return
553
+ try:
554
+ report = sync()
555
+ log_report(report)
556
+ if report.errors or report.missing_models or report.missing:
557
+ return
558
+ except Exception as exc: # noqa: BLE001 —— 迁移路径上的失败不该带崩 `migrate`
559
+ logger.error('迁移之后同步权限声明失败(不影响迁移):%s: %s', type(exc).__name__, exc)
560
+ return
561
+ _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='管理员名单:启动时把这些人加进「平台管理员」权限包(`edit_magic_block` 所在的那个组),名单之外的一律摘掉(逗号分隔,上限 200;没配就是没有管理员)',
161
+ brief='管理员名单:启动时把这些人置为 **Django 的 `is_staff`**("能不能编辑魔法块"就看这一列),'
162
+ '名单之外的一律摘掉(逗号分隔,上限 200;没配就是没有管理员)',
162
163
  hint='BOOTSTRAP_ADMIN_WORK_IDS=1001',
163
164
  group='平台开关',
164
165
  ),
@@ -223,6 +224,19 @@ ENV_ITEMS: tuple[EnvItem, ...] = (
223
224
  hint='LABULL_BLOCK_WORKERS=2',
224
225
  group='平台开关',
225
226
  ),
227
+ # ── 授权(权限码 / 权限包):**声明在项目里的一份 YAML 文件**,落库由框架同步 ──
228
+ # 🔴 为什么不是迁移:迁移里的 `RunPython` 跑在 `create_permissions` **之前**,
229
+ # 要拿权限行去塞组就得自己再建一遍权限行(那是第二份真源)。这里只在启动时对一次账。
230
+ # 🔴 为什么不是环境变量:`group → 权限码` 是一张映射,塞进一行环境变量既难读、又容易写错
231
+ # (见 `labull-access.yaml` 的写法)。
232
+ # 🔴 同步**只增不删**:要删组 / 删权限,只能人自己在库里删(删组会级联掉人的授权)。
233
+ EnvItem(
234
+ name='LABULL_ACCESS_FILE',
235
+ brief='授权声明文件的路径(默认就是 `BASE_DIR/labull-access.yaml`;相对路径按 `BASE_DIR` 解析)。'
236
+ '文件里写 `permissions:`(权限码 + 中文名)与 `groups:`(组名 → 权限码)',
237
+ hint='LABULL_ACCESS_FILE=config/labull-access.yaml',
238
+ group='授权(权限码 / 权限包)',
239
+ ),
226
240
  # ── 块的配置面 ──
227
241
  EnvItem(
228
242
  name=f'{BLOCK_CONFIG_PREFIX}*',