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/ diff --git a/bitemporal/exceptions.py b/bitemporal/exceptions.py new file mode 100644 index 0000000..442249d --- /dev/null +++ b/bitemporal/exceptions.py @@ -0,0 +1,21 @@ +"""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)) + + +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)) diff --git a/bitemporal/models.py b/bitemporal/models.py index ac798cd..0372f3c 100644 --- a/bitemporal/models.py +++ b/bitemporal/models.py @@ -1,7 +1,13 @@ """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 + +from .exceptions import BitemporalObjectAlreadySuperseded +from .exceptions import BitemporalObjectAlreadySupplanted class BitemporalQuerySet(query.QuerySet): @@ -9,39 +15,89 @@ 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.""" - condition = ( + validity_conditions = ( 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) ) - return self.filter(condition) - - -class BitemporalManager(models.Manager): + return self.filter( + validity_conditions, transaction_datetime_end__isnull=True) + + 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) + + # 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']) + + # 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 + + 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.""" - 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) + use_in_migrations = True class BitemporalModel(models.Model): @@ -51,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) diff --git a/setup.py b/setup.py index dfbc86f..2871fdb 100644 --- a/setup.py +++ b/setup.py @@ -3,8 +3,8 @@ setup( name='django-bitemporal', - version='0.1.0', + version='0.2.0', install_requires=[ - 'Django>=1.5', + 'Django>=1.11', ], )