crudkit 0.2.2__tar.gz → 0.3.0__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.2 → crudkit-0.3.0}/CHANGELOG.md +29 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/PKG-INFO +1 -1
- {crudkit-0.2.2 → crudkit-0.3.0}/pyproject.toml +1 -1
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/models.py +13 -1
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/tests/test_fields.py +31 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_api/metadata.py +5 -2
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_api/permissions.py +7 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_api/tests/test_authorization.py +37 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_api/tests/test_email_id_serialization.py +42 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_api/tests/test_list_serialization.py +31 -1
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_api/tests/test_metadata.py +12 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_api/urls.py +20 -1
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_api/views.py +8 -2
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_frontend/context_processors.py +3 -1
- crudkit-0.2.2/src/crudkit_frontend/static/crudkit_frontend/assets/index-DD2G9RcN.js → crudkit-0.3.0/src/crudkit_frontend/static/crudkit_frontend/assets/index-BYJWd3yY.js +179 -176
- crudkit-0.3.0/src/crudkit_frontend/static/crudkit_frontend/assets/index-D_4ufdxB.css +1 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_frontend/templates/crudkit_frontend/index.html +2 -2
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_frontend/tests/test_frontend.py +3 -1
- {crudkit-0.2.2 → crudkit-0.3.0}/tests/settings.py +1 -1
- {crudkit-0.2.2 → crudkit-0.3.0}/tests/testapp/models.py +6 -0
- crudkit-0.2.2/src/crudkit_frontend/static/crudkit_frontend/assets/index-jAk-LEvm.css +0 -1
- {crudkit-0.2.2 → crudkit-0.3.0}/.gitignore +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/README.md +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/manage.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/__init__.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/admin.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/ai_backend.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/apps.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/authorization.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/checks.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/decorators.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/fields.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/llm.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/management/__init__.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/management/commands/__init__.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/management/commands/add_default_exchange_rates.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/management/commands/backfill_ai_fields.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/management/commands/set_exchange_rate.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/migrations/0001_squashed.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/migrations/0002_workspace.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/migrations/0003_enforce_data_invariants.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/migrations/__init__.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/profile.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/signals.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/tasks.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/templatetags/__init__.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/templatetags/crm_forms.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/templatetags/crm_time.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/templatetags/gravatar.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/templatetags/model_magic.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/tests/__init__.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/tests/test_ai_backend.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/tests/test_ai_fields.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/tests/test_ai_tasks.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/tests/test_backfill_command.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/tests/test_integrity.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/tests/test_llm.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/tests/test_money.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/tests/test_tasks.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/tests/test_workspace.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/utils.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit/widgets.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_api/__init__.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_api/authentication.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_api/filters.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_api/pagination.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_api/serializers.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_api/tests/__init__.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_api/tests/test_filters.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_api/tests/test_merge.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_api/tests/test_user_profile.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_assistant/__init__.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_assistant/admin.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_assistant/agent.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_assistant/apps.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_assistant/consumers.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_assistant/deps.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_assistant/execution.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_assistant/migrations/0001_initial.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_assistant/migrations/__init__.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_assistant/models.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_assistant/routing.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_assistant/runner.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_assistant/tests/__init__.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_assistant/tests/test_proposals.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_assistant/tools.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_assistant/utils.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_frontend/__init__.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_frontend/apps.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_frontend/static/crudkit_frontend/assets/favicon-Cit_9t8D.svg +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_frontend/tests/__init__.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_frontend/tests/templates/crudkit_frontend/index.html +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_frontend/tests/urls.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_frontend/urls.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/src/crudkit_frontend/views.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/tests/__init__.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/tests/celery.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/tests/testapp/__init__.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/tests/testapp/adapters.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/tests/testapp/ai.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/tests/testapp/apps.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/tests/testapp/migrations/0001_initial.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/tests/testapp/migrations/0002_customer_currency.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/tests/testapp/migrations/__init__.py +0 -0
- {crudkit-0.2.2 → crudkit-0.3.0}/tests/urls.py +0 -0
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (2026-09-21)
|
|
4
|
+
|
|
5
|
+
- `crudkit_frontend`: related-record pickers offer `+ Create <model> "<typed text>"`, which
|
|
6
|
+
opens the target's create form in a modal (prefilled from the first `search_fields` entry)
|
|
7
|
+
and selects the new record without leaving the form. Modals nest; Esc/Enter only affect the
|
|
8
|
+
topmost one. (#34)
|
|
9
|
+
- `crudkit_api`: the metadata endpoint also returns `search_fields`, `can_create` (add
|
|
10
|
+
permission for the requesting user) and `inline_create`. `build_model_metadata` takes an
|
|
11
|
+
optional `user`. (#34)
|
|
12
|
+
- `crudkit`: new `CrudKitSettings.inline_create` (default `True`); set `False` to hide the
|
|
13
|
+
inline create option for a model. (#34)
|
|
14
|
+
- `crudkit_frontend`: a failed Kanban/Swimlane drop shows the server's validation error
|
|
15
|
+
instead of a generic message. (#31)
|
|
16
|
+
- `crudkit_frontend`: inline email images are hidden behind a toggle; image attachments
|
|
17
|
+
preview on hover. (#32)
|
|
18
|
+
|
|
19
|
+
## 0.2.3 (2026-09-21)
|
|
20
|
+
|
|
21
|
+
- `crudkit_api`: `GET /api/v1/` no longer returns 403 under `CrudKitModelPermissions`.
|
|
22
|
+
`has_permission` checks authentication first and honours DRF's
|
|
23
|
+
`_ignore_model_permissions`; the API root (`CrudKitRouter`/`CrudKitAPIRootView`) now lists
|
|
24
|
+
only the models the user may view. (#25)
|
|
25
|
+
- `crudkit_frontend`: the detail view wraps long (over 60 characters) and multiline text,
|
|
26
|
+
with a Show more/less toggle when it overflows, instead of a single ellipsised line. (#26)
|
|
27
|
+
- `crudkit_api`: fix a 500 when listing FeedItem or ExternalObject rows; generic relations
|
|
28
|
+
are no longer included in the list prefetch. (#27)
|
|
29
|
+
- `crudkit`: multi-table-inheritance children are reachable by their own CK-ID again; the
|
|
30
|
+
parent's `CrudKitIDField` accepts the TYPE_IDs of its descendants. (#28)
|
|
31
|
+
|
|
3
32
|
## 0.2.2 (2026-09-19)
|
|
4
33
|
|
|
5
34
|
- `crudkit_api`: list endpoints no longer build a serializer per row. `GenericViewSet.list`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: crudkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "crudkit"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
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"
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import functools
|
|
1
2
|
import re
|
|
2
3
|
|
|
3
4
|
from django.conf import settings
|
|
@@ -20,6 +21,13 @@ 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]:
|
|
24
32
|
if not isinstance(ck_id, str) or not ck_id_regex.fullmatch(ck_id):
|
|
25
33
|
raise ValueError(f"Invalid CrudKit ID: {ck_id!r}")
|
|
@@ -87,7 +95,9 @@ class CrudKitIDField(models.BigAutoField):
|
|
|
87
95
|
|
|
88
96
|
def _validate_type_id(self, type_id):
|
|
89
97
|
expected = getattr(self.model, "TYPE_ID", None)
|
|
90
|
-
if expected is
|
|
98
|
+
if expected is None or type_id == expected:
|
|
99
|
+
return
|
|
100
|
+
if type_id not in get_accepted_type_ids(self.model):
|
|
91
101
|
raise ValueError(f"Expected a {expected} ID, got {type_id}")
|
|
92
102
|
|
|
93
103
|
|
|
@@ -357,6 +367,8 @@ class BaseCrudKitModel(models.Model):
|
|
|
357
367
|
class CrudKitSettings:
|
|
358
368
|
allowed_prefills = []
|
|
359
369
|
search_fields = []
|
|
370
|
+
# Offer "Create new…" in related-object pickers targeting this model.
|
|
371
|
+
inline_create = True
|
|
360
372
|
ai_trigger_children = []
|
|
361
373
|
# Playbook prompt for the per-object AI assistant. Empty → generic
|
|
362
374
|
# fallback prompt is used. Subclasses override to teach the assistant
|
|
@@ -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}")
|
|
@@ -14,7 +14,7 @@ from django.apps import apps
|
|
|
14
14
|
from django.conf import settings
|
|
15
15
|
from django.db.models import Model
|
|
16
16
|
|
|
17
|
-
from crudkit.authorization import get_authorized_queryset
|
|
17
|
+
from crudkit.authorization import get_authorized_queryset, has_model_permission
|
|
18
18
|
from crudkit.fields import ModelField
|
|
19
19
|
from crudkit.models import ExternalObject, FeedItem
|
|
20
20
|
from crudkit.utils import get_model_types
|
|
@@ -108,7 +108,7 @@ def _generic_relations() -> list[dict[str, Any]]:
|
|
|
108
108
|
return relations
|
|
109
109
|
|
|
110
110
|
|
|
111
|
-
def build_model_metadata(model) -> dict[str, Any]:
|
|
111
|
+
def build_model_metadata(model, user=None) -> dict[str, Any]:
|
|
112
112
|
"""Return the model-level metadata payload served at /api/v1/<type>/metadata/."""
|
|
113
113
|
mfc = _model_field_choices()
|
|
114
114
|
fields = OrderedDict((field.name, _field_metadata(field, mfc)) for field in model._meta.fields)
|
|
@@ -132,6 +132,9 @@ def build_model_metadata(model) -> dict[str, Any]:
|
|
|
132
132
|
"allowed_prefills": list(
|
|
133
133
|
getattr(model.CrudKitSettings, "allowed_prefills", []),
|
|
134
134
|
),
|
|
135
|
+
"search_fields": list(getattr(model.CrudKitSettings, "search_fields", [])),
|
|
136
|
+
"can_create": has_model_permission(user, model, "add"),
|
|
137
|
+
"inline_create": getattr(model.CrudKitSettings, "inline_create", True),
|
|
135
138
|
"fields": fields,
|
|
136
139
|
"relations": relations,
|
|
137
140
|
"actions": [{"verbose_name": func.verbose_name, "action": action} for action, func in model()._actions.items()],
|
|
@@ -5,6 +5,13 @@ from crudkit.authorization import get_permission_action, has_model_permission, h
|
|
|
5
5
|
|
|
6
6
|
class CrudKitModelPermissions(BasePermission):
|
|
7
7
|
def has_permission(self, request, view):
|
|
8
|
+
if not getattr(request.user, "is_authenticated", False):
|
|
9
|
+
return False
|
|
10
|
+
# Set by DRF on rest_framework.routers.APIRootView (inherited by
|
|
11
|
+
# CrudKitAPIRootView) and on SchemaView: views with no model opting out
|
|
12
|
+
# of model permissions. DjangoModelPermissions honours it the same way.
|
|
13
|
+
if getattr(view, "_ignore_model_permissions", False):
|
|
14
|
+
return True
|
|
8
15
|
queryset = getattr(view, "queryset", None)
|
|
9
16
|
if queryset is None:
|
|
10
17
|
return False
|
|
@@ -3,6 +3,7 @@ from django.test import TestCase
|
|
|
3
3
|
from rest_framework.test import APIClient
|
|
4
4
|
|
|
5
5
|
from crudkit.models import View, Workspace
|
|
6
|
+
from crudkit.utils import get_model_types
|
|
6
7
|
from tests.testapp.models import Customer, Ticket
|
|
7
8
|
|
|
8
9
|
|
|
@@ -108,3 +109,39 @@ class GenericAPIAuthorizationTests(TestCase):
|
|
|
108
109
|
|
|
109
110
|
self.assertEqual(response.status_code, 200)
|
|
110
111
|
self.assertEqual([row["id"] for row in response.data["results"]], [self.customer.pk])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class APIRootAuthorizationTests(TestCase):
|
|
115
|
+
def setUp(self):
|
|
116
|
+
self.client = APIClient()
|
|
117
|
+
|
|
118
|
+
def test_anonymous_requests_are_rejected(self):
|
|
119
|
+
response = self.client.get("/api/v1/")
|
|
120
|
+
self.assertEqual(response.status_code, 401)
|
|
121
|
+
|
|
122
|
+
def test_superuser_sees_every_model(self):
|
|
123
|
+
self.client.force_authenticate(User.objects.create_superuser(username="root", password="pw"))
|
|
124
|
+
|
|
125
|
+
response = self.client.get("/api/v1/")
|
|
126
|
+
|
|
127
|
+
self.assertEqual(response.status_code, 200)
|
|
128
|
+
self.assertLessEqual({"CUS", "TIC"}, set(response.data))
|
|
129
|
+
|
|
130
|
+
def test_root_only_lists_models_the_user_can_view(self):
|
|
131
|
+
user = User.objects.create_user(username="other", password="pw")
|
|
132
|
+
grant(user, Customer, "view")
|
|
133
|
+
self.client.force_authenticate(user)
|
|
134
|
+
|
|
135
|
+
response = self.client.get("/api/v1/")
|
|
136
|
+
|
|
137
|
+
self.assertEqual(response.status_code, 200)
|
|
138
|
+
self.assertIn("CUS", response.data)
|
|
139
|
+
self.assertNotIn("TIC", response.data)
|
|
140
|
+
|
|
141
|
+
def test_user_without_model_permissions_gets_an_empty_listing(self):
|
|
142
|
+
self.client.force_authenticate(User.objects.create_user(username="nobody", password="pw"))
|
|
143
|
+
|
|
144
|
+
response = self.client.get("/api/v1/")
|
|
145
|
+
|
|
146
|
+
self.assertEqual(response.status_code, 200)
|
|
147
|
+
self.assertEqual(set(response.data) & set(get_model_types()), set())
|
|
@@ -93,3 +93,45 @@ class SubclassIDSerializationTest(TestCase):
|
|
|
93
93
|
comment_id.startswith("URG"),
|
|
94
94
|
f"Comment ID should start with URG, but got {comment_id}",
|
|
95
95
|
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SubclassDetailRouteTest(TestCase):
|
|
99
|
+
"""The API serialises a MTI child with its own prefix (URG), so every detail
|
|
100
|
+
route has to accept that id back — retrieve, update, merge and action."""
|
|
101
|
+
|
|
102
|
+
def setUp(self):
|
|
103
|
+
self.user = User.objects.create_superuser(username="detailuser", password="testpass")
|
|
104
|
+
self.client = APIClient()
|
|
105
|
+
self.client.force_authenticate(user=self.user)
|
|
106
|
+
|
|
107
|
+
self.ticket = Ticket.objects.create(
|
|
108
|
+
subject="Broken invoice",
|
|
109
|
+
created_by=self.user,
|
|
110
|
+
updated_by=self.user,
|
|
111
|
+
)
|
|
112
|
+
self.comment = UrgentComment.objects.create(
|
|
113
|
+
ticket=self.ticket,
|
|
114
|
+
body="Test content",
|
|
115
|
+
escalation_reason="production down",
|
|
116
|
+
created_by=self.user,
|
|
117
|
+
updated_by=self.user,
|
|
118
|
+
)
|
|
119
|
+
self.ck_id = self.client.get(f"/api/v1/URG/{self.comment.pk}/").data["id"]
|
|
120
|
+
self.assertTrue(self.ck_id.startswith("URG"))
|
|
121
|
+
|
|
122
|
+
def test_retrieve_by_serialized_id(self):
|
|
123
|
+
response = self.client.get(f"/api/v1/URG/{self.ck_id}/")
|
|
124
|
+
self.assertEqual(response.status_code, 200)
|
|
125
|
+
self.assertEqual(response.data["id"], self.ck_id)
|
|
126
|
+
|
|
127
|
+
def test_update_by_serialized_id(self):
|
|
128
|
+
response = self.client.patch(f"/api/v1/URG/{self.ck_id}/", {"body": "Edited"}, format="json")
|
|
129
|
+
self.assertEqual(response.status_code, 200)
|
|
130
|
+
self.comment.refresh_from_db()
|
|
131
|
+
self.assertEqual(self.comment.body, "Edited")
|
|
132
|
+
|
|
133
|
+
def test_action_by_serialized_id(self):
|
|
134
|
+
response = self.client.post(f"/api/v1/URG/{self.ck_id}/action/", {"action": "escalate"}, format="json")
|
|
135
|
+
self.assertEqual(response.status_code, 200)
|
|
136
|
+
self.comment.refresh_from_db()
|
|
137
|
+
self.assertEqual(self.comment.escalation_reason, "escalated")
|
|
@@ -8,7 +8,7 @@ from rest_framework.request import Request
|
|
|
8
8
|
from rest_framework.test import APIClient, APIRequestFactory
|
|
9
9
|
|
|
10
10
|
from crudkit.fields import DEFAULT_CURRENCY
|
|
11
|
-
from crudkit.models import ExchangeRate
|
|
11
|
+
from crudkit.models import ExchangeRate, ExternalObject, FeedItem
|
|
12
12
|
from crudkit_api.serializers import GenericSerializer, _nested_relation_field, get_serializer
|
|
13
13
|
from crudkit_api.views import GenericViewSet
|
|
14
14
|
from tests.testapp.models import Customer, Ticket, Topic
|
|
@@ -96,3 +96,33 @@ class ListSerializationTest(TestCase):
|
|
|
96
96
|
|
|
97
97
|
def test_nested_field_classes_are_cached(self):
|
|
98
98
|
self.assertIs(_nested_relation_field(Customer, 1), _nested_relation_field(Customer, 1))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class GenericRelationListTest(TestCase):
|
|
102
|
+
"""FeedItem and ExternalObject are the targets of the generic relations
|
|
103
|
+
every model inherits, so on those two the relation names resolve to the
|
|
104
|
+
reverse descriptor. Listing them must not try to prefetch that."""
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def setUpTestData(cls):
|
|
108
|
+
cls.user = User.objects.create_superuser(username="genericrelation", password="testpass")
|
|
109
|
+
audit = {"created_by": cls.user, "updated_by": cls.user}
|
|
110
|
+
cls.customer = Customer.objects.create(name="Acme", **audit)
|
|
111
|
+
cls.feed_item = FeedItem.objects.create(parent_object=cls.customer, body="Called them", **audit)
|
|
112
|
+
ExternalObject.objects.create(system_name="hubspot", system_id="123", related_object=cls.customer, **audit)
|
|
113
|
+
|
|
114
|
+
def setUp(self):
|
|
115
|
+
self.client = APIClient()
|
|
116
|
+
self.client.force_authenticate(user=self.user)
|
|
117
|
+
|
|
118
|
+
def test_feed_item_list_filtered_by_parent(self):
|
|
119
|
+
response = self.client.get(f"/api/v1/FEI/?parent_object={self.customer.pk}")
|
|
120
|
+
self.assertEqual(response.status_code, 200)
|
|
121
|
+
results = response.json()["results"]
|
|
122
|
+
self.assertEqual([row["id"] for row in results], [self.feed_item.pk])
|
|
123
|
+
self.assertEqual(results[0]["parent_object"], self.customer.pk)
|
|
124
|
+
|
|
125
|
+
def test_external_object_list(self):
|
|
126
|
+
response = self.client.get("/api/v1/EXT/")
|
|
127
|
+
self.assertEqual(response.status_code, 200)
|
|
128
|
+
self.assertEqual(len(response.json()["results"]), 1)
|
|
@@ -121,6 +121,18 @@ class BuildModelMetadataTests(TestCase):
|
|
|
121
121
|
self.assertEqual(by_name["worklog"]["related_model_type"], "WLG")
|
|
122
122
|
self.assertEqual(by_name["worklog"]["field_name"], "related_object")
|
|
123
123
|
|
|
124
|
+
def test_can_create_reflects_add_permission(self):
|
|
125
|
+
superuser = User.objects.create_superuser("root", password="x")
|
|
126
|
+
plain = User.objects.create_user("plain", password="x")
|
|
127
|
+
self.assertTrue(build_model_metadata(Customer, superuser)["can_create"])
|
|
128
|
+
self.assertFalse(build_model_metadata(Customer, plain)["can_create"])
|
|
129
|
+
self.assertFalse(build_model_metadata(Customer)["can_create"])
|
|
130
|
+
|
|
131
|
+
def test_search_fields_and_inline_create_exposed(self):
|
|
132
|
+
md = build_model_metadata(Customer)
|
|
133
|
+
self.assertEqual(md["search_fields"], list(Customer.CrudKitSettings.search_fields))
|
|
134
|
+
self.assertTrue(md["inline_create"])
|
|
135
|
+
|
|
124
136
|
def test_extra_generic_relations_absent_by_default(self):
|
|
125
137
|
md = build_model_metadata(Ticket)
|
|
126
138
|
self.assertNotIn("worklog", {rel["name"] for rel in md["relations"]})
|
|
@@ -2,6 +2,7 @@ from django.urls import include, path
|
|
|
2
2
|
from rest_framework import routers
|
|
3
3
|
from rest_framework_simplejwt.views import TokenRefreshView
|
|
4
4
|
|
|
5
|
+
from crudkit.authorization import has_model_permission
|
|
5
6
|
from crudkit.utils import get_model_types
|
|
6
7
|
from crudkit_api.authentication import LoginView, UserProfileView
|
|
7
8
|
from crudkit_api.serializers import get_serializer
|
|
@@ -10,8 +11,26 @@ from crudkit_api.views import GenericViewSet, SearchViewSet, WidgetsViewSet
|
|
|
10
11
|
# ViewSets define the view behavior.
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
class CrudKitAPIRootView(routers.APIRootView):
|
|
15
|
+
"""Router root that only advertises the models the user is allowed to view."""
|
|
16
|
+
|
|
17
|
+
def get(self, request, *args, **kwargs):
|
|
18
|
+
response = super().get(request, *args, **kwargs)
|
|
19
|
+
models = get_model_types()
|
|
20
|
+
response.data = {
|
|
21
|
+
type_id: url
|
|
22
|
+
for type_id, url in response.data.items()
|
|
23
|
+
if type_id not in models or has_model_permission(request.user, models[type_id], "view")
|
|
24
|
+
}
|
|
25
|
+
return response
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CrudKitRouter(routers.DefaultRouter):
|
|
29
|
+
APIRootView = CrudKitAPIRootView
|
|
30
|
+
|
|
31
|
+
|
|
13
32
|
# Routers provide an easy way of automatically determining the URL conf.
|
|
14
|
-
router =
|
|
33
|
+
router = CrudKitRouter()
|
|
15
34
|
# router.register(r"users", UserViewSet)
|
|
16
35
|
|
|
17
36
|
for type_id, mdl in get_model_types().items():
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import re
|
|
2
2
|
|
|
3
3
|
from django.contrib.auth.models import User
|
|
4
|
-
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
4
|
+
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
|
5
5
|
from django.core.exceptions import ValidationError
|
|
6
6
|
from django.db import connection, models, reset_queries, transaction
|
|
7
7
|
from django.db.models import ProtectedError, Q
|
|
@@ -106,6 +106,12 @@ class GenericViewSet(viewsets.ModelViewSet):
|
|
|
106
106
|
if f.is_relation
|
|
107
107
|
and not hasattr(f, "related_name")
|
|
108
108
|
and type(f) is not GenericForeignKey
|
|
109
|
+
# A GenericRelation also installs a reverse descriptor of the same
|
|
110
|
+
# name on its target, so on FeedItem and ExternalObject the base
|
|
111
|
+
# class' feeditem_set/externalobject_set are shadowed and
|
|
112
|
+
# prefetching them by name walks the relation backwards. Nothing in
|
|
113
|
+
# GenericSerializer renders a generic relation anyway.
|
|
114
|
+
and not isinstance(f, GenericRelation)
|
|
109
115
|
and (not fields or f.name in fields)
|
|
110
116
|
]
|
|
111
117
|
|
|
@@ -209,7 +215,7 @@ class GenericViewSet(viewsets.ModelViewSet):
|
|
|
209
215
|
|
|
210
216
|
@action(detail=False)
|
|
211
217
|
def metadata(self, request):
|
|
212
|
-
return Response(build_model_metadata(self.queryset.model))
|
|
218
|
+
return Response(build_model_metadata(self.queryset.model, request.user))
|
|
213
219
|
|
|
214
220
|
|
|
215
221
|
CRM_TYPE_REGEX = re.compile(r"[A-Z]{3}")
|
|
@@ -5,9 +5,11 @@ import json
|
|
|
5
5
|
from django.conf import settings
|
|
6
6
|
from django.utils.safestring import mark_safe
|
|
7
7
|
|
|
8
|
+
from crudkit.fields import DEFAULT_CURRENCY
|
|
9
|
+
|
|
8
10
|
|
|
9
11
|
def crudkit_config(request):
|
|
10
|
-
config = getattr(settings, "CRUDKIT_FRONTEND_CONFIG", {})
|
|
12
|
+
config = {"default_currency": DEFAULT_CURRENCY, **getattr(settings, "CRUDKIT_FRONTEND_CONFIG", {})}
|
|
11
13
|
# Escape "<" so "</script>" in values cannot break out of the script tag.
|
|
12
14
|
payload = json.dumps(config).replace("<", "\\u003c")
|
|
13
15
|
# settings.STATIC_URL is already "/"-prefixed by Django on access.
|