From 13d7ca9a0d681babe5d17a11859cc580861210b7 Mon Sep 17 00:00:00 2001 From: Andrey Zevakin Date: Wed, 22 Feb 2012 11:59:48 +0600 Subject: [PATCH] =?UTF-8?q?=D0=B0=20=D0=B2=D0=BE=D1=82=20=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 10 ++ README.rst | 72 ++++++++++ README => example/__init__.py | 0 example/app/__init__.py | 0 example/app/models.py | 20 +++ example/app/tests.py | 16 +++ example/app/views.py | 1 + example/manage.py | 14 ++ example/settings.py | 149 +++++++++++++++++++++ example/temporal | 1 + example/urls.py | 17 +++ temporal/__init__.py | 0 temporal/models/__init__.py | 80 +++++++++++ temporal/models/fields.py | 72 ++++++++++ temporal/models/trail.py | 245 ++++++++++++++++++++++++++++++++++ temporal/tests.py | 16 +++ temporal/views.py | 1 + 17 files changed, 714 insertions(+) create mode 100644 .gitignore create mode 100644 README.rst rename README => example/__init__.py (100%) create mode 100644 example/app/__init__.py create mode 100644 example/app/models.py create mode 100644 example/app/tests.py create mode 100644 example/app/views.py create mode 100644 example/manage.py create mode 100644 example/settings.py create mode 120000 example/temporal create mode 100644 example/urls.py create mode 100644 temporal/__init__.py create mode 100644 temporal/models/__init__.py create mode 100644 temporal/models/fields.py create mode 100644 temporal/models/trail.py create mode 100644 temporal/tests.py create mode 100644 temporal/views.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52d310f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.project +.pydevproject +.settings + +*~ + +*.pyc +*.pyo + +*.sqlite diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..6de0d1e --- /dev/null +++ b/README.rst @@ -0,0 +1,72 @@ +====================== +Django-temporal-models +====================== + +Django-temporal-models это темпоральные модели навеянные 1совскими регистрами сведений. + +Небольшой пример +============== + from temporal.models import models, TemporalForeignKey, TemporalModel, TemporalTrail + + class Person(TemporalModel): + first_name = models.CharField(max_length=255) + last_name = models.CharField(max_length=255) + salary = models.PositiveIntegerField() + organization = TemporalForeignKey('Organization') + + history = TemporalTrail() + + def __str__(self): + return u"%s %s" % (self.first_name, self.last_name) + + class Organization(TemporalModel): + name = models.CharField(max_length=255) + + history = TemporalTrail() + + def __str__(self): + return u"%s" % (self.name) + + ... + + >>> from app.models import Organization, Person + >>> from datetime import date + >>> + >>> org = Organization.objects.create(name=u'Муниципальное унитарное предприятие городского транспорта "Тюменьгортранс"', date_begin=date(1997, 01, 31)) + >>> + >>> org.name = u'Муниципальное учреждение пассажирского городского транспорта "Тюменьгортранс"' + >>> org.date_begin = date(2004,7,1) + >>> org.save() + >>> + >>> org.name = u'Муниципальное казенное учреждение "Тюменьгортранс"' + >>> org.date_begin = date(2012,1,11) + >>> org.save() + >>> + >>> org.get_actual(date(2010,1,1)) + + >>> + >>> person = Person.objects.create(first_name=u'Василий', last_name=u'Пупкин', salary=7000, organization=org, date_begin=date(2000,5,10)) + >>> + >>> person.date_begin=date(2005,1,1) + >>> person.salary=12000 + >>> person.save() + >>> + >>> person.date_begin=date(2010,1,1) + >>> person.salary=17000 + >>> person.save() + >>> + >>> person.date_begin=date(2012,2,1) + >>> person.salary=20000 + >>> person.save() + >>> + >>> person.get_actual() + + >>> + >>> person.get_actual().organization + + >>> + >>> person.get_actual(date(2011,10,1)) + + >>> + >>> person.get_actual(date(2011,10,1)).organization + diff --git a/README b/example/__init__.py similarity index 100% rename from README rename to example/__init__.py diff --git a/example/app/__init__.py b/example/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example/app/models.py b/example/app/models.py new file mode 100644 index 0000000..cd87694 --- /dev/null +++ b/example/app/models.py @@ -0,0 +1,20 @@ +from temporal.models import models, TemporalForeignKey, TemporalModel, TemporalTrail + +class Person(TemporalModel): + first_name = models.CharField(max_length=255) + last_name = models.CharField(max_length=255) + salary = models.PositiveIntegerField() + organization = TemporalForeignKey('Organization') + + history = TemporalTrail() + + def __str__(self): + return u"%s %s" % (self.first_name, self.last_name) + +class Organization(TemporalModel): + name = models.CharField(max_length=255) + + history = TemporalTrail() + + def __str__(self): + return u"%s" % (self.name) diff --git a/example/app/tests.py b/example/app/tests.py new file mode 100644 index 0000000..501deb7 --- /dev/null +++ b/example/app/tests.py @@ -0,0 +1,16 @@ +""" +This file demonstrates writing tests using the unittest module. These will pass +when you run "manage.py test". + +Replace this with more appropriate tests for your application. +""" + +from django.test import TestCase + + +class SimpleTest(TestCase): + def test_basic_addition(self): + """ + Tests that 1 + 1 always equals 2. + """ + self.assertEqual(1 + 1, 2) diff --git a/example/app/views.py b/example/app/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/example/app/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/example/manage.py b/example/manage.py new file mode 100644 index 0000000..3e4eedc --- /dev/null +++ b/example/manage.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +from django.core.management import execute_manager +import imp +try: + imp.find_module('settings') # Assumed to be in the same directory. +except ImportError: + import sys + sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n" % __file__) + sys.exit(1) + +import settings + +if __name__ == "__main__": + execute_manager(settings) diff --git a/example/settings.py b/example/settings.py new file mode 100644 index 0000000..04cba19 --- /dev/null +++ b/example/settings.py @@ -0,0 +1,149 @@ +# Django settings for example project. + +from os.path import dirname, realpath, join +at_project_root = lambda *args: join(realpath(dirname(__file__)), *args) + + +DEBUG = True +TEMPLATE_DEBUG = DEBUG + +ADMINS = ( + # ('Your Name', 'your_email@example.com'), +) + +MANAGERS = ADMINS + +DATABASES = { + 'default': { + 'ENGINE': 'django.contrib.gis.db.backends.mysql', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'. + 'NAME': 'example', # Or path to database file if using sqlite3. + 'USER': '', # Not used with sqlite3. + 'PASSWORD': '', # Not used with sqlite3. + 'HOST': 'localhost', # Set to empty string for localhost. Not used with sqlite3. + } +} + +# Local time zone for this installation. Choices can be found here: +# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name +# although not all choices may be available on all operating systems. +# On Unix systems, a value of None will cause Django to use the same +# timezone as the operating system. +# If running in a Windows environment this must be set to the same as your +# system time zone. +TIME_ZONE = 'America/Chicago' + +# Language code for this installation. All choices can be found here: +# http://www.i18nguy.com/unicode/language-identifiers.html +LANGUAGE_CODE = 'en-us' + +SITE_ID = 1 + +# If you set this to False, Django will make some optimizations so as not +# to load the internationalization machinery. +USE_I18N = True + +# If you set this to False, Django will not format dates, numbers and +# calendars according to the current locale +USE_L10N = True + +# Absolute filesystem path to the directory that will hold user-uploaded files. +# Example: "/home/media/media.lawrence.com/media/" +MEDIA_ROOT = '' + +# URL that handles the media served from MEDIA_ROOT. Make sure to use a +# trailing slash. +# Examples: "http://media.lawrence.com/media/", "http://example.com/media/" +MEDIA_URL = '' + +# Absolute path to the directory static files should be collected to. +# Don't put anything in this directory yourself; store your static files +# in apps' "static/" subdirectories and in STATICFILES_DIRS. +# Example: "/home/media/media.lawrence.com/static/" +STATIC_ROOT = '' + +# URL prefix for static files. +# Example: "http://media.lawrence.com/static/" +STATIC_URL = '/static/' + +# URL prefix for admin static files -- CSS, JavaScript and images. +# Make sure to use a trailing slash. +# Examples: "http://foo.com/static/admin/", "/static/admin/". +ADMIN_MEDIA_PREFIX = '/static/admin/' + +# Additional locations of static files +STATICFILES_DIRS = ( + # Put strings here, like "/home/html/static" or "C:/www/django/static". + # Always use forward slashes, even on Windows. + # Don't forget to use absolute paths, not relative paths. +) + +# List of finder classes that know how to find static files in +# various locations. +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', +# 'django.contrib.staticfiles.finders.DefaultStorageFinder', +) + +# Make this unique, and don't share it with anybody. +SECRET_KEY = 'jkiqbq$c7^5qvw1w^lib2sm4&lpl-=2vykpzwgg1jr=(&c1tu1' + +# List of callables that know how to import templates from various sources. +TEMPLATE_LOADERS = ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', +# 'django.template.loaders.eggs.Loader', +) + +MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', +) + +ROOT_URLCONF = 'example.urls' + +TEMPLATE_DIRS = ( + # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". + # Always use forward slashes, even on Windows. + # Don't forget to use absolute paths, not relative paths. +) + +INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + 'django.contrib.staticfiles', + # Uncomment the next line to enable the admin: + # 'django.contrib.admin', + # Uncomment the next line to enable admin documentation: + # 'django.contrib.admindocs', + 'app' +) + +# A sample logging configuration. The only tangible logging +# performed by this configuration is to send an email to +# the site admins on every HTTP 500 error. +# See http://docs.djangoproject.com/en/dev/topics/logging for +# more details on how to customize your logging configuration. +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'mail_admins': { + 'level': 'ERROR', + 'class': 'django.utils.log.AdminEmailHandler' + } + }, + 'loggers': { + 'django.request': { + 'handlers': ['mail_admins'], + 'level': 'ERROR', + 'propagate': True, + }, + } +} diff --git a/example/temporal b/example/temporal new file mode 120000 index 0000000..fdb58a1 --- /dev/null +++ b/example/temporal @@ -0,0 +1 @@ +../temporal \ No newline at end of file diff --git a/example/urls.py b/example/urls.py new file mode 100644 index 0000000..b083def --- /dev/null +++ b/example/urls.py @@ -0,0 +1,17 @@ +from django.conf.urls.defaults import patterns, include, url + +# Uncomment the next two lines to enable the admin: +# from django.contrib import admin +# admin.autodiscover() + +urlpatterns = patterns('', + # Examples: + # url(r'^$', 'example.views.home', name='home'), + # url(r'^example/', include('example.foo.urls')), + + # Uncomment the admin/doc line below to enable admin documentation: + # url(r'^admin/doc/', include('django.contrib.admindocs.urls')), + + # Uncomment the next line to enable the admin: + # url(r'^admin/', include(admin.site.urls)), +) diff --git a/temporal/__init__.py b/temporal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/temporal/models/__init__.py b/temporal/models/__init__.py new file mode 100644 index 0000000..c275500 --- /dev/null +++ b/temporal/models/__init__.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- + +from django.contrib.gis.db import models +from django.contrib.gis.db.models.query import GeoQuerySet as QuerySet +from django.contrib.auth.models import User + +from django.db.models import Q +from datetime import date + +from temporal.models.fields import TemporalForeignKey +from temporal.models.trail import TemporalTrail + + +#def temporal_period(obj): +# return '%s..%s' % (obj.date_begin.date(), obj.date_end and obj.date_end.date() or '') + + +class FakeDeleteQuerySet(QuerySet): + ' QuerySet, не удаляющий данные физически ' + + def delete(self): + self.update(**{'deleted': True}) + delete.alters_data = True + + +class ActualManager(models.GeoManager): + ' Менеджер актуальных записей ' + + def get_query_set(self): + return FakeDeleteQuerySet(self.model, using=self._db).filter(deleted=False) + + def get_plain_queryset(self): + return QuerySet(self.model, using=self._db) + + +class ActualModel(models.Model): + ' Модель для актуальных данных ' + + # Поле для пометки актуальных записей. + # Если запись уже не актуальна - она не удаляется физически, а лишь помечается как неактивная + deleted = models.BooleanField(u'Удален', default=False, editable=False) + + # По-умолчанию будут выдаваться только актуальные записи + # Порядок важен! _default_manager - это первый объявленный менеджер + objects = ActualManager() + + def delete(self, *args, **kwargs): + ''' + fake delete + ''' + real_delete = kwargs.get('real_delete', False) + if not real_delete: + self.deleted = True + self.save() + else: + super(ActualModel, self).delete(*args, **kwargs) + + class Meta: + abstract = True + + +class TemporalModel(ActualModel): + ''' + Модель для темпоральных данных. + ''' + + date_begin = models.DateTimeField(); + date_end = models.DateTimeField(null=True, blank=True, editable=False); + + def delete(self, *args, **kwargs): + kwargs['date_delete'] = kwargs.get('date_delete', date.today()) + self.date_begin = kwargs['date_delete'] + super(TemporalModel, self).delete(*args, **kwargs) + + def get_actual(self, actual_date=None): + return self.history.get_actual(actual_date) + + class Meta: + unique_together = ("id", "date_begin") + abstract = True diff --git a/temporal/models/fields.py b/temporal/models/fields.py new file mode 100644 index 0000000..ea69cb0 --- /dev/null +++ b/temporal/models/fields.py @@ -0,0 +1,72 @@ +from datetime import date +from django.db.models.fields.related import (ForeignKey, router, QuerySet, ReverseSingleRelatedObjectDescriptor) + + +class ReverseTemporalSingleRelatedObjectDescriptor(ReverseSingleRelatedObjectDescriptor): + # This class provides the functionality that makes the related-object + # managers available as attributes on a model class, for fields that have + # a single "remote" value, on the class that defines the related field. + # In the example "choice.poll", the poll attribute is a + # ReverseSingleRelatedObjectDescriptor instance. + + def __get__(self, instance, instance_type=None): + if instance is None: + return self + + cache_name = self.field.get_cache_name() + try: + return getattr(instance, cache_name) + except AttributeError: + val = getattr(instance, self.field.attname) + # default = date.today() to use with not temporal models + actual_date = getattr(instance, '_actual_date', date.today()) + if val is None: + # If NULL is an allowed value, return it. + if self.field.null: + return None + raise self.field.rel.to.DoesNotExist + other_field = self.field.rel.get_related_field() + if other_field.rel: + params = {'%s__pk' % self.field.rel.field_name: val} + else: + params = {'%s__exact' % self.field.rel.field_name: val} + + # If the related manager indicates that it should be used for + # related fields, respect that. + rel_mgr = self.field.rel.to._default_manager + db = router.db_for_read(self.field.rel.to, instance=instance) + if getattr(rel_mgr, 'use_for_related_fields', False): + rel_obj = rel_mgr.using(db).get(**params) + else: + rel_obj = QuerySet(self.field.rel.to).using(db).get(**params) + + if actual_date: + rel_obj = rel_obj.history.get_actual(actual_date) + # that choice.poll.TYPE could be actual + rel_obj._actual_date = actual_date + + setattr(instance, cache_name, rel_obj) + return rel_obj + + +class TemporalForeignKey(ForeignKey): + + def contribute_to_class(self, cls, name): + super(TemporalForeignKey, self).contribute_to_class(cls, name) + setattr(cls, self.name, ReverseTemporalSingleRelatedObjectDescriptor(self)) + if isinstance(self.rel.to, basestring): + target = self.rel.to + else: + target = self.rel.to._meta.db_table + cls._meta.duplicate_targets[self.column] = (target, "o2m") + +# TODO: write ForeignTemporalRelatedObjectsDescriptor if needed +# def contribute_to_related_class(self, cls, related): +# # Internal FK's - i.e., those with a related name ending with '+' - +# # don't get a related descriptor. +# if not self.rel.is_hidden(): +# setattr(cls, related.get_accessor_name(), ForeignTemporalRelatedObjectsDescriptor(related)) +# if self.rel.limit_choices_to: +# cls._meta.related_fkey_lookups.append(self.rel.limit_choices_to) +# if self.rel.field_name is None: +# self.rel.field_name = cls._meta.pk.name diff --git a/temporal/models/trail.py b/temporal/models/trail.py new file mode 100644 index 0000000..206b7b6 --- /dev/null +++ b/temporal/models/trail.py @@ -0,0 +1,245 @@ +from datetime import date, timedelta +from django.dispatch import dispatcher +from django.db import models +from django.core.exceptions import ImproperlyConfigured +from django.contrib import admin +import copy +import re + +try: + import settings_temporal +except ImportError: + settings_temporal = None +value_error_re = re.compile("^.+'(.+)'$") + +class TemporalTrail(object): + def __init__(self, show_in_admin=False, save_change_type=True, audit_deletes=True, + track_fields=None): + self.opts = {} + self.opts['show_in_admin'] = show_in_admin + self.opts['save_change_type'] = save_change_type + self.opts['audit_deletes'] = audit_deletes + if track_fields: + self.opts['track_fields'] = track_fields + else: + self.opts['track_fields'] = [] + + def contribute_to_class(self, cls, name): + # This should only get added once the class is otherwise complete + def _contribute(sender, **kwargs): + model = create_temporal_model(sender, **self.opts) + if self.opts['show_in_admin']: + # Enable admin integration + # If ModelAdmin needs options or different base class, find + # some way to make the commented code work + # cls_admin_name = cls.__name__ + 'Admin' + # clsAdmin = type(cls_admin_name, (admin.ModelAdmin,),{}) + # admin.site.register(cls, clsAdmin) + # Otherwise, register class with default ModelAdmin + admin.site.register(model) + descriptor = TemporalTrailDescriptor(model._default_manager, sender._meta.pk.attname) + setattr(sender, name, descriptor) + + def _temporal_track(instance, field_arr, **kwargs): + field_name = field_arr[0] + try: + return getattr(instance, field_name) + except: + if len(field_arr) > 2: + if callable(field_arr[2]): + fn = field_arr[2] + return fn(instance) + else: + return field_arr[2] + + def _audit(sender, instance, created, **kwargs): + # Write model changes to the temporal model. + # instance is the current (non-temporal) model. + kwargs = {} + for field in sender._meta.fields: + #kwargs[field.attname] = getattr(instance, field.attname) + kwargs[field.name] = getattr(instance, field.name) + if self.opts['save_change_type']: + if created: + kwargs['_temporal_change_type'] = 'I' + elif not getattr(instance, 'deleted', False): + kwargs['_temporal_change_type'] = 'U' + else: + kwargs['_temporal_change_type'] = 'D' + for field_arr in model._temporal_track: + kwargs[field_arr[0]] = _temporal_track(instance, field_arr) + _fill_date_end(model._default_manager.create(**kwargs)) + + ## Uncomment this line for pre r8223 Django builds + #dispatcher.connect(_audit, signal=models.signals.post_save, sender=cls, weak=False) + ## Comment this line for pre r8223 Django builds + models.signals.post_save.connect(_audit, sender=cls, weak=False) + + if self.opts['audit_deletes']: + def _audit_delete(sender, instance, **kwargs): + # Write model changes to the temporal model + kwargs = {} + for field in sender._meta.fields: + kwargs[field.name] = getattr(instance, field.name) + if self.opts['save_change_type']: + kwargs['_temporal_change_type'] = 'D' + for field_arr in model._temporal_track: + kwargs[field_arr[0]] = _temporal_track(instance, field_arr) + _fill_date_end(model._default_manager.create(**kwargs)) + ## Uncomment this line for pre r8223 Django builds + #dispatcher.connect(_audit_delete, signal=models.signals.pre_delete, sender=cls, weak=False) + ## Comment this line for pre r8223 Django builds + models.signals.pre_delete.connect(_audit_delete, sender=cls, weak=False) + + ## Uncomment this line for pre r8223 Django builds + #dispatcher.connect(_contribute, signal=models.signals.class_prepared, sender=cls, weak=False) + ## Comment this line for pre r8223 Django builds + models.signals.class_prepared.connect(_contribute, sender=cls, weak=False) + +class TemporalTrailDescriptor(object): + def __init__(self, manager, pk_attribute): + self.manager = manager + self.pk_attribute = pk_attribute + + def __get__(self, instance=None, owner=None): + if instance == None: + return create_temporal_manager_class(self.manager) + else: + return create_temporal_manager_with_pk(self.manager, self.pk_attribute, instance._get_pk_val()) + + def __set__(self, instance, value): + raise AttributeError, "Temporal trail may not be edited in this manner." + +def create_temporal_manager_with_pk(manager, pk_attribute, pk): + """Create an temporal trail manager based on the current object""" + class TemporalTrailWithPkManager(manager.__class__): + def __init__(self, *arg, **kw): + super(TemporalTrailWithPkManager, self).__init__(*arg, **kw) + self.model = manager.model + + def get_query_set(self): + qs = super(TemporalTrailWithPkManager, self).get_query_set().filter(**{pk_attribute: pk}) + if self._db is not None: + qs = qs.using(self._db) + return qs + + def get_actual(self, actual_date=None): + if not actual_date: + actual_date = date.today() + + obj = self.get_query_set().get(models.Q(date_begin__lte=actual_date), + models.Q(date_end__exact=None) | models.Q(date_end__gte=actual_date)) + obj._actual_date = actual_date + return obj + + return TemporalTrailWithPkManager() + +def create_temporal_manager_class(manager): + """Create an temporal trail manager based on the current object""" + class TemporalTrailManager(manager.__class__): + def __init__(self, *arg, **kw): + super(TemporalTrailManager, self).__init__(*arg, **kw) + self.model = manager.model + return TemporalTrailManager() + +def create_temporal_model(cls, **kwargs): + """Create an temporal model for the specific class""" + name = cls.__name__ + 'Temporal' + + class Meta: + db_table = '%s_temporal' % cls._meta.db_table + app_label = cls._meta.app_label + verbose_name_plural = '%s temporal trail' % cls._meta.verbose_name + ordering = ['-_temporal_timestamp'] + if hasattr(cls._meta, 'unique_together'): + unique_together = getattr(cls._meta, 'unique_together') + + # Set up a dictionary to simulate declarations within a class + attrs = { + '__module__': cls.__module__, + 'Meta': Meta, + '_temporal_id': models.AutoField(primary_key=True), + '_temporal_timestamp': models.DateTimeField(auto_now_add=True, db_index=True, editable=False), + '_temporal__str__': cls.__str__.im_func, + '_temporal_period': lambda self: '%s..%s' % (self.date_begin.date(), self.date_end and self.date_end.date() or ''), +# '__str__': lambda self: '%s as of %s' % (self._temporal__str__(), self._temporal_timestamp), + '__str__': lambda self: '%s as of %s' % (self._temporal__str__(), self._temporal_period()), + '_temporal_track': _track_fields(track_fields=kwargs['track_fields'], unprocessed=True), + '_display': lambda self: '\n'.join(('%s:\t\t%s' % (x.name, getattr(self, x.name)) for x in self._meta.fields)), + } + + if 'save_change_type' in kwargs and kwargs['save_change_type']: + attrs['_temporal_change_type'] = models.CharField(max_length=1) + + # Copy the fields from the existing model to the temporal model + for field in cls._meta.fields: + #if field.attname in attrs: + if field.name in attrs: + raise ImproperlyConfigured, "%s cannot use %s as it is needed by TemporalTrail." % (cls.__name__, field.attname) + if isinstance(field, models.AutoField): + # Temporal models have a separate AutoField + attrs[field.name] = models.IntegerField(db_index=True, editable=False) + else: + attrs[field.name] = copy.copy(field) + # If 'unique' is in there, we need to remove it, otherwise the index + # is created and multiple temporal entries for one item fail. + attrs[field.name]._unique = False + # If a model has primary_key = True, a second primary key would be + # created in the temporal model. Set primary_key to false. + attrs[field.name].primary_key = False + + # Rebuild and replace the 'rel' object to avoid foreign key clashes. + # Borrowed from the Basie project + # Basie is MIT and GPL dual licensed. + if isinstance(field, models.ForeignKey): + rel = copy.copy(field.rel) + rel.related_name = '_temporal_' + field.related_query_name() + attrs[field.name].rel = rel + + for track_field in _track_fields(kwargs['track_fields']): + if track_field['name'] in attrs: + raise NameError('Field named "%s" already exists in temporal version of %s' % (track_field['name'], cls.__name__)) + attrs[track_field['name']] = copy.copy(track_field['field']) + + return type(name, (models.Model,), attrs) + +def _build_track_field(track_item): + track = {} + track['name'] = track_item[0] + if isinstance(track_item[1], models.Field): + track['field'] = track_item[1] + elif issubclass(track_item[1], models.Model): + track['field'] = models.ForeignKey(track_item[1]) + else: + raise TypeError('Track fields only support items that are Fields or Models.') + return track + +def _track_fields(track_fields=None, unprocessed=False): + # Add in the fields from the Temporal class "track" attribute. + tracks_found = [] + + if settings_temporal: + global_track_fields = getattr(settings_temporal, 'GLOBAL_TEMPORAL_TRACK_FIELDS', []) + for track_item in global_track_fields: + if unprocessed: + tracks_found.append(track_item) + else: + tracks_found.append(_build_track_field(track_item)) + + if track_fields: + for track_item in track_fields: + if unprocessed: + tracks_found.append(track_item) + else: + tracks_found.append(_build_track_field(track_item)) + return tracks_found + +def _fill_date_end(obj): + try: + prev = obj.get_previous_by_date_begin() + prev.date_end = obj.date_begin - timedelta(1) + prev.save() + except obj.DoesNotExist: + pass + except: + raise diff --git a/temporal/tests.py b/temporal/tests.py new file mode 100644 index 0000000..501deb7 --- /dev/null +++ b/temporal/tests.py @@ -0,0 +1,16 @@ +""" +This file demonstrates writing tests using the unittest module. These will pass +when you run "manage.py test". + +Replace this with more appropriate tests for your application. +""" + +from django.test import TestCase + + +class SimpleTest(TestCase): + def test_basic_addition(self): + """ + Tests that 1 + 1 always equals 2. + """ + self.assertEqual(1 + 1, 2) diff --git a/temporal/views.py b/temporal/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/temporal/views.py @@ -0,0 +1 @@ +# Create your views here.