labull-framework 2.0.0__tar.gz → 3.0.1__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 (69) hide show
  1. {labull_framework-2.0.0/labull_framework.egg-info → labull_framework-3.0.1}/PKG-INFO +3 -3
  2. {labull_framework-2.0.0 → labull_framework-3.0.1}/README-pypi.md +2 -2
  3. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/admin.py +9 -21
  4. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/apps.py +35 -37
  5. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/auth/backend.py +6 -44
  6. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/auth/bootstrap.py +26 -25
  7. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/auth/views.py +4 -4
  8. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/canvas/data.py +93 -31
  9. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/canvas/gitinfo.py +3 -2
  10. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/canvas/shape_ast.py +3 -2
  11. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/env.py +1 -1
  12. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/flow.py +144 -114
  13. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/devkit.py +19 -16
  14. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/dispatch.py +1 -4
  15. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/export.py +9 -2
  16. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/health.py +5 -12
  17. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/sdk.py +5 -5
  18. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/types/workflow.py +8 -8
  19. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/views.py +22 -19
  20. labull_framework-3.0.1/labull_framework/migrations/0006_default_groups_permissions.py +142 -0
  21. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/models.py +16 -80
  22. labull_framework-3.0.1/labull_framework/permissions.py +86 -0
  23. {labull_framework-2.0.0 → labull_framework-3.0.1/labull_framework.egg-info}/PKG-INFO +3 -3
  24. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework.egg-info/SOURCES.txt +2 -1
  25. {labull_framework-2.0.0 → labull_framework-3.0.1}/pyproject.toml +90 -90
  26. labull_framework-2.0.0/labull_framework/roles.py +0 -189
  27. {labull_framework-2.0.0 → labull_framework-3.0.1}/LICENSE +0 -0
  28. {labull_framework-2.0.0 → labull_framework-3.0.1}/MANIFEST.in +0 -0
  29. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/__init__.py +0 -0
  30. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/auth/__init__.py +0 -0
  31. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/auth/sso.py +0 -0
  32. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/canvas/__init__.py +0 -0
  33. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/canvas/static/canvas.css +0 -0
  34. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/canvas/static/canvas.js +0 -0
  35. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/canvas/static/page.html +0 -0
  36. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/canvas/views.py +0 -0
  37. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/jsonio.py +0 -0
  38. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/__init__.py +0 -0
  39. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/blockcheck.py +0 -0
  40. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/blockpackage.py +0 -0
  41. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/blocktypes.py +0 -0
  42. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/deepcheck.py +0 -0
  43. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/manifest.py +0 -0
  44. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/parallel.py +0 -0
  45. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/registry.py +0 -0
  46. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/requires.py +0 -0
  47. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/service.py +0 -0
  48. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/subproc.py +0 -0
  49. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/types/__init__.py +0 -0
  50. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/types/plain.py +0 -0
  51. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/magic_block/validate.py +0 -0
  52. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/management/__init__.py +0 -0
  53. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/management/commands/__init__.py +0 -0
  54. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/management/commands/labull_blocktype.py +0 -0
  55. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/management/commands/labull_contract.py +0 -0
  56. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/management/commands/labull_requires.py +0 -0
  57. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/migrations/0001_initial.py +0 -0
  58. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/migrations/0002_remove_magicblock_prev_files_and_more.py +0 -0
  59. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/migrations/0003_magicblock_tags.py +0 -0
  60. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/migrations/0004_magicblock_detail.py +0 -0
  61. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/migrations/0005_labullrole.py +0 -0
  62. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/migrations/__init__.py +0 -0
  63. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/settings.py +0 -0
  64. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/urls.py +0 -0
  65. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework/virtual.py +0 -0
  66. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework.egg-info/dependency_links.txt +0 -0
  67. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework.egg-info/requires.txt +0 -0
  68. {labull_framework-2.0.0 → labull_framework-3.0.1}/labull_framework.egg-info/top_level.txt +0 -0
  69. {labull_framework-2.0.0 → labull_framework-3.0.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 2.0.0
3
+ Version: 3.0.1
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
@@ -93,8 +93,8 @@ CORS_ORIGIN=http://localhost:5173
93
93
 
94
94
  | 想要什么 | 怎么做 |
95
95
  |---|---|
96
- | **谁是管理员** | 配 `BOOTSTRAP_ADMIN_WORK_IDS=工号,工号` —— 它是**声明式**的名单:启动时名单里的人加进**管理员角色**的组,**名单之外的一律摘掉**(没配就是"一个管理员都没有")。它不碰 `is_superuser`,所以别拿超管当后门 |
97
- | **管理员 / 默认角色** | **默认就有**:SDK 的 `0005` 迁移种下 `ADMIN`(管理员角色)/ `USER`(默认角色,新用户第一次登录自动加入)。要换码或组名就写项目自己的 data migration;两条**各恰好一条、不能同一行**,缺了 / 重了服务起不来 |
96
+ | **谁是管理员** | 判据是**权限** `labull_framework.edit_magic_block`(不是组名、不是 `is_staff`)。配 `BOOTSTRAP_ADMIN_WORK_IDS=工号,工号` —— 它是**声明式**的名单:启动时名单里的人加进**持有那个权限的组**(按权限找组,改组名不影响),**名单之外的一律摘掉**(没配就是"一个管理员都没有")。它不碰 `is_superuser`,所以别拿超管当后门 |
97
+ | **默认组(权限包)** | **默认就有**:SDK 的 `0006` 迁移建「平台管理员」(`edit_magic_block` / `manage_users` / `view_magicblock`)与「普通用户」(`view_magicblock`)两个组,**只建组和权限、不加人**。组名只是标签,判据永远是权限;新用户默认进哪个组由**项目自己的 `post_save`** 决定(框架不装) |
98
98
  | **看数据模型那一页** | 设 `LABULL_SCHEMA_CANVAS=on`,浏览器打开 `/` |
99
99
  | **块的依赖声明不许"用了却没声明"** | 它是**默认**行为(`LABULL_REQUIRES=error`,1.0.1 起):代码里用了没声明的东西 ⇒ 拦住保存。⚠️ 升级前先在项目里跑一遍 `python manage.py labull_requires --write` 把存量块扫得到的声明补进库;`warn`(只提示不拦)与 `off`(整段不跑)都要**显式**写 |
100
100
  | **导出一份模型形状** | `python manage.py labull_contract --out .labull/contract.json` |
@@ -65,8 +65,8 @@ CORS_ORIGIN=http://localhost:5173
65
65
 
66
66
  | 想要什么 | 怎么做 |
67
67
  |---|---|
68
- | **谁是管理员** | 配 `BOOTSTRAP_ADMIN_WORK_IDS=工号,工号` —— 它是**声明式**的名单:启动时名单里的人加进**管理员角色**的组,**名单之外的一律摘掉**(没配就是"一个管理员都没有")。它不碰 `is_superuser`,所以别拿超管当后门 |
69
- | **管理员 / 默认角色** | **默认就有**:SDK 的 `0005` 迁移种下 `ADMIN`(管理员角色)/ `USER`(默认角色,新用户第一次登录自动加入)。要换码或组名就写项目自己的 data migration;两条**各恰好一条、不能同一行**,缺了 / 重了服务起不来 |
68
+ | **谁是管理员** | 判据是**权限** `labull_framework.edit_magic_block`(不是组名、不是 `is_staff`)。配 `BOOTSTRAP_ADMIN_WORK_IDS=工号,工号` —— 它是**声明式**的名单:启动时名单里的人加进**持有那个权限的组**(按权限找组,改组名不影响),**名单之外的一律摘掉**(没配就是"一个管理员都没有")。它不碰 `is_superuser`,所以别拿超管当后门 |
69
+ | **默认组(权限包)** | **默认就有**:SDK 的 `0006` 迁移建「平台管理员」(`edit_magic_block` / `manage_users` / `view_magicblock`)与「普通用户」(`view_magicblock`)两个组,**只建组和权限、不加人**。组名只是标签,判据永远是权限;新用户默认进哪个组由**项目自己的 `post_save`** 决定(框架不装) |
70
70
  | **看数据模型那一页** | 设 `LABULL_SCHEMA_CANVAS=on`,浏览器打开 `/` |
71
71
  | **块的依赖声明不许"用了却没声明"** | 它是**默认**行为(`LABULL_REQUIRES=error`,1.0.1 起):代码里用了没声明的东西 ⇒ 拦住保存。⚠️ 升级前先在项目里跑一遍 `python manage.py labull_requires --write` 把存量块扫得到的声明补进库;`warn`(只提示不拦)与 `off`(整段不跑)都要**显式**写 |
72
72
  | **导出一份模型形状** | `python manage.py labull_contract --out .labull/contract.json` |
@@ -1,8 +1,9 @@
1
1
  """Django admin 的注册处 —— 这个库**唯一**的管理界面。
2
2
 
3
3
  🔴 它就是 Django 原生的那一套:用户 / 组 / 权限的日常维护白拿,我们不写任何自己的管理页面。
4
- 🔴 另外登记一张**项目级**的表:`LabullRole`(码 → Django 组名)。角色是**项目数据**、表默认空,
5
- 框架不内置任何码;组要先在「组」里建好,这里只做配对。
4
+ 🔴 **授权只看 `Permission`**(`app_label.codename`):组只是权限包("组"里打包权限、"用户"里加组);
5
+ 平台自己的两个权限住在 `MagicBlock.Meta.permissions`(`edit_magic_block` / `manage_users`)。
6
+ 两个默认组(`平台管理员` / `普通用户`)由迁移建好 —— 组名只是标签,代码不按它判断任何事。
6
7
  🔴 **块的代码在 admin 里只读**:它只能从 magic block 控制台写进去,因为那里有"保存即校验"那一关
7
8
  (`ast` + 子进程试加载)。admin 里放开编辑表单,就等于从 Django 的默认表单往库里塞
8
9
  **未经校验、之后会被 `exec` 执行**的代码,把"体检不过就停用"这套机制绕开。
@@ -13,7 +14,7 @@ from __future__ import annotations
13
14
 
14
15
  from django.contrib import admin
15
16
 
16
- from .models import LabullRole, MagicBlock, User
17
+ from .models import MagicBlock, User
17
18
 
18
19
 
19
20
  @admin.register(User)
@@ -22,8 +23,11 @@ class UserAdmin(admin.ModelAdmin):
22
23
 
23
24
  ⚠️ `username` 装的是 Authing 的 `sub`(登录时同步进来的),所以它**只读** ——
24
25
  改它等于把这一行与那个身份切开。
25
- 🔴 **平台管理员是"管理员角色的组员"**(`LabullRole.is_admin` 那一行),这里不摆 `is_staff` ——
26
- 那一列只留给 Django 自己的 `/admin`,本项目不读它。
26
+ 🔴 **"能不能编辑块" = 有没有 `labull_framework.edit_magic_block`**(`permissions.EDIT_PERMISSION`)——
27
+ 直接给在这个人身上、或者他所在的某个权限包里都算。这里不摆 `is_staff`:
28
+ 那一列只留给 Django 自己的 `/admin`,平台不读它。
29
+ 🔴 `groups` / `user_permissions` 两栏就是全部授权入口(组是权限包,权限码才是契约)。
30
+ 新用户**不自动进任何组**:那是项目的数据/策略(框架不装 `post_save`)。
27
31
  """
28
32
 
29
33
  list_display = ('username', 'work_id', 'name', 'is_active', 'last_login')
@@ -39,22 +43,6 @@ class UserAdmin(admin.ModelAdmin):
39
43
  )
40
44
 
41
45
 
42
- @admin.register(LabullRole)
43
- class LabullRoleAdmin(admin.ModelAdmin):
44
- """项目角色:**码 → Django 组名**(框架不内置任何业务角色码;两个特殊角色由 SDK 的 0005 迁移种下)。
45
-
46
- 🔴 它是"这个项目有哪些岗"的唯一真源:`flow.py` 里只引用 `role:<码>`,不写角色表。
47
- 🔴 `is_admin`(管理员角色,组员就是平台管理员)与 `is_default_role`(默认角色,新用户自动加入)
48
- **各恰好一条、不能同一行** —— 启动校验会拦;两条由 SDK 的 `0005` 迁移种下(`ADMIN` / `USER`)。
49
- 🔴 组要先在「组」里建好 —— 这里只做配对,**不建组、不建用户**。
50
- """
51
-
52
- list_display = ('code', 'name', 'group', 'is_admin', 'is_default_role')
53
- list_filter = ('is_admin', 'is_default_role')
54
- search_fields = ('code', 'name', 'group')
55
- ordering = ('code',)
56
-
57
-
58
46
  @admin.register(MagicBlock)
59
47
  class MagicBlockAdmin(admin.ModelAdmin):
60
48
  """功能块:看状态、筛 —— **改代码不在这里**(见文件头)。"""
@@ -1,37 +1,35 @@
1
- from __future__ import annotations
2
-
3
- from django.apps import AppConfig
4
-
5
-
6
- class LabullConfig(AppConfig):
7
- """这个 app 的登记项。
8
-
9
- 🔴 `ready()` 里**只挂一个钩子,不碰数据库**:Django 对"在 `ready()` 里查库"会明确警告
10
- (`Accessing the database during app initialization is discouraged`),而且新项目第一次
11
- `migrate` 时表还不存在 —— 那时查库会让**整个命令**起不来。
12
-
13
- 🔴 启动时要做的三件事(校验角色 → 重设管理员名单 → 全量体检)在 `wsgi.py` 里一行显式调用:
14
- ```python
15
- from labull_framework.magic_block.health import startup_once
16
- startup_once()
17
- ```
18
- 那一行是**契约**(函数名与签名都不要改):`runserver` / gunicorn / uwsgi / ASGI
19
- 都从那里进来,所以它天然是"每个进程一次"的地方。
20
- ⚠️ 它**不是** `post_migrate` 的替代:那个只在迁移之后发(管"库刚被改过"),
21
- 而启动体检只有 `startup_once()` 能做。
22
- """
23
-
24
- default_auto_field = 'django.db.models.BigAutoField'
25
- name = 'labull_framework'
26
- verbose_name = 'Labull Framework'
27
-
28
- def ready(self) -> None:
29
- # 延迟 import:此刻 app 注册表已经就绪,但避免模块级 import 造成加载顺序问题。
30
- # 钩子是"库自己正常工作"的一部分(体检库里存着的块),**不是**对使用者项目的检查。
31
- from .magic_block import health
32
- from .roles import install_check
33
-
34
- health.install_hook()
35
- # 🔴 只**注册**一条 system check(不在这里查库):`manage.py check` / `runserver` 会在
36
- # 真正查库之前把"两个特殊角色配错了"报出来;表还没迁移时那条 check 自己会跳过。
37
- install_check()
1
+ from __future__ import annotations
2
+
3
+ from django.apps import AppConfig
4
+
5
+
6
+ class LabullConfig(AppConfig):
7
+ """这个 app 的登记项。
8
+
9
+ 🔴 `ready()` 里**只挂一个钩子,不碰数据库**:Django 对"在 `ready()` 里查库"会明确警告
10
+ (`Accessing the database during app initialization is discouraged`),而且新项目第一次
11
+ `migrate` 时表还不存在 —— 那时查库会让**整个命令**起不来。
12
+ 🔴 `ready()` 里**不装任何 `post_save` / `user_logged_in` 接收器**:框架不给新用户加组
13
+ (那是项目的数据/策略,示例项目给了一份可复制的实现:`backend/example/canvasdemo/signals.py`)。
14
+
15
+ 🔴 启动时要做的两件事(重设管理员名单 → 全量体检)在 `wsgi.py` 里一行显式调用:
16
+ ```python
17
+ from labull_framework.magic_block.health import startup_once
18
+ startup_once()
19
+ ```
20
+ 那一行是**契约**(函数名与签名都不要改):`runserver` / gunicorn / uwsgi / ASGI
21
+ 都从那里进来,所以它天然是"每个进程一次"的地方。
22
+ ⚠️ 它**不是** `post_migrate` 的替代:那个只在迁移之后发(管"库刚被改过"),
23
+ 而启动体检只有 `startup_once()` 能做。
24
+ """
25
+
26
+ default_auto_field = 'django.db.models.BigAutoField'
27
+ name = 'labull_framework'
28
+ verbose_name = 'Labull Framework'
29
+
30
+ def ready(self) -> None:
31
+ # 延迟 import:此刻 app 注册表已经就绪,但避免模块级 import 造成加载顺序问题。
32
+ # 钩子是"库自己正常工作"的一部分(体检库里存着的块),**不是**对使用者项目的检查。
33
+ from .magic_block import health
34
+
35
+ health.install_hook()
@@ -5,12 +5,12 @@
5
5
  所以这个类**不碰会话**、也**不校验 token**(验签是 `sso.py` 的事,视图验完才把 claims 送进来)。
6
6
 
7
7
  🔴 **权限一个字都不给**:Django 有 `is_staff` / `is_superuser` / `Group` / `Permission`,
8
- 登录这件事只负责"你是谁";"你能干什么"由**项目角色表**(`LabullRole`)与 Django 的组决定。
9
- 新建的用户布尔位是 `is_staff=False` / `is_superuser=False` / `is_active=True`(`is_staff` 只留给
10
- Django 自己的 `/admin`,平台的管理员看**管理员角色**)。
11
- 🔴 **新用户默认加入默认角色**(`LabullRole.is_default_role` 那一行的组):这是"新来的人至少有个身份"
12
- 的唯一入口;工号在 `BOOTSTRAP_ADMIN_WORK_IDS` 里的新同事还会顺手进**管理员角色**的组。
13
- 老用户的组与权限**不动**(归管理员)。
8
+ 登录这件事只负责"你是谁";"你能干什么"只看 **`Permission`**(`app_label.codename`),
9
+ 组只是权限包。新建的用户布尔位是 `is_staff=False` / `is_superuser=False` / `is_active=True`
10
+ (`is_staff` 只留给 Django 自己的 `/admin`;平台的管理员看 `edit_magic_block` 这个权限)。
11
+ 🔴 **新用户不自动进任何组**(框架不装 `post_save` / `user_logged_in`):新来的人默认属于哪个组是
12
+ **项目的数据/策略**,由项目自己决定(示例项目给了一份可复制的实现:
13
+ `backend/example/canvasdemo/signals.py`)。老用户的组与权限**不动**(归管理员)。
14
14
  """
15
15
 
16
16
  from __future__ import annotations
@@ -36,42 +36,6 @@ NO_WORK_ID_TEXT = (
36
36
  UPDATABLE_FIELDS: tuple[str, ...] = ('name', 'work_id')
37
37
 
38
38
 
39
- def _join_group(user, name: str) -> None:
40
- """把这个用户加进某个组(组不存在就建);失败只记日志 —— 加组不该让人登不进来。"""
41
- if not name:
42
- return
43
- from django.contrib.auth.models import Group
44
-
45
- try:
46
- group, _created = Group.objects.get_or_create(name=name)
47
- user.groups.add(group)
48
- except Exception as error: # noqa: BLE001
49
- logger.error('新用户加入组「%s」失败(不影响登录):%s', name, error)
50
-
51
-
52
- def _join_default_role(user) -> None:
53
- """把这个**新用户**加进默认角色的组(没有默认角色 ⇒ 什么都不做)。
54
-
55
- 🔴 只对**新建**的用户调:老用户的组归管理员,登录不许重排它。
56
- """
57
- from ..roles import default_role_group
58
-
59
- _join_group(user, default_role_group())
60
-
61
-
62
- def _maybe_join_admin_role(user) -> None:
63
- """工号在 `BOOTSTRAP_ADMIN_WORK_IDS` 名单里 ⇒ 顺手加进**管理员角色**的组。
64
-
65
- 🔴 启动时的 `sync_admin_work_ids()` 只管库里已经有的人;这是"名单里那个新同事
66
- 第一次登录就已经是管理员"的那一半(同一个解析函数,名单不会两处不一致)。
67
- """
68
- from ..roles import admin_role_group
69
- from .bootstrap import configured_admin_work_ids
70
-
71
- if str(user.work_id or '') and str(user.work_id) in set(configured_admin_work_ids()):
72
- _join_group(user, admin_role_group())
73
-
74
-
75
39
  class LoginRejected(Exception):
76
40
  """这次登录不放行,**一个字都不落库**,报文直接给用户看(视图回 401)。"""
77
41
 
@@ -173,8 +137,6 @@ class OidcBackend:
173
137
  user = user_model(username=sub, name=name, work_id=work_id)
174
138
  user.set_unusable_password()
175
139
  user.save()
176
- _join_default_role(user)
177
- _maybe_join_admin_role(user)
178
140
  return user
179
141
  except IntegrityError:
180
142
  # 并发首登:另一个请求刚插进去。重查拿**赢家写下的那一行** ——
@@ -1,16 +1,21 @@
1
1
  """启动时按 `BOOTSTRAP_ADMIN_WORK_IDS` **重设**谁是管理员 —— **这是"管理员名单"的唯一入口**。
2
2
 
3
- 🔴 判据是**声明式**的:那份清单**就是**管理员名单。每次启动都拿它和**管理员角色的组**对一遍 ——
4
- 名单里的人加进那个组,**名单之外的(以及没有工号的)一律摘掉**。
5
- 于是"某人离职 / 不该再当管理员"这件事只需要从 `.env` 里删掉一个工号 + 重启,不必记得去点。
6
- 🔴 "管理员"是**管理员角色**(`LabullRole.is_admin` 那一行,SDK 迁移种下的 `ADMIN`)的组员 ——
7
- **不是** Django 的 `is_staff`:平台不读也不写那一列(那一列只留给 Django 自己的 `/admin`)。
8
- 🔴 **没配 / 是空 ⇒ 把所有人的管理员角色摘掉**("没配"的语义就是"管理员一个都没有")。
3
+ 🔴 判据是**声明式**的:那份清单**就是**管理员名单。每次启动都拿它和**那个装管理员权限的组**
4
+ (`edit_magic_block` 所在的权限包)对一遍 —— 名单里的人加进那个组,
5
+ **名单之外的(以及没有工号的)一律摘掉**。于是"某人离职 / 不该再当管理员"这件事只需要从 `.env`
6
+ 里删掉一个工号 + 重启,不必记得去点。
7
+ 🔴 "算不算管理员"的唯一判据是 **有没有 `labull_framework.edit_magic_block` 权限**
8
+ (`EDIT_PERMISSION in permissions_of(user)`)—— **不是**组名、**不是** Django 的 `is_staff`
9
+ (那一列只留给 Django 自己的 `/admin`)。这里之所以要找一个"组",只是因为**写**的时候要有个落点;
10
+ 找法按**权限**找(`permissions.group_holding(EDIT_PERMISSION)`),所以项目把那个组改名也不影响这份名单。
11
+ 🔴 **没配 / 是空 ⇒ 把所有人的管理员权限摘掉**("没配"的语义就是"管理员一个都没有")。
9
12
  ⚠️ 这是刻意选的 **fail-closed**:`.env` 少写一行、部署时忘了带这个变量,后果是**一个管理员都没有**
10
13
  —— 而不是"上一个被删掉的人悄悄留着权限"。要救回来:把那一行补回去重启。
11
- ⚠️ 反过来说:**它不碰 `is_superuser`**(那是 Django 的另一层,拥有全部权限,不归这份名单管)。
14
+ ⚠️ 反过来说:**它不碰 `is_superuser` / 直接给某个人的权限**(那些是另一层,不归这份名单管)。
12
15
  🔴 清单超过 `MAX_WORK_IDS` 时**只取前 200 个**(防误粘)—— 于是第 201 个之后的人会被停掉,
13
16
  所以那一条 warning 必须显式说出来(不然症状是"名单明明写了,他就是进不去")。
17
+ 🔴 框架**不装 `post_save`**:这份名单只在启动 / 迁移之后同步一次。名单里新加的人**下一次重启**
18
+ 才上岗("登录时顺手加组"那条路 3.0.0 起没有了 —— 组归管理员/项目策略)。
14
19
  """
15
20
 
16
21
  from __future__ import annotations
@@ -19,6 +24,7 @@ import logging
19
24
 
20
25
  from ..env import read_env
21
26
  from ..models import User
27
+ from ..permissions import EDIT_PERMISSION, group_holding
22
28
 
23
29
  logger = logging.getLogger(__name__)
24
30
 
@@ -34,18 +40,18 @@ def _raw_work_ids() -> list[str]:
34
40
  def configured_admin_work_ids() -> list[str]:
35
41
  """`BOOTSTRAP_ADMIN_WORK_IDS` 配的那份工号(去空白、截到上限;**不报警告**)。
36
42
 
37
- 🔴 启动同步(`sync_admin_work_ids`)与"新用户第一次登录"共用这一份解析:
38
- 两处各写一遍的话,`.env` 里多一个空格就会让两边的名单不一样。
43
+ 🔴 只有这一处解析:谁要问"名单里有谁"就调它(另一处手写一遍的话,`.env` 里多一个空格
44
+ 就会让两边的名单不一样)。
39
45
  """
40
46
  return _raw_work_ids()[:MAX_WORK_IDS]
41
47
 
42
48
 
43
49
  def sync_admin_work_ids() -> int:
44
- """把"管理员角色的组员"重设成"只有名单里的人",回这次**真的**改了几个人(上岗 + 下岗)。
50
+ """把"那个装管理员权限的组"的成员重设成"只有名单里的人",回这次**真的**改了几个人(上岗 + 下岗)。
45
51
 
46
52
  🔴 **永不抛**:这是启动路径,库连不上 / 表还没迁移都只该在日志里留一句 ——
47
53
  重设管理员失败没有理由让整个后端起不来。
48
- 🔴 只动**管理员角色那一个组**:别的组(业务岗位的组)一个字都不碰 —— 那些是项目自己的数据。
54
+ 🔴 只动**那一个组**:别的组一个字都不碰 —— 那些是项目自己的权限包。
49
55
  🔴 判据是**名单**,不是"库里现在谁在组里":`work_id` 为 `NULL` 的行永远不在名单里
50
56
  (SQL 的 `NULL IN (…)` / `NULL NOT IN (…)` 都是 unknown),所以"没有工号的人"会被摘掉 ——
51
57
  这是对的(认人只认工号),但也意味着**没有工号的人不可能当管理员**。
@@ -61,23 +67,18 @@ def sync_admin_work_ids() -> int:
61
67
  if not work_ids:
62
68
  logger.warning(
63
69
  'BOOTSTRAP_ADMIN_WORK_IDS 没配 / 是空的:按"管理员一个都没有"处理,'
64
- '会把所有人的管理员角色摘掉。要指定管理员就在 .env 里写一行 '
70
+ '会把所有人的管理员权限摘掉。要指定管理员就在 .env 里写一行 '
65
71
  'BOOTSTRAP_ADMIN_WORK_IDS=<工号>[,<工号>…]。'
66
72
  )
67
73
 
68
- from django.contrib.auth.models import Group
69
-
70
- from ..roles import admin_role_group
71
-
72
- group_name = admin_role_group()
73
- if not group_name:
74
- # 启动校验(roles.validate_roles)会先把"没有管理员角色"拦下来;走到这里说明
75
- # 这一步跑在它之前(例如某个自定义命令),不在启动路径上制造第二次失败。
76
- logger.error('还没有管理员角色(迁移里的 ADMIN / 项目角色表):跳过管理员名单同步')
74
+ group = group_holding(EDIT_PERMISSION)
75
+ if group is None:
76
+ # 迁移会建"平台管理员"那个权限包;走到这里说明库还没迁移(或权限行还没建出来),
77
+ # 不在启动路径上制造第二次失败 —— 下一次启动/迁移之后它就有了。
78
+ logger.error('还没有装 %s 的组(权限包):跳过管理员名单同步', EDIT_PERMISSION)
77
79
  return 0
78
80
 
79
81
  try:
80
- group, _created = Group.objects.get_or_create(name=group_name)
81
82
  wanted = list(User.objects.filter(work_id__in=work_ids))
82
83
  wanted_ids = {user.pk for user in wanted}
83
84
  current_ids = set(group.user_set.values_list('pk', flat=True))
@@ -88,11 +89,11 @@ def sync_admin_work_ids() -> int:
88
89
  if removed:
89
90
  group.user_set.remove(*User.objects.filter(pk__in=removed))
90
91
  except Exception as error: # noqa: BLE001 —— 见 docstring:启动路径永不抛
91
- logger.error('按 BOOTSTRAP_ADMIN_WORK_IDS 同步管理员角色失败(不影响启动):%s', error)
92
+ logger.error('按 BOOTSTRAP_ADMIN_WORK_IDS 同步管理员失败(不影响启动):%s', error)
92
93
  return 0
93
94
  if added or removed:
94
95
  logger.info(
95
- '按 BOOTSTRAP_ADMIN_WORK_IDS 同步管理员角色「%s」:加进 %s 个、摘掉 %s 个(名单里有 %s 个工号)',
96
- group_name, len(added), len(removed), len(work_ids),
96
+ '按 BOOTSTRAP_ADMIN_WORK_IDS 同步管理员「%s」:加进 %s 个、摘掉 %s 个(名单里有 %s 个工号)',
97
+ group.name, len(added), len(removed), len(work_ids),
97
98
  )
98
99
  return len(added) + len(removed)
@@ -133,16 +133,16 @@ def user_to_dict(user) -> dict:
133
133
  而模型是 `snake_case` ⇒ 两种形状之间的转换只该有**这一处**。
134
134
  ⚠️ `work_id` 可空就原样回 `None`,不兜成空串(空串会被当成"有个空工号")。
135
135
  ⚠️ 时间只有一处实现(`jsonio.iso_utc`):别再写第二份 `isoformat().replace(…)`。
136
- 🔴 `isAdmin` 是**管理员角色的组员**(`roles.is_admin()`)—— 不是 Django 的 `is_staff`。
136
+ 🔴 **没有 `isAdmin` 这类派生位**:能做什么只看 `permissions`(`app_label.codename`,
137
+ 直接给的 + 经组给的)。前端要判"能不能编辑块"就查
138
+ `permissions.includes('labull_framework.edit_magic_block')`。
139
+ 🔴 `groups` 只是权限包的名字(只给人看/排查),**不要**拿组名做判断。
137
140
  """
138
- from ..roles import is_admin
139
-
140
141
  return {
141
142
  'id': user.pk,
142
143
  'username': user.get_username(),
143
144
  'name': user.name,
144
145
  'workId': user.work_id,
145
- 'isAdmin': is_admin(user),
146
146
  'isSuperuser': user.is_superuser,
147
147
  'isActive': user.is_active,
148
148
  'groups': [group.name for group in user.groups.all()],
@@ -24,23 +24,42 @@ from .. import virtual as virtual_registry
24
24
  #: 拖拽位置的落点(相对项目根)。🚫 不落盘也能用(没有这个目录就不持久化,不报错)。
25
25
  LAYOUT_FILE = '.labull/schema-layout.json'
26
26
 
27
- #: 不画这几张:它们是**Django 自己的**(会话 / 权限 / 内容类型 / 后台日志),
28
- #: 与"这个项目的数据模型"无关,摆在图上只会淹掉真正的业务表。
29
- SKIP_TABLES = frozenset(
30
- {
31
- 'auth_group',
32
- 'auth_group_permissions',
33
- 'auth_permission',
34
- 'auth_user_groups',
35
- 'auth_user_user_permissions',
36
- 'django_admin_log',
37
- 'django_content_type',
38
- 'django_migrations',
39
- 'django_session',
40
- }
27
+ #: Django 自带的那几个模型在画布上归到**这一个模块**(`模块` 下拉框里那一项叫 `Django`)。
28
+ #: 🔴 3.0.0 起它们**也画**(用户要的:`labull_user ──groups──> auth_group ──permissions──> auth_permission`
29
+ #: 这张图要能一眼看全),但只**展示**、**不参与 git diff**(见 `_diffable()`):
30
+ #: 它们的源码住在装着的 Django 里,不存在"这个项目的上一版",比出来的只会是假差异。
31
+ BUILTIN_MODULE = 'Django'
32
+
33
+ #: Django 自带的抽象基类:画成 `Django` 模块里的**紫色虚拟卡片**(与 `WorkflowModel` 同一种样式)。
34
+ #: 🔴 **不画继承线**:它们不是"本项目的泛化",只是"这张表继承了 Django 的那一层"。
35
+ BUILTIN_VIRTUALS: tuple[str, ...] = (
36
+ 'django.contrib.auth.models.AbstractUser',
37
+ 'django.contrib.auth.base_user.AbstractBaseUser',
38
+ 'django.contrib.auth.models.PermissionsMixin',
41
39
  )
42
40
 
43
41
 
42
+ def _is_builtin(model: Any) -> bool:
43
+ """这张模型是不是 **Django 自己的**(`django.contrib.*` 那几张)。
44
+
45
+ 🔴 判据是**模块路径**(`model.__module__` 以 `django.` 开头),不是 app 标签、更不是表名:
46
+ `auth.Group` / `admin.LogEntry` / `contenttypes.ContentType` / `sessions.Session`
47
+ 全都住在 Django 里,而它们的 app 标签各是 `auth` / `admin` / …(一张名单迟早漏)。
48
+ ⚠️ 一律不抛:读不出来就当"不是内置"(宁可多画一张业务卡片,也不要把它藏了)。
49
+ """
50
+ module = str(getattr(model, '__module__', '') or '')
51
+ return module == 'django' or module.startswith('django.')
52
+
53
+
54
+ def _diffable(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
55
+ """从一份形状里剔掉**内置卡片**(`builtin`):它们只展示、不参与 git diff。
56
+
57
+ 🔴 只有这一处判据:`payload()` 算表 / 虚拟类为类的三态之前都先过它 ——
58
+ 内置卡片的"上一版"在任何人手里都不存在(Django 的源码不在这个项目的 git 里)。
59
+ """
60
+ return [item for item in items if not item.get('builtin')]
61
+
62
+
44
63
  def enabled() -> bool:
45
64
  """那一页开不开:`on` 常开;`auto` 只在 `DEBUG` 时开;其余(含默认 `off`)不开。"""
46
65
  switch = env.read_env('LABULL_SCHEMA_CANVAS').lower()
@@ -119,6 +138,28 @@ def _virtual_models() -> list[Any]:
119
138
  return out
120
139
 
121
140
 
141
+ def _builtin_virtuals() -> list[Any]:
142
+ """Django 自带的那几个**抽象基类**(`AbstractUser` / `AbstractBaseUser` / `PermissionsMixin`)。
143
+
144
+ 🔴 它们**不是** `@virtual_class` 打的标记(那要求"本项目自己的类"),所以不进 `virtual` 登记表;
145
+ 但对画布来说它们是同一类东西:**不建表的抽象模型** ⇒ 同一张紫色卡片,只是归到 `Django` 模块。
146
+ 🔴 **不画继承线**:`User` 表上不会因为继承了它们而多出泛化标记(`virtualParents` 只认登记表:
147
+ 见 `_virtual_ancestors_of()`)—— "Django 的那一层"不该混进项目的泛化关系里。
148
+ ⚠️ 拿不到(Django 版本不同 / 模块路径变了)就跳过那一条,不抛。
149
+ """
150
+ import importlib
151
+
152
+ out: list[Any] = []
153
+ for path in BUILTIN_VIRTUALS:
154
+ module_name, _, class_name = path.rpartition('.')
155
+ try:
156
+ model = getattr(importlib.import_module(module_name), class_name)
157
+ except Exception: # noqa: BLE001 —— 见 docstring:拿不到就少一张卡片
158
+ continue
159
+ out.append(model)
160
+ return out
161
+
162
+
122
163
  def _table_of_model(model: Any) -> str:
123
164
  """一个模型的表名(`_meta.db_table`;拿不到回空串)—— 判"这张表画不画"用。
124
165
 
@@ -352,9 +393,9 @@ def m2m_through(field: Any) -> Any:
352
393
  field 本身**,而它在 Django 里**没有 `through` 属性**(不是 None,是 AttributeError),
353
394
  中间表挂在 `field.through` 上。
354
395
  ⚠️ 老代码写的是 `getattr(field.remote_field, 'through', None)`:反向那一侧拿到 `None`,
355
- 紧接着 `None._meta` 抛 `AttributeError` —— 而 `auth` 的那几张表恰好在 `SKIP_TABLES` 里、
356
- 平时又没人写过普通多对多,所以这个坑一直没露出来。`getattr` 的默认值把 AttributeError
357
- 一起吞了,连报错都指不到真正的原因。
396
+ 紧接着 `None._meta` 抛 `AttributeError` —— 而 `auth` 的那几张表当年不画(`SKIP_TABLES`,
397
+ 3.0.0 已删:Django 自带的表现在也画)、平时又没人写过普通多对多,所以这个坑一直没露出来。
398
+ `getattr` 的默认值把 AttributeError 一起吞了,连报错都指不到真正的原因。
358
399
  """
359
400
  for owner in (getattr(field, 'remote_field', None), field):
360
401
  through = getattr(owner, 'through', None)
@@ -376,11 +417,14 @@ def shape() -> dict[str, list[dict[str, Any]]]:
376
417
  """
377
418
  tables: list[dict[str, Any]] = []
378
419
  for model in apps.get_models():
379
- if str(model._meta.db_table) in SKIP_TABLES:
380
- continue
420
+ # 🔴 **一张都不跳**(3.0.0 起):Django 自带的那几张也画,只是归到 `Django` 模块、
421
+ # 并且不参与 git diff(见 `_card()` / `_diffable()`)。
381
422
  tables.append(_card(model, str(model._meta.db_table), virtual=False))
382
423
  tables.sort(key=lambda item: (item['module'], item['table']))
383
- virtuals = [_card(model, str(model._meta.db_table), virtual=True) for model in _virtual_models()]
424
+ virtuals = [
425
+ _card(model, str(model._meta.db_table), virtual=True)
426
+ for model in (list(_virtual_models()) + _builtin_virtuals())
427
+ ]
384
428
  virtuals.sort(key=lambda item: (item['module'], item['table']))
385
429
  return {'tables': tables, 'virtuals': virtuals}
386
430
 
@@ -393,13 +437,18 @@ def _card(model: Any, table: str, *, virtual: bool) -> dict[str, Any]:
393
437
  会变成一类只在虚拟卡片上出现的前端报错。
394
438
  """
395
439
  fields, relations, enums = _fields_of(model)
440
+ builtin = _is_builtin(model)
396
441
  return {
397
442
  'name': model.__name__,
398
443
  'table': table,
399
- 'module': str(model._meta.app_label),
444
+ # 🔴 Django 自带的模型归到 `Django` 这一个模块(模块下拉框里那一项),别的用 app 标签。
445
+ 'module': BUILTIN_MODULE if builtin else str(model._meta.app_label),
400
446
  'verbose': _verbose(model),
401
447
  'brief': _brief(model),
402
448
  'briefLine': _brief_line(model),
449
+ # 🔴 **这张卡片是不是 Django 自带的**:只展示、不参与 git diff(见 `_diffable()`)。
450
+ # 前端可以不读它(模块名已经够分组了),但它是"为什么不带差异色"的书面答案。
451
+ 'builtin': builtin,
403
452
  # 🔴 这张表**自己定义**的那几个枚举类(类名 → 中文名 / 成员)。
404
453
  # 它是**只增不减**的信息:画布不读它,导出(`devkit`)靠它写出 `Order.Status.OPEN`
405
454
  # 那种写法。见 `_enums_of()` 顶上那段(为什么必须扫模型类体)。
@@ -477,14 +526,14 @@ def _fields_of(model: Any) -> tuple[list[dict[str, Any]], list[dict[str, Any]],
477
526
  )
478
527
  if reverse_rel:
479
528
  target = field.related_model
480
- # 🔴 **对端是"我们不画的那几张表"时不补这一条**(`django.contrib.*` / 会话 / 迁移):
481
- # 它的正向声明住在 **Django 自己的源码**里,而"上一版"那一份只扫项目与装着的
482
- # 第三方 app(`shape_ast._reverses()` 里 `if target is None or not self._installed(...)`
483
- # 那一句)⇒ 基线里**永远没有**这一条,于是画布常年报一处假的"新增的关联"。
529
+ # 🔴 **对端是 Django 自己的模型时不补这条反向关联**(`auth_permission` 上的
530
+ # `group` / `user` 之类):它的正向声明住在 **Django 自己的源码**里,而"上一版"
531
+ # 那一份只扫项目与装着的第三方 app(`shape_ast._reverses()` 里
532
+ # `if target is None or not self._installed(...)` 那一句)⇒ 基线里**永远没有**
533
+ # 这一条,于是画布常年报一处假的"新增的关联"。
484
534
  # 实测就是它:装了 `django.contrib.admin` 时用户表上那条 `labull_user.logentry`。
485
- # ⚠️ 这条边界**只能靠"不画的表两边都不挂它的反向关联"来对齐**(见下面那句
486
- # `SKIP_TABLES` 判定)—— 想靠"在基线里把它剔掉"是越剔越糟,别再去试。
487
- if _table_of_model(target) in SKIP_TABLES:
535
+ # ⚠️ 内置卡片本身照样画(`Django` 模块);这里只管"别给反向关联造一份假差异"。
536
+ if _is_builtin(target):
488
537
  continue
489
538
  # 🔴 一对一反过来的**基数也是 `1`**:这一头只对应一行(`OneToOneRel.one_to_one`)。
490
539
  # 报成 `*` 会让前端画出"一对多"那种端口,也与基线那一份(`back_card = '1'`)对不上
@@ -696,7 +745,12 @@ def payload() -> dict[str, Any]:
696
745
  from . import gitinfo
697
746
 
698
747
  current = shape()
699
- current_tables = current['tables']
748
+ # 🔴 **内置卡片只展示、不参与 diff**(`_diffable()`):`current['tables']` / `virtuals` 原样
749
+ # 发给前端画(`Django` 模块那几张),而下面每一条差异函数吃的都是剔掉 `builtin` 的那一份。
750
+ # 它们的"上一版"不存在于这个项目的 git 里 —— 比进去只会给每张内置卡片配一条假差异。
751
+ current_tables = _diffable(current['tables'])
752
+ # ⚠️ 虚拟类**整份**交给 `_virtual_payload()`(内置抽象基类也要画成卡片),
753
+ # 由它内部再剔一次 `builtin` 去算差异(见那个函数)。
700
754
  current_virtuals = current['virtuals']
701
755
  # 🔴 **先把基线里的两份分开**:`gitinfo.baseline_tables()` 现在回报的是**表 + 虚拟类**
702
756
  # (见 `shape_ast.derive()`),而表那一套差异函数(`_diff` / `_field_diff` /
@@ -720,6 +774,7 @@ def payload() -> dict[str, Any]:
720
774
  relation_diff = _relation_diff(baseline_tables, current_tables)
721
775
  field_diff = _field_diff(baseline_tables, current_tables)
722
776
  return {
777
+ # 🔴 这一份是**画在页面上**的(含 Django 自带的卡片);差异那几份都已经剔掉 `builtin`。
723
778
  'tables': current['tables'],
724
779
  'deletedTables': _deleted_tables(baseline_tables, diff['deleted']),
725
780
  'diff': diff,
@@ -794,15 +849,22 @@ def _virtual_payload(
794
849
  是 `[]`,而 `[]` 的语义是"基线里确实一个虚拟类都没有" ⇒ 现在有的都算新增。
795
850
  **这是刻意的**:虚拟类是这一版才有的概念,第一个用它的项目本来就该在画布上
796
851
  看到"新增"(那正是"这个提交开始有了泛化"这句话)。
852
+ 🔴 **内置卡片(`builtin`)不参与**:卡片照发(`virtuals` 原样),差异只算剔掉它们的那一份
853
+ —— Django 自带的那几个抽象基类在任何基线里都不存在,比进去就是一条假"新增"。
797
854
  🔴 **子表(`subclasses`)挂到卡片上**:虚拟卡片要写得出"哪几张表在继承我" ——
798
855
  没有它,虚拟类被改名 / 被删时人不知道会波及谁,而那是真要回去改代码的事。
799
856
  判据只有一处:`table.virtualParents`(`_card()` 算的)里有没有这个名字。
800
857
  """
801
- virtual_diff = _diff(baseline_virtuals, virtuals)
802
- virtual_field_diff = _field_diff(baseline_virtuals, virtuals)
858
+ diffable = _diffable(virtuals)
859
+ virtual_diff = _diff(baseline_virtuals, diffable)
860
+ virtual_field_diff = _field_diff(baseline_virtuals, diffable)
803
861
  # ⚠️ 先算差异再往卡片上挂 `subclasses`:`_diff` / `_field_diff` 比的是**结构**
804
862
  # (名字 + 列的形状),多挂一个键不影响它们 —— 但先算更不容易看错。
805
863
  for item in virtuals:
864
+ # 🔴 内置卡片(Django 自带的抽象基类)不挂 `subclasses`:那会把"Django 的那一层"
865
+ # 画成这个项目的泛化关系(要的是**只展示、不连继承线**,见 `_builtin_virtuals()`)。
866
+ if item.get('builtin'):
867
+ continue
806
868
  name = str(item.get('name') or '')
807
869
  item['subclasses'] = sorted(
808
870
  str(table.get('name'))
@@ -608,8 +608,9 @@ def _read_app_sources(
608
608
  读工作区那份等于**把平台自己未提交的改动吸收进基线**,两边一模一样 ⇒ 差异恒为空。
609
609
  ② 否则读**装着的这一份**(工作区文件):它不是仓库(`pip install` 出来的 wheel、
610
610
  `.zip` 里、源码不在本地),或者调用方要的本来就是"现在这一份"(`current_sources()`)。
611
- 🔴 **`django.contrib.*` 一律不读**:它们的表画布本来就不画(`data.SKIP_TABLES`),
612
- 读进来只会以相反的符号冒出来(基线有、当前没有 ⇒ 报一堆"删除")。
611
+ 🔴 **`django.contrib.*` 一律不读**:它们是 Django 自己的表(画布上归 `Django` 模块、
612
+ **不参与 diff**,见 `data._diffable()`),读进来只会以相反的符号冒出来(基线有、当前没有
613
+ ⇒ 报一堆"删除")。
613
614
  :param packages: 只读这几个包(`None` = 全部)。`current_sources()` 用它把"装着的 app"
614
615
  收窄成"**模型真的定义在**这个 app 里"的那些 —— `INSTALLED_APPS` 里还住着
615
616
  `apps.todos` 这种没有 `models.py` 的条目,收窄了才与 `data.shape()` 同形。
@@ -127,7 +127,8 @@ BIG_PK_TYPE = 'bigint'
127
127
  #:
128
128
  #: 🔴 为什么需要它:`INSTALLED_APPS` 里的 `django.contrib.admin` **也带模型**
129
129
  #: (`LogEntry` 有一条指向用户表的外键),于是 `_meta` 会给出 `labull_user.logentry`
130
- #: 这条反向关联 —— 而它的来源 `django_admin_log` 在 `data.SKIP_TABLES` 里(画布不画)。
130
+ #: 这条反向关联 —— 而它的来源 `django_admin_log` 是 Django 自己的表:3.0.0 起画布**画**它
131
+ #: (`Django` 模块),但**不参与 diff**,所以基线这一侧仍然一条都不读它。
131
132
  #: 不排掉这一档,"这个项目里从没人声明过的那条关联"会一直显示成"新增"(本仓库就是这样)。
132
133
  #: ⚠️ 判据是**包名**(`django.contrib.*` 全是 Django 自己的),不是"有没有源码":
133
134
  #: 本项目里的 app 拿不到源码是**配置问题**,那种情况该报出来,不该被这里悄悄吞掉。
@@ -1020,7 +1021,7 @@ class _Project:
1020
1021
  `AppConfig.label` 改成别的 —— 两种都算命中(宁可多画一张表,也不要因为一个名字
1021
1022
  没对上就把整张表报成"被删了")。
1022
1023
  🔴 **Django 自己的 app 一律不算"这个项目的 app"**(见 `DJANGO_APPS_PREFIX`):
1023
- 它们的确装在里面,但它们的表画布不画(`data.SKIP_TABLES`),
1024
+ 它们的确装在里面,但它们的模型**不参与 diff**(画布上归 `Django` 模块,只展示),
1024
1025
  而它们的模型会往**你的**表上挂反向关联(`labull_user.logentry`)——
1025
1026
  那一条在你的源码里根本不存在,算进来就是一条假差异。
1026
1027
  🔴 **要认"包路径"与"标签"两种写法**:这里的入参是 `model.app`(包路径,
@@ -158,7 +158,7 @@ ENV_ITEMS: tuple[EnvItem, ...] = (
158
158
  # ── 平台开关 ──
159
159
  EnvItem(
160
160
  name='BOOTSTRAP_ADMIN_WORK_IDS',
161
- brief='管理员名单:启动时把这些工号加进**管理员角色**的组,名单之外的一律摘掉(逗号分隔,上限 200;没配就是没有管理员)',
161
+ brief='管理员名单:启动时把这些人加进「平台管理员」权限包(`edit_magic_block` 所在的那个组),名单之外的一律摘掉(逗号分隔,上限 200;没配就是没有管理员)',
162
162
  hint='BOOTSTRAP_ADMIN_WORK_IDS=1001',
163
163
  group='平台开关',
164
164
  ),