crudkit 0.2.0__tar.gz → 0.2.2__tar.gz
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.
- {crudkit-0.2.0 → crudkit-0.2.2}/CHANGELOG.md +24 -1
- {crudkit-0.2.0 → crudkit-0.2.2}/PKG-INFO +14 -2
- {crudkit-0.2.0 → crudkit-0.2.2}/README.md +13 -1
- {crudkit-0.2.0 → crudkit-0.2.2}/pyproject.toml +1 -1
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/apps.py +1 -0
- crudkit-0.2.2/src/crudkit/authorization.py +54 -0
- crudkit-0.2.2/src/crudkit/checks.py +43 -0
- crudkit-0.2.2/src/crudkit/migrations/0002_workspace.py +83 -0
- crudkit-0.2.2/src/crudkit/migrations/0003_enforce_data_invariants.py +57 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/models.py +89 -21
- crudkit-0.2.2/src/crudkit/tests/test_integrity.py +100 -0
- crudkit-0.2.2/src/crudkit/tests/test_workspace.py +127 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/filters.py +12 -1
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/metadata.py +6 -3
- crudkit-0.2.2/src/crudkit_api/permissions.py +16 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/serializers.py +55 -39
- crudkit-0.2.2/src/crudkit_api/tests/test_authorization.py +110 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/tests/test_email_id_serialization.py +1 -1
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/tests/test_filters.py +6 -1
- crudkit-0.2.2/src/crudkit_api/tests/test_list_serialization.py +98 -0
- crudkit-0.2.2/src/crudkit_api/tests/test_merge.py +33 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/views.py +54 -43
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/consumers.py +16 -3
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/execution.py +3 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/runner.py +4 -2
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/tests/test_proposals.py +24 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/tools.py +16 -10
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/utils.py +8 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/context_processors.py +3 -0
- crudkit-0.2.0/src/crudkit_frontend/static/crudkit_frontend/assets/index-BUe8jarF.js → crudkit-0.2.2/src/crudkit_frontend/static/crudkit_frontend/assets/index-DD2G9RcN.js +178 -178
- crudkit-0.2.0/src/crudkit_frontend/static/crudkit_frontend/assets/index-CL5BO7Fx.css → crudkit-0.2.2/src/crudkit_frontend/static/crudkit_frontend/assets/index-jAk-LEvm.css +1 -1
- crudkit-0.2.2/src/crudkit_frontend/templates/crudkit_frontend/index.html +21 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/tests/templates/crudkit_frontend/index.html +3 -1
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/tests/test_frontend.py +19 -0
- crudkit-0.2.2/tests/testapp/migrations/0002_customer_currency.py +19 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/tests/testapp/models.py +2 -1
- crudkit-0.2.0/src/crudkit_frontend/templates/crudkit_frontend/index.html +0 -17
- {crudkit-0.2.0 → crudkit-0.2.2}/.gitignore +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/manage.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/__init__.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/admin.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/ai_backend.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/decorators.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/fields.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/llm.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/management/__init__.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/management/commands/__init__.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/management/commands/add_default_exchange_rates.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/management/commands/backfill_ai_fields.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/management/commands/set_exchange_rate.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/migrations/0001_squashed.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/migrations/__init__.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/profile.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/signals.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tasks.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/templatetags/__init__.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/templatetags/crm_forms.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/templatetags/crm_time.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/templatetags/gravatar.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/templatetags/model_magic.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tests/__init__.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tests/test_ai_backend.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tests/test_ai_fields.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tests/test_ai_tasks.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tests/test_backfill_command.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tests/test_fields.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tests/test_llm.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tests/test_money.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tests/test_tasks.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/utils.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/widgets.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/__init__.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/authentication.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/pagination.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/tests/__init__.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/tests/test_metadata.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/tests/test_user_profile.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/urls.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/__init__.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/admin.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/agent.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/apps.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/deps.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/migrations/0001_initial.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/migrations/__init__.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/models.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/routing.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/tests/__init__.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/__init__.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/apps.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/static/crudkit_frontend/assets/favicon-Cit_9t8D.svg +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/tests/__init__.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/tests/urls.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/urls.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/views.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/tests/__init__.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/tests/celery.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/tests/settings.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/tests/testapp/__init__.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/tests/testapp/adapters.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/tests/testapp/ai.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/tests/testapp/apps.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/tests/testapp/migrations/0001_initial.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/tests/testapp/migrations/__init__.py +0 -0
- {crudkit-0.2.0 → crudkit-0.2.2}/tests/urls.py +0 -0
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.2.
|
|
3
|
+
## 0.2.2 (2026-09-19)
|
|
4
|
+
|
|
5
|
+
- `crudkit_api`: list endpoints no longer build a serializer per row. `GenericViewSet.list`
|
|
6
|
+
reuses one serializer for the whole page, `GenericSerializer.build_nested_field` caches the
|
|
7
|
+
generated nested field classes per (related model, depth), and the nested serializer behind a
|
|
8
|
+
forward FK is built once per field instead of once per value. A 500-row page of a model with
|
|
9
|
+
11 FKs went from a 216 MB to a 15 MB peak downstream (72 MB to 8.5 MB in the test suite's
|
|
10
|
+
smaller fixtures) and serializes ~2.6x faster; the JSON is unchanged. Worker RSS on gunicorn
|
|
11
|
+
no longer climbs from refetching kanban/swimlane views, which request `page_size=500`.
|
|
12
|
+
|
|
13
|
+
- Workspaces: new `Workspace` model (TYPE_ID `WSP`) — switchable sidebar
|
|
14
|
+
apps that pin an ordered set of saved views as tabs, picked from a switcher
|
|
15
|
+
in the sidebar header. Purely additive: deployments with no Workspace rows
|
|
16
|
+
render exactly as before.
|
|
17
|
+
|
|
18
|
+
## 0.2.1 (2026-08-11)
|
|
19
|
+
|
|
20
|
+
- `crudkit_frontend`: asset URLs are no longer hardcoded to `/static/`. The
|
|
21
|
+
SPA shell references its assets via `{% static %}` tags (so
|
|
22
|
+
`ManifestStaticFilesStorage` users also get hashed, cache-busted URLs), and
|
|
23
|
+
the JS bundle builds asset/chunk URLs at runtime from Django's
|
|
24
|
+
`STATIC_URL`, exposed by the shell as `window.__CRUDKIT_STATIC_URL__` via
|
|
25
|
+
the `crudkit_config` context processor. Projects with a custom
|
|
26
|
+
`STATIC_URL` (e.g. `assets/`) now work out of the box.
|
|
4
27
|
|
|
5
28
|
- The distribution is published on PyPI as **`crudkit`** (the planned
|
|
6
29
|
`django-crudkit` name was rejected as too similar to an existing project).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: crudkit
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: A metadata-driven CRUD framework for Django: typed object IDs, soft delete, merge, change log, activity feed, saved views, AI fields and a generic REST API.
|
|
5
5
|
Project-URL: Homepage, https://github.com/fenerum/crudkit
|
|
6
6
|
Project-URL: Changelog, https://github.com/fenerum/crudkit/blob/main/backend/CHANGELOG.md
|
|
@@ -80,6 +80,9 @@ INSTALLED_APPS = [
|
|
|
80
80
|
]
|
|
81
81
|
|
|
82
82
|
REST_FRAMEWORK = {
|
|
83
|
+
"DEFAULT_PERMISSION_CLASSES": [
|
|
84
|
+
"crudkit_api.permissions.CrudKitModelPermissions",
|
|
85
|
+
],
|
|
83
86
|
"DEFAULT_PAGINATION_CLASS": "crudkit_api.pagination.CrudKitPagination",
|
|
84
87
|
"DEFAULT_FILTER_BACKENDS": ["crudkit_api.filters.BasicFilter"],
|
|
85
88
|
"PAGE_SIZE": 50,
|
|
@@ -89,6 +92,13 @@ REST_FRAMEWORK = {
|
|
|
89
92
|
urlpatterns = [path("api/v1/", include("crudkit_api.urls"))]
|
|
90
93
|
```
|
|
91
94
|
|
|
95
|
+
CrudKit requires the standard Django model permissions for every API and
|
|
96
|
+
assistant operation. Projects that need row-level rules can override
|
|
97
|
+
`CrudKitSettings.get_authorized_queryset(user, queryset, action)`; `action`
|
|
98
|
+
is one of `view`, `add`, `change`, or `delete`. Model actions require `change`
|
|
99
|
+
permission and can be narrowed further with
|
|
100
|
+
`CrudKitSettings.has_action_permission(user, instance, action_name)`.
|
|
101
|
+
|
|
92
102
|
## Settings
|
|
93
103
|
|
|
94
104
|
| Setting | Purpose |
|
|
@@ -128,7 +138,9 @@ urlpatterns = [..., path("", include("crudkit_frontend.urls"))]
|
|
|
128
138
|
```
|
|
129
139
|
|
|
130
140
|
Static assets are served by `django.contrib.staticfiles` (or WhiteNoise et
|
|
131
|
-
al.) from `crudkit_frontend/static
|
|
141
|
+
al.) from `crudkit_frontend/static/`, under whatever `STATIC_URL` the project
|
|
142
|
+
uses — nothing assumes the default `static/` prefix. Contributors hacking on
|
|
143
|
+
the SPA itself
|
|
132
144
|
run the Vite dev server from [../frontend](../frontend/) against any CrudKit
|
|
133
145
|
backend; `npm run build` there regenerates the bundled assets.
|
|
134
146
|
|
|
@@ -46,6 +46,9 @@ INSTALLED_APPS = [
|
|
|
46
46
|
]
|
|
47
47
|
|
|
48
48
|
REST_FRAMEWORK = {
|
|
49
|
+
"DEFAULT_PERMISSION_CLASSES": [
|
|
50
|
+
"crudkit_api.permissions.CrudKitModelPermissions",
|
|
51
|
+
],
|
|
49
52
|
"DEFAULT_PAGINATION_CLASS": "crudkit_api.pagination.CrudKitPagination",
|
|
50
53
|
"DEFAULT_FILTER_BACKENDS": ["crudkit_api.filters.BasicFilter"],
|
|
51
54
|
"PAGE_SIZE": 50,
|
|
@@ -55,6 +58,13 @@ REST_FRAMEWORK = {
|
|
|
55
58
|
urlpatterns = [path("api/v1/", include("crudkit_api.urls"))]
|
|
56
59
|
```
|
|
57
60
|
|
|
61
|
+
CrudKit requires the standard Django model permissions for every API and
|
|
62
|
+
assistant operation. Projects that need row-level rules can override
|
|
63
|
+
`CrudKitSettings.get_authorized_queryset(user, queryset, action)`; `action`
|
|
64
|
+
is one of `view`, `add`, `change`, or `delete`. Model actions require `change`
|
|
65
|
+
permission and can be narrowed further with
|
|
66
|
+
`CrudKitSettings.has_action_permission(user, instance, action_name)`.
|
|
67
|
+
|
|
58
68
|
## Settings
|
|
59
69
|
|
|
60
70
|
| Setting | Purpose |
|
|
@@ -94,7 +104,9 @@ urlpatterns = [..., path("", include("crudkit_frontend.urls"))]
|
|
|
94
104
|
```
|
|
95
105
|
|
|
96
106
|
Static assets are served by `django.contrib.staticfiles` (or WhiteNoise et
|
|
97
|
-
al.) from `crudkit_frontend/static
|
|
107
|
+
al.) from `crudkit_frontend/static/`, under whatever `STATIC_URL` the project
|
|
108
|
+
uses — nothing assumes the default `static/` prefix. Contributors hacking on
|
|
109
|
+
the SPA itself
|
|
98
110
|
run the Vite dev server from [../frontend](../frontend/) against any CrudKit
|
|
99
111
|
backend; `npm run build` there regenerates the bundled assets.
|
|
100
112
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "crudkit"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.2"
|
|
4
4
|
description = "A metadata-driven CRUD framework for Django: typed object IDs, soft delete, merge, change log, activity feed, saved views, AI fields and a generic REST API."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from django.core.exceptions import PermissionDenied
|
|
2
|
+
from django.db.models import Model, QuerySet
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_permission_action(method: str, view_action: str | None = None) -> str:
|
|
6
|
+
if view_action in {"create", "initial_data"}:
|
|
7
|
+
return "add"
|
|
8
|
+
if view_action in {"update", "partial_update", "merge", "call_action"}:
|
|
9
|
+
return "change"
|
|
10
|
+
if view_action == "destroy":
|
|
11
|
+
return "delete"
|
|
12
|
+
return "view"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def has_model_permission(user, model: type[Model], action: str) -> bool:
|
|
16
|
+
if not getattr(user, "is_authenticated", False):
|
|
17
|
+
return False
|
|
18
|
+
permission = f"{model._meta.app_label}.{action}_{model._meta.model_name}"
|
|
19
|
+
return user.has_perm(permission)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_authorized_queryset(user, queryset: QuerySet, action: str = "view") -> QuerySet:
|
|
23
|
+
model = queryset.model
|
|
24
|
+
if not has_model_permission(user, model, action):
|
|
25
|
+
return queryset.none()
|
|
26
|
+
if getattr(user, "is_superuser", False):
|
|
27
|
+
return queryset
|
|
28
|
+
|
|
29
|
+
settings = getattr(model, "CrudKitSettings", None)
|
|
30
|
+
authorize = getattr(settings, "get_authorized_queryset", None)
|
|
31
|
+
return authorize(user, queryset, action) if authorize else queryset
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def has_object_permission(user, instance: Model, action: str = "view") -> bool:
|
|
35
|
+
queryset = get_authorized_queryset(user, instance.__class__._default_manager.all(), action)
|
|
36
|
+
return queryset.filter(pk=instance.pk).exists()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def require_object_permission(user, instance: Model, action: str = "view") -> None:
|
|
40
|
+
if not has_object_permission(user, instance, action):
|
|
41
|
+
raise PermissionDenied
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def has_action_permission(user, instance: Model, action_name: str) -> bool:
|
|
45
|
+
if not has_object_permission(user, instance, "change"):
|
|
46
|
+
return False
|
|
47
|
+
settings = getattr(instance.__class__, "CrudKitSettings", None)
|
|
48
|
+
authorize = getattr(settings, "has_action_permission", None)
|
|
49
|
+
return authorize(user, instance, action_name) if authorize else True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def require_action_permission(user, instance: Model, action_name: str) -> None:
|
|
53
|
+
if not has_action_permission(user, instance, action_name):
|
|
54
|
+
raise PermissionDenied
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
|
|
4
|
+
from django.apps import apps
|
|
5
|
+
from django.core.checks import Error, Tags, register
|
|
6
|
+
|
|
7
|
+
from crudkit.models import BaseCrudKitModel
|
|
8
|
+
|
|
9
|
+
TYPE_ID_PATTERN = re.compile(r"^[A-Z]{3}$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@register(Tags.models)
|
|
13
|
+
def check_type_ids(app_configs, **kwargs):
|
|
14
|
+
models_by_type_id = defaultdict(list)
|
|
15
|
+
errors = []
|
|
16
|
+
|
|
17
|
+
for model in apps.get_models():
|
|
18
|
+
if not issubclass(model, BaseCrudKitModel):
|
|
19
|
+
continue
|
|
20
|
+
type_id = getattr(model, "TYPE_ID", None)
|
|
21
|
+
if not isinstance(type_id, str) or not TYPE_ID_PATTERN.fullmatch(type_id):
|
|
22
|
+
errors.append(
|
|
23
|
+
Error(
|
|
24
|
+
f"{model._meta.label} must define TYPE_ID as three uppercase letters.",
|
|
25
|
+
obj=model,
|
|
26
|
+
id="crudkit.E001",
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
continue
|
|
30
|
+
models_by_type_id[type_id].append(model)
|
|
31
|
+
|
|
32
|
+
for type_id, models in models_by_type_id.items():
|
|
33
|
+
if len(models) < 2:
|
|
34
|
+
continue
|
|
35
|
+
labels = ", ".join(model._meta.label for model in models)
|
|
36
|
+
errors.append(
|
|
37
|
+
Error(
|
|
38
|
+
f"TYPE_ID {type_id!r} is used by multiple models: {labels}.",
|
|
39
|
+
id="crudkit.E002",
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return errors
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Generated by Django 5.1.15 on 2026-08-20 17:54
|
|
2
|
+
|
|
3
|
+
import crudkit.models
|
|
4
|
+
import django.db.models.deletion
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.db import migrations, models
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Migration(migrations.Migration):
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
("crudkit", "0001_squashed"),
|
|
13
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
operations = [
|
|
17
|
+
migrations.CreateModel(
|
|
18
|
+
name="Workspace",
|
|
19
|
+
fields=[
|
|
20
|
+
(
|
|
21
|
+
"id",
|
|
22
|
+
crudkit.models.CrudKitIDField(
|
|
23
|
+
editable=False, primary_key=True, serialize=False
|
|
24
|
+
),
|
|
25
|
+
),
|
|
26
|
+
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
27
|
+
("updated_at", models.DateTimeField(auto_now=True)),
|
|
28
|
+
("deleted", models.BooleanField(default=False, editable=False)),
|
|
29
|
+
("name", models.CharField(max_length=128)),
|
|
30
|
+
(
|
|
31
|
+
"icon",
|
|
32
|
+
models.CharField(
|
|
33
|
+
blank=True,
|
|
34
|
+
help_text="Icon name shown in the workspace switcher",
|
|
35
|
+
max_length=64,
|
|
36
|
+
null=True,
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
("public", models.BooleanField(blank=True, default=True)),
|
|
40
|
+
(
|
|
41
|
+
"views",
|
|
42
|
+
models.JSONField(
|
|
43
|
+
blank=True,
|
|
44
|
+
default=list,
|
|
45
|
+
encoder=crudkit.models.PrettyJSONEncoder,
|
|
46
|
+
help_text='Format: ["VIW1", "VIW2", ...] — tab order follows the list order',
|
|
47
|
+
),
|
|
48
|
+
),
|
|
49
|
+
(
|
|
50
|
+
"created_by",
|
|
51
|
+
models.ForeignKey(
|
|
52
|
+
editable=False,
|
|
53
|
+
on_delete=django.db.models.deletion.PROTECT,
|
|
54
|
+
related_name="%(class)s_created_by_set",
|
|
55
|
+
to=settings.AUTH_USER_MODEL,
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
(
|
|
59
|
+
"merged_into",
|
|
60
|
+
models.ForeignKey(
|
|
61
|
+
blank=True,
|
|
62
|
+
editable=False,
|
|
63
|
+
null=True,
|
|
64
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
65
|
+
related_name="%(class)s_merged_set",
|
|
66
|
+
to="crudkit.workspace",
|
|
67
|
+
),
|
|
68
|
+
),
|
|
69
|
+
(
|
|
70
|
+
"updated_by",
|
|
71
|
+
models.ForeignKey(
|
|
72
|
+
editable=False,
|
|
73
|
+
on_delete=django.db.models.deletion.PROTECT,
|
|
74
|
+
related_name="%(class)s_updated_by_set",
|
|
75
|
+
to=settings.AUTH_USER_MODEL,
|
|
76
|
+
),
|
|
77
|
+
),
|
|
78
|
+
],
|
|
79
|
+
options={
|
|
80
|
+
"ordering": ["name"],
|
|
81
|
+
},
|
|
82
|
+
),
|
|
83
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Generated by Django 5.1.15 on 2026-08-24 11:38
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
from django.db.models import Count, F
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def close_duplicate_active_worklogs(apps, schema_editor):
|
|
9
|
+
worklog = apps.get_model("crudkit", "WorkLog")
|
|
10
|
+
duplicate_users = (
|
|
11
|
+
worklog.objects.filter(end_at__isnull=True)
|
|
12
|
+
.values("created_by_id")
|
|
13
|
+
.annotate(total=Count("id"))
|
|
14
|
+
.filter(total__gt=1)
|
|
15
|
+
)
|
|
16
|
+
for row in duplicate_users:
|
|
17
|
+
active = worklog.objects.filter(created_by_id=row["created_by_id"], end_at__isnull=True).order_by(
|
|
18
|
+
"-created_at", "-pk"
|
|
19
|
+
)
|
|
20
|
+
keep_id = active.values_list("pk", flat=True).first()
|
|
21
|
+
active.exclude(pk=keep_id).update(end_at=F("created_at"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Migration(migrations.Migration):
|
|
25
|
+
|
|
26
|
+
dependencies = [
|
|
27
|
+
("contenttypes", "0002_remove_content_type_name"),
|
|
28
|
+
("crudkit", "0002_workspace"),
|
|
29
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
operations = [
|
|
33
|
+
migrations.AlterUniqueTogether(
|
|
34
|
+
name="worklog",
|
|
35
|
+
unique_together=set(),
|
|
36
|
+
),
|
|
37
|
+
migrations.AddConstraint(
|
|
38
|
+
model_name="exchangerate",
|
|
39
|
+
constraint=models.CheckConstraint(
|
|
40
|
+
condition=models.Q(
|
|
41
|
+
("to_date__isnull", True),
|
|
42
|
+
("to_date__gte", models.F("from_date")),
|
|
43
|
+
_connector="OR",
|
|
44
|
+
),
|
|
45
|
+
name="crudkit_exchange_rate_dates_ordered",
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
migrations.RunPython(close_duplicate_active_worklogs, migrations.RunPython.noop),
|
|
49
|
+
migrations.AddConstraint(
|
|
50
|
+
model_name="worklog",
|
|
51
|
+
constraint=models.UniqueConstraint(
|
|
52
|
+
condition=models.Q(("end_at__isnull", True)),
|
|
53
|
+
fields=("created_by",),
|
|
54
|
+
name="crudkit_one_active_worklog_per_user",
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
]
|
|
@@ -21,6 +21,8 @@ def get_ck_id(type_id, pk):
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def parse_ck_id(ck_id: str) -> tuple[str, int]:
|
|
24
|
+
if not isinstance(ck_id, str) or not ck_id_regex.fullmatch(ck_id):
|
|
25
|
+
raise ValueError(f"Invalid CrudKit ID: {ck_id!r}")
|
|
24
26
|
return ck_id[0:3], int(ck_id[3:])
|
|
25
27
|
|
|
26
28
|
|
|
@@ -58,7 +60,11 @@ class CrudKitIDField(models.BigAutoField):
|
|
|
58
60
|
if value == "":
|
|
59
61
|
return None
|
|
60
62
|
if type(value) is str and value is not None:
|
|
61
|
-
|
|
63
|
+
if value.isdigit():
|
|
64
|
+
pk = int(value)
|
|
65
|
+
else:
|
|
66
|
+
type_id, pk = parse_ck_id(value)
|
|
67
|
+
self._validate_type_id(type_id)
|
|
62
68
|
else:
|
|
63
69
|
pk = value
|
|
64
70
|
return super().get_prep_value(pk)
|
|
@@ -71,10 +77,19 @@ class CrudKitIDField(models.BigAutoField):
|
|
|
71
77
|
if value == "":
|
|
72
78
|
return super().get_db_prep_value(None, connection, prepared)
|
|
73
79
|
if value is not None:
|
|
74
|
-
|
|
80
|
+
if isinstance(value, str) and value.isdigit():
|
|
81
|
+
pk = int(value)
|
|
82
|
+
else:
|
|
83
|
+
type_id, pk = parse_ck_id(value)
|
|
84
|
+
self._validate_type_id(type_id)
|
|
75
85
|
return super().get_db_prep_value(pk, connection, prepared)
|
|
76
86
|
return super().get_db_prep_value(value, connection, prepared)
|
|
77
87
|
|
|
88
|
+
def _validate_type_id(self, type_id):
|
|
89
|
+
expected = getattr(self.model, "TYPE_ID", None)
|
|
90
|
+
if expected is not None and type_id != expected:
|
|
91
|
+
raise ValueError(f"Expected a {expected} ID, got {type_id}")
|
|
92
|
+
|
|
78
93
|
|
|
79
94
|
class CrudKitPositiveIntegerField(models.PositiveIntegerField):
|
|
80
95
|
def from_db_value(self, value, expression, connection):
|
|
@@ -352,6 +367,14 @@ class BaseCrudKitModel(models.Model):
|
|
|
352
367
|
# capabilities without touching the framework app.
|
|
353
368
|
assistant_tools = []
|
|
354
369
|
|
|
370
|
+
@staticmethod
|
|
371
|
+
def get_authorized_queryset(user, queryset, action):
|
|
372
|
+
return queryset
|
|
373
|
+
|
|
374
|
+
@staticmethod
|
|
375
|
+
def has_action_permission(user, instance, action_name):
|
|
376
|
+
return True
|
|
377
|
+
|
|
355
378
|
class Meta:
|
|
356
379
|
abstract = True
|
|
357
380
|
ordering = ["id"]
|
|
@@ -577,6 +600,12 @@ class View(BaseCrudKitModel):
|
|
|
577
600
|
class CrudKitSettings(BaseCrudKitModel.CrudKitSettings):
|
|
578
601
|
allowed_prefills = ["model", "fields"]
|
|
579
602
|
|
|
603
|
+
@staticmethod
|
|
604
|
+
def get_authorized_queryset(user, queryset, action):
|
|
605
|
+
if action == "view":
|
|
606
|
+
return queryset.filter(models.Q(public=True) | models.Q(created_by=user))
|
|
607
|
+
return queryset.filter(created_by=user)
|
|
608
|
+
|
|
580
609
|
@classmethod
|
|
581
610
|
def from_query_params(cls, params, kwargs=None):
|
|
582
611
|
instance = super().from_query_params(params, kwargs)
|
|
@@ -585,6 +614,49 @@ class View(BaseCrudKitModel):
|
|
|
585
614
|
return instance
|
|
586
615
|
|
|
587
616
|
|
|
617
|
+
class Workspace(BaseCrudKitModel):
|
|
618
|
+
TYPE_ID = "WSP"
|
|
619
|
+
|
|
620
|
+
name = models.CharField(max_length=128)
|
|
621
|
+
icon = models.CharField(
|
|
622
|
+
max_length=64,
|
|
623
|
+
blank=True,
|
|
624
|
+
null=True,
|
|
625
|
+
help_text=_("Icon name shown in the workspace switcher"),
|
|
626
|
+
)
|
|
627
|
+
public = models.BooleanField(blank=True, default=True)
|
|
628
|
+
views = models.JSONField(
|
|
629
|
+
blank=True,
|
|
630
|
+
default=list,
|
|
631
|
+
encoder=PrettyJSONEncoder,
|
|
632
|
+
help_text='Format: ["VIW1", "VIW2", ...] — tab order follows the list order',
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
class Meta:
|
|
636
|
+
ordering = ["name"]
|
|
637
|
+
|
|
638
|
+
class CrudKitSettings(BaseCrudKitModel.CrudKitSettings):
|
|
639
|
+
search_fields = ["name"]
|
|
640
|
+
|
|
641
|
+
@staticmethod
|
|
642
|
+
def get_authorized_queryset(user, queryset, action):
|
|
643
|
+
if action == "view":
|
|
644
|
+
return queryset.filter(models.Q(public=True) | models.Q(created_by=user))
|
|
645
|
+
return queryset.filter(created_by=user)
|
|
646
|
+
|
|
647
|
+
def __str__(self):
|
|
648
|
+
return self.name
|
|
649
|
+
|
|
650
|
+
def clean(self):
|
|
651
|
+
if not isinstance(self.views, list):
|
|
652
|
+
raise ValidationError({"views": _('Format: ["VIW1", "VIW2", ...]')})
|
|
653
|
+
for ck_id in self.views:
|
|
654
|
+
if not isinstance(ck_id, str) or not ck_id_regex.match(ck_id) or ck_id[:3] != View.TYPE_ID:
|
|
655
|
+
raise ValidationError({"views": _(f"'{ck_id}' is not a View id.")})
|
|
656
|
+
if not View.objects.filter(pk=ck_id).exists():
|
|
657
|
+
raise ValidationError({"views": _(f"View '{ck_id}' does not exist.")})
|
|
658
|
+
|
|
659
|
+
|
|
588
660
|
class FeedItem(BaseCrudKitModel):
|
|
589
661
|
TYPE_ID = "FEI"
|
|
590
662
|
|
|
@@ -767,7 +839,13 @@ class WorkLog(BaseCrudKitModel):
|
|
|
767
839
|
models.Index(fields=["created_by", "end_at"]),
|
|
768
840
|
models.Index(fields=["related_content_type", "related_object_id"]),
|
|
769
841
|
]
|
|
770
|
-
|
|
842
|
+
constraints = [
|
|
843
|
+
models.UniqueConstraint(
|
|
844
|
+
fields=["created_by"],
|
|
845
|
+
condition=models.Q(end_at__isnull=True),
|
|
846
|
+
name="crudkit_one_active_worklog_per_user",
|
|
847
|
+
)
|
|
848
|
+
]
|
|
771
849
|
|
|
772
850
|
def __str__(self):
|
|
773
851
|
return f"Work log on {self.related_object} from {self.created_at}"
|
|
@@ -813,6 +891,12 @@ class ExchangeRate(BaseCrudKitModel):
|
|
|
813
891
|
indexes = [
|
|
814
892
|
models.Index(fields=["currency", "from_date", "to_date"]),
|
|
815
893
|
]
|
|
894
|
+
constraints = [
|
|
895
|
+
models.CheckConstraint(
|
|
896
|
+
condition=models.Q(to_date__isnull=True) | models.Q(to_date__gte=models.F("from_date")),
|
|
897
|
+
name="crudkit_exchange_rate_dates_ordered",
|
|
898
|
+
)
|
|
899
|
+
]
|
|
816
900
|
|
|
817
901
|
def __str__(self):
|
|
818
902
|
to_date_str = f" - {self.to_date}" if self.to_date else " - ongoing"
|
|
@@ -822,29 +906,13 @@ class ExchangeRate(BaseCrudKitModel):
|
|
|
822
906
|
"""
|
|
823
907
|
Validate that there are no overlaps for a currency in terms of from_date and to_date.
|
|
824
908
|
"""
|
|
825
|
-
# Set a far future date for "no end date" comparisons
|
|
826
|
-
far_future = timezone.now().date() + timezone.timedelta(days=36500)
|
|
827
|
-
effective_to_date = self.to_date or far_future
|
|
828
|
-
|
|
829
909
|
# Check for start date after end date
|
|
830
910
|
if self.to_date and self.from_date > self.to_date:
|
|
831
911
|
raise ValidationError({"to_date": _("End date must be after start date")})
|
|
832
912
|
|
|
833
913
|
# Check for overlapping date ranges for the same currency
|
|
834
|
-
overlapping_query = models.Q(
|
|
835
|
-
|
|
836
|
-
# 1. starts during our range
|
|
837
|
-
models.Q(from_date__gte=self.from_date, from_date__lte=effective_to_date)
|
|
838
|
-
|
|
|
839
|
-
# 2. ends during our range
|
|
840
|
-
models.Q(to_date__gte=self.from_date, to_date__lte=effective_to_date)
|
|
841
|
-
|
|
|
842
|
-
# 3. spans our entire range
|
|
843
|
-
models.Q(from_date__lte=self.from_date, to_date__gte=effective_to_date)
|
|
844
|
-
|
|
|
845
|
-
# 4. our range spans their entire range
|
|
846
|
-
models.Q(from_date__gte=self.from_date, to_date__lte=effective_to_date)
|
|
847
|
-
)
|
|
914
|
+
overlapping_query = models.Q(from_date__lte=self.to_date) if self.to_date else models.Q()
|
|
915
|
+
overlapping_query &= models.Q(to_date__isnull=True) | models.Q(to_date__gte=self.from_date)
|
|
848
916
|
|
|
849
917
|
overlapping_filter = ExchangeRate.objects.filter(currency=self.currency).filter(overlapping_query)
|
|
850
918
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
from django.contrib.auth.models import User
|
|
6
|
+
from django.contrib.contenttypes.models import ContentType
|
|
7
|
+
from django.core.exceptions import ValidationError
|
|
8
|
+
from django.db import IntegrityError, transaction
|
|
9
|
+
from django.test import TestCase
|
|
10
|
+
|
|
11
|
+
from crudkit.checks import check_type_ids
|
|
12
|
+
from crudkit.models import ExchangeRate, WorkLog, parse_ck_id
|
|
13
|
+
from tests.testapp.models import Customer, Topic
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CrudKitIDTests(TestCase):
|
|
17
|
+
def setUp(self):
|
|
18
|
+
self.user = User.objects.create_user(username="ids", password="pw")
|
|
19
|
+
self.customer = Customer.objects.create(
|
|
20
|
+
name="Customer",
|
|
21
|
+
created_by=self.user,
|
|
22
|
+
updated_by=self.user,
|
|
23
|
+
)
|
|
24
|
+
self.topic = Topic.objects.create(
|
|
25
|
+
name="Topic",
|
|
26
|
+
created_by=self.user,
|
|
27
|
+
updated_by=self.user,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def test_wrong_model_prefix_is_rejected(self):
|
|
31
|
+
with self.assertRaises(ValueError):
|
|
32
|
+
Customer.objects.get(pk=self.topic.pk)
|
|
33
|
+
|
|
34
|
+
def test_parse_rejects_malformed_ids(self):
|
|
35
|
+
for value in ("CUS", "CUS1x", "cus1", "1"):
|
|
36
|
+
with self.subTest(value=value), self.assertRaises(ValueError):
|
|
37
|
+
parse_ck_id(value)
|
|
38
|
+
|
|
39
|
+
def test_system_check_rejects_duplicate_type_ids(self):
|
|
40
|
+
with (
|
|
41
|
+
patch.object(Topic, "TYPE_ID", Customer.TYPE_ID),
|
|
42
|
+
patch("crudkit.checks.apps.get_models", return_value=[Customer, Topic]),
|
|
43
|
+
):
|
|
44
|
+
errors = check_type_ids(None)
|
|
45
|
+
self.assertEqual([error.id for error in errors], ["crudkit.E002"])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DatabaseInvariantTests(TestCase):
|
|
49
|
+
def setUp(self):
|
|
50
|
+
self.user = User.objects.create_user(username="integrity", password="pw")
|
|
51
|
+
self.customer = Customer.objects.create(
|
|
52
|
+
name="Customer",
|
|
53
|
+
created_by=self.user,
|
|
54
|
+
updated_by=self.user,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def test_only_one_active_worklog_per_user(self):
|
|
58
|
+
content_type = ContentType.objects.get_for_model(self.customer)
|
|
59
|
+
values = {
|
|
60
|
+
"related_content_type": content_type,
|
|
61
|
+
"related_object_id": self.customer.pk,
|
|
62
|
+
"created_by": self.user,
|
|
63
|
+
"updated_by": self.user,
|
|
64
|
+
}
|
|
65
|
+
WorkLog.objects.create(**values)
|
|
66
|
+
|
|
67
|
+
with self.assertRaises(IntegrityError), transaction.atomic():
|
|
68
|
+
WorkLog.objects.create(**values)
|
|
69
|
+
|
|
70
|
+
def test_exchange_rate_rejects_overlap_with_open_interval(self):
|
|
71
|
+
ExchangeRate.objects.create(
|
|
72
|
+
currency="USD",
|
|
73
|
+
from_date=date(2024, 1, 1),
|
|
74
|
+
to_date=None,
|
|
75
|
+
rate=Decimal("7.00"),
|
|
76
|
+
created_by=self.user,
|
|
77
|
+
updated_by=self.user,
|
|
78
|
+
)
|
|
79
|
+
overlapping = ExchangeRate(
|
|
80
|
+
currency="USD",
|
|
81
|
+
from_date=date(2025, 1, 1),
|
|
82
|
+
to_date=date(2025, 12, 31),
|
|
83
|
+
rate=Decimal("7.10"),
|
|
84
|
+
created_by=self.user,
|
|
85
|
+
updated_by=self.user,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
with self.assertRaises(ValidationError):
|
|
89
|
+
overlapping.full_clean()
|
|
90
|
+
|
|
91
|
+
def test_exchange_rate_database_constraint_orders_dates(self):
|
|
92
|
+
with self.assertRaises(IntegrityError), transaction.atomic():
|
|
93
|
+
ExchangeRate.objects.create(
|
|
94
|
+
currency="USD",
|
|
95
|
+
from_date=date(2025, 2, 1),
|
|
96
|
+
to_date=date(2025, 1, 1),
|
|
97
|
+
rate=Decimal("7.00"),
|
|
98
|
+
created_by=self.user,
|
|
99
|
+
updated_by=self.user,
|
|
100
|
+
)
|