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.
- {crudkit-0.2.1 → crudkit-0.2.3}/CHANGELOG.md +28 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/PKG-INFO +11 -1
- {crudkit-0.2.1 → crudkit-0.2.3}/README.md +10 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/pyproject.toml +1 -1
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/apps.py +1 -0
- crudkit-0.2.3/src/crudkit/authorization.py +54 -0
- crudkit-0.2.3/src/crudkit/checks.py +43 -0
- crudkit-0.2.3/src/crudkit/migrations/0002_workspace.py +83 -0
- crudkit-0.2.3/src/crudkit/migrations/0003_enforce_data_invariants.py +57 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/models.py +99 -21
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tests/test_fields.py +31 -0
- crudkit-0.2.3/src/crudkit/tests/test_integrity.py +100 -0
- crudkit-0.2.3/src/crudkit/tests/test_workspace.py +127 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/filters.py +12 -1
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/metadata.py +6 -3
- crudkit-0.2.3/src/crudkit_api/permissions.py +23 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/serializers.py +55 -39
- crudkit-0.2.3/src/crudkit_api/tests/test_authorization.py +147 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/tests/test_email_id_serialization.py +43 -1
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/tests/test_filters.py +6 -1
- crudkit-0.2.3/src/crudkit_api/tests/test_list_serialization.py +128 -0
- crudkit-0.2.3/src/crudkit_api/tests/test_merge.py +33 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/urls.py +20 -1
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/views.py +61 -44
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/consumers.py +16 -3
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/execution.py +3 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/runner.py +4 -2
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/tests/test_proposals.py +24 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/tools.py +16 -10
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/utils.py +8 -0
- 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
- crudkit-0.2.3/src/crudkit_frontend/static/crudkit_frontend/assets/index-EHRF7Jzw.css +1 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/templates/crudkit_frontend/index.html +2 -2
- {crudkit-0.2.1 → crudkit-0.2.3}/tests/settings.py +1 -1
- crudkit-0.2.3/tests/testapp/migrations/0002_customer_currency.py +19 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/tests/testapp/models.py +8 -1
- crudkit-0.2.1/src/crudkit_frontend/static/crudkit_frontend/assets/index-CL5BO7Fx.css +0 -1
- {crudkit-0.2.1 → crudkit-0.2.3}/.gitignore +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/manage.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/__init__.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/admin.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/ai_backend.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/decorators.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/fields.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/llm.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/management/__init__.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/management/commands/__init__.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/management/commands/add_default_exchange_rates.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/management/commands/backfill_ai_fields.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/management/commands/set_exchange_rate.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/migrations/0001_squashed.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/migrations/__init__.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/profile.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/signals.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tasks.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/templatetags/__init__.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/templatetags/crm_forms.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/templatetags/crm_time.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/templatetags/gravatar.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/templatetags/model_magic.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tests/__init__.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tests/test_ai_backend.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tests/test_ai_fields.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tests/test_ai_tasks.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tests/test_backfill_command.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tests/test_llm.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tests/test_money.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/tests/test_tasks.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/utils.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit/widgets.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/__init__.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/authentication.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/pagination.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/tests/__init__.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/tests/test_metadata.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_api/tests/test_user_profile.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/__init__.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/admin.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/agent.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/apps.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/deps.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/migrations/0001_initial.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/migrations/__init__.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/models.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/routing.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_assistant/tests/__init__.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/__init__.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/apps.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/context_processors.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/static/crudkit_frontend/assets/favicon-Cit_9t8D.svg +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/tests/__init__.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/tests/templates/crudkit_frontend/index.html +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/tests/test_frontend.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/tests/urls.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/urls.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/src/crudkit_frontend/views.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/tests/__init__.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/tests/celery.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/tests/testapp/__init__.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/tests/testapp/adapters.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/tests/testapp/ai.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/tests/testapp/apps.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/tests/testapp/migrations/0001_initial.py +0 -0
- {crudkit-0.2.1 → crudkit-0.2.3}/tests/testapp/migrations/__init__.py +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
)
|