django-spire 0.24.1__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 CHANGED
@@ -1,4 +1,4 @@
1
- __VERSION__ = '0.24.1'
1
+ __VERSION__ = '0.24.2'
2
2
 
3
3
  MAINTENANCE_MODE_SETTINGS_NAME = 'MAINTENANCE_MODE'
4
4
 
@@ -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 _get_concrete_fields(self) -> dict:
26
- return {
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
- model_field = concrete_fields.get(field.removesuffix("_id"), None)
31
+ file_field_list = []
32
+ updated_fields = []
44
33
 
45
- if model_field and (getattr(model_field, 'auto_created', False) or not model_field.editable):
34
+ for field in model_fields:
35
+ if isinstance(field, AutoField) or field.name not in field_data:
46
36
  continue
47
37
 
48
- setattr(self.obj, field, value)
49
-
50
- touched_fields.append(field.removesuffix('_id'))
51
-
52
- return touched_fields
53
-
54
- def validate_model_obj(self, **field_data: dict) -> list[str]:
55
- concrete_fields = self._get_concrete_fields()
56
- touched_fields = self._get_touched_fields(concrete_fields, **field_data)
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
- exclude = [field for field in concrete_fields if field not in touched_fields]
59
- self.obj.full_clean(exclude=exclude)
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
- return touched_fields
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
- @transaction.atomic
64
- def save_model_obj(self, **field_data: dict) -> tuple[Model, bool]:
65
- new_model_obj_was_created = False
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
- if not field_data:
68
- message = f'Field data is required to save on {self.obj.__class__.__name__}'
69
- raise ServiceError(message)
88
+ Args:
89
+ **field_data:
70
90
 
71
- touched_fields = self.validate_model_obj(**field_data)
91
+ Returns:
92
+ tuple[Model, bool]
72
93
 
73
- if self.model_obj_is_new:
74
- new_model_obj_was_created = True
75
- self.obj.save()
94
+ """
76
95
 
77
- elif touched_fields:
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
- else:
81
- message = f'{self.obj.__class__.__name__} is not a new object or there was no touched fields to update.'
82
- log.warning(message)
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 test_get_concrete_fields_returns_dict(self) -> None:
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 test_get_touched_fields_logs_warning_for_invalid_field(self) -> None:
66
- service = UserService(self.user)
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
- def test_get_touched_fields_skips_auto_created_fields(self) -> None:
75
- service = UserService(self.user)
76
- concrete_fields = service._get_concrete_fields()
49
+ assert self.user.id != 999
77
50
 
78
- result = service._get_touched_fields(concrete_fields, id=999)
51
+ def test_has_set_non_m2m_fields_method(self) -> None:
52
+ assert hasattr(BaseDjangoModelService, '_set_non_m2m_fields')
79
53
 
80
- assert 'id' not in result
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 test_save_model_obj_logs_warning_when_no_changes(self) -> None:
93
+ def test_save_model_updates_obj_with_previous_changes(self) -> None:
134
94
  service = UserService(self.user)
135
95
 
136
- with patch('django_spire.contrib.service.django_model_service.log') as mock_log:
137
- service.save_model_obj(id=self.user.id)
96
+ service.obj.first_name = 'new_test'
138
97
 
139
- mock_log.warning.assert_called_once()
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
- with pytest.raises(ValidationError):
153
- service.validate_model_obj(email='invalid-email')
100
+ assert service.obj.first_name == 'new_test'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-spire
3
- Version: 0.24.1
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=A90ILXVJYixu3zNw9XSF3jhA9gKjIBnK88lrRZkpHRw,171
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=gky7mEqKw1s7YNa2y2IhlKefBABIZAb_R1QmDVPHqfI,2824
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=p1d_L4P5TcPlD_imFHWmBMoToU-vJY4RGUrLxqbXhRE,5094
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
@@ -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.1.dist-info/licenses/LICENSE.md,sha256=ZAeCT76WvaoEZE9xPhihyWjTwH0wQZXQmyRsnV2VPFs,1091
1405
- django_spire-0.24.1.dist-info/METADATA,sha256=5NmKsUBFFIY13Mj1ET72NddFFiKxCMfZ32oPCjE4Nt0,5127
1406
- django_spire-0.24.1.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
1407
- django_spire-0.24.1.dist-info/top_level.txt,sha256=xf3QV1e--ONkVpgMDQE9iqjQ1Vg4--_6C8wmO-KxPHQ,13
1408
- django_spire-0.24.1.dist-info/RECORD,,
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.1)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5