crudkit 0.2.1__tar.gz → 0.2.3__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.1 → crudkit-0.2.3}/CHANGELOG.md +28 -0
  2. {crudkit-0.2.1 → crudkit-0.2.3}/PKG-INFO +11 -1
  3. {crudkit-0.2.1 → crudkit-0.2.3}/README.md +10 -0
  4. {crudkit-0.2.1 → crudkit-0.2.3}/pyproject.toml +1 -1
  5. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/apps.py +1 -0
  6. crudkit-0.2.3/src/crudkit/authorization.py +54 -0
  7. crudkit-0.2.3/src/crudkit/checks.py +43 -0
  8. crudkit-0.2.3/src/crudkit/migrations/0002_workspace.py +83 -0
  9. crudkit-0.2.3/src/crudkit/migrations/0003_enforce_data_invariants.py +57 -0
  10. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/models.py +99 -21
  11. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tests/test_fields.py +31 -0
  12. crudkit-0.2.3/src/crudkit/tests/test_integrity.py +100 -0
  13. crudkit-0.2.3/src/crudkit/tests/test_workspace.py +127 -0
  14. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/filters.py +12 -1
  15. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/metadata.py +6 -3
  16. crudkit-0.2.3/src/crudkit_api/permissions.py +23 -0
  17. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/serializers.py +55 -39
  18. crudkit-0.2.3/src/crudkit_api/tests/test_authorization.py +147 -0
  19. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/tests/test_email_id_serialization.py +43 -1
  20. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/tests/test_filters.py +6 -1
  21. crudkit-0.2.3/src/crudkit_api/tests/test_list_serialization.py +128 -0
  22. crudkit-0.2.3/src/crudkit_api/tests/test_merge.py +33 -0
  23. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/urls.py +20 -1
  24. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/views.py +61 -44
  25. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/consumers.py +16 -3
  26. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/execution.py +3 -0
  27. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/runner.py +4 -2
  28. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/tests/test_proposals.py +24 -0
  29. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/tools.py +16 -10
  30. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/utils.py +8 -0
  31. crudkit-0.2.1/src/crudkit_frontend/static/crudkit_frontend/assets/index-Cj0Mdbti.js → crudkit-0.2.3/src/crudkit_frontend/static/crudkit_frontend/assets/index-CmafPWf5.js +179 -178
  32. crudkit-0.2.3/src/crudkit_frontend/static/crudkit_frontend/assets/index-EHRF7Jzw.css +1 -0
  33. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/templates/crudkit_frontend/index.html +2 -2
  34. {crudkit-0.2.1 → crudkit-0.2.3}/tests/settings.py +1 -1
  35. crudkit-0.2.3/tests/testapp/migrations/0002_customer_currency.py +19 -0
  36. {crudkit-0.2.1 → crudkit-0.2.3}/tests/testapp/models.py +8 -1
  37. crudkit-0.2.1/src/crudkit_frontend/static/crudkit_frontend/assets/index-CL5BO7Fx.css +0 -1
  38. {crudkit-0.2.1 → crudkit-0.2.3}/.gitignore +0 -0
  39. {crudkit-0.2.1 → crudkit-0.2.3}/manage.py +0 -0
  40. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/__init__.py +0 -0
  41. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/admin.py +0 -0
  42. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/ai_backend.py +0 -0
  43. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/decorators.py +0 -0
  44. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/fields.py +0 -0
  45. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/llm.py +0 -0
  46. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/management/__init__.py +0 -0
  47. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/management/commands/__init__.py +0 -0
  48. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/management/commands/add_default_exchange_rates.py +0 -0
  49. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/management/commands/backfill_ai_fields.py +0 -0
  50. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/management/commands/set_exchange_rate.py +0 -0
  51. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/migrations/0001_squashed.py +0 -0
  52. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/migrations/__init__.py +0 -0
  53. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/profile.py +0 -0
  54. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/signals.py +0 -0
  55. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tasks.py +0 -0
  56. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/templatetags/__init__.py +0 -0
  57. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/templatetags/crm_forms.py +0 -0
  58. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/templatetags/crm_time.py +0 -0
  59. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/templatetags/gravatar.py +0 -0
  60. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/templatetags/model_magic.py +0 -0
  61. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tests/__init__.py +0 -0
  62. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tests/test_ai_backend.py +0 -0
  63. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tests/test_ai_fields.py +0 -0
  64. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tests/test_ai_tasks.py +0 -0
  65. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tests/test_backfill_command.py +0 -0
  66. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tests/test_llm.py +0 -0
  67. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tests/test_money.py +0 -0
  68. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tests/test_tasks.py +0 -0
  69. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/utils.py +0 -0
  70. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/widgets.py +0 -0
  71. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/__init__.py +0 -0
  72. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/authentication.py +0 -0
  73. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/pagination.py +0 -0
  74. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/tests/__init__.py +0 -0
  75. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/tests/test_metadata.py +0 -0
  76. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/tests/test_user_profile.py +0 -0
  77. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/__init__.py +0 -0
  78. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/admin.py +0 -0
  79. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/agent.py +0 -0
  80. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/apps.py +0 -0
  81. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/deps.py +0 -0
  82. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/migrations/0001_initial.py +0 -0
  83. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/migrations/__init__.py +0 -0
  84. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/models.py +0 -0
  85. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/routing.py +0 -0
  86. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/tests/__init__.py +0 -0
  87. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/__init__.py +0 -0
  88. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/apps.py +0 -0
  89. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/context_processors.py +0 -0
  90. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/static/crudkit_frontend/assets/favicon-Cit_9t8D.svg +0 -0
  91. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/tests/__init__.py +0 -0
  92. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/tests/templates/crudkit_frontend/index.html +0 -0
  93. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/tests/test_frontend.py +0 -0
  94. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/tests/urls.py +0 -0
  95. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/urls.py +0 -0
  96. {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/views.py +0 -0
  97. {crudkit-0.2.1 → crudkit-0.2.3}/tests/__init__.py +0 -0
  98. {crudkit-0.2.1 → crudkit-0.2.3}/tests/celery.py +0 -0
  99. {crudkit-0.2.1 → crudkit-0.2.3}/tests/testapp/__init__.py +0 -0
  100. {crudkit-0.2.1 → crudkit-0.2.3}/tests/testapp/adapters.py +0 -0
  101. {crudkit-0.2.1 → crudkit-0.2.3}/tests/testapp/ai.py +0 -0
  102. {crudkit-0.2.1 → crudkit-0.2.3}/tests/testapp/apps.py +0 -0
  103. {crudkit-0.2.1 → crudkit-0.2.3}/tests/testapp/migrations/0001_initial.py +0 -0
  104. {crudkit-0.2.1 → crudkit-0.2.3}/tests/testapp/migrations/__init__.py +0 -0
  105. {crudkit-0.2.1 → crudkit-0.2.3}/tests/urls.py +0 -0
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.3 (2026-09-21)
4
+
5
+ - `crudkit_api`: `GET /api/v1/` no longer returns 403 under `CrudKitModelPermissions`.
6
+ `has_permission` checks authentication first and honours DRF's
7
+ `_ignore_model_permissions`; the API root (`CrudKitRouter`/`CrudKitAPIRootView`) now lists
8
+ only the models the user may view. (#25)
9
+ - `crudkit_frontend`: the detail view wraps long (over 60 characters) and multiline text,
10
+ with a Show more/less toggle when it overflows, instead of a single ellipsised line. (#26)
11
+ - `crudkit_api`: fix a 500 when listing FeedItem or ExternalObject rows; generic relations
12
+ are no longer included in the list prefetch. (#27)
13
+ - `crudkit`: multi-table-inheritance children are reachable by their own CK-ID again; the
14
+ parent's `CrudKitIDField` accepts the TYPE_IDs of its descendants. (#28)
15
+
16
+ ## 0.2.2 (2026-09-19)
17
+
18
+ - `crudkit_api`: list endpoints no longer build a serializer per row. `GenericViewSet.list`
19
+ reuses one serializer for the whole page, `GenericSerializer.build_nested_field` caches the
20
+ generated nested field classes per (related model, depth), and the nested serializer behind a
21
+ forward FK is built once per field instead of once per value. A 500-row page of a model with
22
+ 11 FKs went from a 216 MB to a 15 MB peak downstream (72 MB to 8.5 MB in the test suite's
23
+ smaller fixtures) and serializes ~2.6x faster; the JSON is unchanged. Worker RSS on gunicorn
24
+ no longer climbs from refetching kanban/swimlane views, which request `page_size=500`.
25
+
26
+ - Workspaces: new `Workspace` model (TYPE_ID `WSP`) — switchable sidebar
27
+ apps that pin an ordered set of saved views as tabs, picked from a switcher
28
+ in the sidebar header. Purely additive: deployments with no Workspace rows
29
+ render exactly as before.
30
+
3
31
  ## 0.2.1 (2026-08-11)
4
32
 
5
33
  - `crudkit_frontend`: asset URLs are no longer hardcoded to `/static/`. The
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: crudkit
3
- Version: 0.2.1
3
+ Version: 0.2.3
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 |
@@ -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 |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "crudkit"
3
- version = "0.2.1"
3
+ version = "0.2.3"
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
+ ]
@@ -1,3 +1,4 @@
1
+ import functools
1
2
  import re
2
3
 
3
4
  from django.conf import settings
@@ -20,7 +21,16 @@ def get_ck_id(type_id, pk):
20
21
  return f"{type_id}{pk}"
21
22
 
22
23
 
24
+ @functools.lru_cache(maxsize=None)
25
+ def get_accepted_type_ids(model):
26
+ """The TYPE_IDs that address a row of `model`: its own, plus those of its
27
+ multi-table-inheritance children, which share the parent's pk column."""
28
+ return frozenset(type_id for type_id, mdl in get_model_types().items() if issubclass(mdl, model))
29
+
30
+
23
31
  def parse_ck_id(ck_id: str) -> tuple[str, int]:
32
+ if not isinstance(ck_id, str) or not ck_id_regex.fullmatch(ck_id):
33
+ raise ValueError(f"Invalid CrudKit ID: {ck_id!r}")
24
34
  return ck_id[0:3], int(ck_id[3:])
25
35
 
26
36
 
@@ -58,7 +68,11 @@ class CrudKitIDField(models.BigAutoField):
58
68
  if value == "":
59
69
  return None
60
70
  if type(value) is str and value is not None:
61
- type_id, pk = parse_ck_id(value)
71
+ if value.isdigit():
72
+ pk = int(value)
73
+ else:
74
+ type_id, pk = parse_ck_id(value)
75
+ self._validate_type_id(type_id)
62
76
  else:
63
77
  pk = value
64
78
  return super().get_prep_value(pk)
@@ -71,10 +85,21 @@ class CrudKitIDField(models.BigAutoField):
71
85
  if value == "":
72
86
  return super().get_db_prep_value(None, connection, prepared)
73
87
  if value is not None:
74
- type_id, pk = parse_ck_id(value)
88
+ if isinstance(value, str) and value.isdigit():
89
+ pk = int(value)
90
+ else:
91
+ type_id, pk = parse_ck_id(value)
92
+ self._validate_type_id(type_id)
75
93
  return super().get_db_prep_value(pk, connection, prepared)
76
94
  return super().get_db_prep_value(value, connection, prepared)
77
95
 
96
+ def _validate_type_id(self, type_id):
97
+ expected = getattr(self.model, "TYPE_ID", None)
98
+ if expected is None or type_id == expected:
99
+ return
100
+ if type_id not in get_accepted_type_ids(self.model):
101
+ raise ValueError(f"Expected a {expected} ID, got {type_id}")
102
+
78
103
 
79
104
  class CrudKitPositiveIntegerField(models.PositiveIntegerField):
80
105
  def from_db_value(self, value, expression, connection):
@@ -352,6 +377,14 @@ class BaseCrudKitModel(models.Model):
352
377
  # capabilities without touching the framework app.
353
378
  assistant_tools = []
354
379
 
380
+ @staticmethod
381
+ def get_authorized_queryset(user, queryset, action):
382
+ return queryset
383
+
384
+ @staticmethod
385
+ def has_action_permission(user, instance, action_name):
386
+ return True
387
+
355
388
  class Meta:
356
389
  abstract = True
357
390
  ordering = ["id"]
@@ -577,6 +610,12 @@ class View(BaseCrudKitModel):
577
610
  class CrudKitSettings(BaseCrudKitModel.CrudKitSettings):
578
611
  allowed_prefills = ["model", "fields"]
579
612
 
613
+ @staticmethod
614
+ def get_authorized_queryset(user, queryset, action):
615
+ if action == "view":
616
+ return queryset.filter(models.Q(public=True) | models.Q(created_by=user))
617
+ return queryset.filter(created_by=user)
618
+
580
619
  @classmethod
581
620
  def from_query_params(cls, params, kwargs=None):
582
621
  instance = super().from_query_params(params, kwargs)
@@ -585,6 +624,49 @@ class View(BaseCrudKitModel):
585
624
  return instance
586
625
 
587
626
 
627
+ class Workspace(BaseCrudKitModel):
628
+ TYPE_ID = "WSP"
629
+
630
+ name = models.CharField(max_length=128)
631
+ icon = models.CharField(
632
+ max_length=64,
633
+ blank=True,
634
+ null=True,
635
+ help_text=_("Icon name shown in the workspace switcher"),
636
+ )
637
+ public = models.BooleanField(blank=True, default=True)
638
+ views = models.JSONField(
639
+ blank=True,
640
+ default=list,
641
+ encoder=PrettyJSONEncoder,
642
+ help_text='Format: ["VIW1", "VIW2", ...] — tab order follows the list order',
643
+ )
644
+
645
+ class Meta:
646
+ ordering = ["name"]
647
+
648
+ class CrudKitSettings(BaseCrudKitModel.CrudKitSettings):
649
+ search_fields = ["name"]
650
+
651
+ @staticmethod
652
+ def get_authorized_queryset(user, queryset, action):
653
+ if action == "view":
654
+ return queryset.filter(models.Q(public=True) | models.Q(created_by=user))
655
+ return queryset.filter(created_by=user)
656
+
657
+ def __str__(self):
658
+ return self.name
659
+
660
+ def clean(self):
661
+ if not isinstance(self.views, list):
662
+ raise ValidationError({"views": _('Format: ["VIW1", "VIW2", ...]')})
663
+ for ck_id in self.views:
664
+ if not isinstance(ck_id, str) or not ck_id_regex.match(ck_id) or ck_id[:3] != View.TYPE_ID:
665
+ raise ValidationError({"views": _(f"'{ck_id}' is not a View id.")})
666
+ if not View.objects.filter(pk=ck_id).exists():
667
+ raise ValidationError({"views": _(f"View '{ck_id}' does not exist.")})
668
+
669
+
588
670
  class FeedItem(BaseCrudKitModel):
589
671
  TYPE_ID = "FEI"
590
672
 
@@ -767,7 +849,13 @@ class WorkLog(BaseCrudKitModel):
767
849
  models.Index(fields=["created_by", "end_at"]),
768
850
  models.Index(fields=["related_content_type", "related_object_id"]),
769
851
  ]
770
- unique_together = [("created_by", "end_at")]
852
+ constraints = [
853
+ models.UniqueConstraint(
854
+ fields=["created_by"],
855
+ condition=models.Q(end_at__isnull=True),
856
+ name="crudkit_one_active_worklog_per_user",
857
+ )
858
+ ]
771
859
 
772
860
  def __str__(self):
773
861
  return f"Work log on {self.related_object} from {self.created_at}"
@@ -813,6 +901,12 @@ class ExchangeRate(BaseCrudKitModel):
813
901
  indexes = [
814
902
  models.Index(fields=["currency", "from_date", "to_date"]),
815
903
  ]
904
+ constraints = [
905
+ models.CheckConstraint(
906
+ condition=models.Q(to_date__isnull=True) | models.Q(to_date__gte=models.F("from_date")),
907
+ name="crudkit_exchange_rate_dates_ordered",
908
+ )
909
+ ]
816
910
 
817
911
  def __str__(self):
818
912
  to_date_str = f" - {self.to_date}" if self.to_date else " - ongoing"
@@ -822,29 +916,13 @@ class ExchangeRate(BaseCrudKitModel):
822
916
  """
823
917
  Validate that there are no overlaps for a currency in terms of from_date and to_date.
824
918
  """
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
919
  # Check for start date after end date
830
920
  if self.to_date and self.from_date > self.to_date:
831
921
  raise ValidationError({"to_date": _("End date must be after start date")})
832
922
 
833
923
  # 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
- )
924
+ overlapping_query = models.Q(from_date__lte=self.to_date) if self.to_date else models.Q()
925
+ overlapping_query &= models.Q(to_date__isnull=True) | models.Q(to_date__gte=self.from_date)
848
926
 
849
927
  overlapping_filter = ExchangeRate.objects.filter(currency=self.currency).filter(overlapping_query)
850
928
 
@@ -1,7 +1,9 @@
1
+ from django.contrib.auth.models import User
1
2
  from django.db import connection
2
3
  from django.test import TestCase
3
4
 
4
5
  from crudkit.models import CrudKitIDField, CrudKitPositiveIntegerField, parse_ck_id
6
+ from tests.testapp.models import Comment, Ticket, UrgentComment
5
7
 
6
8
 
7
9
  class TestModel:
@@ -121,3 +123,32 @@ class CrudKitPositiveIntegerFieldTests(TestCase):
121
123
  pk = 42
122
124
 
123
125
  self.assertEqual(self.field.get_prep_value(_FakeInstance()), 42)
126
+
127
+
128
+ class SubclassCkIdLookupTests(TestCase):
129
+ """A multi-table-inheritance child shares the parent's pk column, so both the
130
+ parent's and the child's TYPE_ID address the same row."""
131
+
132
+ def setUp(self):
133
+ self.user = User.objects.create_user(username="fielduser", password="pw")
134
+ self.ticket = Ticket.objects.create(subject="Broken invoice", created_by=self.user, updated_by=self.user)
135
+ self.comment = UrgentComment.objects.create(
136
+ ticket=self.ticket,
137
+ body="Test content",
138
+ created_by=self.user,
139
+ updated_by=self.user,
140
+ )
141
+ self.pk = int(str(self.comment.pk).removeprefix("COM").removeprefix("URG"))
142
+
143
+ def test_lookup_by_child_type_id(self):
144
+ self.assertEqual(UrgentComment.objects.get(pk=f"URG{self.pk}"), self.comment)
145
+
146
+ def test_lookup_by_parent_type_id(self):
147
+ self.assertEqual(UrgentComment.objects.get(pk=f"COM{self.pk}"), self.comment)
148
+
149
+ def test_parent_queryset_accepts_child_type_id(self):
150
+ self.assertEqual(Comment.objects.get(pk=f"URG{self.pk}").pk, self.comment.pk)
151
+
152
+ def test_unrelated_type_id_still_rejected(self):
153
+ with self.assertRaises(ValueError):
154
+ UrgentComment.objects.get(pk=f"TIC{self.pk}")
@@ -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
+ )