work on player: integrate vuejs + noscript; remove TemplateMixin for Component and ExposedData; rewrite most of the player; clean up files; do lot of other things

This commit is contained in:
bkfox
2018-02-08 06:46:42 +01:00
parent 343279a777
commit d80322dd15
36 changed files with 12711 additions and 1740 deletions

816
aircox_cms/models/__init__.py Executable file
View File

@ -0,0 +1,816 @@
import datetime
from django.db import models
from django.contrib.auth.models import User
from django.contrib import messages
from django.utils import timezone as tz
from django.utils.translation import ugettext as _, ugettext_lazy
# pages and panels
from wagtail.contrib.settings.models import BaseSetting, register_setting
from wagtail.wagtailcore.models import Page, Orderable, \
PageManager, PageQuerySet
from wagtail.wagtailcore.fields import RichTextField
from wagtail.wagtailimages.edit_handlers import ImageChooserPanel
from wagtail.wagtailadmin.edit_handlers import FieldPanel, FieldRowPanel, \
MultiFieldPanel, InlinePanel, PageChooserPanel, StreamFieldPanel
from wagtail.wagtailsearch import index
# snippets
from wagtail.wagtailsnippets.models import register_snippet
# tags
from modelcluster.fields import ParentalKey
from modelcluster.tags import ClusterTaggableManager
from taggit.models import TaggedItemBase
# comment clean-up
import bleach
import aircox.models
import aircox_cms.settings as settings
from aircox_cms.models.lists import *
from aircox_cms.models.sections import *
from aircox_cms.template import TemplateMixin
from aircox_cms.utils import image_url
@register_setting
class WebsiteSettings(BaseSetting):
station = models.OneToOneField(
aircox.models.Station,
verbose_name = _('aircox station'),
related_name = 'website_settings',
unique = True,
blank = True, null = True,
help_text = _(
'refers to an Aircox\'s station; it is used to make the link '
'between the website and Aircox'
),
)
# general website information
favicon = models.ImageField(
verbose_name = _('favicon'),
null=True, blank=True,
help_text = _('small logo for the website displayed in the browser'),
)
tags = models.CharField(
_('tags'),
max_length=256,
null=True, blank=True,
help_text = _('tags describing the website; used for referencing'),
)
description = models.CharField(
_('public description'),
max_length=256,
null=True, blank=True,
help_text = _('public description of the website; used for referencing'),
)
list_page = models.ForeignKey(
'aircox_cms.DynamicListPage',
verbose_name = _('page for lists'),
help_text=_('page used to display the results of a search and other '
'lists'),
related_name= 'list_page',
blank = True, null = True,
)
# comments
accept_comments = models.BooleanField(
default = True,
help_text = _('publish comments automatically without verifying'),
)
allow_comments = models.BooleanField(
default = True,
help_text = _('publish comments automatically without verifying'),
)
comment_success_message = models.TextField(
_('success message'),
default = _('Your comment has been successfully posted!'),
help_text = _(
'message displayed when a comment has been successfully posted'
),
)
comment_wait_message = models.TextField(
_('waiting message'),
default = _('Your comment is awaiting for approval.'),
help_text = _(
'message displayed when a comment has been sent, but waits for '
' website administrators\' approval.'
),
)
comment_error_message = models.TextField(
_('error message'),
default = _('We could not save your message. Please correct the error(s) below.'),
help_text = _(
'message displayed when the form of the comment has been '
' submitted but there is an error, such as an incomplete field'
),
)
sync = models.BooleanField(
_('synchronize with Aircox'),
default = False,
help_text = _(
'create publication for each object added to an Aircox\'s '
'station; for example when there is a new program, or '
'when a diffusion has been added to the timetable. Note: '
'it does not concern the Station themselves.'
# /doc/ the page is saved but not pubished -- this must be
# done manually, when the user edit it.
)
)
default_programs_page = ParentalKey(
Page,
verbose_name = _('default programs page'),
blank = True, null = True,
help_text = _(
'when a new program is saved and a publication is created, '
'put this publication as a child of this page. If no page '
'has been specified, try to put it as the child of the '
'website\'s root page (otherwise, do not create the page).'
# /doc/ (technicians, admin): if the page has not been created,
# it still can be created using the `programs_to_cms` command.
),
limit_choices_to = lambda: {
'show_in_menus': True,
'publication__isnull': False,
},
)
panels = [
MultiFieldPanel([
FieldPanel('favicon'),
FieldPanel('tags'),
FieldPanel('description'),
FieldPanel('list_page'),
], heading=_('Promotion')),
MultiFieldPanel([
FieldPanel('allow_comments'),
FieldPanel('accept_comments'),
FieldPanel('comment_success_message'),
FieldPanel('comment_wait_message'),
FieldPanel('comment_error_message'),
], heading = _('Comments')),
MultiFieldPanel([
FieldPanel('sync'),
FieldPanel('default_programs_page'),
], heading = _('Programs and controls')),
]
class Meta:
verbose_name = _('website settings')
@register_snippet
class Comment(models.Model):
publication = models.ForeignKey(
Page,
verbose_name = _('page')
)
published = models.BooleanField(
verbose_name = _('published'),
default = False
)
author = models.CharField(
verbose_name = _('author'),
max_length = 32,
)
email = models.EmailField(
verbose_name = _('email'),
blank = True, null = True,
)
url = models.URLField(
verbose_name = _('website'),
blank = True, null = True,
)
date = models.DateTimeField(
_('date'),
auto_now_add = True,
)
content = models.TextField (
_('comment'),
)
class Meta:
verbose_name = _('comment')
verbose_name_plural = _('comments')
def __str__(self):
# Translators: text shown in the comments list (in admin)
return _('{date}, {author}: {content}...').format(
author = self.author,
date = self.date.strftime('%d %A %Y, %H:%M'),
content = self.content[:128]
)
def make_safe(self):
self.author = bleach.clean(self.author, tags=[])
if self.email:
self.email = bleach.clean(self.email, tags=[])
self.email = self.email.replace('"', '%22')
if self.url:
self.url = bleach.clean(self.url, tags=[])
self.url = self.url.replace('"', '%22')
self.content = bleach.clean(
self.content,
tags=settings.AIRCOX_CMS_BLEACH_COMMENT_TAGS,
attributes=settings.AIRCOX_CMS_BLEACH_COMMENT_ATTRS
)
def save(self, make_safe = True, *args, **kwargs):
if make_safe:
self.make_safe()
return super().save(*args, **kwargs)
class BasePage(Page):
body = RichTextField(
_('body'),
null = True, blank = True,
help_text = _('the publication itself')
)
cover = models.ForeignKey(
'wagtailimages.Image',
verbose_name = _('cover'),
null=True, blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text = _('image to use as cover of the publication'),
)
allow_comments = models.BooleanField(
_('allow comments'),
default = True,
help_text = _('allow comments')
)
# panels
content_panels = [
MultiFieldPanel([
FieldPanel('title'),
ImageChooserPanel('cover'),
FieldPanel('body', classname='full'),
], heading=_('Content'))
]
settings_panels = Page.settings_panels + [
FieldPanel('allow_comments'),
]
search_fields = [
index.SearchField('title', partial_match=True),
index.SearchField('body', partial_match=True),
index.FilterField('live'),
index.FilterField('show_in_menus'),
]
# properties
@property
def url(self):
if not self.live:
parent = self.get_parent().specific
return parent and parent.url
return super().url
@property
def icon(self):
return image_url(self.cover, 'fill-64x64')
@property
def small_icon(self):
return image_url(self.cover, 'fill-32x32')
@property
def comments(self):
return Comment.objects.filter(
publication = self,
published = True,
).order_by('-date')
# methods
def get_list_page(self):
"""
Return the page that should be used for lists related to this
page. If None is returned, use a default one.
"""
return None
def get_context(self, request, *args, **kwargs):
from aircox_cms.forms import CommentForm
context = super().get_context(request, *args, **kwargs)
if self.allow_comments and \
WebsiteSettings.for_site(request.site).allow_comments:
context['comment_form'] = CommentForm()
context['settings'] = {
'debug': settings.DEBUG
}
return context
def serve(self, request):
from aircox_cms.forms import CommentForm
if request.POST and 'comment' in request.POST['type']:
settings = WebsiteSettings.for_site(request.site)
comment_form = CommentForm(request.POST)
if comment_form.is_valid():
comment = comment_form.save(commit=False)
comment.publication = self
comment.published = settings.accept_comments
comment.save()
messages.success(request,
settings.comment_success_message
if comment.published else
settings.comment_wait_message,
fail_silently=True,
)
else:
messages.error(
request, settings.comment_error_message, fail_silently=True
)
return super().serve(request)
class Meta:
abstract = True
#
# Publications
#
class PublicationRelatedLink(RelatedLinkBase,Component):
template = 'aircox_cms/snippets/link.html'
parent = ParentalKey('Publication', related_name='links')
class PublicationTag(TaggedItemBase):
content_object = ParentalKey('Publication', related_name='tagged_items')
class Publication(BasePage):
order_field = 'date'
date = models.DateTimeField(
_('date'),
blank = True, null = True,
auto_now_add = True,
)
publish_as = models.ForeignKey(
'ProgramPage',
verbose_name = _('publish as program'),
on_delete=models.SET_NULL,
blank = True, null = True,
help_text = _('use this program as the author of the publication'),
)
focus = models.BooleanField(
_('focus'),
default = False,
help_text = _('the publication is highlighted;'),
)
allow_comments = models.BooleanField(
_('allow comments'),
default = True,
help_text = _('allow comments')
)
headline = models.TextField(
_('headline'),
blank = True, null = True,
help_text = _('headline of the publication, use it as an introduction'),
)
tags = ClusterTaggableManager(
verbose_name = _('tags'),
through=PublicationTag,
blank=True
)
class Meta:
verbose_name = _('Publication')
verbose_name_plural = _('Publication')
content_panels = [
MultiFieldPanel([
FieldPanel('title'),
ImageChooserPanel('cover'),
FieldPanel('headline'),
FieldPanel('body', classname='full'),
], heading=_('Content'))
]
promote_panels = [
MultiFieldPanel([
FieldPanel('tags'),
FieldPanel('focus'),
], heading=_('Content')),
] + Page.promote_panels
settings_panels = Page.settings_panels + [
FieldPanel('publish_as'),
FieldPanel('allow_comments'),
]
search_fields = BasePage.search_fields + [
index.SearchField('headline', partial_match=True),
]
@property
def recents(self):
return self.get_children().type(Publication).not_in_menu().live() \
.order_by('-publication__date')
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
view = request.GET.get('view')
context.update({
'view': view,
'page': self,
})
if view == 'list':
context.update(BaseList.from_request(request, related = self))
context['list_url_args'] += '&view=list'
return context
def save(self, *args, **kwargs):
if not self.date and self.first_published_at:
self.date = self.first_published_at
return super().save(*args, **kwargs)
class ProgramPage(Publication):
program = models.OneToOneField(
aircox.models.Program,
verbose_name = _('program'),
related_name = 'page',
on_delete=models.SET_NULL,
blank=True, null=True,
)
# rss = models.URLField()
email = models.EmailField(
_('email'), blank=True, null=True,
)
email_is_public = models.BooleanField(
_('email is public'),
default = False,
help_text = _('the email addess is accessible to the public'),
)
class Meta:
verbose_name = _('Program')
verbose_name_plural = _('Programs')
content_panels = [
# FieldPanel('program'),
] + Publication.content_panels
settings_panels = Publication.settings_panels + [
FieldPanel('email'),
FieldPanel('email_is_public'),
]
def diffs_to_page(self, diffs):
for diff in diffs:
if not diff.page:
diff.page = ListItem(
title = '{}, {}'.format(
self.program.name, diff.date.strftime('%d %B %Y')
),
cover = self.cover,
live = True,
date = diff.start,
)
return [
diff.page for diff in diffs if diff.page.live
]
@property
def next(self):
now = tz.now()
diffs = aircox.models.Diffusion.objects \
.filter(end__gte = now, program = self.program) \
.order_by('start').prefetch_related('page')
return self.diffs_to_page(diffs)
@property
def prev(self):
now = tz.now()
diffs = aircox.models.Diffusion.objects \
.filter(end__lte = now, program = self.program) \
.order_by('-start').prefetch_related('page')
return self.diffs_to_page(diffs)
def save(self, *args, **kwargs):
# set publish_as
if self.program and not self.pk:
super().save()
self.publish_as = self
super().save(*args, **kwargs)
class Track(aircox.models.Track,Orderable):
diffusion = ParentalKey(
'DiffusionPage', related_name='tracks',
null = True, blank = True,
on_delete = models.SET_NULL
)
sort_order_field = 'position'
panels = [
FieldPanel('artist'),
FieldPanel('title'),
FieldPanel('tags'),
FieldPanel('info'),
]
def save(self, *args, **kwargs):
if self.diffusion.diffusion:
self.related = self.diffusion.diffusion
self.in_seconds = False
super().save(*args, **kwargs)
class DiffusionPage(Publication):
diffusion = models.OneToOneField(
aircox.models.Diffusion,
verbose_name = _('diffusion'),
related_name = 'page',
null=True, blank = True,
# not blank because we enforce the connection to a diffusion
# (still users always tend to break sth)
on_delete=models.SET_NULL,
limit_choices_to = {
'initial__isnull': True,
},
)
publish_archive = models.BooleanField(
_('publish archive'),
default = False,
help_text = _('publish the podcast of the complete diffusion'),
)
class Meta:
verbose_name = _('Diffusion')
verbose_name_plural = _('Diffusions')
content_panels = Publication.content_panels + [
InlinePanel('tracks', label=_('Tracks')),
]
promote_panels = [
MultiFieldPanel([
FieldPanel('publish_archive'),
FieldPanel('tags'),
FieldPanel('focus'),
], heading=_('Content')),
] + Page.promote_panels
settings_panels = Publication.settings_panels + [
FieldPanel('diffusion')
]
@classmethod
def from_diffusion(cl, diff, model = None, **kwargs):
model = model or cl
model_kwargs = {
'diffusion': diff,
'title': '{}, {}'.format(
diff.program.name, tz.localtime(diff.date).strftime('%d %B %Y')
),
'cover': (diff.program.page and \
diff.program.page.cover) or None,
'date': diff.start,
}
model_kwargs.update(kwargs)
r = model(**model_kwargs)
return r
@classmethod
def as_item(cl, diff):
"""
Return a DiffusionPage or ListItem from a Diffusion.
"""
initial = diff.initial or diff
if hasattr(initial, 'page'):
item = initial.page
else:
item = cl.from_diffusion(diff, ListItem)
item.live = True
item.info = []
# Translators: informations about a diffusion
if diff.initial:
item.info.append(_('Rerun of %(date)s') % {
'date': diff.initial.start.strftime('%A %d')
})
if diff.type == diff.Type.canceled:
item.info.append(_('Cancelled'))
item.info = '; '.join(item.info)
item.date = diff.start
item.css_class = 'diffusion'
now = tz.now()
if diff.start <= now <= diff.end:
item.css_class = ' now'
item.now = True
return item
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
context['podcasts'] = self.diffusion and SectionPlaylist(
title=_('Podcasts'),
page = self,
sounds = self.diffusion.get_sounds(
archive = self.publish_archive, excerpt = True
)
)
return context
def save(self, *args, **kwargs):
if self.diffusion:
# force to sort by diffusion date in wagtail explorer
self.latest_revision_created_at = self.diffusion.start
# set publish_as
if not self.pk:
self.publish_as = self.diffusion.program.page
# sync date
self.date = self.diffusion.start
# update podcasts' attributes
for podcast in self.diffusion.sound_set \
.exclude(type = aircox.models.Sound.Type.removed):
publish = self.live and self.publish_archive \
if podcast.type == podcast.Type.archive else self.live
if podcast.public != publish:
podcast.public = publish
podcast.save()
super().save(*args, **kwargs)
#
# Others types of pages
#
class CategoryPage(BasePage, BaseList):
# TODO: hide related in panels?
content_panels = BasePage.content_panels + BaseList.panels
def get_list_page(self):
return self
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
context.update(BaseList.get_context(self, request, paginate = True))
context['view'] = 'list'
return context
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# we force related attribute
if not self.related:
self.related = self
class DynamicListPage(BasePage):
"""
Displays a list of publications using query passed by the url.
This can be used for search/tags page, and generally only one
page is used per website.
If a title is given, use it instead of the generated one.
"""
# FIXME/TODO: title in template <title></title>
# TODO: personnalized titles depending on request
class Meta:
verbose_name = _('Dynamic List Page')
verbose_name_plural = _('Dynamic List Pages')
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
context.update(BaseList.from_request(request))
return context
class DatedListPage(DatedBaseList,BasePage):
class Meta:
abstract = True
def get_queryset(self, request, context):
"""
Must be implemented by the child
"""
return []
def get_context(self, request, *args, **kwargs):
"""
note: context is updated using self.get_date_context
"""
context = super().get_context(request, *args, **kwargs)
# date navigation
if 'date' in request.GET:
date = request.GET.get('date')
date = self.str_to_date(date)
else:
date = tz.now().date()
context.update(self.get_date_context(date))
# queryset
context['object_list'] = self.get_queryset(request, context)
context['target'] = self
return context
class LogsPage(DatedListPage):
template = 'aircox_cms/dated_list_page.html'
# TODO: make it a property that automatically select the station
station = models.ForeignKey(
aircox.models.Station,
verbose_name = _('station'),
null = True, blank = True,
on_delete = models.SET_NULL,
help_text = _('(required) related station')
)
max_age = models.IntegerField(
_('maximum age'),
default=15,
help_text = _('maximum days in the past allowed to be shown. '
'0 means no limit')
)
reverse = models.BooleanField(
_('reverse list'),
default=False,
help_text = _('print logs in ascending order by date'),
)
class Meta:
verbose_name = _('Logs')
verbose_name_plural = _('Logs')
content_panels = DatedListPage.content_panels + [
MultiFieldPanel([
FieldPanel('station'),
FieldPanel('max_age'),
FieldPanel('reverse'),
], heading=_('Configuration')),
]
def get_nav_dates(self, date):
"""
Return a list of dates availables for the navigation
"""
# there might be a bug if max_age < nav_days
today = tz.now().date()
first = min(date, today)
first = first - tz.timedelta(days = self.nav_days-1)
if self.max_age:
first = max(first, today - tz.timedelta(days = self.max_age))
return [ first + tz.timedelta(days=i)
for i in range(0, self.nav_days) ]
def get_queryset(self, request, context):
today = tz.now().date()
if self.max_age and context['nav_dates']['next'] > today:
context['nav_dates']['next'] = None
if self.max_age and context['nav_dates']['prev'] < \
today - tz.timedelta(days = self.max_age):
context['nav_dates']['prev'] = None
logs = []
for date in context['nav_dates']['dates']:
items = self.station.on_air(date = date) \
.select_related('track','diffusion')
items = [ SectionLogsList.as_item(item) for item in items ]
logs.append(
(date, reversed(items) if self.reverse else items)
)
return logs
class TimetablePage(DatedListPage):
template = 'aircox_cms/dated_list_page.html'
station = models.ForeignKey(
aircox.models.Station,
verbose_name = _('station'),
on_delete = models.SET_NULL,
null = True, blank = True,
help_text = _('(required) related station')
)
content_panels = DatedListPage.content_panels + [
MultiFieldPanel([
FieldPanel('station'),
], heading=_('Configuration')),
]
class Meta:
verbose_name = _('Timetable')
verbose_name_plural = _('Timetable')
def get_queryset(self, request, context):
diffs = []
for date in context['nav_dates']['dates']:
items = aircox.models.Diffusion.objects.at(self.station, date)
items = [ DiffusionPage.as_item(item) for item in items ]
diffs.append((date, items))
return diffs

536
aircox_cms/models/lists.py Normal file
View File

@ -0,0 +1,536 @@
"""
Generic list manipulation used to render list of items
Includes various usefull class and abstract models to make lists and
list items.
"""
import datetime
import re
from enum import IntEnum
from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.contrib.staticfiles.templatetags.staticfiles import static
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.utils.translation import ugettext as _, ugettext_lazy
from django.utils import timezone as tz
from django.utils.functional import cached_property
from wagtail.wagtailadmin.edit_handlers import *
from wagtail.wagtailcore.models import Page, Orderable
from wagtail.wagtailimages.models import Image
from wagtail.wagtailimages.edit_handlers import ImageChooserPanel
from aircox_cms.utils import related_pages_filter
class ListItem:
"""
Generic normalized element to add item in lists that are not based
on Publication.
"""
title = ''
headline = ''
url = ''
cover = None
date = None
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
self.specific = self
class RelatedLinkBase(Orderable):
"""
Base model to make a link item. It can link to an url, or a page and
includes some common fields.
"""
url = models.URLField(
_('url'),
null=True, blank=True,
help_text = _('URL of the link'),
)
page = models.ForeignKey(
Page,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text = _('Use a page instead of a URL')
)
icon = models.ForeignKey(
Image,
verbose_name = _('icon'),
null=True, blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text = _(
'icon from the gallery'
),
)
icon_path = models.CharField(
_('icon path'),
null=True, blank=True,
max_length=128,
help_text = _(
'icon from a given URL or path in the directory of static files'
)
)
text = models.CharField(
_('text'),
max_length = 64,
null = True, blank=True,
help_text = _('text of the link'),
)
info = models.CharField(
_('info'),
max_length = 128,
null=True, blank=True,
help_text = _(
'description displayed in a popup when the mouse hovers '
'the link'
)
)
class Meta:
abstract = True
panels = [
MultiFieldPanel([
FieldPanel('text'),
FieldPanel('info'),
ImageChooserPanel('icon'),
FieldPanel('icon_path'),
FieldPanel('url'),
PageChooserPanel('page'),
], heading=_('link'))
]
def icon_url(self):
"""
Return icon_path as a complete url, since it can either be an
url or a path to static file.
"""
if self.icon_path.startswith('http://') or \
self.icon_path.startswith('https://'):
return self.icon_path
return static(self.icon_path)
def as_dict(self):
"""
Return compiled values from parameters as dict with
'url', 'icon', 'text'
"""
if self.page:
url, text = self.page.url, self.text or self.page.title
else:
url, text = self.url, self.text or self.url
return {
'url': url,
'text': text,
'info': self.info,
'icon': self.icon,
'icon_path': self.icon_path and self.icon_url(),
}
class BaseList(models.Model):
"""
Generic list
"""
class DateFilter(IntEnum):
none = 0x00
previous = 0x01
next = 0x02
before_related = 0x03
after_related = 0x04
class RelationFilter(IntEnum):
none = 0x00
subpages = 0x01
siblings = 0x02
subpages_or_siblings = 0x03
# rendering
use_focus = models.BooleanField(
_('focus available'),
default = False,
help_text = _('if true, highlight the first focused article found')
)
count = models.SmallIntegerField(
_('count'),
default = 30,
help_text = _('number of items to display in the list'),
)
asc = models.BooleanField(
verbose_name = _('ascending order'),
default = True,
help_text = _('if selected sort list in the ascending order by date')
)
# selectors
date_filter = models.SmallIntegerField(
verbose_name = _('filter on date'),
choices = [ (int(y), _(x.replace('_', ' ')))
for x,y in DateFilter.__members__.items() ],
blank = True, null = True,
help_text = _(
'select pages whose date follows the given constraint'
)
)
model = models.ForeignKey(
ContentType,
verbose_name = _('filter on page type'),
blank = True, null = True,
on_delete=models.SET_NULL,
help_text = _('if set, select only elements that are of this type'),
limit_choices_to = related_pages_filter,
)
related = models.ForeignKey(
Page,
verbose_name = _('related page'),
blank = True, null = True,
on_delete=models.SET_NULL,
help_text = _(
'if set, select children or siblings of this page'
),
related_name = '+'
)
relation = models.SmallIntegerField(
verbose_name = _('relation'),
choices = [ (int(y), _(x.replace('_', ' ')))
for x,y in RelationFilter.__members__.items() ],
default = 1,
help_text = _(
'when the list is related to a page, only select pages that '
'correspond to this relationship'
),
)
search = models.CharField(
verbose_name = _('filter on search'),
blank = True, null = True,
max_length = 128,
help_text = _(
'keep only pages that matches the given search'
)
)
tags = models.CharField(
verbose_name = _('filter on tag'),
blank = True, null = True,
max_length = 128,
help_text = _(
'keep only pages with the given tags (separated by a colon)'
)
)
panels = [
MultiFieldPanel([
FieldPanel('count'),
FieldPanel('use_focus'),
FieldPanel('asc'),
], heading=_('rendering')),
MultiFieldPanel([
FieldPanel('date_filter'),
FieldPanel('model'),
PageChooserPanel('related'),
FieldPanel('relation'),
FieldPanel('search'),
FieldPanel('tags'),
], heading=_('filters'))
]
class Meta:
abstract = True
def __get_related(self, qs):
related = self.related and self.related.specific
filter = self.RelationFilter
if self.relation in (filter.subpages, filter.subpages_or_siblings):
qs_ = qs.descendant_of(related)
if self.relation == filter.subpages_or_siblings and \
not qs.count():
qs_ = qs.sibling_of(related)
qs = qs_
else:
qs = qs.sibling_of(related)
date = related.date if hasattr(related, 'date') else \
related.first_published_at
if self.date_filter == self.DateFilter.before_related:
qs = qs.filter(date__lt = date)
elif self.date_filter == self.DateFilter.after_related:
qs = qs.filter(date__gte = date)
return qs
def get_queryset(self):
"""
Get queryset based on the arguments. This class is intended to be
reusable by other classes if needed.
"""
# FIXME: check if related is published
from aircox_cms.models import Publication
# model
if self.model:
qs = self.model.model_class().objects.all()
else:
qs = Publication.objects.all()
qs = qs.live().not_in_menu()
# related
if self.related:
qs = self.__get_related(qs)
# date_filter
date = tz.now()
if self.date_filter == self.DateFilter.previous:
qs = qs.filter(date__lt = date)
elif self.date_filter == self.DateFilter.next:
qs = qs.filter(date__gte = date)
# sort
qs = qs.order_by('date', 'pk') \
if self.asc else qs.order_by('-date', '-pk')
# tags
if self.tags:
qs = qs.filter(tags__name__in = ','.split(self.tags))
# search
if self.search:
# this qs.search does not return a queryset
qs = qs.search(self.search)
return qs
def get_context(self, request, qs = None, paginate = True):
"""
Return a context object using the given request and arguments.
@param paginate: paginate and include paginator into context
Context arguments:
- object_list: queryset of the list's objects
- paginator: [if paginate] paginator object for this list
- list_url_args: GET arguments of the url as string
! Note: BaseList does not inherit from Wagtail.Page, and calling
this method won't call other super() get_context.
"""
qs = qs or self.get_queryset()
paginator = None
context = {}
if qs.count():
if paginate:
context.update(self.paginate(request, qs))
else:
context['object_list'] = qs[:self.count]
else:
# keep empty queryset
context['object_list'] = qs
context['list_url_args'] = self.to_url(full_url = False)
context['list_selector'] = self
return context
def paginate(self, request, qs):
# paginator
paginator = Paginator(qs, self.count)
try:
qs = paginator.page(request.GET.get('page') or 1)
except PageNotAnInteger:
qs = paginator.page(1)
except EmptyPage:
qs = paginator.page(paginator.num_pages)
return {
'paginator': paginator,
'object_list': qs
}
def to_url(self, page = None, **kwargs):
"""
Return a url to a given page with GET corresponding to this
list's parameters.
@param page: if given use it to prepend url with page's url instead of giving only
GET parameters
@param **kwargs: override list parameters
If there is related field use it to get the page, otherwise use
the given list_page or the first BaseListPage it finds.
"""
params = {
'asc': self.asc,
'date_filter': self.get_date_filter_display(),
'model': self.model and self.model.model,
'relation': self.relation,
'search': self.search,
'tags': self.tags
}
params.update(kwargs)
if self.related:
params['related'] = self.related.pk
params = '&'.join([
key if value == True else '{}={}'.format(key, value)
for key, value in params.items() if value
])
if not page:
return params
return page.url + '?' + params
@classmethod
def from_request(cl, request, related = None):
"""
Return a context from the request's GET parameters. Context
can be used to update relative informations, more information
on this object from BaseList.get_context()
@param request: get params from this request
@param related: reference page for a related list
@return context object from BaseList.get_context()
This function can be used by other views if needed
Parameters:
* asc: if present, sort ascending instead of descending
* date_filter: one of DateFilter attribute's key.
* model: ['program','diffusion','event'] type of the publication
* relation: one of RelationFilter attribute's key
* related: list is related to the method's argument `related`.
It can be a page id.
* tag: tag to search for
* search: query to search in the publications
* page: page number
"""
date_filter = request.GET.get('date_filter')
model = request.GET.get('model')
relation = request.GET.get('relation')
if relation is not None:
try:
relation = int(relation)
except:
relation = None
related_= request.GET.get('related')
if related_:
try:
related_ = int(related_)
related_ = Page.objects.filter(pk = related_).first()
related_ = related_ and related_.specific
except:
related_ = None
kwargs = {
'asc': 'asc' in request.GET,
'date_filter':
int(getattr(cl.DateFilter, date_filter))
if date_filter and hasattr(cl.DateFilter, date_filter)
else None,
'model':
ProgramPage if model == 'program' else
DiffusionPage if model == 'diffusion' else
EventPage if model == 'event' else None,
'related': related_,
'relation': relation,
'tags': request.GET.get('tags'),
'search': request.GET.get('search'),
}
base_list = cl(
count = 30, **{ k:v for k,v in kwargs.items() if v }
)
return base_list.get_context(request)
class DatedBaseList(models.Model):
"""
List that display items per days. Renders a navigation section on the
top.
"""
nav_days = models.SmallIntegerField(
_('navigation days count'),
default = 7,
help_text = _('number of days to display in the navigation header '
'when we use dates')
)
nav_per_week = models.BooleanField(
_('navigation per week'),
default = False,
help_text = _('if selected, show dates navigation per weeks instead '
'of show days equally around the current date')
)
hide_icons = models.BooleanField(
_('hide icons'),
default = False,
help_text = _('if selected, images of publications will not be '
'displayed in the list')
)
class Meta:
abstract = True
panels = [
MultiFieldPanel([
FieldPanel('nav_days'),
FieldPanel('nav_per_week'),
FieldPanel('hide_icons'),
], heading=_('Navigation')),
]
@staticmethod
def str_to_date(date):
"""
Parse a string and return a regular date or None.
Format is either "YYYY/MM/DD" or "YYYY-MM-DD" or "YYYYMMDD"
"""
try:
exp = r'(?P<year>[0-9]{4})(-|\/)?(?P<month>[0-9]{1,2})(-|\/)?' \
r'(?P<day>[0-9]{1,2})'
date = re.match(exp, date).groupdict()
return datetime.date(
year = int(date['year']), month = int(date['month']),
day = int(date['day'])
)
except:
return None
def get_nav_dates(self, date):
"""
Return a list of dates availables for the navigation
"""
if self.nav_per_week:
first = date.weekday()
else:
first = int((self.nav_days - 1) / 2)
first = date - tz.timedelta(days = first)
return [ first + tz.timedelta(days=i)
for i in range(0, self.nav_days) ]
def get_date_context(self, date = None):
"""
Return a dict that can be added to the context to be used by
a date_list.
"""
today = tz.now().date()
if not date:
date = today
# next/prev weeks/date bunch
dates = self.get_nav_dates(date)
next = date + tz.timedelta(days=self.nav_days)
prev = date - tz.timedelta(days=self.nav_days)
# context dict
return {
'nav_dates': {
'today': today,
'date': date,
'next': next,
'prev': prev,
'dates': dates,
}
}

View File

@ -0,0 +1,644 @@
from enum import IntEnum
from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.template import Template, Context
from django.utils.translation import ugettext as _, ugettext_lazy
from django.utils.functional import cached_property
from django.urls import reverse
from modelcluster.models import ClusterableModel
from modelcluster.fields import ParentalKey
from wagtail.wagtailadmin.edit_handlers import *
from wagtail.wagtailimages.edit_handlers import ImageChooserPanel
from wagtail.wagtailcore.models import Page
from wagtail.wagtailcore.fields import RichTextField
from wagtail.wagtailsnippets.models import register_snippet
import aircox.models
from aircox_cms.models.lists import *
from aircox_cms.views.components import Component, ExposedData
from aircox_cms.utils import related_pages_filter
@register_snippet
class Region(ClusterableModel):
"""
Region is a container of multiple items of different types
that are used to render extra content related or not the current
page.
A section has an assigned position in the page, and can be restrained
to a given type of page.
"""
name = models.CharField(
_('name'),
max_length=32,
blank = True, null = True,
help_text=_('name of this section (not displayed)'),
)
position = models.CharField(
_('position'),
max_length=16,
blank = True, null = True,
help_text = _('name of the template block in which the section must '
'be set'),
)
order = models.IntegerField(
_('order'),
default = 100,
help_text = _('order of rendering, the higher the latest')
)
model = models.ForeignKey(
ContentType,
verbose_name = _('model'),
blank = True, null = True,
help_text=_('this section is displayed only when the current '
'page or publication is of this type'),
limit_choices_to = related_pages_filter,
)
page = models.ForeignKey(
Page,
verbose_name = _('page'),
blank = True, null = True,
help_text=_('this section is displayed only on this page'),
)
panels = [
MultiFieldPanel([
FieldPanel('name'),
FieldPanel('position'),
FieldPanel('model'),
FieldPanel('page'),
], heading=_('General')),
# InlinePanel('items', label=_('Region Items')),
]
@classmethod
def get_sections_at (cl, position, page = None):
"""
Return a queryset of sections that are at the given position.
Filter out Region that are not for the given page.
"""
qs = Region.objects.filter(position = position)
if page:
qs = qs.filter(
models.Q(page__isnull = True) |
models.Q(page = page)
)
qs = qs.filter(
models.Q(model__isnull = True) |
models.Q(
model = ContentType.objects.get_for_model(page).pk
)
)
return qs.order_by('order','pk')
def add_item(self, item):
"""
Add an item to the section. Automatically save the item and
create the corresponding SectionPlace.
"""
item.section = self
item.save()
def render(self, request, page = None, context = None, *args, **kwargs):
return ''.join([
item.specific.render(request, page, context, *args, **kwargs)
for item in self.items.all().order_by('order','pk')
])
def __str__(self):
return '{}: {}'.format(self.__class__.__name__, self.name or self.pk)
@register_snippet
class Section(Component, models.Model):
"""
Section is a widget configurable by user that can be rendered inside
Regions.
"""
template_name = 'aircox_cms/sections/section.html'
section = ParentalKey(Region, related_name='items')
order = models.IntegerField(
_('order'),
default = 100,
help_text = _('order of rendering, the higher the latest')
)
real_type = models.CharField(
max_length=32,
blank = True, null = True,
)
title = models.CharField(
_('title'),
max_length=32,
blank = True, null = True,
)
show_title = models.BooleanField(
_('show title'),
default = False,
help_text=_('if set show a title at the head of the section'),
)
css_class = models.CharField(
_('CSS class'),
max_length=64,
blank = True, null = True,
help_text=_('section container\'s "class" attribute')
)
template_name = 'aircox_cms/sections/item.html'
panels = [
MultiFieldPanel([
FieldPanel('section'),
FieldPanel('title'),
FieldPanel('show_title'),
FieldPanel('order'),
FieldPanel('css_class'),
], heading=_('General')),
]
# TODO make it reusable
@cached_property
def specific(self):
"""
Return a downcasted version of the model if it is from another
model, or itself
"""
if not self.real_type or type(self) != Section:
return self
return getattr(self, self.real_type)
def save(self, *args, **kwargs):
if type(self) != Section and not self.real_type:
self.real_type = type(self).__name__.lower()
return super().save(*args, **kwargs)
def __str__(self):
return '{}: {}'.format(
(self.real_type or 'section item').replace('section','section '),
self.title or self.pk
)
class SectionRelativeItem(Section):
is_related = models.BooleanField(
_('is related'),
default = False,
help_text=_(
'if set, section is related to the page being processed '
'e.g rendering a list of links will use thoses of the '
'publication instead of an assigned one.'
)
)
class Meta:
abstract=True
panels = Section.panels.copy()
panels[-1] = MultiFieldPanel(
panels[-1].children + [ FieldPanel('is_related') ],
heading = panels[-1].heading
)
def related_attr(self, page, attr):
"""
Return an attribute from the given page if self.is_related,
otherwise retrieve the attribute from self.
"""
return self.is_related and hasattr(page, attr) \
and getattr(page, attr)
@register_snippet
class SectionText(Section):
template_name = 'aircox_cms/sections/text.html'
body = RichTextField()
panels = Section.panels + [
FieldPanel('body'),
]
def get_context(self, request, page):
from wagtail.wagtailcore.rich_text import expand_db_html
context = super().get_context(request, page)
context['content'] = expand_db_html(self.body)
return context
@register_snippet
class SectionImage(SectionRelativeItem):
class ResizeMode(IntEnum):
max = 0x00
min = 0x01
crop = 0x02
image = models.ForeignKey(
'wagtailimages.Image',
verbose_name = _('image'),
related_name='+',
blank=True, null=True,
help_text=_(
'If this item is related to the current page, this image will '
'be used only when the page has not a cover'
)
)
width = models.SmallIntegerField(
_('width'),
blank=True, null=True,
help_text=_('if set and > 0, sets a maximum width for the image'),
)
height = models.SmallIntegerField(
_('height'),
blank=True, null=True,
help_text=_('if set 0 and > 0, sets a maximum height for the image'),
)
resize_mode = models.SmallIntegerField(
verbose_name = _('resize mode'),
choices = [ (int(y), _(x)) for x,y in ResizeMode.__members__.items() ],
default = int(ResizeMode.max),
help_text=_('if the image is resized, set the resizing mode'),
)
panels = Section.panels + [
ImageChooserPanel('image'),
MultiFieldPanel([
FieldPanel('width'),
FieldPanel('height'),
FieldPanel('resize_mode'),
], heading=_('Resizing'))
]
cache = ""
def get_filter(self):
return \
'original' if not (self.height or self.width) else \
'width-{}'.format(self.width) if not self.height else \
'height-{}'.format(self.height) if not self.width else \
'{}-{}x{}'.format(
self.get_resize_mode_display(),
self.width, self.height
)
def ensure_cache(self, image):
"""
Ensure that we have a generated image and that it is put in cache.
We use this method since generating dynamic signatures don't generate
static images (and we need it).
"""
# Note: in order to put the generated image in db, we first need a way
# to get save events from related page or image.
if self.cache:
return self.cache
if self.width or self.height:
template = Template(
'{% load wagtailimages_tags %}\n' +
'{{% image source {filter} as img %}}'.format(
filter = self.get_filter()
) +
'<img src="{{ img.url }}">'
)
context = Context({
"source": image
})
self.cache = template.render(context)
else:
self.cache = '<img src="{}"/>'.format(image.file.url)
return self.cache
def get_context(self, request, page):
from wagtail.wagtailimages.views.serve import generate_signature
context = super().get_context(request, page)
image = self.related_attr(page, 'cover') or self.image
if not image:
return context
context['content'] = self.ensure_cache(image)
return context
@register_snippet
class SectionLinkList(ClusterableModel, Section):
template_name = 'aircox_cms/sections/link_list.html'
panels = Section.panels + [
InlinePanel('links', label=_('Links')),
]
@register_snippet
class SectionLink(RelatedLinkBase, Component):
"""
Render a link to a page or a given url.
Can either be used standalone or in a SectionLinkList
"""
template_name = 'aircox_cms/snippets/link.html'
parent = ParentalKey(
'SectionLinkList', related_name = 'links',
null = True
)
def __str__(self):
return 'link: {} #{}'.format(
self.text or (self.page and self.page.title) or self.title,
self.pk
)
@register_snippet
class SectionList(BaseList, SectionRelativeItem):
"""
This one is quite badass, but needed: render a list of pages
using given parameters (cf. BaseList).
If focus_available, the first article in the list will be the last
article with a focus, and will be rendered in a bigger size.
"""
template_name = 'aircox_cms/sections/list.html'
# TODO/FIXME: focus, quid?
# TODO: logs in menu show headline???
url_text = models.CharField(
_('text of the url'),
max_length=32,
blank = True, null = True,
help_text = _('use this text to display an URL to the complete '
'list. If empty, no link is displayed'),
)
panels = SectionRelativeItem.panels + [
FieldPanel('url_text'),
] + BaseList.panels
def get_context(self, request, page):
import aircox_cms.models as cms
if self.is_related and not self.related:
# set current page if there is not yet a related page only
self.related = page
context = BaseList.get_context(self, request, paginate = False)
if not context['object_list'].count():
self.hide = True
return {}
context.update(SectionRelativeItem.get_context(self, request, page))
if self.url_text:
self.related = self.related and self.related.specific
target = None
if self.related and hasattr(self.related, 'get_list_page'):
target = self.related.get_list_page()
if not target:
settings = cms.WebsiteSettings.for_site(request.site)
target = settings.list_page
context['url'] = self.to_url(page = target) + '&view=list'
return context
SectionList._meta.get_field('count').default = 5
@register_snippet
class SectionLogsList(Section):
template_name = 'aircox_cms/sections/logs_list.html'
station = models.ForeignKey(
aircox.models.Station,
verbose_name = _('station'),
null = True,
on_delete=models.SET_NULL,
help_text = _('(required) the station on which the logs happened')
)
count = models.SmallIntegerField(
_('count'),
default = 5,
help_text = _('number of items to display in the list (max 100)'),
)
class Meta:
verbose_name = _('list of logs')
verbose_name_plural = _('lists of logs')
panels = Section.panels + [
FieldPanel('station'),
FieldPanel('count'),
]
@staticmethod
def as_item(log):
"""
Return a log object as a DiffusionPage or ListItem.
Supports: Log/Track, Diffusion
"""
from aircox_cms.models import DiffusionPage
if log.diffusion:
return DiffusionPage.as_item(log.diffusion)
track = log.track
return ListItem(
title = '{artist} -- {title}'.format(
artist = track.artist,
title = track.title,
),
headline = track.info,
date = log.date,
info = '',
css_class = 'track'
)
def get_context(self, request, page):
context = super().get_context(request, page)
context['object_list'] = [
self.as_item(item)
for item in self.station.on_air(count = min(self.count, 100))
]
return context
@register_snippet
class SectionTimetable(Section,DatedBaseList):
template_name = 'aircox_cms/sections/timetable.html'
class Meta:
verbose_name = _('Section: Timetable')
verbose_name_plural = _('Sections: Timetable')
station = models.ForeignKey(
aircox.models.Station,
verbose_name = _('station'),
help_text = _('(required) related station')
)
target = models.ForeignKey(
'aircox_cms.TimetablePage',
verbose_name = _('timetable page'),
blank = True, null = True,
help_text = _('select a timetable page used to show complete timetable'),
)
nav_visible = models.BooleanField(
_('show date navigation'),
default = True,
help_text = _('if checked, navigation dates will be shown')
)
# TODO: put in multi-field panel of DatedBaseList
panels = Section.panels + DatedBaseList.panels + [
MultiFieldPanel([
FieldPanel('nav_visible'),
FieldPanel('station'),
FieldPanel('target'),
], heading=_('Timetable')),
]
def get_queryset(self, context):
from aircox_cms.models import DiffusionPage
diffs = []
for date in context['nav_dates']['dates']:
items = aircox.models.Diffusion.objects.at(self.station, date)
items = [ DiffusionPage.as_item(item) for item in items ]
diffs.append((date, items))
return diffs
def get_context(self, request, page):
context = super().get_context(request, page)
context.update(self.get_date_context())
context['object_list'] = self.get_queryset(context)
context['target'] = self.target
if not self.nav_visible:
del context['nav_dates']['dates'];
return context
@register_snippet
class SectionPublicationInfo(Section):
template_name = 'aircox_cms/sections/publication_info.html'
class Meta:
verbose_name = _('Section: publication\'s info')
verbose_name_plural = _('Sections: publication\'s info')
@register_snippet
class SectionSearchField(Section):
template_name = 'aircox_cms/sections/search_field.html'
default_text = models.CharField(
_('default text'),
max_length=32,
default=_('search'),
help_text=_('text to display when the search field is empty'),
)
class Meta:
verbose_name = _('Section: search field')
verbose_name_plural = _('Sections: search field')
panels = Section.panels + [
FieldPanel('default_text'),
]
@register_snippet
class SectionPlaylist(Section):
"""
User playlist. Can be used to add sounds in it -- there should
only be one for the moment.
"""
class Track(ExposedData):
"""
Class exposed to Javascript playlist manager as Track.
"""
fields = {
'name': 'name',
'duration': lambda e, o: (
o.duration.hour, o.duration.minute, o.duration.second
),
'sources': lambda e, o: [ o.url() ],
'detail_url':
lambda e, o: o.diffusion and hasattr(o.diffusion, 'page') \
and o.diffusion.page.url
,
'cover':
lambda e, o: o.diffusion and hasattr(o.diffusion, 'page') \
and o.diffusion.page.icon
,
}
user_playlist = models.BooleanField(
_('user playlist'),
default = False,
help_text = _(
'if set, this playlist is to be editable by the user'
)
)
single_mode = models.BooleanField(
_('single_mode'),
default = False,
help_text = _(
'enable single mode by default on this playlist'
)
)
tracks = None
template_name = 'aircox_cms/sections/playlist.html'
panels = Section.panels + [
FieldPanel('user_playlist'),
FieldPanel('single_mode'),
]
def __init__(self, *args, sounds = None, tracks = None, page = None, **kwargs):
"""
Init playlist section. If ``sounds`` is given initialize playlist
tracks with it. If ``page`` is given use it for Track infos
related to a page (cover, detail_url, ...)
"""
self.tracks = (tracks or []) + [
self.Track(object = sound, detail_url = page and page.url,
cover = page and page.icon)
for sound in sounds or []
]
super().__init__(*args, **kwargs)
def get_context(self, request, page):
context = super().get_context(request, page)
context.update({
'is_default': self.user_playlist,
'modifiable': self.user_playlist,
'storage_key': self.user_playlist and str(self.pk),
'tracks': self.tracks
})
if not self.user_playlist and not self.tracks:
self.hide = True
return context
@register_snippet
class SectionPlayer(Section):
"""
Radio stream player.
"""
template_name = 'aircox_cms/sections/playlist.html'
live_title = models.CharField(
_('live title'),
max_length = 32,
help_text = _('text to display when it plays live'),
)
streams = models.TextField(
_('audio streams'),
help_text = _('one audio stream per line'),
)
class Meta:
verbose_name = _('Section: Player')
panels = Section.panels + [
FieldPanel('live_title'),
FieldPanel('streams'),
]
def get_context(self, request, page):
context = super().get_context(request, page)
context['tracks'] = [SectionPlaylist.Track(
name = self.live_title,
sources = self.streams.split('\r\n'),
data_url = 'https://aircox.radiocampus.be/aircox/on_air', # reverse('aircox.on_air'),
interval = 10,
run = True,
)]
return context