diff --git a/cms/models.py b/cms/models.py index a90c59f..76c5358 100644 --- a/cms/models.py +++ b/cms/models.py @@ -5,7 +5,6 @@ from django.contrib.contenttypes.models import ContentType from django.utils import timezone from django.utils.text import slugify from django.utils.translation import ugettext as _, ugettext_lazy -from django.core.urlresolvers import reverse from django.db.models.signals import Signal, post_save, pre_save from django.dispatch import receiver @@ -38,11 +37,12 @@ class Routable: ) @classmethod - def route_url(cl, route, **kwargs): - name = cl._website.name_of_model(cl) - name = route.get_view_name(name) - r = reverse(name, kwargs = kwargs) - return r + def reverse(cl, route, use_default = True, **kwargs): + """ + Reverse a url using a given route for the model - simple wrapper + around cl._website.reverse + """ + return cl._website.reverse(cl, route, use_default, **kwargs) class Comment(models.Model, Routable): @@ -154,7 +154,10 @@ class Post (models.Model, Routable): blank = True, ) - search_fields = [ 'title', 'content' ] + search_fields = [ 'title', 'content', 'tags__name' ] + """ + Fields on which routes.SearchRoute must run the search + """ def get_comments(self): """ @@ -171,12 +174,33 @@ class Post (models.Model, Routable): """ Return an url to the post detail view. """ - return self.route_url( + return self.reverse( routes.DetailRoute, pk = self.pk, slug = slugify(self.title) ) + def fill_empty(self): + """ + Fill empty values using parent thread. Can be used before saving or + at loading + """ + if not self.thread: + return + + if not self.title: + self.title = _('{name} // {date}').format( + name = self.thread.title, + date = self.date + ) + if not self.content: + self.content = self.thread.content + if not self.image: + self.image = self.thread.image + if not self.tags and self.pk: + self.tags = self.thread.tags + def get_object_list(self, request, object, **kwargs): + # FIXME: wtf type = ContentType.objects.get_for_model(object) qs = Comment.objects.filter( thread_id = object.pk, @@ -185,6 +209,9 @@ class Post (models.Model, Routable): return qs def make_safe(self): + """ + Ensure that data of the publication are safe from code injection. + """ self.title = bleach.clean( self.title, tags=settings.AIRCOX_CMS_BLEACH_TITLE_TAGS, @@ -293,6 +320,7 @@ class RelatedMeta (models.base.ModelBase): else: return post.rel_to_post() + post.fill_empty() post.save(avoid_sync = True) post_save.connect(handler_rel, model._relation.model, False) diff --git a/cms/qcombine.py b/cms/qcombine.py index 9e4a227..8409116 100644 --- a/cms/qcombine.py +++ b/cms/qcombine.py @@ -2,8 +2,12 @@ import operator import itertools import heapq +from django.utils.translation import ugettext as _, ugettext_lazy from django.db.models.query import QuerySet +from aircox.cms.models import Routable + + class QCombine: """ This class helps to combine querysets of different models and lists of @@ -29,9 +33,9 @@ class QCombine: Map results of qs_func for QuerySet instance and of non_qs for the others (if given), because QuerySet always clones itself. """ - for i, qs in self.lists: - if issubclass(type(qs, QuerySet): - self.lists[i] = func(qs) + for i, qs in enumerate(self.lists): + if issubclass(type(qs), QuerySet): + self.lists[i] = qs_func(qs) elif non_qs: self.lists[i] = non_qs(qs) @@ -47,7 +51,7 @@ class QCombine: return self def distinct(self, **kwargs): - self.map(qs.distinct()) + self.map(lambda qs: qs.distinct()) return self def get(self, **kwargs): @@ -68,13 +72,12 @@ class QCombine: self.order_reverse = reverse self.order_fields = fields - self.map( lambda qs: qs.order_by(*fields), lambda qs: sorted( qs, qs.sort( - key = operator.attrgetter(fields), + key = operator.attrgetter(*fields), reverse = reverse ) ) @@ -112,7 +115,18 @@ class QCombine: return list(it) -class QCombined: + + +class Manager(type): + models = [] + + @property + def objects(self): + qs = QCombine(*[model.objects.all() for model in self.models]) + return qs + + +class FakeModel(Routable,metaclass=Manager): """ This class is used to register a route for multiple models to a website. A QCombine is created with qs for all given models when objects @@ -120,21 +134,12 @@ class QCombined: Note: there no other use-case. """ - def __init__(*models): - self.models = models - self._meta = self.Meta() - class Meta: verbose_name = _('publication') verbose_name_plural = _('publications') - @property - def objects(self): - """ - The QCombine that is returned actually holds the models' managers, - in order to simulate the same behaviour than a regular model. - """ - qs = QCombine([model.objects for model in self.models]) - return qs + _meta = Meta() + def __init__(self, **kwargs): + self.__dict__.update(kwargs) diff --git a/cms/routes.py b/cms/routes.py index b213241..3c262db 100644 --- a/cms/routes.py +++ b/cms/routes.py @@ -5,6 +5,9 @@ from django.contrib.contenttypes.models import ContentType from django.utils import timezone from django.utils.translation import ugettext as _, ugettext_lazy +import aircox.cms.qcombine as qcombine + + class Route: """ Base class for routing. Given a model, we generate url specific for each @@ -41,7 +44,7 @@ class Route: return '' @classmethod - def get_view_name(cl, name): + def make_view_name(cl, name): return name + '.' + cl.name @classmethod @@ -65,7 +68,7 @@ class Route: kwargs.update(view_kwargs) return url(pattern, view, kwargs = kwargs, - name = cl.get_view_name(name)) + name = cl.make_view_name(name)) class DetailRoute(Route): @@ -174,21 +177,28 @@ class SearchRoute(Route): name = 'search' @classmethod - def get_queryset(cl, model, request, **kwargs): - q = request.GET.get('q') or '' + def __search(cl, model, q): qs = None - - ## TODO: by tag for search_field in model.search_fields or []: r = models.Q(**{ search_field + '__icontains': q }) if qs: qs = qs | r else: qs = r - return model.objects.filter(qs).distinct() + + @classmethod + def get_queryset(cl, model, request, **kwargs): + q = request.GET.get('q') or '' + if issubclass(model, qcombine.FakeModel): + models = model.models + return qcombine.QCombine( + *(cl.__search(model, q) for model in models) + ) + return cl.__search(model, q) + @classmethod def get_title(cl, model, request, **kwargs): - return _('Search "%(search)s" in %(model)s') % { + return _('Search %(search)s in %(model)s') % { 'model': model._meta.verbose_name_plural, 'search': request.GET.get('q') or '', } @@ -207,14 +217,14 @@ class TagsRoute(Route): @classmethod def get_queryset(cl, model, request, tags, **kwargs): tags = tags.split('+') - return model.objects.filter(tags__name__in=tags) + return model.objects.filter(tags__slug__in=tags).distinct() @classmethod def get_title(cl, model, request, tags, **kwargs): - return _('Tagged %(model)s with %(tags)s') % { + # FIXME: get tag name instead of tag slug + return _('%(model)s tagged with %(tags)s') % { 'model': model._meta.verbose_name_plural, - 'tags': tags.replace('+', ', ') + 'tags': model.tags_to_html(model, tags = tags.split('+')) + if '+' in tags else tags } - - diff --git a/cms/sections.py b/cms/sections.py index 3cd06b9..05aeff7 100644 --- a/cms/sections.py +++ b/cms/sections.py @@ -2,6 +2,7 @@ Define different Section css_class that can be used by views.Sections; """ import re +from random import shuffle from django.template.loader import render_to_string from django.views.generic.base import View @@ -329,13 +330,22 @@ class Similar(List): List of models allowed in the resulting list. If not set, all models are available. """ + shuffle = 20 + """ + Shuffle results in the self.shuffle most recents articles. If 0 or + None, do not shuffle. + """ + # FIXME: limit in a date range def get_object_list(self): if not self.object: return qs = self.object.tags.similar_objects() qs.sort(key = lambda post: post.date, reverse=True) + if self.shuffle: + qs = qs[:self.shuffle] + shuffle(qs) return qs @@ -371,11 +381,9 @@ class Comments(List): import aircox.cms.models as models import aircox.cms.routes as routes if self.object: - return models.Comment.route_url(routes.ThreadRoute, { + return models.Comment.reverse(routes.ThreadRoute, { 'pk': self.object.id, - 'thread_model': self.object._website.name_of_model( - self.object.__class__ - ), + 'thread_model': self.object._registration.name }) return '' diff --git a/cms/templatetags/aircox_cms.py b/cms/templatetags/aircox_cms.py index d5975a6..aaf847b 100644 --- a/cms/templatetags/aircox_cms.py +++ b/cms/templatetags/aircox_cms.py @@ -1,27 +1,16 @@ from django import template from django.core.urlresolvers import reverse -import aircox.cms.routes as routes - +import aircox.cms.utils as utils register = template.Library() @register.filter(name='post_tags') -def post_tags(post, sep = '-'): +def post_tags(post, sep = ' - '): """ - print the list of all the tags of the given post, with url if available + return the result of post.tags_url """ - tags = post.tags.all() - r = [] - for tag in tags: - try: - r.append('{name}'.format( - url = post.route_url(routes.TagsRoute, tags = tag), - name = tag, - )) - except: - r.push(tag) - return sep.join(r) + return utils.tags_to_html(type(post), post.tags.all(), sep) @register.filter(name='threads') @@ -37,7 +26,7 @@ def threads(post, sep = '/'): return sep.join([ '{}'.format(post.url(), post.title) - for post in posts if post.published + for post in posts[:-1] if post.published ]) @register.filter(name='around') diff --git a/cms/website.py b/cms/website.py index 2565cc9..20ff557 100644 --- a/cms/website.py +++ b/cms/website.py @@ -1,4 +1,7 @@ +from collections import namedtuple + from django.utils.text import slugify +from django.core.urlresolvers import reverse from django.conf.urls import include, url import aircox.cms.routes as routes @@ -33,6 +36,10 @@ class Website: """register list routes for the Comment model""" ## components + Registration = namedtuple('Registration', + 'name model routes as_default' + ) + urls = [] """list of urls generated thourgh registrations""" exposures = [] @@ -57,44 +64,26 @@ class Website: if self.comments_routes: self.register_comments() - def name_of_model(self, model): + def register_model(self, name, model, as_default): """ - Return the registered name for a given model if found. - """ - for name, _model in self.registry.items(): - if model is _model: - return name + Register a model and update model's fields with few data: + - _website: back ref to self + - _registration: ref to the registration object - def register_comments(self): - """ - Register routes for comments, for the moment, only - ThreadRoute - """ - self.register( - 'comment', - view = views.PostListView, - routes = [routes.ThreadRoute], - model = models.Comment, - css_class = 'comments', - list = sections.Comments( - truncate = 30, - fields = ['content','author','date','time'], - ) - ) - - def __register_model(self, name, model): - """ - Register a model and return the name under which it is registered. Raise a ValueError if another model is yet associated under this name. """ if name in self.registry: - if self.registry[name] is model: - return name + reg = self.registry[name] + if reg.model is model: + return reg raise ValueError('A model has yet been registered under "{}"' .format(name)) - self.registry[name] = model + + reg = self.Registration(name, model, [], as_default) + self.registry[name] = reg + model._registration = reg model._website = self - return name + return reg def register_exposures(self, sections): """ @@ -112,7 +101,8 @@ class Website: ] def register(self, name, routes = [], view = views.PageView, - model = None, sections = None, **view_kwargs): + model = None, sections = None, + as_default = False, **view_kwargs): """ Register a view using given name and routes. If model is given, register the views for it. @@ -120,11 +110,21 @@ class Website: * name is used to register the routes as urls and the model if given * routes: can be a path or a route used to generate urls for the view. Can be a one item or a list of items. + * view: route that is registered for the given routes + * model: model being registrated. If given, register it in the website + under the given name, and make it available to the view. + * as_default: make the view available as a default view. """ + if type(routes) not in (tuple, list): + routes = [ routes ] + + # model registration if model: - name = self.__register_model(name, model) + reg = self.register_model(name, model, as_default) + reg.routes.extend(routes) view_kwargs['model'] = model + # init view if not view_kwargs.get('menus'): view_kwargs['menus'] = self.menus @@ -137,9 +137,7 @@ class Website: **view_kwargs ) - if type(routes) not in (tuple, list): - routes = [ routes ] - + # url gen self.urls += [ route.as_url(name, view) if type(route) == type and issubclass(route, routes_.Route) @@ -148,24 +146,51 @@ class Website: for route in routes ] - def register_post(self, name, model, sections = None, routes = None, - list_view = views.PostListView, - detail_view = views.PostDetailView, - list_kwargs = {}, detail_kwargs = {}): + def register_dl(self, name, model, sections = None, routes = None, + list_view = views.PostListView, + detail_view = views.PostDetailView, + list_kwargs = {}, detail_kwargs = {}, + as_default = False): """ Register a detail and list view for a given model, using - routes. Just a wrapper around register. + routes. + + Just a wrapper around `register`. """ if sections: self.register(name, [ routes_.DetailRoute ], view = detail_view, - model = model, sections = sections, **detail_kwargs) + model = model, sections = sections, + as_default = as_default, + **detail_kwargs) if routes: self.register(name, routes, view = list_view, - model = model, **list_kwargs) + model = model, as_default = as_default, + **list_kwargs) + + def register_comments(self): + """ + Register routes for comments, for the moment, only + ThreadRoute. + + Just a wrapper around `register`. + """ + self.register( + 'comment', + view = views.PostListView, + routes = [routes.ThreadRoute], + model = models.Comment, + css_class = 'comments', + list = sections.Comments( + truncate = 30, + fields = ['content','author','date','time'], + ) + ) def set_menu(self, menu): """ - Set a menu, and remove any previous menu at the same position + Set a menu, and remove any previous menu at the same position. + Also update the menu's tag depending on its position, in order + to have a semantic HTML5 on the web 2.0 (lol). """ if menu.position in ('footer','header'): menu.tag = menu.position @@ -174,10 +199,40 @@ class Website: self.menus[menu.position] = menu self.register_exposures(menu.sections) - def get_menu(self, position): + def find_default(self, route): """ - Get an enabled menu by its position + Return a registration that can be used as default for the + given route. """ - return self.menus.get(position) + for r in self.registry.values(): + if r.as_default and route in r.routes: + return r + + def reverse(self, model, route, use_default = True, **kwargs): + """ + Reverse a url using the given model and route. If the reverse does + not function and use_default is True, use a model that have been + registered as a default view and that have the given road. + + If no model is given reverse with default. + """ + if model and route in model._registration.routes: + try: + name = route.make_view_name(model._registration.name) + return reverse(name, kwargs = kwargs) + except: + pass + + if model and not use_default: + return '' + + for r in self.registry.values(): + if r.as_default and route in r.routes: + try: + name = route.make_view_name(r.name) + return reverse(name, kwargs = kwargs) + except: + pass + return '' diff --git a/notes.md b/notes.md index 6290fae..2d36b43 100644 --- a/notes.md +++ b/notes.md @@ -21,11 +21,12 @@ - empty content -> empty string - update documentation: - cms.views - - cms.parts + - cms.exposure - cms.script - cms.qcombine - routes - - integrate QCombine + - tag name instead of tag slug for the title + - optional url args - admin cms - content management -> do we use a markup language? - sections: diff --git a/website/models.py b/website/models.py index bfce0f5..02121f3 100644 --- a/website/models.py +++ b/website/models.py @@ -7,11 +7,12 @@ logger = logging.getLogger('aircox') from django.db import models from django.utils.translation import ugettext as _, ugettext_lazy -from aircox.cms.models import Post, RelatedPost import aircox.programs.models as programs +import aircox.cms.models as cms +import aircox.cms.qcombine as qcombine -class Article (Post): +class Article (cms.Post): """ Represent an article or a static page on the website. """ @@ -29,7 +30,7 @@ class Article (Post): verbose_name_plural = _('Articles') -class Program (RelatedPost): +class Program (cms.RelatedPost): website = models.URLField( _('website'), blank=True, null=True @@ -49,7 +50,7 @@ class Program (RelatedPost): auto_create = True -class Diffusion (RelatedPost): +class Diffusion (cms.RelatedPost): class Relation: model = programs.Diffusion bindings = { @@ -68,18 +69,7 @@ class Diffusion (RelatedPost): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if self.thread: - if not self.title: - self.title = _('{name} // {first_diff}').format( - name = self.related.program.name, - first_diff = self.related.start.strftime('%A %d %B') - ) - if not self.content: - self.content = self.thread.content - if not self.image: - self.image = self.thread.image - if not self.tags and self.pk: - self.tags = self.thread.tags + self.fill_empty() @property def info(self): @@ -90,7 +80,7 @@ class Diffusion (RelatedPost): } -class Sound (RelatedPost): +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 @@ -138,3 +128,12 @@ class Sound (RelatedPost): ) ) + +class Publications (qcombine.FakeModel): + """ + Combine views + """ + models = [ Article, Program, Diffusion, Sound ] + + + diff --git a/website/sections.py b/website/sections.py index de38cf6..3aeeb31 100644 --- a/website/sections.py +++ b/website/sections.py @@ -110,7 +110,9 @@ class Diffusions(sections.List): continue name = post.related.program.name if name not in post.title: - post.title = '{}: {}'.format(name, post.title) + post.title = ': ' + post.title if post.title else \ + ' // ' + post.related.start.strftime('%A %d %B') + post.title = name + post.title return object_list def get_object_list(self): diff --git a/website/templates/aircox/website/player.html b/website/templates/aircox/website/player.html index 958aa2d..ce42efe 100644 --- a/website/templates/aircox/website/player.html +++ b/website/templates/aircox/website/player.html @@ -524,8 +524,10 @@ player = { /// Select the next track in the current playlist, eventually play it next: function(play = true) { var playlist = this.playlist; + if(playlist == this.live) + return + var index = this.playlist.items.indexOf(this.item); - console.log(index, this.item, this.playlist.items) if(index == -1) return;