django-spire 0.24.0__py3-none-any.whl → 0.24.2__py3-none-any.whl
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.
- django_spire/consts.py +1 -1
- django_spire/contrib/service/django_model_service.py +65 -47
- django_spire/contrib/service/tests/test_service.py +13 -66
- django_spire/core/templates/django_spire/container/infinite_scroll_container.html +1 -1
- django_spire/history/querysets.py +3 -0
- {django_spire-0.24.0.dist-info → django_spire-0.24.2.dist-info}/METADATA +1 -1
- {django_spire-0.24.0.dist-info → django_spire-0.24.2.dist-info}/RECORD +10 -10
- {django_spire-0.24.0.dist-info → django_spire-0.24.2.dist-info}/WHEEL +1 -1
- {django_spire-0.24.0.dist-info → django_spire-0.24.2.dist-info}/licenses/LICENSE.md +0 -0
- {django_spire-0.24.0.dist-info → django_spire-0.24.2.dist-info}/top_level.txt +0 -0
django_spire/consts.py
CHANGED
|
@@ -3,15 +3,18 @@ from __future__ import annotations
|
|
|
3
3
|
import logging
|
|
4
4
|
|
|
5
5
|
from abc import ABC
|
|
6
|
+
from itertools import chain
|
|
6
7
|
from typing import Generic, TypeVar
|
|
7
8
|
|
|
9
|
+
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
8
10
|
from django.db import transaction
|
|
9
|
-
from django.db.models import Model
|
|
11
|
+
from django.db.models import Model, Field, AutoField, ForeignObjectRel, FileField
|
|
10
12
|
|
|
11
13
|
from django_spire.contrib.constructor.django_model_constructor import BaseDjangoModelConstructor
|
|
12
14
|
from django_spire.contrib.service.exceptions import ServiceError
|
|
13
15
|
|
|
14
16
|
|
|
17
|
+
|
|
15
18
|
log = logging.getLogger(__name__)
|
|
16
19
|
|
|
17
20
|
TypeDjangoModel_co = TypeVar('TypeDjangoModel_co', bound=Model, covariant=True)
|
|
@@ -22,63 +25,78 @@ class BaseDjangoModelService(
|
|
|
22
25
|
ABC,
|
|
23
26
|
Generic[TypeDjangoModel_co]
|
|
24
27
|
):
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
-
field.name: field
|
|
28
|
-
for field in self.obj._meta.get_fields()
|
|
29
|
-
if field.concrete and not field.many_to_many and not field.one_to_many
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
def _get_touched_fields(self, concrete_fields: dict, **field_data: dict) -> list[str]:
|
|
33
|
-
foreign_key_id_aliases = {f"{name}_id" for name, field in concrete_fields.items() if field.many_to_one}
|
|
34
|
-
allowed = set(concrete_fields) | foreign_key_id_aliases
|
|
35
|
-
|
|
36
|
-
touched_fields: list[str] = []
|
|
37
|
-
|
|
38
|
-
for field, value in field_data.items():
|
|
39
|
-
if field not in allowed:
|
|
40
|
-
log.warning(f'Field {field!r} is not valid for {self.obj.__class__.__name__}')
|
|
41
|
-
continue
|
|
28
|
+
def _set_non_m2m_fields(self, **field_data: dict):
|
|
29
|
+
model_fields = self.obj._meta.fields
|
|
42
30
|
|
|
43
|
-
|
|
31
|
+
file_field_list = []
|
|
32
|
+
updated_fields = []
|
|
44
33
|
|
|
45
|
-
|
|
34
|
+
for field in model_fields:
|
|
35
|
+
if isinstance(field, AutoField) or field.name not in field_data:
|
|
46
36
|
continue
|
|
47
37
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
38
|
+
# Defer saving file-type fields until after the other fields, so a
|
|
39
|
+
# callable upload_to can use the values from other fields (from django's construct_instance).
|
|
40
|
+
if isinstance(field, FileField):
|
|
41
|
+
file_field_list.append(field)
|
|
42
|
+
updated_fields.append(field.name)
|
|
43
|
+
else:
|
|
44
|
+
field.save_form_data(self.obj, field_data[field.name])
|
|
45
|
+
updated_fields.append(field.name)
|
|
46
|
+
|
|
47
|
+
# Update foreign key id aliases in field_data for
|
|
48
|
+
# related fields that weren't already updated above
|
|
49
|
+
foreign_key_id_aliases = [
|
|
50
|
+
f"{field.name}_id" for field in model_fields
|
|
51
|
+
if f"{field.name}_id" in field_data and field.many_to_one and field.name not in updated_fields
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
for field_name in foreign_key_id_aliases:
|
|
55
|
+
setattr(self.obj, field_name, field_data[field_name])
|
|
56
|
+
|
|
57
|
+
# Update file fields deferred from earlier
|
|
58
|
+
for field in file_field_list:
|
|
59
|
+
field.save_form_data(self.obj, field_data[field.name])
|
|
60
|
+
|
|
61
|
+
def _set_m2m_fields(self, **field_data):
|
|
62
|
+
model_meta = self.obj._meta
|
|
63
|
+
|
|
64
|
+
for field in chain(model_meta.many_to_many, model_meta.private_fields):
|
|
65
|
+
if not hasattr(field, "save_form_data"):
|
|
66
|
+
continue
|
|
67
|
+
if field.name in field_data:
|
|
68
|
+
field.save_form_data(self.obj, field_data[field.name])
|
|
57
69
|
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
@transaction.atomic
|
|
71
|
+
def save_model_obj(self, **field_data: dict | None) -> tuple[Model, bool]:
|
|
72
|
+
"""
|
|
73
|
+
This is the core service method for saving a Django model object with field data provided via kwargs.
|
|
74
|
+
It will update the object with the given kwargs and handle any upstream attribute changes that were applied
|
|
75
|
+
directly to the model instance (i.e. it can also be called without any kwargs, similar to `Model.save()`).
|
|
60
76
|
|
|
61
|
-
|
|
77
|
+
Its purpose is to run extra operations related to the model instance that need to run each time
|
|
78
|
+
the model is saved - it is meant to replace the need for overriding `BaseModelForm.save()` or `Model.save()`.
|
|
62
79
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
80
|
+
It is designed to emulate django's `BaseModelForm.save()` method as close as possible:
|
|
81
|
+
first, in `_set_non_m2m_fields`, it updates the fields on `self.obj` using logic similar to `django.forms.models.construct_instance`,
|
|
82
|
+
then it calls self.obj.save(), then updates the m2m fields on the object instance
|
|
83
|
+
using logic similar to django's `BaseModelForm._save_m2m()` method. In all cases,
|
|
84
|
+
it treats the incoming `field_data` exactly the same as `cleaned_data` is treated
|
|
85
|
+
in the django code that it is emulating, and therefore does not perform any validation
|
|
86
|
+
on the data or the model instance as it is assumed that field_data has already been validated upstream.
|
|
66
87
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
raise ServiceError(message)
|
|
88
|
+
Args:
|
|
89
|
+
**field_data:
|
|
70
90
|
|
|
71
|
-
|
|
91
|
+
Returns:
|
|
92
|
+
tuple[Model, bool]
|
|
72
93
|
|
|
73
|
-
|
|
74
|
-
new_model_obj_was_created = True
|
|
75
|
-
self.obj.save()
|
|
94
|
+
"""
|
|
76
95
|
|
|
77
|
-
|
|
78
|
-
self.obj.save(update_fields=touched_fields)
|
|
96
|
+
new_model_obj_was_created = True if self.model_obj_is_new else False
|
|
79
97
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
98
|
+
self._set_non_m2m_fields(**field_data)
|
|
99
|
+
self.obj.save()
|
|
100
|
+
self._set_m2m_fields(**field_data)
|
|
83
101
|
|
|
84
102
|
return self.obj, new_model_obj_was_created
|
|
@@ -37,66 +37,26 @@ class TestBaseDjangoModelService(TestCase):
|
|
|
37
37
|
password='testpass' # noqa: S106
|
|
38
38
|
)
|
|
39
39
|
|
|
40
|
-
def
|
|
40
|
+
def test_set_non_m2m_fields_sets_attribute(self) -> None:
|
|
41
41
|
service = UserService(self.user)
|
|
42
|
-
|
|
43
|
-
result = service._get_concrete_fields()
|
|
44
|
-
|
|
45
|
-
assert isinstance(result, dict)
|
|
46
|
-
assert 'username' in result
|
|
47
|
-
assert 'email' in result
|
|
48
|
-
|
|
49
|
-
def test_get_touched_fields_returns_list(self) -> None:
|
|
50
|
-
service = UserService(self.user)
|
|
51
|
-
concrete_fields = service._get_concrete_fields()
|
|
52
|
-
|
|
53
|
-
result = service._get_touched_fields(concrete_fields, username='newuser')
|
|
54
|
-
|
|
55
|
-
assert isinstance(result, list)
|
|
56
|
-
|
|
57
|
-
def test_get_touched_fields_sets_attribute(self) -> None:
|
|
58
|
-
service = UserService(self.user)
|
|
59
|
-
concrete_fields = service._get_concrete_fields()
|
|
60
|
-
|
|
61
|
-
service._get_touched_fields(concrete_fields, first_name='NewName')
|
|
42
|
+
service._set_non_m2m_fields(first_name='NewName')
|
|
62
43
|
|
|
63
44
|
assert service.obj.first_name == 'NewName'
|
|
64
45
|
|
|
65
|
-
def
|
|
66
|
-
|
|
67
|
-
concrete_fields = service._get_concrete_fields()
|
|
68
|
-
|
|
69
|
-
with patch('django_spire.contrib.service.django_model_service.log') as mock_log:
|
|
70
|
-
service._get_touched_fields(concrete_fields, invalid_field='value')
|
|
71
|
-
|
|
72
|
-
mock_log.warning.assert_called_once()
|
|
46
|
+
def test_set_non_m2m_fields_skips_auto_created_fields(self) -> None:
|
|
47
|
+
UserService(self.user)._set_non_m2m_fields(id=999)
|
|
73
48
|
|
|
74
|
-
|
|
75
|
-
service = UserService(self.user)
|
|
76
|
-
concrete_fields = service._get_concrete_fields()
|
|
49
|
+
assert self.user.id != 999
|
|
77
50
|
|
|
78
|
-
|
|
51
|
+
def test_has_set_non_m2m_fields_method(self) -> None:
|
|
52
|
+
assert hasattr(BaseDjangoModelService, '_set_non_m2m_fields')
|
|
79
53
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def test_has_get_concrete_fields_method(self) -> None:
|
|
83
|
-
assert hasattr(BaseDjangoModelService, '_get_concrete_fields')
|
|
84
|
-
|
|
85
|
-
def test_has_get_touched_fields_method(self) -> None:
|
|
86
|
-
assert hasattr(BaseDjangoModelService, '_get_touched_fields')
|
|
54
|
+
def test_has_set_m2m_fields_method(self) -> None:
|
|
55
|
+
assert hasattr(BaseDjangoModelService, '_set_m2m_fields')
|
|
87
56
|
|
|
88
57
|
def test_has_save_model_obj_method(self) -> None:
|
|
89
58
|
assert hasattr(BaseDjangoModelService, 'save_model_obj')
|
|
90
59
|
|
|
91
|
-
def test_has_validate_model_obj_method(self) -> None:
|
|
92
|
-
assert hasattr(BaseDjangoModelService, 'validate_model_obj')
|
|
93
|
-
|
|
94
|
-
def test_save_model_obj_raises_error_without_field_data(self) -> None:
|
|
95
|
-
service = UserService(self.user)
|
|
96
|
-
|
|
97
|
-
with pytest.raises(ServiceError, match='Field data is required'):
|
|
98
|
-
service.save_model_obj()
|
|
99
|
-
|
|
100
60
|
def test_save_model_obj_returns_tuple(self) -> None:
|
|
101
61
|
service = UserService(self.user)
|
|
102
62
|
|
|
@@ -130,24 +90,11 @@ class TestBaseDjangoModelService(TestCase):
|
|
|
130
90
|
assert created is True
|
|
131
91
|
assert obj.pk is not None
|
|
132
92
|
|
|
133
|
-
def
|
|
93
|
+
def test_save_model_updates_obj_with_previous_changes(self) -> None:
|
|
134
94
|
service = UserService(self.user)
|
|
135
95
|
|
|
136
|
-
|
|
137
|
-
service.save_model_obj(id=self.user.id)
|
|
96
|
+
service.obj.first_name = 'new_test'
|
|
138
97
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def test_validate_model_obj_returns_touched_fields(self) -> None:
|
|
142
|
-
service = UserService(self.user)
|
|
143
|
-
|
|
144
|
-
result = service.validate_model_obj(first_name='Valid')
|
|
145
|
-
|
|
146
|
-
assert isinstance(result, list)
|
|
147
|
-
assert 'first_name' in result
|
|
148
|
-
|
|
149
|
-
def test_validate_model_obj_raises_validation_error(self) -> None:
|
|
150
|
-
service = UserService(self.user)
|
|
98
|
+
service.save_model_obj(id=self.user.id)
|
|
151
99
|
|
|
152
|
-
|
|
153
|
-
service.validate_model_obj(email='invalid-email')
|
|
100
|
+
assert service.obj.first_name == 'new_test'
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
style="height: calc(100vh - {{ container_offset|default:'350' }}px); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch;"
|
|
30
30
|
x-ref="scroll_container"
|
|
31
31
|
>
|
|
32
|
-
<div x-ref="content_container">
|
|
32
|
+
<div x-ref="content_container" class="{% block container_content_inner_class %}{% endblock %}">
|
|
33
33
|
{% block container_content %}{% endblock %}
|
|
34
34
|
</div>
|
|
35
35
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-spire
|
|
3
|
-
Version: 0.24.
|
|
3
|
+
Version: 0.24.2
|
|
4
4
|
Summary: A project for Django Spire
|
|
5
5
|
Author-email: Brayden Carlson <braydenc@stratusadv.com>, Nathan Johnson <nathanj@stratusadv.com>
|
|
6
6
|
License: Copyright (c) 2025 Stratus Advanced Technologies and Contributors.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
django_spire/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
2
|
django_spire/conf.py,sha256=3oUB1mtgHRjvbJsxfQWG5uL1KUP9uGig3zdP2dZphe8,942
|
|
3
|
-
django_spire/consts.py,sha256=
|
|
3
|
+
django_spire/consts.py,sha256=biDSV-Uptfgm170WiJVdnjKxxa_xLSNHvUOESbiBmAY,171
|
|
4
4
|
django_spire/exceptions.py,sha256=M7buFvm-K4lK09pH5fVcZ-MxsDIzdpEJBF33Xss5bSw,289
|
|
5
5
|
django_spire/settings.py,sha256=Pr98O2Na5Cv9YXs5y8c2CvGYv1szmXED8RJVT5q2-W4,1164
|
|
6
6
|
django_spire/urls.py,sha256=wQx6R-nXx69MeOF-WmDxcEUM5WmUHGplbY5uZ_HnDp8,703
|
|
@@ -463,10 +463,10 @@ django_spire/contrib/seeding/tests/test_enums.py,sha256=tpysk3dKDtOXfD-0ebqSTBba
|
|
|
463
463
|
django_spire/contrib/seeding/tests/test_intel.py,sha256=Hi6Jr91nBIPoLGt4KtRZO4-uGq5gTCMltJ8tHMgDCUs,980
|
|
464
464
|
django_spire/contrib/seeding/tests/test_override.py,sha256=8eliLQBsQ7n1-ARqXLQdZOcrWDD8XhWfYoc_iaDaktM,1843
|
|
465
465
|
django_spire/contrib/service/__init__.py,sha256=EB44rklqr317T2tDXDW5qocfQDE7b4f2UFjf7DAWj-0,215
|
|
466
|
-
django_spire/contrib/service/django_model_service.py,sha256=
|
|
466
|
+
django_spire/contrib/service/django_model_service.py,sha256=yQyNQZMfidRQKwDcPyXydF0yBSNIG1iSzEuJi-KKRXE,4188
|
|
467
467
|
django_spire/contrib/service/exceptions.py,sha256=nIfh1kjeCN94eZE12zR4cGtvn3xKC2WAr_1KVakU28E,138
|
|
468
468
|
django_spire/contrib/service/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
469
|
-
django_spire/contrib/service/tests/test_service.py,sha256=
|
|
469
|
+
django_spire/contrib/service/tests/test_service.py,sha256=BlRY-UgQtZwylTj6nderZ-NjKB4Kj9o1iL4zWbGzKhw,3077
|
|
470
470
|
django_spire/contrib/session/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
471
471
|
django_spire/contrib/session/apps.py,sha256=zrDjGlxth4TvLZel-CSGt-CQa-S8Xs92sM-uq5m7d58,257
|
|
472
472
|
django_spire/contrib/session/controller.py,sha256=4kOTDw4URLy3pCBWqWfwuOKEHzpjNT6iKVDU8-uH7Zk,3022
|
|
@@ -709,7 +709,7 @@ django_spire/core/templates/django_spire/card/infinite_scroll_card.html,sha256=E
|
|
|
709
709
|
django_spire/core/templates/django_spire/card/title_card.html,sha256=5ahO5PoB3ZPiluEZI7FqwB8E9t-BqVmj-uLSVh2RYPQ,1424
|
|
710
710
|
django_spire/core/templates/django_spire/container/container.html,sha256=Dfr7K8xgEbg3CZYxMleLjjiQGyRS-GNJdPySA4OYY8U,867
|
|
711
711
|
django_spire/core/templates/django_spire/container/form_container.html,sha256=FZwZs4gnIYkurV5u8v6fj2rbrm4WDX16jyUSTMniEtw,313
|
|
712
|
-
django_spire/core/templates/django_spire/container/infinite_scroll_container.html,sha256=
|
|
712
|
+
django_spire/core/templates/django_spire/container/infinite_scroll_container.html,sha256=xaHlMZ4yLKhZCvbbRHUAHeEZOFKJf-s8eGUN_y9FNJE,2558
|
|
713
713
|
django_spire/core/templates/django_spire/dropdown/dropdown.html,sha256=O3gUp2YZm_2g0Qd-odFOnxfBkRc4c4af4zTbyGibSU0,528
|
|
714
714
|
django_spire/core/templates/django_spire/dropdown/ellipsis_dropdown.html,sha256=6DrFtcvfCnegs_gLfDZDkEGYb6ZpJ85clKQWckiTH00,1431
|
|
715
715
|
django_spire/core/templates/django_spire/dropdown/ellipsis_modal_dropdown.html,sha256=kUNcg-dSqr1wHjU_NijBAyCsa_7Z6_spVE5jytqYaME,898
|
|
@@ -909,7 +909,7 @@ django_spire/history/apps.py,sha256=pVcuuIfcu0kk4-TbmKguTxVmeZfWCz8-dHzbENFa9DY,
|
|
|
909
909
|
django_spire/history/choices.py,sha256=NvKwg-pX-QHl8TZQji8ZuEdZ4v4CHNHdj-kJx0RdjR4,311
|
|
910
910
|
django_spire/history/mixins.py,sha256=uQKZ0qlIWChPNwxCM59dwejKnnQV_c2zDWn4rsAg5qw,1617
|
|
911
911
|
django_spire/history/models.py,sha256=MGMOeUjJkI9SI1MCuxqabXwmj9G_4rKDZF-pAyxA5BQ,1168
|
|
912
|
-
django_spire/history/querysets.py,sha256=
|
|
912
|
+
django_spire/history/querysets.py,sha256=C2KRrBvGItaOKtx_99xsulTldFMnqZ0xGCLBoE6pbh8,420
|
|
913
913
|
django_spire/history/activity/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
914
914
|
django_spire/history/activity/admin.py,sha256=ggx784yMrR9Pa-TVpjk5J5JCdNNGdYGSH_KJG3Z1u5o,1970
|
|
915
915
|
django_spire/history/activity/apps.py,sha256=DNcIYQpozIT5jA2xfKAMJ7COQ9VcIl2w46zoUFhaVWw,445
|
|
@@ -1401,8 +1401,8 @@ django_spire/theme/urls/page_urls.py,sha256=Oak3x_xwQEb01NKdrsB1nk6yPaOEnheuSG1m
|
|
|
1401
1401
|
django_spire/theme/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
1402
1402
|
django_spire/theme/views/json_views.py,sha256=PWwVTaty0BVGbj65L5cxex6JNhc-xVAI_rEYjbJWqEM,1893
|
|
1403
1403
|
django_spire/theme/views/page_views.py,sha256=WenjOa6Welpu3IMolY56ZwBjy4aK9hpbiMNuygjAl1A,3922
|
|
1404
|
-
django_spire-0.24.
|
|
1405
|
-
django_spire-0.24.
|
|
1406
|
-
django_spire-0.24.
|
|
1407
|
-
django_spire-0.24.
|
|
1408
|
-
django_spire-0.24.
|
|
1404
|
+
django_spire-0.24.2.dist-info/licenses/LICENSE.md,sha256=ZAeCT76WvaoEZE9xPhihyWjTwH0wQZXQmyRsnV2VPFs,1091
|
|
1405
|
+
django_spire-0.24.2.dist-info/METADATA,sha256=pVb0KnuCc5tuu28_0UqjZvt-NySVfnujEs7V4TFYbRQ,5127
|
|
1406
|
+
django_spire-0.24.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
1407
|
+
django_spire-0.24.2.dist-info/top_level.txt,sha256=xf3QV1e--ONkVpgMDQE9iqjQ1Vg4--_6C8wmO-KxPHQ,13
|
|
1408
|
+
django_spire-0.24.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|