]> git.0d.be Git - django-panik-emissions.git/blob - emissions/models.py
prefill new episode with duration from schedule (instead of default)
[django-panik-emissions.git] / emissions / models.py
1 import datetime
2 import os
3
4 from django.conf import settings
5 from django.core.exceptions import ObjectDoesNotExist
6 from django.forms import fields
7 from django.core.urlresolvers import reverse
8
9 from django.db import models
10 from django.utils.encoding import python_2_unicode_compatible
11 from django.utils.translation import ugettext
12 from django.utils.translation import ugettext_lazy as _
13
14 from django.db.models.signals import pre_save, post_delete
15 from django.dispatch.dispatcher import receiver
16
17 from ckeditor.fields import RichTextField
18 from taggit.managers import TaggableManager
19
20 from .utils import maybe_resize
21
22
23 LICENSES = (
24     ('', _('Unspecified')),
25     ('cc by', _('Creative Commons Attribution')),
26     ('cc by-sa', _('Creative Commons Attribution ShareAlike')),
27     ('cc by-nc', _('Creative Commons Attribution NonCommercial')),
28     ('cc by-nd', _('Creative Commons Attribution NoDerivs')),
29     ('cc by-nc-sa', _('Creative Commons Attribution NonCommercial ShareAlike')),
30     ('cc by-nc-nd', _('Creative Commons Attribution NonCommercial NoDerivs')),
31     ('cc0 / pd', _('Creative Commons Zero / Public Domain')),
32     ('artlibre', _('Art Libre')),
33 )
34
35
36 class WeekdayMixin(object):
37     DAY_HOUR_START = 5
38
39     def get_weekday(self):
40         weekday = self.datetime.weekday() + 7
41         if self.datetime.time() < datetime.time(self.DAY_HOUR_START, 0):
42             weekday -= 1
43         weekday %= 7
44         return weekday
45
46     def is_on_weekday(self, day): # day is [1..7]
47         week_day = self.datetime.weekday()
48         if self.datetime.hour < self.DAY_HOUR_START:
49             week_day -= 1
50         week_day = (week_day % 7) + 1
51         return week_day == day
52
53
54 @python_2_unicode_compatible
55 class Category(models.Model):
56
57     class Meta:
58         verbose_name = _('Category')
59         verbose_name_plural = _('Categories')
60         ordering = ['title']
61
62     title = models.CharField(_('Title'), max_length=50)
63     slug = models.SlugField(null=True)
64     itunes_category = models.CharField(_('iTunes Category Name'), max_length=100, null=True)
65
66     def sorted_emission(self):
67         return self.emission_set.order_by('title')
68
69     def __str__(self):
70         return self.title
71
72
73 @python_2_unicode_compatible
74 class Colour(models.Model):
75
76     class Meta:
77         verbose_name = _('Colour')
78         verbose_name_plural = _('Colours')
79         ordering = ['title']
80
81     title = models.CharField(_('Title'), max_length=50)
82     slug = models.SlugField(null=True)
83
84     def sorted_emission(self):
85         return self.emission_set.order_by('title')
86
87     def __str__(self):
88         return self.title
89
90
91 @python_2_unicode_compatible
92 class Format(models.Model):
93
94     class Meta:
95         verbose_name = _('Format')
96         verbose_name_plural = _('Formats')
97         ordering = ['title']
98
99     title = models.CharField(_('Title'), max_length=50)
100     slug = models.SlugField(null=True)
101
102     def __str__(self):
103         return self.title
104
105
106 def get_emission_image_path(instance, filename):
107     return os.path.join('images', instance.slug,
108             os.path.basename(filename))
109
110
111 @python_2_unicode_compatible
112 class Emission(models.Model):
113
114     class Meta:
115         verbose_name = _('Emission')
116         verbose_name_plural = _('Emissions')
117         ordering = ['title']
118
119     title = models.CharField(_('Title'), max_length=200)
120     slug = models.SlugField(max_length=200)
121     subtitle = models.CharField(_('Subtitle'), max_length=80, null=True, blank=True)
122     text = RichTextField(_('Description'), null=True)
123     archived = models.BooleanField(_('Archived'), default=False)
124     categories = models.ManyToManyField(Category, verbose_name=_('Categories'), blank=True)
125     colours = models.ManyToManyField(Colour, verbose_name=_('Colours'), blank=True)
126
127     # XXX: languages (models.ManyToManyField(Language))
128
129     duration = models.IntegerField(_('Duration'), default=60,
130             help_text=_('In minutes'))
131
132     default_license = models.CharField(_('Default license for podcasts'),
133             max_length=20, blank=True, default='', choices=LICENSES)
134     email = models.EmailField(_('Email'), max_length=254, null=True, blank=True)
135     website = models.URLField(_('Website'), null=True, blank=True)
136
137     image = models.ImageField(_('Image'),
138             upload_to=get_emission_image_path, max_length=250, null=True, blank=True)
139
140     chat_open = models.DateTimeField(null=True, blank=True)
141
142     # denormalized from Focus
143     got_focus = models.DateTimeField(default=None, null=True, blank=True)
144     has_focus = models.BooleanField(default=False)
145
146     creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
147     last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
148
149     def get_absolute_url(self):
150         return reverse('emission-view', kwargs={'slug': str(self.slug)})
151
152     def __str__(self):
153         return self.title
154
155     def save(self, *args, **kwargs):
156         super(Emission, self).save(*args, **kwargs)
157         if self.id is not None and self.image:
158             maybe_resize(self.image.path)
159
160     def get_absolute_url(self):
161         return reverse('emission-view',
162                         kwargs={'slug':  str(self.slug)})
163
164     def get_schedules(self):
165         return Schedule.objects.filter(emission=self).order_by('datetime')
166
167     def get_sorted_episodes(self):
168         return self.episode_set.select_related().extra(select={
169                 'first_diffusion': 'emissions_diffusion.datetime',
170                 },
171                 select_params=(False, True),
172                 where=['''datetime = (SELECT MIN(datetime)
173                                         FROM emissions_diffusion
174                                        WHERE episode_id = emissions_episode.id)'''],
175                 tables=['emissions_diffusion'],
176             ).order_by('-first_diffusion')
177
178     def get_sorted_newsitems(self):
179         return self.newsitem_set.select_related().order_by('-date')
180
181     def get_next_planned_date_and_schedule(self, since=None):
182         schedules = self.schedule_set.filter(rerun=False)
183         if not schedules:
184             return None
185         if since is None:
186             since = datetime.datetime.today()
187         possible_dates = []
188         for schedule in schedules:
189             possible_dates.append((schedule.get_next_planned_date(since), schedule))
190         possible_dates.sort()
191         return possible_dates[0]
192
193     def get_next_planned_date(self, since=None):
194         result = self.get_next_planned_date_and_schedule(since=since)
195         return result[0] if result else None
196
197     def get_next_planned_duration(self, since=None):
198         result = self.get_next_planned_date_and_schedule(since=since)
199         if not result:
200             return None
201         return result[1].duration or self.duration
202
203 @python_2_unicode_compatible
204 class Schedule(models.Model, WeekdayMixin):
205
206     class Meta:
207         verbose_name = _('Schedule')
208         verbose_name_plural = _('Schedules')
209         ordering = ['datetime']
210
211     WEEK_CHOICES = (
212         (0b1111, _('Every week')),
213         (0b0001, _('First week')),
214         (0b0010, _('Second week')),
215         (0b0100, _('Third week')),
216         (0b1000, _('Fourth week')),
217         (0b0101, _('First and third week')),
218         (0b1010, _('Second and fourth week'))
219     )
220     emission = models.ForeignKey('Emission', verbose_name=u'Emission')
221     datetime = models.DateTimeField()
222     weeks = models.IntegerField(_('Weeks'), default=15, choices=WEEK_CHOICES)
223     rerun = models.BooleanField(_('Rerun'), default=False)
224     duration = models.IntegerField(_('Duration'), null=True, blank=True,
225             help_text=_('In minutes'))
226
227     @property
228     def weeks_string(self):
229         if self.weeks == 0b0001:
230             return ugettext('1st of the month')
231         elif self.weeks == 0b0010:
232             return ugettext('2nd of the month')
233         elif self.weeks == 0b0100:
234             return ugettext('3rd of the month')
235         elif self.weeks == 0b1000:
236             return ugettext('4th of the month')
237         elif self.weeks == 0b0101:
238             return ugettext('1st and 3rd')
239         elif self.weeks == 0b1010:
240             return ugettext('2nd and 4th')
241         return None
242
243     def week_sort_key(self):
244         order = [
245             0b1111, # Every week
246             0b0001, # First week
247             0b0101, # First and third week
248             0b0010, # Second week
249             0b1010, # Second and fourth week
250             0b0100, # Third week
251             0b1000, # Fourth week
252         ]
253         if self.weeks in order:
254             return order.index(self.weeks)
255         return -1
256
257     def get_duration(self):
258         if self.duration:
259             return self.duration
260         else:
261             return self.emission.duration
262
263     def end_datetime(self):
264         return self.datetime + datetime.timedelta(minutes=self.get_duration())
265
266     def match_week(self, week_no):
267         if week_no == 4:
268             # this is the fifth week of the month, only return True for
269             # emissions scheduled every week.
270             return (self.weeks == 0b1111)
271         if (self.weeks & (0b0001<<(week_no)) == 0):
272             return False
273         return True
274
275     def matches(self, dt):
276         weekday = dt.weekday()
277         if dt.hour < self.DAY_HOUR_START:
278             weekday -= 1
279         if weekday != self.get_weekday():
280             return False
281         if self.weeks != 0b1111:
282             week_no = (dt.day-1) // 7
283             if self.match_week(week_no) is False:
284                 return False
285         if dt.time() >= self.datetime.time() and \
286                 dt.time() <= (self.datetime + datetime.timedelta(minutes=self.get_duration())).time():
287             return True
288         return False
289
290     def get_next_planned_date(self, since):
291         possible_dates = []
292         monday_since_date = since - datetime.timedelta(days=since.weekday())
293         start_week_date = self.datetime.replace(
294                 year=monday_since_date.year,
295                 month=monday_since_date.month,
296                 day=monday_since_date.day)
297         start_week_date -= datetime.timedelta(days=start_week_date.weekday())
298         start_week_date += datetime.timedelta(days=self.datetime.weekday())
299         for i in range(6):
300             week_date = start_week_date + datetime.timedelta(days=i*7)
301             if week_date < since:
302                 continue
303             if self.match_week((week_date.day-1)//7):
304                 possible_dates.append(week_date)
305         possible_dates.sort()
306         return possible_dates[0]
307
308
309     def __str__(self):
310         return u'%s at %s' % (self.emission.title,
311                 self.datetime.strftime('%a %H:%M'))
312
313
314 def get_episode_image_path(instance, filename):
315     return os.path.join('images', instance.emission.slug,
316             os.path.basename(filename))
317
318
319 @python_2_unicode_compatible
320 class Episode(models.Model):
321
322     class Meta:
323         verbose_name = _('Episode')
324         verbose_name_plural = _('Episodes')
325         ordering = ['title']
326
327     emission = models.ForeignKey('Emission', verbose_name=_('Emission'))
328     title = models.CharField(_('Title'), max_length=200)
329     slug = models.SlugField(max_length=200)
330     subtitle = models.CharField(_('Subtitle'), max_length=150, null=True, blank=True)
331     text = RichTextField(_('Description'), null=True)
332     tags = TaggableManager(_('Tags'), blank=True)
333     duration = models.IntegerField(_('Duration'), null=True, blank=True,
334             help_text=_('In minutes'))
335
336     image = models.ImageField(_('Image'),
337             upload_to=get_episode_image_path, max_length=250, null=True, blank=True)
338
339     effective_start = models.DateTimeField(null=True, blank=True)
340     effective_end = models.DateTimeField(null=True, blank=True)
341
342     # denormalized from Focus
343     got_focus = models.DateTimeField(default=None, null=True, blank=True)
344     has_focus = models.BooleanField(default=False)
345
346     creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
347     last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
348
349     # XXX: languages (models.ManyToManyField(Language))
350
351     def __str__(self):
352         return self.title
353
354     def save(self, *args, **kwargs):
355         super(Episode, self).save(*args, **kwargs)
356         if self.id is not None and self.image:
357             maybe_resize(self.image.path)
358
359     def get_duration(self):
360         if self.duration:
361             return self.duration
362         else:
363             return self.emission.duration
364
365     def get_absolute_url(self):
366         return reverse('episode-view',
367                         kwargs={'emission_slug': str(self.emission.slug),
368                                 'slug':  str(self.slug)})
369
370     def has_sound(self):
371         return (self.soundfile_set.count() > 0)
372
373     def get_pige_download_url(self):
374         return '%s/%s-%s-%s.wav' % (
375                 settings.PIGE_DOWNLOAD_BASE_URL,
376                 self.effective_start.strftime('%Y%m%d'),
377                 self.effective_start.strftime('%Hh%Mm%S.%f'),
378                 self.effective_end.strftime('%Hh%Mm%S.%f'))
379
380     _soundfiles = {}
381
382     @classmethod
383     def set_prefetched_soundfiles(cls, soundfiles):
384         cls._soundfiles.update(soundfiles)
385
386     def has_prefetched_soundfile(self):
387         return self.id in self._soundfiles
388
389     def get_prefetched_soundfile(self):
390         return self._soundfiles.get(self.id)
391
392     _main_sound = False
393
394     @property
395     def main_sound(self):
396         if self._main_sound is not False:
397             return self._main_sound
398
399         if self.has_prefetched_soundfile():
400             self._main_sound = self.get_prefetched_soundfile()
401             return self._main_sound
402
403         t = self.soundfile_set.exclude(podcastable=False).exclude(fragment=True)
404         if t:
405             self._main_sound = t[0]
406         else:
407             self._main_sound = None
408
409         return self._main_sound
410
411     @main_sound.setter
412     def main_sound(self, value):
413         self._main_sound = value
414
415     def fragment_sounds(self):
416         return self.soundfile_set.exclude(podcastable=False).exclude(fragment=False)
417
418     def diffusions(self):
419         return Diffusion.objects.filter(episode=self.id).order_by('datetime')
420
421
422 @python_2_unicode_compatible
423 class Diffusion(models.Model, WeekdayMixin):
424
425     class Meta:
426         verbose_name = _('Diffusion')
427         verbose_name_plural = _('Diffusions')
428         ordering = ['datetime']
429
430     episode = models.ForeignKey('Episode', verbose_name=_('Episode'))
431     datetime = models.DateTimeField(_('Date/time'), db_index=True)
432
433     def __str__(self):
434         return u'%s at %02d:%02d' % (self.episode.title, self.datetime.hour,
435                 self.datetime.minute)
436
437     def get_duration(self):
438         return self.episode.get_duration()
439
440     def end_datetime(self):
441         return self.datetime + datetime.timedelta(minutes=self.get_duration())
442
443
444 @python_2_unicode_compatible
445 class Absence(models.Model):
446
447     class Meta:
448         verbose_name = _('Absence')
449         verbose_name_plural = _('Absences')
450
451     emission = models.ForeignKey('Emission', verbose_name=u'Emission')
452     datetime = models.DateTimeField(_('Date/time'), db_index=True)
453
454     def __str__(self):
455         return u'Absence for %s on %s' % (self.emission.title, self.datetime)
456
457
458 def get_sound_path(instance, filename):
459     return os.path.join('sounds.orig', instance.episode.emission.slug,
460             os.path.basename(filename))
461
462
463 @python_2_unicode_compatible
464 class SoundFile(models.Model):
465
466     class Meta:
467         verbose_name = _('Sound file')
468         verbose_name_plural = _('Sound files')
469         ordering = ['creation_timestamp']
470
471     episode = models.ForeignKey('Episode', verbose_name=_('Episode'))
472     file = models.FileField(_('File'), upload_to=get_sound_path, max_length=250)
473     podcastable = models.BooleanField(_('Podcastable'), default=False,
474             db_index=True,
475             help_text=_('The file can be published online according to SABAM rules.'))
476     fragment = models.BooleanField(_('Fragment'), default=False, db_index=True,
477             help_text=_('The file is some segment or extra content, not the complete recording.'))
478     title = models.CharField(_('Title'), max_length=200)
479     duration = models.IntegerField(_('Duration'), null=True, help_text=_('In seconds'))
480
481     format = models.ForeignKey('Format', verbose_name=_('Format'), null=True, blank=True)
482     license = models.CharField(_('License'), max_length=20, blank=True, default='', choices=LICENSES)
483
484     # denormalized from Focus
485     got_focus = models.DateTimeField(default=None, null=True, blank=True)
486     has_focus = models.BooleanField(default=False)
487
488     creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
489     last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
490
491     def get_format_filename(self, format):
492         return '%s_%05d__%s.%s' % (
493                 self.episode.slug,
494                 self.id,
495                 (self.fragment and '0' or '1'),
496                 format)
497
498     def get_format_path(self, format):
499         if not self.file:
500             return None
501         return '%s/%s' % (os.path.dirname(self.file.path).replace('.orig', ''), self.get_format_filename(format))
502
503     def get_format_url(self, format):
504         if not self.file:
505             return None
506         return '%s/%s' % (os.path.dirname(self.file.url).replace('.orig', ''), self.get_format_filename(format))
507
508     def get_duration_string(self):
509         if not self.duration:
510             return ''
511         return '%d:%02d' % (self.duration/60, self.duration%60)
512
513     def __str__(self):
514         return '%s - %s' % (self.title or self.id, self.episode.title)
515
516
517 @python_2_unicode_compatible
518 class NewsCategory(models.Model):
519
520     class Meta:
521         verbose_name = _('News Category')
522         verbose_name_plural = _('News Categories')
523         ordering = ['title']
524
525     title = models.CharField(_('Title'), max_length=50)
526     slug = models.SlugField(null=True)
527
528     def __str__(self):
529         return self.title
530
531     def get_sorted_newsitems(self):
532         return self.newsitem_set.select_related().order_by('-date')
533
534
535 def get_newsitem_image_path(instance, filename):
536     return os.path.join('images', 'news', instance.slug,
537             os.path.basename(filename))
538
539
540 @python_2_unicode_compatible
541 class NewsItem(models.Model):
542
543     class Meta:
544         verbose_name = _('News Item')
545         verbose_name_plural = _('News Items')
546         ordering = ['title']
547
548     title = models.CharField(_('Title'), max_length=200)
549     slug = models.SlugField(max_length=200)
550     text = RichTextField(_('Description'))
551     date = models.DateField(_('Publication Date'),
552             help_text=_('The news won\'t appear on the website before this date.'))
553     image = models.ImageField(_('Image'),
554             upload_to=get_newsitem_image_path, max_length=250, null=True, blank=True)
555
556     tags = TaggableManager(_('Tags'), blank=True)
557     category = models.ForeignKey('NewsCategory', verbose_name=_('Category'), null=True, blank=True)
558     emission = models.ForeignKey('Emission', verbose_name=_('Emission'), null=True, blank=True)
559
560     expiration_date = models.DateField(_('Expiration Date'), null=True, blank=True)
561     event_date = models.DateField(_('Event Date'), null=True, blank=True,
562             help_text=_('If this is an event, set the date here so it appears in the agenda.'))
563
564     # denormalized from Focus
565     got_focus = models.DateTimeField(default=None, null=True, blank=True)
566     has_focus = models.BooleanField(default=False)
567
568     creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
569     last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
570
571     def __str__(self):
572         return self.title
573
574     def get_absolute_url(self):
575         return reverse('newsitem-view', kwargs={'slug': str(self.slug)})
576
577
578 @python_2_unicode_compatible
579 class Nonstop(models.Model):
580
581     class Meta:
582         verbose_name = _('Nonstop zone')
583         verbose_name_plural = _('Nonstop zones')
584         ordering = ['title']
585
586     title = models.CharField(_('Title'), max_length=50)
587     slug = models.SlugField()
588
589     start = models.TimeField(_('Start'))
590     end = models.TimeField(_('End'))
591     text = RichTextField(_('Description'), null=True, blank=True)
592     redirect_path = models.CharField(_('Redirect Path'), max_length=200, blank=True)
593
594     def __str__(self):
595         return self.title
596
597
598 @python_2_unicode_compatible
599 class Focus(models.Model):
600     title = models.CharField(_('Alternate Title'), max_length=50,
601             null=True, blank=True)
602     newsitem = models.ForeignKey('NewsItem', verbose_name=_('News Item'),
603             null=True, blank=True)
604     emission = models.ForeignKey('Emission', verbose_name=_('Emission'),
605             null=True, blank=True)
606     episode = models.ForeignKey('Episode', verbose_name=_('Episode'),
607             null=True, blank=True)
608     soundfile = models.ForeignKey('SoundFile', verbose_name=_('Sound file'),
609             null=True, blank=True)
610     page = models.ForeignKey('data.Page', verbose_name=_('Page'),
611             null=True, blank=True)
612     current = models.BooleanField('Current', default=True)
613
614     creation_timestamp = models.DateTimeField(auto_now_add=True, null=True)
615     last_update_timestamp = models.DateTimeField(auto_now=True, null=True)
616
617     def __str__(self):
618         if self.newsitem:
619             return u'Newsitem: %s' % self.newsitem.title
620         if self.emission:
621             return u'Emission: %s' % self.emission.title
622         if self.episode:
623             return u'Episode: %s' % self.episode.title
624         if self.soundfile:
625             return u'Soundfile: %s' % (self.soundfile.title or self.soundfile.episode.title)
626         if self.page:
627             return u'Page: %s' % self.page.title
628         return u'%s' % self.id
629
630     def focus_title(self):
631         if self.title:
632             return self.title
633         if self.newsitem:
634             return self.newsitem.title
635         if self.emission:
636             return self.emission.title
637         if self.page:
638             return self.page.title
639
640         episode = None
641         if self.soundfile:
642             if self.soundfile.fragment and self.soundfile.title:
643                 return self.soundfile.title
644             episode = self.soundfile.episode
645         elif self.episode:
646             episode = self.episode
647
648         if episode:
649             if episode.title:
650                 return episode.title
651             else:
652                 return episode.emission.title
653
654         return None
655
656     def content_image(self):
657         if self.newsitem:
658             return self.newsitem.image
659         if self.emission:
660             return self.emission.image
661         if self.page:
662             from panikombo.models import Topik
663             return Topik.objects.get(page=self.page).image
664
665         episode = None
666         if self.soundfile:
667             episode = self.soundfile.episode
668         elif self.episode:
669             episode = self.episode
670
671         if episode:
672             if episode.image:
673                 return episode.image
674             else:
675                 return episode.emission.image
676
677     def content_category_title(self):
678         if self.newsitem:
679             if self.newsitem.category:
680                 return self.newsitem.category.title
681             return _('News')
682         if self.emission:
683             return _('Emission')
684         if self.episode:
685             return self.episode.emission.title
686         if self.soundfile:
687             return self.soundfile.episode.emission.title
688         if self.page:
689             return 'Topik'
690
691     def get_related_object(self):
692         try:
693             if self.newsitem:
694                 return self.newsitem
695             if self.emission:
696                 return self.emission
697             if self.episode:
698                 return self.episode
699             if self.soundfile:
700                 return self.soundfile
701             if self.page:
702                 from panikombo.models import Topik
703                 return Topik.objects.get(page=self.page)
704         except ObjectDoesNotExist:
705             return None
706
707         return None
708
709
710 def get_playlist_sound_path(instance, filename):
711     return os.path.join('playlists', instance.episode.emission.slug,
712             instance.episode.slug, os.path.basename(filename))
713
714
715 class PlaylistElement(models.Model):
716     episode = models.ForeignKey('Episode', null=True)
717     title = models.CharField(_('Title'), max_length=200)
718     notes = models.CharField(_('Notes'), max_length=200, blank=True, default='')
719     sound = models.FileField(_('Sound'), upload_to=get_playlist_sound_path, max_length=250)
720     order = models.PositiveIntegerField()
721
722     class Meta:
723         verbose_name = _('Playlist Element')
724         verbose_name_plural = _('Playlist Elements')
725         ordering = ['order']
726
727     def shortcut(self):
728         return chr(ord('a')+self.order-1)
729
730
731 @receiver(pre_save, sender=Focus, dispatch_uid='focus_pre_save')
732 def set_focus_on_save(sender, instance, **kwargs):
733     object = instance.get_related_object()
734     must_save = False
735     if instance.current != object.has_focus:
736         object.has_focus = instance.current
737         must_save = True
738     if object and not object.got_focus and instance.current:
739         object.got_focus = datetime.datetime.now()
740         must_save = True
741     if must_save:
742         object.save()
743
744 @receiver(post_delete, sender=Focus, dispatch_uid='focus_post_delete')
745 def remove_focus_on_delete(sender, instance, **kwargs):
746     object = instance.get_related_object()
747     if object and (object.got_focus or object.has_focus):
748         object.got_focus = None
749         object.has_focus = False
750         object.save()