integrate qcombine with routes; default routes in website; search in tags too; qcombine fixes (search, model); website's Publications model

This commit is contained in:
bkfox 2016-06-21 20:35:21 +02:00
parent 7ba887b3cd
commit 47991dfa3d
10 changed files with 226 additions and 127 deletions

View File

@ -5,7 +5,6 @@ from django.contrib.contenttypes.models import ContentType
from django.utils import timezone from django.utils import timezone
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import ugettext as _, ugettext_lazy 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.db.models.signals import Signal, post_save, pre_save
from django.dispatch import receiver from django.dispatch import receiver
@ -38,11 +37,12 @@ class Routable:
) )
@classmethod @classmethod
def route_url(cl, route, **kwargs): def reverse(cl, route, use_default = True, **kwargs):
name = cl._website.name_of_model(cl) """
name = route.get_view_name(name) Reverse a url using a given route for the model - simple wrapper
r = reverse(name, kwargs = kwargs) around cl._website.reverse
return r """
return cl._website.reverse(cl, route, use_default, **kwargs)
class Comment(models.Model, Routable): class Comment(models.Model, Routable):
@ -154,7 +154,10 @@ class Post (models.Model, Routable):
blank = True, 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): def get_comments(self):
""" """
@ -171,12 +174,33 @@ class Post (models.Model, Routable):
""" """
Return an url to the post detail view. Return an url to the post detail view.
""" """
return self.route_url( return self.reverse(
routes.DetailRoute, routes.DetailRoute,
pk = self.pk, slug = slugify(self.title) 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): def get_object_list(self, request, object, **kwargs):
# FIXME: wtf
type = ContentType.objects.get_for_model(object) type = ContentType.objects.get_for_model(object)
qs = Comment.objects.filter( qs = Comment.objects.filter(
thread_id = object.pk, thread_id = object.pk,
@ -185,6 +209,9 @@ class Post (models.Model, Routable):
return qs return qs
def make_safe(self): def make_safe(self):
"""
Ensure that data of the publication are safe from code injection.
"""
self.title = bleach.clean( self.title = bleach.clean(
self.title, self.title,
tags=settings.AIRCOX_CMS_BLEACH_TITLE_TAGS, tags=settings.AIRCOX_CMS_BLEACH_TITLE_TAGS,
@ -293,6 +320,7 @@ class RelatedMeta (models.base.ModelBase):
else: else:
return return
post.rel_to_post() post.rel_to_post()
post.fill_empty()
post.save(avoid_sync = True) post.save(avoid_sync = True)
post_save.connect(handler_rel, model._relation.model, False) post_save.connect(handler_rel, model._relation.model, False)

View File

@ -2,8 +2,12 @@ import operator
import itertools import itertools
import heapq import heapq
from django.utils.translation import ugettext as _, ugettext_lazy
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from aircox.cms.models import Routable
class QCombine: class QCombine:
""" """
This class helps to combine querysets of different models and lists of 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 Map results of qs_func for QuerySet instance and of non_qs for
the others (if given), because QuerySet always clones itself. the others (if given), because QuerySet always clones itself.
""" """
for i, qs in self.lists: for i, qs in enumerate(self.lists):
if issubclass(type(qs, QuerySet): if issubclass(type(qs), QuerySet):
self.lists[i] = func(qs) self.lists[i] = qs_func(qs)
elif non_qs: elif non_qs:
self.lists[i] = non_qs(qs) self.lists[i] = non_qs(qs)
@ -47,7 +51,7 @@ class QCombine:
return self return self
def distinct(self, **kwargs): def distinct(self, **kwargs):
self.map(qs.distinct()) self.map(lambda qs: qs.distinct())
return self return self
def get(self, **kwargs): def get(self, **kwargs):
@ -68,13 +72,12 @@ class QCombine:
self.order_reverse = reverse self.order_reverse = reverse
self.order_fields = fields self.order_fields = fields
self.map( self.map(
lambda qs: qs.order_by(*fields), lambda qs: qs.order_by(*fields),
lambda qs: sorted( lambda qs: sorted(
qs, qs,
qs.sort( qs.sort(
key = operator.attrgetter(fields), key = operator.attrgetter(*fields),
reverse = reverse reverse = reverse
) )
) )
@ -112,7 +115,18 @@ class QCombine:
return list(it) 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. 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 A QCombine is created with qs for all given models when objects
@ -120,21 +134,12 @@ class QCombined:
Note: there no other use-case. Note: there no other use-case.
""" """
def __init__(*models):
self.models = models
self._meta = self.Meta()
class Meta: class Meta:
verbose_name = _('publication') verbose_name = _('publication')
verbose_name_plural = _('publications') verbose_name_plural = _('publications')
@property _meta = Meta()
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
def __init__(self, **kwargs):
self.__dict__.update(kwargs)

View File

@ -5,6 +5,9 @@ from django.contrib.contenttypes.models import ContentType
from django.utils import timezone from django.utils import timezone
from django.utils.translation import ugettext as _, ugettext_lazy from django.utils.translation import ugettext as _, ugettext_lazy
import aircox.cms.qcombine as qcombine
class Route: class Route:
""" """
Base class for routing. Given a model, we generate url specific for each Base class for routing. Given a model, we generate url specific for each
@ -41,7 +44,7 @@ class Route:
return '' return ''
@classmethod @classmethod
def get_view_name(cl, name): def make_view_name(cl, name):
return name + '.' + cl.name return name + '.' + cl.name
@classmethod @classmethod
@ -65,7 +68,7 @@ class Route:
kwargs.update(view_kwargs) kwargs.update(view_kwargs)
return url(pattern, view, kwargs = kwargs, return url(pattern, view, kwargs = kwargs,
name = cl.get_view_name(name)) name = cl.make_view_name(name))
class DetailRoute(Route): class DetailRoute(Route):
@ -174,21 +177,28 @@ class SearchRoute(Route):
name = 'search' name = 'search'
@classmethod @classmethod
def get_queryset(cl, model, request, **kwargs): def __search(cl, model, q):
q = request.GET.get('q') or ''
qs = None qs = None
## TODO: by tag
for search_field in model.search_fields or []: for search_field in model.search_fields or []:
r = models.Q(**{ search_field + '__icontains': q }) r = models.Q(**{ search_field + '__icontains': q })
if qs: qs = qs | r if qs: qs = qs | r
else: qs = r else: qs = r
return model.objects.filter(qs).distinct() 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 @classmethod
def get_title(cl, model, request, **kwargs): def get_title(cl, model, request, **kwargs):
return _('Search "%(search)s" in %(model)s') % { return _('Search <i>%(search)s</i> in %(model)s') % {
'model': model._meta.verbose_name_plural, 'model': model._meta.verbose_name_plural,
'search': request.GET.get('q') or '', 'search': request.GET.get('q') or '',
} }
@ -207,14 +217,14 @@ class TagsRoute(Route):
@classmethod @classmethod
def get_queryset(cl, model, request, tags, **kwargs): def get_queryset(cl, model, request, tags, **kwargs):
tags = tags.split('+') tags = tags.split('+')
return model.objects.filter(tags__name__in=tags) return model.objects.filter(tags__slug__in=tags).distinct()
@classmethod @classmethod
def get_title(cl, model, request, tags, **kwargs): 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, 'model': model._meta.verbose_name_plural,
'tags': tags.replace('+', ', ') 'tags': model.tags_to_html(model, tags = tags.split('+'))
if '+' in tags else tags
} }

View File

@ -2,6 +2,7 @@
Define different Section css_class that can be used by views.Sections; Define different Section css_class that can be used by views.Sections;
""" """
import re import re
from random import shuffle
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.views.generic.base import View 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 List of models allowed in the resulting list. If not set, all models
are available. 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): def get_object_list(self):
if not self.object: if not self.object:
return return
qs = self.object.tags.similar_objects() qs = self.object.tags.similar_objects()
qs.sort(key = lambda post: post.date, reverse=True) qs.sort(key = lambda post: post.date, reverse=True)
if self.shuffle:
qs = qs[:self.shuffle]
shuffle(qs)
return qs return qs
@ -371,11 +381,9 @@ class Comments(List):
import aircox.cms.models as models import aircox.cms.models as models
import aircox.cms.routes as routes import aircox.cms.routes as routes
if self.object: if self.object:
return models.Comment.route_url(routes.ThreadRoute, { return models.Comment.reverse(routes.ThreadRoute, {
'pk': self.object.id, 'pk': self.object.id,
'thread_model': self.object._website.name_of_model( 'thread_model': self.object._registration.name
self.object.__class__
),
}) })
return '' return ''

View File

@ -1,27 +1,16 @@
from django import template from django import template
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
import aircox.cms.routes as routes import aircox.cms.utils as utils
register = template.Library() register = template.Library()
@register.filter(name='post_tags') @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() return utils.tags_to_html(type(post), post.tags.all(), sep)
r = []
for tag in tags:
try:
r.append('<a href="{url}">{name}</a>'.format(
url = post.route_url(routes.TagsRoute, tags = tag),
name = tag,
))
except:
r.push(tag)
return sep.join(r)
@register.filter(name='threads') @register.filter(name='threads')
@ -37,7 +26,7 @@ def threads(post, sep = '/'):
return sep.join([ return sep.join([
'<a href="{}">{}</a>'.format(post.url(), post.title) '<a href="{}">{}</a>'.format(post.url(), post.title)
for post in posts if post.published for post in posts[:-1] if post.published
]) ])
@register.filter(name='around') @register.filter(name='around')

View File

@ -1,4 +1,7 @@
from collections import namedtuple
from django.utils.text import slugify from django.utils.text import slugify
from django.core.urlresolvers import reverse
from django.conf.urls import include, url from django.conf.urls import include, url
import aircox.cms.routes as routes import aircox.cms.routes as routes
@ -33,6 +36,10 @@ class Website:
"""register list routes for the Comment model""" """register list routes for the Comment model"""
## components ## components
Registration = namedtuple('Registration',
'name model routes as_default'
)
urls = [] urls = []
"""list of urls generated thourgh registrations""" """list of urls generated thourgh registrations"""
exposures = [] exposures = []
@ -57,44 +64,26 @@ class Website:
if self.comments_routes: if self.comments_routes:
self.register_comments() 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. Register a model and update model's fields with few data:
""" - _website: back ref to self
for name, _model in self.registry.items(): - _registration: ref to the registration object
if model is _model:
return name
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. Raise a ValueError if another model is yet associated under this name.
""" """
if name in self.registry: if name in self.registry:
if self.registry[name] is model: reg = self.registry[name]
return name if reg.model is model:
return reg
raise ValueError('A model has yet been registered under "{}"' raise ValueError('A model has yet been registered under "{}"'
.format(name)) .format(name))
self.registry[name] = model
reg = self.Registration(name, model, [], as_default)
self.registry[name] = reg
model._registration = reg
model._website = self model._website = self
return name return reg
def register_exposures(self, sections): def register_exposures(self, sections):
""" """
@ -112,7 +101,8 @@ class Website:
] ]
def register(self, name, routes = [], view = views.PageView, 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 a view using given name and routes. If model is given,
register the views for it. 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 * 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. * 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. 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: if model:
name = self.__register_model(name, model) reg = self.register_model(name, model, as_default)
reg.routes.extend(routes)
view_kwargs['model'] = model view_kwargs['model'] = model
# init view
if not view_kwargs.get('menus'): if not view_kwargs.get('menus'):
view_kwargs['menus'] = self.menus view_kwargs['menus'] = self.menus
@ -137,9 +137,7 @@ class Website:
**view_kwargs **view_kwargs
) )
if type(routes) not in (tuple, list): # url gen
routes = [ routes ]
self.urls += [ self.urls += [
route.as_url(name, view) route.as_url(name, view)
if type(route) == type and issubclass(route, routes_.Route) if type(route) == type and issubclass(route, routes_.Route)
@ -148,24 +146,51 @@ class Website:
for route in routes for route in routes
] ]
def register_post(self, name, model, sections = None, routes = None, def register_dl(self, name, model, sections = None, routes = None,
list_view = views.PostListView, list_view = views.PostListView,
detail_view = views.PostDetailView, detail_view = views.PostDetailView,
list_kwargs = {}, detail_kwargs = {}): list_kwargs = {}, detail_kwargs = {},
as_default = False):
""" """
Register a detail and list view for a given model, using 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: if sections:
self.register(name, [ routes_.DetailRoute ], view = detail_view, 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: if routes:
self.register(name, routes, view = list_view, 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): 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'): if menu.position in ('footer','header'):
menu.tag = menu.position menu.tag = menu.position
@ -174,10 +199,40 @@ class Website:
self.menus[menu.position] = menu self.menus[menu.position] = menu
self.register_exposures(menu.sections) 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 ''

View File

@ -21,11 +21,12 @@
- empty content -> empty string - empty content -> empty string
- update documentation: - update documentation:
- cms.views - cms.views
- cms.parts - cms.exposure
- cms.script - cms.script
- cms.qcombine - cms.qcombine
- routes - routes
- integrate QCombine - tag name instead of tag slug for the title
- optional url args
- admin cms - admin cms
- content management -> do we use a markup language? - content management -> do we use a markup language?
- sections: - sections:

View File

@ -7,11 +7,12 @@ logger = logging.getLogger('aircox')
from django.db import models from django.db import models
from django.utils.translation import ugettext as _, ugettext_lazy from django.utils.translation import ugettext as _, ugettext_lazy
from aircox.cms.models import Post, RelatedPost
import aircox.programs.models as programs 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. Represent an article or a static page on the website.
""" """
@ -29,7 +30,7 @@ class Article (Post):
verbose_name_plural = _('Articles') verbose_name_plural = _('Articles')
class Program (RelatedPost): class Program (cms.RelatedPost):
website = models.URLField( website = models.URLField(
_('website'), _('website'),
blank=True, null=True blank=True, null=True
@ -49,7 +50,7 @@ class Program (RelatedPost):
auto_create = True auto_create = True
class Diffusion (RelatedPost): class Diffusion (cms.RelatedPost):
class Relation: class Relation:
model = programs.Diffusion model = programs.Diffusion
bindings = { bindings = {
@ -68,18 +69,7 @@ class Diffusion (RelatedPost):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.thread: self.fill_empty()
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
@property @property
def info(self): 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 Publication concerning sound. In order to manage access of sound
files in the filesystem, we use permissions -- it is up to the 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 ]

View File

@ -110,7 +110,9 @@ class Diffusions(sections.List):
continue continue
name = post.related.program.name name = post.related.program.name
if name not in post.title: 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 return object_list
def get_object_list(self): def get_object_list(self):

View File

@ -524,8 +524,10 @@ player = {
/// Select the next track in the current playlist, eventually play it /// Select the next track in the current playlist, eventually play it
next: function(play = true) { next: function(play = true) {
var playlist = this.playlist; var playlist = this.playlist;
if(playlist == this.live)
return
var index = this.playlist.items.indexOf(this.item); var index = this.playlist.items.indexOf(this.item);
console.log(index, this.item, this.playlist.items)
if(index == -1) if(index == -1)
return; return;