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.
Files changed (105) hide show
  1. {crudkit-0.2.0 → crudkit-0.2.2}/CHANGELOG.md +24 -1
  2. {crudkit-0.2.0 → crudkit-0.2.2}/PKG-INFO +14 -2
  3. {crudkit-0.2.0 → crudkit-0.2.2}/README.md +13 -1
  4. {crudkit-0.2.0 → crudkit-0.2.2}/pyproject.toml +1 -1
  5. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/apps.py +1 -0
  6. crudkit-0.2.2/src/crudkit/authorization.py +54 -0
  7. crudkit-0.2.2/src/crudkit/checks.py +43 -0
  8. crudkit-0.2.2/src/crudkit/migrations/0002_workspace.py +83 -0
  9. crudkit-0.2.2/src/crudkit/migrations/0003_enforce_data_invariants.py +57 -0
  10. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/models.py +89 -21
  11. crudkit-0.2.2/src/crudkit/tests/test_integrity.py +100 -0
  12. crudkit-0.2.2/src/crudkit/tests/test_workspace.py +127 -0
  13. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/filters.py +12 -1
  14. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/metadata.py +6 -3
  15. crudkit-0.2.2/src/crudkit_api/permissions.py +16 -0
  16. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/serializers.py +55 -39
  17. crudkit-0.2.2/src/crudkit_api/tests/test_authorization.py +110 -0
  18. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/tests/test_email_id_serialization.py +1 -1
  19. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/tests/test_filters.py +6 -1
  20. crudkit-0.2.2/src/crudkit_api/tests/test_list_serialization.py +98 -0
  21. crudkit-0.2.2/src/crudkit_api/tests/test_merge.py +33 -0
  22. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/views.py +54 -43
  23. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/consumers.py +16 -3
  24. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/execution.py +3 -0
  25. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/runner.py +4 -2
  26. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/tests/test_proposals.py +24 -0
  27. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/tools.py +16 -10
  28. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/utils.py +8 -0
  29. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/context_processors.py +3 -0
  30. 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
  31. 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
  32. crudkit-0.2.2/src/crudkit_frontend/templates/crudkit_frontend/index.html +21 -0
  33. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/tests/templates/crudkit_frontend/index.html +3 -1
  34. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/tests/test_frontend.py +19 -0
  35. crudkit-0.2.2/tests/testapp/migrations/0002_customer_currency.py +19 -0
  36. {crudkit-0.2.0 → crudkit-0.2.2}/tests/testapp/models.py +2 -1
  37. crudkit-0.2.0/src/crudkit_frontend/templates/crudkit_frontend/index.html +0 -17
  38. {crudkit-0.2.0 → crudkit-0.2.2}/.gitignore +0 -0
  39. {crudkit-0.2.0 → crudkit-0.2.2}/manage.py +0 -0
  40. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/__init__.py +0 -0
  41. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/admin.py +0 -0
  42. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/ai_backend.py +0 -0
  43. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/decorators.py +0 -0
  44. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/fields.py +0 -0
  45. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/llm.py +0 -0
  46. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/management/__init__.py +0 -0
  47. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/management/commands/__init__.py +0 -0
  48. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/management/commands/add_default_exchange_rates.py +0 -0
  49. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/management/commands/backfill_ai_fields.py +0 -0
  50. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/management/commands/set_exchange_rate.py +0 -0
  51. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/migrations/0001_squashed.py +0 -0
  52. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/migrations/__init__.py +0 -0
  53. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/profile.py +0 -0
  54. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/signals.py +0 -0
  55. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tasks.py +0 -0
  56. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/templatetags/__init__.py +0 -0
  57. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/templatetags/crm_forms.py +0 -0
  58. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/templatetags/crm_time.py +0 -0
  59. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/templatetags/gravatar.py +0 -0
  60. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/templatetags/model_magic.py +0 -0
  61. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tests/__init__.py +0 -0
  62. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tests/test_ai_backend.py +0 -0
  63. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tests/test_ai_fields.py +0 -0
  64. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tests/test_ai_tasks.py +0 -0
  65. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tests/test_backfill_command.py +0 -0
  66. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tests/test_fields.py +0 -0
  67. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tests/test_llm.py +0 -0
  68. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tests/test_money.py +0 -0
  69. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/tests/test_tasks.py +0 -0
  70. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/utils.py +0 -0
  71. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit/widgets.py +0 -0
  72. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/__init__.py +0 -0
  73. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/authentication.py +0 -0
  74. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/pagination.py +0 -0
  75. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/tests/__init__.py +0 -0
  76. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/tests/test_metadata.py +0 -0
  77. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/tests/test_user_profile.py +0 -0
  78. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_api/urls.py +0 -0
  79. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/__init__.py +0 -0
  80. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/admin.py +0 -0
  81. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/agent.py +0 -0
  82. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/apps.py +0 -0
  83. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/deps.py +0 -0
  84. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/migrations/0001_initial.py +0 -0
  85. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/migrations/__init__.py +0 -0
  86. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/models.py +0 -0
  87. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/routing.py +0 -0
  88. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_assistant/tests/__init__.py +0 -0
  89. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/__init__.py +0 -0
  90. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/apps.py +0 -0
  91. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/static/crudkit_frontend/assets/favicon-Cit_9t8D.svg +0 -0
  92. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/tests/__init__.py +0 -0
  93. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/tests/urls.py +0 -0
  94. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/urls.py +0 -0
  95. {crudkit-0.2.0 → crudkit-0.2.2}/src/crudkit_frontend/views.py +0 -0
  96. {crudkit-0.2.0 → crudkit-0.2.2}/tests/__init__.py +0 -0
  97. {crudkit-0.2.0 → crudkit-0.2.2}/tests/celery.py +0 -0
  98. {crudkit-0.2.0 → crudkit-0.2.2}/tests/settings.py +0 -0
  99. {crudkit-0.2.0 → crudkit-0.2.2}/tests/testapp/__init__.py +0 -0
  100. {crudkit-0.2.0 → crudkit-0.2.2}/tests/testapp/adapters.py +0 -0
  101. {crudkit-0.2.0 → crudkit-0.2.2}/tests/testapp/ai.py +0 -0
  102. {crudkit-0.2.0 → crudkit-0.2.2}/tests/testapp/apps.py +0 -0
  103. {crudkit-0.2.0 → crudkit-0.2.2}/tests/testapp/migrations/0001_initial.py +0 -0
  104. {crudkit-0.2.0 → crudkit-0.2.2}/tests/testapp/migrations/__init__.py +0 -0
  105. {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.0 (2026-08-11)
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.0
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/`. Contributors hacking on the SPA itself
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/`. Contributors hacking on the SPA itself
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.0"
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"
@@ -6,4 +6,5 @@ class CrudKitConfig(AppConfig):
6
6
  name = "crudkit"
7
7
 
8
8
  def ready(self):
9
+ import crudkit.checks # noqa: F401 # App registry must load model classes first.
9
10
  import crudkit.signals # noqa
@@ -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
- type_id, pk = parse_ck_id(value)
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
- type_id, pk = parse_ck_id(value)
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
- unique_together = [("created_by", "end_at")]
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
- # Overlapping cases:
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
+ )