labull-framework 0.1.0__py3-none-any.whl

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 (40) hide show
  1. labull_framework/__init__.py +8 -0
  2. labull_framework/admin.py +63 -0
  3. labull_framework/apps.py +33 -0
  4. labull_framework/auth/__init__.py +4 -0
  5. labull_framework/auth/backend.py +144 -0
  6. labull_framework/auth/bootstrap.py +47 -0
  7. labull_framework/auth/sso.py +259 -0
  8. labull_framework/auth/views.py +184 -0
  9. labull_framework/canvas/__init__.py +19 -0
  10. labull_framework/canvas/data.py +523 -0
  11. labull_framework/canvas/gitinfo.py +155 -0
  12. labull_framework/canvas/static/canvas.css +487 -0
  13. labull_framework/canvas/static/canvas.js +1036 -0
  14. labull_framework/canvas/static/page.html +52 -0
  15. labull_framework/canvas/views.py +121 -0
  16. labull_framework/env.py +187 -0
  17. labull_framework/jsonio.py +57 -0
  18. labull_framework/magic_block/__init__.py +2 -0
  19. labull_framework/magic_block/devkit.py +428 -0
  20. labull_framework/magic_block/dispatch.py +314 -0
  21. labull_framework/magic_block/export.py +773 -0
  22. labull_framework/magic_block/health.py +220 -0
  23. labull_framework/magic_block/registry.py +157 -0
  24. labull_framework/magic_block/sdk.py +245 -0
  25. labull_framework/magic_block/service.py +405 -0
  26. labull_framework/magic_block/validate.py +558 -0
  27. labull_framework/magic_block/views.py +407 -0
  28. labull_framework/management/__init__.py +0 -0
  29. labull_framework/management/commands/__init__.py +0 -0
  30. labull_framework/management/commands/labull_contract.py +52 -0
  31. labull_framework/migrations/0001_initial.py +71 -0
  32. labull_framework/migrations/__init__.py +0 -0
  33. labull_framework/models.py +76 -0
  34. labull_framework/settings.py +223 -0
  35. labull_framework/urls.py +93 -0
  36. labull_framework-0.1.0.dist-info/METADATA +140 -0
  37. labull_framework-0.1.0.dist-info/RECORD +40 -0
  38. labull_framework-0.1.0.dist-info/WHEEL +5 -0
  39. labull_framework-0.1.0.dist-info/licenses/LICENSE +28 -0
  40. labull_framework-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,407 @@
1
+ """
2
+ `/magic-blocks/*` 与 `/bx/*` 的全部视图(路由由 `urls.py` 按函数名指过来)。
3
+
4
+ 🔴 权限用 Django 原生的 `is_staff`:写口与管理员读口要 staff(**自己写 `staff_only`**:
5
+ `@staff_member_required` 对未登录用户会 302 到 admin 登录页,而这里要的是 401 + JSON);
6
+ `status` / `render` 只要求登录,而且**永远不许**改成回整行 / 回全量 `files`。
7
+ 🔴 所有回话一律走 `jsonio`(一处出口:编码政策与错误形状都在那里)。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from functools import wraps
14
+ from typing import Any, Callable
15
+
16
+ from django.http import HttpRequest, HttpResponse
17
+
18
+ from ..jsonio import problem
19
+ from .dispatch import block_dispatch_view as block_dispatch_view
20
+ from .health import check_all_blocks
21
+ from .sdk import BLOCK_BACKEND_FILE, BLOCK_ENTRY
22
+ from .service import (
23
+ PAGE_IDS_MAX,
24
+ BlockNotFound,
25
+ MagicBlockService,
26
+ SaveRejected,
27
+ api_dts,
28
+ health_issues,
29
+ iso,
30
+ sorted_files,
31
+ unique_ids,
32
+ )
33
+
34
+ __all__ = [
35
+ 'index_view',
36
+ 'status_view',
37
+ 'render_view',
38
+ 'rules_view',
39
+ 'export_view',
40
+ 'import_view',
41
+ 'read_view',
42
+ 'check_view',
43
+ 'list_view',
44
+ 'detail_view',
45
+ 'rollback_view',
46
+ 'delete_view',
47
+ 'block_dispatch_view',
48
+ 'staff_only',
49
+ 'ids_of',
50
+ ]
51
+
52
+
53
+ def staff_only(view: Callable[..., HttpResponse]) -> Callable[..., HttpResponse]:
54
+ """管理员守卫:没登录 ⇒ **401 + JSON**;登录了但 `is_staff=False` ⇒ **403 + JSON**。
55
+
56
+ 🔴 两条判据分开报:一个是"你是谁",一个是"你能不能" —— 合成一条会让前端不知道该去登录还是找管理员。
57
+ 🔴 **不返回 302**:这几条是接口,前端要看状态码分支(302 会被 `fetch` 当成功跟着跳,最后拿到一页 HTML)。
58
+ """
59
+ from ..jsonio import no_identity
60
+
61
+ @wraps(view)
62
+ def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
63
+ user = getattr(request, 'user', None)
64
+ if user is None or not getattr(user, 'is_authenticated', False):
65
+ return no_identity()
66
+ if not getattr(user, 'is_staff', False):
67
+ return problem('这条接口只给管理员(Django 的 is_staff)', 403)
68
+ return view(request, *args, **kwargs)
69
+
70
+ return wrapper
71
+
72
+
73
+ def login_only(view: Callable[..., HttpResponse]) -> Callable[..., HttpResponse]:
74
+ """只要求登录的守卫(`status` / `render` 两条读口用)。同样回 401 而不是 302。"""
75
+ from ..jsonio import no_identity
76
+
77
+ @wraps(view)
78
+ def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
79
+ user = getattr(request, 'user', None)
80
+ if user is None or not getattr(user, 'is_authenticated', False):
81
+ return no_identity()
82
+ return view(request, *args, **kwargs)
83
+
84
+ return wrapper
85
+
86
+
87
+ # ──────────────────────────── 小件 ────────────────────────────
88
+
89
+
90
+ def _json(payload: Any, status: int = 200) -> HttpResponse:
91
+ """回一份 JSON(编码政策不在这里 —— 一律走 `jsonio` 那个出口)。"""
92
+ from ..jsonio import json_response
93
+
94
+ return json_response(payload, status=status)
95
+
96
+
97
+ def _body(request: HttpRequest) -> dict:
98
+ """读 JSON 正文(读不出来当空对象:GET 那几条没有正文,写口漏了字段由各自的判据报)。"""
99
+ raw = request.body or b''
100
+ if not raw:
101
+ return {}
102
+ try:
103
+ parsed = json.loads(raw.decode('utf-8', errors='replace'))
104
+ except ValueError:
105
+ return {}
106
+ return parsed if isinstance(parsed, dict) else {}
107
+
108
+
109
+ def _method(request: HttpRequest, want: str) -> HttpResponse | None:
110
+ """动词不对 ⇒ 405(路径是对的,只是动词不对 —— 报文要说清这一点)。"""
111
+ if request.method == want:
112
+ return None
113
+ return problem(f'这条接口只认 {want}', 405)
114
+
115
+
116
+ def ids_of(query: str | None) -> list[str]:
117
+ """解析 `?ids=`:切分 → 去空白 → 去重 → 校验上限。
118
+
119
+ 🔴 缺了 / 一个都没有 ⇒ **400 并说清怎么写**:回空数组会把"参数写错了"装成"这些块都没有"。
120
+ 🔴 上限按**去重之后**的个数算(重复的 uuid 本来就是同一次查询)。
121
+ """
122
+ wanted = unique_ids([piece.strip() for piece in str(query or '').split(',')])
123
+ if not wanted:
124
+ raise SaveRejected(
125
+ 'ids 不能为空:请用 ?ids=<uuid>[,<uuid>…] 一次问一个或多个块(未开发的块回 draft,不是 404)'
126
+ )
127
+ if len(wanted) > PAGE_IDS_MAX:
128
+ raise SaveRejected(f'一次最多问 {PAGE_IDS_MAX} 个块,这次给了 {len(wanted)} 个:请拆成几次')
129
+ return wanted
130
+
131
+
132
+ def _reject(exc: SaveRejected) -> HttpResponse:
133
+ """保存 / 参数被拦下 ⇒ 400 + 逐条 issues(报文形态与别的错一致,多一个 `issues` 列)。"""
134
+ from ..jsonio import json_response
135
+
136
+ payload: dict[str, Any] = {
137
+ 'statusCode': getattr(exc, 'status', 400) or 400,
138
+ 'message': exc.message,
139
+ }
140
+ if exc.issues:
141
+ payload['issues'] = list(exc.issues)
142
+ return json_response(payload, status=payload['statusCode'])
143
+
144
+
145
+ def _row_view(row: Any) -> dict:
146
+ """一行 → 管理员读口的下发形状(含文件包、体检结论与**接口名单**)。
147
+
148
+ 🔴 接口名单只在这几条口上(列表 / 单条 / 保存 / 回滚):它和块代码是同一侧的信息。
149
+ """
150
+ service = MagicBlockService()
151
+ routes, dts = service.api_manifest(row.id)
152
+ return {
153
+ 'id': row.id,
154
+ 'module': row.module,
155
+ 'name': row.name,
156
+ 'brief': row.brief,
157
+ 'enabled': row.enabled is not False,
158
+ 'disabledReason': row.disabled_reason or '',
159
+ 'issues': health_issues(row),
160
+ 'files': dict(row.files or {}),
161
+ 'fileNames': sorted_files(row.files or {}),
162
+ 'apiRoutes': routes,
163
+ 'apiDts': dts,
164
+ 'prevFiles': dict(row.prev_files or {}),
165
+ 'prevUpdatedAt': iso(row.prev_updated_at),
166
+ 'updatedBy': row.updated_by or '',
167
+ 'updatedAt': iso(row.updated_at),
168
+ }
169
+
170
+
171
+ # ──────────────────────────── 管理员读口 ────────────────────────────
172
+
173
+
174
+ @staff_only
175
+ def index_view(request: HttpRequest) -> HttpResponse:
176
+ """`GET /magic-blocks/index` —— 控制台用的块索引(**不带文件正文**)。
177
+
178
+ 🔴 回话里**没有**"业务表清单"那种东西:块后端要模型就直接 import 它自己那一个
179
+ (Django 的写法),控制台也不需要知道项目的表结构。
180
+ """
181
+ refused = _method(request, 'GET')
182
+ if refused is not None:
183
+ return refused
184
+ service = MagicBlockService()
185
+ return _json({'blocks': service.index(request.GET.get('module') or None)})
186
+
187
+
188
+ @staff_only
189
+ def list_view(request: HttpRequest) -> HttpResponse:
190
+ """`/magic-blocks` —— **GET 列全部块**(带正文,管理员控制台用);**POST 保存一个块**。
191
+
192
+ 🔴 两个动词**同一个视图**:URLconf 是按路径匹配的,同一个路径写两条 `path()` 时
193
+ 第二条永远匹配不到 —— `POST /magic-blocks` 会先撞上那条只认 GET 的,回一个 405。
194
+ """
195
+ if request.method == 'POST':
196
+ body = _body(request)
197
+ files = body.get('files')
198
+ try:
199
+ row = MagicBlockService().save(
200
+ block_id=str(body.get('id') or ''),
201
+ module=str(body.get('module') or ''),
202
+ name=str(body.get('name') or ''),
203
+ brief=str(body.get('brief') or ''),
204
+ files=files if isinstance(files, dict) else {},
205
+ # 审计字段拿不到就留空,**不许拿它挡住写入**。
206
+ actor=str(body.get('updatedBy') or ''),
207
+ )
208
+ except SaveRejected as exc:
209
+ return _reject(exc)
210
+ return _json(_row_view(row))
211
+
212
+ refused = _method(request, 'GET')
213
+ if refused is not None:
214
+ return refused
215
+ rows = MagicBlockService().list(request.GET.get('module') or None)
216
+ return _json([_row_view(row) for row in rows])
217
+
218
+
219
+ @staff_only
220
+ def detail_view(request: HttpRequest, block_id: str) -> HttpResponse:
221
+ """`GET /magic-blocks/<id>` —— 一行(没有就是 `null`,不是 404)。"""
222
+ refused = _method(request, 'GET')
223
+ if refused is not None:
224
+ return refused
225
+ row = MagicBlockService().get(block_id)
226
+ return _json(_row_view(row) if row is not None else None)
227
+
228
+
229
+ # ──────────────────────────── 任何登录用户都能读的两条 ────────────────────────────
230
+
231
+
232
+ @login_only
233
+ def status_view(request: HttpRequest) -> HttpResponse:
234
+ """`GET /magic-blocks/status?ids=` —— 页面拿它决定三态。
235
+
236
+ 🔴 回话里**一个字节的块代码都没有**:这条口任何登录用户都能读。
237
+ """
238
+ refused = _method(request, 'GET')
239
+ if refused is not None:
240
+ return refused
241
+ try:
242
+ wanted = ids_of(request.GET.get('ids'))
243
+ except SaveRejected as exc:
244
+ return _reject(exc)
245
+ return _json(MagicBlockService().statuses(wanted))
246
+
247
+
248
+ @login_only
249
+ def render_view(request: HttpRequest) -> HttpResponse:
250
+ """`GET /magic-blocks/render?ids=` —— `ok` 的块连**前端**文件一起给。
251
+
252
+ 🔴 两条边界都在 `service.render()` 里:`.py` 一律不出门,且只有 `ok` 才带 `files` 键。
253
+ """
254
+ refused = _method(request, 'GET')
255
+ if refused is not None:
256
+ return refused
257
+ try:
258
+ wanted = ids_of(request.GET.get('ids'))
259
+ except SaveRejected as exc:
260
+ return _reject(exc)
261
+ return _json(MagicBlockService().render(wanted))
262
+
263
+
264
+ # ──────────────────────────── 写口 ────────────────────────────
265
+
266
+
267
+ @staff_only
268
+ def rules_view(request: HttpRequest) -> HttpResponse:
269
+ """`GET /magic-blocks/rules` —— **读回改动时要照的扫描规则**(认哪些扩展名 / 跳过哪些目录 / 几层到顶)。
270
+
271
+ 🔴 为什么要单独一条口:规则是一份**平台契约**,不是导出产物。原来只随 `POST /magic-blocks/export`
272
+ 的回话给,于是"刷新页面之后想读回改动"就变成"必须再导出一次" —— 而导出会覆盖用户刚改的目录
273
+ (真踩过:用户点「读回改动」被要求先导出,合理反应是"这不合理")。
274
+ 🔴 判据只有 `export.scan_rules()` 那一处:这条口只是把它发出去,浏览器一条都不许抄。
275
+ """
276
+ refused = _method(request, 'GET')
277
+ if refused is not None:
278
+ return refused
279
+ from .export import scan_rules
280
+
281
+ return _json({'rules': scan_rules()})
282
+
283
+
284
+ @staff_only
285
+ def export_view(request: HttpRequest) -> HttpResponse:
286
+ """`POST /magic-blocks/export` —— **magic block export**:把库里的块算成**一整个开发包**。
287
+
288
+ 请求体:`{}`(**没有参数**:开发目录由浏览器选,页面名单位于前端自己写的那份 `pages.lock.ts`)。
289
+ 回话:`{'files': {'workspace/<可读名>-<uuid>/index.open.html': '<正文>', …}, 'blocks': N, 'rules': {…}}`
290
+
291
+ 🔴 它**一个字节都不写盘**:写盘在用户自己机器上(浏览器 File System Access API),后端只算内容 ——
292
+ "铺到服务器某个目录"那条路没了(那会把开发目录放在离用户编辑器最远的地方)。
293
+ 🔴 `rules` 是"读回改动"时浏览器要照的扫描规则(哪些扩展名 / 跳过哪些目录 / 几层到顶):
294
+ 判据只有 `export.scan_rules()` 一处,浏览器一条都不抄。
295
+ """
296
+ refused = _method(request, 'POST')
297
+ if refused is not None:
298
+ return refused
299
+ from .export import export_package
300
+
301
+ return _json(export_package())
302
+
303
+
304
+ @staff_only
305
+ def import_view(request: HttpRequest) -> HttpResponse:
306
+ """`POST /magic-blocks/import` —— 把改好的目录**读回库**(一个块失败只报它自己)。
307
+
308
+ 请求体:`{'path': '<服务器上的目录>', 'includeMissing'?, 'blocks'?}`
309
+ 🔴 走的是**保存那一条路**(`service.save`):校验、体检、编译缓存失效全在那一处。
310
+ """
311
+ refused = _method(request, 'POST')
312
+ if refused is not None:
313
+ return refused
314
+ from .export import read_dir, write_dir
315
+
316
+ body = _body(request)
317
+ blocks = body.get('blocks')
318
+ items = [item for item in (blocks if isinstance(blocks, list) else []) if isinstance(item, dict)]
319
+ if not items and body.get('path'):
320
+ # 只给了目录:按读回来的结果取能存的那几条(与前端勾选全部同一件事)。
321
+ scanned = read_dir(str(body['path']))
322
+ items = [item for item in scanned['blocks'] if item.get('canApply')]
323
+ if not items:
324
+ # 🔴 逐条失败也要带出去:只说一句"没有能保存的块",用户不知道是目录选错了还是块本身不合格。
325
+ detail = ';'.join(str(item.get('reason') or '') for item in scanned['failures'][:3])
326
+ return problem(f'这个目录里没有能保存的块。{detail}'.strip(), 400)
327
+ return _json(write_dir(items, actor=str(body.get('updatedBy') or '')))
328
+
329
+
330
+ @staff_only
331
+ def read_view(request: HttpRequest) -> HttpResponse:
332
+ """`POST /magic-blocks/read` —— 读一个目录,与库里的块逐块对比(**只读,不写库**)。
333
+
334
+ 请求体两种(**两种入口、同一套判据**,见 `export._judge()`):
335
+ · `{'root', 'folders': [{'where', 'files'}], 'failures'?, 'truncated'?}` —— **浏览器扫的**。
336
+ 开发目录在用户自己机器上(导出就铺在那里),后端读不到它:由浏览器照导出报文里的
337
+ `rules` 扫一遍,把"我扫到了什么"递上来(形状见 `frontend/magic-block/src/localfs.ts`)。
338
+ · `{'path': '<服务器上的绝对路径>'}` —— 后端自己读那个目录(源码本来就在服务器上时用)。
339
+ """
340
+ refused = _method(request, 'POST')
341
+ if refused is not None:
342
+ return refused
343
+ from .export import read_dir, scan_folders
344
+
345
+ body = _body(request)
346
+ if isinstance(body.get('folders'), list):
347
+ return _json(scan_folders(body, include_missing=body.get('includeMissing') is True))
348
+ return _json(read_dir(str(body.get('path') or ''), include_missing=body.get('includeMissing') is True))
349
+
350
+
351
+ @staff_only
352
+ def check_view(request: HttpRequest) -> HttpResponse:
353
+ """`POST /magic-blocks/check` —— 重新体检(默认全量,也可以点名几块),然后**统一写库**。
354
+
355
+ 请求体:`{'blocks': [uuid…]?}`(省略 = 全量)
356
+ 🔴 点名库里没有的块 ⇒ **整条 404**(不许静默丢掉用户看到的失败)。
357
+ 🔴 **没有"浏览器那一半"**:块前端不由我们校验。
358
+ """
359
+ refused = _method(request, 'POST')
360
+ if refused is not None:
361
+ return refused
362
+ body = _body(request)
363
+ blocks = body.get('blocks')
364
+ wanted = [str(item) for item in blocks] if isinstance(blocks, list) and blocks else None
365
+ if wanted:
366
+ from ..models import MagicBlock
367
+
368
+ known = set(MagicBlock.objects.filter(id__in=wanted).values_list('id', flat=True))
369
+ missing = [item for item in wanted if item not in known]
370
+ if missing:
371
+ return problem(f'这些块在库里找不到:{" / ".join(missing)}', 404)
372
+ result = check_all_blocks(wanted)
373
+ result.pop('log', None)
374
+ return _json(result)
375
+
376
+
377
+ @staff_only
378
+ def rollback_view(request: HttpRequest, block_id: str) -> HttpResponse:
379
+ """`POST /magic-blocks/<id>/rollback` —— 回滚到上一版(**双向兑换**:再点一次就回去)。"""
380
+ refused = _method(request, 'POST')
381
+ if refused is not None:
382
+ return refused
383
+ try:
384
+ row = MagicBlockService().rollback(block_id)
385
+ except BlockNotFound as exc:
386
+ return problem(exc.message, 404)
387
+ except SaveRejected as exc:
388
+ return _reject(exc)
389
+ return _json(_row_view(row))
390
+
391
+
392
+ @staff_only
393
+ def delete_view(request: HttpRequest, block_id: str) -> HttpResponse:
394
+ """`DELETE /magic-blocks/<id>` —— 删一个块(一行,`files` 跟着走)。"""
395
+ refused = _method(request, 'DELETE')
396
+ if refused is not None:
397
+ return refused
398
+ try:
399
+ MagicBlockService().delete(block_id)
400
+ except BlockNotFound as exc:
401
+ return problem(exc.message, 404)
402
+ return _json({'ok': True})
403
+
404
+
405
+ # ──────────────────────────── 块后端那条口 ────────────────────────────
406
+ # 🔴 它的定义在 `dispatch.py`(那里才是机制说明),这里只做**显式转发**,
407
+ # 好让"这份文件就是全部入口"这句话成立(`urls.py` 按名字指过来)。
File without changes
File without changes
@@ -0,0 +1,52 @@
1
+ """`manage.py labull_contract [--out PATH]`:把当前的模型形状导成一份 JSON。
2
+
3
+ 它是一个**可选出口**,不是任何东西的输入:这一页页面自己读的是进程内的模型元数据。
4
+ 导出它的两个理由:① 让别的语言的工具 / 前端拿到一份类型;② **让画布有"上一版"可比** ——
5
+ 把它 `--out` 到 git 里那份路径并提交,之后模型一改,画布上就能看出加 / 改 / 删。
6
+
7
+ 🔴 形状的真源是 Django 的 `_meta` —— **一行正则、一段 AST 都没有**。
8
+ 扫源码抠模型只是在一份已经结构化的东西上再猜一遍。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from django.core.management.base import BaseCommand, CommandError
18
+
19
+ # 🔴 `...` 三个点:这个模块住在 `labull_framework.management.commands` 里,而 `shape` 在
20
+ # `labull_framework.canvas.data` —— 少一个点就会去找 `labull_framework.management.canvas`,
21
+ # 报的是 `ModuleNotFoundError: No module named '…management.canvas'`(真踩过,命令整个不可用)。
22
+ from ...canvas.data import shape
23
+
24
+
25
+ class Command(BaseCommand):
26
+ help = '导出当前的模型形状(画布的"上一版"就靠它进 git)'
27
+
28
+ def add_arguments(self, parser) -> None:
29
+ parser.add_argument(
30
+ '--out',
31
+ default=None,
32
+ help='写到这个路径;不给就打到 stdout(stdout 上只有这一份 JSON)',
33
+ )
34
+
35
+ def handle(self, *args: Any, **options: Any) -> None:
36
+ payload = shape()
37
+ if options.get('out'):
38
+ path = Path(str(options['out']))
39
+ try:
40
+ # 🔴 顺手把父目录建出来:README 教的就是 `--out .labull/contract.json`,
41
+ # 而 `open()` 不会替你建 `.labull/`(第一次跑必然 CommandError,纯添堵)。
42
+ path.parent.mkdir(parents=True, exist_ok=True)
43
+ with path.open('w', encoding='utf-8', newline='\n') as handle:
44
+ json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=False)
45
+ handle.write('\n')
46
+ except OSError as exc:
47
+ raise CommandError(f'写不出来 {path}:{exc}') from exc
48
+ self.stderr.write(f'已写出 {len(payload["tables"])} 张表 → {path}')
49
+ return
50
+ # 🔴 stdout 分支转义非 ASCII:Windows 控制台默认 GBK,中文会变成 U+FFFD;
51
+ # 而 stdout 上**只有**这份 JSON(人话一律走 stderr)。
52
+ self.stdout.write(json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=False))
@@ -0,0 +1,71 @@
1
+ # Generated by Django 6.1.1 on 2026-10-03 02:26
2
+
3
+ import django.contrib.auth.models
4
+ import django.contrib.auth.validators
5
+ import django.utils.timezone
6
+ from django.db import migrations, models
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+
11
+ initial = True
12
+
13
+ dependencies = [
14
+ ('auth', '0012_alter_user_first_name_max_length'),
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name='MagicBlock',
20
+ fields=[
21
+ ('id', models.TextField(help_text='功能块 id', primary_key=True, serialize=False)),
22
+ ('module', models.TextField(help_text='所属模块')),
23
+ ('name', models.TextField(help_text='可读名')),
24
+ ('brief', models.TextField(blank=True, default='', help_text='备注')),
25
+ ('enabled', models.BooleanField(default=True, help_text='是否启用')),
26
+ ('disabled_reason', models.TextField(blank=True, help_text='停用原因', null=True)),
27
+ ('health_issues', models.JSONField(blank=True, help_text='体检问题', null=True)),
28
+ ('health_checked_at', models.DateTimeField(blank=True, help_text='体检时间', null=True)),
29
+ ('files', models.JSONField(help_text='文件包')),
30
+ ('prev_files', models.JSONField(blank=True, help_text='上一版文件', null=True)),
31
+ ('prev_updated_at', models.DateTimeField(blank=True, help_text='上一版时间', null=True)),
32
+ ('updated_by', models.TextField(blank=True, default='', help_text='最后修改人')),
33
+ ('created_at', models.DateTimeField(auto_now_add=True, help_text='创建时间')),
34
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='更新时间')),
35
+ ],
36
+ options={
37
+ 'verbose_name': '功能块',
38
+ 'verbose_name_plural': '功能块',
39
+ 'db_table': 'labull_magic_block',
40
+ },
41
+ ),
42
+ migrations.CreateModel(
43
+ name='User',
44
+ fields=[
45
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
46
+ ('password', models.CharField(max_length=128, verbose_name='password')),
47
+ ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
48
+ ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
49
+ ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
50
+ ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
51
+ ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
52
+ ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
53
+ ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
54
+ ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
55
+ ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
56
+ ('work_id', models.TextField(help_text='工号', null=True, unique=True)),
57
+ ('name', models.TextField(blank=True, default='', help_text='显示名')),
58
+ ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
59
+ ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
60
+ ],
61
+ options={
62
+ 'verbose_name': '用户',
63
+ 'verbose_name_plural': '用户',
64
+ 'db_table': 'labull_user',
65
+ 'abstract': False,
66
+ },
67
+ managers=[
68
+ ('objects', django.contrib.auth.models.UserManager()),
69
+ ],
70
+ ),
71
+ ]
File without changes
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from django.contrib.auth.models import AbstractUser
4
+ from django.db import models
5
+
6
+
7
+ class User(AbstractUser):
8
+ """登录进来的人。
9
+
10
+ 🔴 **身份的唯一凭据是 `username`** —— 它装的是 Authing 的 `sub`("是同一个人"就靠它),
11
+ `AbstractUser` 自带 `username` 的 unique 约束,所以"同一身份两行"在库这一层就不可能发生。
12
+ ⚠️ 没有本地口令:登录只走 OIDC,口令列由 `set_unusable_password()` 堵死。
13
+
14
+ 🔴 **"是不是管理员"用 Django 自带的 `is_staff`** —— 这里不另造角色字段。
15
+ 要更细的粒度就用 Django 的 `Group` / `Permission`(也是原生的),本库不预置任何组。
16
+ """
17
+
18
+ # Authing 里的 `username`(工号):一个工号对应一个人。
19
+ # ⚠️ 可空:Authing 那边没配 `username` 时**拒登**,但历史行可能没有工号。
20
+ work_id = models.TextField(null=True, unique=True, help_text='工号')
21
+
22
+ # 从 Authing 同步过来的显示名(同步不回来时退回 `username`)。
23
+ name = models.TextField(blank=True, default='', help_text='显示名')
24
+
25
+ class Meta(AbstractUser.Meta):
26
+ # 🔴 表名显式写:`AbstractUser` 的默认名是 `<app_label>_user`,而那张表的名字是契约的一部分。
27
+ db_table = 'labull_user'
28
+ verbose_name = '用户'
29
+ verbose_name_plural = '用户'
30
+
31
+
32
+ class MagicBlock(models.Model):
33
+ """功能块:一个 uuid + 一段说明 + **一整个文件包**。
34
+
35
+ 认块只认 `id`(开发者在页面里写死的 uuid),`name` 不做唯一约束。
36
+ `files` = 文件名(包内相对路径)→ 正文;入口固定 `index.open.html`,由沙箱 iframe 渲染 ——
37
+ **不重新编译、不重启**。
38
+ """
39
+
40
+ id = models.TextField(primary_key=True, help_text='功能块 id')
41
+ module = models.TextField(help_text='所属模块')
42
+ name = models.TextField(help_text='可读名')
43
+ brief = models.TextField(blank=True, default='', help_text='备注')
44
+
45
+ # 停用的块不渲染、它的后端也调不到。🔴 **只由体检写**(单一写者):通过 ⇒ 启用,不通过 ⇒ 停用;
46
+ # 改好代码再体检一次就自动恢复。所以没有"手动启用"这个动作。
47
+ enabled = models.BooleanField(default=True, help_text='是否启用')
48
+
49
+ # 体检不通过时**为什么**被停用(一句话,给管理员看);通过时清空。
50
+ disabled_reason = models.TextField(null=True, blank=True, help_text='停用原因')
51
+
52
+ # 逐条问题(`[{stage, kind, message}]`),抽屉才能一条条列出来;通过时是空列表。
53
+ health_issues = models.JSONField(null=True, blank=True, help_text='体检问题')
54
+ health_checked_at = models.DateTimeField(null=True, blank=True, help_text='体检时间')
55
+
56
+ files = models.JSONField(help_text='文件包')
57
+
58
+ # 上一版的那一包文件:保存是**整包替换**,这是"手滑能救回来"的底稿。
59
+ # 🔴 只留一版(不是版本史):回滚是"与当前这版双向兑换",两版就够;真正的版本管理在 git 里。
60
+ prev_files = models.JSONField(null=True, blank=True, help_text='上一版文件')
61
+ prev_updated_at = models.DateTimeField(null=True, blank=True, help_text='上一版时间')
62
+
63
+ # 最后改这个块的人(审计字段;Authing 的 `sub`,拿不到就空串)。
64
+ updated_by = models.TextField(blank=True, default='', help_text='最后修改人')
65
+
66
+ created_at = models.DateTimeField(auto_now_add=True, help_text='创建时间')
67
+ # ⚠️ 与 `prev_updated_at` 是两件事:这一列是"现在这版"的版本号(编译缓存按它做缓存键)。
68
+ updated_at = models.DateTimeField(auto_now=True, help_text='更新时间')
69
+
70
+ class Meta:
71
+ db_table = 'labull_magic_block'
72
+ verbose_name = '功能块'
73
+ verbose_name_plural = '功能块'
74
+
75
+ def __str__(self) -> str:
76
+ return f'{self.module}/{self.name}'