cndb 0.1.1__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.
- cndb/__init__.py +7 -0
- cndb/accounts/__init__.py +1 -0
- cndb/accounts/admin.py +16 -0
- cndb/accounts/apps.py +12 -0
- cndb/accounts/migrations/0001_initial.py +44 -0
- cndb/accounts/migrations/__init__.py +0 -0
- cndb/accounts/models.py +20 -0
- cndb/accounts/serializers.py +58 -0
- cndb/accounts/urls.py +14 -0
- cndb/accounts/views.py +59 -0
- cndb/asgi.py +11 -0
- cndb/py.typed +0 -0
- cndb/settings/__init__.py +1 -0
- cndb/settings/base.py +124 -0
- cndb/settings/dev.py +9 -0
- cndb/settings/prod.py +22 -0
- cndb/settings/test.py +16 -0
- cndb/tables/__init__.py +1 -0
- cndb/tables/access.py +161 -0
- cndb/tables/admin.py +23 -0
- cndb/tables/aggregations.py +129 -0
- cndb/tables/apps.py +12 -0
- cndb/tables/board_views.py +140 -0
- cndb/tables/ddl.py +110 -0
- cndb/tables/field_types/__init__.py +29 -0
- cndb/tables/field_types/base.py +63 -0
- cndb/tables/field_types/builtin.py +329 -0
- cndb/tables/field_types/registry.py +29 -0
- cndb/tables/form_urls.py +12 -0
- cndb/tables/form_views.py +96 -0
- cndb/tables/migrations/0001_initial.py +55 -0
- cndb/tables/migrations/0002_datafield_db_column_name.py +19 -0
- cndb/tables/migrations/0003_dataview.py +37 -0
- cndb/tables/migrations/0004_tablepermission.py +34 -0
- cndb/tables/migrations/0005_dataview_form_options_dataview_slug.py +23 -0
- cndb/tables/migrations/__init__.py +0 -0
- cndb/tables/models.py +237 -0
- cndb/tables/permission_rules.py +81 -0
- cndb/tables/permission_views.py +64 -0
- cndb/tables/query.py +185 -0
- cndb/tables/record_views.py +247 -0
- cndb/tables/records.py +226 -0
- cndb/tables/serializers.py +97 -0
- cndb/tables/services.py +88 -0
- cndb/tables/share_urls.py +11 -0
- cndb/tables/share_views.py +60 -0
- cndb/tables/sql.py +15 -0
- cndb/tables/transfer.py +150 -0
- cndb/tables/transfer_views.py +64 -0
- cndb/tables/urls.py +41 -0
- cndb/tables/view_rules.py +151 -0
- cndb/tables/view_views.py +138 -0
- cndb/tables/views.py +194 -0
- cndb/tokens/__init__.py +1 -0
- cndb/tokens/apps.py +12 -0
- cndb/tokens/authentication.py +36 -0
- cndb/tokens/migrations/0001_initial.py +34 -0
- cndb/tokens/migrations/__init__.py +0 -0
- cndb/tokens/models.py +61 -0
- cndb/tokens/serializers.py +16 -0
- cndb/tokens/urls.py +12 -0
- cndb/tokens/views.py +37 -0
- cndb/urls.py +16 -0
- cndb/webui/__init__.py +1 -0
- cndb/webui/apps.py +12 -0
- cndb/webui/static/webui/actions.js +1293 -0
- cndb/webui/static/webui/app.js +540 -0
- cndb/webui/static/webui/auth.js +54 -0
- cndb/webui/static/webui/form.js +59 -0
- cndb/webui/static/webui/share.js +56 -0
- cndb/webui/static/webui/style.css +730 -0
- cndb/webui/templates/webui/form.html +51 -0
- cndb/webui/templates/webui/index.html +94 -0
- cndb/webui/templates/webui/login.html +40 -0
- cndb/webui/templates/webui/share.html +33 -0
- cndb/webui/templatetags/__init__.py +0 -0
- cndb/webui/templatetags/static_version.py +25 -0
- cndb/webui/urls.py +14 -0
- cndb/webui/views.py +83 -0
- cndb/workspaces/__init__.py +1 -0
- cndb/workspaces/admin.py +22 -0
- cndb/workspaces/apps.py +12 -0
- cndb/workspaces/migrations/0001_initial.py +48 -0
- cndb/workspaces/migrations/__init__.py +0 -0
- cndb/workspaces/models.py +70 -0
- cndb/workspaces/permissions.py +46 -0
- cndb/workspaces/serializers.py +51 -0
- cndb/workspaces/urls.py +24 -0
- cndb/workspaces/views.py +143 -0
- cndb/wsgi.py +11 -0
- cndb-0.1.1.dist-info/METADATA +169 -0
- cndb-0.1.1.dist-info/RECORD +94 -0
- cndb-0.1.1.dist-info/WHEEL +4 -0
- cndb-0.1.1.dist-info/licenses/LICENSE +21 -0
cndb/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""accounts - 用户与认证应用."""
|
cndb/accounts/admin.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""用户后台注册."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.contrib import admin
|
|
6
|
+
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
|
7
|
+
|
|
8
|
+
from cndb.accounts.models import User
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@admin.register(User)
|
|
12
|
+
class UserAdmin(DjangoUserAdmin):
|
|
13
|
+
"""用户后台:补充昵称展示."""
|
|
14
|
+
|
|
15
|
+
list_display = ("id", "username", "nickname", "email", "is_active", "date_joined")
|
|
16
|
+
fieldsets = (*DjangoUserAdmin.fieldsets, ("扩展信息", {"fields": ("nickname",)}))
|
cndb/accounts/apps.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Generated by Django 5.2.17 on 2026-09-01 12:31
|
|
2
|
+
|
|
3
|
+
import django.contrib.auth.models
|
|
4
|
+
import django.contrib.auth.validators
|
|
5
|
+
import django.utils.timezone
|
|
6
|
+
from django.db import migrations, models
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Migration(migrations.Migration):
|
|
10
|
+
|
|
11
|
+
initial = True
|
|
12
|
+
|
|
13
|
+
dependencies = [
|
|
14
|
+
('auth', '0012_alter_user_first_name_max_length'),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
operations = [
|
|
18
|
+
migrations.CreateModel(
|
|
19
|
+
name='User',
|
|
20
|
+
fields=[
|
|
21
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
22
|
+
('password', models.CharField(max_length=128, verbose_name='password')),
|
|
23
|
+
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
|
24
|
+
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
|
25
|
+
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
|
26
|
+
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
|
27
|
+
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
|
28
|
+
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
|
29
|
+
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
|
30
|
+
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
|
31
|
+
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
|
32
|
+
('nickname', models.CharField(blank=True, max_length=150, verbose_name='昵称')),
|
|
33
|
+
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
|
34
|
+
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
|
35
|
+
],
|
|
36
|
+
options={
|
|
37
|
+
'verbose_name': '用户',
|
|
38
|
+
'verbose_name_plural': '用户',
|
|
39
|
+
},
|
|
40
|
+
managers=[
|
|
41
|
+
('objects', django.contrib.auth.models.UserManager()),
|
|
42
|
+
],
|
|
43
|
+
),
|
|
44
|
+
]
|
|
File without changes
|
cndb/accounts/models.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""用户模型."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.contrib.auth.models import AbstractUser
|
|
6
|
+
from django.db import models
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class User(AbstractUser):
|
|
10
|
+
"""平台用户:扩展 Django 内置用户,增加昵称用于界面展示."""
|
|
11
|
+
|
|
12
|
+
nickname = models.CharField("昵称", max_length=150, blank=True)
|
|
13
|
+
|
|
14
|
+
class Meta:
|
|
15
|
+
verbose_name = "用户"
|
|
16
|
+
verbose_name_plural = "用户"
|
|
17
|
+
|
|
18
|
+
def __str__(self) -> str:
|
|
19
|
+
"""返回用户名便于后台展示."""
|
|
20
|
+
return self.username # type: ignore[bad-return]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""用户序列化器:注册、登录与个人信息."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.contrib.auth import authenticate
|
|
6
|
+
from django.contrib.auth.password_validation import validate_password
|
|
7
|
+
from rest_framework import serializers
|
|
8
|
+
from rest_framework.exceptions import AuthenticationFailed
|
|
9
|
+
|
|
10
|
+
from cndb.accounts.models import User
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RegisterSerializer(serializers.ModelSerializer):
|
|
14
|
+
"""注册请求:创建用户并校验密码强度."""
|
|
15
|
+
|
|
16
|
+
password = serializers.CharField(write_only=True, trim_whitespace=False, label="密码")
|
|
17
|
+
|
|
18
|
+
class Meta:
|
|
19
|
+
model = User
|
|
20
|
+
fields = ("id", "username", "email", "nickname", "password")
|
|
21
|
+
read_only_fields = ("id",)
|
|
22
|
+
extra_kwargs = {"email": {"required": True}}
|
|
23
|
+
|
|
24
|
+
def validate_password(self, value: str) -> str:
|
|
25
|
+
"""按全局密码校验器检查强度."""
|
|
26
|
+
validate_password(value)
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
def create(self, validated_data: dict[str, object]) -> User:
|
|
30
|
+
"""使用 create_user 完成密码哈希后落库."""
|
|
31
|
+
return User.objects.create_user(**validated_data) # type: ignore[arg-type]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LoginSerializer(serializers.Serializer):
|
|
35
|
+
"""登录请求:校验用户名与密码."""
|
|
36
|
+
|
|
37
|
+
username = serializers.CharField(label="用户名")
|
|
38
|
+
password = serializers.CharField(write_only=True, trim_whitespace=False, label="密码")
|
|
39
|
+
|
|
40
|
+
def validate(self, attrs: dict[str, str]) -> dict[str, object]:
|
|
41
|
+
"""认证失败统一抛 AuthenticationFailed,避免泄露具体原因."""
|
|
42
|
+
user = authenticate(
|
|
43
|
+
request=self.context.get("request"),
|
|
44
|
+
username=attrs["username"],
|
|
45
|
+
password=attrs["password"],
|
|
46
|
+
)
|
|
47
|
+
if user is None:
|
|
48
|
+
raise AuthenticationFailed("用户名或密码错误")
|
|
49
|
+
return {**attrs, "user": user}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class UserSerializer(serializers.ModelSerializer):
|
|
53
|
+
"""用户信息:注册响应与个人信息读写共用."""
|
|
54
|
+
|
|
55
|
+
class Meta:
|
|
56
|
+
model = User
|
|
57
|
+
fields = ("id", "username", "email", "nickname", "date_joined")
|
|
58
|
+
read_only_fields = ("id", "username", "date_joined")
|
cndb/accounts/urls.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""accounts 路由."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.urls import path
|
|
6
|
+
|
|
7
|
+
from cndb.accounts.views import LoginView, LogoutView, MeView, RegisterView
|
|
8
|
+
|
|
9
|
+
urlpatterns = [
|
|
10
|
+
path("register/", RegisterView.as_view(), name="auth-register"),
|
|
11
|
+
path("login/", LoginView.as_view(), name="auth-login"),
|
|
12
|
+
path("logout/", LogoutView.as_view(), name="auth-logout"),
|
|
13
|
+
path("me/", MeView.as_view(), name="auth-me"),
|
|
14
|
+
]
|
cndb/accounts/views.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""认证 API 视图:注册、登录、登出与个人信息."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.contrib.auth import login, logout
|
|
6
|
+
from rest_framework import generics, permissions
|
|
7
|
+
from rest_framework.request import Request
|
|
8
|
+
from rest_framework.response import Response
|
|
9
|
+
from rest_framework.views import APIView
|
|
10
|
+
|
|
11
|
+
from cndb.accounts.models import User
|
|
12
|
+
from cndb.accounts.serializers import LoginSerializer, RegisterSerializer, UserSerializer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RegisterView(generics.CreateAPIView):
|
|
16
|
+
"""注册:创建用户并直接建立会话."""
|
|
17
|
+
|
|
18
|
+
permission_classes = [permissions.AllowAny]
|
|
19
|
+
serializer_class = RegisterSerializer
|
|
20
|
+
|
|
21
|
+
def create(self, request: Request, *_args: object, **_kwargs: object) -> Response:
|
|
22
|
+
"""校验、创建用户并登录会话,返回用户信息."""
|
|
23
|
+
serializer = self.get_serializer(data=request.data)
|
|
24
|
+
serializer.is_valid(raise_exception=True)
|
|
25
|
+
user = serializer.save()
|
|
26
|
+
login(request, user)
|
|
27
|
+
return Response(UserSerializer(user).data, status=201)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LoginView(APIView):
|
|
31
|
+
"""登录:校验凭证并建立会话."""
|
|
32
|
+
|
|
33
|
+
permission_classes = [permissions.AllowAny]
|
|
34
|
+
|
|
35
|
+
def post(self, request: Request) -> Response:
|
|
36
|
+
"""校验用户名密码,成功返回用户信息."""
|
|
37
|
+
serializer = LoginSerializer(data=request.data, context={"request": request})
|
|
38
|
+
serializer.is_valid(raise_exception=True)
|
|
39
|
+
login(request, serializer.validated_data["user"]) # type: ignore[arg-type]
|
|
40
|
+
return Response(UserSerializer(serializer.validated_data["user"]).data) # type: ignore[arg-type]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LogoutView(APIView):
|
|
44
|
+
"""登出:销毁当前会话."""
|
|
45
|
+
|
|
46
|
+
def post(self, request: Request) -> Response:
|
|
47
|
+
"""销毁会话并返回 204."""
|
|
48
|
+
logout(request)
|
|
49
|
+
return Response(status=204)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class MeView(generics.RetrieveUpdateAPIView):
|
|
53
|
+
"""个人信息:读取与更新昵称、邮箱."""
|
|
54
|
+
|
|
55
|
+
serializer_class = UserSerializer
|
|
56
|
+
|
|
57
|
+
def get_object(self) -> User:
|
|
58
|
+
"""当前登录用户."""
|
|
59
|
+
return self.request.user # type: ignore[return-value]
|
cndb/asgi.py
ADDED
cndb/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Django 配置包:按环境拆分为 base/dev/test/prod 模块."""
|
cndb/settings/base.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Django 基础配置:所有环境共享."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# 项目根目录(src layout:settings/base.py 上溯 4 级到仓库根)
|
|
9
|
+
BASE_DIR = Path(__file__).resolve().parents[3]
|
|
10
|
+
|
|
11
|
+
# 安全密钥:生产环境必须通过环境变量注入,开发环境使用不安全默认值
|
|
12
|
+
SECRET_KEY = os.environ.get("CNDB_SECRET_KEY", "dev-insecure-secret-key")
|
|
13
|
+
|
|
14
|
+
DEBUG = False
|
|
15
|
+
|
|
16
|
+
ALLOWED_HOSTS: list[str] = []
|
|
17
|
+
|
|
18
|
+
INSTALLED_APPS = [
|
|
19
|
+
"django.contrib.admin",
|
|
20
|
+
"django.contrib.auth",
|
|
21
|
+
"django.contrib.contenttypes",
|
|
22
|
+
"django.contrib.sessions",
|
|
23
|
+
"django.contrib.messages",
|
|
24
|
+
"django.contrib.staticfiles",
|
|
25
|
+
# 三方
|
|
26
|
+
"rest_framework",
|
|
27
|
+
# 平台应用
|
|
28
|
+
"cndb.accounts",
|
|
29
|
+
"cndb.workspaces",
|
|
30
|
+
"cndb.tables",
|
|
31
|
+
"cndb.tokens",
|
|
32
|
+
"cndb.webui",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
MIDDLEWARE = [
|
|
36
|
+
"django.middleware.security.SecurityMiddleware",
|
|
37
|
+
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
38
|
+
"django.middleware.common.CommonMiddleware",
|
|
39
|
+
"django.middleware.csrf.CsrfViewMiddleware",
|
|
40
|
+
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
41
|
+
"django.contrib.messages.middleware.MessageMiddleware",
|
|
42
|
+
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
ROOT_URLCONF = "cndb.urls"
|
|
46
|
+
|
|
47
|
+
TEMPLATES = [
|
|
48
|
+
{
|
|
49
|
+
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
50
|
+
"APP_DIRS": True,
|
|
51
|
+
"OPTIONS": {
|
|
52
|
+
"context_processors": [
|
|
53
|
+
"django.template.context_processors.request",
|
|
54
|
+
"django.contrib.auth.context_processors.auth",
|
|
55
|
+
"django.contrib.messages.context_processors.messages",
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
WSGI_APPLICATION = "cndb.wsgi.application"
|
|
62
|
+
ASGI_APPLICATION = "cndb.asgi.application"
|
|
63
|
+
|
|
64
|
+
# 数据库:默认 SQLite(开发/测试),CNDB_DB=postgres 切换 PostgreSQL
|
|
65
|
+
if os.environ.get("CNDB_DB", "sqlite").lower() == "postgres":
|
|
66
|
+
DATABASES = {
|
|
67
|
+
"default": {
|
|
68
|
+
"ENGINE": "django.db.backends.postgresql",
|
|
69
|
+
"NAME": os.environ.get("CNDB_DB_NAME", "cndb"),
|
|
70
|
+
"USER": os.environ.get("CNDB_DB_USER", "cndb"),
|
|
71
|
+
"PASSWORD": os.environ.get("CNDB_DB_PASSWORD", ""),
|
|
72
|
+
"HOST": os.environ.get("CNDB_DB_HOST", "localhost"),
|
|
73
|
+
"PORT": os.environ.get("CNDB_DB_PORT", "5432"),
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
else:
|
|
77
|
+
_SQLITE_PATH = BASE_DIR / "data" / "cndb.sqlite3"
|
|
78
|
+
_SQLITE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
DATABASES = {
|
|
80
|
+
"default": {
|
|
81
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
82
|
+
"NAME": _SQLITE_PATH,
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# 自定义用户模型
|
|
87
|
+
AUTH_USER_MODEL = "accounts.User"
|
|
88
|
+
|
|
89
|
+
# 登录页地址:未登录访问主应用时重定向到 webui 登录页
|
|
90
|
+
LOGIN_URL = "/login/"
|
|
91
|
+
|
|
92
|
+
AUTH_PASSWORD_VALIDATORS = [
|
|
93
|
+
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
|
94
|
+
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
|
95
|
+
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
|
96
|
+
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
LANGUAGE_CODE = "zh-hans"
|
|
100
|
+
|
|
101
|
+
TIME_ZONE = "Asia/Shanghai"
|
|
102
|
+
|
|
103
|
+
USE_I18N = True
|
|
104
|
+
|
|
105
|
+
USE_TZ = True
|
|
106
|
+
|
|
107
|
+
STATIC_URL = "static/"
|
|
108
|
+
|
|
109
|
+
STATIC_ROOT = BASE_DIR / "staticfiles"
|
|
110
|
+
|
|
111
|
+
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|
112
|
+
|
|
113
|
+
# DRF 全局配置:会话 + Token 认证,默认需登录,分页每页 50 条
|
|
114
|
+
REST_FRAMEWORK = {
|
|
115
|
+
"DEFAULT_AUTHENTICATION_CLASSES": [
|
|
116
|
+
"cndb.tokens.authentication.TokenAuthentication",
|
|
117
|
+
"rest_framework.authentication.SessionAuthentication",
|
|
118
|
+
],
|
|
119
|
+
"DEFAULT_PERMISSION_CLASSES": [
|
|
120
|
+
"rest_framework.permissions.IsAuthenticated",
|
|
121
|
+
],
|
|
122
|
+
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
|
123
|
+
"PAGE_SIZE": 50,
|
|
124
|
+
}
|
cndb/settings/dev.py
ADDED
cndb/settings/prod.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""生产环境配置."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
8
|
+
|
|
9
|
+
from .base import *
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _require_secret_key() -> str:
|
|
13
|
+
"""生产环境必须通过环境变量提供密钥,缺失即拒绝启动。"""
|
|
14
|
+
key = os.environ.get("CNDB_SECRET_KEY")
|
|
15
|
+
if not key:
|
|
16
|
+
raise ImproperlyConfigured("生产环境必须设置环境变量 CNDB_SECRET_KEY")
|
|
17
|
+
return key
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
SECRET_KEY = _require_secret_key()
|
|
21
|
+
|
|
22
|
+
ALLOWED_HOSTS = [h for h in os.environ.get("CNDB_ALLOWED_HOSTS", "").split(",") if h]
|
cndb/settings/test.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""测试环境配置:SQLite 内存库 + 快速密码哈希."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import *
|
|
6
|
+
|
|
7
|
+
DEBUG = False
|
|
8
|
+
|
|
9
|
+
DATABASES = {
|
|
10
|
+
"default": {
|
|
11
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
12
|
+
"NAME": ":memory:",
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
|
cndb/tables/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""tables - 数据表元数据中心应用."""
|
cndb/tables/access.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""表级访问策略链:动作定义、策略协议与链式裁决.
|
|
2
|
+
|
|
3
|
+
策略链是表域权限的统一入口:动作(TableAction)声明"做什么",
|
|
4
|
+
链上各策略(TableAccessPolicy)独立裁决"能否做",任一环节拒绝即短路。
|
|
5
|
+
后续行级/字段级权限、Token 范围等以新策略环节接入,不改既有调用方.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import Enum, auto
|
|
13
|
+
|
|
14
|
+
from django.contrib.auth.models import AbstractBaseUser
|
|
15
|
+
|
|
16
|
+
from cndb.tables.models import DataTable, TablePermission
|
|
17
|
+
from cndb.tables.query import RowQuery, compile_filters
|
|
18
|
+
from cndb.workspaces.models import Workspace, WorkspaceMember
|
|
19
|
+
from cndb.workspaces.permissions import get_member_role, has_role
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TableAction(Enum):
|
|
23
|
+
"""表级动作集合:读写分离,为后续行级/字段级扩展预留粒度."""
|
|
24
|
+
|
|
25
|
+
READ = auto()
|
|
26
|
+
EDIT_RECORDS = auto()
|
|
27
|
+
EDIT_VIEWS = auto()
|
|
28
|
+
EDIT_SCHEMA = auto()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class AccessVerdict:
|
|
33
|
+
"""策略裁决结果:拒绝时 reason 为对外可见的原因."""
|
|
34
|
+
|
|
35
|
+
allowed: bool
|
|
36
|
+
reason: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class AccessContext:
|
|
41
|
+
"""访问上下文:工作区必有,动作落在具体表时才有表."""
|
|
42
|
+
|
|
43
|
+
workspace: Workspace
|
|
44
|
+
table: DataTable | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TableAccessPolicy(ABC):
|
|
48
|
+
"""访问策略协议:链上每一环独立裁决."""
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def check(self, user: AbstractBaseUser | None, context: AccessContext, action: TableAction) -> AccessVerdict:
|
|
52
|
+
"""对给定用户、上下文与动作给出裁决."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class WorkspaceRolePolicy(TableAccessPolicy):
|
|
56
|
+
"""工作区角色策略:动作须达到该动作的最低角色."""
|
|
57
|
+
|
|
58
|
+
_MIN_ROLE: dict[TableAction, str] = {
|
|
59
|
+
TableAction.READ: WorkspaceMember.Role.VIEWER,
|
|
60
|
+
TableAction.EDIT_RECORDS: WorkspaceMember.Role.EDITOR,
|
|
61
|
+
TableAction.EDIT_VIEWS: WorkspaceMember.Role.EDITOR,
|
|
62
|
+
TableAction.EDIT_SCHEMA: WorkspaceMember.Role.EDITOR,
|
|
63
|
+
}
|
|
64
|
+
_DENY_REASON: dict[TableAction, str] = {
|
|
65
|
+
TableAction.READ: "需要工作区成员身份",
|
|
66
|
+
TableAction.EDIT_RECORDS: "需要编辑者及以上权限",
|
|
67
|
+
TableAction.EDIT_VIEWS: "需要编辑者及以上权限",
|
|
68
|
+
TableAction.EDIT_SCHEMA: "需要编辑者及以上权限",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
def check(self, user: AbstractBaseUser | None, context: AccessContext, action: TableAction) -> AccessVerdict:
|
|
72
|
+
"""按动作最低角色裁决,非成员一律拒绝."""
|
|
73
|
+
minimum = self._MIN_ROLE[action]
|
|
74
|
+
if has_role(user, context.workspace, minimum):
|
|
75
|
+
return AccessVerdict(True)
|
|
76
|
+
return AccessVerdict(False, self._DENY_REASON[action])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TableOverridePolicy(TableAccessPolicy):
|
|
80
|
+
"""表级角色覆盖策略:表权限对象对动作收紧最低角色,OWNER 豁免."""
|
|
81
|
+
|
|
82
|
+
_OVERRIDE_FIELD: dict[TableAction, str] = {
|
|
83
|
+
TableAction.READ: "read_role",
|
|
84
|
+
TableAction.EDIT_RECORDS: "edit_records_role",
|
|
85
|
+
TableAction.EDIT_VIEWS: "edit_views_role",
|
|
86
|
+
TableAction.EDIT_SCHEMA: "edit_schema_role",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
def check(self, user: AbstractBaseUser | None, context: AccessContext, action: TableAction) -> AccessVerdict:
|
|
90
|
+
"""表无权限对象或动作未覆盖时放行;覆盖则按覆盖角色裁决,OWNER 豁免."""
|
|
91
|
+
table = context.table
|
|
92
|
+
if table is None:
|
|
93
|
+
return AccessVerdict(True)
|
|
94
|
+
if get_member_role(user, context.workspace) == WorkspaceMember.Role.OWNER:
|
|
95
|
+
return AccessVerdict(True)
|
|
96
|
+
permission = TablePermission.objects.filter(table=table).only(*self._OVERRIDE_FIELD.values()).first()
|
|
97
|
+
if permission is None:
|
|
98
|
+
return AccessVerdict(True)
|
|
99
|
+
override = str(getattr(permission, self._OVERRIDE_FIELD[action]) or "")
|
|
100
|
+
if not override:
|
|
101
|
+
return AccessVerdict(True)
|
|
102
|
+
if has_role(user, context.workspace, override):
|
|
103
|
+
return AccessVerdict(True)
|
|
104
|
+
return AccessVerdict(False, "当前角色不足以访问该表")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# 策略链:按序裁决,任一环节拒绝即短路;后续权限维度以新环节追加
|
|
108
|
+
POLICIES: list[TableAccessPolicy] = [WorkspaceRolePolicy(), TableOverridePolicy()]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def check_access(user: AbstractBaseUser | None, context: AccessContext, action: TableAction) -> AccessVerdict:
|
|
112
|
+
"""链式裁决入口:遍历策略链,首个拒绝环节的结果即最终结果."""
|
|
113
|
+
for policy in POLICIES:
|
|
114
|
+
verdict = policy.check(user, context, action)
|
|
115
|
+
if not verdict.allowed:
|
|
116
|
+
return verdict
|
|
117
|
+
return AccessVerdict(True)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def check_table_access(user: AbstractBaseUser | None, table: DataTable, action: TableAction) -> AccessVerdict:
|
|
121
|
+
"""表级动作裁决便捷入口."""
|
|
122
|
+
return check_access(user, AccessContext(workspace=table.workspace, table=table), action) # type: ignore[bad-argument-type]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def check_workspace_access(user: AbstractBaseUser | None, workspace: Workspace, action: TableAction) -> AccessVerdict:
|
|
126
|
+
"""工作区级动作裁决便捷入口(如建表,表尚不存在)."""
|
|
127
|
+
return check_access(user, AccessContext(workspace=workspace), action)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _table_permission(table: DataTable) -> TablePermission | None:
|
|
131
|
+
"""读取表权限对象,无则返回 None."""
|
|
132
|
+
return TablePermission.objects.filter(table=table).first()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def row_scope(user: AbstractBaseUser | None, table: DataTable) -> RowQuery | None:
|
|
136
|
+
"""行级访问范围:表权限配置了行级过滤时编译为 RowQuery(AND 并入业务查询).
|
|
137
|
+
|
|
138
|
+
OWNER 不受行级过滤限制;规则编译失败视为无限制(保存期已校验,此处仅防御).
|
|
139
|
+
"""
|
|
140
|
+
permission = _table_permission(table)
|
|
141
|
+
if permission is None or not permission.row_filters:
|
|
142
|
+
return None
|
|
143
|
+
if get_member_role(user, table.workspace) == WorkspaceMember.Role.OWNER: # type: ignore[bad-argument-type]
|
|
144
|
+
return None
|
|
145
|
+
where, params = compile_filters(table, permission.row_filters, str(permission.row_filter_type)) # type: ignore[bad-argument-type]
|
|
146
|
+
return RowQuery(where=where, params=params)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def hidden_field_names(user: AbstractBaseUser | None, table: DataTable) -> set[str]:
|
|
150
|
+
"""字段级隐藏:返回当前用户角色不可见的字段名集合(OWNER 全可见)."""
|
|
151
|
+
permission = _table_permission(table)
|
|
152
|
+
if permission is None or not permission.hidden_fields:
|
|
153
|
+
return set()
|
|
154
|
+
role = get_member_role(user, table.workspace) # type: ignore[bad-argument-type]
|
|
155
|
+
if role == WorkspaceMember.Role.OWNER:
|
|
156
|
+
return set()
|
|
157
|
+
hidden: set[str] = set()
|
|
158
|
+
for field_name, minimum in permission.hidden_fields.items():
|
|
159
|
+
if role is None or not has_role(user, table.workspace, str(minimum)): # type: ignore[bad-argument-type]
|
|
160
|
+
hidden.add(str(field_name))
|
|
161
|
+
return hidden
|
cndb/tables/admin.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""数据表后台注册."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.contrib import admin
|
|
6
|
+
|
|
7
|
+
from cndb.tables.models import DataField, DataTable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@admin.register(DataTable)
|
|
11
|
+
class DataTableAdmin(admin.ModelAdmin):
|
|
12
|
+
"""数据表后台."""
|
|
13
|
+
|
|
14
|
+
list_display = ("id", "name", "workspace", "db_table_name", "trashed", "created_on")
|
|
15
|
+
list_filter = ("trashed",)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@admin.register(DataField)
|
|
19
|
+
class DataFieldAdmin(admin.ModelAdmin):
|
|
20
|
+
"""数据字段后台."""
|
|
21
|
+
|
|
22
|
+
list_display = ("id", "name", "table", "field_type", "required", "trashed", "order")
|
|
23
|
+
list_filter = ("field_type", "required", "trashed")
|