Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Python bytecode files
*.py[co]
__pycache__/

# Python package metadata
*.egg-info/
21 changes: 21 additions & 0 deletions bitemporal/exceptions.py
Original file line number Diff line number Diff line change
@@ -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))
102 changes: 79 additions & 23 deletions bitemporal/models.py
Original file line number Diff line number Diff line change
@@ -1,47 +1,103 @@
"""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):
"""QuerySet for bitemporal model managers."""

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):
Expand All @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

setup(
name='django-bitemporal',
version='0.1.0',
version='0.2.0',
install_requires=[
'Django>=1.5',
'Django>=1.11',
],
)