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.
Files changed (94) hide show
  1. cndb/__init__.py +7 -0
  2. cndb/accounts/__init__.py +1 -0
  3. cndb/accounts/admin.py +16 -0
  4. cndb/accounts/apps.py +12 -0
  5. cndb/accounts/migrations/0001_initial.py +44 -0
  6. cndb/accounts/migrations/__init__.py +0 -0
  7. cndb/accounts/models.py +20 -0
  8. cndb/accounts/serializers.py +58 -0
  9. cndb/accounts/urls.py +14 -0
  10. cndb/accounts/views.py +59 -0
  11. cndb/asgi.py +11 -0
  12. cndb/py.typed +0 -0
  13. cndb/settings/__init__.py +1 -0
  14. cndb/settings/base.py +124 -0
  15. cndb/settings/dev.py +9 -0
  16. cndb/settings/prod.py +22 -0
  17. cndb/settings/test.py +16 -0
  18. cndb/tables/__init__.py +1 -0
  19. cndb/tables/access.py +161 -0
  20. cndb/tables/admin.py +23 -0
  21. cndb/tables/aggregations.py +129 -0
  22. cndb/tables/apps.py +12 -0
  23. cndb/tables/board_views.py +140 -0
  24. cndb/tables/ddl.py +110 -0
  25. cndb/tables/field_types/__init__.py +29 -0
  26. cndb/tables/field_types/base.py +63 -0
  27. cndb/tables/field_types/builtin.py +329 -0
  28. cndb/tables/field_types/registry.py +29 -0
  29. cndb/tables/form_urls.py +12 -0
  30. cndb/tables/form_views.py +96 -0
  31. cndb/tables/migrations/0001_initial.py +55 -0
  32. cndb/tables/migrations/0002_datafield_db_column_name.py +19 -0
  33. cndb/tables/migrations/0003_dataview.py +37 -0
  34. cndb/tables/migrations/0004_tablepermission.py +34 -0
  35. cndb/tables/migrations/0005_dataview_form_options_dataview_slug.py +23 -0
  36. cndb/tables/migrations/__init__.py +0 -0
  37. cndb/tables/models.py +237 -0
  38. cndb/tables/permission_rules.py +81 -0
  39. cndb/tables/permission_views.py +64 -0
  40. cndb/tables/query.py +185 -0
  41. cndb/tables/record_views.py +247 -0
  42. cndb/tables/records.py +226 -0
  43. cndb/tables/serializers.py +97 -0
  44. cndb/tables/services.py +88 -0
  45. cndb/tables/share_urls.py +11 -0
  46. cndb/tables/share_views.py +60 -0
  47. cndb/tables/sql.py +15 -0
  48. cndb/tables/transfer.py +150 -0
  49. cndb/tables/transfer_views.py +64 -0
  50. cndb/tables/urls.py +41 -0
  51. cndb/tables/view_rules.py +151 -0
  52. cndb/tables/view_views.py +138 -0
  53. cndb/tables/views.py +194 -0
  54. cndb/tokens/__init__.py +1 -0
  55. cndb/tokens/apps.py +12 -0
  56. cndb/tokens/authentication.py +36 -0
  57. cndb/tokens/migrations/0001_initial.py +34 -0
  58. cndb/tokens/migrations/__init__.py +0 -0
  59. cndb/tokens/models.py +61 -0
  60. cndb/tokens/serializers.py +16 -0
  61. cndb/tokens/urls.py +12 -0
  62. cndb/tokens/views.py +37 -0
  63. cndb/urls.py +16 -0
  64. cndb/webui/__init__.py +1 -0
  65. cndb/webui/apps.py +12 -0
  66. cndb/webui/static/webui/actions.js +1293 -0
  67. cndb/webui/static/webui/app.js +540 -0
  68. cndb/webui/static/webui/auth.js +54 -0
  69. cndb/webui/static/webui/form.js +59 -0
  70. cndb/webui/static/webui/share.js +56 -0
  71. cndb/webui/static/webui/style.css +730 -0
  72. cndb/webui/templates/webui/form.html +51 -0
  73. cndb/webui/templates/webui/index.html +94 -0
  74. cndb/webui/templates/webui/login.html +40 -0
  75. cndb/webui/templates/webui/share.html +33 -0
  76. cndb/webui/templatetags/__init__.py +0 -0
  77. cndb/webui/templatetags/static_version.py +25 -0
  78. cndb/webui/urls.py +14 -0
  79. cndb/webui/views.py +83 -0
  80. cndb/workspaces/__init__.py +1 -0
  81. cndb/workspaces/admin.py +22 -0
  82. cndb/workspaces/apps.py +12 -0
  83. cndb/workspaces/migrations/0001_initial.py +48 -0
  84. cndb/workspaces/migrations/__init__.py +0 -0
  85. cndb/workspaces/models.py +70 -0
  86. cndb/workspaces/permissions.py +46 -0
  87. cndb/workspaces/serializers.py +51 -0
  88. cndb/workspaces/urls.py +24 -0
  89. cndb/workspaces/views.py +143 -0
  90. cndb/wsgi.py +11 -0
  91. cndb-0.1.1.dist-info/METADATA +169 -0
  92. cndb-0.1.1.dist-info/RECORD +94 -0
  93. cndb-0.1.1.dist-info/WHEEL +4 -0
  94. cndb-0.1.1.dist-info/licenses/LICENSE +21 -0
cndb/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """cndb - 通用数据库管理平台。."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["__version__"]
6
+
7
+ __version__ = "0.1.1"
@@ -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,12 @@
1
+ """accounts 应用配置."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from django.apps import AppConfig
6
+
7
+
8
+ class AccountsConfig(AppConfig):
9
+ """用户与认证应用配置."""
10
+
11
+ name = "cndb.accounts"
12
+ verbose_name = "用户"
@@ -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
@@ -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
@@ -0,0 +1,11 @@
1
+ """ASGI 入口."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from django.core.asgi import get_asgi_application
8
+
9
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cndb.settings.prod")
10
+
11
+ application = get_asgi_application()
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
@@ -0,0 +1,9 @@
1
+ """开发环境配置."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import *
6
+
7
+ DEBUG = True
8
+
9
+ ALLOWED_HOSTS = ["*"]
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"]
@@ -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")