django-simpleui-antd 1.3.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.
Potentially problematic release.
This version of django-simpleui-antd might be problematic. Click here for more details.
- django_simpleui_antd-1.3.0.dist-info/METADATA +75 -0
- django_simpleui_antd-1.3.0.dist-info/RECORD +26 -0
- django_simpleui_antd-1.3.0.dist-info/WHEEL +5 -0
- django_simpleui_antd-1.3.0.dist-info/top_level.txt +1 -0
- simpleui_antd/__init__.py +3 -0
- simpleui_antd/api/__init__.py +0 -0
- simpleui_antd/api/auth.py +42 -0
- simpleui_antd/api/base.py +66 -0
- simpleui_antd/api/crud.py +299 -0
- simpleui_antd/api/dashboard.py +84 -0
- simpleui_antd/api/listing.py +77 -0
- simpleui_antd/api/registry.py +354 -0
- simpleui_antd/apps.py +7 -0
- simpleui_antd/components/__init__.py +1 -0
- simpleui_antd/components/fields.py +152 -0
- simpleui_antd/editor/__init__.py +0 -0
- simpleui_antd/editor/fields.py +26 -0
- simpleui_antd/migrations/0001_initial.py +30 -0
- simpleui_antd/migrations/__init__.py +0 -0
- simpleui_antd/models.py +19 -0
- simpleui_antd/static/simpleui_antd/assets/index.css +1 -0
- simpleui_antd/static/simpleui_antd/assets/index.js +407 -0
- simpleui_antd/static/simpleui_antd/index.html +13 -0
- simpleui_antd/templates/simpleui_antd/index.html +18 -0
- simpleui_antd/urls.py +27 -0
- simpleui_antd/views.py +8 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-simpleui-antd
|
|
3
|
+
Version: 1.3.0
|
|
4
|
+
Summary: simpleui(Django Admin 主题)的 React 19 + Ant Design 6 复刻版 —— 可安装的 Django 管理后台
|
|
5
|
+
Author: neekin
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Framework :: Django
|
|
8
|
+
Classifier: Framework :: Django :: 4.2
|
|
9
|
+
Classifier: Framework :: Django :: 5.0
|
|
10
|
+
Classifier: Framework :: Django :: 5.1
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: django>=4.2
|
|
15
|
+
|
|
16
|
+
# django-simpleui-antd
|
|
17
|
+
|
|
18
|
+
[simpleui](https://github.com/newpanjing/simpleui)(Django Admin 主题,Vue + Element-UI)的
|
|
19
|
+
**React 19 + Ant Design 6** 复刻版 —— 可安装的 Django 管理后台。
|
|
20
|
+
|
|
21
|
+
`pip install` 后把 `simpleui_antd` 加入 `INSTALLED_APPS` 并挂载 URL,即可在 `/simpleui/`
|
|
22
|
+
得到一套 React 管理界面,数据直接来自你项目中**注册到 Django Admin 的模型**
|
|
23
|
+
(复用 ModelAdmin 的 queryset、权限与 ModelForm 校验)。
|
|
24
|
+
|
|
25
|
+
## 安装
|
|
26
|
+
|
|
27
|
+
从仓库 Release 安装(当前发布渠道):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# 方式一:下载 Release 的 wheel 后安装
|
|
31
|
+
gh release download v1.1.0 -R neekin/simpleui-antd
|
|
32
|
+
pip install ./django_simpleui_antd-1.1.0-py3-none-any.whl
|
|
33
|
+
|
|
34
|
+
# 方式二:直接从 Git tag 安装(需要仓库访问权限)
|
|
35
|
+
pip install "git+https://github.com/neekin/simpleui-antd.git@v1.1.0#subdirectory=django"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
> 发布到 PyPI 后即可 `pip install django-simpleui-antd`。
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
# settings.py
|
|
42
|
+
INSTALLED_APPS = [
|
|
43
|
+
...,
|
|
44
|
+
"simpleui_antd", # 放在你自己的 app 之后即可
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
# urls.py
|
|
48
|
+
urlpatterns = [
|
|
49
|
+
...,
|
|
50
|
+
path("simpleui/", include("simpleui_antd.urls")),
|
|
51
|
+
]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python manage.py migrate # 无迁移也会创建表结构依赖(可选)
|
|
56
|
+
python manage.py runserver
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
访问 <http://127.0.0.1:8000/simpleui/>,使用**任意 is_staff 账号**登录。
|
|
60
|
+
|
|
61
|
+
## 工作方式
|
|
62
|
+
|
|
63
|
+
- 前端为 React 19 + AntD 6 单页应用,构建产物已内置于包的 `static/` 目录
|
|
64
|
+
- 后端提供轻量管理 API(`/simpleui/api/*`),数据源是 `django.contrib.admin` 的模型注册表:
|
|
65
|
+
- `menu` 侧边栏(按 app 分组,仅含当前用户有查看权限的模型)
|
|
66
|
+
- `meta` 编辑表单元数据(复用 ModelForm,字段类型/必填/下拉选项)
|
|
67
|
+
- `list` 列表查询(复用 ModelAdmin.get_queryset,支持搜索 / 排序 / 分页)
|
|
68
|
+
- `save` / `delete` 增删改(复用 ModelForm 校验与 ModelAdmin 权限,写入 Admin LogEntry)
|
|
69
|
+
- 认证复用 Django session,登录接口使用 Django `authenticate()`,CSRF 走标准中间件
|
|
70
|
+
|
|
71
|
+
## 已知边界(v1.1)
|
|
72
|
+
|
|
73
|
+
- 编辑表单支持常用字段类型(文本 / 数值 / 布尔 / 外键下拉 / 带选项字段);不支持内联(inline)、文件上传、多对多
|
|
74
|
+
- 列表搜索基于文本类字段的 icontains;未复刻 admin 的自定义 filter
|
|
75
|
+
- 需要更复杂行为时,可以同时保留原生 `/admin/` 两者并存
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
simpleui_antd/__init__.py,sha256=NNL2B0rwQTEVGkLX1Ovk5EB0CB_aHplw5MQqyH4SkXQ,117
|
|
2
|
+
simpleui_antd/apps.py,sha256=21bLoJjV2rB9CGRL2jwbomOvHC0C-ItmV-KlwAGAYA0,205
|
|
3
|
+
simpleui_antd/models.py,sha256=Wy5M0AX3d6hi4sZKrdx_zp3C3a7fN79MtNVCCp7JEKA,585
|
|
4
|
+
simpleui_antd/urls.py,sha256=O60irtVwXOHmMomvm0p9dQ_qNKE1DocGPfyU0HwK7TY,1384
|
|
5
|
+
simpleui_antd/views.py,sha256=Uk5evZZeq4itM6OhYKeTZfq8bvCQIfDIHUV3Pno5U04,279
|
|
6
|
+
simpleui_antd/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
simpleui_antd/api/auth.py,sha256=07lg1b8gjQIwdbeP460HpMz2i6kmc1IOF-81SDz1G_M,1280
|
|
8
|
+
simpleui_antd/api/base.py,sha256=VcPQuyHTqhfxHhpL1Qvn-0-AiDxBjn2DT0oFwbZ_lBM,1984
|
|
9
|
+
simpleui_antd/api/crud.py,sha256=2bPZ_rcBci3Wfbb6P2X21UzJYv8w5csRtVcyMBDl3d8,11263
|
|
10
|
+
simpleui_antd/api/dashboard.py,sha256=iC7MDocOyLoxNwmU87jI8ZMzIN8VR7zvELNNu0OYZ1w,2901
|
|
11
|
+
simpleui_antd/api/listing.py,sha256=AfcwqkMwaKH2uADYLxeTjYG58XqpVa_jw0wv_Y6HOi4,2298
|
|
12
|
+
simpleui_antd/api/registry.py,sha256=fy96O_h0oxaLsD_gK2iPqeyhLlmdGVnXhPEZN8cWjO4,12884
|
|
13
|
+
simpleui_antd/components/__init__.py,sha256=QdHSl-5z5XwifnpFwX5abUn9mQoPDVKwKQ6tp-HA4Fo,35
|
|
14
|
+
simpleui_antd/components/fields.py,sha256=3IJiABTopUsjMXDpW9JEZ77TIh4FLmkSQ61TRNf5B6E,5944
|
|
15
|
+
simpleui_antd/editor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
simpleui_antd/editor/fields.py,sha256=U0YMXBqngjpOG0_CRcnjpsbqBgsk_bEBpbhiI5b7SRU,817
|
|
17
|
+
simpleui_antd/migrations/0001_initial.py,sha256=Gt7GxUOm-gY5XGHtwsFJSDGnv2pD7T1kDNrw4zrRysc,1008
|
|
18
|
+
simpleui_antd/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
+
simpleui_antd/static/simpleui_antd/index.html,sha256=GFJiXa5nM6racfWE4Y8mYw27DDYbQbkHwAtde6F23M0,430
|
|
20
|
+
simpleui_antd/static/simpleui_antd/assets/index.css,sha256=BOKkqM-K-uEhQwcl0F4dcgXX1InMEQVckC7jUHAiBIg,22441
|
|
21
|
+
simpleui_antd/static/simpleui_antd/assets/index.js,sha256=xPwcrijr5HIvf8kVmf1RIG5HfqXm2AIxaH3o3t-7Cz8,2587193
|
|
22
|
+
simpleui_antd/templates/simpleui_antd/index.html,sha256=PN-qg9rjmfHnGWLeSSkFtsVofnBj0_PlblvYG2QYtzY,638
|
|
23
|
+
django_simpleui_antd-1.3.0.dist-info/METADATA,sha256=jn_DgGfODlmYL-W3EPzzGdktq7CrMnndRhRybN3kE28,2826
|
|
24
|
+
django_simpleui_antd-1.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
25
|
+
django_simpleui_antd-1.3.0.dist-info/top_level.txt,sha256=sszuygkyK5r3xIgSdpI52bRKDbE5Hl3LbRl_lfQuWaQ,14
|
|
26
|
+
django_simpleui_antd-1.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
simpleui_antd
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""认证接口:session 登录 / 登出 / 当前用户"""
|
|
2
|
+
|
|
3
|
+
from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout
|
|
4
|
+
|
|
5
|
+
from .base import json_err, json_ok, read_body
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def me(request):
|
|
9
|
+
from .base import staff_required
|
|
10
|
+
|
|
11
|
+
@staff_required
|
|
12
|
+
def _inner(request):
|
|
13
|
+
user = request.user
|
|
14
|
+
return json_ok(
|
|
15
|
+
{
|
|
16
|
+
"username": user.username,
|
|
17
|
+
"name": user.get_full_name() or user.username,
|
|
18
|
+
"is_superuser": user.is_superuser,
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
return _inner(request)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def login(request):
|
|
26
|
+
if request.method != "POST":
|
|
27
|
+
return json_err("仅支持 POST", status=405)
|
|
28
|
+
body = read_body(request)
|
|
29
|
+
user = authenticate(request, username=body.get("username") or "", password=body.get("password") or "")
|
|
30
|
+
if user is None:
|
|
31
|
+
return json_err("用户名或密码错误")
|
|
32
|
+
if not (user.is_staff or user.is_superuser):
|
|
33
|
+
return json_err("需要管理员(Staff)账号", status=403)
|
|
34
|
+
auth_login(request, user)
|
|
35
|
+
return json_ok({"username": user.username, "is_superuser": user.is_superuser})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def logout(request):
|
|
39
|
+
if request.method != "POST":
|
|
40
|
+
return json_err("仅支持 POST", status=405)
|
|
41
|
+
auth_logout(request)
|
|
42
|
+
return json_ok({})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""API 基础设施:JSON 响应、权限装饰器、Admin 注册表查找"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from functools import wraps
|
|
5
|
+
|
|
6
|
+
from django.contrib import admin
|
|
7
|
+
from django.apps import apps
|
|
8
|
+
from django.http import Http404, JsonResponse
|
|
9
|
+
from django.test import RequestFactory
|
|
10
|
+
|
|
11
|
+
_perm_factory = RequestFactory()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def json_ok(data=None):
|
|
15
|
+
return JsonResponse({"ok": True, "data": data or {}})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def json_err(message, status=400, errors=None):
|
|
19
|
+
payload = {"ok": False, "error": message}
|
|
20
|
+
if errors:
|
|
21
|
+
payload["errors"] = errors
|
|
22
|
+
return JsonResponse(payload, status=status)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_body(request):
|
|
26
|
+
try:
|
|
27
|
+
return json.loads(request.body or b"{}")
|
|
28
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def staff_required(view):
|
|
33
|
+
"""要求已登录且 is_staff(is_superuser 视为 staff)"""
|
|
34
|
+
|
|
35
|
+
@wraps(view)
|
|
36
|
+
def wrapper(request, *args, **kwargs):
|
|
37
|
+
if not request.user.is_authenticated:
|
|
38
|
+
return json_err("未登录", status=401)
|
|
39
|
+
if not (request.user.is_staff or request.user.is_superuser):
|
|
40
|
+
return json_err("需要管理员(Staff)账号", status=403)
|
|
41
|
+
return view(request, *args, **kwargs)
|
|
42
|
+
|
|
43
|
+
return wrapper
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def perm_request(user):
|
|
47
|
+
"""构造用于 ModelAdmin 权限检查的最小 request(部分自定义 ModelAdmin 需要完整 request,此处尽力兼容)"""
|
|
48
|
+
req = _perm_factory.get("/simpleui/")
|
|
49
|
+
req.user = user
|
|
50
|
+
return req
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_admin(model_key):
|
|
54
|
+
"""'app_label.model_name' → (model, ModelAdmin);未注册到 Admin 的模型返回 404"""
|
|
55
|
+
try:
|
|
56
|
+
app_label, model_name = model_key.split(".", 1)
|
|
57
|
+
except ValueError:
|
|
58
|
+
raise Http404("模型标识不合法")
|
|
59
|
+
try:
|
|
60
|
+
model = apps.get_model(app_label, model_name)
|
|
61
|
+
except (LookupError, ValueError):
|
|
62
|
+
raise Http404("模型不存在")
|
|
63
|
+
madmin = admin.site._registry.get(model)
|
|
64
|
+
if madmin is None:
|
|
65
|
+
raise Http404("该模型未注册到 Django Admin")
|
|
66
|
+
return model, madmin
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""增删改接口:多对多、文件上传、内联(Inline)、批量 Action"""
|
|
2
|
+
|
|
3
|
+
from django.contrib.admin.models import ADDITION, CHANGE, DELETION, LogEntry
|
|
4
|
+
from django.contrib import messages
|
|
5
|
+
from django.contrib.contenttypes.models import ContentType
|
|
6
|
+
from django.core.files.storage import default_storage
|
|
7
|
+
from django.db import models as dbmodels
|
|
8
|
+
from django.forms.models import modelform_factory
|
|
9
|
+
from django.http import HttpResponse
|
|
10
|
+
from django.shortcuts import get_object_or_404
|
|
11
|
+
|
|
12
|
+
from .base import get_admin, json_err, json_ok, perm_request, read_body, staff_required
|
|
13
|
+
from ..components import fields as pro_fields
|
|
14
|
+
from .registry import build_form_fields_meta, form_field_names, m2m_field_names
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _log_action(user, obj, flag, message):
|
|
18
|
+
"""直接写入 LogEntry,Django 4.2 ~ 6.x 均兼容(log_action 管理器方法已在 6.0 移除)"""
|
|
19
|
+
LogEntry.objects.create(
|
|
20
|
+
user_id=user.pk,
|
|
21
|
+
content_type_id=ContentType.objects.get_for_model(obj).pk,
|
|
22
|
+
object_id=str(obj.pk),
|
|
23
|
+
object_repr=str(obj)[:200],
|
|
24
|
+
action_flag=flag,
|
|
25
|
+
change_message=message,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def save_view(request, model_key):
|
|
30
|
+
"""新增/修改:多对多走 ModelForm,文件/图片字段以存储后的路径字符串赋值"""
|
|
31
|
+
model, madmin = get_admin(model_key)
|
|
32
|
+
preq = perm_request(request.user)
|
|
33
|
+
body = read_body(request)
|
|
34
|
+
obj_id = body.get("id")
|
|
35
|
+
data = body.get("data") or {}
|
|
36
|
+
|
|
37
|
+
names = form_field_names(model) # 不含文件类字段
|
|
38
|
+
all_names = names + m2m_field_names(model)
|
|
39
|
+
instance = None
|
|
40
|
+
if obj_id:
|
|
41
|
+
instance = get_object_or_404(model, pk=obj_id)
|
|
42
|
+
if not madmin.has_change_permission(preq, instance):
|
|
43
|
+
return json_err("无修改权限", status=403)
|
|
44
|
+
else:
|
|
45
|
+
if not madmin.has_add_permission(preq):
|
|
46
|
+
return json_err("无新增权限", status=403)
|
|
47
|
+
|
|
48
|
+
Form = modelform_factory(model, fields=all_names)
|
|
49
|
+
payload = {k: v for k, v in data.items() if k in all_names}
|
|
50
|
+
# CheckboxField:前端以逗号分隔字符串提交,表单字段需要列表
|
|
51
|
+
for f in model._meta.concrete_fields:
|
|
52
|
+
if isinstance(f, pro_fields.CheckboxField) and isinstance(payload.get(f.name), str):
|
|
53
|
+
payload[f.name] = [v for v in payload[f.name].split(",") if v]
|
|
54
|
+
form = Form(data=payload, instance=instance)
|
|
55
|
+
if not form.is_valid():
|
|
56
|
+
return json_err(
|
|
57
|
+
"校验失败",
|
|
58
|
+
status=400,
|
|
59
|
+
errors={f: [str(m) for m in errs] for f, errs in form.errors.items()},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
obj = form.save(commit=False)
|
|
63
|
+
# 文件/图片字段:前端先上传拿到 URL,保存时写回相对路径(兼容带 MEDIA_URL 前缀的值)
|
|
64
|
+
from django.conf import settings as dj_settings
|
|
65
|
+
|
|
66
|
+
media_url = dj_settings.MEDIA_URL or ""
|
|
67
|
+
for f in model._meta.concrete_fields:
|
|
68
|
+
if f.name in data and f.editable and isinstance(f, dbmodels.FileField):
|
|
69
|
+
v = data[f.name] or ""
|
|
70
|
+
if isinstance(v, str) and media_url:
|
|
71
|
+
if v.startswith(media_url):
|
|
72
|
+
v = v[len(media_url):]
|
|
73
|
+
elif v.startswith("/" + media_url.lstrip("/")):
|
|
74
|
+
v = v[len(media_url) + 1:]
|
|
75
|
+
v = v.lstrip("/")
|
|
76
|
+
setattr(obj, f.name, v)
|
|
77
|
+
obj.save()
|
|
78
|
+
form.save_m2m()
|
|
79
|
+
if instance:
|
|
80
|
+
_log_action(request.user, obj, CHANGE, "通过 SimpleUI AntD 修改")
|
|
81
|
+
else:
|
|
82
|
+
_log_action(request.user, obj, ADDITION, "通过 SimpleUI AntD 新增")
|
|
83
|
+
return json_ok({"id": obj.pk, "__str__": str(obj)})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def delete_view(request, model_key):
|
|
87
|
+
model, madmin = get_admin(model_key)
|
|
88
|
+
if not madmin.has_delete_permission(perm_request(request.user)):
|
|
89
|
+
return json_err("无删除权限", status=403)
|
|
90
|
+
ids = read_body(request).get("ids") or []
|
|
91
|
+
deleted = 0
|
|
92
|
+
for obj in model._default_manager.filter(pk__in=ids):
|
|
93
|
+
_log_action(request.user, obj, DELETION, "")
|
|
94
|
+
obj.delete()
|
|
95
|
+
deleted += 1
|
|
96
|
+
return json_ok({"deleted": deleted})
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@staff_required
|
|
100
|
+
def upload(request):
|
|
101
|
+
"""文件/图片上传:multipart,保存到默认存储,返回 URL"""
|
|
102
|
+
if request.method != "POST":
|
|
103
|
+
return json_err("仅支持 POST", status=405)
|
|
104
|
+
f = request.FILES.get("file")
|
|
105
|
+
if not f:
|
|
106
|
+
return json_err("缺少文件")
|
|
107
|
+
model_key = request.POST.get("model") or ""
|
|
108
|
+
field_name = request.POST.get("field") or ""
|
|
109
|
+
try:
|
|
110
|
+
model, madmin = get_admin(model_key)
|
|
111
|
+
except Exception:
|
|
112
|
+
return json_err("模型不存在", status=404)
|
|
113
|
+
if not madmin.has_change_permission(perm_request(request.user)):
|
|
114
|
+
return json_err("无修改权限,不能上传", status=403)
|
|
115
|
+
try:
|
|
116
|
+
mfield = model._meta.get_field(field_name)
|
|
117
|
+
except Exception:
|
|
118
|
+
return json_err("字段不存在", status=404)
|
|
119
|
+
if not isinstance(mfield, dbmodels.FileField):
|
|
120
|
+
return json_err("该字段不是文件/图片字段", status=400)
|
|
121
|
+
|
|
122
|
+
path = default_storage.save(
|
|
123
|
+
f"simpleui_antd/{model._meta.app_label}/{model._meta.model_name}/{field_name}/{f.name}", f
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
url = default_storage.url(path)
|
|
127
|
+
except Exception:
|
|
128
|
+
url = path
|
|
129
|
+
return json_ok({"url": url, "path": path, "name": f.name, "size": f.size})
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------- 内联(InlineModelAdmin) ----------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _resolve_inline(madmin, inline_key):
|
|
136
|
+
"""inline_key 形如 '0:app.model',取该 ModelAdmin 的第 index 个 inline"""
|
|
137
|
+
try:
|
|
138
|
+
idx = int(inline_key.split(":", 1)[0])
|
|
139
|
+
except (ValueError, IndexError):
|
|
140
|
+
return None, None
|
|
141
|
+
if idx < 0 or idx >= len(madmin.inlines or []):
|
|
142
|
+
return None, None
|
|
143
|
+
cls = madmin.inlines[idx]
|
|
144
|
+
return cls, cls(madmin.model, admin_site())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def admin_site():
|
|
148
|
+
from django.contrib import admin
|
|
149
|
+
|
|
150
|
+
return admin.site
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _inline_fk(inst, child_model, parent_model):
|
|
154
|
+
from .registry import _inline_fk_name
|
|
155
|
+
|
|
156
|
+
return _inline_fk_name(inst, child_model, parent_model)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _inline_fields(inst, child_model, fk):
|
|
160
|
+
from .registry import build_form_fields_meta
|
|
161
|
+
|
|
162
|
+
return build_form_fields_meta(child_model, exclude_files=True, exclude_names=(fk,))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@staff_required
|
|
166
|
+
def inline_list(request, model_key, inline_key):
|
|
167
|
+
"""GET:某父记录的内联子记录列表"""
|
|
168
|
+
model, madmin = get_admin(model_key)
|
|
169
|
+
cls, inst = _resolve_inline(madmin, inline_key)
|
|
170
|
+
if inst is None:
|
|
171
|
+
return json_err("内联不存在", status=404)
|
|
172
|
+
child = inst.model
|
|
173
|
+
fk = _inline_fk(inst, child, model)
|
|
174
|
+
if not fk:
|
|
175
|
+
return json_err("内联外键无法确定", status=400)
|
|
176
|
+
preq = perm_request(request.user)
|
|
177
|
+
if not inst.has_view_permission(preq):
|
|
178
|
+
return json_err("无查看权限", status=403)
|
|
179
|
+
parent_id = request.GET.get("parent_id")
|
|
180
|
+
fields = _inline_fields(inst, child, fk)
|
|
181
|
+
qs = child._default_manager.filter(**{fk: parent_id}) if parent_id else child._default_manager.none()
|
|
182
|
+
items = [{"id": o.pk, "fields": {fd["key"]: _raw_value(o, fd) for fd in fields}} for o in qs]
|
|
183
|
+
return json_ok({"fields": fields, "items": items})
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _raw_value(obj, fd):
|
|
187
|
+
"""内联行值:日期/时间格式化为字符串,布尔原样,其余 str"""
|
|
188
|
+
import datetime as _dt
|
|
189
|
+
|
|
190
|
+
v = getattr(obj, fd["key"], None)
|
|
191
|
+
t = fd["type"]
|
|
192
|
+
if v is None:
|
|
193
|
+
return "" if t not in ("bool",) else False
|
|
194
|
+
if t == "datetime":
|
|
195
|
+
from django.utils import timezone
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
return timezone.localtime(v).strftime("%Y-%m-%d %H:%M:%S")
|
|
199
|
+
except (ValueError, OSError):
|
|
200
|
+
return v.strftime("%Y-%m-%d %H:%M:%S")
|
|
201
|
+
if t == "date":
|
|
202
|
+
return v.strftime("%Y-%m-%d")
|
|
203
|
+
if t == "time":
|
|
204
|
+
return v.strftime("%H:%M:%S")
|
|
205
|
+
if isinstance(v, bool):
|
|
206
|
+
return v
|
|
207
|
+
if isinstance(v, _dt.datetime):
|
|
208
|
+
return v.strftime("%Y-%m-%d %H:%M:%S")
|
|
209
|
+
return str(v)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@staff_required
|
|
213
|
+
def inline_save(request, model_key, inline_key):
|
|
214
|
+
"""POST:内联子记录批量保存 {parent_id, added, updated, deleted}"""
|
|
215
|
+
model, madmin = get_admin(model_key)
|
|
216
|
+
cls, inst = _resolve_inline(madmin, inline_key)
|
|
217
|
+
if inst is None:
|
|
218
|
+
return json_err("内联不存在", status=404)
|
|
219
|
+
child = inst.model
|
|
220
|
+
fk = _inline_fk(inst, child, model)
|
|
221
|
+
if not fk:
|
|
222
|
+
return json_err("内联外键无法确定", status=400)
|
|
223
|
+
preq = perm_request(request.user)
|
|
224
|
+
body = read_body(request)
|
|
225
|
+
parent_id = body.get("parent_id")
|
|
226
|
+
if not parent_id:
|
|
227
|
+
return json_err("缺少 parent_id")
|
|
228
|
+
parent_obj = model._default_manager.get(pk=parent_id)
|
|
229
|
+
fields = _inline_fields(inst, child, fk)
|
|
230
|
+
names = [f["key"] for f in fields]
|
|
231
|
+
|
|
232
|
+
added = updated = deleted = 0
|
|
233
|
+
for row in body.get("added") or []:
|
|
234
|
+
if not inst.has_add_permission(preq, None):
|
|
235
|
+
return json_err("无新增权限", status=403)
|
|
236
|
+
form = modelform_factory(child, fields=names)(data={k: v for k, v in row.items() if k in names})
|
|
237
|
+
if not form.is_valid():
|
|
238
|
+
return json_err("内联行校验失败", status=400, errors=form.errors)
|
|
239
|
+
obj = form.save(commit=False)
|
|
240
|
+
setattr(obj, fk, parent_obj)
|
|
241
|
+
obj.save()
|
|
242
|
+
_log_action(request.user, obj, ADDITION, "通过 SimpleUI AntD 内联新增")
|
|
243
|
+
added += 1
|
|
244
|
+
|
|
245
|
+
for row in body.get("updated") or []:
|
|
246
|
+
obj = get_object_or_404(child, pk=row.get("id"))
|
|
247
|
+
if not inst.has_change_permission(preq, obj):
|
|
248
|
+
return json_err("无修改权限", status=403)
|
|
249
|
+
form = modelform_factory(child, fields=names)(
|
|
250
|
+
data={k: v for k, v in row.items() if k in names}, instance=obj
|
|
251
|
+
)
|
|
252
|
+
if not form.is_valid():
|
|
253
|
+
return json_err("内联行校验失败", status=400, errors=form.errors)
|
|
254
|
+
obj = form.save()
|
|
255
|
+
_log_action(request.user, obj, CHANGE, "通过 SimpleUI AntD 内联修改")
|
|
256
|
+
updated += 1
|
|
257
|
+
|
|
258
|
+
for rid in body.get("deleted") or []:
|
|
259
|
+
obj = get_object_or_404(child, pk=rid)
|
|
260
|
+
if not inst.has_delete_permission(preq, obj):
|
|
261
|
+
return json_err("无删除权限", status=403)
|
|
262
|
+
_log_action(request.user, obj, DELETION, "")
|
|
263
|
+
obj.delete()
|
|
264
|
+
deleted += 1
|
|
265
|
+
|
|
266
|
+
return json_ok({"added": added, "updated": updated, "deleted": deleted})
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------- 批量 Action ----------------
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@staff_required
|
|
273
|
+
def action_view(request, model_key):
|
|
274
|
+
"""执行 ModelAdmin.actions(仅无中间页的 Action)"""
|
|
275
|
+
if request.method != "POST":
|
|
276
|
+
return json_err("仅支持 POST", status=405)
|
|
277
|
+
model, madmin = get_admin(model_key)
|
|
278
|
+
preq = perm_request(request.user)
|
|
279
|
+
body = read_body(request)
|
|
280
|
+
name = body.get("name")
|
|
281
|
+
ids = body.get("ids") or []
|
|
282
|
+
if not ids:
|
|
283
|
+
return json_err("请先勾选记录")
|
|
284
|
+
try:
|
|
285
|
+
actions = madmin.get_actions(preq)
|
|
286
|
+
except Exception:
|
|
287
|
+
actions = {}
|
|
288
|
+
if name not in actions:
|
|
289
|
+
return json_err("Action 不存在", status=404)
|
|
290
|
+
func = actions[name][0]
|
|
291
|
+
qs = madmin.get_queryset(request).filter(pk__in=ids)
|
|
292
|
+
try:
|
|
293
|
+
result = func(madmin, request, qs)
|
|
294
|
+
except Exception as e: # noqa: BLE001
|
|
295
|
+
return json_err(f"Action 执行失败:{e}")
|
|
296
|
+
if isinstance(result, HttpResponse):
|
|
297
|
+
return json_err("该 Action 需要跳转中间页,暂不支持", status=400)
|
|
298
|
+
msgs = [{"level": m.level_tag, "text": str(m.message)} for m in messages.get_messages(request)]
|
|
299
|
+
return json_ok({"messages": msgs})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""数据大屏接口:布局持久化 + 图表数据源"""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
|
|
5
|
+
from django.contrib.admin.models import LogEntry
|
|
6
|
+
from django.utils import timezone
|
|
7
|
+
|
|
8
|
+
from ..models import DashboardLayout
|
|
9
|
+
from .base import json_err, json_ok, read_body, staff_required
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@staff_required
|
|
13
|
+
def layout(request):
|
|
14
|
+
"""GET:读取当前用户大屏布局;PUT:保存拖拽后的布局"""
|
|
15
|
+
if request.method == "PUT":
|
|
16
|
+
body = read_body(request)
|
|
17
|
+
layout = body.get("layout")
|
|
18
|
+
if not isinstance(layout, list):
|
|
19
|
+
return json_err("layout 必须是数组")
|
|
20
|
+
clean = []
|
|
21
|
+
for w in layout:
|
|
22
|
+
if not isinstance(w, dict) or "id" not in w or "type" not in w:
|
|
23
|
+
continue
|
|
24
|
+
clean.append(
|
|
25
|
+
{
|
|
26
|
+
"id": str(w["id"])[:64],
|
|
27
|
+
"type": str(w["type"])[:32],
|
|
28
|
+
"title": str(w.get("title", ""))[:64],
|
|
29
|
+
"span": min(4, max(1, int(w.get("span", 2) or 2))),
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
obj, _ = DashboardLayout.objects.get_or_create(user=request.user)
|
|
33
|
+
obj.layout = clean
|
|
34
|
+
obj.save(update_fields=["layout", "updated_at"])
|
|
35
|
+
return json_ok({"saved": len(clean)})
|
|
36
|
+
|
|
37
|
+
obj = getattr(request.user, "simpleui_antd_dashboard", None)
|
|
38
|
+
return json_ok({"layout": obj.layout if obj else None})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@staff_required
|
|
42
|
+
def data(request):
|
|
43
|
+
"""图表数据源:模型记录数 + 近 7 日管理动作趋势"""
|
|
44
|
+
preq_items = []
|
|
45
|
+
from .base import perm_request
|
|
46
|
+
|
|
47
|
+
preq = perm_request(request.user)
|
|
48
|
+
stats = []
|
|
49
|
+
for model, madmin in admin_registry().items():
|
|
50
|
+
if not madmin.has_view_permission(preq):
|
|
51
|
+
continue
|
|
52
|
+
stats.append(
|
|
53
|
+
{
|
|
54
|
+
"key": f"{model._meta.app_label}.{model._meta.model_name}",
|
|
55
|
+
"label": str(model._meta.verbose_name),
|
|
56
|
+
"count": model._default_manager.count(),
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
stats.sort(key=lambda x: -x["count"])
|
|
60
|
+
|
|
61
|
+
today = timezone.localdate()
|
|
62
|
+
days = [today - datetime.timedelta(days=i) for i in range(6, -1, -1)]
|
|
63
|
+
activity = []
|
|
64
|
+
for d in days:
|
|
65
|
+
count = LogEntry.objects.filter(action_time__date=d).count()
|
|
66
|
+
activity.append({"date": d.strftime("%m-%d"), "count": count})
|
|
67
|
+
|
|
68
|
+
logs = []
|
|
69
|
+
flags = dict(LogEntry._meta.get_field("action_flag").choices)
|
|
70
|
+
for e in LogEntry.objects.select_related("content_type", "user").order_by("-action_time")[:6]:
|
|
71
|
+
who = str(e.user) if e.user else "系统"
|
|
72
|
+
logs.append(
|
|
73
|
+
{
|
|
74
|
+
"date": timezone.localtime(e.action_time).strftime("%Y年%m月%d日 %H:%M"),
|
|
75
|
+
"text": f'{who}: {flags.get(e.action_flag, "操作")} "{e.object_repr}"',
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
return json_ok({"stats": stats, "activity": activity, "logs": logs})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def admin_registry():
|
|
82
|
+
from django.contrib import admin
|
|
83
|
+
|
|
84
|
+
return admin.site._registry
|