calendarium.models: 163 total statements, 85.5% covered

Generated: Tue 2013-04-16 13:15 CEST

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

Stats: 118 executed, 20 missed, 25 excluded, 273 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, category=None):
  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. if category:
  52. relevant_events = qs.filter(start__lt=end, category=category)
  53. else:
  54. relevant_events = qs.filter(start__lt=end)
  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. blank=True, null=True,
  103. )
  104. category = models.ForeignKey(
  105. 'EventCategory',
  106. verbose_name=_('Category'),
  107. related_name='events',
  108. )
  109. rule = models.ForeignKey(
  110. 'Rule',
  111. verbose_name=_('Rule'),
  112. blank=True, null=True,
  113. )
  114. end_recurring_period = models.DateTimeField(
  115. verbose_name=_('End of recurring'),
  116. blank=True, null=True,
  117. )
  118. title = models.CharField(
  119. max_length=256,
  120. verbose_name=_('Title'),
  121. )
  122. objects = EventModelManager()
  123. def get_absolute_url(self):
  124. return reverse('calendar_event_detail', kwargs={'pk': self.pk})
  125. def _create_occurrence(self, occ_start, occ_end=None):
  126. """Creates an Occurrence instance."""
  127. # if the length is not altered, it is okay to only pass occ_start
  128. if not occ_end:
  129. occ_end = occ_start + (self.end - self.start)
  130. return Occurrence(
  131. event=self, start=occ_start, end=occ_end,
  132. # TODO not sure why original start and end also are occ_start/_end
  133. original_start=occ_start, original_end=occ_end,
  134. title=self.title, description=self.description,
  135. creation_date=self.creation_date, created_by=self.created_by)
  136. def _get_date_gen(self, rr, start, end):
  137. """Returns a generator to create the start dates for occurrences."""
  138. date = rr.after(start)
  139. while end and date <= end or not(end):
  140. yield date
  141. date = rr.after(date)
  142. def _get_occurrence_gen(self, start, end):
  143. """Computes all occurrences for this event from start to end."""
  144. # get length of the event
  145. length = self.end - self.start
  146. if self.rule:
  147. # if the end of the recurring period is before the end arg passed
  148. # the end of the recurring period should be the new end
  149. if self.end_recurring_period and end and (
  150. self.end_recurring_period < end):
  151. end = self.end_recurring_period
  152. # making start date generator
  153. occ_start_gen = self._get_date_gen(
  154. self.get_rrule_object(),
  155. start - length, end)
  156. # chosing the first item from the generator to initiate
  157. occ_start = occ_start_gen.next()
  158. while not end or (end and occ_start <= end):
  159. occ_end = occ_start + length
  160. yield self._create_occurrence(occ_start, occ_end)
  161. occ_start = occ_start_gen.next()
  162. else:
  163. # check if event is in the period
  164. if (not end or self.start < end) and self.end >= start:
  165. yield self._create_occurrence(self.start, self.end)
  166. def get_occurrences(self, start, end=None):
  167. """Returns all occurrences from start to end."""
  168. # get persistent occurrences
  169. persistent_occurrences = self.occurrences.all()
  170. # setup occ_replacer with p_occs
  171. occ_replacer = OccurrenceReplacer(persistent_occurrences)
  172. # compute own occurrences according to rule that overlap with the
  173. # period
  174. occurrence_gen = self._get_occurrence_gen(start, end)
  175. # get additional occs, that we need to take into concern
  176. additional_occs = occ_replacer.get_additional_occurrences(
  177. start, end)
  178. occ = occurrence_gen.next()
  179. while not end or (occ.start < end or any(additional_occs)):
  180. if occ_replacer.has_occurrence(occ):
  181. p_occ = occ_replacer.get_occurrence(occ)
  182. # if the persistent occ falls into the period, replace it
  183. if (end and p_occ.start < end) and p_occ.end >= start:
  184. estimated_occ = p_occ
  185. else:
  186. occ = occurrence_gen.next()
  187. continue
  188. else:
  189. # if there is no persistent match, use the original occ
  190. estimated_occ = occ
  191. if any(additional_occs) and (
  192. estimated_occ.start == additional_occs[0].start):
  193. final_occ = additional_occs.pop(0)
  194. else:
  195. final_occ = estimated_occ
  196. if not final_occ.cancelled:
  197. yield final_occ
  198. occ = occurrence_gen.next()
  199. def get_rrule_object(self):
  200. """Returns the rrule object for this ``Event``."""
  201. if self.rule:
  202. params = self.rule.get_params()
  203. frequency = 'rrule.{0}'.format(self.rule.frequency)
  204. return rrule.rrule(eval(frequency), dtstart=self.start, **params)
  205. class EventCategory(models.Model):
  206. """The category of an event."""
  207. name = models.CharField(
  208. max_length=256,
  209. verbose_name=_('Name'),
  210. )
  211. color = ColorField(
  212. verbose_name=_('Color'),
  213. )
  214. def __unicode__(self):
  215. return self.name
  216. class EventRelation(models.Model):
  217. """
  218. This class allows to relate additional or external data to an event.
  219. :event: A FK to the ``Event`` this additional data is related to.
  220. :content_type: A FK to ContentType of the generic object.
  221. :object_id: The id of the generic object.
  222. :content_object: The generic foreign key to the generic object.
  223. :relation_type: A string representing the type of the relation. This allows
  224. to relate to the same content_type several times but mean different
  225. things, such as (normal_guests, speakers, keynote_speakers, all being
  226. Guest instances)
  227. """
  228. event = models.ForeignKey(
  229. 'Event',
  230. verbose_name=_("Event"),
  231. )
  232. content_type = models.ForeignKey(
  233. ContentType,
  234. )
  235. object_id = models.IntegerField()
  236. content_object = generic.GenericForeignKey(
  237. 'content_type',
  238. 'object_id',
  239. )
  240. relation_type = models.CharField(
  241. verbose_name=_('Relation type'),
  242. max_length=32,
  243. blank=True, null=True,
  244. )
  245. def __unicode__(self):
  246. return 'type "{0}" for "{1}"'.format(
  247. self.relation_type, self.event.title)
  248. class Occurrence(EventModelMixin):
  249. """
  250. Needed if one occurrence of an event has slightly different settings than
  251. all other.
  252. :created_by: FK to the ``User``, who created this event.
  253. :event: FK to the ``Event`` this ``Occurrence`` belongs to.
  254. :original_start: The original start of the related ``Event``.
  255. :original_end: The original end of the related ``Event``.
  256. :title: The title of the event.
  257. """
  258. created_by = models.ForeignKey(
  259. 'auth.User',
  260. verbose_name=_('Created by'),
  261. related_name='occurrences',
  262. blank=True, null=True,
  263. )
  264. event = models.ForeignKey(
  265. 'Event',
  266. verbose_name=_('Event'),
  267. related_name='occurrences',
  268. )
  269. original_start = models.DateTimeField(
  270. verbose_name=_('Original start'),
  271. )
  272. original_end = models.DateTimeField(
  273. verbose_name=_('Original end'),
  274. )
  275. cancelled = models.BooleanField(
  276. verbose_name=_('Cancelled'),
  277. )
  278. title = models.CharField(
  279. max_length=256,
  280. verbose_name=_('Title'),
  281. blank=True,
  282. )
  283. def category(self):
  284. return self.event.category
  285. def delete_period(self, period):
  286. """Deletes a set of occurrences based on the given decision."""
  287. # check if this is the last or only one
  288. is_last = False
  289. is_only = False
  290. gen = self.event.get_occurrences(
  291. self.start, self.event.end_recurring_period)
  292. occs = [occ for occ in gen]
  293. if len(occs) == 1:
  294. is_only = True
  295. elif len(occs) > 1 and self == occs[-1]:
  296. is_last = True
  297. if period == OCCURRENCE_DECISIONS['all']:
  298. # delete all persistent occurrences along with the parent event
  299. self.event.occurrences.all().delete()
  300. self.event.delete()
  301. elif period == OCCURRENCE_DECISIONS['this one']:
  302. # check if it is the last one. If so, shorten the recurring period,
  303. # otherwise cancel the event
  304. if is_last:
  305. self.event.end_recurring_period = self.start - timedelta(
  306. days=1)
  307. self.event.save()
  308. elif is_only:
  309. self.event.occurrences.all().delete()
  310. self.event.delete()
  311. else:
  312. self.cancelled = True
  313. self.save()
  314. elif period == OCCURRENCE_DECISIONS['following']:
  315. # just shorten the recurring period
  316. self.event.end_recurring_period = self.start - timedelta(days=1)
  317. self.event.occurrences.filter(start__gte=self.start).delete()
  318. if is_only:
  319. self.event.delete()
  320. else:
  321. self.event.save()
  322. def get_absolute_url(self):
  323. return reverse(
  324. 'calendar_occurrence_detail', kwargs={
  325. 'pk': self.event.pk, 'year': self.start.year,
  326. 'month': self.start.month, 'day': self.start.day})
  327. class Rule(models.Model):
  328. """
  329. This defines the rule by which an event will recur.
  330. :name: Name of this rule.
  331. :description: Description of this rule.
  332. :frequency: A string representing the frequency of the recurrence.
  333. :params: JSON string to hold the exact rule parameters as used by
  334. dateutil.rrule to define the pattern of the recurrence.
  335. """
  336. name = models.CharField(
  337. verbose_name=_("name"),
  338. max_length=32,
  339. )
  340. description = models.TextField(
  341. _("description"),
  342. )
  343. frequency = models.CharField(
  344. verbose_name=_("frequency"),
  345. choices=FREQUENCY_CHOICES,
  346. max_length=10,
  347. )
  348. params = models.TextField(
  349. verbose_name=_("params"),
  350. blank=True, null=True,
  351. )
  352. def __unicode__(self):
  353. return self.name
  354. def get_params(self):
  355. if self.params:
  356. return json.loads(self.params)
  357. return {}