Skip to content

Commit 756be52

Browse files
authored
Merge pull request #92 from AbdullahKady/support-update_fields
Support Django's `update_fields` in save method
2 parents 7bc565a + 72604ae commit 756be52

File tree

3 files changed

+75
-10
lines changed

3 files changed

+75
-10
lines changed

django_lifecycle/mixins.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -126,26 +126,26 @@ def save(self, *args, **kwargs):
126126
is_new = self._state.adding
127127

128128
if is_new:
129-
self._run_hooked_methods(BEFORE_CREATE)
129+
self._run_hooked_methods(BEFORE_CREATE, **kwargs)
130130
else:
131-
self._run_hooked_methods(BEFORE_UPDATE)
131+
self._run_hooked_methods(BEFORE_UPDATE, **kwargs)
132132

133-
self._run_hooked_methods(BEFORE_SAVE)
133+
self._run_hooked_methods(BEFORE_SAVE, **kwargs)
134134
save(*args, **kwargs)
135-
self._run_hooked_methods(AFTER_SAVE)
135+
self._run_hooked_methods(AFTER_SAVE, **kwargs)
136136

137137
if is_new:
138-
self._run_hooked_methods(AFTER_CREATE)
138+
self._run_hooked_methods(AFTER_CREATE, **kwargs)
139139
else:
140-
self._run_hooked_methods(AFTER_UPDATE)
140+
self._run_hooked_methods(AFTER_UPDATE, **kwargs)
141141

142142
self._initial_state = self._snapshot_state()
143143

144144
@transaction.atomic
145145
def delete(self, *args, **kwargs):
146-
self._run_hooked_methods(BEFORE_DELETE)
146+
self._run_hooked_methods(BEFORE_DELETE, **kwargs)
147147
value = super().delete(*args, **kwargs)
148-
self._run_hooked_methods(AFTER_DELETE)
148+
self._run_hooked_methods(AFTER_DELETE, **kwargs)
149149
return value
150150

151151
@classmethod
@@ -188,7 +188,7 @@ def _watched_fk_model_fields(cls) -> List[str]:
188188
def _watched_fk_models(cls) -> List[str]:
189189
return [_.split(".")[0] for _ in cls._watched_fk_model_fields()]
190190

191-
def _run_hooked_methods(self, hook: str) -> List[str]:
191+
def _run_hooked_methods(self, hook: str, **kwargs) -> List[str]:
192192
"""
193193
Iterate through decorated methods to find those that should be
194194
triggered by the current hook. If conditions exist, check them before
@@ -204,14 +204,24 @@ def _run_hooked_methods(self, hook: str) -> List[str]:
204204
when_field = callback_specs.get("when")
205205
when_any_field = callback_specs.get("when_any")
206206

207+
# None is explicit instead of an empty list; since Django aborts the save with an empty list
208+
update_fields = kwargs.get("update_fields", None)
209+
update_fields_exist = update_fields is not None
210+
207211
if when_field:
212+
if update_fields_exist and when_field not in update_fields:
213+
continue
208214
if not self._check_callback_conditions(when_field, callback_specs):
209215
continue
210216
elif when_any_field:
217+
filtered_when_any_fields = when_any_field
218+
if update_fields_exist:
219+
filtered_when_any_fields = list(set(update_fields) & set(when_any_field)) # Intersected.
220+
211221
if not any(
212222
[
213223
self._check_callback_conditions(field_name, callback_specs)
214-
for field_name in when_any_field
224+
for field_name in filtered_when_any_fields
215225
]
216226
):
217227
continue

tests/testapp/tests/test_mixin.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,24 @@ def test_changes_to_condition_should_pass(self):
316316
user_account.last_name = "Flanders"
317317
user_account.save()
318318

319+
def test_changes_to_condition_included_in_update_fields_should_fire_hook(self):
320+
user_account = UserAccount.objects.create(**self.stub_data)
321+
user_account.first_name = "Flanders"
322+
user_account.last_name = "Flanders"
323+
with self.assertRaises(CannotRename, msg="Oh, not Flanders. Anybody but Flanders."):
324+
user_account.last_name = "Flanders"
325+
user_account.save(update_fields=["last_name"])
326+
327+
def test_changes_to_condition_not_included_in_update_fields_should_not_fire_hook(self):
328+
user_account = UserAccount.objects.create(**self.stub_data)
329+
user_account.first_name = "Flanders"
330+
user_account.last_name = "Flanders"
331+
user_account.save(update_fields=["first_name"]) # `CannotRename` exception is not raised
332+
333+
user_account.refresh_from_db()
334+
self.assertEqual(user_account.first_name, "Flanders")
335+
self.assertNotEqual(user_account.last_name, "Flanders")
336+
319337
def test_changes_to_condition_should_not_pass(self):
320338
data = self.stub_data
321339
data["first_name"] = "Marge"

tests/testapp/tests/test_user_account.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,43 @@ def test_email_user_about_name_change(self):
117117
mail.outbox[0].body, "You changed your first name or your last name"
118118
)
119119

120+
def test_does_not_email_user_about_name_change_when_name_excluded_from_update_fields(self):
121+
account = UserAccount.objects.create(**self.stub_data)
122+
mail.outbox = []
123+
account.first_name = "Homer the Great"
124+
account.password = "New password!"
125+
126+
old_password_updated_at = account.password_updated_at
127+
account.save(update_fields=["password"])
128+
self.assertEqual(len(mail.outbox), 0) # `first_name` change was skipped (as a hook).
129+
self.assertNotEqual(account.password_updated_at, old_password_updated_at) # Ensure the other hook is fired.
130+
131+
def test_emails_user_about_name_change_when_one_field_from_update_fields_intersects_with_condition(self):
132+
account = UserAccount.objects.create(**self.stub_data)
133+
mail.outbox = []
134+
account.first_name = "Homer the Great"
135+
account.password = "New password!"
136+
137+
old_password_updated_at = account.password_updated_at
138+
account.save(update_fields=["first_name", "password"])
139+
self.assertEqual(
140+
mail.outbox[0].body, "You changed your first name or your last name"
141+
)
142+
self.assertNotEqual(account.password_updated_at, old_password_updated_at) # Both hooks fired.
143+
144+
def test_empty_update_fields_does_not_fire_any_hooks(self):
145+
# In Django, an empty list supplied to `update_fields` means not updating any field.
146+
account = UserAccount.objects.create(**self.stub_data)
147+
mail.outbox = []
148+
account.first_name = "Flanders"
149+
account.password = "new pass"
150+
151+
old_password_updated_at = account.password_updated_at
152+
account.save(update_fields=[])
153+
# Did not raise, so last name hook didn't fire.
154+
self.assertEqual(len(mail.outbox), 0)
155+
self.assertEqual(account.password_updated_at, old_password_updated_at) # Password hook didn't fire either.
156+
120157
def test_skip_hooks(self):
121158
"""
122159
Hooked method that auto-lowercases email should be skipped.

0 commit comments

Comments
 (0)