From 4b5f908b3dd0052e1305c26873b6293f75975625 Mon Sep 17 00:00:00 2001 From: bkfox Date: Tue, 15 Sep 2015 16:50:12 +0200 Subject: [PATCH] fixes, updates --- README.md | 12 +- programs/admin.py | 56 +-- programs/management/commands/programs.py | 1 - programs/models.py | 414 ++++++++++++----------- programs/requirements.txt | 6 +- 5 files changed, 254 insertions(+), 235 deletions(-) diff --git a/README.md b/README.md index 6607460..08d9f75 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,13 @@ Platform to manage radio programs, schedules, cms, etc. -- main test repo # Applications -* **programs**: core application that have all defined models - -# Note -We make the assumption that admin is used with autocomplete-light and django-suit +* **programs**: programs, episodes, schedules, sounds and tracks; +* **streams**: streams and diffusions, links with LiquidSoap; +* **website**: website rendering, using models defined by the previous apps; +# Code and names conventions and uses +* absolute dates: datetime fields, named "begin" "end" for ranges and "date" otherwise +* time range: timefield name "duration" +* parents: when only one parent, named "parent", otherwise model/reference's name + diff --git a/programs/admin.py b/programs/admin.py index 9e239be..902e564 100755 --- a/programs/admin.py +++ b/programs/admin.py @@ -3,16 +3,20 @@ import copy from django.contrib import admin from django.db import models -from suit.admin import SortableTabularInline +from suit.admin import SortableTabularInline, SortableModelAdmin +from autocomplete_light.contrib.taggit_field import TaggitWidget, TaggitField from programs.forms import * from programs.models import * - # # Inlines # # TODO: inherits from the corresponding admin view +class SoundInline (admin.TabularInline): + model = Sound + + class ScheduleInline (admin.TabularInline): model = Schedule extra = 1 @@ -20,12 +24,11 @@ class ScheduleInline (admin.TabularInline): class DiffusionInline (admin.TabularInline): model = Diffusion - fields = ('episode', 'type', 'begin', 'end', 'stream') - readonly_fields = ('begin', 'end', 'stream') + fields = ('episode', 'type', 'date', 'stream') + readonly_fields = ('date', 'stream') extra = 1 - class TrackInline (SortableTabularInline): fields = ['artist', 'title', 'tags', 'position'] form = TrackForm @@ -34,9 +37,6 @@ class TrackInline (SortableTabularInline): extra = 10 -# -# Parents -# class MetadataAdmin (admin.ModelAdmin): fieldsets = [ ( None, { @@ -47,31 +47,27 @@ class MetadataAdmin (admin.ModelAdmin): }), ] - def save_model (self, request, obj, form, change): + # FIXME: if request.data.author? if not obj.author: obj.author = request.user obj.save() -from autocomplete_light.contrib.taggit_field import TaggitWidget, TaggitField class PublicationAdmin (MetadataAdmin): fieldsets = copy.deepcopy(MetadataAdmin.fieldsets) - list_display = ('id', 'title', 'date', 'public', 'parent') + list_display = ('id', 'title', 'date', 'public', 'enumerable', 'parent') list_filter = ['date', 'public', 'parent', 'author'] + list_editable = ('public', 'enumerable') search_fields = ['title', 'content'] - fieldsets[0][1]['fields'].insert(1, 'subtitle') fieldsets[0][1]['fields'] += [ 'img', 'content' ] fieldsets[1][1]['fields'] += [ 'parent' ] #, 'meta' ], - -# -# ModelAdmin list -# +@admin.register(Sound) class SoundAdmin (MetadataAdmin): fieldsets = [ (None, { 'fields': ['title', 'tags', 'path' ] } ), @@ -79,12 +75,21 @@ class SoundAdmin (MetadataAdmin): ] +@admin.register(Stream) +class StreamAdmin (SortableModelAdmin): + list_display = ('id', 'name', 'type', 'public', 'enumerable', 'priority') + list_editable = ('public', 'enumerable') + sortable = "priority" + + +@admin.register(Article) class ArticleAdmin (PublicationAdmin): fieldsets = copy.deepcopy(PublicationAdmin.fieldsets) fieldsets[1][1]['fields'] += ['static_page'] +@admin.register(Program) class ProgramAdmin (PublicationAdmin): fieldsets = copy.deepcopy(PublicationAdmin.fieldsets) inlines = [ ScheduleInline ] @@ -92,6 +97,7 @@ class ProgramAdmin (PublicationAdmin): fieldsets[1][1]['fields'] += ['email', 'url'] +@admin.register(Episode) class EpisodeAdmin (PublicationAdmin): fieldsets = copy.deepcopy(PublicationAdmin.fieldsets) list_filter = ['parent'] + PublicationAdmin.list_filter @@ -101,18 +107,20 @@ class EpisodeAdmin (PublicationAdmin): inlines = (TrackInline, DiffusionInline) +@admin.register(Diffusion) class DiffusionAdmin (admin.ModelAdmin): - list_display = ('type', 'begin', 'end', 'episode', 'program', 'stream') - list_filter = ('type', 'begin', 'program', 'stream') - + list_display = ('id', 'type', 'date', 'episode', 'program', 'stream') + list_filter = ('type', 'date', 'program', 'stream') + list_editable = ('type', 'date') + def get_queryset(self, request): + qs = super(DiffusionAdmin, self).get_queryset(request) + if 'type__exact' in request.GET and \ + str(Diffusion.Type['unconfirmed']) in request.GET['type__exact']: + return qs + return qs.exclude(type = Diffusion.Type['unconfirmed']) admin.site.register(Track) -admin.site.register(Sound, SoundAdmin) admin.site.register(Schedule) -admin.site.register(Article, ArticleAdmin) -admin.site.register(Program, ProgramAdmin) -admin.site.register(Episode, EpisodeAdmin) -admin.site.register(Diffusion, DiffusionAdmin) diff --git a/programs/management/commands/programs.py b/programs/management/commands/programs.py index 06aa3b5..0abedb8 100644 --- a/programs/management/commands/programs.py +++ b/programs/management/commands/programs.py @@ -255,7 +255,6 @@ class Command (BaseCommand): parser.epilog += '\n ' + model.model.type() + ': \n' \ + model.to_string() - def handle (self, *args, **options): model = options.get('model') if not model: diff --git a/programs/models.py b/programs/models.py index eeacf9c..fad47cd 100755 --- a/programs/models.py +++ b/programs/models.py @@ -7,7 +7,7 @@ from django.template.defaultfilters import slugify from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.utils.translation import ugettext as _, ugettext_lazy -from django.utils import timezone +from django.utils import timezone as tz from django.utils.html import strip_tags # extensions @@ -16,75 +16,42 @@ from taggit.managers import TaggableManager import programs.settings as settings - -# Frequency for schedules. Basically, it is a mask of bits where each bit is a -# week. Bits > rank 5 are used for special schedules. -# Important: the first week is always the first week where the weekday of the -# schedule is present. -Frequency = { - 'ponctual': 0b000000, - 'first': 0b000001, - 'second': 0b000010, - 'third': 0b000100, - 'fourth': 0b001000, - 'last': 0b010000, - 'first and third': 0b000101, - 'second and fourth': 0b001010, - 'every': 0b011111, - 'one on two': 0b100000, -} +def date_or_default (date, date_only = False): + """ + Return date or default value (now) if not defined, and remove time info + if date_only is True + """ + date = date or tz.datetime.today() + if not tz.is_aware(date): + date = tz.make_aware(date) + if date_only: + return date.replace(hour = 0, minute = 0, second = 0, microsecond = 0) + return date -# Translators: html safe values -ugettext_lazy('ponctual') -ugettext_lazy('every') -ugettext_lazy('first') -ugettext_lazy('second') -ugettext_lazy('third') -ugettext_lazy('fourth') -ugettext_lazy('first and third') -ugettext_lazy('second and fourth') -ugettext_lazy('one on two') +#class Model (models.Model): +# @classmethod +# def type (cl): +# """ +# Return a string with the type of the model (class name lowered) +# """ +# name = cl.__name__.lower() +# return name + +# @classmethod +# def name (cl, plural = False): +# """ +# Return the name of the model using meta.verbose_name +# """ +# if plural: +# return cl._meta.verbose_name_plural.title() +# return cl._meta.verbose_name.title() +# +# class Meta: +# abstract = True -DiffusionType = { - 'diffuse': 0x01, # the diffusion is planified or done - 'scheduled': 0x02, # the diffusion has been scheduled automatically - 'cancel': 0x03, # the diffusion has been canceled from grid; useful to - # give the info to the users -} - - -class Model (models.Model): - @classmethod - def type (cl): - """ - Return a string with the type of the model (class name lowered) - """ - name = cl.__name__.lower() - return name - - @classmethod - def type_plural (cl): - """ - Return a string with the name in plural of the model (cf. name()) - """ - return cl.type() + 's' - - @classmethod - def name (cl, plural = False): - """ - Return the name of the model using meta.verbose_name - """ - if plural: - return cl._meta.verbose_name_plural.title() - return cl._meta.verbose_name.title() - - class Meta: - abstract = True - - -class Metadata (Model): +class Metadata (models.Model): """ meta is used to extend a model for future needs """ @@ -99,7 +66,7 @@ class Metadata (Model): ) date = models.DateTimeField( _('date'), - default = timezone.datetime.now, + default = tz.datetime.now, ) public = models.BooleanField( _('public'), @@ -170,7 +137,7 @@ class Publication (Metadata): res = {} res[prefix + 'public'] = False - res[prefix + 'date__gt'] = timezone.now() + res[prefix + 'date__gt'] = tz.now() return res @classmethod @@ -182,7 +149,7 @@ class Publication (Metadata): Otherwise, return None """ kwargs['public'] = True - kwargs['date__lte'] = timezone.now() + kwargs['date__lte'] = tz.now() e = cl.objects.filter(**kwargs) @@ -197,8 +164,7 @@ class Publication (Metadata): abstract = True - -class Track (Model): +class Track (models.Model): # There are no nice solution for M2M relations ship (even without # through) in django-admin. So we unfortunately need to make one- # to-one relations and add a position argument @@ -250,6 +216,11 @@ class Sound (Metadata): recursive = True, blank = True, null = True, ) + embed = models.TextField( + _('embed HTML code from external website'), + blank = True, null = True, + help_text = _('if set, consider the sound podcastable'), + ) duration = models.TimeField( _('duration'), blank = True, null = True, @@ -259,11 +230,6 @@ class Sound (Metadata): default = False, help_text = _("the file has been cut"), ) - embed = models.TextField( - _('embed HTML code from external website'), - blank = True, null = True, - help_text = _('if set, consider the sound podcastable'), - ) removed = models.BooleanField( default = False, help_text = _('this sound has been removed from filesystem'), @@ -274,8 +240,8 @@ class Sound (Metadata): Get the last modification date from file """ mtime = os.stat(self.path).st_mtime - mtime = timezone.datetime.fromtimestamp(mtime) - return timezone.make_aware(mtime, timezone.get_current_timezone()) + mtime = tz.datetime.fromtimestamp(mtime) + return tz.make_aware(mtime, timezone.get_current_timezone()) def save (self, *args, **kwargs): if not self.pk: @@ -290,15 +256,33 @@ class Sound (Metadata): verbose_name_plural = _('Sounds') -class Schedule (Model): +class Schedule (models.Model): + # Frequency for schedules. Basically, it is a mask of bits where each bit is + # a week. Bits > rank 5 are used for special schedules. + # Important: the first week is always the first week where the weekday of + # the schedule is present. + # For ponctual programs, there is no need for a schedule, only a diffusion + Frequency = { + 'first': 0b000001, + 'second': 0b000010, + 'third': 0b000100, + 'fourth': 0b001000, + 'last': 0b010000, + 'first and third': 0b000101, + 'second and fourth': 0b001010, + 'every': 0b011111, + 'one on two': 0b100000, + } + for key, value in Frequency.items(): + ugettext_lazy(key) + parent = models.ForeignKey( 'Program', blank = True, null = True, ) - begin = models.DateTimeField(_('begin')) - end = models.DateTimeField( - _('end'), - blank = True, null = True, + date = models.DateTimeField(_('date')) + duration = models.TimeField( + _('duration'), ) frequency = models.SmallIntegerField( _('frequency'), @@ -310,16 +294,14 @@ class Schedule (Model): help_text = "Schedule of a rerun", ) - def match (self, date = None, check_time = False): + def match (self, date = None, check_time = True): """ Return True if the given datetime matches the schedule """ - if not date: - date = timezone.datetime.today() + date = date_or_default(date) if self.date.weekday() == date.weekday() and self.match_week(date): - return (check_time and self.date.time() == date.date.time()) \ - or True + return self.date.time() == date.time() if check_time else True return False def match_week (self, date = None): @@ -328,17 +310,12 @@ class Schedule (Model): otherwise. If the schedule is ponctual, return None. """ - if not date: - date = timezone.datetime.today() - - if self.frequency == Frequency['ponctual']: - return None - - if self.frequency == Frequency['one on two']: + date = date_or_default(date) + if self.frequency == Schedule.Frequency['one on two']: week = date.isocalendar()[1] return (week % 2) == (self.date.isocalendar()[1] % 2) - first_of_month = timezone.datetime.date(date.year, date.month, 1) + first_of_month = tz.datetime.date(date.year, date.month, 1) week = date.isocalendar()[1] - first_of_month.isocalendar()[1] # weeks of month @@ -355,32 +332,23 @@ class Schedule (Model): def dates_of_month (self, date = None): """ - Return a list with all matching dates of the month of the given - date (= today). + Return a list with all matching dates of date.month (=today) """ - if self.frequency == Frequency['ponctual']: - return None - - if not date: - date = timezone.datetime.today() - - date = timezone.datetime(year = date.year, month = date.month, day = 1) + date = date_or_default(date, True).replace(day=1) wday = self.date.weekday() fwday = date.weekday() # move date to the date weekday of the schedule # check on SO#3284452 for the formula - date += timezone.timedelta( - days = (7 if fwday > wday else 0) - fwday + wday - ) + date += tz.timedelta(days = (7 if fwday > wday else 0) - fwday + wday) fwday = date.weekday() # special frequency case weeks = self.frequency - if self.frequency == Frequency['last']: - date += timezone.timedelta(month = 1, days = -7) + if self.frequency == Schedule.Frequency['last']: + date += tz.timedelta(month = 1, days = -7) return self.normalize([date]) - if weeks == Frequency['one on two']: + if weeks == Schedule.Frequency['one on two']: # if both week are the same, then the date week of the month # matches. Note: wday % 2 + fwday % 2 => (wday + fwday) % 2 fweek = date.isocalendar()[1] @@ -392,61 +360,55 @@ class Schedule (Model): # there can be five weeks in a month if not weeks & (0b1 << week): continue - - wdate = date + timezone.timedelta(days = week * 7) + wdate = date + tz.timedelta(days = week * 7) if wdate.month == date.month: dates.append(self.normalize(wdate)) return dates - def diffusions_of_month (self, date = None, exclude_saved = False): + def diffusions_of_month (self, date, exclude_saved = False): """ - Return a list of generated (unsaved) diffusions for this program for the - month of the given date. If exclude_saved, exclude all diffusions that - are yet in the database. + Return a list of Diffusion instances, from month of the given date, that + can be not in the database. - When a diffusion is created, it tries to attach the corresponding - episode. + If exclude_saved, exclude all diffusions that are yet in the database. + + When a Diffusion is created, it tries to attach the corresponding + episode using a match of episode.date (and takes care of rerun case); """ - if not date: - date = timezone.datetime.today() - - dates = self.dates_of_month() + dates = self.dates_of_month(date) saved = Diffusion.objects.filter(date__in = dates, program = self.parent) diffusions = [] # existing diffusions - for saved_item in saved: - dates.remove(saved_item.date) + for item in saved: + if item.date in dates: + dates.remove(item.date) if not exclude_saved: - diffusions.append(saved_item) + diffusions.append(item) # others for date in dates: - # get episode ep_date = date if self.rerun: - ep_date = self.rerun.date + ep_date -= self.date - self.rerun.date - episode = Episode.objects().filter(date = ep_date, - parent = self.parent) - episode = episode[0] if episode.count() else None + episode = Episode.objects.filter(date = date, + parent = self.parent) + episode = episode[0] if episode.count() else None - # make diffusion - diffusion = Diffusion( - episode = episode, - program = self.parent, - type = DiffusionType['scheduled'], - begin = date, - end = timezone.datetime.combine(date.date(), self.end.time()), - stream = settings.AIRCOX_SCHEDULED_STREAM - ) - diffusion.program = self.program - diffusions.append(diffusion) + diffusions.append(Diffusion( + episode = episode, + program = self.parent, + stream = self.parent.stream, + type = Diffusion.Type['unconfirmed'], + date = date, + )) return diffusions def __str__ (self): - frequency = [ x for x,y in Frequency.items() if y == self.frequency ] + frequency = [ x for x,y in Schedule.Frequency.items() + if y == self.frequency ] return self.parent.title + ': ' + frequency[0] class Meta: @@ -454,7 +416,104 @@ class Schedule (Model): verbose_name_plural = _('Schedules') +class Diffusion (models.Model): + Type = { + 'normal': 0x00, # simple diffusion (done/planed) + 'unconfirmed': 0x01, # scheduled by the generator but not confirmed for diffusion + 'cancel': 0x02, # cancellation happened; used to inform users + 'restart': 0x03, # manual restart; used to remix/give up antenna + 'stop': 0x04, # diffusion has been forced to stop + } + for key, value in Type.items(): + ugettext_lazy(key) + + episode = models.ForeignKey ( + 'Episode', + blank = True, null = True, + verbose_name = _('episode'), + ) + program = models.ForeignKey ( + 'Program', + verbose_name = _('program'), + ) + # program.stream can change, but not the stream; + stream = models.ForeignKey( + 'Stream', + verbose_name = _('stream'), + default = 0, + help_text = 'stream id on which the diffusion happens', + ) + type = models.SmallIntegerField( + verbose_name = _('type'), + choices = [ (y, x) for x,y in Type.items() ], + ) + date = models.DateTimeField( _('start of the diffusion') ) + + def save (self, *args, **kwargs): + if self.episode: # FIXME self.episode or kwargs['episode'] + self.program = self.episode.parent + # check type against stream's type + super(Diffusion, self).save(*args, **kwargs) + + def __str__ (self): + return self.program.title + ' on ' + str(self.date) \ + + str(self.type) + + class Meta: + verbose_name = _('Diffusion') + verbose_name_plural = _('Diffusions') + + +class Stream (models.Model): + Type = { + 'random': 0x00, # selection using random function + 'schedule': 0x01, # selection using schedule + } + for key, value in Type.items(): + ugettext_lazy(key) + + # FIXME: id as integer? + name = models.CharField( + _('name'), + max_length = 32, + blank = True, + null = True, + ) + type = models.SmallIntegerField( + verbose_name = _('type'), + choices = [ (y, x) for x,y in Type.items() ], + ) + # FIXME unique value / suit's orderable + # + priority = models.SmallIntegerField( + _('priority'), + default = 0, + help_text = _('priority of the stream') + ) + public = models.BooleanField( + _('public'), + default = True, + help_text = _('content is public'), + ) + enumerable = models.BooleanField( + _('enumerable'), + default = True, + help_text = _('publication is listable'), + ) + + # get info for: + # - random lists + # - scheduled lists + # link between Streams and Programs: + # - hours range (non-stop) + # - stream/pgm + + def __str__ (self): + return self.name + ' / ' + str(self.priority) + + class Article (Publication): + # FIXME: move to website? parent = models.ForeignKey( 'self', verbose_name = _('parent'), @@ -482,6 +541,10 @@ class Program (Publication): blank = True, null = True, help_text = _('parent article'), ) + stream = models.ForeignKey( + Stream, + verbose_name = _('stream'), + ) email = models.EmailField( _('email'), max_length = 128, @@ -491,9 +554,10 @@ class Program (Publication): _('website'), blank = True, null = True, ) - non_stop = models.BooleanField( - _('non-stop'), - default = False, + active = models.BooleanField( + _('inactive'), + default = True, + help_text = _('if not set this program is no longer active') ) @property @@ -507,21 +571,11 @@ class Program (Publication): """ schedules = Schedule.objects.filter(parent = self) for schedule in schedules: - if schedule.match(date): + if schedule.match(date, check_time = False): return schedule - class Meta: - verbose_name = _('Program') - verbose_name_plural = _('Programs') - class Episode (Publication): - # Note: - # We do not especially need a duration here, because even if an - # program's schedule can have specified durations, in practice this - # duration may vary. Furthermore, we want the users have to enter a - # minimum of values. - # Duration can be retrieved from the sound file if there is one. parent = models.ForeignKey( Program, verbose_name = _('parent'), @@ -538,49 +592,3 @@ class Episode (Publication): verbose_name_plural = _('Episodes') - -class Diffusion (Model): - """ - Diffusion logs and planifications. - - A diffusion is: - - scheduled: when it has been generated following programs' Schedule - - planified: when it has been generated manually/ponctually or scheduled - """ - episode = models.ForeignKey ( - Episode, - blank = True, null = True, - verbose_name = _('episode'), - ) - program = models.ForeignKey ( - Program, - verbose_name = _('program'), - ) - type = models.SmallIntegerField( - verbose_name = _('type'), - choices = [ (y, x) for x,y in DiffusionType.items() ], - ) - begin = models.DateTimeField( _('start of the diffusion') ) - end = models.DateTimeField( - _('end of the diffusion'), - blank = True, null = True, - ) - stream = models.SmallIntegerField( - verbose_name = _('stream'), - default = 0, - help_text = 'stream id on which the diffusion happens', - ) - - def save (self, *args, **kwargs): - if self.episode: - self.program = self.episode.parent - super(Diffusion, self).save(*args, **kwargs) - - def __str__ (self): - return self.program.title + ' on ' + str(self.start) \ - + str(self.type) - - class Meta: - verbose_name = _('Diffusion') - verbose_name_plural = _('Diffusions') - diff --git a/programs/requirements.txt b/programs/requirements.txt index dd1bb13..0ff759d 100644 --- a/programs/requirements.txt +++ b/programs/requirements.txt @@ -1,5 +1,5 @@ Django>=1.9.0 -taggit>=0.12.1 -sortedm2m>=1.0.2 - +django-taggit>=0.12.1 +django-autocomplete-light>=2.2.5 +django-suit>=0.2.14