import datetime from django.db import models from django.core.exceptions import ObjectDoesNotExist from django.contrib.contenttypes.models import ContentType class GenericIntermediary(models.Model): """Link between two content types. This model enables generic foreign keys with arbitrary models on both sides of the relationship. It is enforced using django's content types, in essence splitting apart the link between two objects types and the specific instances. There are just two attributes. ``.left`` -- a key to the content type of the model which you would normally put in a ``ForeignKey``. ``.right`` -- a key to the content type of the model which would normally have the ``ForeignKey`` attribute itself. Example usage: class Foo(models.Model): ... relationship = models.Foreignkey(GenericIntermediary) left_id = models.PositiveIntegerField() right_id = models.PositiveIntegerField() ... left_object = relationship.left.get_object_for_this_type(left_id) right_object = relationship.right.get_object_for_this_type(right_id) If you're doing this a lot, look at ``IntermediaryKey``. """ # this model sets up a relationship between arbitrary types A and B # (called left and right to aid visualisation) left = models.ForeignKey(ContentType, related_name = "against") right = models.ForeignKey(ContentType, related_name = "for") class Meta: unique_together=(('left','right'),) def __unicode__(self): return u'%s against %s' % (self.right, self.left) class IntermediaryKey(object): """Key-like object for accessing objects through GenericIntermediary. This class provides a django model attribute for an object that is defined by a GenericIntermediary relationship and ID pairing. It is constructed with two positional arguments. 1. The name and side of the field which is a foreign key on GenericIntermediaryKey, joined with double-underscores (ie normal django passthrough syntax). The side is always either ``left`` or ``right``. 2. The name of the field which holds the ID of the instance, for the content type referred to in the first argument. Example usage: class Foo(models.Model): ... relationship = models.ForeignKey(GenericIntermediary) left_id = models.PositiveIntegerField() right_id = models.PositiveIntegerField() # define left_object = IntermediaryKey('relationship__left','left_id') right_object = IntermediaryKey('relationship__right','right_id') f=Foo.objects.get(pk=1) f.left_object # returns object on left of this relationship f.right_object # returns object on right This attribute is most useful when implementing a mixin class. """ def __init__(self, gi_key, id_key): self.gi_key, self.side = gi_key.split('__') self.id_key = id_key def contribute_to_class(self, cls, name): self.name = name self.model = cls self.cache_attr = "_%s_cache" % name # django 1.0. contenttypes.generic does this (it also doesn't do it in # pre-1.0; i'm just being forward compatible...) try: cls._meta.add_virtual_field(self) except AttributeError: pass setattr(cls, name, self) def __get__(self, instance, instance_type = None): if instance is None: raise AttributeError, u'can only call %s via instance' % self.name if hasattr(instance, self.cache_attr): return getattr(instance, self.cache_attr) else: rel_obj = None gi = getattr(instance, self.gi_key) ct = getattr(gi, self.side) try: rel_obj = ct.get_object_for_this_type(id = getattr(instance, self.id_key)) except ObjectDoesNotExist: pass setattr(instance, self.cache_attr, rel_obj) return rel_obj class ScheduleMixin(object): """Mixin class which provides scheduling for django models. Inheriting from this class and declaring a ``schedules`` attribute gives the model time-based foreign keys. Usage: # models.py class Page(ScheduleMixin, models.Model): # this is the foreign key which varies according to time. schedules = (model,) # example # schedules = (Style,) # views.py def detail(request,...): page = Page.objects.all()[0] # the Slot for Style matching right now, if there is one style = page.current_for('Style') if style is None: style = page.default_style # do stuff with style ... # properties page.schedule # dictionary of schedules keyed by model, # each entry is an array of slots ordered by time page.current # dictionary of all objects (or None) currently # scheduled, keyed by model name. page.next # returns next scheduled objects (ie, where start time is # later than right now) in same format as current page.last # returns last scheduled objects (ie, where end time is # earlier than right now) in same format as current # per-type query methods page.schedule_for('model') # the array of slots for a model instance page.current_for('model') # the model instance currently scheduled, # or None page.next_for('model') # the model instance scheduled next, or # None page.last_for('model') # the model instance which most recently # finished, or None # scheduling an object page.add_to_schedule(model_object, start_datetime, end_datetime, notes) """ def __init__(self, *args, **kwargs): """ Stores a dictionary of intermediaries, creating them if necessary, linking our (parent) class and those declared in self.schedules. We are on the left of this relationship, things scheduled against us are on the right. """ super(ScheduleMixin, self).__init__(*args,**kwargs) left = ContentType.objects.get_for_model(self) self._meta.gi = {} for m in self.schedules: right = ContentType.objects.get_for_model(m) gi, created = GenericIntermediary.objects.get_or_create( left = left, right = right) self._meta.gi[m.__name__] = gi @property def schedule(self): """ Returns a full schedule of all types against a particular instance. """ d = {} for model in self._meta.gi.keys(): d[model]=self.schedule_for_model(model) return d @property def current(self): """ Returns the current instance of each thing scheduled against us. """ d = {} for model in self._meta.gi.keys(): d[model] = self.current_for_model(model) return d def add_to_schedule(self, instance, start_time, end_time, notes=""): """Schedules a new model instance against this instance. When passed an instance of a schedulable model, with start and end times and optional notes, this method adds it to the schedule. A SlotError will be raised if there is trouble with the times passed. Example: christmas_style = Style.objects.get(...) # dec_1st and jan_1st are datetime objects page.add_to_schedule(christmas_style, dec_1st, jan_1st, "christmas style for the homepage") """ name = instance.__class__.__name__ if name in self._meta.gi.keys(): relationship = self._meta.gi[name] # XXX tried to use Slot.objects.create() but there seems to be # a bug in it new_slot = Slot(relationship = relationship, start_time = start_time, end_time = end_time, notes = notes, slotted_object_id = instance.pk, against_object_id = self.pk) new_slot.save() else: raise ValueError, \ "Cannot schedule %s against %s" % (model, self.__class__.__name__) def schedule_for_model(self, model): if model in self._meta.gi.keys(): return Slot.objects.filter(relationship = self._meta.gi[model], against_object_id = self.id) else: raise ValueError, \ "%s is not scheduled against %s" % (model, self.__class__.__name__) def next(self): d = {} for model in self._meta.gi.keys(): d[model]=self.next_for_model(model) return d def next_for_model(self, model): """ Returns the next instance slotted against this instance.""" now = datetime.datetime.now() if model in self._meta.gi.keys(): try: slot = Slot.objects.filter(relationship = self._meta.gi[model], against_object_id = self.id, start_time__gte = now)[0] # we return the actual object, not the slot return { 'starts': slot.start_time, 'object': slot.slotted } except IndexError: return None else: raise ValueError, \ "%s is not scheduled against %s" % (model, self.__class__.__name__) def last(self): d = {} for model in self._meta.gi.keys(): d[model]=self.last_for_model(model) return d def last_for_model(self, model): now = datetime.datetime.now() if model in self._meta.gi.keys(): try: slot = Slot.objects.filter(relationship=self._meta.gi[model], against_object_id=self.id, end_time__lte=now).order_by('-end_time')[0] # we return the actual object, not the slot return { 'ended': slot.end_time, 'object': slot.slotted } except IndexError: return None else: raise ValueError, \ "%s is not scheduled against %s" % (model, self.__class__.__name__) def current_for_model(self,model,time=None): """Return the instance of the given model scheduled for right now. This method takes a class name as its argument, plus optional time (which defaults to the current time). It returns the scheduled object of the given type, or None. """ if time is None: time = datetime.datetime.now() if model in self._meta.gi.keys(): try: slot = Slot.objects.get(relationship=self._meta.gi[model], against_object_id=self.id, start_time__lte=time, end_time__gte=time) # we return the actual object, not the slot return slot.slotted except Slot.DoesNotExist: return None else: raise ValueError, \ "%s is not scheduled against %s" % (model, self.__class__.__name__) class SlotError(Exception): def __init__(self, slot, type = 'clash', clashes = [], message = ''): self.slot = slot self.type = type self.clashes = clashes self.message = message def __str__(self): if self.type == 'clash': if len(self.clashes) == 1: clash = self.clashes[0] return 'error with slot [%s => %s] clashes with [%s => %s]: %s' \ % (self.slot.start_time, self.slot.end_time, clash.start_time, clash.end_time, self.message) else: return 'error with slot [%s => %s] clashes with multiple slots: %s' \ % (self.slot.start_time, self.slot.end_time, self.clashes) elif self.type == 'timing': if self.slot.start_time == self.slot.end_time: return 'start and end time are the same: %s == %s' \ % (self.slot.start_time, self.slot.end_time) elif self.slot.end_time < self.slot.start_time: return 'start time %s later than end time %s!' \ % (self.slot.start_time, self.slot.end_time) class Slot(models.Model): """Model which ties a B to an A at a certain time. start_time - start time end_time - end time notes - optional notes about this entry """ # this is a handle on the model instance linking the two content types, # the A and the B relationship = models.ForeignKey(GenericIntermediary,editable=False) # these are the concrete A and B instances. # a schedule is the set of slotted_objects for a given # relationship+left_object_id against_object_id = models.PositiveIntegerField(editable=False) slotted_object_id = models.PositiveIntegerField() # The start and end times can't be blank start_time = models.DateTimeField() end_time = models.DateTimeField() notes = models.TextField(blank = True, null = True, help_text = "Notes for why this has been scheduled ") against = IntermediaryKey('relationship__left','against_object_id') slotted = IntermediaryKey('relationship__right','slotted_object_id') @property def slotted_o(self): so=self.relationship.right.get_object_for_this_type( id=self.slotted_object_id) return so @property def against_o(self): a=self.relationship.left.get_object_for_this_type( id=self.against_object_id) return a class Meta: ordering = ['start_time'] def __unicode__(self): return u'[%s:%s] against [%s:%s] %s -> %s' % \ (self.relationship.right, self.slotted.id, self.relationship.left, self.against.id, self.start_time, self.end_time) def save(self, force_insert=False, force_update=False): """Ensure a schedule has no duplicate entries for a time. Our start and end time have precedence over everything, and we use them to alter the start and end times for the objects currently scheduled around them. This happens after we save; and we only save our change if it doesn't clash with another slot, ie is either entirely within another or entirely surrounding it. """ # The validation in here is record-level, rather than field-level. # I'm not sure we can do it with normal validation methods. # # First we check for nonsensical data: start == end, or # start > end. This method raises SlotError if required, as # does check_for_clash below. TODO: get a 500 which catches # this stuff. TODO #2: work out how to do entire dataset # validation. Right now if you change multiple schedules it # doesn't work because each item gets .save()d in turn. Lookahead # validation anyone? *cough* self.check_for_nonsense() # Second, get the entire timeline as it stands, excluding ourself # if this is not a new slot (determined by if we have .id already) timeline = Slot.objects.filter(relationship=self.relationship, against_object_id=self.against_object_id) if self.id: timeline = timeline.exclude(id = self.id) # Now check our new position against this timeline. self.check_for_clashes(timeline) # If it doesn't clash, we're going to save ourselves(!) and then # alter any overlapping existing slots. # # This sets self.id if it wasn't already there. super(Slot,self).save() # Now we need to get a new copy (cache-bust) of self and the # timeline, because we're potentially going to act on them. new_self = Slot.objects.get(pk = self.id) # TODO: figure out why we seem to need self instead of new_self # below # This returns a QuerySet ... timeline = Slot.objects.filter(relationship=self.relationship, against_object_id=self.against_object_id) # ... which we turn into a ValueQuerySet ... values = timeline.values() # ... which by casting to a list makes the elements # dictionaries; .__dict__ is our own dictionary so we can use .index index = list(values).index(new_self.__dict__) # 1 second is the delta we use for separating neighbouring slots one_second = datetime.timedelta(seconds = 1) # We might be (and are likely to be) first or last in the list, # so only check previous and next if they exist. # we use new_self in our comparisons because it will have rounded off # the microseconds if index > 0: prev = timeline[index - 1] if prev.end_time >= new_self.start_time: prev.end_time = new_self.start_time - one_second prev.save() if index < len(timeline) - 1: next = timeline[index + 1] if next.start_time <= new_self.end_time: next.start_time = new_self.end_time + one_second next.save() def check_for_nonsense(self): """Check internal integrity of dates""" if self.start_time >= self.end_time: raise SlotError(self, type='timing', clashes = []) def check_for_clashes(self, timeline): """ Raise an exception if the slot is wholly within another, or wholly surrounds one more more existing slots. """ try: candidate = timeline.get(start_time__lte = self.start_time, end_time__gte = self.end_time) except: pass else: raise SlotError(self, type = 'clash', clashes = [candidate], message = 'slot falls entirely within another') candidates = timeline.filter(start_time__gte = self.start_time, end_time__lte = self.end_time) if len(candidates) > 0: raise SlotError(self, type = 'clash', clashes = candidates, message = 'slot overwrites one or more existing slots')