diff --git a/.gitignore b/.gitignore index 8344f7c..8f328d5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,5 @@ README.txt .tox django_lifecycle.egg-info site/ -.idea/ -venv/ \ No newline at end of file +.idea +venv diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..ed6e84f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,47 @@ +language: python +install: + - pip install tox +dist: xenial +sudo: required +script: + - tox +matrix: + include: + - python: 3.5 + env: TOXENV=py35-django20 + - python: 3.6 + env: TOXENV=py36-django20 + - python: 3.7 + env: TOXENV=py37-django20 + - python: 3.8 + env: TOXENV=py38-django20 + - python: 3.5 + env: TOXENV=py35-django21 + - python: 3.6 + env: TOXENV=py36-django21 + - python: 3.7 + env: TOXENV=py37-django21 + - python: 3.8 + env: TOXENV=py38-django21 + - python: 3.5 + env: TOXENV=py35-django22 + - python: 3.6 + env: TOXENV=py36-django22 + - python: 3.7 + env: TOXENV=py37-django22 + - python: 3.8 + env: TOXENV=py38-django22 + - python: 3.6 + env: TOXENV=py36-django30 + - python: 3.7 + env: TOXENV=py37-django30 + - python: 3.8 + env: TOXENV=py38-django30 + - python: 3.6 + env: TOXENV=py36-django31 + - python: 3.7 + env: TOXENV=py37-django31 + - python: 3.8 + env: TOXENV=py38-django31 + - python: 3.7 + env: TOXENV=flake8 diff --git a/README.md b/README.md index 86a6cd2..bb0cdfb 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,18 @@ [![Package version](https://badge.fury.io/py/django-lifecycle.svg)](https://pypi.python.org/pypi/django-lifecycle) [![Python versions](https://img.shields.io/pypi/status/django-lifecycle.svg)](https://img.shields.io/pypi/status/django-lifecycle.svg/) +[![Python versions](https://img.shields.io/pypi/pyversions/django-lifecycle.svg)](https://pypi.org/project/django-lifecycle/) +![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-lifecycle) -This project provides a `@hook` decorator as well as a base model and mixin to add lifecycle hooks to your Django models. Django's built-in approach to offering lifecycle hooks is [Signals](https://docs.djangoproject.com/en/dev/topics/signals/). However, my team often finds that Signals introduce unnesseary indirection and are at odds with Django's "fat models" approach. -**Django Lifecycle Hooks** supports Python 3.5, 3.6, and 3.7, Django 2.0.x, 2.1.x, 2.2.x. +This project provides a `@hook` decorator as well as a base model and mixin to add lifecycle hooks to your Django models. Django's built-in approach to offering lifecycle hooks is [Signals](https://docs.djangoproject.com/en/dev/topics/signals/). However, my team often finds that Signals introduce unnecessary indirection and are at odds with Django's "fat models" approach. + +**Django Lifecycle Hooks** supports Python 3.5, 3.6, 3.7, 3.8 and 3.9, Django 2.0.x, 2.1.x, 2.2.x, 3.0.x and 3.1.x. In short, you can write model code like this: ```python -from django_lifecycle import LifecycleModel, hook +from django_lifecycle import LifecycleModel, hook, BEFORE_UPDATE, AFTER_UPDATE class Article(LifecycleModel): @@ -19,16 +22,16 @@ class Article(LifecycleModel): status = models.ChoiceField(choices=['draft', 'published']) editor = models.ForeignKey(AuthUser) - @hook('before_update', when='contents', has_changed=True) + @hook(BEFORE_UPDATE, when='contents', has_changed=True) def on_content_change(self): self.updated_at = timezone.now() - @hook('after_update', when="status", was="draft", is_now="published") + @hook(AFTER_UPDATE, when="status", was="draft", is_now="published") def on_publish(self): send_email(self.editor.email, "An article has published!") ``` -Instead of overriding `save` and `__init___` in a clunky way that hurts readability: +Instead of overriding `save` and `__init__` in a clunky way that hurts readability: ```python # same class and field declarations as above ... @@ -59,6 +62,22 @@ Instead of overriding `save` and `__init___` in a clunky way that hurts readabil # Changelog +## 0.8.1 (January 2021) +* Added missing return to `delete()` method override. Thanks @oaosman84! + +## 0.8.0 (October 2020) +* Significant performance improvements. Thanks @dralley! + +## 0.7.7 (August 2020) +* Fixes issue with `GenericForeignKey`. Thanks @bmbouter! + +## 0.7.6 (May 2020) +* Updates to use constants for hook names; updates docs to indicate Python 3.8/Django 3.x support. Thanks @thejoeejoee! + +## 0.7.5 (April 2020) +* Adds static typed variables for hook names; thanks @Faisal-Manzer! +* Fixes some typos in docs; thanks @tomdyson and @bmispelon! + ## 0.7.1 (January 2020) * Fixes bug in `utils._get_field_names` that could cause recursion bug in some cases. diff --git a/django_lifecycle/__init__.py b/django_lifecycle/__init__.py index 1e0a73a..8daf9a7 100644 --- a/django_lifecycle/__init__.py +++ b/django_lifecycle/__init__.py @@ -1,5 +1,9 @@ from .django_info import IS_GTE_1_POINT_9 +__version__ = "0.8.1" +__author__ = "Robert Singer" +__author_email__ = "robertgsinger@gmail.com" + class NotSet(object): pass @@ -7,6 +11,7 @@ class NotSet(object): from .decorators import hook from .mixins import LifecycleModelMixin +from .hooks import * if IS_GTE_1_POINT_9: diff --git a/django_lifecycle/decorators.py b/django_lifecycle/decorators.py index a66fa1c..4722824 100644 --- a/django_lifecycle/decorators.py +++ b/django_lifecycle/decorators.py @@ -3,23 +3,13 @@ from django_lifecycle import NotSet +from .hooks import VALID_HOOKS + class DjangoLifeCycleException(Exception): pass -VALID_HOOKS = ( - "before_save", - "after_save", - "before_create", - "after_create", - "before_update", - "after_update", - "before_delete", - "after_delete", -) - - def _validate_hook_params(hook, when, when_any, has_changed): if hook not in VALID_HOOKS: raise DjangoLifeCycleException( diff --git a/django_lifecycle/hooks.py b/django_lifecycle/hooks.py new file mode 100644 index 0000000..3a70715 --- /dev/null +++ b/django_lifecycle/hooks.py @@ -0,0 +1,23 @@ +BEFORE_SAVE = "before_save" +AFTER_SAVE = "after_save" + +BEFORE_CREATE = "before_create" +AFTER_CREATE = "after_create" + +BEFORE_UPDATE = "before_update" +AFTER_UPDATE = "after_update" + +BEFORE_DELETE = "before_delete" +AFTER_DELETE = "after_delete" + + +VALID_HOOKS = ( + BEFORE_SAVE, + AFTER_SAVE, + BEFORE_CREATE, + AFTER_CREATE, + BEFORE_UPDATE, + AFTER_UPDATE, + BEFORE_DELETE, + AFTER_DELETE +) diff --git a/django_lifecycle/mixins.py b/django_lifecycle/mixins.py index 0839555..1317a85 100644 --- a/django_lifecycle/mixins.py +++ b/django_lifecycle/mixins.py @@ -1,13 +1,23 @@ -from functools import reduce -from inspect import ismethod +from functools import reduce, lru_cache +from inspect import isfunction from typing import Any, List from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist from django.utils.functional import cached_property -from django_lifecycle import NotSet +from . import NotSet +from .hooks import ( + BEFORE_CREATE, + BEFORE_UPDATE, + BEFORE_SAVE, + BEFORE_DELETE, + AFTER_CREATE, + AFTER_UPDATE, + AFTER_SAVE, + AFTER_DELETE, +) -from .utils import get_unhookable_attribute_names +from .django_info import DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES class LifecycleModelMixin(object): @@ -18,7 +28,7 @@ def __init__(self, *args, **kwargs): def _snapshot_state(self): state = self.__dict__.copy() - for watched_related_field in self._watched_fk_model_fields: + for watched_related_field in self._watched_fk_model_fields(): state[watched_related_field] = self._current_value(watched_related_field) if "_state" in state: @@ -97,7 +107,7 @@ def _clear_watched_fk_model_cache(self): """ """ - for watched_field_name in self._watched_fk_models: + for watched_field_name in self._watched_fk_models(): field = self._meta.get_field(watched_field_name) if field.is_relation and field.is_cached(self): @@ -115,45 +125,48 @@ def save(self, *args, **kwargs): is_new = self._state.adding if is_new: - self._run_hooked_methods("before_create") + self._run_hooked_methods(BEFORE_CREATE) else: - self._run_hooked_methods("before_update") + self._run_hooked_methods(BEFORE_UPDATE) - self._run_hooked_methods("before_save") + self._run_hooked_methods(BEFORE_SAVE) save(*args, **kwargs) - self._run_hooked_methods("after_save") + self._run_hooked_methods(AFTER_SAVE) if is_new: - self._run_hooked_methods("after_create") + self._run_hooked_methods(AFTER_CREATE) else: - self._run_hooked_methods("after_update") + self._run_hooked_methods(AFTER_UPDATE) self._initial_state = self._snapshot_state() def delete(self, *args, **kwargs): - self._run_hooked_methods("before_delete") - super().delete(*args, **kwargs) - self._run_hooked_methods("after_delete") - - @cached_property - def _potentially_hooked_methods(self): - skip = set(get_unhookable_attribute_names(self)) + self._run_hooked_methods(BEFORE_DELETE) + value = super().delete(*args, **kwargs) + self._run_hooked_methods(AFTER_DELETE) + return value + + @classmethod + @lru_cache(typed=True) + def _potentially_hooked_methods(cls): + skip = set(cls._get_unhookable_attribute_names()) collected = [] - for name in dir(self): + for name in dir(cls): if name in skip: continue try: - attr = getattr(self, name) - if ismethod(attr) and hasattr(attr, "_hooked"): + attr = getattr(cls, name) + if isfunction(attr) and hasattr(attr, "_hooked"): collected.append(attr) except AttributeError: pass return collected - @cached_property - def _watched_fk_model_fields(self) -> List[str]: + @classmethod + @lru_cache(typed=True) + def _watched_fk_model_fields(cls) -> List[str]: """ Gather up all field names (values in 'when' key) that correspond to field names on FK-related models. These will be strings that contain @@ -161,16 +174,17 @@ def _watched_fk_model_fields(self) -> List[str]: """ watched = [] # List[str] - for method in self._potentially_hooked_methods: + for method in cls._potentially_hooked_methods(): for specs in method._hooked: if specs["when"] is not None and "." in specs["when"]: watched.append(specs["when"]) return watched - @cached_property - def _watched_fk_models(self) -> List[str]: - return [_.split(".")[0] for _ in self._watched_fk_model_fields] + @classmethod + @lru_cache(typed=True) + def _watched_fk_models(cls) -> List[str]: + return [_.split(".")[0] for _ in cls._watched_fk_model_fields()] def _run_hooked_methods(self, hook: str) -> List[str]: """ @@ -180,7 +194,7 @@ def _run_hooked_methods(self, hook: str) -> List[str]: """ fired = [] - for method in self._potentially_hooked_methods: + for method in self._potentially_hooked_methods(): for callback_specs in method._hooked: if callback_specs["hook"] != hook: continue @@ -200,7 +214,7 @@ def _run_hooked_methods(self, hook: str) -> List[str]: # Only call the method once per hook fired.append(method.__name__) - method() + method(self) break return fired @@ -228,11 +242,7 @@ def _check_callback_conditions(self, field_name: str, specs: dict) -> bool: def _check_has_changed(self, field_name: str, specs: dict) -> bool: has_changed = specs["has_changed"] - - if has_changed is None: - return True - - return has_changed == self.has_changed(field_name) + return has_changed is None or has_changed == self.has_changed(field_name) def _check_is_now_condition(self, field_name: str, specs: dict) -> bool: return specs["is_now"] in (self._current_value(field_name), "*") @@ -250,7 +260,77 @@ def _check_was_not_condition(self, field_name: str, specs: dict) -> bool: def _check_changes_to_condition(self, field_name: str, specs: dict) -> bool: changes_to = specs["changes_to"] - return any([ - changes_to is NotSet, - (self.initial_value(field_name) != changes_to and self._current_value(field_name) == changes_to) - ]) + return any( + [ + changes_to is NotSet, + ( + self.initial_value(field_name) != changes_to + and self._current_value(field_name) == changes_to + ), + ] + ) + + @classmethod + def _get_model_property_names(cls) -> List[str]: + """ + Gather up properties and cached_properties which may be methods + that were decorated. Need to inspect class versions b/c doing + getattr on them could cause unwanted side effects. + """ + property_names = [] + + for name in dir(cls): + attr = getattr(cls, name, None) + + if attr and ( + isinstance(attr, property) or isinstance(attr, cached_property) + ): + property_names.append(name) + + return property_names + + @classmethod + def _get_model_descriptor_names(cls) -> List[str]: + """ + Attributes which are Django descriptors. These represent a field + which is a one-to-many or many-to-many relationship that is + potentially defined in another model, and doesn't otherwise appear + as a field on this model. + """ + + descriptor_names = [] + + for name in dir(cls): + attr = getattr(cls, name, None) + + if attr and isinstance(attr, DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES): + descriptor_names.append(name) + + return descriptor_names + + @classmethod + def _get_field_names(cls) -> List[str]: + names = [] + + for f in cls._meta.get_fields(): + names.append(f.name) + + try: + internal_type = cls._meta.get_field(f.name).get_internal_type() + except AttributeError: + # Skip fields which don't provide a `get_internal_type` method, e.g. GenericForeignKey + continue + else: + if internal_type == "ForeignKey": + names.append(f.name + "_id") + + return names + + @classmethod + def _get_unhookable_attribute_names(cls) -> List[str]: + return ( + cls._get_field_names() + + cls._get_model_descriptor_names() + + cls._get_model_property_names() + + ["_run_hooked_methods"] + ) diff --git a/django_lifecycle/utils.py b/django_lifecycle/utils.py deleted file mode 100644 index 72cb0e9..0000000 --- a/django_lifecycle/utils.py +++ /dev/null @@ -1,69 +0,0 @@ -from typing import List - -from django.utils.functional import cached_property - -from .django_info import DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES - - -def _get_model_property_names(instance) -> List[str]: - """ - Gather up properties and cached_properties which may be methods - that were decorated. Need to inspect class versions b/c doing - getattr on them could cause unwanted side effects. - """ - property_names = [] - - for name in dir(instance): - try: - attr = getattr(type(instance), name) - - if isinstance(attr, property) or isinstance(attr, cached_property): - property_names.append(name) - - except AttributeError: - pass - - return property_names - - -def _get_model_descriptor_names(instance) -> List[str]: - """ - Attributes which are Django descriptors. These represent a field - which is a one-to-many or many-to-many relationship that is - potentially defined in another model, and doesn't otherwise appear - as a field on this model. - """ - - descriptor_names = [] - - for name in dir(instance): - try: - attr = getattr(type(instance), name) - - if isinstance(attr, DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES): - descriptor_names.append(name) - except AttributeError: - pass - - return descriptor_names - - -def _get_field_names(instance) -> List[str]: - names = [] - - for f in instance._meta.get_fields(): - names.append(f.name) - - if instance._meta.get_field(f.name).get_internal_type() == "ForeignKey": - names.append(f.name + "_id") - - return names - - -def get_unhookable_attribute_names(instance) -> List[str]: - return ( - _get_field_names(instance) - + _get_model_descriptor_names(instance) - + _get_model_property_names(instance) - + ["_run_hooked_methods"] - ) diff --git a/docs/advanced.md b/docs/advanced.md index 269c7c3..7dc11c3 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -13,22 +13,21 @@ These are available on your model instance when the mixin or extend the base mod You can use these methods for more advanced checks, for example: ```python -from datetime import timedelta -from django_lifecycle import LifecycleModel +from django_lifecycle import LifecycleModel, AFTER_UPDATE, hook class UserAccount(LifecycleModel): first_name = models.CharField(max_length=100) last_name = models.CharField(max_length=100) email = models.CharField(max_length=100) - marietal_status = models.CharField(max_length=100) + marital_status = models.CharField(max_length=100) - @hook("after_update") + @hook(AFTER_UPDATE) def on_name_change_heck_on_marietal_status(self): - if self.has_changed('last_name') and not self.has_changed('marietal_status) + if self.has_changed('last_name') and not self.has_changed('marietal_status'): send_mail(to=self.email, "Has your marietal status changed recently?") -``` +``` ## Suppressing Hooked Methods diff --git a/docs/examples.md b/docs/examples.md index 8a6d5f4..53ce527 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -7,7 +7,7 @@ For simple cases, you might always want something to happen at a certain point, When a user is first created, you could process a thumbnail image in the background and send the user an email: ```python - @hook('after_create') + @hook(AFTER_CREATE) def do_after_create_jobs(self): enqueue_job(process_thumbnail, self.picture_url) @@ -20,7 +20,7 @@ When a user is first created, you could process a thumbnail image in the backgro Or you want to email a user when their account is deleted. You could add the decorated method below: ```python - @hook('after_delete') + @hook(AFTER_DELETE) def email_deleted_user(self): mail.send_mail( 'We have deleted your account', 'We will miss you!.', @@ -30,12 +30,12 @@ Or you want to email a user when their account is deleted. You could add the dec Read on to see how to only fire the hooked method if certain conditions about the model's current and previous state are met. -## Transitions btwn specific values +## Transitions between specific values Maybe you only want the hooked method to run under certain circumstances related to the state of your model. If a model's `status` field change from `"active"` to `"banned"`, you may want to send an email to the user: ```python - @hook('after_update', when='status', was='active', is_now='banned') + @hook(AFTER_UPDATE, when='status', was='active', is_now='banned') def email_banned_user(self): mail.send_mail( 'You have been banned', 'You may or may not deserve it.', @@ -47,22 +47,22 @@ The `was` and `is_now` keyword arguments allow you to compare the model's state ## Preventing state transitions -You can also enforce certain dissallowed transitions. For example, maybe you don't want your staff to be able to delete an active trial because they should expire instead: +You can also enforce certain disallowed transitions. For example, maybe you don't want your staff to be able to delete an active trial because they should expire instead: ```python - @hook('before_delete', when='has_trial', is_now=True) + @hook(BEFORE_DELETE, when='has_trial', is_now=True) def ensure_trial_not_active(self): raise CannotDeleteActiveTrial('Cannot delete trial user!') ``` -We've ommitted the `was` keyword meaning that the initial state of the `has_trial` field can be any value ("*"). +We've omitted the `was` keyword meaning that the initial state of the `has_trial` field can be any value ("*"). ## Any change to a field You can pass the keyword argument `has_changed=True` to run the hooked method if a field has changed. ```python - @hook('before_update', when='address', has_changed=True) + @hook(BEFORE_UPDATE, when='address', has_changed=True) def timestamp_address_change(self): self.address_updated_at = timezone.now() ``` @@ -72,7 +72,7 @@ You can pass the keyword argument `has_changed=True` to run the hooked method if You can have a hooked method fire when a field's value IS NOT equal to a certain value. ```python - @hook('before_save', when='email', is_not=None) + @hook(BEFORE_SAVE, when='email', is_not=None) def lowercase_email(self): self.email = self.email.lower() ``` @@ -82,7 +82,7 @@ You can have a hooked method fire when a field's value IS NOT equal to a certain You can have a hooked method fire when a field's initial value was not equal to a specific value. ```python - @hook('before_save', when='status', was_not="rejected", is_now="published") + @hook(BEFORE_SAVE, when='status', was_not="rejected", is_now="published") def send_publish_alerts(self): send_mass_email() ``` @@ -93,7 +93,7 @@ You can have a hooked method fire when a field's initial value was not equal to but now is. ```python - @hook('before_save', when='status', changes_to="published") + @hook(BEFORE_SAVE, when='status', changes_to="published") def send_publish_alerts(self): send_mass_email() ``` @@ -102,7 +102,7 @@ Generally, `changes_to` is a shorthand for the situation when `was_not` and `is_ same value. The sample above is equal to: ```python - @hook('before_save', when='status', was_not="published", is_now="published") + @hook(BEFORE_SAVE, when='status', was_not="published", is_now="published") def send_publish_alerts(self): send_mass_email() ``` @@ -112,8 +112,8 @@ same value. The sample above is equal to: You can decorate the same method multiple times if you want to hook a method to multiple moments. ```python - @hook("after_update", when="published", has_changed=True) - @hook("after_create", when="type", has_changed=True) + @hook(AFTER_UPDATE, when="published", has_changed=True) + @hook(AFTER_CREATE, when="type", has_changed=True) def handle_update(self): # do something ``` @@ -123,7 +123,7 @@ You can decorate the same method multiple times if you want to hook a method to If you want to hook into the same moment, but base its conditions on multiple fields, you can use the `when_any` parameter. ```python - @hook('before_save', when_any=['status', 'type'], has_changed=True) + @hook(BEFORE_SAVE, when_any=['status', 'type'], has_changed=True) def do_something(self): # do something ``` @@ -133,7 +133,7 @@ If you want to hook into the same moment, but base its conditions on multiple fi If you need to hook into events with more complex conditions, you can take advantage of `has_changed` and `initial_value` [utility methods](advanced.md): ```python - @hook('after_update') + @hook(AFTER_UPDATE) def on_update(self): if self.has_changed('username') and not self.has_changed('password'): # do the thing here diff --git a/docs/fk_changes.md b/docs/fk_changes.md index fa5665d..83341f4 100644 --- a/docs/fk_changes.md +++ b/docs/fk_changes.md @@ -14,7 +14,7 @@ class UserAccount(LifecycleModel): email = models.CharField(max_length=600) employer = models.ForeignKey(Organization, on_delete=models.SET_NULL) - @hook("after_update", when="employer", has_changed=True) + @hook(AFTER_UPDATE, when="employer", has_changed=True) def notify_user_of_employer_change(self): mail.send_mail("Update", "You now work for someone else!", [self.email]) ``` @@ -22,7 +22,7 @@ class UserAccount(LifecycleModel): To be clear: This hook will fire when the value in the database column that stores the foreign key (in this case, `organization_id`) changes. Read on to see how to watch for changes to *fields on the related model*. ## ForeignKey Field Value Changes -You can have a hooked method fire based on the *value of a field* on a foreign key-related model using dot notation: +You can have a hooked method fire based on the *value of a field* on a foreign key-related model using dot-notation: ```python class Organization(models.Model): @@ -34,7 +34,7 @@ class UserAccount(LifecycleModel): email = models.CharField(max_length=600) employer = models.ForeignKey(Organization, on_delete=models.SET_NULL) - @hook("after_update", when="employer.name", has_changed=True, is_now="Google") + @hook(AFTER_UPDATE, when="employer.name", has_changed=True, is_now="Google") def notify_user_of_google_buy_out(self): mail.send_mail("Update", "Google bought your employer!", ["to@example.com"],) ``` diff --git a/docs/hooks_and_conditions.md b/docs/hooks_and_conditions.md index 9212a5c..70b854f 100644 --- a/docs/hooks_and_conditions.md +++ b/docs/hooks_and_conditions.md @@ -1,6 +1,8 @@ # Available Hooks & Conditions -You can hook into one or more lifecycle moments by adding the `@hook` decorator to a model's method. The moment name is passed as the first positional argument, `@hook('before_create')`, and optional keyword arguments can be passed to set up conditions for when the method should fire. +You can hook into one or more lifecycle moments by adding the `@hook` decorator to a model's method. The moment name + is passed as the first positional argument, `@hook(BEFORE_CREATE)`, and optional keyword arguments can be passed to + set up conditions for when the method should fire. ## Decorator Signature @@ -21,29 +23,32 @@ You can hook into one or more lifecycle moments by adding the `@hook` decorator Below is a full list of hooks, in the same order in which they will get called during the respective operations: -| Hook name | When it fires | -|:-------------:|:-------------:| -| before_save | Immediately before `save` is called | -| after_save | Immediately after `save` is called -| before_create | Immediately before `save` is called, if `pk` is `None` | -| after_create | Immediately after `save` is called, if `pk` was initially `None` | -| before_update | Immediately before `save` is called, if `pk` is NOT `None` | -| after_update | Immediately after `save` is called, if `pk` was NOT `None` | -| before_delete | Immediately before `delete` is called | -| after_delete | Immediately after `delete` is called | +| Hook constant | Hook name | When it fires | +|:---------------:|:-------------:|:----------------| +| `BEFORE_SAVE` | before_save | Immediately before `save` is called | +| `AFTER_SAVE` | after_save | Immediately after `save` is called +| `BEFORE_CREATE` | before_create | Immediately before `save` is called, if `pk` is `None` | +| `AFTER_CREATE` | after_create | Immediately after `save` is called, if `pk` was initially `None` | +| `BEFORE_UPDATE` | before_update | Immediately before `save` is called, if `pk` is NOT `None` | +| `AFTER_UPDATE` | after_update | Immediately after `save` is called, if `pk` was NOT `None` | +| `BEFORE_DELETE` | before_delete | Immediately before `delete` is called | +| `AFTER_DELETE` | after_delete | Immediately after `delete` is called | +All of hook constants are strings containing the specific hook name, for example `AFTER_UPDATE` is string + `"after_update"` - preferably way is to use hook constant. -## Condition Keyward Arguments + +## Condition Keyword Arguments If you do not use any conditional parameters, the hook will fire every time the lifecycle moment occurs. You can use the keyword arguments below to conditionally fire the method depending on the initial or current state of a model instance's fields. -| Keywarg arg | Type | Details | +| Keyword arg | Type | Details | |:-------------:|:-------------:|:-------------:| | when | str | The name of the field that you want to check against; required for the conditions below to be checked. Use the name of a FK field to watch changes to the related model *reference* or use dot-notation to watch changes to the *values* of fields on related models, e.g. `"organization.name"`. But [please be aware](fk_changes.md#fk-hook-warning) of potential performance drawbacks. | | when_any | List[str] | Similar to the `when` parameter, but takes a list of field names. The hooked method will fire if any of the corresponding fields meet the keyword conditions. Useful if you don't like stacking decorators. | | has_changed | bool | Only fire the hooked method if the value of the `when` field has changed since the model was initialized | -| is_now | any | Only fire the hooked method if the value of the `when` field is currently equal to this value; defaults to `*`. | -| is_not | any | Only fire the hooked method if the value of the `when` field is NOT equal to this value | -| was | any | Only fire the hooked method if the value of the `when` field was equal to this value when first initialized; defaults to `*`. | -| was_not | any | Only fire the hooked method if the value of the `when` field was NOT equal to this value when first initialized. | -| changes_to | any | Only fire the hooked method if the value of the `when` field was NOT equal to this value when first initialized but is currently equal to this value. | \ No newline at end of file +| is_now | Any | Only fire the hooked method if the value of the `when` field is currently equal to this value; defaults to `*`. | +| is_not | Any | Only fire the hooked method if the value of the `when` field is NOT equal to this value | +| was | Any | Only fire the hooked method if the value of the `when` field was equal to this value when first initialized; defaults to `*`. | +| was_not | Any | Only fire the hooked method if the value of the `when` field was NOT equal to this value when first initialized. | +| changes_to | Any | Only fire the hooked method if the value of the `when` field was NOT equal to this value when first initialized but is currently equal to this value. | diff --git a/docs/index.md b/docs/index.md index 0833b8f..2f0cb75 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,13 +2,16 @@ [![Package version](https://badge.fury.io/py/django-lifecycle.svg)](https://pypi.python.org/pypi/django-lifecycle) [![Python versions](https://img.shields.io/pypi/status/django-lifecycle.svg)](https://img.shields.io/pypi/status/django-lifecycle.svg/) +[![Python versions](https://img.shields.io/pypi/pyversions/django-lifecycle.svg)](https://pypi.org/project/django-lifecycle/) +![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-lifecycle) -This project provides a `@hook` decorator as well as a base model and mixin to add lifecycle hooks to your Django models. Django's built-in approach to offering lifecycle hooks is [Signals](https://docs.djangoproject.com/en/dev/topics/signals/). However, my team often finds that Signals introduce unnesseary indirection and are at odds with Django's "fat models" approach. + +This project provides a `@hook` decorator as well as a base model and mixin to add lifecycle hooks to your Django models. Django's built-in approach to offering lifecycle hooks is [Signals](https://docs.djangoproject.com/en/dev/topics/signals/). However, my team often finds that Signals introduce unnecessary indirection and are at odds with Django's "fat models" approach. In short, you can write model code like this: ```python -from django_lifecycle import LifecycleModel, hook +from django_lifecycle import LifecycleModel, hook, BEFORE_UPDATE, AFTER_UPDATE class Article(LifecycleModel): @@ -17,26 +20,26 @@ class Article(LifecycleModel): status = models.ChoiceField(choices=['draft', 'published']) editor = models.ForeignKey(AuthUser) - @hook('before_update', when='contents', has_changed=True) + @hook(BEFORE_UPDATE, when='contents', has_changed=True) def on_content_change(self): self.updated_at = timezone.now() - @hook('after_update', when="status", was="draft", is_now="published") + @hook(AFTER_UPDATE, when="status", was="draft", is_now="published") def on_publish(self): send_email(self.editor.email, "An article has published!") ``` -Instead of overriding `save` and `__init___` in a clunky way that hurts readability: +Instead of overriding `save` and `__init__` in a clunky way that hurts readability: ```python # same class and field declarations as above ... - + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._orig_contents = self.contents self._orig_status = self.status - - + + def save(self, *args, **kwargs): if self.pk is not None and self.contents != self._orig_contents): self.updated_at = timezone.now() @@ -49,8 +52,8 @@ Instead of overriding `save` and `__init___` in a clunky way that hurts readabil ## Requirements -* Python (3.3+) -* Django (1.8+) +* Python (3.5+) +* Django (2.0+) ## Installation @@ -58,7 +61,7 @@ Instead of overriding `save` and `__init___` in a clunky way that hurts readabil pip install django-lifecycle ``` -## Getting Started +## Getting Started Either extend the provided abstract base model class: @@ -84,6 +87,4 @@ class YourModel(LifecycleModelMixin, models.Model): ``` -If you are using **Django 1.8 or below** and want to extend the base model, you also have to add `django_lifecycle` to `INSTALLED_APPS`. - -[Read on](/examples/) to see more examples of how to use lifecycle hooks. +[Read on](examples.md) to see more examples of how to use lifecycle hooks. diff --git a/manage.py b/manage.py index 5bba3ba..1e5b998 100755 --- a/manage.py +++ b/manage.py @@ -6,10 +6,10 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") try: from django.core.management import execute_from_command_line - except ImportError as exc: + except ImportError: raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" ) - execute_from_command_line(sys.argv) \ No newline at end of file + execute_from_command_line(sys.argv) diff --git a/pypi_submit.py b/pypi_submit.py index ea86ec2..cf30789 100644 --- a/pypi_submit.py +++ b/pypi_submit.py @@ -1,4 +1,4 @@ import os os.system("python setup.py sdist --verbose") -os.system("twine upload dist/*") +os.system("twine upload dist/*") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 60f80cd..be23687 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,17 @@ -Django==2.2.8 +Click==7.0 +Django==3.0.7 djangorestframework==3.9.4 +Jinja2==2.11.1 +livereload==2.6.1 +Markdown==3.2.1 +MarkupSafe==1.1.1 +mkdocs==1.0.4 +mkdocs-material==4.6.3 +Pygments==2.5.2 +pymdown-extensions==6.3 pytz==2018.3 +PyYAML==5.3 +six==1.14.0 sqlparse==0.3.0 +tornado==6.0.3 urlman==1.2.0 diff --git a/script/deploy b/script/deploy new file mode 100755 index 0000000..5e016a2 --- /dev/null +++ b/script/deploy @@ -0,0 +1,6 @@ +#!/bin/sh +set -e + +mkdocs gh-deploy +python setup.py sdist --verbose +twine upload dist/* \ No newline at end of file diff --git a/setup.py b/setup.py index d556aad..8b7c7e4 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,22 @@ #!/usr/bin/env python +import codecs +import os +import re + from setuptools import setup from codecs import open +def get_metadata(package, field): + """ + Return package data as listed in `__{field}__` in `init.py`. + """ + init_py = codecs.open(os.path.join(package, "__init__.py"), encoding="utf-8").read() + return re.search( + "^__{}__ = ['\"]([^'\"]+)['\"]".format(field), init_py, re.MULTILINE + ).group(1) + + def readme(): with open("README.md", "r") as infile: return infile.read() @@ -12,17 +26,25 @@ def readme(): # Pick your license as you wish (should match "license" above) "Development Status :: 4 - Beta", "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.4", + "Programming Language :: Python", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Framework :: Django", + "Framework :: Django :: 2.0", + "Framework :: Django :: 2.1", + "Framework :: Django :: 2.2", + "Framework :: Django :: 3.0", + "Framework :: Django :: 3.1", ] setup( name="django-lifecycle", - version="0.7.1", + version=get_metadata("django_lifecycle", "version"), description="Declarative model lifecycle hooks.", - author="Robert Singer", - author_email="robertgsinger@gmail.com", + author=get_metadata("django_lifecycle", "author"), + author_email=get_metadata("django_lifecycle", "author_email"), packages=["django_lifecycle"], url="https://github.com/rsinger86/django-lifecycle", license="MIT", @@ -30,4 +52,8 @@ def readme(): long_description=readme(), classifiers=classifiers, long_description_content_type="text/markdown", + install_requires=[ + "Django>=2.0", + "urlman>=1.2.0" + ], ) diff --git a/tests/settings.py b/tests/settings.py index 8f6ceaa..6ea20ee 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -120,4 +120,3 @@ # https://docs.djangoproject.com/en/2.0/howto/static-files/ STATIC_URL = "/static/" - diff --git a/tests/testapp/migrations/0001_initial.py b/tests/testapp/migrations/0001_initial.py index 0f1a6de..65fc524 100644 --- a/tests/testapp/migrations/0001_initial.py +++ b/tests/testapp/migrations/0001_initial.py @@ -25,7 +25,10 @@ class Migration(migrations.Migration): ('password_updated_at', models.DateTimeField(null=True)), ('joined_at', models.DateTimeField(null=True)), ('has_trial', models.BooleanField(default=False)), - ('status', models.CharField(choices=[('active', 'Active'), ('banned', 'Banned'), ('inactive', 'Inactive')], default='active', max_length=30)), + ('status', models.CharField( + choices=[('active', 'Active'), ('banned', 'Banned'), ('inactive', 'Inactive')], + default='active', max_length=30 + )), ], options={ 'abstract': False, diff --git a/tests/testapp/models.py b/tests/testapp/models.py index 9b98fe2..12f5241 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -1,14 +1,14 @@ import uuid -from django.utils import timezone from django.core import mail from django.db import models +from django.utils import timezone from django.utils.functional import cached_property +from urlman import Urls + from django_lifecycle import hook from django_lifecycle.models import LifecycleModel -import urlman - class CannotDeleteActiveTrial(Exception): pass @@ -40,7 +40,7 @@ class UserAccount(LifecycleModel): choices=(("active", "Active"), ("banned", "Banned"), ("inactive", "Inactive")), ) - class urls(urlman.Urls): + class urls(Urls): view = "/books/{self.pk}/" @hook("before_save", when="email", is_not=None) @@ -53,7 +53,7 @@ def timestamp_joined_at(self): @hook("after_create") def do_after_create_jobs(self): - ## queue background job to process thumbnail image... + # queue background job to process thumbnail image... mail.send_mail( "Welcome!", "Thank you for joining.", "from@example.com", ["to@example.com"] ) diff --git a/tests/testapp/tests/test_mixin.py b/tests/testapp/tests/test_mixin.py index 7d89ac8..510e749 100644 --- a/tests/testapp/tests/test_mixin.py +++ b/tests/testapp/tests/test_mixin.py @@ -93,80 +93,84 @@ def test_current_value_for_watched_fk_model_field(self): def test_run_hooked_methods_for_when(self): instance = UserAccount(first_name="Bob") - instance._potentially_hooked_methods = [ - MagicMock( - __name__="method_that_does_fires", - _hooked=[ - { - "hook": "after_create", - "when": "first_name", - "when_any": None, - "has_changed": None, - "is_now": "Bob", - "is_not": NotSet, - "was": "*", - "was_not": NotSet, - "changes_to": NotSet, - } - ], - ), - MagicMock( - __name__="method_that_does_not_fire", - _hooked=[ - { - "hook": "after_create", - "when": "first_name", - "when_any": None, - "has_changed": None, - "is_now": "Bill", - "is_not": NotSet, - "was": "*", - "was_not": NotSet, - "changes_to": NotSet, - } - ], - ), - ] + instance._potentially_hooked_methods = MagicMock( + return_value=[ + MagicMock( + __name__="method_that_does_fires", + _hooked=[ + { + "hook": "after_create", + "when": "first_name", + "when_any": None, + "has_changed": None, + "is_now": "Bob", + "is_not": NotSet, + "was": "*", + "was_not": NotSet, + "changes_to": NotSet, + } + ], + ), + MagicMock( + __name__="method_that_does_not_fire", + _hooked=[ + { + "hook": "after_create", + "when": "first_name", + "when_any": None, + "has_changed": None, + "is_now": "Bill", + "is_not": NotSet, + "was": "*", + "was_not": NotSet, + "changes_to": NotSet, + } + ], + ), + ] + ) fired_methods = instance._run_hooked_methods("after_create") self.assertEqual(fired_methods, ["method_that_does_fires"]) def test_run_hooked_methods_for_when_any(self): instance = UserAccount(first_name="Bob") - instance._potentially_hooked_methods = [ - MagicMock( - __name__="method_that_does_fires", - _hooked=[ - { - "hook": "after_create", - "when": None, - "when_any": ["first_name", "last_name", "password"], - "has_changed": None, - "is_now": "Bob", - "is_not": NotSet, - "was": "*", - "was_not": NotSet, - "changes_to": NotSet, - } - ], - ), - MagicMock( - __name__="method_that_does_not_fire", - _hooked=[ - { - "hook": "after_create", - "when": "first_name", - "when_any": None, - "has_changed": None, - "is_now": "Bill", - "is_not": NotSet, - "was": "*", - "was_not": NotSet, - "changes_to": NotSet, - } - ], - ), - ] + instance._potentially_hooked_methods = MagicMock( + return_value = [ + MagicMock( + __name__="method_that_does_fires", + _hooked=[ + { + "hook": "after_create", + "when": None, + "when_any": ["first_name", "last_name", "password"], + "has_changed": None, + "is_now": "Bob", + "is_not": NotSet, + "was": "*", + "was_not": NotSet, + "changes_to": NotSet, + } + ], + ), + MagicMock( + __name__="method_that_does_not_fire", + _hooked=[ + { + "hook": "after_create", + "when": "first_name", + "when_any": None, + "has_changed": None, + "is_now": "Bill", + "is_not": NotSet, + "was": "*", + "was_not": NotSet, + "changes_to": NotSet, + } + ], + ), + ] + ) fired_methods = instance._run_hooked_methods("after_create") self.assertEqual(fired_methods, ["method_that_does_fires"]) @@ -305,8 +309,6 @@ def test_is_not_condition_should_not_pass(self): self.assertFalse(user_account._check_is_not_condition("first_name", specs)) def test_changes_to_condition_should_pass(self): - specs = {"when": "last_name", "changes_to": "Flanders"} - data = self.stub_data UserAccount.objects.create(**data) user_account = UserAccount.objects.get() @@ -315,8 +317,6 @@ def test_changes_to_condition_should_pass(self): user_account.save() def test_changes_to_condition_should_not_pass(self): - specs = {"when": "last_name", "changes_to": "Bouvier"} - data = self.stub_data data["first_name"] = "Marge" data["last_name"] = "Simpson" diff --git a/tests/testapp/tests/test_user_account.py b/tests/testapp/tests/test_user_account.py index 20d9b51..4f17815 100644 --- a/tests/testapp/tests/test_user_account.py +++ b/tests/testapp/tests/test_user_account.py @@ -22,7 +22,7 @@ def test_update_joined_at_before_create(self): self.assertTrue(isinstance(account.joined_at, datetime.datetime)) def test_send_welcome_email_after_create(self): - account = UserAccount.objects.create(**self.stub_data) + UserAccount.objects.create(**self.stub_data) self.assertEqual(len(mail.outbox), 1) self.assertEqual(mail.outbox[0].subject, "Welcome!") @@ -126,3 +126,13 @@ def test_skip_hooks(self): account.save(skip_hooks=True) self.assertEqual(account.email, "Homer.Simpson@springfieldnuclear") + def test_delete_should_return_default_django_value(self): + """ + Hooked method that auto-lowercases email should be skipped. + """ + UserAccount.objects.create(**self.stub_data) + value = UserAccount.objects.all().delete() + + self.assertEqual( + value, (1, {"testapp.Locale_users": 0, "testapp.UserAccount": 1}) + ) diff --git a/tests/testapp/views.py b/tests/testapp/views.py index 91ea44a..e69de29 100644 --- a/tests/testapp/views.py +++ b/tests/testapp/views.py @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/tox.ini b/tox.ini index 6fb63a2..4b58b81 100644 --- a/tox.ini +++ b/tox.ini @@ -3,16 +3,32 @@ toxworkdir={env:TOXWORKDIR:{toxinidir}/.tox} envlist = {py35,py36,py37}-django20 {py35,py36,py37}-django21 - {py35,py36,py37}-django22 + {py35,py36,py37,py38,py39}-django22 + {py36,py37,py38,py39}-django30 + {py36,py37,py38,py39}-django31 + flake8 skip_missing_interpreters = True +[flake8] +max-line-length = 120 + [testenv] -commands = python ./manage.py test --settings=tests.settings +commands = python ./manage.py test envdir = {toxworkdir}/venvs/{envname} setenv = PYTHONDONTWRITEBYTECODE=1 PYTHONWARNINGS=once + DJANGO_SETTINGS_MODULE=tests.settings deps = django20: django>=2.0,<2.1 django21: django>=2.1,<2.2 django22: django>=2.2,<3 + django30: django>=3.0,<3.1 + django31: django>=3.1,<3.2 + +[testenv:flake8] +basepython = python3.7 +deps = + flake8==3.8.4 +commands = + flake8 . --exclude=venv/,.tox/,django_lifecycle/__init__.py