From 284db431099e2699cc169db1952945fbdcc9f87f Mon Sep 17 00:00:00 2001 From: Romel Santoral Date: Tue, 12 Mar 2019 16:55:22 +0400 Subject: [PATCH 01/16] Add `.gitignore` file with entries for bytecode and package metadata --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb459bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Python bytecode files +*.py[co] +__pycache__/ + +# Python package metadata +*.egg-info/ From 8c965cc0d8030c397a79440cdb1221c120a7552e Mon Sep 17 00:00:00 2001 From: Romel Santoral Date: Tue, 12 Mar 2019 16:56:09 +0400 Subject: [PATCH 02/16] Specify `BitemporalManager` to be used in migrations --- bitemporal/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bitemporal/models.py b/bitemporal/models.py index ac798cd..30d876b 100644 --- a/bitemporal/models.py +++ b/bitemporal/models.py @@ -27,6 +27,8 @@ def valid_on(self, date_time): class BitemporalManager(models.Manager): """Model manager for bitemporal models.""" + use_in_migrations = True + def get_query_set(self): """Return an instance of `BitemporalQuerySet`.""" return BitemporalQuerySet(self.model, using=self._db) From 5f98839981e395bd1d10b002ff0ef67731a70456 Mon Sep 17 00:00:00 2001 From: Romel Santoral Date: Tue, 12 Mar 2019 18:02:56 +0400 Subject: [PATCH 03/16] Update minimum `Django` requirement to version `1.11` --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index dfbc86f..2c3c0a2 100644 --- a/setup.py +++ b/setup.py @@ -5,6 +5,6 @@ name='django-bitemporal', version='0.1.0', install_requires=[ - 'Django>=1.5', + 'Django>=1.11', ], ) From e30379f3d28333932fb5aedc71f114dd6215cec3 Mon Sep 17 00:00:00 2001 From: Romel Santoral Date: Tue, 12 Mar 2019 18:03:13 +0400 Subject: [PATCH 04/16] Bump version to `0.2.0` --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2c3c0a2..2871fdb 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name='django-bitemporal', - version='0.1.0', + version='0.2.0', install_requires=[ 'Django>=1.11', ], From debfb575e21d72d6cc7ba2a675f83a197adf6529 Mon Sep 17 00:00:00 2001 From: Romel Santoral Date: Tue, 12 Mar 2019 21:20:21 +0400 Subject: [PATCH 05/16] Build manager class using `BaseManager.from_queryset` generator Fixes pre-1.6 `get_query_set` method and avoids duplicating methods. --- bitemporal/models.py | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/bitemporal/models.py b/bitemporal/models.py index 30d876b..f57ed4b 100644 --- a/bitemporal/models.py +++ b/bitemporal/models.py @@ -1,6 +1,7 @@ """Models for bitemporal objects.""" from django.db import models +from django.db.models import manager from django.db.models import query @@ -24,27 +25,11 @@ def valid_on(self, date_time): return self.filter(condition) -class BitemporalManager(models.Manager): +class BitemporalManager(manager.BaseManager.from_queryset(BitemporalQuerySet)): """Model manager for bitemporal models.""" use_in_migrations = True - def get_query_set(self): - """Return an instance of `BitemporalQuerySet`.""" - return BitemporalQuerySet(self.model, using=self._db) - - # - # Proxies to queryset - # - - def valid(self): - """Return queryset filtered to current valid objects.""" - return self.get_query_set().valid() - - def valid_on(self, date_time): - """Return queryset filtered to objects valid on given datetime.""" - return self.get_query_set().valid_on(date_time) - class BitemporalModel(models.Model): """Base model class for bitemporal models.""" From c0f28f95db05ce2ef7d79ad770a97d6618d73b52 Mon Sep 17 00:00:00 2001 From: Romel Santoral Date: Sun, 19 May 2019 20:55:13 +0400 Subject: [PATCH 06/16] Correct `valid_on` implementation to skip records that already ended --- bitemporal/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitemporal/models.py b/bitemporal/models.py index f57ed4b..57092ad 100644 --- a/bitemporal/models.py +++ b/bitemporal/models.py @@ -17,7 +17,7 @@ def valid_on(self, date_time): condition = ( models.Q( valid_datetime_start__lte=date_time, - valid_datetime_end__gte=date_time) | + valid_datetime_end__gt=date_time) | models.Q( valid_datetime_start__lte=date_time, valid_datetime_end__isnull=True) From caaa2002eeb90a2477795066af20c1a842f7b59a Mon Sep 17 00:00:00 2001 From: Romel Santoral Date: Sun, 19 May 2019 20:55:32 +0400 Subject: [PATCH 07/16] Implement `supersede` method --- bitemporal/models.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/bitemporal/models.py b/bitemporal/models.py index 57092ad..2b211a9 100644 --- a/bitemporal/models.py +++ b/bitemporal/models.py @@ -1,8 +1,10 @@ """Models for bitemporal objects.""" from django.db import models +from django.db import transaction from django.db.models import manager from django.db.models import query +from django.utils import timezone class BitemporalQuerySet(query.QuerySet): @@ -24,6 +26,32 @@ def valid_on(self, date_time): ) return self.filter(condition) + def supersede(self, values, **kwargs): + """Supersede the object that matched **kwargs with provided values.""" + lookup, params = self._extract_model_params(values, **kwargs) + with transaction.atomic(using=self.db): + # datetime the superseding obj will supersede + cutoff_datetime = params.get( + 'valid_datetime_start', timezone.now()) + curr_obj = self.select_for_update().valid().get(**lookup) + + # invalidate existing instance + curr_obj.valid_datetime_end = cutoff_datetime + curr_obj.save(update_fields=['valid_datetime_end']) + + # create superseding instance + sup_obj = curr_obj + sup_obj.pk = None + sup_obj.valid_datetime_start = cutoff_datetime + sup_obj.valid_datetime_end = None + sup_obj.transaction_datetime_start = None + sup_obj.transaction_datetime_end = None + for k, v in params.items(): + setattr(sup_obj, k, v() if callable(v) else v) + sup_obj.save() + + return sup_obj + class BitemporalManager(manager.BaseManager.from_queryset(BitemporalQuerySet)): """Model manager for bitemporal models.""" From ecf8f7d379a7e723f6e48da8d24df7eb403cfe28 Mon Sep 17 00:00:00 2001 From: Romel Santoral Date: Sun, 19 May 2019 21:22:31 +0400 Subject: [PATCH 08/16] Correct `valid` method implementation to return currently valid record --- bitemporal/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitemporal/models.py b/bitemporal/models.py index 2b211a9..763f702 100644 --- a/bitemporal/models.py +++ b/bitemporal/models.py @@ -12,7 +12,7 @@ class BitemporalQuerySet(query.QuerySet): def valid(self): """Return objects that are currently valid.""" - return self.filter(valid_datetime_end__isnull=True) + return self.valid_on(timezone.now()) def valid_on(self, date_time): """Return objects that were valid on the given datetime.""" From 33101d8df6c25830d94640f98b8bd32e507a87d6 Mon Sep 17 00:00:00 2001 From: Romel Santoral Date: Sun, 19 May 2019 22:23:00 +0400 Subject: [PATCH 09/16] Rename variable --- bitemporal/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bitemporal/models.py b/bitemporal/models.py index 763f702..1ef39b3 100644 --- a/bitemporal/models.py +++ b/bitemporal/models.py @@ -16,7 +16,7 @@ def valid(self): def valid_on(self, date_time): """Return objects that were valid on the given datetime.""" - condition = ( + validity_conditions = ( models.Q( valid_datetime_start__lte=date_time, valid_datetime_end__gt=date_time) | @@ -24,7 +24,7 @@ def valid_on(self, date_time): valid_datetime_start__lte=date_time, valid_datetime_end__isnull=True) ) - return self.filter(condition) + return self.filter(validity_conditions) def supersede(self, values, **kwargs): """Supersede the object that matched **kwargs with provided values.""" From 7da59a026e9f7def35c18b6899527728b92c4151 Mon Sep 17 00:00:00 2001 From: Romel Santoral Date: Sat, 25 May 2019 03:38:03 +0400 Subject: [PATCH 10/16] Add condition to `valid_on` to skip invalidated records --- bitemporal/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bitemporal/models.py b/bitemporal/models.py index 1ef39b3..ed09396 100644 --- a/bitemporal/models.py +++ b/bitemporal/models.py @@ -24,7 +24,8 @@ def valid_on(self, date_time): valid_datetime_start__lte=date_time, valid_datetime_end__isnull=True) ) - return self.filter(validity_conditions) + return self.filter( + validity_conditions, transaction_datetime_end__isnull=True) def supersede(self, values, **kwargs): """Supersede the object that matched **kwargs with provided values.""" From 4f97d0e769a49e3f583d07fe2805c8ea2df04f89 Mon Sep 17 00:00:00 2001 From: Romel Santoral Date: Sat, 25 May 2019 03:39:17 +0400 Subject: [PATCH 11/16] Update code comment --- bitemporal/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitemporal/models.py b/bitemporal/models.py index ed09396..e0b251e 100644 --- a/bitemporal/models.py +++ b/bitemporal/models.py @@ -36,7 +36,7 @@ def supersede(self, values, **kwargs): 'valid_datetime_start', timezone.now()) curr_obj = self.select_for_update().valid().get(**lookup) - # invalidate existing instance + # obsolesce existing instance curr_obj.valid_datetime_end = cutoff_datetime curr_obj.save(update_fields=['valid_datetime_end']) From 4daceb49aa6df6c4836b707d6667b8f9ff07a634 Mon Sep 17 00:00:00 2001 From: Romel Santoral Date: Sat, 25 May 2019 04:39:14 +0400 Subject: [PATCH 12/16] Add `exceptions` module with `BitemporalObjectAlreadySuperseded` class --- bitemporal/exceptions.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 bitemporal/exceptions.py diff --git a/bitemporal/exceptions.py b/bitemporal/exceptions.py new file mode 100644 index 0000000..63d4c3d --- /dev/null +++ b/bitemporal/exceptions.py @@ -0,0 +1,11 @@ +"""Exception classes related to bitemporal objects.""" + + +class BitemporalObjectAlreadySuperseded(Exception): + """Exception raised when bitemporal object is already superseded.""" + + def __init__(self, obj): + """Initialize exception with the already superseded object.""" + super().__init__( + 'Bitemporal object {} is already superseded and can no ' + 'longer be superseded.'.format(obj)) From 3a98ee41308f4defbdb106ea635a371b0204ba42 Mon Sep 17 00:00:00 2001 From: Romel Santoral Date: Sat, 25 May 2019 04:40:44 +0400 Subject: [PATCH 13/16] Raise exception when trying to supersede an already superseded object --- bitemporal/models.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/bitemporal/models.py b/bitemporal/models.py index e0b251e..912cfb0 100644 --- a/bitemporal/models.py +++ b/bitemporal/models.py @@ -6,6 +6,8 @@ from django.db.models import query from django.utils import timezone +from .exceptions import BitemporalObjectAlreadySuperseded + class BitemporalQuerySet(query.QuerySet): """QuerySet for bitemporal model managers.""" @@ -36,6 +38,19 @@ def supersede(self, values, **kwargs): 'valid_datetime_start', timezone.now()) curr_obj = self.select_for_update().valid().get(**lookup) + # Check if current object is already superseded. + # The object is superseded if its `valid_datetime_end` is + # already set, and a similar object exists with + # `valid_datetime_start` is on or after this object's + # `valid_datetime_end` + if (curr_obj.valid_datetime_end is not None + and self.filter(**lookup) + .filter( + valid_datetime_start__gte=( + curr_obj.valid_datetime_end)) + .exists()): + raise BitemporalObjectAlreadySuperseded(curr_obj) + # obsolesce existing instance curr_obj.valid_datetime_end = cutoff_datetime curr_obj.save(update_fields=['valid_datetime_end']) From d84bfc7e3d856ce23250d631bee4b19f10f392f4 Mon Sep 17 00:00:00 2001 From: Romel Santoral Date: Sun, 26 May 2019 01:52:02 +0400 Subject: [PATCH 14/16] Add `BitemporalObjectAlreadySupplanted` exception class --- bitemporal/exceptions.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bitemporal/exceptions.py b/bitemporal/exceptions.py index 63d4c3d..442249d 100644 --- a/bitemporal/exceptions.py +++ b/bitemporal/exceptions.py @@ -9,3 +9,13 @@ def __init__(self, obj): super().__init__( 'Bitemporal object {} is already superseded and can no ' 'longer be superseded.'.format(obj)) + + +class BitemporalObjectAlreadySupplanted(Exception): + """Exception raised when bitemporal object is already supplanted.""" + + def __init__(self, obj): + """Initialize exception with the already supplanted object.""" + super().__init__( + 'Bitemporal object {} is already supplanted and can no ' + 'longer be supplanted.'.format(obj)) From 5457a47fcb5306354a0dd23eabb23ac7fa3134cd Mon Sep 17 00:00:00 2001 From: Romel Santoral Date: Sun, 26 May 2019 01:52:25 +0400 Subject: [PATCH 15/16] Implement `supplant` method --- bitemporal/models.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/bitemporal/models.py b/bitemporal/models.py index 912cfb0..7d7ddf0 100644 --- a/bitemporal/models.py +++ b/bitemporal/models.py @@ -7,6 +7,7 @@ from django.utils import timezone from .exceptions import BitemporalObjectAlreadySuperseded +from .exceptions import BitemporalObjectAlreadySupplanted class BitemporalQuerySet(query.QuerySet): @@ -68,6 +69,30 @@ def supersede(self, values, **kwargs): return sup_obj + def supplant(self, values, **kwargs): + """Replace object by invalidating it and creating a new one.""" + lookup, params = self._extract_model_params(values, **kwargs) + with transaction.atomic(using=self.db): + matched_obj = self.select_for_update().get(**lookup) + + if matched_obj.transaction_datetime_end is not None: + raise BitemporalObjectAlreadySupplanted(matched_obj) + + # invalidate matched instance + matched_obj.transaction_datetime_end = timezone.now() + matched_obj.save(update_fields=['transaction_datetime_end']) + + # new instance + new_obj = matched_obj + new_obj.pk = None + new_obj.transaction_datetime_start = None + new_obj.transaction_datetime_end = None + for k, v in params.items(): + setattr(new_obj, k, v() if callable(v) else v) + new_obj.save() + + return new_obj + class BitemporalManager(manager.BaseManager.from_queryset(BitemporalQuerySet)): """Model manager for bitemporal models.""" From b1b967e1e56afd9fec7739699b38b3a599968f54 Mon Sep 17 00:00:00 2001 From: Romel Santoral Date: Sun, 26 May 2019 02:35:31 +0400 Subject: [PATCH 16/16] Remove `auto_now_add` option from field and use `default` instead This skips making the `transaction_datetime_start` field undisplayable from the admin site, which is inconsistent with the other fields. --- bitemporal/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitemporal/models.py b/bitemporal/models.py index 7d7ddf0..0372f3c 100644 --- a/bitemporal/models.py +++ b/bitemporal/models.py @@ -107,7 +107,7 @@ class BitemporalModel(models.Model): valid_datetime_end = models.DateTimeField( blank=True, null=True, db_index=True) transaction_datetime_start = models.DateTimeField( - auto_now_add=True, db_index=True) + default=timezone.now, db_index=True) transaction_datetime_end = models.DateTimeField( blank=True, null=True, db_index=True)