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,523 @@
|
|
|
1
|
+
"""画布要的数据:从模型元数据算出来,再与 git 里那份比出加 / 改 / 删。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from django.apps import apps
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
|
|
12
|
+
from .. import env
|
|
13
|
+
|
|
14
|
+
#: 拖拽位置的落点(相对项目根)。🚫 不落盘也能用(没有这个目录就不持久化,不报错)。
|
|
15
|
+
LAYOUT_FILE = '.labull/schema-layout.json'
|
|
16
|
+
|
|
17
|
+
#: 不画这几张:它们是**Django 自己的**(会话 / 权限 / 内容类型 / 后台日志),
|
|
18
|
+
#: 与"这个项目的数据模型"无关,摆在图上只会淹掉真正的业务表。
|
|
19
|
+
SKIP_TABLES = frozenset(
|
|
20
|
+
{
|
|
21
|
+
'auth_group',
|
|
22
|
+
'auth_group_permissions',
|
|
23
|
+
'auth_permission',
|
|
24
|
+
'auth_user_groups',
|
|
25
|
+
'auth_user_user_permissions',
|
|
26
|
+
'django_admin_log',
|
|
27
|
+
'django_content_type',
|
|
28
|
+
'django_migrations',
|
|
29
|
+
'django_session',
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def enabled() -> bool:
|
|
35
|
+
"""那一页开不开:`on` 常开;`auto` 只在 `DEBUG` 时开;其余(含默认 `off`)不开。"""
|
|
36
|
+
switch = env.read_env('LABULL_SCHEMA_CANVAS').lower()
|
|
37
|
+
if switch == 'on':
|
|
38
|
+
return True
|
|
39
|
+
if switch == 'auto':
|
|
40
|
+
return bool(getattr(settings, 'DEBUG', False))
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _relation_of(field: Any, kind: str, cardinality: str, target: Any, **extra: Any) -> dict[str, Any]:
|
|
45
|
+
"""一条关联在画布上的全部信息(**唯一一处**拼这个字典)。
|
|
46
|
+
|
|
47
|
+
🔴 `null` / `unique` 也要给:关联键名旁边那两个小标(必填 / 可空、唯一)就读它们。
|
|
48
|
+
只有**真列**才有这两项 —— 反向关联在库里没有这一列,所以那两个键是 `None`(不是 `False`:
|
|
49
|
+
`False` 会被前端读成"非空",对这个字段来说是句谎话)。
|
|
50
|
+
"""
|
|
51
|
+
item: dict[str, Any] = {
|
|
52
|
+
'field': field.name,
|
|
53
|
+
'kind': kind,
|
|
54
|
+
'cardinality': cardinality,
|
|
55
|
+
'target': target.__name__ if target else '',
|
|
56
|
+
'targetTable': str(target._meta.db_table) if target else '',
|
|
57
|
+
'column': None,
|
|
58
|
+
'via': None,
|
|
59
|
+
'brief': _brief(field),
|
|
60
|
+
# 真列才有的那几项;反向 / 多对多用 `None` 表示"这一侧没有列"。
|
|
61
|
+
'null': bool(getattr(field, 'null', False)) if kind in ('foreignKey', 'oneToOne') else None,
|
|
62
|
+
'unique': bool(getattr(field, 'unique', False)) if kind in ('foreignKey', 'oneToOne') else None,
|
|
63
|
+
}
|
|
64
|
+
item.update(extra)
|
|
65
|
+
return item
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _brief(obj: Any) -> str:
|
|
69
|
+
"""模型的 docstring / 字段的 `help_text` —— 画布上的"备注"就读它。
|
|
70
|
+
|
|
71
|
+
🔴 **整段都留着,不砍成一行**:点开表标题时要看到完整的说明(那才是"备注"的用处)。
|
|
72
|
+
画布上那一行短标签用 `_brief_line()` 取首行。
|
|
73
|
+
"""
|
|
74
|
+
text = (getattr(obj, 'help_text', '') or getattr(obj, '__doc__', '') or '').strip()
|
|
75
|
+
if not text:
|
|
76
|
+
return ''
|
|
77
|
+
# 🔴 `help_text` 常常是"订单号"这种一行;模型 docstring 可能多段 —— 去掉每行的公共缩进,
|
|
78
|
+
# 别把源码里的缩进带到页面上。
|
|
79
|
+
lines = [line.rstrip() for line in text.splitlines()]
|
|
80
|
+
indents = [len(line) - len(line.lstrip()) for line in lines if line.strip()]
|
|
81
|
+
cut = min(indents) if indents else 0
|
|
82
|
+
return '\n'.join(line[cut:] for line in lines).strip()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _brief_line(obj: Any) -> str:
|
|
86
|
+
"""备注的首行(画布上那行短标签用)。"""
|
|
87
|
+
brief = _brief(obj)
|
|
88
|
+
return brief.splitlines()[0].strip() if brief else ''
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _type_of(field: Any) -> str:
|
|
92
|
+
"""Django 字段 → **库里那一列的真实类型**(画布右侧那个小字)。
|
|
93
|
+
|
|
94
|
+
🔴 这里只回答"数据库里长什么样";"这个字段在 Django 里是什么"由 `_kind_of` 回答。
|
|
95
|
+
两者刻意分开:`FileField` 在库里就是一列 varchar,而人关心的是"这是个文件字段"。
|
|
96
|
+
"""
|
|
97
|
+
from django.db import models
|
|
98
|
+
|
|
99
|
+
mapping = (
|
|
100
|
+
(models.BigAutoField, 'bigint'),
|
|
101
|
+
(models.BigIntegerField, 'bigint'),
|
|
102
|
+
(models.AutoField, 'integer'),
|
|
103
|
+
(models.IntegerField, 'integer'),
|
|
104
|
+
(models.SmallIntegerField, 'integer'),
|
|
105
|
+
(models.PositiveIntegerField, 'integer'),
|
|
106
|
+
(models.BooleanField, 'boolean'),
|
|
107
|
+
(models.DateField, 'date'),
|
|
108
|
+
(models.DateTimeField, 'timestamp'),
|
|
109
|
+
(models.TimeField, 'time'),
|
|
110
|
+
(models.DurationField, 'interval'),
|
|
111
|
+
(models.DecimalField, 'numeric'),
|
|
112
|
+
(models.FloatField, 'real'),
|
|
113
|
+
(models.JSONField, 'jsonb'),
|
|
114
|
+
(models.UUIDField, 'uuid'),
|
|
115
|
+
(models.BinaryField, 'bytea'),
|
|
116
|
+
(models.FileField, 'varchar'),
|
|
117
|
+
(models.ImageField, 'varchar'),
|
|
118
|
+
(models.EmailField, 'varchar'),
|
|
119
|
+
(models.URLField, 'varchar'),
|
|
120
|
+
(models.SlugField, 'varchar'),
|
|
121
|
+
(models.GenericIPAddressField, 'inet'),
|
|
122
|
+
(models.CharField, 'varchar'),
|
|
123
|
+
(models.TextField, 'text'),
|
|
124
|
+
)
|
|
125
|
+
for cls, name in mapping:
|
|
126
|
+
if isinstance(field, cls):
|
|
127
|
+
return name
|
|
128
|
+
return 'text'
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _kind_of(field: Any) -> str:
|
|
132
|
+
"""Django 字段的**种类**(画布左侧那个彩色小标:枚举 / 文件 / 关联 / 金额 …)。
|
|
133
|
+
|
|
134
|
+
⚠️ 顺序有意义:`ImageField` 是 `FileField` 的子类、`EmailField` 是 `CharField` 的子类 ——
|
|
135
|
+
具体的必须先判,否则全都会被归成父类那一档。
|
|
136
|
+
"""
|
|
137
|
+
from django.db import models
|
|
138
|
+
|
|
139
|
+
if getattr(field, 'is_relation', False):
|
|
140
|
+
if field.one_to_one:
|
|
141
|
+
return 'oneToOne'
|
|
142
|
+
if field.many_to_many:
|
|
143
|
+
return 'manyToMany'
|
|
144
|
+
return 'foreignKey'
|
|
145
|
+
if getattr(field, 'choices', None):
|
|
146
|
+
return 'enum'
|
|
147
|
+
# 具体类型要排在父类前面(见上面那条警告)。
|
|
148
|
+
for cls, name in (
|
|
149
|
+
(models.ImageField, 'image'),
|
|
150
|
+
(models.FileField, 'file'),
|
|
151
|
+
(models.EmailField, 'email'),
|
|
152
|
+
(models.URLField, 'url'),
|
|
153
|
+
(models.SlugField, 'slug'),
|
|
154
|
+
(models.UUIDField, 'uuid'),
|
|
155
|
+
(models.GenericIPAddressField, 'ip'),
|
|
156
|
+
(models.JSONField, 'json'),
|
|
157
|
+
(models.DecimalField, 'money'),
|
|
158
|
+
(models.DurationField, 'duration'),
|
|
159
|
+
(models.DateTimeField, 'datetime'),
|
|
160
|
+
(models.DateField, 'date'),
|
|
161
|
+
(models.TimeField, 'time'),
|
|
162
|
+
(models.BooleanField, 'boolean'),
|
|
163
|
+
(models.BinaryField, 'binary'),
|
|
164
|
+
(models.TextField, 'text'),
|
|
165
|
+
(models.CharField, 'varchar'),
|
|
166
|
+
(models.IntegerField, 'integer'),
|
|
167
|
+
(models.FloatField, 'float'),
|
|
168
|
+
):
|
|
169
|
+
if isinstance(field, cls):
|
|
170
|
+
return name
|
|
171
|
+
return 'other'
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _file_of(field: Any) -> dict[str, Any] | None:
|
|
175
|
+
"""文件 / 图片字段那一行额外信息:上传到哪、是不是必填、能传什么。
|
|
176
|
+
|
|
177
|
+
🔴 路径按**相对项目根**显示(`media/…`):绝对路径在页面上没有信息量,
|
|
178
|
+
而且会把开发机的目录结构暴露出去。
|
|
179
|
+
"""
|
|
180
|
+
from django.db import models
|
|
181
|
+
|
|
182
|
+
if not isinstance(field, models.FileField):
|
|
183
|
+
return None
|
|
184
|
+
base = getattr(settings, 'BASE_DIR', None)
|
|
185
|
+
upload = getattr(field, 'upload_to', None)
|
|
186
|
+
location = ''
|
|
187
|
+
if upload:
|
|
188
|
+
location = upload if isinstance(upload, str) else getattr(upload, '__name__', str(upload))
|
|
189
|
+
if base is not None and location.startswith(str(base)):
|
|
190
|
+
location = str(Path(location).relative_to(base)).replace('\\', '/')
|
|
191
|
+
return {
|
|
192
|
+
'uploadTo': location or None,
|
|
193
|
+
'storage': type(field.storage).__name__ if getattr(field, 'storage', None) is not None else None,
|
|
194
|
+
'widthField': getattr(field, 'width_field', None) or None,
|
|
195
|
+
'heightField': getattr(field, 'height_field', None) or None,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _shape_field(field: Any, model: Any) -> dict[str, Any]:
|
|
200
|
+
"""一个字段在画布上的全部信息(**唯一一处**拼这个字典)。"""
|
|
201
|
+
kind = _kind_of(field)
|
|
202
|
+
item: dict[str, Any] = {
|
|
203
|
+
'name': field.name,
|
|
204
|
+
'column': str(field.attname),
|
|
205
|
+
'type': _type_of(field),
|
|
206
|
+
'kind': kind,
|
|
207
|
+
'primary': bool(field.primary_key),
|
|
208
|
+
'null': bool(getattr(field, 'null', False)),
|
|
209
|
+
'blank': bool(getattr(field, 'blank', False)),
|
|
210
|
+
'unique': bool(getattr(field, 'unique', False)) and not field.primary_key,
|
|
211
|
+
'brief': _brief(field),
|
|
212
|
+
'briefLine': _brief_line(field),
|
|
213
|
+
'enum': _enum_of(field),
|
|
214
|
+
'file': _file_of(field),
|
|
215
|
+
# 默认值也要能看(`default=Status.OPEN` 这类信息在页面上很有用)。
|
|
216
|
+
'default': _default_of(field),
|
|
217
|
+
}
|
|
218
|
+
if kind in ('foreignKey', 'oneToOne', 'manyToMany'):
|
|
219
|
+
target = field.related_model
|
|
220
|
+
item['target'] = {
|
|
221
|
+
'table': str(target._meta.db_table) if target else '',
|
|
222
|
+
'name': target.__name__ if target else '',
|
|
223
|
+
'label': str(getattr(target._meta, 'verbose_name', '') or '') if target else '',
|
|
224
|
+
}
|
|
225
|
+
item['onDelete'] = getattr(getattr(field, 'remote_field', None), 'on_delete', None).__name__ if getattr(field, 'remote_field', None) and getattr(field.remote_field, 'on_delete', None) else None
|
|
226
|
+
if kind == 'manyToMany':
|
|
227
|
+
through = getattr(getattr(field, 'remote_field', None), 'through', None)
|
|
228
|
+
item['via'] = str(through._meta.db_table) if through is not None else None
|
|
229
|
+
return item
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _default_of(field: Any) -> str | None:
|
|
233
|
+
"""默认值的人类写法(`NOT_PROVIDED` / 可调用的 / 枚举取值都要说得清)。"""
|
|
234
|
+
from django.db.models import NOT_PROVIDED
|
|
235
|
+
|
|
236
|
+
if getattr(field, 'has_default', lambda: False)():
|
|
237
|
+
default = getattr(field, 'default', None)
|
|
238
|
+
if default is NOT_PROVIDED:
|
|
239
|
+
return None
|
|
240
|
+
if callable(default):
|
|
241
|
+
return f'{getattr(default, "__name__", default)}()'
|
|
242
|
+
return str(getattr(default, 'value', default))
|
|
243
|
+
# `auto_now_add` / `auto_now` 不是 default,但页面上得看得见。
|
|
244
|
+
if getattr(field, 'auto_now_add', False):
|
|
245
|
+
return 'auto_now_add'
|
|
246
|
+
if getattr(field, 'auto_now', False):
|
|
247
|
+
return 'auto_now'
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _verbose(model: Any) -> str:
|
|
252
|
+
"""模型的中文名(`Meta.verbose_name`)—— 有就显示,没有就空着。"""
|
|
253
|
+
return str(getattr(model._meta, 'verbose_name', '') or '')
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def shape() -> dict[str, list[dict[str, Any]]]:
|
|
257
|
+
"""当前的模型形状(进程内现算,不需要任何文件)。
|
|
258
|
+
|
|
259
|
+
🔴 这里**只读 Django 的 `_meta`**:docstring 当表备注、`help_text` 当字段备注、
|
|
260
|
+
`choices` 当枚举、`related_model` 当关联 —— 一行正则、一段 AST 都没有。
|
|
261
|
+
扫源码抠这些信息只是在一份已经结构化的东西上再猜一遍。
|
|
262
|
+
"""
|
|
263
|
+
tables: list[dict[str, Any]] = []
|
|
264
|
+
for model in apps.get_models():
|
|
265
|
+
if str(model._meta.db_table) in SKIP_TABLES:
|
|
266
|
+
continue
|
|
267
|
+
label = str(model._meta.app_label)
|
|
268
|
+
fields: list[dict[str, Any]] = []
|
|
269
|
+
relations: list[dict[str, Any]] = []
|
|
270
|
+
for field in model._meta.get_fields():
|
|
271
|
+
if getattr(field, 'many_to_many', False):
|
|
272
|
+
target = field.related_model
|
|
273
|
+
through = getattr(getattr(field, 'remote_field', None), 'through', None)
|
|
274
|
+
relations.append(
|
|
275
|
+
_relation_of(
|
|
276
|
+
field,
|
|
277
|
+
'manyToMany',
|
|
278
|
+
'*',
|
|
279
|
+
target,
|
|
280
|
+
via=str(getattr(through._meta, 'db_table', '') or '') or None,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
continue
|
|
284
|
+
if getattr(field, 'one_to_many', False) and not getattr(field, 'one_to_one', False):
|
|
285
|
+
# 🔴 **反向那一侧也要画**:从 `Customer` 看不到"它被多少订单引用",
|
|
286
|
+
# 图就是半个 —— 老项目那套连线两边都连。反向是**只读**的(库里没有这一列)。
|
|
287
|
+
# ⚠️ 反向的一对一**跳过**:它的正向那一边已经画了一条线,两边都画就成了两条重合的线。
|
|
288
|
+
target = field.related_model
|
|
289
|
+
relations.append(_relation_of(field, 'reverse', '*', target, readonly=True))
|
|
290
|
+
continue
|
|
291
|
+
if not hasattr(field, 'attname'):
|
|
292
|
+
continue
|
|
293
|
+
if getattr(field, 'is_relation', False):
|
|
294
|
+
target = field.related_model
|
|
295
|
+
# 🔴 基数:`1` = 这一头只指一行(外键 / 一对一);`*` = 多(多对多)。
|
|
296
|
+
# 连线两端各画一个,合起来就是"一对多""多对多"。
|
|
297
|
+
kind = 'oneToOne' if field.one_to_one else 'foreignKey'
|
|
298
|
+
relations.append(
|
|
299
|
+
_relation_of(field, kind, '1', target, column=str(field.attname))
|
|
300
|
+
)
|
|
301
|
+
fields.append(_shape_field(field, model))
|
|
302
|
+
tables.append(
|
|
303
|
+
{
|
|
304
|
+
'name': model.__name__,
|
|
305
|
+
'table': str(model._meta.db_table),
|
|
306
|
+
'module': label,
|
|
307
|
+
'verbose': _verbose(model),
|
|
308
|
+
'brief': _brief(model),
|
|
309
|
+
'briefLine': _brief_line(model),
|
|
310
|
+
'fields': fields,
|
|
311
|
+
'relations': relations,
|
|
312
|
+
}
|
|
313
|
+
)
|
|
314
|
+
tables.sort(key=lambda item: (item['module'], item['table']))
|
|
315
|
+
return {'tables': tables}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _type_of(field: Any) -> str:
|
|
319
|
+
"""Django 字段 → 契约里那几种类型之一。
|
|
320
|
+
|
|
321
|
+
🔴 文件列**没有单独的类型**:`FileField` 在库里就是一列 varchar,所以按 `text` 报。
|
|
322
|
+
"""
|
|
323
|
+
from django.db import models
|
|
324
|
+
|
|
325
|
+
mapping = (
|
|
326
|
+
(models.BigAutoField, 'bigint'),
|
|
327
|
+
(models.BigIntegerField, 'bigint'),
|
|
328
|
+
(models.AutoField, 'integer'),
|
|
329
|
+
(models.IntegerField, 'integer'),
|
|
330
|
+
(models.SmallIntegerField, 'integer'),
|
|
331
|
+
(models.BooleanField, 'boolean'),
|
|
332
|
+
(models.DateField, 'date'),
|
|
333
|
+
(models.DateTimeField, 'timestamp'),
|
|
334
|
+
(models.DecimalField, 'numeric'),
|
|
335
|
+
(models.FloatField, 'real'),
|
|
336
|
+
(models.JSONField, 'jsonb'),
|
|
337
|
+
)
|
|
338
|
+
for cls, name in mapping:
|
|
339
|
+
if isinstance(field, cls):
|
|
340
|
+
return name
|
|
341
|
+
if isinstance(field, (models.CharField, models.SlugField)):
|
|
342
|
+
return 'varchar'
|
|
343
|
+
return 'text'
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _enum_of(field: Any) -> dict[str, Any] | None:
|
|
347
|
+
"""枚举列:`TextChoices` 在 `_meta` 里只剩「取值 → 中文名」这两样。"""
|
|
348
|
+
choices = list(getattr(field, 'choices', None) or [])
|
|
349
|
+
if not choices:
|
|
350
|
+
return None
|
|
351
|
+
values = [str(value) for value, _ in choices]
|
|
352
|
+
labels = {str(value): str(label) for value, label in choices}
|
|
353
|
+
return {'values': values, 'labels': labels}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def payload() -> dict[str, Any]:
|
|
357
|
+
"""画布整包:当前形状 + 与 git 那份比出来的三态 + 头部那行 git 信息 + 布局。
|
|
358
|
+
|
|
359
|
+
🔴 **被删掉的表也要带上**(`deletedTables`):不然页面上只有"删除 N"这个数字,
|
|
360
|
+
看不到删掉的是哪张、原来有哪些列 —— 那正是重构时最需要看的东西。
|
|
361
|
+
它们从基线那份契约里取,列结构与当前形状同形(老契约里没有的新字段按缺省补)。
|
|
362
|
+
"""
|
|
363
|
+
from . import gitinfo
|
|
364
|
+
|
|
365
|
+
current = shape()
|
|
366
|
+
current_tables = current['tables']
|
|
367
|
+
baseline = gitinfo.baseline_tables()
|
|
368
|
+
diff = _diff(baseline, current_tables)
|
|
369
|
+
return {
|
|
370
|
+
'tables': current_tables,
|
|
371
|
+
'deletedTables': _deleted_tables(baseline, diff['deleted']),
|
|
372
|
+
'diff': diff,
|
|
373
|
+
# 逐列的差异(`表名 → {added, modified, deleted}`):前端拿它给**整行**上色。
|
|
374
|
+
'fieldDiff': _field_diff(baseline, current_tables),
|
|
375
|
+
'git': gitinfo.head(),
|
|
376
|
+
'baseline': 'none' if baseline is None else 'present',
|
|
377
|
+
'layout': read_layout(),
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _deleted_tables(baseline: list[dict[str, Any]] | None, deleted: list[str]) -> list[dict[str, Any]]:
|
|
382
|
+
"""基线里有、现在没有的那些表(补上新契约才有的键,免得前端到处判空)。"""
|
|
383
|
+
if not baseline or not deleted:
|
|
384
|
+
return []
|
|
385
|
+
wanted = set(deleted)
|
|
386
|
+
out: list[dict[str, Any]] = []
|
|
387
|
+
for table in baseline:
|
|
388
|
+
if str(table.get('table')) not in wanted:
|
|
389
|
+
continue
|
|
390
|
+
item = dict(table)
|
|
391
|
+
item.setdefault('verbose', '')
|
|
392
|
+
item.setdefault('briefLine', item.get('brief', ''))
|
|
393
|
+
fields = []
|
|
394
|
+
for field in item.get('fields') or []:
|
|
395
|
+
field = dict(field)
|
|
396
|
+
field.setdefault('kind', 'other')
|
|
397
|
+
field.setdefault('briefLine', field.get('brief', ''))
|
|
398
|
+
field.setdefault('blank', False)
|
|
399
|
+
field.setdefault('unique', False)
|
|
400
|
+
fields.append(field)
|
|
401
|
+
item['fields'] = fields
|
|
402
|
+
item.setdefault('relations', [])
|
|
403
|
+
out.append(item)
|
|
404
|
+
return out
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _field_diff(baseline: list[dict[str, Any]] | None, current_tables: list[dict[str, Any]]) -> dict[str, dict[str, list[str]]]:
|
|
408
|
+
"""每一张表里**哪几列**是新增 / 改过 / 删掉的(键是列名)。
|
|
409
|
+
|
|
410
|
+
🔴 为什么要在后端算:书**没有 git 基线**时前端手里只有"现在这一份",
|
|
411
|
+
它无从知道哪一列是新的。差异的判据只能有一处(就是这里),前端只负责上色。
|
|
412
|
+
🔴 只拿基线里那几张**现在还在**的表来比:被删掉的那些表由 `deletedTables` 整块呈现,
|
|
413
|
+
不需要再逐列标一遍。
|
|
414
|
+
🔴 判据与 `_compare_key()` 同源:**列的类型 / 主键 / 可空 / 唯一变了才算改**,
|
|
415
|
+
备注文案(`brief`)变了不算 —— 否则给某个字段补一句说明,整张表就满屏黄。
|
|
416
|
+
"""
|
|
417
|
+
if not baseline:
|
|
418
|
+
return {}
|
|
419
|
+
before = {str(t.get('table')): _columns_of(t) for t in baseline}
|
|
420
|
+
out: dict[str, dict[str, list[str]]] = {}
|
|
421
|
+
for table in current_tables:
|
|
422
|
+
name = str(table.get('table'))
|
|
423
|
+
old = before.get(name)
|
|
424
|
+
if old is None:
|
|
425
|
+
continue # 新增的表整块标色,不必逐列
|
|
426
|
+
new = _columns_of(table)
|
|
427
|
+
added = [key for key in new if key not in old]
|
|
428
|
+
removed = [key for key in old if key not in new]
|
|
429
|
+
changed = [
|
|
430
|
+
key
|
|
431
|
+
for key, column in new.items()
|
|
432
|
+
if key in old and _column_key(old[key]) != _column_key(column)
|
|
433
|
+
]
|
|
434
|
+
if added or removed or changed:
|
|
435
|
+
out[name] = {'added': sorted(added), 'modified': sorted(changed), 'deleted': sorted(removed)}
|
|
436
|
+
return out
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _columns_of(table: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
440
|
+
"""一张表的列(老契约用 `fields`、新契约用 `columns`,两个都认)。"""
|
|
441
|
+
columns = table.get('columns') if isinstance(table.get('columns'), list) else table.get('fields')
|
|
442
|
+
return {str(column.get('name')): column for column in columns or [] if isinstance(column, dict)}
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _column_key(column: dict[str, Any]) -> Any:
|
|
446
|
+
"""一列**用来比差异的那部分**(与 `_compare_key()` 里那一条同一份判据)。"""
|
|
447
|
+
return (
|
|
448
|
+
str(column.get('column') or column.get('name') or ''),
|
|
449
|
+
column.get('type'),
|
|
450
|
+
bool(column.get('primary')),
|
|
451
|
+
bool(column.get('null')),
|
|
452
|
+
bool(column.get('unique')),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _compare_key(table: dict[str, Any]) -> Any:
|
|
457
|
+
"""一张表**用来比差异的那部分**(表名 + 列的形状)。
|
|
458
|
+
|
|
459
|
+
🔴 为什么不能整份对象直接 `!=`:契约里除了结构还有**文案与修饰**(`brief` / `briefLine` /
|
|
460
|
+
`verbose` / `kind` …),而这一页每次打开都可能给它们补点东西 ——
|
|
461
|
+
一旦补了,**没人碰过的表**也会被判成"修改"(真踩过:给 `brief` 补了一段 docstring,
|
|
462
|
+
于是三张没动过的表全变"修改",那个功能就等于坏了)。
|
|
463
|
+
判据只留"改了什么才算改了结构":列名 / 列名 / 类型 / 主键 / 可空 / 唯一。
|
|
464
|
+
⚠️ 老契约用 `fields` 这个键,新契约用 `columns`:两个都要认,否则升级那一刻所有表都成"新增"。
|
|
465
|
+
"""
|
|
466
|
+
columns = table.get('columns') if isinstance(table.get('columns'), list) else table.get('fields')
|
|
467
|
+
shape = []
|
|
468
|
+
for column in columns or []:
|
|
469
|
+
if not isinstance(column, dict):
|
|
470
|
+
continue
|
|
471
|
+
shape.append(_column_key(column))
|
|
472
|
+
return (table.get('table') or table.get('name'), shape)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _diff(baseline: list[dict[str, Any]] | None, current: list[dict[str, Any]]) -> dict[str, list[str]]:
|
|
476
|
+
"""逐表逐列比出加 / 改 / 删。
|
|
477
|
+
|
|
478
|
+
🔴 **`baseline is None`(没有可比基线)⇒ 一条差异都不报**:把它当成"所有表都是新增"
|
|
479
|
+
会让一张干净的图满屏假红。三种输入各说各的:`None` = 没法比;`[]` = 基线里确实一张表都没有。
|
|
480
|
+
🔴 比的是 `_compare_key()`(结构),不是整份对象 —— 理由见那个函数。
|
|
481
|
+
"""
|
|
482
|
+
if baseline is None:
|
|
483
|
+
return {'added': [], 'modified': [], 'deleted': []}
|
|
484
|
+
before = {str(t.get('table')): _compare_key(t) for t in baseline}
|
|
485
|
+
after = {str(t.get('table')): _compare_key(t) for t in current}
|
|
486
|
+
added = [name for name in after if name not in before]
|
|
487
|
+
deleted = [name for name in before if name not in after]
|
|
488
|
+
modified = [name for name in after if name in before and before[name] != after[name]]
|
|
489
|
+
return {
|
|
490
|
+
'added': sorted(added),
|
|
491
|
+
'modified': sorted(modified),
|
|
492
|
+
'deleted': sorted(deleted),
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def read_layout() -> dict[str, Any]:
|
|
497
|
+
"""读拖拽位置(没有 / 读不出来都回空对象 —— 退回自动布局,不是错误)。"""
|
|
498
|
+
path = _layout_path()
|
|
499
|
+
if path is None or not path.is_file():
|
|
500
|
+
return {}
|
|
501
|
+
try:
|
|
502
|
+
parsed = json.loads(path.read_text(encoding='utf-8'))
|
|
503
|
+
except (OSError, ValueError):
|
|
504
|
+
return {}
|
|
505
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def write_layout(data: Any) -> dict[str, Any]:
|
|
509
|
+
"""存拖拽位置。⚠️ 落点不存在就**不落盘**(也不报错):这一页不该要求项目有某个目录。"""
|
|
510
|
+
path = _layout_path()
|
|
511
|
+
if path is None or not isinstance(data, dict):
|
|
512
|
+
return read_layout()
|
|
513
|
+
try:
|
|
514
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
515
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
516
|
+
except OSError:
|
|
517
|
+
return {}
|
|
518
|
+
return data
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _layout_path():
|
|
522
|
+
base = getattr(settings, 'BASE_DIR', None)
|
|
523
|
+
return (base / LAYOUT_FILE) if base else None
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""画布要的 git 那一半:当前提交、那一行备注、以及**上一版**的模型形状。
|
|
2
|
+
|
|
3
|
+
🔴 **基线就是当前提交(HEAD),不是 `HEAD~1`**:画布要回答的是"你现在改了什么"。
|
|
4
|
+
用 `HEAD~1` 的话,只有一个提交的项目会拿到空基线、页面永远显示"没有变化" —— 对新项目等于失效。
|
|
5
|
+
🔴 **不是仓库 / 取不到那份文件 ⇒ 回 `None`**("没有可比基线"是**正常状态**,不是错误)。
|
|
6
|
+
🔴 这一层的输出只影响"差异"那几列;**当前形状完全不依赖 git**(它读的是进程内的模型元数据)。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from django.conf import settings
|
|
17
|
+
|
|
18
|
+
#: 模型形状 JSON 的落点(相对项目根)。它由 `manage.py labull_contract --out` 写出,**可选**:
|
|
19
|
+
#: 不写就没有"上一版"可比,画布只画当前这份。
|
|
20
|
+
DEFAULT_CONTRACT_PATH = '.labull/contract.json'
|
|
21
|
+
|
|
22
|
+
#: git 单次调用的超时(秒):它是画布页面的一个同步依赖,卡住就等于页面卡住。
|
|
23
|
+
_TIMEOUT = 5
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _run(args: list[str], cwd: Path) -> str | None:
|
|
27
|
+
"""跑一条只读的 git 命令,返回 stdout(失败一律回 `None`)。
|
|
28
|
+
|
|
29
|
+
🔴 **必须显式 `encoding='utf-8'`**:`text=True` 在 Windows 上默认用**系统 ANSI 代码页(GBK)**
|
|
30
|
+
解码,而提交说明是中文 UTF-8 ⇒ 解码线程抛 `UnicodeDecodeError`,`stdout` 变成 `None`,
|
|
31
|
+
于是下一句 `.strip()` 报 `'NoneType' object has no attribute 'strip'` ——
|
|
32
|
+
症状是 `/canvas` 直接 500,而真因跟"模型"一个字都没关系(真踩过)。
|
|
33
|
+
`errors='replace'` 是第二道保险:真碰上编码诡异的输出,也宁可显示成问号,不要让整页崩掉。
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
done = subprocess.run(
|
|
37
|
+
['git', *args],
|
|
38
|
+
cwd=str(cwd),
|
|
39
|
+
capture_output=True,
|
|
40
|
+
encoding='utf-8',
|
|
41
|
+
errors='replace',
|
|
42
|
+
timeout=_TIMEOUT,
|
|
43
|
+
check=False,
|
|
44
|
+
)
|
|
45
|
+
except (OSError, subprocess.SubprocessError):
|
|
46
|
+
# git 没装 / 不是仓库 / 超时:都是"这一层拿不到信息",不是错误(差异那几列留空即可)。
|
|
47
|
+
return None
|
|
48
|
+
output = done.stdout or ''
|
|
49
|
+
return output.strip() if done.returncode == 0 else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _root() -> Path | None:
|
|
53
|
+
"""项目根(`settings.BASE_DIR`)—— 那条"相对项目"的契约路径是相对它算的。"""
|
|
54
|
+
base = getattr(settings, 'BASE_DIR', None)
|
|
55
|
+
return Path(base) if base else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _repo_root() -> Path | None:
|
|
59
|
+
"""**git 仓库根**(`git rev-parse --show-toplevel`)。
|
|
60
|
+
|
|
61
|
+
🔴 为什么不能拿 `BASE_DIR` 当仓库根:`BASE_DIR` 是**项目**根(示例里是 `backend/example`),
|
|
62
|
+
而 `git show HEAD:<路径>` 里的路径是**相对仓库根**的。仓库根只要在更上面一层
|
|
63
|
+
(这个仓库就是:`BASE_DIR` 在 `backend/example`,仓库根在它上两层),
|
|
64
|
+
那条命令就会失败、`baseline_tables()` 永远回 `None` ——
|
|
65
|
+
症状是画布永远显示"没有可比基线",而"提交过契约"这件事一点错都不报(真踩过)。
|
|
66
|
+
"""
|
|
67
|
+
base = _root()
|
|
68
|
+
if base is None:
|
|
69
|
+
return None
|
|
70
|
+
top = _run(['rev-parse', '--show-toplevel'], base)
|
|
71
|
+
return Path(top) if top else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def contract_path() -> str:
|
|
75
|
+
"""那份 JSON 的落点(项目可用 `LABULL_CONTRACT_PATH` 覆盖)。"""
|
|
76
|
+
from .. import env
|
|
77
|
+
|
|
78
|
+
return env.read_env('LABULL_CONTRACT_PATH') or DEFAULT_CONTRACT_PATH
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _repo_relative_contract(repo: Path) -> str | None:
|
|
82
|
+
"""契约路径换算成**相对仓库根**的形式(`git show` / `git diff` 认这个)。
|
|
83
|
+
|
|
84
|
+
契约在仓库外面(项目根不在仓库里,或者契约被 `LABULL_CONTRACT_PATH` 指到外面)⇒ `None`:
|
|
85
|
+
那种"这份文件不受版本控制"的情况就是**没有可比基线**,不是错误。
|
|
86
|
+
"""
|
|
87
|
+
base = _root()
|
|
88
|
+
if base is None:
|
|
89
|
+
return None
|
|
90
|
+
absolute = (base / contract_path()).resolve()
|
|
91
|
+
try:
|
|
92
|
+
relative = absolute.relative_to(repo.resolve())
|
|
93
|
+
except ValueError:
|
|
94
|
+
return None
|
|
95
|
+
return relative.as_posix()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def head() -> dict[str, Any] | None:
|
|
99
|
+
"""头部那一行:提交号 / 备注 / 有没有父提交 / 这次改了哪些文件。
|
|
100
|
+
|
|
101
|
+
备注优先取中文(`备注:` 那一行,否则含中文的首行)—— 中文项目里英文的 commit 标题没什么信息量。
|
|
102
|
+
"""
|
|
103
|
+
root = _root()
|
|
104
|
+
if root is None:
|
|
105
|
+
return None
|
|
106
|
+
full = _run(['rev-parse', 'HEAD'], root)
|
|
107
|
+
if not full:
|
|
108
|
+
return None
|
|
109
|
+
message = _run(['log', '-1', '--pretty=format:%s'], root) or ''
|
|
110
|
+
parent = _run(['rev-parse', f'{full}~1'], root)
|
|
111
|
+
files = (_run(['diff', '--name-only', parent, full], root) or '').splitlines() if parent else []
|
|
112
|
+
return {
|
|
113
|
+
'hash': full[:8],
|
|
114
|
+
'msg': _chinese_remark(message),
|
|
115
|
+
'hasParent': bool(parent),
|
|
116
|
+
'files': [line.strip() for line in files if line.strip()],
|
|
117
|
+
'baseRev': full[:8],
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _chinese_remark(message: str) -> str:
|
|
122
|
+
for line in message.splitlines():
|
|
123
|
+
head, sep, tail = line.strip().partition(':')
|
|
124
|
+
if sep and head.lower() in ('备注', 'remark') and tail.strip():
|
|
125
|
+
return tail.strip()
|
|
126
|
+
for line in message.splitlines():
|
|
127
|
+
text = line.strip()
|
|
128
|
+
if text and any('\u4e00' <= ch <= '\u9fa5' for ch in text):
|
|
129
|
+
return text
|
|
130
|
+
return message.splitlines()[0].strip() if message else ''
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def baseline_tables() -> list[dict[str, Any]] | None:
|
|
134
|
+
"""**上一版**的表定义(从 git 的那个提交里取)。
|
|
135
|
+
|
|
136
|
+
三种返回值的语义不同,别混:`None` = 没有可比基线(不是仓库 / 提交里没有那份文件 / 文件坏了);
|
|
137
|
+
`[]` = 有基线,但基线里确实一张表都没有;有内容 = 正常逐表比。
|
|
138
|
+
"""
|
|
139
|
+
repo = _repo_root()
|
|
140
|
+
if repo is None:
|
|
141
|
+
return None
|
|
142
|
+
relative = _repo_relative_contract(repo)
|
|
143
|
+
if relative is None:
|
|
144
|
+
return None
|
|
145
|
+
text = _run(['show', f'HEAD:{relative}'], repo)
|
|
146
|
+
if not text:
|
|
147
|
+
return None
|
|
148
|
+
try:
|
|
149
|
+
parsed = json.loads(text)
|
|
150
|
+
except ValueError:
|
|
151
|
+
# 提交里那份坏了(手改过 / 写到一半就提交了)⇒ 当"没有基线",
|
|
152
|
+
# 而不是拿半份数据去比(那会报出一堆假的新增 / 删除)。
|
|
153
|
+
return None
|
|
154
|
+
tables = parsed.get('tables') if isinstance(parsed, dict) else None
|
|
155
|
+
return tables if isinstance(tables, list) else None
|