calendarium.models: 160 total statements, 78.1% covered

Generated: Sun 2013-03-24 21:11 CET

Source file: /home/tobi/Projects/calendarium/src/calendarium/models.py

Stats: 107 executed, 30 missed, 23 excluded, 269 ignored

  1. """
  2. Models for the ``calendarium`` app.
  3. The code of these models is highly influenced by or taken from the models of
  4. django-schedule:
  5. https://github.com/thauber/django-schedule/tree/master/schedule/models
  6. """
  7. import json
  8. from dateutil import rrule
  9. from django.contrib.contenttypes import generic
  10. from django.contrib.contenttypes.models import ContentType
  11. from django.core.urlresolvers import reverse
  12. from django.core.validators import RegexValidator
  13. from django.db import models
  14. from django.utils.timezone import timedelta
  15. from django.utils.translation import ugettext_lazy as _
  16. from calendarium.constants import FREQUENCY_CHOICES, OCCURRENCE_DECISIONS
  17. from calendarium.utils import OccurrenceReplacer
  18. from calendarium.widgets import ColorPickerWidget
  19. from south.modelsinspector import add_introspection_rules
  20. class ColorField(models.CharField):
  21. """Custom color field to display a color picker."""
  22. def __init__(self, *args, **kwargs):
  23. kwargs['max_length'] = 6
  24. super(ColorField, self).__init__(*args, **kwargs)
  25. self.validators.append(RegexValidator(
  26. regex='^([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$',
  27. message='Only RGB color model inputs allowed, like 00000',
  28. code='nomatch'))
  29. def formfield(self, **kwargs):
  30. kwargs['widget'] = ColorPickerWidget
  31. return super(ColorField, self).formfield(**kwargs)
  32. add_introspection_rules([], ["^calendarium\.models\.ColorField"])
  33. class EventModelManager(models.Manager):
  34. """Custom manager for the ``Event`` model class."""
  35. def get_occurrences(self, start, end):
  36. """Returns a list of events and occurrences for the given period."""
  37. # we always want the time of start and end to be at 00:00
  38. start = start.replace(minute=0, hour=0)
  39. end = end.replace(minute=0, hour=0)
  40. # if we recieve the date of one day as start and end, we need to set
  41. # end one day forward
  42. if start == end:
  43. end = start + timedelta(days=1)
  44. # retrieving relevant events
  45. # TODO currently for events with a rule, I can't properly find out when
  46. # the last occurrence of the event ends, or find a way to filter that,
  47. # so I'm still fetching **all** events before this period, that have a
  48. # end_recurring_period.
  49. # For events without a rule, I fetch only the relevant ones.
  50. qs = self.get_query_set()
  51. qs = qs.filter(start__lt=end)
  52. relevant_events = qs.filter(
  53. models.Q(end_recurring_period__isnull=True, end__gt=start) |
  54. models.Q(end_recurring_period__isnull=False))
  55. # get all occurrences for those events that don't already have a
  56. # persistent match and that lie in this period.
  57. all_occurrences = []
  58. for event in relevant_events:
  59. all_occurrences.extend(event.get_occurrences(start, end))
  60. # sort and return
  61. return sorted(all_occurrences, key=lambda x: x.start)
  62. class EventModelMixin(models.Model):
  63. """
  64. Abstract base class to prevent code duplication.
  65. :start: The start date of the event.
  66. :end: The end date of the event.
  67. :creation_date: When this event was created.
  68. :description: The description of the event.
  69. """
  70. start = models.DateTimeField(
  71. verbose_name=_('Start date'),
  72. )
  73. end = models.DateTimeField(
  74. verbose_name=_('End date'),
  75. )
  76. creation_date = models.DateTimeField(
  77. verbose_name=_('Creation date'),
  78. auto_now_add=True,
  79. )
  80. description = models.TextField(
  81. max_length=2048,
  82. verbose_name=_('Description'),
  83. blank=True,
  84. )
  85. def __unicode__(self):
  86. return self.title
  87. class Meta:
  88. abstract = True
  89. class Event(EventModelMixin):
  90. """
  91. Hold the information about an event in the calendar.
  92. :created_by: FK to the ``User``, who created this event.
  93. :category: FK to the ``EventCategory`` this event belongs to.
  94. :rule: FK to the definition of the recurrence of an event.
  95. :end_recurring_period: The possible end of the recurring definition.
  96. :title: The title of the event.
  97. """
  98. created_by = models.ForeignKey(
  99. 'auth.User',
  100. verbose_name=_('Created by'),
  101. related_name='events',
  102. )
  103. category = models.ForeignKey(
  104. 'EventCategory',
  105. verbose_name=_('Category'),
  106. related_name='events',
  107. )
  108. rule = models.ForeignKey(
  109. 'Rule',
  110. verbose_name=_('Rule'),
  111. blank=True, null=True,
  112. )
  113. end_recurring_period = models.DateTimeField(
  114. verbose_name=_('End of recurring'),
  115. blank=True, null=True,
  116. )
  117. title = models.CharField(
  118. max_length=256,
  119. verbose_name=_('Title'),
  120. )
  121. objects = EventModelManager()
  122. def get_absolute_url(self):
  123. return reverse('calendar_event_detail', kwargs={'pk': self.pk})
  124. def _create_occurrence(self, occ_start, occ_end=None):
  125. """Creates an Occurrence instance."""
  126. # if the length is not altered, it is okay to only pass occ_start
  127. if not occ_end:
  128. occ_end = occ_start + (self.end - self.start)
  129. return Occurrence(
  130. event=self, start=occ_start, end=occ_end,
  131. # TODO not sure why original start and end also are occ_start/_end
  132. original_start=occ_start, original_end=occ_end,
  133. title=self.title, description=self.description,
  134. creation_date=self.creation_date, created_by=self.created_by)
  135. def _get_date_gen(self, rr, start, end):
  136. """Returns a generator to create the start dates for occurrences."""
  137. date = rr.after(start)
  138. while end and date <= end or not(end):
  139. yield date
  140. date = rr.after(date)
  141. def _get_occurrence_gen(self, start, end):
  142. """Computes all occurrences for this event from start to end."""
  143. # get length of the event
  144. length = self.end - self.start
  145. if self.rule:
  146. # if the end of the recurring period is before the end arg passed
  147. # the end of the recurring period should be the new end
  148. if self.end_recurring_period and end and (
  149. self.end_recurring_period < end):
  150. end = self.end_recurring_period
  151. # making start date generator
  152. occ_start_gen = self._get_date_gen(
  153. self.get_rrule_object(),
  154. start - length, end)
  155. # chosing the first item from the generator to initiate
  156. occ_start = occ_start_gen.next()
  157. while not end or (end and occ_start <= end):
  158. occ_end = occ_start + length
  159. yield self._create_occurrence(occ_start, occ_end)
  160. occ_start = occ_start_gen.next()
  161. else:
  162. # check if event is in the period
  163. if (not end or self.start < end) and self.end >= start:
  164. yield self._create_occurrence(self.start, self.end)
  165. def get_occurrences(self, start, end=None):
  166. """Returns all occurrences from start to end."""
  167. # get persistent occurrences
  168. persistent_occurrences = self.occurrences.all()
  169. # setup occ_replacer with p_occs
  170. occ_replacer = OccurrenceReplacer(persistent_occurrences)
  171. # compute own occurrences according to rule that overlap with the
  172. # period
  173. occurrence_gen = self._get_occurrence_gen(start, end)
  174. # get additional occs, that we need to take into concern
  175. additional_occs = occ_replacer.get_additional_occurrences(
  176. start, end)
  177. occ = occurrence_gen.next()
  178. while not end or (occ.start < end or any(additional_occs)):
  179. if occ_replacer.has_occurrence(occ):
  180. p_occ = occ_replacer.get_occurrence(occ)
  181. # if the persistent occ falls into the period, replace it
  182. if (end and p_occ.start < end) and p_occ.end >= start:
  183. estimated_occ = p_occ
  184. else:
  185. occ = occurrence_gen.next()
  186. continue
  187. else:
  188. # if there is no persistent match, use the original occ
  189. estimated_occ = occ
  190. if any(additional_occs) and (
  191. estimated_occ.start == additional_occs[0].start):
  192. final_occ = additional_occs.pop(0)
  193. else:
  194. final_occ = estimated_occ
  195. if not final_occ.cancelled:
  196. yield final_occ
  197. occ = occurrence_gen.next()
  198. def get_rrule_object(self):
  199. """Returns the rrule object for this ``Event``."""
  200. if self.rule:
  201. params = self.rule.get_params()
  202. frequency = 'rrule.{0}'.format(self.rule.frequency)
  203. return rrule.rrule(eval(frequency), dtstart=self.start, **params)
  204. class EventCategory(models.Model):
  205. """The category of an event."""
  206. name = models.CharField(
  207. max_length=256,
  208. verbose_name=_('Name'),
  209. )
  210. color = ColorField(
  211. verbose_name=_('Color'),
  212. )
  213. def __unicode__(self):
  214. return self.name
  215. class EventRelation(models.Model):
  216. """
  217. This class allows to relate additional or external data to an event.
  218. :event: A FK to the ``Event`` this additional data is related to.
  219. :content_type: A FK to ContentType of the generic object.
  220. :object_id: The id of the generic object.
  221. :content_object: The generic foreign key to the generic object.
  222. :relation_type: A string representing the type of the relation. This allows
  223. to relate to the same content_type several times but mean different
  224. things, such as (normal_guests, speakers, keynote_speakers, all being
  225. Guest instances)
  226. """
  227. event = models.ForeignKey(
  228. 'Event',
  229. verbose_name=_("Event"),
  230. )
  231. content_type = models.ForeignKey(
  232. ContentType,
  233. )
  234. object_id = models.IntegerField()
  235. content_object = generic.GenericForeignKey(
  236. 'content_type',
  237. 'object_id',
  238. )
  239. relation_type = models.CharField(
  240. verbose_name=_('Relation type'),
  241. max_length=32,
  242. blank=True, null=True,
  243. )
  244. def __unicode__(self):
  245. return 'type "{0}" for "{1}"'.format(
  246. self.relation_type, self.event.title)
  247. class Occurrence(EventModelMixin):
  248. """
  249. Needed if one occurrence of an event has slightly different settings than
  250. all other.
  251. :created_by: FK to the ``User``, who created this event.
  252. :event: FK to the ``Event`` this ``Occurrence`` belongs to.
  253. :original_start: The original start of the related ``Event``.
  254. :original_end: The original end of the related ``Event``.
  255. :title: The title of the event.
  256. """
  257. created_by = models.ForeignKey(
  258. 'auth.User',
  259. verbose_name=_('Created by'),
  260. related_name='occurrences',
  261. )
  262. event = models.ForeignKey(
  263. 'Event',
  264. verbose_name=_('Event'),
  265. related_name='occurrences',
  266. )
  267. original_start = models.DateTimeField(
  268. verbose_name=_('Original start'),
  269. )
  270. original_end = models.DateTimeField(
  271. verbose_name=_('Original end'),
  272. )
  273. cancelled = models.BooleanField(
  274. verbose_name=_('Cancelled'),
  275. )
  276. title = models.CharField(
  277. max_length=256,
  278. verbose_name=_('Title'),
  279. blank=True,
  280. )
  281. def category(self):
  282. return self.event.category
  283. def delete_period(self, period):
  284. """Deletes a set of occurrences based on the given decision."""
  285. # check if this is the last or only one
  286. is_last = False
  287. is_only = False
  288. gen = self.event.get_occurrences(
  289. self.start, self.event.end_recurring_period)
  290. occs = [occ for occ in gen]
  291. if len(occs) == 1:
  292. is_only = True
  293. elif len(occs) > 1 and self == occs[-1]:
  294. is_last = True
  295. if period == OCCURRENCE_DECISIONS['all']:
  296. # delete all persistent occurrences along with the parent event
  297. self.event.occurrences.all().delete()
  298. self.event.delete()
  299. elif period == OCCURRENCE_DECISIONS['this one']:
  300. # check if it is the last one. If so, shorten the recurring period,
  301. # otherwise cancel the event
  302. if is_last:
  303. self.event.end_recurring_period = self.start - timedelta(
  304. days=1)
  305. self.event.save()
  306. elif is_only:
  307. self.event.occurrences.all().delete()
  308. self.event.delete()
  309. else:
  310. self.cancelled = True
  311. self.save()
  312. elif period == OCCURRENCE_DECISIONS['following']:
  313. # just shorten the recurring period
  314. self.event.end_recurring_period = self.start - timedelta(days=1)
  315. self.event.occurrences.filter(start__gte=self.start).delete()
  316. if is_only:
  317. self.event.delete()
  318. else:
  319. self.event.save()
  320. class Rule(models.Model):
  321. """
  322. This defines the rule by which an event will recur.
  323. :name: Name of this rule.
  324. :description: Description of this rule.
  325. :frequency: A string representing the frequency of the recurrence.
  326. :params: JSON string to hold the exact rule parameters as used by
  327. dateutil.rrule to define the pattern of the recurrence.
  328. """
  329. name = models.CharField(
  330. verbose_name=_("name"),
  331. max_length=32,
  332. )
  333. description = models.TextField(
  334. _("description"),
  335. )
  336. frequency = models.CharField(
  337. verbose_name=_("frequency"),
  338. choices=FREQUENCY_CHOICES,
  339. max_length=10,
  340. )
  341. params = models.TextField(
  342. verbose_name=_("params"),
  343. blank=True, null=True,
  344. )
  345. def __unicode__(self):
  346. return self.name
  347. def get_params(self):
  348. if self.params:
  349. return json.loads(self.params)
  350. return {}