From 88a5a9556ef6a8e0047b51cee88d1fbefe6b937d Mon Sep 17 00:00:00 2001 From: bkfox Date: Fri, 8 Jul 2016 01:17:02 +0200 Subject: [PATCH] cms.actions + website.actions; Sounds section; player: bug fix (ask for restart on live stream), actions; remove website.Sound (not really used): move chmod/public into programs.Sound --- cms/actions.py | 126 ++++++++++++++++ cms/{decorators.py => exposures.py} | 0 cms/models.py | 5 + cms/sections.py | 20 ++- cms/static/aircox/cms/scripts.js | 8 +- cms/templates/aircox/cms/list_item.html | 7 +- cms/templates/aircox/cms/website.html | 8 + cms/templatetags/aircox_cms.py | 5 + cms/views.py | 11 +- notes.md | 17 +-- programs/admin.py | 5 +- programs/models.py | 37 ++++- programs/settings.py | 10 ++ website/actions.py | 75 ++++++++++ website/admin.py | 1 - website/models.py | 53 +------ website/sections.py | 87 ++++++++--- website/templates/aircox/website/player.html | 148 ++++++++++--------- website/utils.py | 6 + 19 files changed, 456 insertions(+), 173 deletions(-) create mode 100644 cms/actions.py rename cms/{decorators.py => exposures.py} (100%) create mode 100644 website/actions.py create mode 100644 website/utils.py diff --git a/cms/actions.py b/cms/actions.py new file mode 100644 index 0000000..ce86c8d --- /dev/null +++ b/cms/actions.py @@ -0,0 +1,126 @@ +""" +Actions are used to add controllers available to the end user. +They are attached to models, and tested (+ rendered if it is okay) +before rendering each instance of the models. + +For the moment it only can execute javascript code. There is also +a javascript mini-framework in order to make it easy. The code of +the action is then registered and executable on users actions. +""" +class Actions(type): + """ + General class that is used to register and manipulate actions + """ + registry = [] + + def __new__(cls, name, bases, attrs): + cl = super().__new__(cls, name, bases, attrs) + if name != 'Action': + cls.registry.append(cl) + return cl + + @classmethod + def make(cl, request, object_list = None, object = None): + """ + Make action on the given object_list or object + """ + if object_list: + in_list = True + else: + object_list = [object] + in_list = False + + for object in object_list: + if not hasattr(object, 'actions') or not object.actions: + continue + object.actions = [ + action.test(request, object, in_list) + if type(action) == cl and issubclass(action, Action) else + str(action) + for action in object.actions + ] + object.actions = [ code for code in object.actions if code ] + + @classmethod + def register_code(cl): + """ + Render javascript code that can be inserted somewhere to register + all actions + """ + return '\n'.join(action.register_code() for action in cl.registry) + + +class Action(metaclass=Actions): + """ + An action available to the end user. + Don't forget to note in docstring the needed things. + """ + id = '' + """ + Unique ID for the given action + """ + symbol = '' + """ + UTF-8 symbol for the given action + """ + title = '' + """ + Used to render the correct button for the given action + """ + code = '' + """ + If set, used as javascript code executed when the action is + activated + """ + + @classmethod + def register_code(cl): + """ + Render a Javascript code that append the code to the available actions. + Used by Actions + """ + if not cl.code: + return '' + + return """ + Actions.register('{cl.id}', '{cl.symbol}', '{cl.title}', {cl.code}) + """.format(cl = cl) + + @classmethod + def has_me(cl, object): + return hasattr(object, 'actions') and cl.id in object.actions + + @classmethod + def to_str(cl, object, url = None, **data): + """ + Utility class to add the action on the object using the + given data. + """ + if cl.has_me(object): + return + + code = \ + '' \ + '{cl.symbol}' \ + ''.format( + href = '' if not url else 'href="' + url + '"', + onclick = 'onclick="return Actions.run(event, \'{cl.id}\');"' \ + .format(cl = cl) + if cl.id and cl.code else '', + data = ' '.join('data-{k}="{v}"'.format(k=k, v=v) + for k,v in data.items()), + cl = cl + ) + + return code + + @classmethod + def test(cl, request, object, in_list): + """ + Test if the given object can have the generated action. If yes, return + the generated content, otherwise, return None + + in_list: object is rendered in a list + """ + + diff --git a/cms/decorators.py b/cms/exposures.py similarity index 100% rename from cms/decorators.py rename to cms/exposures.py diff --git a/cms/models.py b/cms/models.py index ed8148c..843fbad 100644 --- a/cms/models.py +++ b/cms/models.py @@ -171,6 +171,11 @@ class Post (models.Model, Routable): """ Fields on which routes.SearchRoute must run the search """ + actions = None + """ + Actions are a list of actions available to the end user for this model. + See aircox.cms.actions for more information + """ def get_comments(self): """ diff --git a/cms/sections.py b/cms/sections.py index 7ce4568..d27bf47 100644 --- a/cms/sections.py +++ b/cms/sections.py @@ -17,7 +17,8 @@ from django.utils.translation import ugettext as _, ugettext_lazy from honeypot.decorators import check_honeypot from aircox.cms.forms import CommentForm -import aircox.cms.decorators as decorators +from aircox.cms.exposures import expose +from aircox.cms.actions import Actions class Viewable: @@ -51,7 +52,7 @@ class Viewable: setattr(Sub, k, v) if hasattr(cl, '_exposure'): - return decorators.expose(Sub) + return expose(Sub) return Sub @@ -224,6 +225,7 @@ class ListItem: image = None info = None url = None + actions = None css_class = None attrs = None @@ -285,7 +287,7 @@ class List(Section): def get_object_list(self): return self.object_list - def prepare_object_list(self, object_list): + def prepare_list(self, object_list): """ Prepare objects before context is sent to the template renderer. Return the object_list that is prepared. @@ -302,7 +304,7 @@ class List(Section): instances of Post or ListItem. If object_list is not given, call `get_object_list` to retrieve it. - Prepare the object_list using `self.prepare_object_list`. + Prepare the object_list using `self.prepare_list`. Set `request`, `object`, `object_list` and `kwargs` in self. """ @@ -314,10 +316,11 @@ class List(Section): object_list = self.object_list or self.get_object_list() if not object_list and not self.message_empty: return - self.object_list = object_list + self.object_list = object_list if object_list: - object_list = self.prepare_object_list(object_list) + object_list = self.prepare_list(object_list) + Actions.make(request, object_list = object_list) context = super().get_context_data(request, object, *args, **kwargs) context.update({ @@ -500,7 +503,7 @@ class Search(Section): ) -@decorators.expose +@expose class Calendar(Section): model = None template_name = "aircox/cms/calendar.html" @@ -535,11 +538,12 @@ class Calendar(Section): }) return context - @decorators.expose + @expose def render_exp(cl, *args, year, month, **kwargs): year = int(year) month = int(month) return cl.render(*args, year = year, month = month, **kwargs) + render_exp._exposure.name = 'render' render_exp._exposure.pattern = '(?P[0-9]{4})/(?P[0-1]?[0-9])' diff --git a/cms/static/aircox/cms/scripts.js b/cms/static/aircox/cms/scripts.js index 0659544..13183b4 100644 --- a/cms/static/aircox/cms/scripts.js +++ b/cms/static/aircox/cms/scripts.js @@ -13,7 +13,6 @@ var Actions = { } }, - /// Init an existing action HTML element init_action: function(item, action_id, data, url) { var action = this.registry[action_id]; @@ -54,15 +53,15 @@ var Actions = { action = Actions.registry[action]; if(!action) - return + return; - data = item.data || item.dataset; - action.handler(data, item); + action.handler(item.data || item.dataset, item); return true; }, }; +/* document.addEventListener('DOMContentLoaded', function(event) { var items = document.querySelectorAll('.action[action]'); for(var i = 0; i < items.length; i++) { @@ -72,6 +71,7 @@ document.addEventListener('DOMContentLoaded', function(event) { Actions.init_action(item, action_id, data); } }, false); +*/ /// Small utility used to make XMLHttpRequests, and map results on objects. diff --git a/cms/templates/aircox/cms/list_item.html b/cms/templates/aircox/cms/list_item.html index b541499..a3070f6 100644 --- a/cms/templates/aircox/cms/list_item.html +++ b/cms/templates/aircox/cms/list_item.html @@ -60,10 +60,9 @@ {% if object.actions and 'actions' in list.fields %}
- {% for action_id,action_data in object.actions.items %} - - {% endfor %} + {% for action in object.actions %} + {{ action|safe }} + {% endfor %}
{% endif %} diff --git a/cms/templates/aircox/cms/website.html b/cms/templates/aircox/cms/website.html index 83fae0a..de9b172 100644 --- a/cms/templates/aircox/cms/website.html +++ b/cms/templates/aircox/cms/website.html @@ -14,6 +14,14 @@ {% endif %} + + {% if actions %} + + {% endif %} + + {% if title %}{{ title|striptags }} - {% endif %}{{ website.name }} diff --git a/cms/templatetags/aircox_cms.py b/cms/templatetags/aircox_cms.py index 2e980d1..168e516 100644 --- a/cms/templatetags/aircox_cms.py +++ b/cms/templatetags/aircox_cms.py @@ -2,6 +2,7 @@ from django import template from django.core.urlresolvers import reverse import aircox.cms.utils as utils +import aircox.cms.actions as actions register = template.Library() @@ -40,8 +41,12 @@ def threads(post, sep = '/'): for post in posts[:-1] if post.published ]) + @register.filter(name='around') def around(page_num, n): + """ + Return a range of value around a given number. + """ return range(page_num-n, page_num+n+1) diff --git a/cms/views.py b/cms/views.py index 4b07366..3d1389d 100644 --- a/cms/views.py +++ b/cms/views.py @@ -6,8 +6,9 @@ from django.utils.translation import ugettext as _, ugettext_lazy from django.contrib import messages from django.http import Http404 +from aircox.cms.actions import Actions import aircox.cms.sections as sections -import aircox.cms.sections as sections_ +sections_ = sections # used for name clashes class BaseView: @@ -101,6 +102,7 @@ class BaseView: for k, v in self.menus.items() if v is not self } + context['actions'] = Actions.register_code() context['embed'] = False else: context['embed'] = True @@ -163,7 +165,7 @@ class PostListView(BaseView, ListView): return qs - def init_list(self): + def prepare_list(self): if not self.list: self.list = sections.List( truncate = 32, @@ -180,8 +182,11 @@ class PostListView(BaseView, ListView): if field in self.list.fields ] + # done in list + # Actions.make(self.request, object_list = self.object_list) + def get_context_data(self, **kwargs): - self.init_list() + self.prepare_list() self.add_css_class('list') context = super().get_context_data(**kwargs) diff --git a/notes.md b/notes.md index 33ee713..6647fdf 100644 --- a/notes.md +++ b/notes.md @@ -18,34 +18,33 @@ - config generation and sound diffusion - cms: - - empty content -> empty string + - empty content/list -> nothing - update documentation: - cms.script - cms.exposure; make it right, see nomenclature, + docstring + - cms.actions; - admin cms - - sections: - - article list with the focus - -> set html attribute based on values that are public - website: - render schedule does not get the correct list -> postlistview has not the same queryset as website/sections/schedule - diffusions: - - filter sounds for undiffused diffusions - - print sounds of diffusions - - print program's name in lists + - print program's name in lists / clean up that thing also a bit + - article list with the focus - player: - mixcloud - - seek bar + - seek bar + timer + - remove from playing playlist -> stop - list of played diffusions and tracks when non-stop; -# Later todo +# Long term TODO - sounds monitor: max_size of path, take in account - logs: archive functionnality - track stats for diffusions - debug/prod configuration - player support diffusions with multiple archive files - view as grid +- actions -> noscript case, think of accessibility diff --git a/programs/admin.py b/programs/admin.py index ad4872e..13df2b9 100755 --- a/programs/admin.py +++ b/programs/admin.py @@ -56,10 +56,11 @@ class NameableAdmin(admin.ModelAdmin): @admin.register(Sound) class SoundAdmin(NameableAdmin): fields = None - list_display = ['id', 'name', 'duration', 'type', 'mtime', 'good_quality', 'removed'] + list_display = ['id', 'name', 'duration', 'type', 'mtime', + 'public', 'good_quality', 'removed'] fieldsets = [ (None, { 'fields': NameableAdmin.fields + ['path', 'type', 'diffusion'] } ), - (None, { 'fields': ['embed', 'duration', 'mtime'] }), + (None, { 'fields': ['embed', 'duration', 'public', 'mtime'] }), (None, { 'fields': ['removed', 'good_quality' ] } ) ] readonly_fields = ('path', 'duration',) diff --git a/programs/models.py b/programs/models.py index 3a63485..8a3e0de 100755 --- a/programs/models.py +++ b/programs/models.py @@ -154,6 +154,11 @@ class Sound(Nameable): default = False, help_text = _('sound\'s quality is okay') ) + public = models.BooleanField( + _('public'), + default = False, + help_text = _('the sound is accessible to the public') + ) def get_mtime(self): """ @@ -204,14 +209,40 @@ class Sound(Nameable): return True return old_removed != self.removed - def save(self, check = True, *args, **kwargs): - if check: - self.check_on_file() + def check_perms(self): + """ + Check permissions and update them if this is activated + """ + if not settings.AIRCOX_SOUND_AUTO_CHMOD or \ + self.removed or not os.path.exists(self.path): + return + flags = settings.AIRCOX_SOUND_CHMOD_FLAGS[self.public] + try: + os.chmod(self.path, flags) + except PermissionError as err: + logger.error( + 'cannot set permissions {} to file {}: {}'.format( + self.flags[self.public], + self.path, err + ) + ) + + def __check_name(self): if not self.name and self.path: + # FIXME: later, remove date? self.name = os.path.basename(self.path) self.name = os.path.splitext(self.name)[0] self.name = self.name.replace('_', ' ') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__check_name() + + def save(self, check = True, *args, **kwargs): + if check: + self.check_on_file() + self.__check_name() super().save(*args, **kwargs) def __str__(self): diff --git a/programs/settings.py b/programs/settings.py index c1e7f9a..e892609 100755 --- a/programs/settings.py +++ b/programs/settings.py @@ -1,4 +1,5 @@ import os +import stat from django.conf import settings @@ -18,6 +19,15 @@ ensure('AIRCOX_SOUND_ARCHIVES_SUBDIR', 'archives') # Sub directory used for the excerpts of the episode ensure('AIRCOX_SOUND_EXCERPTS_SUBDIR', 'excerpts') +# Change sound perms based on 'public' attribute if True +ensure('AIRCOX_SOUND_AUTO_CHMOD', True) +# Chmod bits flags as a tuple for (not public, public). Use os.chmod +# and stat.* +ensure( + 'AIRCOX_SOUND_CHMOD_FLAGS', + (stat.S_IRWXU, stat.S_IRWXU | stat.S_IRWXG | stat.S_IROTH ) +) + # Quality attributes passed to sound_quality_check from sounds_monitor ensure('AIRCOX_SOUND_QUALITY', { 'attribute': 'RMS lev dB', diff --git a/website/actions.py b/website/actions.py new file mode 100644 index 0000000..111a822 --- /dev/null +++ b/website/actions.py @@ -0,0 +1,75 @@ +from django.utils import timezone as tz +from django.utils.translation import ugettext as _, ugettext_lazy + +from aircox.cms.actions import Action +import aircox.website.utils as utils + +class AddToPlaylist(Action): + """ + Remember a sound and add it into the default playlist. The given + object can be: + - a Diffusion post + - a programs.Sound instance + - an object with an attribute 'sound' used to generate the code + """ + id = 'sound.add' + symbol = '☰' + title = _('add to the playlist') + code = """ + function(sound, item) { + Player.playlist.add(sound); + item.parentNode.removeChild(item) + } + """ + + @classmethod + def make_for_diffusions(cl, request, object): + from aircox.website.sections import Player + if object.related.end > tz.make_aware(tz.datetime.now()): + return + + archives = object.related.get_archives() + if not archives: + return False + + sound = Player.make_sound(object, archives[0]) + return cl.to_str(object, **sound) + + @classmethod + def make_for_sound(cl, request, object): + from aircox.website.sections import Player + sound = Player.make_sound(None, object) + return cl.to_str(object, **sound) + + @classmethod + def test(cl, request, object, in_list): + from aircox.programs.models import Sound + from aircox.website.models import Diffusion + + print(object) + if not in_list: + return False + + if issubclass(type(object), Diffusion): + return cl.make_for_diffusions(request, object) + if issubclass(type(object), Sound): + return cl.make_for_sound(request, object) + if hasattr(object, 'sound') and object.sound: + return cl.make_for_sound(request, object.sound) + +class Play(AddToPlaylist): + """ + Play a sound + """ + id = 'sound.play' + symbol = '▶' + title = _('listen') + code = """ + function(sound) { + sound = Player.playlist.add(sound); + Player.select_playlist(Player.playlist); + Player.select(sound, true); + } + """ + + diff --git a/website/admin.py b/website/admin.py index 34cbc9c..71d03d6 100644 --- a/website/admin.py +++ b/website/admin.py @@ -21,5 +21,4 @@ admin.site.register(models.Diffusion, cms.RelatedPostAdmin) cms.inject_inline(programs.Diffusion, TrackInline, True) cms.inject_related_inline(models.Program, True) cms.inject_related_inline(models.Diffusion, True) -cms.inject_related_inline(models.Sound, True) diff --git a/website/models.py b/website/models.py index e9ae427..1f3ab82 100644 --- a/website/models.py +++ b/website/models.py @@ -9,6 +9,7 @@ from django.utils.translation import ugettext as _, ugettext_lazy import aircox.programs.models as programs import aircox.cms.models as cms +import aircox.website.actions as actions class Article (cms.Post): @@ -50,6 +51,8 @@ class Program (cms.RelatedPost): class Diffusion (cms.RelatedPost): + actions = [actions.Play, actions.AddToPlaylist] + class Relation: model = programs.Diffusion bindings = { @@ -78,53 +81,3 @@ class Diffusion (cms.RelatedPost): 'day': self.related.initial.start.strftime('%A %d/%m') } - -class Sound (cms.RelatedPost): - """ - Publication concerning sound. In order to manage access of sound - files in the filesystem, we use permissions -- it is up to the - user to work select the correct groups and permissions. - """ - embed = models.TextField( - _('embedding code'), - blank=True, null=True, - help_text = _('HTML code used to embed a sound from an external ' - 'plateform'), - ) - """ - Embedding code if the file has been published on an external - plateform. - """ - - auto_chmod = True - """ - change file permission depending on the "published" attribute. - """ - chmod_flags = (stat.S_IRWXU, stat.S_IRWXU | stat.S_IRWXG | stat.S_IROTH ) - """ - chmod bit flags, for (not_published, published) - """ - class Relation: - model = programs.Sound - bindings = { - 'title': 'name', - 'date': 'mtime', - } - rel_to_post = True - - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - if self.auto_chmod and not self.related.removed and \ - os.path.exists(self.related.path): - try: - os.chmod(self.related.path, - self.chmod_flags[self.published]) - except PermissionError as err: - logger.error( - 'cannot set permission {} to file {}: {}'.format( - self.chmod_flags[self.published], - self.related.path, err - ) - ) - - diff --git a/website/sections.py b/website/sections.py index e09629d..e178ba9 100644 --- a/website/sections.py +++ b/website/sections.py @@ -7,12 +7,16 @@ import aircox.programs.models as programs import aircox.cms.models as cms import aircox.cms.routes as routes import aircox.cms.sections as sections -import aircox.cms.decorators as decorators + +from aircox.cms.exposures import expose +from aircox.cms.actions import Action import aircox.website.models as models +import aircox.website.actions as actions +import aircox.website.utils as utils -@decorators.expose +@expose class Player(sections.Section): """ Display a player that is cool. @@ -24,7 +28,7 @@ class Player(sections.Section): """ #default_sounds - @decorators.expose + @expose def on_air(cl, request): qs = programs.Diffusion.get( now = True, @@ -46,17 +50,56 @@ class Player(sections.Section): 'item': post, 'list': sections.List, } + on_air._exposure.template_name = 'aircox/cms/list_item.html' + @staticmethod + def make_sound(post = None, sound = None): + """ + Return a standard item from a sound that can be used as a + player's item + """ + r = { + 'title': post.title if post else sound.name, + 'url': post.url() if post else None, + 'info': utils.duration_to_str(sound.duration), + } + if sound.embed: + r['embed'] = sound.embed + else: + r['stream'] = sound.url() + return r + + @classmethod + def get_recents(cl, count): + """ + Return a list of count recent published diffusions that have sounds, + as item usable in the playlist. + """ + qs = models.Diffusion.objects \ + .filter(published = True) \ + .filter(related__end__lte = tz.datetime.now()) \ + .order_by('-related__end') + + recents = [] + for post in qs: + archives = post.related.get_archives() + if not archives: + continue + + archives = archives[0] + recents.append(cl.make_sound(post, archives)) + if len(recents) >= count: + break + return recents def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) + context.update({ 'base_template': 'aircox/cms/section.html', 'live_streams': self.live_streams, - 'last_sounds': models.Sound.objects. \ - filter(published = True). \ - order_by('-pk')[:10], + 'recents': self.get_recents(10), }) return context @@ -99,7 +142,7 @@ class Diffusions(sections.List): # .order_by('-start')[:self.prev_count]) #return r - def prepare_object_list(self, object_list): + def prepare_list(self, object_list): """ This function just prepare the list of object, in order to: - have a good title @@ -115,17 +158,6 @@ class Diffusions(sections.List): post.title = ': ' + post.title if post.title else \ ' // ' + post.related.start.strftime('%A %d %B') post.title = name + post.title - - # sounds - pl = post.related.get_archives() - if pl: - item = { 'title': post.title, 'stream': pl[0].url, - 'url': post.url() } - post.actions = { - 'sound.play': item, - 'sound.mark': item, - } - return object_list def get_object_list(self): @@ -205,7 +237,24 @@ class Playlist(sections.List): class Sounds(sections.List): - pass + title = _('Podcasts') + + def get_object_list(self): + if self.object.related.end > tz.make_aware(tz.datetime.now()): + return + + sounds = programs.Sound.objects.filter( + diffusion = self.object.related + # public = True + ).order_by('type') + return [ + sections.ListItem( + title=sound.name, + info=utils.duration_to_str(sound.duration), + sound = sound, + actions = [ actions.AddToPlaylist, actions.Play ], + ) for sound in sounds + ] class Schedule(Diffusions): diff --git a/website/templates/aircox/website/player.html b/website/templates/aircox/website/player.html index 906a51d..c7d6a71 100644 --- a/website/templates/aircox/website/player.html +++ b/website/templates/aircox/website/player.html @@ -102,7 +102,7 @@ .playlist .actions label, #playlist-live .actions, #playlist-recents .actions a.action[action="remove"], - #playlist-marked .actions a.action[action="sound.mark"], + #playlist-favorites .actions a.action[action="sound.mark"], .playlist .actions a.action[action="sound.play"], .playlist .actions a.url:not([href]), .playlist .actions a.url[href=""] { @@ -118,8 +118,11 @@

+ - +
@@ -130,7 +133,7 @@ Your browser does not support the audio element. -

@@ -149,7 +152,7 @@
{% endblock %} diff --git a/website/utils.py b/website/utils.py new file mode 100644 index 0000000..2553fc1 --- /dev/null +++ b/website/utils.py @@ -0,0 +1,6 @@ + +def duration_to_str(duration): + return duration.strftime( + '%H:%M:%S' if duration.hour else '%M:%S' + ) +