labull-framework 0.1.0__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-0.1.0/LICENSE +28 -0
- labull_framework-0.1.0/MANIFEST.in +22 -0
- labull_framework-0.1.0/PKG-INFO +140 -0
- labull_framework-0.1.0/README-pypi.md +112 -0
- labull_framework-0.1.0/labull_framework/__init__.py +8 -0
- labull_framework-0.1.0/labull_framework/admin.py +63 -0
- labull_framework-0.1.0/labull_framework/apps.py +33 -0
- labull_framework-0.1.0/labull_framework/auth/__init__.py +4 -0
- labull_framework-0.1.0/labull_framework/auth/backend.py +144 -0
- labull_framework-0.1.0/labull_framework/auth/bootstrap.py +47 -0
- labull_framework-0.1.0/labull_framework/auth/sso.py +259 -0
- labull_framework-0.1.0/labull_framework/auth/views.py +184 -0
- labull_framework-0.1.0/labull_framework/canvas/__init__.py +19 -0
- labull_framework-0.1.0/labull_framework/canvas/data.py +523 -0
- labull_framework-0.1.0/labull_framework/canvas/gitinfo.py +155 -0
- labull_framework-0.1.0/labull_framework/canvas/static/canvas.css +487 -0
- labull_framework-0.1.0/labull_framework/canvas/static/canvas.js +1036 -0
- labull_framework-0.1.0/labull_framework/canvas/static/page.html +52 -0
- labull_framework-0.1.0/labull_framework/canvas/views.py +121 -0
- labull_framework-0.1.0/labull_framework/env.py +187 -0
- labull_framework-0.1.0/labull_framework/jsonio.py +57 -0
- labull_framework-0.1.0/labull_framework/magic_block/__init__.py +2 -0
- labull_framework-0.1.0/labull_framework/magic_block/devkit.py +428 -0
- labull_framework-0.1.0/labull_framework/magic_block/dispatch.py +314 -0
- labull_framework-0.1.0/labull_framework/magic_block/export.py +773 -0
- labull_framework-0.1.0/labull_framework/magic_block/health.py +220 -0
- labull_framework-0.1.0/labull_framework/magic_block/registry.py +157 -0
- labull_framework-0.1.0/labull_framework/magic_block/sdk.py +245 -0
- labull_framework-0.1.0/labull_framework/magic_block/service.py +405 -0
- labull_framework-0.1.0/labull_framework/magic_block/validate.py +558 -0
- labull_framework-0.1.0/labull_framework/magic_block/views.py +407 -0
- labull_framework-0.1.0/labull_framework/management/__init__.py +0 -0
- labull_framework-0.1.0/labull_framework/management/commands/__init__.py +0 -0
- labull_framework-0.1.0/labull_framework/management/commands/labull_contract.py +52 -0
- labull_framework-0.1.0/labull_framework/migrations/0001_initial.py +71 -0
- labull_framework-0.1.0/labull_framework/migrations/__init__.py +0 -0
- labull_framework-0.1.0/labull_framework/models.py +76 -0
- labull_framework-0.1.0/labull_framework/settings.py +223 -0
- labull_framework-0.1.0/labull_framework/urls.py +93 -0
- labull_framework-0.1.0/labull_framework.egg-info/PKG-INFO +140 -0
- labull_framework-0.1.0/labull_framework.egg-info/SOURCES.txt +44 -0
- labull_framework-0.1.0/labull_framework.egg-info/dependency_links.txt +1 -0
- labull_framework-0.1.0/labull_framework.egg-info/requires.txt +2 -0
- labull_framework-0.1.0/labull_framework.egg-info/top_level.txt +1 -0
- labull_framework-0.1.0/pyproject.toml +85 -0
- labull_framework-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Labull
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
第三方组件(随本包分发的成品 CSS / 字形)各自的许可归其作者所有:
|
|
26
|
+
|
|
27
|
+
- PrimeVue 主题层(`Mira`)与 PrimeIcons 字形:搬入的成品 CSS,均为 MIT。
|
|
28
|
+
- 其余依赖(Django、PyJWT 等)按各自许可(BSD-3-Clause / MIT)分发,未做修改。
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# sdist 只该装 `labull_framework` 这一个 app。
|
|
2
|
+
# 🔴 `backend/example/`(含 `.env` 真密钥、`db.sqlite3`、`.venv/` 整个 Django 示例项目)
|
|
3
|
+
# 与仓库根的 `scratch/` 一律不进包 —— 显式排掉,不指望 setuptools 的默认扫描。
|
|
4
|
+
global-exclude *.py[cod] *.pyo
|
|
5
|
+
global-exclude .env .env.*
|
|
6
|
+
global-exclude db.sqlite3
|
|
7
|
+
global-exclude *.sqlite3
|
|
8
|
+
|
|
9
|
+
prune example
|
|
10
|
+
prune scratch
|
|
11
|
+
prune tests
|
|
12
|
+
prune .venv
|
|
13
|
+
prune __pycache__
|
|
14
|
+
|
|
15
|
+
include LICENSE
|
|
16
|
+
include README-pypi.md
|
|
17
|
+
include pyproject.toml
|
|
18
|
+
|
|
19
|
+
# 包内数据(`canvas/views.py` 运行期真的会读这三个)。
|
|
20
|
+
recursive-include labull_framework/canvas/static *.css *.js *.html
|
|
21
|
+
recursive-include labull_framework *.pyi py.typed
|
|
22
|
+
recursive-include labull_framework/migrations *.py
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: labull-framework
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks
|
|
5
|
+
Author: Labull Framework
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: django,authing,oidc,sso,magic-block
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Framework :: Django
|
|
10
|
+
Classifier: Framework :: Django :: 4.2
|
|
11
|
+
Classifier: Framework :: Django :: 5.0
|
|
12
|
+
Classifier: Framework :: Django :: 5.1
|
|
13
|
+
Classifier: Framework :: Django :: 5.2
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: Django>=4.2
|
|
26
|
+
Requires-Dist: PyJWT[crypto]>=2.8
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# labull-framework
|
|
30
|
+
|
|
31
|
+
一个可插拔的 **Django app**(import 名 `labull_framework`),提供两件事:
|
|
32
|
+
|
|
33
|
+
- **Authing / OIDC 登录**:自带的认证后端,只用标准库 + PyJWT,不依赖任何 OIDC 客户端包。
|
|
34
|
+
- **magic block**:一个 uuid + 一整个文件包就是一个功能块,住在数据库里、在沙箱 iframe 里渲染,
|
|
35
|
+
不重新编译、不重启;块可以带自己的后端 Python(`backend_open.py`)。
|
|
36
|
+
|
|
37
|
+
另附一个可选的 **数据模型页**(`LABULL_SCHEMA_CANVAS=on` 才开)。
|
|
38
|
+
|
|
39
|
+
## 安装
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install labull-framework
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 接进一个 Django 项目
|
|
46
|
+
|
|
47
|
+
`settings.py` 里三件事:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from labull_framework.env import load_env_file
|
|
51
|
+
load_env_file(BASE_DIR / '.env') # ① 第一行:读 .env
|
|
52
|
+
|
|
53
|
+
AUTH_USER_MODEL = 'labull_framework.User' # ② 第一次 migrate 之前就要设好
|
|
54
|
+
|
|
55
|
+
from labull_framework.settings import apply_platform_settings
|
|
56
|
+
apply_platform_settings(globals()) # ③ 最后一行:把平台的设置补进来
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`urls.py` 里一行:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from labull_framework.urls import urlpatterns as labull_urls
|
|
63
|
+
urlpatterns = [*labull_urls, path('admin/', admin.site.urls)]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`wsgi.py` 里一行(**别漏**,补管理员与启动体检都在这里):
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from labull_framework.magic_block.health import startup_once
|
|
70
|
+
startup_once()
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
然后:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
python manage.py migrate
|
|
77
|
+
python manage.py runserver
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`.env` 最少要这几项:
|
|
81
|
+
|
|
82
|
+
```ini
|
|
83
|
+
DATABASE_URL=postgres://postgres:postgres@localhost:5432/app
|
|
84
|
+
SSO_ISSUER=https://<你的域名>.authing.cn/oidc
|
|
85
|
+
SSO_CLIENT_ID=<Authing 应用 ID>
|
|
86
|
+
SSO_CLIENT_SECRET=<Authing 应用密钥>
|
|
87
|
+
SSO_REDIRECT_URI=http://localhost:8000/auth/callback
|
|
88
|
+
SSO_WEB_ORIGIN=http://localhost:5173
|
|
89
|
+
CORS_ORIGIN=http://localhost:5173
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`sqlite:///db.sqlite3` 也认,本地开发不必装数据库。
|
|
93
|
+
|
|
94
|
+
| 想要什么 | 怎么做 |
|
|
95
|
+
|---|---|
|
|
96
|
+
| **谁是管理员** | Django 的 `is_staff`(admin 里勾,或配 `BOOTSTRAP_ADMIN_WORK_IDS` 写工号) |
|
|
97
|
+
| **看数据模型那一页** | 设 `LABULL_SCHEMA_CANVAS=on`,浏览器打开 `/` |
|
|
98
|
+
| **导出一份模型形状** | `python manage.py labull_contract --out .labull/contract.json` |
|
|
99
|
+
|
|
100
|
+
## magic block
|
|
101
|
+
|
|
102
|
+
一个块 = **一个 uuid + 一整个文件包**:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
我的块/
|
|
106
|
+
├─ index.open.html 入口(必备)
|
|
107
|
+
├─ magic-block.open.yaml 清单(必备):id / module / name / brief 四个键
|
|
108
|
+
├─ 任意 js / css
|
|
109
|
+
└─ backend_open.py 块自己的后端(可选)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
块后端与项目里别的 Python 代码没有区别:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from labull_framework.magic_block.sdk import MagicBackendAPI, errors
|
|
116
|
+
|
|
117
|
+
class Order:
|
|
118
|
+
@MagicBackendAPI() # 省略 permission = 任何登录用户都能调
|
|
119
|
+
def list(self, ctx, params):
|
|
120
|
+
from modules.orders.models import Order
|
|
121
|
+
return [{'id': o.id} for o in Order.objects.all()[:20]]
|
|
122
|
+
|
|
123
|
+
@MagicBackendAPI(permission='labull_framework.edit_blocks')
|
|
124
|
+
def close(self, ctx, params):
|
|
125
|
+
if not params.get('id'):
|
|
126
|
+
raise errors.BadRequest('要给我一个 id')
|
|
127
|
+
return {'ok': True}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
配置:环境变量里**带 `BLOCK_` 前缀**的才会进 `ctx.config`(`BLOCK_OCR_KEY=a` ⇒ `ctx.config['OCR_KEY']`)。
|
|
131
|
+
|
|
132
|
+
## 运行环境
|
|
133
|
+
|
|
134
|
+
- Python ≥ 3.10
|
|
135
|
+
- Django ≥ 4.2(在 6.1 上验证过)
|
|
136
|
+
- PyJWT(带 `crypto` 后端)
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# labull-framework
|
|
2
|
+
|
|
3
|
+
一个可插拔的 **Django app**(import 名 `labull_framework`),提供两件事:
|
|
4
|
+
|
|
5
|
+
- **Authing / OIDC 登录**:自带的认证后端,只用标准库 + PyJWT,不依赖任何 OIDC 客户端包。
|
|
6
|
+
- **magic block**:一个 uuid + 一整个文件包就是一个功能块,住在数据库里、在沙箱 iframe 里渲染,
|
|
7
|
+
不重新编译、不重启;块可以带自己的后端 Python(`backend_open.py`)。
|
|
8
|
+
|
|
9
|
+
另附一个可选的 **数据模型页**(`LABULL_SCHEMA_CANVAS=on` 才开)。
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install labull-framework
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 接进一个 Django 项目
|
|
18
|
+
|
|
19
|
+
`settings.py` 里三件事:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from labull_framework.env import load_env_file
|
|
23
|
+
load_env_file(BASE_DIR / '.env') # ① 第一行:读 .env
|
|
24
|
+
|
|
25
|
+
AUTH_USER_MODEL = 'labull_framework.User' # ② 第一次 migrate 之前就要设好
|
|
26
|
+
|
|
27
|
+
from labull_framework.settings import apply_platform_settings
|
|
28
|
+
apply_platform_settings(globals()) # ③ 最后一行:把平台的设置补进来
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`urls.py` 里一行:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from labull_framework.urls import urlpatterns as labull_urls
|
|
35
|
+
urlpatterns = [*labull_urls, path('admin/', admin.site.urls)]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`wsgi.py` 里一行(**别漏**,补管理员与启动体检都在这里):
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from labull_framework.magic_block.health import startup_once
|
|
42
|
+
startup_once()
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
然后:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
python manage.py migrate
|
|
49
|
+
python manage.py runserver
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`.env` 最少要这几项:
|
|
53
|
+
|
|
54
|
+
```ini
|
|
55
|
+
DATABASE_URL=postgres://postgres:postgres@localhost:5432/app
|
|
56
|
+
SSO_ISSUER=https://<你的域名>.authing.cn/oidc
|
|
57
|
+
SSO_CLIENT_ID=<Authing 应用 ID>
|
|
58
|
+
SSO_CLIENT_SECRET=<Authing 应用密钥>
|
|
59
|
+
SSO_REDIRECT_URI=http://localhost:8000/auth/callback
|
|
60
|
+
SSO_WEB_ORIGIN=http://localhost:5173
|
|
61
|
+
CORS_ORIGIN=http://localhost:5173
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`sqlite:///db.sqlite3` 也认,本地开发不必装数据库。
|
|
65
|
+
|
|
66
|
+
| 想要什么 | 怎么做 |
|
|
67
|
+
|---|---|
|
|
68
|
+
| **谁是管理员** | Django 的 `is_staff`(admin 里勾,或配 `BOOTSTRAP_ADMIN_WORK_IDS` 写工号) |
|
|
69
|
+
| **看数据模型那一页** | 设 `LABULL_SCHEMA_CANVAS=on`,浏览器打开 `/` |
|
|
70
|
+
| **导出一份模型形状** | `python manage.py labull_contract --out .labull/contract.json` |
|
|
71
|
+
|
|
72
|
+
## magic block
|
|
73
|
+
|
|
74
|
+
一个块 = **一个 uuid + 一整个文件包**:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
我的块/
|
|
78
|
+
├─ index.open.html 入口(必备)
|
|
79
|
+
├─ magic-block.open.yaml 清单(必备):id / module / name / brief 四个键
|
|
80
|
+
├─ 任意 js / css
|
|
81
|
+
└─ backend_open.py 块自己的后端(可选)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
块后端与项目里别的 Python 代码没有区别:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from labull_framework.magic_block.sdk import MagicBackendAPI, errors
|
|
88
|
+
|
|
89
|
+
class Order:
|
|
90
|
+
@MagicBackendAPI() # 省略 permission = 任何登录用户都能调
|
|
91
|
+
def list(self, ctx, params):
|
|
92
|
+
from modules.orders.models import Order
|
|
93
|
+
return [{'id': o.id} for o in Order.objects.all()[:20]]
|
|
94
|
+
|
|
95
|
+
@MagicBackendAPI(permission='labull_framework.edit_blocks')
|
|
96
|
+
def close(self, ctx, params):
|
|
97
|
+
if not params.get('id'):
|
|
98
|
+
raise errors.BadRequest('要给我一个 id')
|
|
99
|
+
return {'ok': True}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
配置:环境变量里**带 `BLOCK_` 前缀**的才会进 `ctx.config`(`BLOCK_OCR_KEY=a` ⇒ `ctx.config['OCR_KEY']`)。
|
|
103
|
+
|
|
104
|
+
## 运行环境
|
|
105
|
+
|
|
106
|
+
- Python ≥ 3.10
|
|
107
|
+
- Django ≥ 4.2(在 6.1 上验证过)
|
|
108
|
+
- PyJWT(带 `crypto` 后端)
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Django admin 的注册处 —— 这个库**唯一**的管理界面。
|
|
2
|
+
|
|
3
|
+
🔴 它就是 Django 原生的那一套:用户 / 组 / 权限的日常维护白拿,我们不写任何自己的管理页面。
|
|
4
|
+
🔴 **块的代码在 admin 里只读**:它只能从 magic block 控制台写进去,因为那里有"保存即校验"那一关
|
|
5
|
+
(`ast` + 子进程试加载)。admin 里放开编辑表单,就等于从 Django 的默认表单往库里塞
|
|
6
|
+
**未经校验、之后会被 `exec` 执行**的代码,把"体检不过就停用"这套机制绕开。
|
|
7
|
+
要看、要筛、要重跑体检 —— admin 全都能做;要改代码 —— 去控制台。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from django.contrib import admin
|
|
13
|
+
|
|
14
|
+
from .magic_block import health
|
|
15
|
+
from .models import MagicBlock, User
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@admin.register(User)
|
|
19
|
+
class UserAdmin(admin.ModelAdmin):
|
|
20
|
+
"""用户:Django 自带的那份管身份,我们只把自加的字段摆进去。
|
|
21
|
+
|
|
22
|
+
⚠️ `username` 装的是 Authing 的 `sub`(登录时同步进来的),所以它**只读** ——
|
|
23
|
+
改它等于把这一行与那个身份切开。
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
list_display = ('username', 'work_id', 'name', 'is_staff', 'is_active', 'last_login')
|
|
27
|
+
list_filter = ('is_staff', 'is_superuser', 'is_active')
|
|
28
|
+
search_fields = ('username', 'work_id', 'name')
|
|
29
|
+
ordering = ('-date_joined',)
|
|
30
|
+
readonly_fields = ('username', 'last_login', 'date_joined')
|
|
31
|
+
fieldsets = (
|
|
32
|
+
(None, {'fields': ('username', 'password')}),
|
|
33
|
+
('身份', {'fields': ('work_id', 'name', 'email')}),
|
|
34
|
+
('权限', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
|
|
35
|
+
('时间', {'fields': ('last_login', 'date_joined')}),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@admin.register(MagicBlock)
|
|
40
|
+
class MagicBlockAdmin(admin.ModelAdmin):
|
|
41
|
+
"""功能块:看状态、筛、重跑体检 —— **改代码不在这里**(见文件头)。"""
|
|
42
|
+
|
|
43
|
+
list_display = ('id', 'module', 'name', 'enabled', 'health_checked_at', 'updated_at')
|
|
44
|
+
list_filter = ('module', 'enabled')
|
|
45
|
+
search_fields = ('id', 'name', 'brief')
|
|
46
|
+
ordering = ('module', 'name')
|
|
47
|
+
# 🔴 全字段只读:块的正文与体检结论都由控制台 / 体检模块写。
|
|
48
|
+
readonly_fields = tuple(field.name for field in MagicBlock._meta.fields)
|
|
49
|
+
actions = ('rerun_health',)
|
|
50
|
+
|
|
51
|
+
@admin.action(description='重新体检选中的块')
|
|
52
|
+
def rerun_health_action(self, request, queryset):
|
|
53
|
+
"""只体检选中的那几块(全量体检在每次 `migrate` 与进程启动时各跑一次)。"""
|
|
54
|
+
summary = health.check_all_blocks(list(queryset.values_list('id', flat=True)))
|
|
55
|
+
self.message_user(
|
|
56
|
+
request,
|
|
57
|
+
f'体检 {summary["checked"]} 块:通过 {summary["passed"]},停用 {summary["disabled"]}。',
|
|
58
|
+
)
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def has_add_permission(self, request) -> bool:
|
|
62
|
+
# 块的 uuid 由开发者在前端写死(`<MagicBlockHost uuid="…">`),admin 里"新增"没有意义。
|
|
63
|
+
return False
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
|
|
33
|
+
health.install_hook()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""OIDC 认证后端:把回调那一步**已经验过的** claims 变成一行 `User`。
|
|
2
|
+
|
|
3
|
+
🔴 **为什么还要一个认证后端**:OIDC 不是 Django 认识的密码后端(没有 `password` 可比),
|
|
4
|
+
但我们要的只是它的 `authenticate()` 这一步 —— **会话仍然完全交给 Django 的 `login()`**,
|
|
5
|
+
所以这个类**不碰会话**、也**不校验 token**(验签是 `sso.py` 的事,视图验完才把 claims 送进来)。
|
|
6
|
+
|
|
7
|
+
🔴 **权限一个字都不给**:Django 有 `is_staff` / `is_superuser` / `Group` / `Permission`,
|
|
8
|
+
登录这件事只负责"你是谁";"你能干什么"由管理员在 admin 里给。
|
|
9
|
+
所以新建的用户三个布尔位就是 `is_staff=False` / `is_superuser=False` / `is_active=True`。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from django.contrib.auth import get_user_model
|
|
18
|
+
from django.db import IntegrityError, transaction
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
#: 缺工号时那句固定报文。
|
|
23
|
+
#: 🔴 没有工号就**拒登**(不落库):工号是"这个人在组织里是谁",缺了它这条身份没有意义,
|
|
24
|
+
#: 而"先放进来再补"会让库里留下一批永远要人回头去认的账号。
|
|
25
|
+
NO_WORK_ID_TEXT = (
|
|
26
|
+
'你的账号在 Authing 里没有 `username`(工号),不放行 —— '
|
|
27
|
+
'请管理员在 Authing 里给这个账号补上工号后重试。'
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
#: 老用户每次登录只允许被刷新的两列。🔴 抄进 `update_fields` 的那一份必须是**白名单**:
|
|
31
|
+
#: 一旦顺手写成"全部字段",管理员在 admin 里设的 `is_staff` / 组 / 权限就会被一次登录冲掉。
|
|
32
|
+
UPDATABLE_FIELDS: tuple[str, ...] = ('name', 'work_id')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LoginRejected(Exception):
|
|
36
|
+
"""这次登录不放行,**一个字都不落库**,报文直接给用户看(视图回 401)。"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NoWorkId(LoginRejected):
|
|
40
|
+
"""缺工号(`username`)那一支。
|
|
41
|
+
|
|
42
|
+
⚠️ 单独一个类型只是为了让人读日志时一眼看出是哪一支;视图对两者的处理是同一件
|
|
43
|
+
(401 + 原样报文),所以它们共一个父类、只在视图里 catch 一次。
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _text(value: Any) -> str:
|
|
48
|
+
"""把一个 claim 当字符串用(不是字符串就算空)。
|
|
49
|
+
|
|
50
|
+
⚠️ 不用 `str()` 硬转:claim 完全由 IdP 决定,`str(None)` 会得到 `'None'` ——
|
|
51
|
+
那是一个**看起来像工号**的东西,它会进唯一索引,然后下一个人永远登不进来。
|
|
52
|
+
"""
|
|
53
|
+
return value if isinstance(value, str) else ''
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _work_id_of(source: Any) -> str:
|
|
57
|
+
"""取工号(**只认 `username`**)。
|
|
58
|
+
|
|
59
|
+
🔴 只认这一个键:Authing 为 workId 特设的就是它;OIDC 标准里的 `preferred_username`
|
|
60
|
+
常常是邮箱 / 昵称,收进来就等于让一个不是工号的东西占住唯一索引。
|
|
61
|
+
"""
|
|
62
|
+
return _text(source.get('username')).strip() if isinstance(source, dict) else ''
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _claim_keys_of(source: Any) -> list[str]:
|
|
66
|
+
"""一份 claim 里**真实出现**的键名(排序后)。
|
|
67
|
+
|
|
68
|
+
🔴 只列键名、永不列值:值是 PII(姓名 / 邮箱 / 工号),而这句话要回给浏览器、也会进日志。
|
|
69
|
+
键名恰好回答得了那个真问题 ——"工号该补在哪个键上"。
|
|
70
|
+
"""
|
|
71
|
+
return sorted(str(key) for key in source) if isinstance(source, dict) else []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class OidcBackend:
|
|
75
|
+
"""`AUTHENTICATION_BACKENDS` 里那一个;`claims` / `userinfo` 由回调视图传进来。"""
|
|
76
|
+
|
|
77
|
+
def authenticate(
|
|
78
|
+
self, request: Any, claims: dict | None = None, userinfo: dict | None = None, **kwargs: Any
|
|
79
|
+
):
|
|
80
|
+
"""按 claims 造 / 更新用户行,回 `User` 或 `None`(拒登时抛 `LoginRejected`)。
|
|
81
|
+
|
|
82
|
+
⚠️ 视图已经验过签名,这里只看 claim 的值 —— 再验一次会变成"两处口径"。
|
|
83
|
+
🔴 不认识的凭据(`username` / `password` 那类)一律回 `None`:`authenticate()` 是
|
|
84
|
+
Django 的框架出口,凭据不对就抛异常的后端会让 `client.login()` 这类正常调用直接 500。
|
|
85
|
+
"""
|
|
86
|
+
if claims is None and userinfo is None:
|
|
87
|
+
# 没有我们认的那两样东西 ⇒ 这不是能靠 OIDC 验的身份(交给别的后端或直接失败)。
|
|
88
|
+
return None
|
|
89
|
+
return self._upsert(claims, userinfo)
|
|
90
|
+
|
|
91
|
+
def get_user(self, user_id: Any):
|
|
92
|
+
"""按主键取人 —— `AuthenticationMiddleware` 每个请求都靠它把会话里的 id 换回 `User`。
|
|
93
|
+
|
|
94
|
+
🔴 少了这个方法,登录那一刻是好的,**下一个请求就 500**(框架在恢复身份时会找它)。
|
|
95
|
+
⚠️ 不查 `is_active`:账号停用该由 Django 的 `user_can_authenticate` 判(它就在
|
|
96
|
+
`login()` / `get_user()` 那条路上),在这里再判一次就是同一条规矩两处口径。
|
|
97
|
+
"""
|
|
98
|
+
return get_user_model().objects.filter(pk=user_id).first()
|
|
99
|
+
|
|
100
|
+
def _upsert(self, claims: dict | None, userinfo: dict | None):
|
|
101
|
+
"""真正的映射(见模块头):`sub` → `username`、`username` → `work_id`、`name` → `name`。"""
|
|
102
|
+
data = claims if isinstance(claims, dict) else {}
|
|
103
|
+
profile = userinfo if isinstance(userinfo, dict) else {}
|
|
104
|
+
sub = _text(data.get('sub')).strip()
|
|
105
|
+
if not sub:
|
|
106
|
+
# 🔴 连"是谁"都定不下来 ⇒ 一个字都不落库(`sub` 是唯一凭据)。
|
|
107
|
+
raise LoginRejected('id_token 里没有 sub,无法定位用户。')
|
|
108
|
+
|
|
109
|
+
# 🔴 工号先看 id_token、再看用户信息接口:Authing 默认不把用户资料放进 id_token,
|
|
110
|
+
# 所以"id_token 里没有 username"**不等于**"这个人没有工号"。
|
|
111
|
+
work_id = _work_id_of(data) or _work_id_of(profile)
|
|
112
|
+
if not work_id:
|
|
113
|
+
raise NoWorkId(
|
|
114
|
+
f'{NO_WORK_ID_TEXT}这次 id_token 的键名有 {_claim_keys_of(data)}、'
|
|
115
|
+
f'用户信息接口的键名有 {_claim_keys_of(profile)},两处都没有 username。'
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
user_model = get_user_model()
|
|
119
|
+
current = user_model.objects.filter(username=sub).first()
|
|
120
|
+
name = _text(data.get('name')) or sub
|
|
121
|
+
if current is not None:
|
|
122
|
+
# 🔴 老用户只刷新这两列:`is_staff` / `is_superuser` / 组与权限归管理员,
|
|
123
|
+
# 密码根本没有(见 `set_unusable_password`)。
|
|
124
|
+
current.name = name
|
|
125
|
+
current.work_id = work_id
|
|
126
|
+
current.save(update_fields=list(UPDATABLE_FIELDS))
|
|
127
|
+
return current
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
# ⚠️ `atomic()` 是必须的:唯一约束撞了之后 `IntegrityError` 会让**外层事务**
|
|
131
|
+
# 也进入"已中止"状态,后面那条重查会抛 `TransactionManagementError`。
|
|
132
|
+
with transaction.atomic():
|
|
133
|
+
user = user_model(username=sub, name=name, work_id=work_id)
|
|
134
|
+
user.set_unusable_password()
|
|
135
|
+
user.save()
|
|
136
|
+
return user
|
|
137
|
+
except IntegrityError:
|
|
138
|
+
# 并发首登:另一个请求刚插进去。重查拿**赢家写下的那一行** ——
|
|
139
|
+
# `get_or_create` 在这里回的是它自己那个没落库的对象,拿它写会话就会指向不存在的行。
|
|
140
|
+
winner = user_model.objects.filter(username=sub).first()
|
|
141
|
+
if winner is None:
|
|
142
|
+
logger.error('首次落库撞了唯一约束,按 username=%s 却查不到那一行(多半是工号重复)。', sub)
|
|
143
|
+
return None
|
|
144
|
+
return winner
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""启动时按 `BOOTSTRAP_ADMIN_WORK_IDS` 给点名的人 `is_staff=True` —— **这是"第一个管理员从哪来"的唯一入口**。
|
|
2
|
+
|
|
3
|
+
🔴 为什么需要它:权限是 Django 的 `is_staff` / `Group`,而库里一行用户都没有时,没有任何人能进
|
|
4
|
+
admin 去勾那个框(我们**没有自己写的管理界面**,admin 只有 `is_staff` 的人能进)——
|
|
5
|
+
所以第一个管理员只能从配置里点名。
|
|
6
|
+
🔴 **只增不减**:把某人从清单里删掉、重启,他的 `is_staff` 留着。摘权限必须是人明说的动作
|
|
7
|
+
(去 admin 里取消勾选),不能由"改一次 `.env` + 重启"完成。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
from ..env import read_env
|
|
15
|
+
from ..models import User
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
MAX_WORK_IDS = 200
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def sync_staff_work_ids() -> int:
|
|
23
|
+
"""给清单里命中的用户补 `is_staff`,回这次改了几个人。
|
|
24
|
+
|
|
25
|
+
🔴 **永不抛**:这是启动路径,库连不上 / 表还没迁移都只该在日志里留一句 ——
|
|
26
|
+
补管理员失败没有理由让整个后端起不来。
|
|
27
|
+
"""
|
|
28
|
+
raw = read_env('BOOTSTRAP_ADMIN_WORK_IDS')
|
|
29
|
+
work_ids = [item.strip() for item in raw.split(',') if item.strip()]
|
|
30
|
+
if not work_ids:
|
|
31
|
+
return 0 # 没配就什么都不做(清单本来就是可选的)。
|
|
32
|
+
if len(work_ids) > MAX_WORK_IDS:
|
|
33
|
+
logger.warning(
|
|
34
|
+
'BOOTSTRAP_ADMIN_WORK_IDS 有 %s 个工号,本次只处理前 %s 个(上限 %s):'
|
|
35
|
+
'请拆成多批,或确认这一行是不是误粘了。',
|
|
36
|
+
len(work_ids), MAX_WORK_IDS, MAX_WORK_IDS,
|
|
37
|
+
)
|
|
38
|
+
work_ids = work_ids[:MAX_WORK_IDS]
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
# 🔴 用 `update()` 而不是逐个 `save()`:它不会碰 `date_joined` / `last_login` 这类
|
|
42
|
+
# 自动字段("改动角色"不该让"这个人什么时候注册的"跟着变),也一条 SQL 就完事。
|
|
43
|
+
# ⚠️ 不带 `is_staff=False` 那一半:只增不减,见模块头。
|
|
44
|
+
return User.objects.filter(work_id__in=work_ids).update(is_staff=True)
|
|
45
|
+
except Exception as error: # noqa: BLE001 —— 见 docstring:启动路径永不抛
|
|
46
|
+
logger.error('按 BOOTSTRAP_ADMIN_WORK_IDS 补 is_staff 失败(不影响启动):%s', error)
|
|
47
|
+
return 0
|