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.
- labull_framework/__init__.py +8 -0
- labull_framework/admin.py +63 -0
- labull_framework/apps.py +33 -0
- labull_framework/auth/__init__.py +4 -0
- labull_framework/auth/backend.py +144 -0
- labull_framework/auth/bootstrap.py +47 -0
- labull_framework/auth/sso.py +259 -0
- labull_framework/auth/views.py +184 -0
- labull_framework/canvas/__init__.py +19 -0
- labull_framework/canvas/data.py +523 -0
- labull_framework/canvas/gitinfo.py +155 -0
- labull_framework/canvas/static/canvas.css +487 -0
- labull_framework/canvas/static/canvas.js +1036 -0
- labull_framework/canvas/static/page.html +52 -0
- labull_framework/canvas/views.py +121 -0
- labull_framework/env.py +187 -0
- labull_framework/jsonio.py +57 -0
- labull_framework/magic_block/__init__.py +2 -0
- labull_framework/magic_block/devkit.py +428 -0
- labull_framework/magic_block/dispatch.py +314 -0
- labull_framework/magic_block/export.py +773 -0
- labull_framework/magic_block/health.py +220 -0
- labull_framework/magic_block/registry.py +157 -0
- labull_framework/magic_block/sdk.py +245 -0
- labull_framework/magic_block/service.py +405 -0
- labull_framework/magic_block/validate.py +558 -0
- labull_framework/magic_block/views.py +407 -0
- labull_framework/management/__init__.py +0 -0
- labull_framework/management/commands/__init__.py +0 -0
- labull_framework/management/commands/labull_contract.py +52 -0
- labull_framework/migrations/0001_initial.py +71 -0
- labull_framework/migrations/__init__.py +0 -0
- labull_framework/models.py +76 -0
- labull_framework/settings.py +223 -0
- labull_framework/urls.py +93 -0
- labull_framework-0.1.0.dist-info/METADATA +140 -0
- labull_framework-0.1.0.dist-info/RECORD +40 -0
- labull_framework-0.1.0.dist-info/WHEEL +5 -0
- labull_framework-0.1.0.dist-info/licenses/LICENSE +28 -0
- labull_framework-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>数据模型</title>
|
|
7
|
+
<!-- ⚠️ `?v=` 是**给浏览器的缓存换钥匙**:这两个静态件在"这一页是公开的"之后尤其容易被缓存住,
|
|
8
|
+
改了正文而人不刷新(或刷新了也还是旧的),症状是"代码明明改了、页面却没变"(真踩过)。
|
|
9
|
+
改这两个文件时把版本号往上加一位。 -->
|
|
10
|
+
<link rel="stylesheet" href="canvas/asset/canvas.css?v=24">
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<header class="bar">
|
|
14
|
+
<span class="brand">数据模型</span>
|
|
15
|
+
<span id="gitline" class="git"></span>
|
|
16
|
+
<span class="grow"></span>
|
|
17
|
+
<span id="stat" class="stat"></span>
|
|
18
|
+
</header>
|
|
19
|
+
|
|
20
|
+
<div class="toolbar">
|
|
21
|
+
<select id="module"></select>
|
|
22
|
+
<span class="legend"><i class="sw added"></i>新增</span>
|
|
23
|
+
<span class="legend"><i class="sw modified"></i>修改</span>
|
|
24
|
+
<span class="legend"><i class="sw deleted"></i>删除</span>
|
|
25
|
+
<!-- 列序开关:字段名优先 ⇄ 备注优先。文案由 canvas.js 写(状态只有一处真源)。 -->
|
|
26
|
+
<button id="order-toggle" type="button" title="切换字段行的列序:字段名优先 ⇄ 备注优先">列序:字段名优先</button>
|
|
27
|
+
<span class="grow"></span>
|
|
28
|
+
<!-- 缩放三件 + 适应视图。⚠️ `zoom` 那个百分数是 canvas.js 写的(它是缩放的唯一真源)。 -->
|
|
29
|
+
<span class="zoom-group">
|
|
30
|
+
<button id="zoom-out" type="button" title="缩小(Ctrl + 滚轮也行)">−</button>
|
|
31
|
+
<span id="zoom" class="zoom-value" title="当前缩放">100%</span>
|
|
32
|
+
<button id="zoom-in" type="button" title="放大(Ctrl + 滚轮也行)">+</button>
|
|
33
|
+
<button id="zoom-reset" type="button" title="回到 100%">1:1</button>
|
|
34
|
+
<button id="zoom-fit" type="button" title="把图缩放到视野内">适应视图</button>
|
|
35
|
+
</span>
|
|
36
|
+
<button id="fit" type="button" title="清掉手工拖的位置,回到自动排列">自动排列</button>
|
|
37
|
+
<button id="reload" type="button" title="重新取一次数据">刷新</button>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<p id="alert" class="alert" hidden></p>
|
|
41
|
+
|
|
42
|
+
<main id="viewport">
|
|
43
|
+
<div id="canvas"></div>
|
|
44
|
+
</main>
|
|
45
|
+
|
|
46
|
+
<p class="hint">
|
|
47
|
+
拖动表可排版(拖完自动存);空白处按住拖 = 平移,Ctrl + 滚轮 = 缩放;
|
|
48
|
+
点表标题展开备注与关联,点字段行展开枚举取值 / 文件去向 / 关联目标,点线上的关联键名看它的备注。
|
|
49
|
+
</p>
|
|
50
|
+
<script src="canvas/asset/canvas.js?v=24"></script>
|
|
51
|
+
</body>
|
|
52
|
+
</html>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""画布那四个视图:那一页、它要的数据、拖拽位置、以及页面自己的 js / css。
|
|
2
|
+
|
|
3
|
+
🔴 **这一页公开**(`/`、`/canvas`、`/canvas/layout`、`/canvas/asset/*` 都不校验身份):
|
|
4
|
+
它是**开发期看数据库结构的工具**,打开就能看,不必先登录 —— 调模型的时候还要先去 Authing
|
|
5
|
+
转一圈实在碍事。所以**唯一的开关是那个环境变量**(`LABULL_SCHEMA_CANVAS`,默认关):
|
|
6
|
+
|
|
7
|
+
LABULL_SCHEMA_CANVAS=off 这一页根本不存在(`/` 404)—— **生产环境的默认值**
|
|
8
|
+
LABULL_SCHEMA_CANVAS=auto 只在 DEBUG 时挂上
|
|
9
|
+
LABULL_SCHEMA_CANVAS=on 总是挂上
|
|
10
|
+
|
|
11
|
+
⚠️ 打开它就等于把**全部表结构**摊在一个不需要登录的 URL 上。要么别在公网开,
|
|
12
|
+
要么在 nginx / 反向代理那一层给它加 Basic 认证或 IP 白名单 —— 那种"谁知道这个地址"的
|
|
13
|
+
隐蔽性不算保护。
|
|
14
|
+
|
|
15
|
+
🔴 **静态件不注册 `django.contrib.staticfiles`**:由本模块一条视图读**包内文件**。
|
|
16
|
+
理由:为一个页面去加一个 app + 一套 `STATIC_URL` 约定,是把"装法"变复杂;
|
|
17
|
+
而"只读白名单里的那几个名字"本身就是一道安全边界。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from django.conf import settings
|
|
26
|
+
from django.http import HttpRequest, HttpResponse
|
|
27
|
+
from django.middleware.csrf import get_token
|
|
28
|
+
from django.views.decorators.http import require_http_methods
|
|
29
|
+
|
|
30
|
+
from ..jsonio import json_response, problem
|
|
31
|
+
from . import data as payload
|
|
32
|
+
|
|
33
|
+
_ASSETS_DIR = Path(__file__).resolve().parent / 'static'
|
|
34
|
+
|
|
35
|
+
#: 页面允许取的**那几个**文件(白名单,不是黑名单):名字对不上就 404,不拼路径。
|
|
36
|
+
_ASSETS = {
|
|
37
|
+
'canvas.js': 'text/javascript; charset=utf-8',
|
|
38
|
+
'canvas.css': 'text/css; charset=utf-8',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#: 那一页的 HTML。
|
|
42
|
+
_PAGE = _ASSETS_DIR / 'page.html'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@require_http_methods(['GET'])
|
|
46
|
+
def page(request: HttpRequest) -> HttpResponse:
|
|
47
|
+
"""根路径那一页(**公开**,见模块头那段说明)。
|
|
48
|
+
|
|
49
|
+
🔴 顺手 `get_token()`:这一页要能**存拖动位置**(`POST /canvas/layout`,走 Django 的双提交
|
|
50
|
+
CSRF)。而"第一枚 CSRF cookie 从哪来"必须有确定答案 —— 这里种下它。
|
|
51
|
+
⚠️ 名字不能写死:cookie 名与头名都是跨语言契约(见 `settings.py` 里那两个常量),
|
|
52
|
+
所以页面自己不去猜,由 `/canvas/csrf` 告诉它。
|
|
53
|
+
"""
|
|
54
|
+
get_token(request)
|
|
55
|
+
if not _PAGE.is_file():
|
|
56
|
+
return problem('页面文件缺失(包内 static/page.html 不在)。', 500)
|
|
57
|
+
return HttpResponse(_PAGE.read_text(encoding='utf-8'), content_type='text/html; charset=utf-8')
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@require_http_methods(['GET'])
|
|
61
|
+
def csrf(request: HttpRequest) -> HttpResponse:
|
|
62
|
+
"""告诉页面:CSRF 的 cookie 叫什么、请求头叫什么、这枚值是多少。
|
|
63
|
+
|
|
64
|
+
🔴 为什么不写死在 `canvas.js` 里:那两个名字是**跨语言契约**(`settings.py` 的
|
|
65
|
+
`CSRF_COOKIE_NAME` / `CSRF_HEADER_NAME`),写死的话项目一改名,
|
|
66
|
+
症状是"拖动位置存不上,而页面不报任何错"(真踩过:一直 403)。
|
|
67
|
+
🔴 `CSRF_HEADER_NAME` 是 Django 的**内部形式**(`HTTP_X_LABULL_CSRF`),
|
|
68
|
+
而浏览器 `fetch` 要的是**头名本身**(`X-Labull-Csrf`)—— 这里转好再给,
|
|
69
|
+
别让每个前端各写一遍这段转换(写错的表现同样是"静默 403")。
|
|
70
|
+
"""
|
|
71
|
+
token = get_token(request)
|
|
72
|
+
return json_response(
|
|
73
|
+
{
|
|
74
|
+
'cookie': settings.CSRF_COOKIE_NAME,
|
|
75
|
+
'header': _header_name(settings.CSRF_HEADER_NAME),
|
|
76
|
+
'token': token,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _header_name(raw: str) -> str:
|
|
82
|
+
"""`HTTP_X_LABULL_CSRF` ⇒ `X-Labull-Csrf`(Django 内部形式 → HTTP 头名)。"""
|
|
83
|
+
name = str(raw or '')
|
|
84
|
+
if name.upper().startswith('HTTP_'):
|
|
85
|
+
name = name[5:]
|
|
86
|
+
return '-'.join(part.capitalize() for part in name.split('_') if part)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@require_http_methods(['GET'])
|
|
90
|
+
def data(request: HttpRequest) -> HttpResponse:
|
|
91
|
+
"""画布整包数据(**公开**)。"""
|
|
92
|
+
return json_response(payload.payload())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@require_http_methods(['GET', 'POST'])
|
|
96
|
+
def layout(request: HttpRequest) -> HttpResponse:
|
|
97
|
+
"""读 / 存拖拽位置(存不下也不报错 —— 退回自动布局是正常状态)。**公开**。"""
|
|
98
|
+
if request.method == 'GET':
|
|
99
|
+
return json_response(payload.read_layout())
|
|
100
|
+
try:
|
|
101
|
+
body = json.loads(request.body or b'{}')
|
|
102
|
+
except ValueError:
|
|
103
|
+
return problem('body 不是合法 JSON。', 400)
|
|
104
|
+
return json_response(payload.write_layout(body))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@require_http_methods(['GET'])
|
|
108
|
+
def asset(request: HttpRequest, name: str) -> HttpResponse:
|
|
109
|
+
"""页面自己的 js / css(只认白名单里的名字)。**公开**。
|
|
110
|
+
|
|
111
|
+
🔴 回话里带 `Cache-Control: no-cache`:这一页是**开发期工具**,它的 js / css 会被人反复改。
|
|
112
|
+
让浏览器把旧正文留着,症状是"代码明明改了、页面却没变"(真踩过:一个早已修掉的统计数字
|
|
113
|
+
在页面上留了好几轮)。`no-cache` 不是"不缓存",是"每次先来问一句"——够用且不影响离线。
|
|
114
|
+
"""
|
|
115
|
+
content_type = _ASSETS.get(name)
|
|
116
|
+
path = _ASSETS_DIR / name
|
|
117
|
+
if content_type is None or not path.is_file():
|
|
118
|
+
return problem(f'没有这个静态件:{name}', 404)
|
|
119
|
+
response = HttpResponse(path.read_text(encoding='utf-8'), content_type=content_type)
|
|
120
|
+
response['Cache-Control'] = 'no-cache'
|
|
121
|
+
return response
|
labull_framework/env.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""环境变量的唯一入口:读值、加载 `.env`、给块的配置白名单,以及**有哪几个变量**这份清单。
|
|
2
|
+
|
|
3
|
+
🔴 `.env` 解析器只有这一处(Django 自己不读 `.env`,标准库也没有这回事)。
|
|
4
|
+
两处各解析一遍的后果是"平台读得到、你的代码读不到" —— 最难查的一类不一致。
|
|
5
|
+
🔴 `ENV_ITEMS` 是"平台读哪些环境变量"的**唯一真源**:开发包里的 `backend.lock.pyi`
|
|
6
|
+
照它铺一份给块作者看,登录那一组的必填清单与 `.env` 例子也从它取(见 `auth/sso.py`)。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Mapping
|
|
16
|
+
|
|
17
|
+
#: 给块的配置前缀:`BLOCK_OCR_KEY=abc` ⇒ 块后端里 `ctx.config['OCR_KEY'] == 'abc'`。
|
|
18
|
+
#: 🔴 这是**白名单**(不是黑名单):想让块读一项,就给那个变量加这个前缀,不用改库的代码。
|
|
19
|
+
BLOCK_CONFIG_PREFIX = 'BLOCK_'
|
|
20
|
+
|
|
21
|
+
#: `.env` 里一行的形状。允许 `export` 前缀、行首缩进、`=` 两边空白;
|
|
22
|
+
#: 🔴 **不剥行内注释**(值里真带 `#` 的密钥不该被截断)、**不做变量展开**(那会让"谁先谁后"变成隐式规则)。
|
|
23
|
+
_ENV_LINE = re.compile(r'^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_env_file(path: str | Path) -> None:
|
|
27
|
+
"""把一份 `.env` 并进 `os.environ`(在 `settings.py` 的**第一行**调一次)。
|
|
28
|
+
|
|
29
|
+
🔴 必须排在读 `os.environ` 之前:顺序反了,`.env` 里给的值读不到,
|
|
30
|
+
而报出来的症状是"我配了,它还是不生效"。
|
|
31
|
+
🔴 `.env` **覆盖**进程环境:它是项目的配置真源,而进程里可能留着同名的外部变量。
|
|
32
|
+
🔴 用 `utf-8-sig` 读:编辑器存"UTF-8 带 BOM"时那个 BOM 会粘在第一个键名前面,
|
|
33
|
+
而 `^\\s*` 不匹配它 ⇒ 第一行**静默消失**(症状是"我明明配了它")。
|
|
34
|
+
⚠️ 文件不在 = 什么都不做(不抛、不报):缺配置该由**用到它的那一刻**报出来。
|
|
35
|
+
"""
|
|
36
|
+
source = Path(path)
|
|
37
|
+
if not source.is_file():
|
|
38
|
+
return
|
|
39
|
+
values: dict[str, str] = {}
|
|
40
|
+
for line in source.read_text(encoding='utf-8-sig').splitlines():
|
|
41
|
+
match = _ENV_LINE.match(line)
|
|
42
|
+
if not match:
|
|
43
|
+
continue # 空行与 `#` 注释都走这一条
|
|
44
|
+
raw = match.group(2).strip()
|
|
45
|
+
quoted = len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in ('"', "'")
|
|
46
|
+
values[match.group(1)] = raw[1:-1] if quoted else raw
|
|
47
|
+
os.environ.update(values)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def read_env(name: str) -> str:
|
|
51
|
+
"""读一个环境变量(空串当没写 —— `.env` 里 `KEY=` 是常态),去掉首尾空白。"""
|
|
52
|
+
return str(os.environ.get(name, '') or '').strip()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def block_config(env: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
56
|
+
"""给块的配置:带 `BLOCK_` 前缀的那几个,**剥掉前缀当键**。
|
|
57
|
+
|
|
58
|
+
🔴 没配的键在 `ctx.config` 上**不存在**(别拿 `None` 当默认值使,先判 `'X' in ctx.config`);
|
|
59
|
+
配成空串的**照给**(`BLOCK_X=` ⇒ `ctx.config['X'] == ''`)。
|
|
60
|
+
🔴 剥完是空串的**不算键**(`BLOCK_=` 不可能是名字)。
|
|
61
|
+
🔴 别的变量一个都不给块 —— 数据库连接串、SSO 密钥都不在这里。
|
|
62
|
+
"""
|
|
63
|
+
source = os.environ if env is None else env
|
|
64
|
+
result: dict[str, str] = {}
|
|
65
|
+
for name, value in source.items():
|
|
66
|
+
if not name.startswith(BLOCK_CONFIG_PREFIX):
|
|
67
|
+
continue
|
|
68
|
+
key = name[len(BLOCK_CONFIG_PREFIX):]
|
|
69
|
+
if key:
|
|
70
|
+
result[key] = value
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ──────────────────────────── "有哪几个环境变量"这份清单 ────────────────────────────
|
|
75
|
+
|
|
76
|
+
#: 登录那一组的组名。🔴 `auth/sso.py` 按它取必填清单(**不抄第二份名单**)。
|
|
77
|
+
LOGIN_GROUP = '登录(Authing OIDC)'
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class EnvItem:
|
|
82
|
+
"""一个环境变量在**开发包**里被看到的样子。
|
|
83
|
+
|
|
84
|
+
🔴 `hint` 是"能直接粘进 `.env` 的一行":缺必填时那句报错就照它说(见 `auth/sso.py`)——
|
|
85
|
+
只说"缺了"的报错,用户还得自己去翻文档才知道那一行该怎么写。
|
|
86
|
+
🔴 **这里永远不写值**:这份清单会铺进开发包(跟着块作者的编辑器走),而环境里有
|
|
87
|
+
数据库连接串与登录密钥。块的配置面是白名单,同一个道理。
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
#: 变量名。⚠️ 前缀那一条写的是 `BLOCK_*` —— 它不是一个真名字,是一族名字的前缀。
|
|
91
|
+
name: str
|
|
92
|
+
#: 这个变量管什么(一句人话)。
|
|
93
|
+
brief: str
|
|
94
|
+
#: 能直接粘进 `.env` 的一行(没有例子就是空串)。
|
|
95
|
+
hint: str = ''
|
|
96
|
+
#: 必填吗(缺了就跑不起来的那几项)。
|
|
97
|
+
required: bool = False
|
|
98
|
+
#: 分组(开发包里按它分段;"哪几项是登录用的"也从这里取)。
|
|
99
|
+
group: str = '平台'
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
#: 平台读的**全部**环境变量 —— 唯一真源。
|
|
103
|
+
#:
|
|
104
|
+
#: 🔴 它是一份**清单**,不是"读值的入口":各处仍然各读各的(`read_env('DATABASE_URL')` 之类)。
|
|
105
|
+
#: 两者对不上的风险由 `tools/sdk-check-py.mjs` 兜住:它扫一遍库里所有 `read_env('<名字>')`
|
|
106
|
+
#: 字面量,漏在这份清单外的当场报红。
|
|
107
|
+
ENV_ITEMS: tuple[EnvItem, ...] = (
|
|
108
|
+
# ── 登录(Authing OIDC):这四项不配就没人能登录 ──
|
|
109
|
+
EnvItem(
|
|
110
|
+
name='SSO_ISSUER',
|
|
111
|
+
brief='Authing 的 OIDC 发证地址(OIDC discovery 就在它下面)',
|
|
112
|
+
hint='SSO_ISSUER=https://<你的域名>.authing.cn/oidc',
|
|
113
|
+
required=True,
|
|
114
|
+
group=LOGIN_GROUP,
|
|
115
|
+
),
|
|
116
|
+
EnvItem(
|
|
117
|
+
name='SSO_CLIENT_ID',
|
|
118
|
+
brief='Authing 应用的 App ID',
|
|
119
|
+
hint='SSO_CLIENT_ID=<Authing 应用的 App ID>',
|
|
120
|
+
required=True,
|
|
121
|
+
group=LOGIN_GROUP,
|
|
122
|
+
),
|
|
123
|
+
EnvItem(
|
|
124
|
+
name='SSO_CLIENT_SECRET',
|
|
125
|
+
brief='Authing 应用的 App Secret(**只在服务端**用,别带进前端)',
|
|
126
|
+
hint='SSO_CLIENT_SECRET=<Authing 应用的 App Secret>',
|
|
127
|
+
required=True,
|
|
128
|
+
group=LOGIN_GROUP,
|
|
129
|
+
),
|
|
130
|
+
EnvItem(
|
|
131
|
+
name='SSO_REDIRECT_URI',
|
|
132
|
+
brief='登录回调地址(必须与 Authing 应用里登记的那个逐字一致)',
|
|
133
|
+
hint='SSO_REDIRECT_URI=http://localhost:8000/auth/callback',
|
|
134
|
+
required=True,
|
|
135
|
+
group=LOGIN_GROUP,
|
|
136
|
+
),
|
|
137
|
+
EnvItem(
|
|
138
|
+
name='SSO_WEB_ORIGIN',
|
|
139
|
+
brief='登录成功后把人送回哪个前端地址(不写就用 auth/sso.py 里的默认值)',
|
|
140
|
+
hint='SSO_WEB_ORIGIN=http://localhost:5173',
|
|
141
|
+
group=LOGIN_GROUP,
|
|
142
|
+
),
|
|
143
|
+
# ── 跨源与数据库 ──
|
|
144
|
+
EnvItem(
|
|
145
|
+
name='CORS_ORIGIN',
|
|
146
|
+
brief='允许跨源访问的前端地址(逗号分隔);它同时是 CSRF 信任名单的来源',
|
|
147
|
+
hint='CORS_ORIGIN=http://localhost:5173',
|
|
148
|
+
group='跨源与数据库',
|
|
149
|
+
),
|
|
150
|
+
EnvItem(
|
|
151
|
+
name='DATABASE_URL',
|
|
152
|
+
brief='项目数据库连接串(postgres://… 形式);不写就用项目自己 settings 里的 DATABASES',
|
|
153
|
+
hint='DATABASE_URL=postgres://user:password@127.0.0.1:5432/app',
|
|
154
|
+
group='跨源与数据库',
|
|
155
|
+
),
|
|
156
|
+
# ── 平台开关 ──
|
|
157
|
+
EnvItem(
|
|
158
|
+
name='BOOTSTRAP_ADMIN_WORK_IDS',
|
|
159
|
+
brief='启动时按工号把这些人设成管理员(逗号分隔,上限 200;只增不减)',
|
|
160
|
+
hint='BOOTSTRAP_ADMIN_WORK_IDS=1001',
|
|
161
|
+
group='平台开关',
|
|
162
|
+
),
|
|
163
|
+
EnvItem(
|
|
164
|
+
name='LABULL_SCHEMA_CANVAS',
|
|
165
|
+
brief='数据模型那一页的开关:on 常开 / auto 只在 DEBUG 开 / 其余(默认)不开',
|
|
166
|
+
hint='LABULL_SCHEMA_CANVAS=on',
|
|
167
|
+
group='平台开关',
|
|
168
|
+
),
|
|
169
|
+
EnvItem(
|
|
170
|
+
name='LABULL_CONTRACT_PATH',
|
|
171
|
+
brief='模型形状那份 JSON 的落点(画布拿它比"上一版";默认 .labull/contract.json)',
|
|
172
|
+
hint='LABULL_CONTRACT_PATH=.labull/contract.json',
|
|
173
|
+
group='平台开关',
|
|
174
|
+
),
|
|
175
|
+
# ── 块的配置面 ──
|
|
176
|
+
EnvItem(
|
|
177
|
+
name=f'{BLOCK_CONFIG_PREFIX}*',
|
|
178
|
+
brief='块的配置面:**只有**带这个前缀的变量才进 ctx.config(前缀剥掉当键,别的变量一个都不给块)',
|
|
179
|
+
hint=f'{BLOCK_CONFIG_PREFIX}OCR_KEY=xxx',
|
|
180
|
+
group='块的配置',
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def required_items(group: str) -> tuple[EnvItem, ...]:
|
|
186
|
+
"""某一组里必填的那几项(顺序 = `ENV_ITEMS` 里的顺序,所以报错顺序稳定)。"""
|
|
187
|
+
return tuple(item for item in ENV_ITEMS if item.group == group and item.required)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""JSON 线格式的唯一出口。
|
|
2
|
+
|
|
3
|
+
🔴 三件事都只有一处实现:回一份 JSON、回一句人话的报错、"没登录"那句报文。
|
|
4
|
+
两处各写一份的后果是前端要按两种形状判错 —— 那种漂最难查。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from django.http import JsonResponse
|
|
13
|
+
|
|
14
|
+
#: "没登录"那句报文。**唯一一份**(尾部的句号是报文的一部分)。
|
|
15
|
+
NO_IDENTITY_MESSAGE = '还没有登录,或者会话已经失效了。'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def json_response(data: Any, status: int = 200) -> JsonResponse:
|
|
19
|
+
"""回一份 JSON。
|
|
20
|
+
|
|
21
|
+
🔴 `ensure_ascii=False`:否则给管理员看的中文操作指引全变 `\\uXXXX`,等于报文不存在。
|
|
22
|
+
🔴 **`charset=utf-8` 要显式写**:Django 默认只回 `application/json`,而**没有 charset 的 JSON
|
|
23
|
+
按标准默认是 UTF-8、按某些客户端的实现不是** —— 那会让中文在浏览器里变乱码,
|
|
24
|
+
而症状看着像"后端没转义对"。带 BOM 的请求体(Windows 编辑器常见)也靠它才解得对。
|
|
25
|
+
⚠️ `data` 只能是可序列化的**数据**(dict / list / 标量):块返回值那种"已经编好的 JSON 文本"
|
|
26
|
+
由 `dispatch` 自己回 `HttpResponse`,不走这里 —— 走这里就会被再编一遍(变成带引号的字符串)。
|
|
27
|
+
"""
|
|
28
|
+
return JsonResponse(
|
|
29
|
+
data,
|
|
30
|
+
safe=False,
|
|
31
|
+
status=status,
|
|
32
|
+
content_type='application/json; charset=utf-8',
|
|
33
|
+
json_dumps_params={'ensure_ascii': False},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def problem(message: str, status: int) -> JsonResponse:
|
|
38
|
+
"""回一句人话的报错(形状与前端约定的 `{statusCode, message}` 一致)。"""
|
|
39
|
+
return json_response({'statusCode': status, 'message': message}, status=status)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def no_identity() -> JsonResponse:
|
|
43
|
+
"""回 401 的唯一出口。"""
|
|
44
|
+
return problem(NO_IDENTITY_MESSAGE, 401)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def iso_utc(value: datetime | None) -> str | None:
|
|
48
|
+
"""时间序列化**只有这一处**:UTC、毫秒精度、`Z` 结尾。
|
|
49
|
+
|
|
50
|
+
🔴 两份形状(一处毫秒、一处 `isoformat()`)迟早让前端做两套解析 —— 所以只留这一份。
|
|
51
|
+
"""
|
|
52
|
+
if value is None:
|
|
53
|
+
return None
|
|
54
|
+
if value.tzinfo is None:
|
|
55
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
56
|
+
value = value.astimezone(timezone.utc)
|
|
57
|
+
return value.strftime('%Y-%m-%dT%H:%M:%S.') + f'{value.microsecond // 1000:03d}Z'
|