remove old cms, switch to wagtail; move website to cms

This commit is contained in:
bkfox 2016-07-22 05:50:00 +02:00
parent 4bbffa9a50
commit ba3bf68e33
50 changed files with 950 additions and 4836 deletions

View File

@ -1,30 +1,28 @@
![](/data/logo.png)
Platform to manage a radio, schedules, website, and so on. We use the power of Django and Liquidsoap.
Platform to manage a radio, schedules, website, and so on. We use the power of great tools like Django or Liquidsoap.
## Current features
* **streams**: multiple random music streams when no program is played. We also can specify a time range and frequency;
* **diffusions**: generate diffusions time slot for programs that have schedule informations. Check for conflicts and rerun.
* **liquidsoap**: create a configuration to use liquidsoap as a stream generator. Also provides interface and control to it;
* **sounds**: each programs have a folder where sounds can be put, that will be detected by the system. Quality can be check and reported for later use. Later, we plan to have uploaders to external plateforms. Sounds can be defined as excerpts or as archives.
* **cms**: a small CMS to generate a website with all cool informations related to programs and diffusions. On the contrary of some other plateforms, we keep program and content management distinct.
* **cms**: application that can be used as basis for website (we use Wagtail; if you don't want it this application is not required to make everything run);
* **log**: keep a trace of every played/loaded sounds on the stream generator.
## Applications
* **programs**: managing stations, programs, schedules and diffusions. This is the core application, that handle most of the work;
* **controllers**: interface with external stream generators. For the moment only support [Liquidsoap](http://liquidsoap.fm/). Generate configuration files, trigger scheduled diffusions and so on;
* **cms**: cms manager with reusable tools (can be used in another website application);
* **website**: set of common models, sections, and other items ready to be used for a website;
* **cms**: defines models and templates to generate a website connected to Aircox;
## Installation
For now, we provide only applications availables under the aircox directory. Create a django project, and add the aircox applications directory.
Later we would provide a package, but now we have other priorities.
### settings.py
* INSTALLED_APPS:
- dependencies: `'taggit'` (*programs* and *cms* applications),
`'easy_thumbnails'` (*cms*), `'honeypot'` (*cms*)
- optional dependencies (in order to make users' life easier): `'autocomplete_light'`, `'suit'`
- aircox: `'aircox.programs'`, `'aircox.controllers'`, `'aircox.cms'`, `'aircox.website'`
Dependencies:
* wagtail (cms)
* honeypot (cms)
* taggit (cms, programs)

View File

@ -1,247 +0,0 @@
# Aircox.CMS
Simple CMS generator used in Aircox. Main features includes:
- website configuration and templating
- articles and static pages
- sections:
- embeddable views used to display related elements of an object
- load dynamic personnalized data
- posts related to external models:
- attributes binding, automatically updated
- personalization of view rendering, using templates or sections
- integrated admin interface if desired
- list and detail views + routing
- positioned menus using views
- comment
We aims here to automatize most common tasks and to ease website
configuration.
# Dependencies
* `django-taggit`: publications' tags;
* `easy_thumbnails`: publications' images and previews;
* `django-honeypot`: comments anti-spam
Note: this application can be used outside aircox if needed.
# Architecture
A **Website** holds all required informations to run the server instance. It
is used to register all kind of posts, routes to the views, menus, etc.
Basically, for each type of publication, the user declare the corresponding
model, the routes, the views used to render it, using `website.register`.
## Posts
**Post** is the base model for a publication. **Article** is the provided model
for articles and static pages.
**RelatedPost** is used to generate posts related to a model, the corresponding
bindings and so on. The idea is that you declare your own models using it as
parent, and give informations for bindings and so on. This is as simple as:
```python
class MyModelPost(RelatedPost):
class Relation:
model = MyModel
bindings = {
'thread': 'parent_field_name',
'title': 'name'
}
```
Note: it is possible to assign a function as a bounded value; in such case, the
function will be called using arguments **(post, related_object)**.
At rendering, the property *info* can be retrieved from the Post. It is however
not a field.
### Combine multiple models
It is possible to render views that combine multiple models, with some
limitation. In order to make it possible, the `GenericModel` has been created
with its own query__set class `QCombine`.
It can be used to register views that must render list of different elements,
such as for search. Once declared, you just need to register the class with
a view to the website.
```python
import aircox.cms.qcombine as qcombine
class Article(RelatedPost):
pass
class Program(RelatedPost):
pass
class Diffusion(RelatedPost):
pass
class Publication(qcombine.GenericModel):
models = [
Program,
Diffusion,
Article,
]
# website.register(... model = Publication ...)
# qs = Publication.objects.filter(title__icontain == "Hi")
```
## Routes
Routes are used to generate the URLs of the website. We provide some of the
common routes: for the detail view of course, but also to select all posts or
by thread, date, search, tags.
It is of course possible to create your own routes.
## Sections
Sections are used to render part of a publication, for example to render a
playlist related to the diffusion of a program.
If the rendering of a publication can vary depending the related element, in
most of the cases when we render elements, we can find the same patterns: a
picture, a text, a list of URLs/related posts/other items.
In order to avoid to much code writing and the multiplication of template
files (e.g. one for each type of publication), we prefer to declare these
sections and configure them. This reduce the work, keep design coherent,
and reduce the risk of bugs and so on.
## Exposure
Section can expose one or more methods whose result are accessible from the
web. Such function is called an *Exposure*, and return a *string*(!).
The routes to the exposures are generated and registered at the same time
a model is registered to the website, with the corresponding Section.
To expose a method, there is to steps:
* decorate the parent class with `aircox.cms.decorators.expose`
* decorate the method with `aircox.cms.decorators.expose`
An exposed method has an `_exposure` attribute, that is an instance of
Exposure, that holds exposure's informations and offer some tools to
work with exposure:
```python
from aircox.cms.decorators import expose
@expose
class MySection(sections.Section):
@expose
hi(self, request, *args, **kwargs):
return "hi!"
@expose
hello(self, request, name, *args, **kwargs):
return "hello " + name
hello._exposure.pattern = "(?P<name>\w+)"
```
The urls will be generated once the class decorator is called, using for each
exposed item the following values:
- name: `'exps.' + class._exposure.name + '.' + method._exposure.name`
- url pattern: `'exps/' + class._exposure.pattern + '/' + method._exposure.name`
- kwargs: `{'cl': class }`
Check also the javascript scripts where there are various utility to get the
result of exposure and map them easily.
### Using templates
It is possible to render using templates, by setting `_exposure.template_name`.
In this case, the function is wrapped using `aircox.cms.decorators.template`,
and have this signature:
```python
function(class, request) -> dict
```
The returned dictionnary is used as context object to render the template.
## Website
This class is used to create the website itself and regroup all the needed
informations to make it beautiful. There are different steps to create the
website, using instance of the Website class:
1. Create the Website instance with all basic information: name, tags,
description, menus and so on.
2. Register the views: eventually linking with a model, a list of used
sections, routes, and optional parameters. The given name is used for
routing.
3. Register website's URLs to Django.
4. Change templates and css styles if needed.
It also offers various facilities, such as comments view registering, menu
initialization, url reversing.
### Default view and reverse
The Website class offers a `reverse` function that can be used to reverse
a url using a Route, a Post and kwargs arguments.
It is possible to ask to reverse to a default route if the reversing process
failed with the given model. In this case, it uses the registered views
that have been registered as *default view* (register function with argument
`as_default=True`).
# Rendering
## Views
They are three kind of views, among which two are used to render related content (`PostListView`, `PostDetailView`), and one is used to set arbitrary content at given url pattern (`PageView`).
The `PostDetailView` and `PageView` use a list of sections to render their content. If there is only one section in the view, it is merged as the main content instead of being a block in the content; this has for consequence that the template's context are merged and rendering at the same time than the page instead of before.
`PostListView` uses the route that have been matched in order to render the list. Internally, it uses `sections.List` to render the list, if no section is given by the user. The context used to render the page is initialized using the list's rendering context, then completed with its own context's stuff.
## Sections
A Section behave similar to a view with few differences:
* it renders its content to a string, using the function `render`;
* the method `as_view` return an instance of the section rather than a function, in order to keep possible to access section's data;
## Menus
`Menu` is a section containing others sections, and are used to render the website's menus. By default they are the ones of the parent website, but it is also possible to change the menus per view.
It is possible to render only the content of a view without any menu, by adding the parameter `embed` in the request's url. This has been done in order to allow XMLHttpRequests proper.
## Lists
Lists in `PostListView` and as a section in another view always uses the **list.html** template. It extends another template, that is configurable using `base_template` template argument; this has been used to render the list either in a section or as a page.
It is also possible to specify a list of fields that are rendered in the list, at the list initialisation or using request parameter `fields` (in this case it must be a subset of the list.fields).
# Rendered content
## Templates
All sections and pages uses the **website.html** view to be rendered. If `context['embed']` is True, it only render the content without any menu. Previously there was two distinct templates, but in order to reduce the amount of code, keep coherence between the templates, they have been merged.
The following blocks are available, with their related container (declared *inside* the block):
* *title*: the optional title in a `<h1>` tag;
* *header*: the optional header in a `<header>` tag;
* *content*: the content itself; by default there is no related container. By convention however, if there is a container it has the class `.content`;
* *footer*: the footer in a `<footer>` tag;
The other templates Aircox.cms uses are:
* **detail.html**: used to render post details (extends *website.html*);
* **list.html**: used to render lists (extends *website.html*);
* **list__item.html**: item in a list;
* **comments.html**: used to render comments including a form (*list.html*);
# CSS classes
* **.meta**: metadata of any item (author, date, info, tags...)
* **.info**: used to render extra information, usually in lists
The following classes are used for sections (on the section container) and page-wide views (on the <main> tag):
* **.section**: associated to all sections
* **.section_*class***: associated to all section, where name is the name of the classe used to generate the section;
* **.list**: for lists (sections and general list)
* **.detail**: for the detail page view

View File

View File

@ -1,126 +0,0 @@
"""
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 = \
'<a class="action" {onclick} action="{cl.id}" {data} title="{cl.title}">' \
'{cl.symbol}<label>{cl.title}</label>' \
'</a>'.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
"""

View File

@ -1,105 +1,3 @@
import copy
from django.contrib import admin
from django.utils.translation import ugettext as _, ugettext_lazy
import aircox.cms.models as models
class PostAdmin(admin.ModelAdmin):
list_display = [ 'title', 'date', 'author', 'published', 'post_tags']
list_editable = [ 'published' ]
list_filter = ['date', 'author', 'published']
def post_tags(self, post):
tags = []
for tag in post.tags.all():
tags.append(str(tag))
return ', '.join(tags)
post_tags.short_description = _('tags')
def post_image(self, post):
if not post.image:
return
from easy_thumbnails.files import get_thumbnailer
options = {'size': (48, 48), 'crop': True}
url = get_thumbnailer(post.image).get_thumbnail(options).url
return u'<img src="{url}">'.format(url=url)
post_image.short_description = _('image')
post_image.allow_tags = True
class RelatedPostAdmin(PostAdmin):
list_display = ['title', 'date', 'published', 'post_tags', 'post_image' ]
class CommentAdmin(admin.ModelAdmin):
list_display = [ 'date', 'author', 'published', 'content_slice' ]
list_editable = [ 'published' ]
list_filter = ['date', 'author', 'published']
def content_slice(self, post):
return post.content[:256]
content_slice.short_description = _('content')
class PostInline(admin.StackedInline):
extra = 1
max_num = 1
verbose_name = _('Post')
fieldsets = [
(None, {
'fields': ['title', 'content', 'image', 'tags']
}),
(None, {
'fields': ['date', 'published', 'author']
})
]
def inject_related_inline(post_model, prepend = False, inline = None):
"""
Create an inline class and inject it into the related model admin class.
Clean-up bound attributes.
"""
class InlineModel(PostInline):
model = post_model
verbose_name = _('Related post')
inline = inline or InlineModel
inline.fieldsets = copy.deepcopy(inline.fieldsets)
# remove bound attributes
for none, dic in inline.fieldsets:
if not dic.get('fields'):
continue
dic['fields'] = [ v for v in dic['fields']
if v not in post_model._relation.bindings.keys() ]
inject_inline(post_model._meta.get_field('related').rel.to,
inline, prepend)
def inject(model, name, value):
registry = admin.site._registry
if not model in registry:
return TypeError('{} not in admin registry'.format(model.__name__))
setattr(registry[model], name, value)
def inject_inline(model, inline, prepend = False):
registry = admin.site._registry
if not model in registry:
return TypeError('{} not in admin registry'.format(model.__name__))
inlines = list(registry[model].inlines) or []
if prepend:
inlines.insert(0, inline)
else:
inlines.append(inline)
registry[model].inlines = inlines
admin.site.register(models.Comment, CommentAdmin)
# Register your models here.

View File

@ -1,131 +0,0 @@
import inspect
from django.template.loader import render_to_string
from django.http import HttpResponse, Http404
from django.conf.urls import url
from django.core.urlresolvers import reverse
from django.utils.text import slugify
class Exposure:
"""
Define an exposure. Look at @expose decorator.
"""
__uuid = 0
name = None
"""generated view name"""
pattern = None
"""url pattern"""
template_name = None
"""
for methods: exposed method return a context to be use with
the given template. The view will be wrapped in @template
"""
item = None
"""
Back ref to the exposed item, can be used to detect inheritance of
exposed classes.
"""
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
@staticmethod
def new_id():
Exposure.__uuid += 1
return Exposure.__uuid
@staticmethod
def gather(cl, website):
"""
Prepare all exposure declared in self.cl, create urls and return
them. This is done at this place in order to allow sub-classing
of exposed classes.
"""
def view(request, key, *args, fn = None, **kwargs):
if not fn:
if not hasattr(cl, key):
raise Http404()
fn = getattr(cl, key)
if not hasattr(fn, '_exposure'):
raise Http404()
exp = fn._exposure
# kwargs['request'] = request
res = fn(cl, *args, **kwargs)
if res and exp.template_name:
ctx = res or {}
ctx.update({
'embed': True,
'exp': cl._exposure,
})
res = render_to_string(exp.template_name,
ctx, request = request)
return HttpResponse(res or '')
uuid = Exposure.new_id()
exp = cl._exposure
exp.pattern = '{name}/{id}'.format(name = exp.name, id = uuid)
exp.name = 'exps.{name}.{id}'.format(name = exp.name, id = uuid)
urls = []
for name, fn in inspect.getmembers(cl):
if name.startswith('__') or not hasattr(fn, '_exposure'):
continue
fn_exp = fn._exposure
if not fn_exp.pattern:
continue
name = fn_exp.name or name
pattern = exp.pattern + '/(?P<key>{name})/{pattern}'.format(
name = name, pattern = fn_exp.pattern
)
urls.append(url(
pattern, name = exp.name, view = view,
kwargs = { 'fn': fn, 'website': website }
))
urls.append(url(
exp.pattern + '(?P<key>\w+)', name = exp.name, view = view
))
return urls
def expose(item):
"""
Expose a class and its methods as views. This allows data to be
retrieved dynamiccaly from client (e.g. with javascript).
To expose a method of a class, you must expose the class, then the
method.
The exposed method has the following signature:
`func(cl, request, *args, **kwargs) -> str`
Data related to the exposure are put in the `_exposure` attribute,
as instance of Exposure.
To add extra parameter, such as template_name, just update the correct
field in func._exposure, that will be taken in account at the class
decoration.
The exposed method will be prefix'ed with it's parent class exposure.
When adding views to a website, the exposure of their sections are
added to the list of url.
"""
def get_attr(attr, default):
v = (hasattr(item, attr) and getattr(item, attr)) or default
return slugify(v.lower())
name = get_attr('name', item.__name__)
pattern = get_attr('pattern', item.__name__)
exp = Exposure(name = name, pattern = pattern, item = item)
item._exposure = exp;
return item

View File

@ -23,13 +23,13 @@ class CommentForm(forms.ModelForm):
'placeholder': _('your website (optional)'),
}),
'comment': forms.TextInput(attrs={
'placeholder': _('your lovely comment'),
'placeholder': _('your comment'),
})
}
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request', None)
self.thread = kwargs.pop('object', None)
self.page = kwargs.pop('object', None)
super().__init__(*args, **kwargs)
def clean(self):
@ -42,6 +42,3 @@ class CommentForm(forms.ModelForm):
raise ValidationError(_('No publication found for this comment'))

File diff suppressed because it is too large Load Diff

View File

@ -1,254 +0,0 @@
import datetime
from django.db import models
from django.utils import timezone as tz
from django.conf.urls import url
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import ugettext as _, ugettext_lazy
from taggit.models import Tag
class Route:
"""
Base class for routing. Given a model, we generate url specific for each
type of route.
The generated url takes this form:
name + '/' + route_name + '/' + '/'.join(params)
And their name (to use for reverse:
name + '_' + route_name
By default name is the verbose name of the model. It is always in
singular form.
"""
name = None
"""
Regular name of the route
"""
params = []
"""
Arguments passed by the url, it is a list of tupple with values:
- name: (required) name of the argument
- regex: (required) regular expression to append to the url
- optional: (not required) if true, argument is optional
"""
@classmethod
def get_queryset(cl, website, request, **kwargs):
"""
Called by the view to get the queryset when it is needed
"""
pass
@classmethod
def get_object(cl, website, request, **kwargs):
"""
Called by the view to get the object when it is needed
"""
pass
@classmethod
def get_title(cl, website, request, **kwargs):
return ''
@classmethod
def make_view_name(cl, name):
return name + '.' + cl.name
@classmethod
def make_pattern(cl, prefix = ''):
"""
Make a url pattern using prefix as prefix and cl.params as
parameters.
"""
pattern = prefix
if cl.params:
pattern += ''.join([
'{pre}/(?P<{name}>{regexp}){post}'.format(
name = name, regexp = regexp,
pre = (optional and optional[0] and '(?:') or '',
post = (optional and optional[0] and ')?') or '',
)
for name, regexp, *optional in cl.params
])
pattern += '/?$'
return pattern
@classmethod
def as_url(cl, name, view, kwargs = None):
pattern = cl.make_pattern('^{}/{}'.format(name, cl.name))
kwargs = kwargs.copy() if kwargs else {}
kwargs['route'] = cl
return url(pattern, view, kwargs = kwargs,
name = cl.make_view_name(name))
class DetailRoute(Route):
name = 'detail'
params = [
('pk', '[0-9]+'),
('slug', '(\w|-|_)+', True),
]
@classmethod
def get_object(cl, model, request, pk, **kwargs):
"""
* request: optional
"""
return model.objects.get(pk = int(pk))
class AllRoute(Route):
"""
Retrieve all element of the given model.
"""
name = 'all'
@classmethod
def get_queryset(cl, model, request, **kwargs):
"""
* request: optional
"""
return model.objects.all()
@classmethod
def get_title(cl, model, request, **kwargs):
return _('All %(model)s') % {
'model': model._meta.verbose_name_plural
}
class ThreadRoute(Route):
"""
Select posts using by their assigned thread.
- "thread_model" can be a string with the name of a registered item on
website or a model.
- "pk" is the pk of the thread item.
"""
name = 'thread'
params = [
('thread_model', '(\w|_|-)+'),
('pk', '[0-9]+'),
]
@classmethod
def get_thread(cl, model, thread_model, pk=None):
"""
Return a model if not pk, otherwise the model element of given id
"""
if type(thread_model) is str:
thread_model = model._website.registry.get(thread_model)
if not thread_model or not pk:
return thread_model
return thread_model.objects.get(pk=pk)
@classmethod
def get_queryset(cl, model, request, thread_model, pk, **kwargs):
"""
* request: optional
"""
thread = cl.get_thread(model, thread_model, pk)
return model.get_siblings(thread_model = thread, thread_id = pk)
@classmethod
def get_title(cl, model, request, thread_model, pk, **kwargs):
thread = cl.get_thread(model, thread_model, pk)
return '<a href="{url}">{title}</a>'.format(
url = thread.url(),
title = _('%(name)s: %(model)s') % {
'model': model._meta.verbose_name_plural,
'name': thread.title,
}
)
class DateRoute(Route):
"""
Select posts using a date with format yyyy/mm/dd;
"""
name = 'date'
params = [
('year', '[0-9]{4}'),
('month', '[0-1]?[0-9]'),
('day', '[0-3]?[0-9]'),
]
@classmethod
def get_queryset(cl, model, request, year, month, day, **kwargs):
"""
* request: optional
* attr: name of the attribute to check the date against
"""
date = datetime.date(int(year), int(month), int(day))
return model.objects.filter(date__contains = date)
@classmethod
def get_title(cl, model, request, year, month, day, **kwargs):
date = tz.datetime(year = int(year), month = int(month), day = int(day))
return _('%(model)s of %(date)s') % {
'model': model._meta.verbose_name_plural,
'date': date.strftime('%A %d %B %Y'),
}
class SearchRoute(Route):
"""
Search post using request.GET['q'] or q optional argument. It searches in
fields designated by model.search_fields
"""
name = 'search'
params = [
( 'q', '[^/]+', True)
]
@classmethod
def get_queryset(cl, model, request, q = None, **kwargs):
"""
* request: required if q is None
"""
q = request.GET.get('q') or q or ''
qs = None
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_title(cl, model, request, q = None, **kwargs):
return _('Search <i>%(search)s</i> in %(model)s') % {
'model': model._meta.verbose_name_plural,
'search': request.GET.get('q') or q or '',
}
class TagsRoute(Route):
"""
Select posts that contains the given tags. The tags are separated
by a '+'.
"""
name = 'tags'
params = [
('tags', '(\w|-|_|\+)+')
]
@classmethod
def get_queryset(cl, model, request, tags, **kwargs):
tags = tags.split('+')
return model.objects.filter(tags__slug__in=tags).distinct()
@classmethod
def get_title(cl, model, request, tags, **kwargs):
import aircox.cms.utils as utils
tags = Tag.objects.filter(slug__in = tags.split('+'))
# FIXME: get tag name instead of tag slug
return _('%(model)s tagged with %(tags)s') % {
'model': model._meta.verbose_name_plural,
'tags': utils.tags_to_html(model, tags = tags)
}

View File

@ -1,649 +0,0 @@
"""
Define different Section css_class that can be used by views.Sections;
"""
import re
import datetime # used in calendar
from random import shuffle
from django.utils import timezone as tz
from django.template.loader import render_to_string
from django.views.generic.base import View
from django.templatetags.static import static
from django.http import HttpResponse
from django.contrib import messages
from django.utils.html import escape
from django.utils.translation import ugettext as _, ugettext_lazy
from honeypot.decorators import check_honeypot
from aircox.cms.forms import CommentForm
from aircox.cms.exposures import expose
from aircox.cms.actions import Actions
class Viewable:
"""
Describe a view that is still usable as a class after as_view() has
been called.
"""
@classmethod
def as_view (cl, *args, **kwargs):
"""
Create a view containing the current viewable, using a subclass
of aircox.cms.views.BaseView.
All the arguments are passed to the view directly.
"""
from aircox.cms.views import PageView
kwargs['sections'] = cl
return PageView.as_view(*args, **kwargs)
@classmethod
def extends (cl, **kwargs):
"""
Return a sub class where the given attribute have been updated
using kwargs.
"""
class Sub(cl):
pass
Sub.__name__ = cl.__name__
for k, v in kwargs.items():
setattr(Sub, k, v)
if hasattr(cl, '_exposure'):
return expose(Sub)
return Sub
class Sections(Viewable, list):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def prepare(self, *args, **kwargs):
"""
prepare all children sections
"""
for i, section in enumerate(self):
if callable(section) or type(section) == type:
self[i] = section()
self[i].prepare(*args, **kwargs)
def render(self, *args, **kwargs):
if args:
self.prepare(*args, **kwargs)
return ''.join([
section.render()
for section in self
])
def filter(self, predicate):
return [ section for section in self if predicate(section) ]
class Section(Viewable, View):
"""
On the contrary to Django's views, we create an instance of the view
only once, when the server is run.
Attributes are not changed once they have been set, and are related
to Section configuration/rendering. However, some data are considered
as temporary, and are reset at each rendering, using given arguments.
When get_context_data returns None, returns an empty string
! Important Note: values given for rendering are considered as safe
HTML in templates.
"""
template_name = 'aircox/cms/website.html'
"""
Template used for rendering
"""
tag = 'div'
"""
HTML tag used for the container
"""
name = ''
"""
Name/ID of the container
"""
css_class = ''
"""
CSS classes for the container
"""
attrs = None
"""
HTML Attributes of the container
"""
title = ''
"""
Safe HTML code for the title
"""
header = ''
"""
Safe HTML code for the header
"""
footer = ''
"""
Safe HTML code for the footer
"""
message_empty = None
"""
If message_empty is not None, print its value as
content of the section instead of hiding it. This works also when
its value is an empty string (prints an empty string).
"""
view = None
request = None
object = None
kwargs = None
def add_css_class(self, css_class):
if self.css_class:
if css_class not in self.css_class.split(' '):
self.css_class += ' ' + css_class
else:
self.css_class = css_class
def __init__ (self, **kwargs):
super().__init__(**kwargs)
self.add_css_class('section')
if type(self) != Section:
self.add_css_class('section_' + type(self).__name__.lower())
if not self.attrs:
self.attrs = {}
if self.name:
self.attrs['name'] = self.name
self.attrs['id'] = self.name
def is_empty(self):
"""
Return True if the section content will be empty. This allows to
hide the section.
This must be implemented by the subclasses.
"""
return False
def get_context_data(self):
return {
'view': self,
'exp': (hasattr(self, '_exposure') and self._exposure) or None,
'tag': self.tag,
'css_class': self.css_class,
'attrs': self.attrs,
'title': self.title,
'header': self.header,
'footer': self.footer,
'content': '',
'object': self.object,
'embed': True,
}
def prepare(self, view = None, **kwargs):
"""
initialize the object with valuable informations.
"""
self.kwargs = kwargs
if view:
self.view = view
self.request = view.request
if hasattr(view, 'object'):
self.object = view.object
def render(self, *args, **kwargs):
"""
Render the section as a string. Use *args and **kwargs to prepare
the section, then get_context_data and render.
"""
if args or kwargs:
self.prepare(*args, **kwargs)
context = self.get_context_data()
is_empty = self.is_empty()
if not context or (is_empty and not self.message_empty):
return ''
if is_empty and self.message_empty:
context['content'] = self.message_empty
context['embed'] = True
return render_to_string(
self.template_name, context, request=self.request
)
class Image(Section):
"""
Render an image using the relative url or relative to self.object.
Attributes:
* url: relative image url
* img_attr: name of the attribute of self.object to use
"""
url = None
img_attr = 'image'
def get_image(self):
if self.url:
return static(self.url)
if hasattr(self.object, self.img_attr):
image = getattr(self.object, self.img_attr)
return (image and image.url) or None
def is_empty(self):
return not self.get_image()
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
url = self.get_image()
if url:
context['content'] += '<img src="{}">'.format(url)
return context
class Content(Image):
"""
Render content using the self.content or relative to self.object.
Since it is a child of Image, can also render an image.
Attributes:
* content: raw HTML code to render
* content_attr: name of the attribute of self.object to use
"""
# FIXME: markup language -- coordinate with object's one (post/comment)?
content = None
content_attr = 'content'
def get_content(self):
if self.content:
return self.content
if hasattr(self.object, self.content_attr):
return getattr(self.object, self.content_attr) or None
def is_empty(self):
return super().is_empty() and not self.get_content()
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
content = self.get_content()
if content:
if not self.content:
content = escape(content)
content = re.sub(r'(^|\n\n)((\n?[^\n])+)', r'<p>\2</p>', content)
content = re.sub(r'\n', r'<br>', content)
context['content'] += content
return context
class ListItem:
"""
Used to render items in simple lists and lists of posts.
In list of posts, it is used when an object is not a post but
behaves like it.
"""
title = None
subtitle = None
content = None
author = None
date = None
image = None
info = None
url = None
actions = None
css_class = None
attrs = None
def __init__ (self, post = None, **kwargs):
if post:
self.update(post)
self.__dict__.update(**kwargs)
def update(self, post):
"""
Update empty fields using the given post
"""
for i in self.__class__.__dict__.keys():
if i[0] == '_':
continue
if hasattr(post, i) and not getattr(self, i):
setattr(self, i, getattr(post, i))
if not self.url and hasattr(post, 'url'):
self.url = post.url() if callable(post.url) else post.url
class List(Section):
"""
Common interface for list configuration.
Attributes:
"""
template_name = 'aircox/cms/list.html'
object_list = None
"""
Use this object list (default behaviour for lists)
"""
url = None
"""
URL to the list in full page; If given, print it
"""
paginate_by = 4
fields = [ 'date', 'time', 'image', 'title', 'subtitle', 'content', 'info',
'actions' ]
"""
Fields that must be rendered.
"""
image_size = '64x64'
"""
Size of the image when rendered in the list
"""
truncate = 16
"""
Number of words to print in content. If 0, print all the content
"""
def __init__ (self, items = None, *args, **kwargs):
"""
If posts is given, create the object_list with instances
of ListItem.
"""
super().__init__(*args, **kwargs)
self.add_css_class('list')
if items:
self.object_list = [
ListItem(item) for item in items
]
@classmethod
def as_view(cl, *args, **kwargs):
from aircox.cms.views import PostListView
kwargs['sections'] = cl
return PostListView.as_view(*args, **kwargs)
def is_empty(self):
return not self.object_list
def get_object_list(self):
return self.object_list or []
def prepare_list(self, object_list):
"""
Prepare objects before context is sent to the template renderer.
Return the object_list that is prepared.
"""
return object_list
def get_context_data(self, *args, object_list=None, **kwargs):
"""
Return a context that is passed to the template at rendering, with
the following values:
- `list`: a reference to self, that contain values used for rendering
- `object_list`: a list of object that can be rendered, either
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_list`, and make actions
for its items.
Set `request`, `object`, `object_list` and `kwargs` in self.
"""
if args:
self.prepare(*args, **kwargs)
if object_list is None:
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
if object_list:
object_list = self.prepare_list(object_list)
Actions.make(self.request, object_list = object_list)
context = super().get_context_data()
context.update({
'list': self,
'object_list': object_list[:self.paginate_by]
if object_list and self.paginate_by else
object_list,
})
return context
def need_url(self):
"""
Return True if there should be a pagination url
"""
return self.paginate_by and self.paginate_by < len(self.object_list)
class Similar(List):
"""
Section that uses tags to render similar objects of a given one.
Note that the list is not a queryset, but the sorted result of
taggit's similar_objects function.
"""
title = _('Similar publications')
models = None
"""
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
class Comments(List):
"""
Section used to render comment form and comments list. It renders the
form and comments, and save them.
"""
template_name = 'aircox/cms/comments.html'
title=_('Comments')
css_class='comments'
truncate = 0
fields = [ 'date', 'time', 'author', 'content' ]
message_empty = _('no comment has been posted yet')
comment_form = None
success_message = ( _('Your message is awaiting for approval'),
_('Your message has been published') )
error_message = _('There was an error while saving your post. '
'Please check errors below')
def get_object_list(self):
if not self.object:
return
qs = self.object.get_comments().filter(published=True). \
order_by('-date')
return [ ListItem(post=comment, css_class="comment",
attrs={ 'id': comment.id })
for comment in qs ]
@property
def url(self):
import aircox.cms.models as models
import aircox.cms.routes as routes
if self.object:
return models.Comment.reverse(routes.ThreadRoute, {
'pk': self.object.id,
'thread_model': self.object._registration.name
})
return ''
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
comment_form = None
if self.object:
post = self.object
if hasattr(post, 'allow_comments') and post.allow_comments:
comment_form = (self.comment_form or CommentForm())
context.update({
'comment_form': comment_form,
})
self.comment_form = None
return context
def post(self, view, request, object):
"""
Forward data to this view
"""
comment_form = CommentForm(request.POST)
if comment_form.is_valid():
comment = comment_form.save(commit=False)
comment.thread = object
comment.published = view.website.auto_publish_comments
comment.save()
messages.success(request, self.success_message[comment.published],
fail_silently=True)
else:
messages.error(request, self.error_message, fail_silently=True)
self.comment_form = comment_form
class Menu(Section):
tag = 'nav'
position = '' # top, left, bottom, right, header, footer, page_top, page_bottom
sections = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_css_class('menu')
self.add_css_class('menu_' + str(self.name or self.position))
self.sections = Sections(self.sections)
if not self.attrs:
self.attrs = {}
def prepare(self, *args, **kwargs):
super().prepare(*args, **kwargs)
self.sections.prepare(*args, **kwargs)
def get_context_data(self):
return {
'tag': self.tag,
'css_class': self.css_class,
'attrs': self.attrs,
'content': self.sections.render()
}
class Search(Section):
"""
Implement a search form that can be used in menus. Model must be set,
even if it is a generic model, in order to be able to reverse urls.
"""
model = None
"""
Model to search on
"""
placeholder = _('Search %(name_plural)s')
"""
Placeholder in the input text. The string is formatted before rendering.
- name: model's verbose name
- name_plural: model's verbose name in plural form
"""
no_button = True
"""
Hide submit button if true
"""
# TODO: (later) autocomplete using exposures -> might need templates
def get_context_data(self, *args, **kwargs):
import aircox.cms.routes as routes
context = super().get_context_data(*args, **kwargs)
url = self.model.reverse(routes.SearchRoute)
context['content'] += """
<form action="{url}" method="get">
<input type="text" name="q" placeholder="{placeholder}"/>
<input type="submit" {submit_style}/>
</form>
""".format(
url = url, placeholder = self.placeholder % {
'name': self.model._meta.verbose_name,
'name_plural': self.model._meta.verbose_name_plural,
},
submit_style = (self.no_button and 'style="display: none;"') or '',
)
return context
@expose
class Calendar(Section):
model = None
template_name = "aircox/cms/calendar.html"
def get_context_data(self):
import calendar
import aircox.cms.routes as routes
context = super().get_context_data()
if self.kwargs:
year, month = self.kwargs.get('year'), self.kwargs.get('month')
else:
year, month = None, None
date = datetime.date.today()
if year:
date = date.replace(year = year)
if month:
date = date.replace(month = month)
date = date.replace(day = 1)
first, count = calendar.monthrange(date.year, date.month)
def make_date(date, day):
date += tz.timedelta(days=day)
return (
date, self.website.reverse(
model = None,
route = routes.DateRoute, year = date.year,
month = date.month, day = date.day
)
)
context.update({
'first_weekday': first,
'days': [ make_date(date, day) for day in range(0, count) ],
'today': datetime.date.today(),
'this_month': date,
'prev_month': date - tz.timedelta(days=10),
'next_month': date + tz.timedelta(days=count),
})
return context
@expose
def render_exp(cl, *args, year, month, **kwargs):
year = int(year)
month = int(month)
calendar = cl(website = cl.website)
return calendar.render(year = year, month = month, **kwargs)
render_exp._exposure.name = 'render'
render_exp._exposure.pattern = '(?P<year>[0-9]{4})/(?P<month>[0-1]?[0-9])'

20
cms/settings.py Executable file
View File

@ -0,0 +1,20 @@
import os
from django.conf import settings
def ensure (key, default):
globals()[key] = getattr(settings, key, default)
ensure('AIRCOX_CMS_BLEACH_COMMENT_TAGS', [
'i', 'emph', 'b', 'strong', 'strike', 's',
'p', 'span', 'quote','blockquote','code',
'sup', 'sub', 'a',
])
ensure('AIRCOX_CMS_BLEACH_COMMENT_ATTRS', {
'*': ['title'],
'a': ['href', 'rel'],
})

View File

@ -1,301 +0,0 @@
/// Actions manager
/// This class is used to register actions and to execute them when it is
/// triggered by the user.
var Actions = {
registry: {},
/// Add an handler for a given action
register: function(id, symbol, title, handler) {
this.registry[id] = {
symbol: symbol,
title: title,
handler: handler,
}
},
/// Init an existing action HTML element
init_action: function(item, action_id, data, url) {
var action = this.registry[action_id];
if(!action)
return;
item.title = action.title;
item.innerHTML = (action.symbol || '') + '<label>' +
action.title + '</label>';
item.data = data;
item.className = 'action';
if(url)
item.href = url;
item.setAttribute('action', action_id);
item.addEventListener('click', Actions.run, true);
},
/// Add an action to the given item
add_action: function(item, action_id, data, url) {
var actions = item.querySelector('.actions');
if(actions && actions.querySelector('[action="' + action_id + '"]'))
return;
var item = document.createElement('a');
this.init_action(item, action_id, data, url);
actions.appendChild(item);
},
/// Run an action from the given event -- ! this can be undefined
run: function(event) {
var item = event.target;
var action = item.hasAttribute('action') &&
item.getAttribute('action');
if(!action)
return;
event.preventDefault();
event.stopImmediatePropagation();
action = Actions.registry[action];
if(!action)
return;
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++) {
var item = items[i];
var action_id = item.getAttribute('action');
var data = item.dataset;
Actions.init_action(item, action_id, data);
}
}, false);
*/
/// Small utility used to make XMLHttpRequests, and map results on objects.
/// This is intended to dynamically retrieve Section and exposed data.
///
/// Request.Selector is a utility class that can be used to map data using
/// selectors.
///
/// Since there is no a common method to render items in JS and Django, we
/// retrieve items yet rendered, and select data over it.
function Request_(url = '', params = '') {
this.url = url;
this.params = params;
this.selectors = [];
this.actions = [];
}
Request_.prototype = {
/// XMLHttpRequest object used to retrieve data
xhr: null,
/// delayed actions that have been registered
actions: null,
/// registered selectors
selectors: null,
/// parse request result and save in this.stanza
__parse_dom: function() {
if(self.stanza)
return;
var doc = document.implementation.createHTMLDocument('xhr').documentElement;
doc.innerHTML = this.xhr.responseText;
this.stanza = doc;
},
/// make an xhr request, and call callback(err, xhr) if given
get: function() {
var self = this;
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if(xhr.readyState != 4)
return
// TODO: error handling
var err = self.xhr.status != 200 && self.xhr.status;
if(err)
return;
for(var i = 0; i < self.actions.length; i++)
self.actions[i].apply(self);
}
if(this.params)
xhr.open('GET', this.url + '?' + this.params, true);
else
xhr.open('GET', this.url, true);
this.xhr = xhr;
return this;
},
/// send request
send: function() {
this.xhr.send();
return this;
},
/// set selectors.
/// * callback: if set, call it once data are downloaded with an object
/// of elements matched with the given selectors only. The object is
/// made of `selector_name: select_result` items.
select: function(selectors, callback = undefined) {
for(var i in selectors) {
selector = selectors[i];
if(!selector.sort)
selector = [selector]
selector = new Request_.Selector(i, selector[0], selector[1], selector[2])
selectors[i] = selector;
this.selectors.push(selector)
}
if(callback) {
var self = this;
this.actions.push(function() {
self.__parse_dom();
var r = {}
for(var i in selectors) {
r[i] = selectors[i].select(self.stanza);
}
callback(r);
});
}
return this;
},
/// map data using this.selectors on xhr result *and* dest
map: function(dest) {
var self = this;
this.actions.push(function() {
self.__parse_dom();
for(var i = 0; i < self.selectors.length; i++) {
selector = self.selectors[i]
selector.map(self.stanza, dest);
}
});
return this;
},
/// add an action to the list of actions
on: function(callback) {
this.actions.push(callback)
return this;
},
};
Request_.Selector = function(name, selector, attribute = null, all = false) {
this.name = name;
this.selector = selector;
this.attribute = attribute;
this.all = all;
}
Request_.Selector.prototype = {
select: function(obj, use_attr = true) {
if(!this.all) {
obj = obj.querySelector(this.selector)
return (this.attribute && use_attr && obj) ? obj[this.attribute] : obj;
}
obj = obj.querySelectorAll(this.selector);
if(!obj)
return;
r = []
for(var i = 0; i < obj.length; i++) {
r.push(this.attribute && use_attr ? obj[i][this.attribute] : obj[i])
}
return r;
},
map: function(src, dst) {
src_qs = this.select(src, false);
dst_qs = this.select(dst, false);
if(!src_qs || !dst_qs)
return
if(!this.all) {
src_qs = [ src_qs ];
dst_qs = [ dst_qs ];
}
var size = Math.min(src_qs.length, dst_qs.length);
for(var i = 0; i < size; i++) {
var src = src_qs[i];
var dst = dst_qs[i];
if(this.attribute)
dst[this.attribute] = src[this.attribute] || '';
else
dst.parentNode.replaceChild(src, dst);
}
},
}
/// Just to by keep same convention between utilities
/// Create an instance of Request_
function Request(url = '', params = '') {
return new Request_(url, params);
}
var Section = {
/// Return the parent section of a DOM element. Compare it using its
/// className. If not class_name is given, use "section"
get_section: function(item, class_name) {
class_name = class_name || 'section';
while(item) {
if(item.className && item.className.indexOf(class_name) != -1)
break
item = item.parentNode;
}
return item;
},
/// Load a section using the given url and parameters, update the header,
/// the content and the footer automatically. No need to add "embed" in
/// url params.
///
/// If find_section is true, item is a child of the wanted section.
///
/// Return a Request.Selector
load: function(item, url, params, find_section) {
if(find_section)
item = this.get_section(item);
var rq = Request(url, 'embed&' + (params || ''))
return rq.get().select({
'header': ['header', 'innerHTML', true],
'content': ['.content', 'innerHTML', true],
'footer': ['footer', 'innerHTML', true]
}).map(item).send();
},
/// Load a Section on event, and handle it.
load_event: function(event, params) {
var item = event.currentTarget;
var url = item.getAttribute('href');
event.preventDefault();
this.load(item, url, params, true);
return true;
},
}

View File

@ -1,155 +0,0 @@
/** main layout **/
body {
padding: 0;
margin: 0;
}
.page {
display: flex;
}
.page .menu {
width: 20em;
}
.page .menu_left { margin-right: 0.5em; }
.page .menu_right { margin-left: 0.5em; }
.page main {
flex-grow: 1;
}
.section {
vertical-align: top;
}
/** detail and list content **/
main .section {
/* width: calc(50% - 2em);
display: inline-block; */
padding: 0.5em;
}
main .section .section_content {
font-size: 0.95em;
}
main .section h1 {
font-size: 1.2em;
}
main .section * {
max-width: 100%;
}
.post_item {
display: block;
}
/** comments **/
.comments form input:not([type=checkbox]),
.comments form textarea {
display: inline-block;
width: 100%;
max-height: 6em;
margin: 0.2em 0em;
padding: 0.2em;
}
.comments form input[type=checkbox],
.comments form button[type=submit] {
vertical-align:bottom;
margin: 0.2em 0em;
text-align: center;
}
.comments form button[type=submit] {
float: right;
}
.comments form #show_more:not(:checked) ~ .extra {
display: none;
}
.comments label[for="show_more"] {
font-size: 0.8em;
}
/** calendar **/
.section_calendar header {
border-bottom: 1px black solid;
width: 100%;
}
.section_calendar header h3,
.section_calendar header a {
display: inline-block;
width: 2em;
padding: 0;
margin: 0;
font-size: 1em;
}
.section_calendar header h3 {
width: calc(100% - 4em);
text-align: center;
}
.section_calendar header a:last-child {
text-align: right;
}
.section_calendar .content {
padding: 0.2em;
margin: 0;
font-size: 0.9em;
}
.section_calendar .content > * {
padding: 0;
margin: 0;
text-align: right;
display: inline-block;
}
.section_calendar .content a {
width: calc(100% / 7);
}
.section_calendar .content a.today {
font-style: italic;
}
.section_calendar div[first_weekday] {
width: 0;
}
.section_calendar div[first_weekday="1"] {
width: calc(100% / 7);
}
.section_calendar div[first_weekday="2"] {
width: calc(100% / 7 * 2);
}
.section_calendar div[first_weekday="3"] {
width: calc(100% / 7 * 3);
}
.section_calendar div[first_weekday="4"] {
width: calc(100% / 7 * 4);
}
.section_calendar div[first_weekday="5"] {
width: calc(100% / 7 * 5);
}
.section_calendar div[first_weekday="6"] {
width: calc(100% / 7 * 6);
}

View File

@ -1,32 +0,0 @@
{% extends "aircox/cms/website.html" %}
{% block header %}
{% spaceless %}
<header>
<a href="{% url exp.name key="render" year=prev_month.year month=prev_month.month %}"
onclick="return Section.load_event(event);">&lt;</a>
<h3>{{ this_month|date:'F Y' }}</h3>
<a href="{% url exp.name key="render" year=next_month.year month=next_month.month %}"
onclick="return Section.load_event(event);">&gt;</a>
</header>
{% endspaceless %}
{% endblock %}
{% block content %}
{% spaceless %}
<div class="content">
<div first_weekday="{{ first_weekday }}">&nbsp;</div>
{% for day, url in days %}
{% if day == today %}
<a href="{{ url }}" weekday="{{ day.weekday }}" class="today">{{ day.day }}</a>
{% else %}
<a href="{{ url }}" weekday="{{ day.weekday }}">{{ day.day }}</a>
{% endif %}
{% endfor %}
</div>
{% endspaceless %}
{% endblock %}

View File

@ -1,29 +0,0 @@
{% extends "aircox/cms/website.html" %}
{% load aircox_cms %}
{% block header %}
<header>
{% if object.thread %}
<div class="threads">
{{ object|threads:' > '|safe }}
</div>
{% endif %}
<time datetime="{{ object.date }}">
{{ object.date|date:'l d F Y' }},
{{ object.date|time:'H\hi' }}
</time>
{% if object.tags.all %}
<div class="tags">
{{ object|post_tags:' - '|safe }}
</div>
{% endif %}
</header>
{% endblock %}
{% block content %}
{{ content|safe }}
{% endblock %}

View File

@ -1,64 +0,0 @@
{% extends "aircox/cms/website.html" %}
{% load i18n %}
{% load thumbnail %}
{% load aircox_cms %}
{% block content %}
<ul class="content">
{% for object in object_list %}
{% include "aircox/cms/list_item.html" %}
{% empty %}
<div class="message empty">
{{ list.message_empty }}
</div>
{% endfor %}
</ul>
{% if object_list and not embed %}
{% if list.url or page_obj %}
<nav>
{% if not page_obj or embed %}
{% comment %}link to show more elements of the list{% endcomment %}
<a href="{{list.url}}">{% trans "&#8226;&#8226;&#8226;" %}</a>
{% elif page_obj.paginator.num_pages > 1 %}
{% with page_obj.paginator.num_pages as num_pages %}
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}">previous</a>
{% endif %}
{% if page_obj.number > 3 %}
<a href="?page=1">1</a>
{% if page_obj.number > 4 %}
&#8230;
{% endif %}
{% endif %}
{% for i in page_obj.number|around:2 %}
{% if i == page_obj.number %}
{{ page_obj.number }}
{% elif i > 0 and i <= num_pages %}
<a href="?page={{ i }}">{{ i }}</a>
{% endif %}
{% endfor %}
{% with page_obj.number|add:"2" as max %}
{% if max < num_pages %}
{% if max|add:"1" < num_pages %}
&#8230;
{% endif %}
<a href="?page={{ num_pages }}">{{ num_pages }}</a>
{% endif %}
{% endwith %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">next</a>
{% endif %}
{% endwith %}
{% endif %}
</nav>
{% endif %}
{% endif %}
{% endblock %}

View File

@ -1,74 +0,0 @@
{% load i18n %}
{% load thumbnail %}
{% load aircox_cms %}
{% with object|downcast as object %}
<li {% if object.css_class %}class="{{ object.css_class }}"{% endif %}
{% for k, v in object.attrs.items %}
{{ k }} = "{{ v|addslashes }}"
{% endfor %} >
{% if object.url %}
<a class="url" href="{{ object.url }}">
{% endif %}
{% if 'image' in list.fields and object.image %}
<img class="image" src="{% thumbnail object.image list.image_size crop %}">
{% endif %}
<div class="body">
{% if 'title' in list.fields and object.title %}
<h2 class="title">{{ object.title }}</h2>
{% endif %}
{% if 'content' in list.fields and object.content %}
<div class="content">
{% if list.truncate %}
{{ object.content|striptags|truncatewords:list.truncate }}
{% else %}
{{ object.content|striptags }}
{% endif %}
</div>
{% endif %}
</div>
<div class="meta">
{% if object.date and 'date' in list.fields or 'time' in list.fields %}
<time datetime="{{ object.date }}">
{% if 'date' in list.fields %}
<span class="date">
{{ object.date|date:'D. d F' }}
</span>
{% endif %}
{% if 'time' in list.fields %}
<span class="time">
{{ object.date|date:'H:i' }}
</span>
{% endif %}
</time>
{% endif %}
{% if object.author and 'author' in list.fields %}
<span class="author">
{{ object.author }}
</span>
{% endif %}
{% if object.info and 'info' in list.fields %}
<span class="info">
{{ object.info }}
</span>
{% endif %}
</div>
{% if object.actions and 'actions' in list.fields %}
<div class="actions">
{% for action in object.actions %}
{{ action|safe }}
{% endfor %}
</div>
{% endif %}
{% if object.url %}
</a>
{% endif %}
</li>
{% endwith %}

View File

@ -1,37 +0,0 @@
{% if tag %}
<{{ tag }} {% if css_class %} class="{{ css_class }}" {% endif %}
{% for k, v in attrs.items %}
{{ k }} = "{{ v|addslashes }}"
{% endfor %} >
{% endif %}
{% block title %}
{% if title %}
<h1>{{ title }}</h1>
{% endif %}
{% endblock %}
{% block header %}
{% if header %}
<header>
{{ header|safe }}
</header>
{% endif %}
{% endblock %}
{% block content %}
{{ content|safe }}
{% endblock %}
{% block footer %}
{% if footer %}
<footer>
{{ footer|safe }}
</footer>
{% endif %}
{% endblock %}
{% if tag %}
</{{ tag }}>
{% endif %}

View File

@ -1,111 +0,0 @@
{% if not embed %}
{% load staticfiles %}
<html>
<head>
{# FIXME: extra head block #}
<meta charset="utf-8">
<meta name="application-name" content="aircox-cms">
<meta name="description" content="{{ website.description }}">
<meta name="keywords" content="{{ website.tags }}">
<link rel="stylesheet" href="{% static "aircox/cms/styles.css" %}" type="text/css">
{% if website.styles %}
<link rel="stylesheet" href="{% static website.styles %}" type="text/css">
{% endif %}
<script src="{% static "aircox/cms/scripts.js" %}"></script>
{% if actions %}
<script>
{{ actions|safe }}
</script>
{% endif %}
<title>{% if title %}{{ title|striptags }} - {% endif %}{{ website.name }}</title>
</head>
<body>
{% block page_header %}
{% if menus.header %}
{{ menus.header|safe }}
{% endif %}
{% endblock %}
<div class="page-container">
{% if menus.top %}
{{ menus.top|safe }}
{% endif %}
<div class="page">
{% if menus.left %}
{{ menus.left|safe }}
{% endif %}
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>
{{ message }}
</li>
{% endfor %}
</ul>
{% endif %}
{% endif %}
{% if tag %}
<{{ tag }} {% if css_class %} class="{{ css_class }}" {% endif %}
{% for k, v in attrs.items %}{{ k }} = "{{ v|addslashes }}"
{% endfor %}>
{% endif %}
{% block title %}
{% if title %}
<h1>{{ title|safe }}</h1>
{% endif %}
{% endblock %}
{% block header %}
{% if header %}
<header>
{{ header|safe }}
</header>
{% endif %}
{% endblock %}
{% block content %}
{{ content|safe }}
{% endblock %}
{% block footer %}
{% if footer %}
<footer>
{{ footer|safe }}
</footer>
{% endif %}
{% endblock %}
{% if tag %}
</{{ tag }}>
{% endif %}
{% if not embed %}
{% if menus.right %}
{{ menus.right|safe }}
{% endif %}
</div>
{% if menus.page_bottom %}
{{ menus.page_bottom|safe }}
{% endif %}
</div>
{% block page_footer %}
{% if menus.footer %}
{{ menus.footer|safe }}
{% endif %}
{% endblock %}
{% if menus.bottom %}
{{ menus.bottom|safe }}
{% endif %}
</body>
</html>
{% endif %}

View File

@ -0,0 +1,51 @@
{% load staticfiles %}
{% load i18n %}
{% load wagtailimages_tags %}
{% load wagtailsettings_tags %}
{% get_settings %}
<html>
<head>
{% block css %}
<link rel="stylesheet" href="{% static 'cms/css/layout.css' %}" type="text/css" />
<link rel="stylesheet" href="{% static 'cms/css/theme.css' %}" type="text/css" />
{% block css_extras %}{% endblock %}
{% endblock %}
<title>{{ page.title }}</title>
</head>
<body>
<div class="top">
<nav>
<a href="">Grille Horaire</a>
<a href="">Programmes</a>
<a href="">Contact</a>
</nav>
</div>
<div class="middle">
<nav class="left">
<img src="{{ settings.cms.WebsiteSettings.logo.file.url }}" class="logo">
</nav>
<main>
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>
{{ message }}
</li>
{% endfor %}
</ul>
{% endif %}
{% block title %}
<h1>{{ page.title }}</h1>
{% endblock %}
{% block content %}
{% endblock %}
</main>
</div>
</body>
</html>

View File

@ -0,0 +1,38 @@
{% extends "cms/publication.html" %}
{% load i18n %}
{% block content_extras %}
{% with tracks=page.tracks.all %}
{% if tracks %}
<div class="playlist">
<h2>{% trans "Playlist" %}</h2>
<ul>
{% for track in tracks %}
<li><span class="artist">{{ track.artist }}</span>
<span class="title">{{ track.title }}</span>
{% if track.info %} <span class="info">{{ track.info }}</span>{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endwith %}
<div class="dates">
<h2>{% trans "Dates of diffusion" %}</h2>
<ul>
{% with diffusion=page.diffusion %}
<li>{{ diffusion.date|date:"l d F Y, H:i" }}</li>
{% for diffusion in diffusion.diffusion_set.all %}
<li>{{ diffusion.date|date:"l d F Y, H:i" }}</li>
{% endfor %}
{% endwith %}
</ul>
</div>
{# TODO: podcasts #}
{% endblock %}

View File

@ -0,0 +1,24 @@
{% extends "cms/publication.html" %}
{% load i18n %}
{% block content %}
<div>
<h2>{% trans "Practical information" %}</h2>
<ul>
{% with start=page.start|date:'l d F H:i' %}
{% with end=page.end|date:'l d F H:i' %}
<li><b>{% trans "Date" %}</b>:
{% transblock %}{{ start }} until {{ end }}{% endtransblock %}
</li>
<li><b>{% trans "Place" %}</b>: {{ page.address }}</li>
{% if page.price %}
<li><b>{% trans "Price" %}</b>: {{ page.price }}</li>
{% endif %}
{% if page.info %}<li>{{ page.info }}</li>{% endif %}
{% endwith %}
{% endwith %}
</ul>
{% endblock %}

View File

@ -0,0 +1,42 @@
{% extends "cms/base_site.html" %}
{# generic page to display list of articles #}
{% load i18n %}
{% load wagtailimages_tags %}
{% block title %}
<h1>
{% if search_query %}
{% blocktrans %}Search in publications for <i>{{ search_query }}</i>{% endblocktrans %}
{% elif tag_query %}
{% blocktrans %}Search in publications for <i>{{ search_query }}</i>{% endblocktrans %}
{% elif thread_query %}
{# should never happen #}
{% with title=thread_query.title url=thread_query.url %}
{% blocktrans %}
Publications in <a href="{{ url }}">{{ title }}</a>{% endblocktrans %}
{% endwith %}
{% else %}
{% blocktrans %}All the publications{% endblocktrans %}
{% endif %}
</h1>
{% endblock %}
{% block content %}
{% if thread_query %}
<div class="body summary">
{% image thread_query.cover fill-128x128 class="cover item_cover" %}
{{ thread_query.summary }}
<a href="{{ thread_query.url }}">{% trans "More about it" %}</a>
</div>
{% endif %}
{% with list_paginator=paginator %}
{% include "cms/list.html" %}
{% endwith %}
{% endblock %}

View File

@ -0,0 +1,60 @@
{% extends "cms/publication.html" %}
{# generic page to display programs #}
{% load i18n %}
{% load wagtailcore_tags %}
{# TODO message if program is no more active #}
{% block content_extras %}
<div class="schedule">
{% if page.program.active %}
<h2>{% trans "Schedule" %}</h2>
<ul>
{% for schedule in page.program.schedule_set.all %}
<li>
{% with frequency=schedule.get_frequency_display day=schedule.date|date:'l' %}
{% with start=schedule.date|date:"H:i" end=schedule.end|date:"H:i" %}
{% blocktrans %}
{{ day }} {{ start }} until {{ end }}, {{ frequency }}
{% endblocktrans %}
{% endwith %}
{% endwith %}
{% if schedule.initial %}
<span class="info">{% trans "Rerun" %}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<div class="warning">{% trans "This program is no longer active" %}</div>
{% endif %}
</div>
{% endblock %}
{% block page_nav_extras %}
{% if page.program.active %}
{% with object_list=page.next_diffs %}
{% if object_list %}
<div>
<h2>{% trans "Next Diffusions" %}</h2>
{% include "cms/list.html" %}
</div>
{% endif %}
{% endwith %}
{% endif %}{# program.active #}
{% with object_list=page.prev_diffs %}
{% if object_list %}
<div>
<h2>{% trans "Previous Diffusions" %}</h2>
{% include "cms/list.html" %}
</div>
{% endif %}
{% endwith %}
{% endblock %}

View File

@ -0,0 +1,91 @@
{% extends "cms/base_site.html" %}
{% load i18n %}
{% load wagtailcore_tags %}
{% load wagtailimages_tags %}
{% block content %}
{% if object_list %}
{# list view #}
<div class="body summary">
{{ page.summary }}
</div>
{% with list_paginator=paginator %}
{% include "cms/snippets/list.html" %}
{% endwith %}
{% else %}
{# detail view #}
<div class="content">
<img class="cover" src="{{ page.cover.file.url }}">
<div class="body">
{{ page.body|richtext}}
</div>
{% block content_extras %}
{% endblock %}
{% if page.related_links.all %}
<ul class="related">
<h3>{% trans "Related links" %}</h3>
{% for link in page.related_links.all %}
<li>
<a href="{{ link.url }}">
{% if link.icon %}{% image link.icon fill-size-32x32 %}{% endif %}
{{ link.title|default:link.url }}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
<div class="comments">
{% include "cms/snippets/comments.html" %}
</div>
</div>
{% block page_nav %}
<nav class="page_nav">
{% block metadata %}
<div class="meta">
<div class="author">
{% if page.publish_as %}
{% with page.publish_as as item %}
{% include "cms/snippets/list_item.html" %}
{% endwith %}
{% else %}
{{ page.owner }}
{% endif %}
</div>
<time datetime="{{ page.first_published_at }}">
{% trans "Published on " %}
{{ page.first_published_at|date:'l d F, H:i' }}
</time>
<div class="tags">
{% for tag in page.tags.all %}
{# <a href="{% pageurl page.blog_index %}?tag={{ tag }}">{{ tag }}</a> #}
{{ tag }}
{% endfor %}
</div>
</div>
{% endblock %}
{% block page_nav_extras %}
{% endblock %}
{% if page.recents %}
<div>
<h2>{% trans "Last Publications" %}</h2>
{% with object_list=page.recents %}
{% include "cms/snippets/list.html" %}
{% endwith %}
{# TODO: url to complete list of publications #}
</div>
{% endif %}
</nav>
{% endblock %}
{% endif %}
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "cms/base_site.html" %}
{% load i18n %}
{% block title %}
{% endblock %}

View File

@ -1,9 +1,6 @@
{% extends 'aircox/cms/list.html' %}
{% load i18n %}
{% load honeypot %}
{% block header %}
{% if comment_form %}
{% with comment_form as form %}
{{ form.non_field_errors }}
@ -11,6 +8,7 @@
{% csrf_token %}
{% render_honeypot_field "hp_website" %}
<div>
<input type="hidden" name="type" value="comments">
{{ form.author.errors }}
{{ form.author }}
<div>
@ -31,7 +29,24 @@
<button type="submit">{% trans "Post!" %}</button>
</div>
</form>
<ul>
{% for comment in page.comments %}
<li class="comment">
<div class="metadata">
<a {% if comment.url %}href="{{ comment.url }}" {% endif %}
class="author">{{ comment.author }}</a>
<time datetime="{{ comment.date }}">
{{ comment.date|date:'l d F, H:i' }}
</time>
</div>
<div class="body">{{ comment.content }}</div>
</li>
{% endfor %}
</ul>
{% endwith %}
{% endif %}
{% endblock %}

View File

@ -0,0 +1,9 @@
<div class="list">
{% for page in object_list %}
{% with item=page.specific %}
{% include "cms/snippets/list_item.html" %}
{% endwith %}
{% endfor %}
</div>

View File

@ -0,0 +1,16 @@
{% load wagtailimages_tags %}
<a {% if item.url %}href="{{ item.url }}" {% endif %}class="item page_item">
{% image item.cover fill-64x64 class="cover item_cover" %}
<h3>{{ item.title }}</h3>
<div class="summary">{{ item.summary }}</div>
{% if not item.show_in_menus %}
{% if item.date %}
<time datetime="{{ item.date }}">
{{ item.date|date:'l d F, H:i' }}
</time>
{% endif %}
{% endif %}
</a>

View File

@ -1,52 +0,0 @@
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()
@register.filter(name='downcast')
def downcast(post):
"""
Downcast an object if it has a downcast function, or just return the
post.
"""
if hasattr(post, 'downcast') and callable(post.downcast):
return post.downcast
return post
@register.filter(name='post_tags')
def post_tags(post, sep = ' - '):
"""
return the result of post.tags_url
"""
return utils.tags_to_html(type(post), post.tags.all(), sep)
@register.filter(name='threads')
def threads(post, sep = '/'):
"""
print the list of all the parents of the given post, from top to bottom
"""
posts = [post]
while posts[0].thread:
post = posts[0].thread
if post not in posts:
posts.insert(0, post)
return sep.join([
'<a href="{}">{}</a>'.format(post.url(), post.title)
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)

View File

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@ -1,22 +0,0 @@
import aircox.cms.routes as routes
def tags_to_html(model, tags, sep = ', '):
"""
Render tags as string of HTML urls. `self` can be a class, but in
this case, it `tags` must be provided.
tags must be an iterator on taggit's Tag models (or similar)
"""
website = model._website
r = []
for tag in tags:
url = website.reverse(model, routes.TagsRoute, tags = tag.slug)
if url:
r.append('<a href="{url}">{name}</a>'.format(
url = url, name = tag.name)
)
else:
r.append(tag.name)
return sep.join(r)

View File

@ -1,264 +1,12 @@
from django.templatetags.static import static
from django.template.loader import render_to_string
from django.views.generic import ListView, DetailView
from django.views.generic.base import View, TemplateView
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
sections_ = sections # used for name clashes
from django.shortcuts import render
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from aircox.cms.models import *
class BaseView:
"""
Render a page using given sections.
If sections is a list of sections, then render like a detail view;
If it is a single section, render it as website.html view;
# Request GET params:
* embed: view is embedded, render only the content of the view
"""
template_name = 'aircox/cms/website.html'
"""it is set to "aircox/cms/detail.html" to render multiple sections"""
sections = None
"""sections used to render the page"""
website = None
"""website that uses the view"""
menus = None
"""menus used to render the view page"""
title = ''
"""title of the page (used in <title> tags and page <h1>)"""
attrs = '' # attr for the HTML element of the content
"""attributes to set in the HTML element containing the view"""
css_class = '' # css classes for the HTML element of the content
"""css classes used for the HTML element containing the view"""
def __init__(self, sections = None, *args, **kwargs):
if hasattr(sections, '__iter__'):
self.sections = sections_.Sections(sections)
else:
self.sections = sections
super().__init__(*args, **kwargs)
def add_css_class(self, css_class):
"""
Add the given class to the current class list if not yet present.
"""
if self.css_class:
if css_class not in self.css_class.split(' '):
self.css_class += ' ' + css_class
else:
self.css_class = css_class
def get_context_data(self, **kwargs):
"""
Return a context with all attributes of this classe plus 'view' set
to self.
"""
context = {}
# update from sections
if self.sections:
if issubclass(type(self.sections), sections.Section):
self.template_name = self.sections.template_name
self.sections.prepare(self, **self.kwargs)
context.update(self.sections.get_context_data())
else:
if not self.template_name:
self.template_name = 'aircox/cms/detail.html'
context.update({
'content': self.sections.render(self)
})
context.update(super().get_context_data(**kwargs))
# then from me
context.update({
'website': self.website,
'view': self,
'title': self.title,
'tag': 'main',
'attrs': self.attrs,
'css_class': self.css_class,
})
if 'embed' not in self.request.GET:
if not kwargs.get('object'):
kwargs['object'] = self.object if hasattr(self, 'object') \
else None
if self.menus:
context['menus'] = {
k: v.render(self)
for k, v in self.menus.items()
if v is not self
}
context['actions'] = Actions.register_code()
context['embed'] = False
else:
context['embed'] = True
return context
class PostListView(BaseView, ListView):
"""
List view for posts and children.
If list is given:
- use list's template and css_class
- use list's context as base context
Note that we never use list.get_object_list, but instead use
route.get_queryset or self.model.objects.all()
Request.GET params:
* exclude: exclude item of the given id
* order: 'desc' or 'asc'
* page: page number
"""
template_name = 'aircox/cms/list.html'
allow_empty = True
paginate_by = 30
model = None
route = None
"""route used to render this list"""
@property
def list(self):
"""list section to use to render the list and get base context.
By default it is sections.List"""
return self.sections
@list.setter
def list(self, value):
self.sections = value
def dispatch(self, request, *args, **kwargs):
self.route = self.kwargs.get('route') or self.route
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
default = self.prepare_list()
if not default:
qs = self.list.get_object_list()
if qs is not None:
return qs
if self.route:
qs = self.route.get_queryset(self.model, self.request,
**self.kwargs)
else:
qs = self.queryset or self.model.objects.all()
qs = qs.filter(published = True)
query = self.request.GET
if query.get('exclude'):
qs = qs.exclude(id = int(query['exclude']))
if query.get('order') == 'desc':
qs = qs.order_by('-date', '-id')
else:
qs = qs.order_by('date', 'id')
return qs
def prepare_list(self):
"""
Prepare the list and return True if the list has been created using
defaults.
"""
if not self.list:
self.list = sections.List(
truncate = 32,
paginate_by = 0,
)
default = True
elif type(self.list) == type:
self.list = self.list(paginate_by = 0)
self.template_name = self.list.template_name
self.css_class = self.list.css_class
default = False
self.list.prepare(self, **self.kwargs)
if self.request.GET.get('fields'):
self.list.fields = [
field for field in self.request.GET.getlist('fields')
if field in self.list.fields
]
# done in list
# Actions.make(self.request, object_list = self.object_list)
return default
def get_context_data(self, **kwargs):
self.add_css_class('list')
context = super().get_context_data(**kwargs)
if not context.get('object_list'):
context['object_list'] = self.list.object_list
if self.route and not context.get('title'):
context['title'] = self.route.get_title(
self.model, self.request, **self.kwargs
)
context['list'] = self.list
return context
class PostDetailView(BaseView, DetailView):
"""
Detail view for posts and children
"""
comments = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_css_class('detail')
def get_queryset(self):
if self.model:
return super().get_queryset().filter(published = True)
return []
def get_object(self, **kwargs):
if self.model:
object = super().get_object(**kwargs)
if object.published:
return object
return None
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if not context.get('title'):
context['title'] = self.object.title
return context
def post(self, request, *args, **kwargs):
"""
Handle new comments
"""
self.sections.prepare(self)
if not self.comments:
for section in self.sections:
if issubclass(type(section), sections.Comments):
self.comments = section
self.object = self.get_object()
self.comments.prepare(self)
self.comments.post(self, request, self.object)
return self.get(request, *args, **kwargs)
class PageView(BaseView, TemplateView):
"""
Render a page using given sections.
If sections is a list of sections, then render like a detail view;
If it is a single section, render it as website.html view;
"""
def index_page(request):
context = {}
if ('tag' or 'search') in request.GET:
qs = Publication.get_queryset(request, context = context)
return render(request, 'index_page.html', context)

View File

@ -1,249 +0,0 @@
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
import aircox.cms.routes as routes_
import aircox.cms.views as views
import aircox.cms.models as models
import aircox.cms.sections as sections
import aircox.cms.sections as sections_
class Website:
"""
Describe a website and all its settings that are used for its rendering.
"""
## metadata
name = ''
domain = ''
description = 'An aircox website'
tags = 'aircox,radio,music'
## rendering
styles = ''
"""extra css style file"""
menus = None
"""dict of default menus used to render website pages"""
## user interaction
allow_comments = True
"""allow comments on the website"""
auto_publish_comments = False
"""publish comment without human approval"""
comments_routes = True
"""register list routes for the Comment model"""
## components
Registration = namedtuple('Registration',
'name model routes default'
)
urls = []
"""list of urls generated thourgh registrations"""
exposures = []
"""list of registered exposures (done through sections registration)"""
registry = {}
"""dict of registered models by their name"""
def __init__(self, menus = None, **kwargs):
"""
* menus: a list of menus to add to the website
"""
self.registry = {}
self.exposures = []
self.urls = [ url(r'^exps/', include(self.exposures)) ]
self.menus = {}
self.__dict__.update(kwargs)
if menus:
for menu in menus:
self.set_menu(menu)
if self.comments_routes:
self.register_comments()
def register_model(self, name, model, default):
"""
Register a model and update model's fields with few data:
- _website: back ref to self
- _registration: ref to the registration object
Raise a ValueError if another model is yet associated under this name.
"""
if name in self.registry:
reg = self.registry[name]
if reg.model is model:
return reg
raise ValueError('A model has yet been registered under "{}"'
.format(reg.model, name))
reg = self.Registration(name, model, [], default)
self.registry[name] = reg
model._registration = reg
model._website = self
return reg
def register_exposures(self, sections):
"""
Register exposures that are used in the given sections.
"""
if not hasattr(sections, '__iter__'):
sections = [sections]
for section in sections:
if not hasattr(section, '_exposure'):
continue
self.exposures += section._exposure.gather(section, website = self)
section.website = self
def __route_to_url(self, name, route, view, sections, kwargs):
# route can be a tuple
if type(route) in (tuple,list):
route, view = route
view = view.as_view(
website = self, **kwargs
)
# route can be a route or a string
if type(route) == type and issubclass(route, routes_.Route):
return route.as_url(name, view)
return url(
slugify(name) if not route else str(route),
view = view, name = name, kwargs = kwargs
)
def add_page(self, name, routes = [], view = views.PageView,
sections = None, default = False, **kwargs):
"""
Add a view and declare it on the given routes.
* routes: list of routes or patterns, or tuple (route/pattern, view)
to force a view to be used;
* view: view to use by default to render the page;
* sections: sections to display on the view;
* default: use this as a default view;
* kwargs: extra kwargs to pass to the view;
If view is a section, generate a PageView with this section as
child. Note: the kwargs are passed to the PageView constructor.
"""
if view and issubclass(type(view), sections_.Section):
sections, view = view, views.PageView
if not kwargs.get('menus'):
kwargs['menus'] = self.menus
if sections:
self.register_exposures(sections)
view = view.as_view(website = self, sections = sections, **kwargs)
if not hasattr(routes, '__iter__'):
routes = [routes]
self.urls += [
self.__route_to_url(name, route, view, sections, kwargs)
for route in routes
]
def add_model(self, name, model, sections = None, routes = None,
default = False,
list_view = views.PostListView,
detail_view = views.PostDetailView,
**kwargs):
"""
Add a model to the Website, register it and declare its routes.
* model: model to register
* sections: sections to display in the *detail* view;
* routes: routes to use for the *list* view -- cf. add_page.routes;
* default: use as default route;
* list_view: use it as view for lists;
* detail_view: use it as view for details;
* kwargs: extra kwargs arguments to pass to the view;
"""
# register the model and the routes
reg = self.register_model(name, model, default)
reg.routes.extend([
route[0] if type(route) in (list,tuple) else route
for route in routes
])
reg.routes.append(routes_.DetailRoute)
kwargs['model'] = model
if sections:
self.add_page(name, view = detail_view, sections = sections,
routes = routes_.DetailRoute, default = default,
**kwargs)
if routes:
self.add_page(name, view = list_view, routes = routes,
default = default, **kwargs)
def register_comments(self):
"""
Register routes for comments, for the moment, only
ThreadRoute.
Just a wrapper around `register`.
"""
self.add_model(
'comment',
model = models.Comment,
routes = [routes.ThreadRoute],
css_class = 'comments',
list = sections.Comments
)
def set_menu(self, menu):
"""
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
elif menu.position in ('left', 'right'):
menu.tag = 'side'
self.menus[menu.position] = menu
self.register_exposures(menu.sections)
def find_default(self, route):
"""
Return a registration that can be used as default for the
given route.
"""
for r in self.registry.values():
if r.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.default and route in r.routes:
try:
name = route.make_view_name(r.name)
return reverse(name, kwargs = kwargs)
except:
pass
return ''

View File

@ -97,7 +97,7 @@ class Monitor:
logs = [ log.related_id for log in logs ]
tracks = programs.Track.get_for(object = log.related) \
.filter(pos_in_secs = True)
.filter(in_seconds = True)
if tracks and len(tracks) == len(logs):
return

View File

@ -175,6 +175,6 @@ class ScheduleAdmin(admin.ModelAdmin):
@admin.register(Track)
class TrackAdmin(admin.ModelAdmin):
list_display = ['id', 'title', 'artist', 'position', 'pos_in_secs', 'related']
list_display = ['id', 'title', 'artist', 'position', 'in_seconds', 'related']

View File

@ -64,12 +64,12 @@ class Importer:
maps = settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS
tracks = []
pos_in_secs = ('minutes' or 'seconds') in maps
in_seconds = ('minutes' or 'seconds') in maps
for index, line in enumerate(self.data):
position = \
int(self.__get(line, 'minutes', 0)) * 60 + \
int(self.__get(line, 'seconds', 0)) \
if pos_in_secs else index
if in_seconds else index
track, created = Track.objects.get_or_create(
related_type = ContentType.objects.get_for_model(related),
@ -79,7 +79,7 @@ class Importer:
position = position,
)
track.pos_in_secs = pos_in_secs
track.in_seconds = pos_in_secs
track.info = self.__get(line, 'info')
tags = self.__get(line, 'tags')
if tags:

View File

@ -348,6 +348,10 @@ class Schedule(models.Model):
help_text = 'this schedule is a rerun of this one',
)
@property
def end(self):
return self.date + utils.to_timedelta(self.duration)
def match(self, date = None, check_time = True):
"""
Return True if the given datetime matches the schedule
@ -746,9 +750,9 @@ class Track(Related):
_('artist'),
max_length = 128,
)
position = models.SmallIntegerField(
default = 0,
help_text=_('position in the playlist'),
tags = TaggableManager(
verbose_name=_('tags'),
blank=True,
)
info = models.CharField(
_('information'),
@ -757,12 +761,12 @@ class Track(Related):
help_text=_('additional informations about this track, such as '
'the version, if is it a remix, features, etc.'),
)
tags = TaggableManager(
verbose_name=_('tags'),
blank=True,
position = models.SmallIntegerField(
default = 0,
help_text=_('position in the playlist'),
)
pos_in_secs = models.BooleanField(
_('seconds'),
in_seconds = models.BooleanField(
_('in seconds'),
default = False,
help_text=_('position in the playlist is expressed in seconds')
)

View File

@ -1,31 +0,0 @@
# Website
Application that propose a set of different tools that might be common to
different radio projects. This application has been started to avoid to
pollute *aircox.cms* with aircox specific code and models that might not
be used in other cases.
We define here different models and sections that can be used to construct
a website in a fast and simple manner.
# Dependencies
* `django-suit`: admin interface;
* `django-autocomplete-light`: autocompletion in the admin interface;
* `aircox.cms`, `aircox.programs`
# Features
## Models
* **Program**: publication related to a program;
* **Diffusion**: publication related to an initial Diffusion;
## Sections
* **Player**: player widget
* **Diffusions**: generic section list to retrieve diffusions by date, related
or not to a specific Program. If wanted, can show schedule in the header of
the section (with indication of reruns).
* **Playlist**: playlist of a given Diffusion
## Admin
Register all models declared upper, uses django-suit features in order to manage
some fields and autocompletion.

View File

View File

@ -1,74 +0,0 @@
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_diffusion(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
if not in_list:
return False
if issubclass(type(object), Diffusion):
return cl.make_for_diffusion(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);
}
"""

View File

@ -1,27 +0,0 @@
from django.contrib import admin
from suit.admin import SortableTabularInline, SortableModelAdmin
from suit.admin import SortableGenericTabularInline
import aircox.programs.models as programs
import aircox.cms.admin as cms
import aircox.website.models as models
import aircox.website.forms as forms
class TrackInline(SortableGenericTabularInline):
ct_field = 'related_type'
ct_fk_field = 'related_id'
form = forms.TrackForm
model = programs.Track
sortable = 'position'
extra = 4
fields = ['artist', 'title', 'tags', 'info', 'position']
admin.site.register(models.Article, cms.PostAdmin)
admin.site.register(models.Program, cms.RelatedPostAdmin)
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)

View File

@ -1,40 +0,0 @@
import autocomplete_light.shortcuts as al
import aircox.programs.models as programs
from taggit.models import Tag
al.register(Tag)
class OneFieldAutocomplete(al.AutocompleteModelBase):
choice_html_format = u'''
<span class="block" data-value="%s">%s</span>
'''
def choice_html (self, choice):
value = choice[self.search_fields[0]]
return self.choice_html_format % (self.choice_label(choice),
self.choice_label(value))
def choices_for_request(self):
#if not self.request.user.is_staff:
# self.choices = self.choices.filter(private=False)
filter_args = { self.search_fields[0] + '__icontains': self.request.GET['q'] }
self.choices = self.choices.filter(**filter_args)
self.choices = self.choices.values(self.search_fields[0]).distinct()
return self.choices
class TrackArtistAutocomplete(OneFieldAutocomplete):
search_fields = ['artist']
model = programs.Track
al.register(TrackArtistAutocomplete)
class TrackNameAutocomplete(OneFieldAutocomplete):
search_fields = ['name']
model = programs.Track
al.register(TrackNameAutocomplete)

View File

@ -1,19 +0,0 @@
from django import forms
#import autocomplete_light.shortcuts as al
#from autocomplete_light.contrib.taggit_field import TaggitWidget
import aircox.programs.models as programs
class TrackForm (forms.ModelForm):
class Meta:
model = programs.Track
fields = ['artist', 'title', 'tags', 'position']
widgets = {
# 'artist': al.TextWidget('TrackArtistAutocomplete'),
# 'name': al.TextWidget('TrackNameAutocomplete'),
# 'tags': TaggitWidget('TagAutocomplete'),
}

View File

@ -1,148 +0,0 @@
import os
import stat
import logging
logger = logging.getLogger('aircox')
from django.db import models
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):
"""
Represent an article or a static page on the website.
"""
static_page = models.BooleanField(
_('static page'),
default = False,
)
focus = models.BooleanField(
_('article is focus'),
default = False,
)
class Meta:
verbose_name = _('Article')
verbose_name_plural = _('Articles')
class Program (cms.RelatedPost):
website = models.URLField(
_('website'),
blank=True, null=True
)
# rss = models.URLField()
email = models.EmailField(
_('email'), blank=True, null=True,
help_text=_('contact address, stays private')
)
class Relation:
model = programs.Program
bindings = {
'title': 'name',
}
rel_to_post = True
auto_create = True
class DiffusionManager(models.Manager):
@staticmethod
def post_or_default(diff, post, create = True, save = False):
if not post and create:
post = Diffusion(related = diff.initial or diff)
if save:
post.save()
else:
post.rel_to_post()
if post:
post.date = diff.start
post.related = diff
return post
def get_for(self, diffs, create = True, save = False):
"""
Get posts for the given related diffusion. Return a list
not a Queryset, ordered following the given list.
Update the post objects to make date corresponding to the
diffusions.
- diffs: a programs.Diffusion, or iterable of
programs.Diffusion. In the first case, return
an object instead of a list
- create: create a post for each Diffusion if missing
- save: save the created posts
"""
if not hasattr(diffs, '__iter__'):
qs = self.filter(related = diffs.initial or diff,
published = True)
return post_or_default(diffs, post, create, save)
qs = self.filter(related__in = [
diff.initial or diff for diff in diffs
], published = True)
posts = []
for diff in diffs:
post = qs.filter(related = diff.initial or diff).first()
post = self.post_or_default(diff, post, create, save)
if post:
posts.append(post)
return posts
class Diffusion(cms.RelatedPost):
objects = DiffusionManager()
actions = [actions.Play, actions.AddToPlaylist]
class Relation:
model = programs.Diffusion
bindings = {
'thread': 'program',
'title': lambda post, rel: rel.program.name,
'date': 'start',
}
fields_args = {
'limit_choice_to': {
'initial': None
}
}
rel_to_post = True
def auto_create(object):
return not object.initial
def __init__(self, *args, rel_to_post = False, **kwargs):
super().__init__(*args, **kwargs)
if rel_to_post and self.related:
self.rel_to_post()
self.fill_empty()
if not self.subtitle and hasattr(self, 'related'):
self.subtitle = _('Diffusion of the %(date)s') % {
'date': self.related.start.strftime('%A %d/%m')
}
@property
def info(self):
if not self.related or not self.related.initial:
return
return _('rerun of %(day)s') % {
'day': self.related.initial.start.strftime('%A %d/%m')
}
def url(self):
url = super().url()
if url or not self.related.initial:
return url
post = Diffusions.objects.filter(related = self.related.initial) \
.first()
return post.url() if post else ''

View File

@ -1,393 +0,0 @@
import json
import datetime
from django.utils import timezone as tz
from django.utils.translation import ugettext as _, ugettext_lazy
import aircox.programs.models as programs
import aircox.controllers.models as controllers
import aircox.cms.models as cms
import aircox.cms.routes as routes
import aircox.cms.sections as sections
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
@expose
class Player(sections.Section):
"""
Display a player that is cool.
"""
template_name = 'aircox/website/player.html'
live_streams = []
"""
ListItem objects that display a list of available streams.
"""
#default_sounds
@expose
def on_air(cl, request):
now = tz.now()
qs = programs.Diffusion.objects.get_at(now).filter(
type = programs.Diffusion.Type.normal
)
if not qs or not qs[0].is_date_in_range():
return {}
qs = qs[0]
post = models.Diffusion.objects.filter(related = qs) or \
models.Program.objects.filter(related = qs.program)
if post:
post = post[0]
else:
post = ListItem(title = qs.program.name)
return {
'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,
'recents': self.get_recents(10),
})
return context
class Diffusions(sections.List):
"""
Section that print diffusions. When rendering, if there is no post yet
associated, use the programs' article.
"""
order_by = '-start'
show_schedule = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def get_diffs(self, **filter_args):
qs = programs.Diffusion.objects.filter(
type = programs.Diffusion.Type.normal
)
if self.object:
obj = self.object.related
obj_type = type(obj)
if obj_type == programs.Program:
qs = qs.filter(program = obj)
elif obj_type == programs.Diffusion:
if obj.initial:
obj = obj.initial
qs = qs.filter(initial = obj) | qs.filter(pk = obj.pk)
if filter_args:
qs = qs.filter(**filter_args).order_by('start')
return qs
#r = []
#if self.next_count:
# r += list(programs.Diffusion.get(next=True, queryset = qs)
# .order_by('-start')[:self.next_count])
#if self.prev_count:
# r += list(programs.Diffusion.get(prev=True, queryset = qs)
# .order_by('-start')[:self.prev_count])
#return r
def get_object_list(self):
diffs = self.get_diffs().order_by('start')
return models.Diffusion.objects.get_for(diffs)
@property
def url(self):
if not self.need_url():
return
if self.object:
return models.Diffusion.reverse(routes.ThreadRoute,
pk = self.object.id,
thread_model = 'program',
)
return models.Diffusion.reverse(routes.AllRoute)
@property
def header(self):
if not self.show_schedule:
return None
def str_sched(sched):
info = ' <span class="info">(' + _('rerun of %(day)s') % {
'day': sched.initial.date.strftime('%A')
} + ')</span>' if sched.initial else ''
text = _('%(day)s at %(time)s, %(freq)s') % {
'day': sched.date.strftime('%A'),
'time': sched.date.strftime('%H:%M'),
'freq': sched.get_frequency_display(),
}
return text + info
return ' / \n'.join([str_sched(sched)
for sched in programs.Schedule.objects \
.filter(program = self.object and self.object.related.pk)
])
class Playlist(sections.List):
title = _('Playlist')
message_empty = ''
def get_object_list(self):
tracks = programs.Track.get_for(object = self.object.related) \
.order_by('position')
return [ sections.ListItem(title=track.title, content=track.artist)
for track in tracks ]
class Sounds(sections.List):
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 ListByDate(sections.List):
"""
List that add a navigation by date in its header. It aims to be
used with DateRoute.
"""
template_name = 'aircox/website/list_by_date.html'
message_empty = ''
model = None
date = None
"""
date of the items to print
"""
nav_days = 7
"""
number of days to display in the header
"""
nav_date_format = '%a. %d'
"""
format of dates to display in the header
"""
nav_per_week = True
"""
if true, print days in header by week
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.add_css_class('list_by_date')
def nav_dates(self, date):
"""
Return a list of dates of the week of the given date.
"""
first = int((self.nav_days - 1) / 2)
first = date - tz.timedelta(days=date.weekday()) \
if self.nav_per_week else \
date - tz.timedelta(days=first)
return [ first + tz.timedelta(days=i) for i in range(0, self.nav_days) ]
def date_or_default(self):
"""
Return self.date or create a date if needed, using kwargs'
year, month, day attributes if exists (otherwise, use today)
"""
if self.date:
return datetime.date(self.date)
elif self.kwargs and 'year' in self.kwargs:
return datetime.date(
year = int(self.kwargs['year']),
month = int(self.kwargs['month']),
day = int(self.kwargs['day'])
)
return datetime.date.today()
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
date = self.date_or_default()
dates = [ (date, self.get_date_url(date))
for date in self.nav_dates(date) ]
# FIXME
next_week = dates[-1][0] + tz.timedelta(days=1)
next_week = self.get_date_url(next_week)
prev_week = dates[0][0] - tz.timedelta(days=1)
prev_week = self.get_date_url(prev_week)
context.update({
'nav': {
'date': date,
'dates': dates,
'next': next_week,
'prev': prev_week,
}
})
return context
@staticmethod
def get_date_url(date):
"""
return an url for the given date
"""
return self.view.website.reverse(
model = self.model, route = routes.DateRoute,
year = date.year, month = date.month, day = date.day,
)
@property
def url(self):
return None
class Schedule(Diffusions,ListByDate):
"""
Render a list of diffusions in the form of a schedule
"""
model = models.Diffusion
fields = [ 'time', 'image', 'title', 'content', 'info', 'actions' ]
truncate = 30
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_css_class('schedule')
def get_object_list(self):
date = self.date_or_default()
diffs = programs.Diffusion.objects.get_at(date).order_by('start')
return models.Diffusion.objects.get_for(diffs, create = True)
@staticmethod
def get_date_url(date):
"""
return an url for the given date
"""
return models.Diffusion.reverse(
routes.DateRoute,
year = date.year, month = date.month, day = date.day,
)
class Logs(ListByDate):
"""
Print a list of played stream tracks and diffusions.
Note that for the moment we don't print if the track has been
partially hidden by a scheduled diffusion
"""
model = controllers.Log
@staticmethod
def make_item(item):
"""
Return a list of items to add to the playlist.
Only support Log related to a Track and programs.Diffusion
"""
if issubclass(type(item), programs.Diffusion):
return models.Diffusion.objects.get_for(
item, create = True, save = False
)
track = log.related
post = ListItem(
title = track.name,
subtitle = track.artist,
date = log.date,
content = track.info,
css_class = 'track',
info = '',
)
return post
def get_object_list(self):
date = self.date_or_default()
if date > datetime.date.today():
return []
logs = controllers.Log.get_for(model = programs.Track) \
.filter(date__contains = date) \
.order_by('date')
diffs = programs.Diffusion.objects.get_at(date) \
.filter(type = programs.Diffusion.Type.normal)
items = []
prev_diff = None
for diff in diffs:
logs_ = logs.filter(date__gt = prev_diff.end,
date__lt = diff.start) \
if prev_diff else \
logs.filter(date__lt = diff.start)
prev_diff = diff
items.extend(logs_)
items.append(diff)
return list(map(self.make_item, items))

View File

@ -1,694 +0,0 @@
{% extends 'aircox/cms/list.html' %}
{% load staticfiles %}
{% load i18n %}
{% block header %}
<style>
.player-box {
padding-top: 0.2em;
}
.player-box * {
vertical-align: middle;
}
.player-box h3, #player h2 {
display: inline-block;
font-size: 0.9em;
margin: 0;
padding: 0;
}
.player-button {
display: inline-block;
width: 1.5em;
height: 1.5em;
overflow: hidden;
cursor: pointer;
margin-right: 0.4em;
text-align: center;
}
.player-button:after {
content: '▶';
}
#player[state="playing"] .player-button:after {
content: '▮▮';
}
#player[state="stalled"] .player-button:after {
content: '...';
}
#player .on_air {
padding: 0.2em;
}
#player .on_air .title {
font-size: 0.8em;
display: inline;
}
#player .on_air a {
float: right;
}
#player .info {
float: right;
}
#player progress {
background: none;
border: none;
width: 100%;
height: 0.8em;
cursor: pointer;
display: none;
}
#player[seekable]:hover progress {
display: block;
}
#player .playlists {
}
#player .playlists ul:not([selected]) {
display: none;
}
#player .playlists nav > a.close,
#player .playlists nav > label {
float: right;
}
#player-single-mode + label[for="player-single-mode"]::after {
content:"{% trans "single mode" %}";
}
#player .playlist {
margin: 0;
padding: 0;
height: 15em;
/*overflow-y: auto;*/
}
#player .playlist .item > *:not(.actions) {
display: inline;
margin: 0.2em;
vertical-align: middle;
}
#player .playlist .actions {
float: right;
}
#player .playlist .actions a.action {
display: inline;
}
#player .playlist .actions label,
#playlist-live .actions,
#playlist-recents .actions a.action[action="remove"],
#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=""] {
display: none;
}
#player .playlist .action[action="remove"] {
float: right;
}
</style>
<div id="player">
<li class='item' style="display: none;">
<h2 class="title"></h2>
<div class="info"></div>
<div class="actions">
<a class="action" action="sound.mark"
title="{% trans "add to my favorites" %}">★</a>
<a class="url action" title="{% trans "more informations" %}"></a>
<a class="action" action="sound.remove"
title="{% trans "remove from the playlist" %}">✖</a>
</div>
</li>
<div class="player-box">
<div id="embed-player">
</div>
<div id="simple-player">
<audio preload="metadata">
Your browser does not support the <code>audio</code> element.
</audio>
<span class="player-button" onclick="Player.play()"
title="{% trans "play/pause" %}"></span>
<h3 class="title"></h3>
<div class="progress info"></div>
</div>
<progress value="0" max="1"></progress>
</div>
<div class="playlists">
<nav>
<input type="checkbox" class="single" id="player-single-mode">
<label for="player-single-mode"></label>
</nav>
</div>
<div class='item on_air'>
<h2 class="title"></h2>
<a class="url"></a>
</div>
</div>
<script>
PlayerStore = {
// save data to localstorage, or remove it if data is null
set: function(name, data) {
name = 'player.' + name;
if(data == undefined) {
localStorage.removeItem(name);
return;
}
localStorage.setItem(name, JSON.stringify(data))
},
// load data from localstorage
get: function(name) {
try {
name = 'player.' + name;
var data = localStorage.getItem(name);
if(data)
return JSON.parse(data);
}
catch(e) { console.log(e, data); }
},
// return true if the given item is stored
exists: function(name) {
name = 'player.' + name;
return (localStorage.getItem(name) != null);
},
// update a field in the stored data
update: function(name, key, value) {
data = this.get(name) || {};
if(value)
data[key] = value;
else
delete data[key];
this.set(name, data);
},
}
// Create a Playlist:
// * name: name of the playlist, used for container id and storage
// * tab: text to put in the tab
// * items: list of items to append
// * store: store the playlist in localStorage
function Playlist(name, tab, items, store = false) {
this.name = name;
this.store = store;
this.playlist = document.createElement('ul');
this.playlist.setAttribute('id', 'playlist-' + name );
this.playlist.className = 'playlist list';
var self = this;
this.tab = document.createElement('a');
this.tab.addEventListener('click', function(event) {
Player.select_playlist(self);
event.preventDefault();
}, true);
this.tab.className = 'tab';
this.tab.innerHTML = tab;
Player.playlists.appendChild(this.playlist);
Player.playlists.querySelector('nav').appendChild(this.tab);
this.items = [];
if(store)
this.load()
if(items)
this.add_list(items);
}
Playlist.prototype = {
items: undefined,
/// find an item in playlist
find: function(item, by_stream = false) {
if(by_stream)
return this.items.find(function(v) {
return v.stream == item;
});
return this.items.find(function(v) {
return v.stream == item.stream;
});
},
/// add sound actions to a given element
add_actions: function(item, container) {
Actions.add_action(container, 'sound.mark', item);
Actions.add_action(container, 'sound.play', item, item.stream);
var elm = container.querySelector('.actions a[action="sound.mark"]');
elm.addEventListener('click', function(event) {
Player.favorites.add(item);
}, true);
var elm = container.querySelector('.actions a[action="sound.remove"]');
elm.addEventListener('click', function() {
item.playlist.remove(item);
}, true);
},
/// add an item to the playlist or container, if not in this.playlist.
/// return the existing item or the newly created item.
add: function(item, container) {
var item_ = this.find(item);
if(item_)
return item_;
var elm = Player.player.querySelector('.item').cloneNode(true);
elm.removeAttribute('style');
if(!container)
container = this.playlist;
if(container.childNodes.length)
container.insertBefore(elm, container.childNodes[0]);
else
container.appendChild(elm);
item = {
title: item.title,
url: item.url,
stream: item.stream,
info: item.info,
seekable: 'seekable' in item ? item.seekable : true,
elm: elm,
playlist: this,
}
elm.item = item;
elm.querySelector('.title').innerHTML = item.title || '';
elm.querySelector('.url').href = item.url || '';
elm.querySelector('.info').innerHTML = item.info || '';
if(item.class)
elm.className += " " + item.class;
elm.addEventListener('click', function(event) {
if(event.currentTarget.tagName == 'A' ||
event.target.tagName == 'A')
return;
var item = event.currentTarget.item;
if(item.stream || item.embed)
Player.select(item);
event.stopPropagation();
return true;
}, false);
if(item.embed || item.stream)
this.add_actions(item, elm);
this.items.push(item);
if(container == this.playlist && this.store)
this.save()
return item;
},
/// Add a list of items (optimized)
add_list: function (items) {
var container = document.createDocumentFragment();
for(var i = 0; i < items.length; i++)
this.add(items[i], container);
this.playlist.appendChild(container);
if(this.store)
this.save()
},
/// remove an item from the playlist
remove: function(item) {
for(var i = 0; i < this.items.length; i++) {
var item_ = this.items[i];
if(item_.stream != item.stream)
continue;
item_.elm.parentNode.removeChild(item_.elm);
this.items.splice(i,1);
break;
}
if(this.store)
this.save()
},
/// Save a playlist to local storage
save: function() {
var pl = [];
for(var i = 0; i < this.items.length; i++) {
var item = Object.assign({}, this.items[i])
delete item.elm;
delete item.playlist;
pl.push(item);
}
PlayerStore.set('playlist.' + this.name, pl)
},
/// Load playlist from local storage
load: function() {
var pl = PlayerStore.get('playlist.' + this.name);
if(pl)
this.add_list(pl);
},
/// called by Player when the given item is unselected
unselect: function(item) {
this.tab.removeAttribute('active');
if(item.elm)
item.elm.removeAttribute('selected');
var audio = Player.audio;
if(this.store && !audio.ended) {
item.currentTime = audio.currentTime;
this.save();
}
},
/// called by Player when the given item is selected, in order to
/// prepare it.
select: function(item) {
this.tab.setAttribute('active', 'true');
if(item.elm)
item.elm.setAttribute('selected', 'true');
},
}
function PlayerProgress(player) {
this.player = player;
this.progress = player.player.querySelector('progress');
this.info = player.player.querySelector('.progress.info');
var self = this;
// events
player.audio.addEventListener('timeupdate', function(evt) {
self.update();
}, false);
this.progress.addEventListener('click', function(evt) {
player.audio.currentTime = self.time_from_event(evt);
}, false);
this.progress.addEventListener('mouseout', function(evt) {
self.update();
}, false);
this.progress.addEventListener('mousemove', function(evt) {
if(self.player.audio.duration == Infinity)
return;
var pos = self.time_from_event(evt);
self.info.innerHTML = self.secs_to_str(pos);
}, false);
}
PlayerProgress.prototype = {
update: function() {
if( //!this.player.item.seekable ||
this.player.audio.duration == Infinity) {
this.info.innerHTML = '';
this.progress.value = 0;
return;
}
var pos = this.player.audio.currentTime;
this.progress.value = pos;
this.progress.max = this.player.audio.duration;
this.info.innerHTML = this.secs_to_str(pos);
},
secs_to_str: function(seconds) {
seconds = Math.floor(seconds);
var hours = Math.floor(seconds / 3600);
seconds -= hours;
var minutes = Math.floor(seconds / 60);
seconds -= minutes;
var str = hours ? (hours < 10 ? '0' + hours : hours) + ':' : '';
str += (minutes < 10 ? '0' + minutes : minutes) + ':';
str += (seconds < 10 ? '0' + seconds : seconds);
return str;
},
time_from_event: function(evt) {
bounding = this.progress.getBoundingClientRect()
offset = (evt.clientX - bounding.left);
return offset * this.player.audio.duration / bounding.width;
},
}
Player = {
/// main container of the Player
player: undefined,
/// <audio> container
audio: undefined,
/// controls
controls: undefined,
/// init Player
init: function(id) {
this.player = document.getElementById(id);
this.audio = this.player.querySelector('audio');
this.controls = {
single: this.player.querySelector('input.single'),
}
// TODO: event on controls -> save info in storage
this.__init_audio();
this.__init_playlists();
this.progress = new PlayerProgress(this);
this.load();
},
__init_audio: function() {
var self = this;
this.audio.addEventListener('playing', function() {
self.player.setAttribute('state', 'playing');
}, false);
this.audio.addEventListener('pause', function() {
self.player.setAttribute('state', 'paused');
}, false);
this.audio.addEventListener('loadstart', function() {
self.player.setAttribute('state', 'stalled');
}, false);
this.audio.addEventListener('loadeddata', function() {
self.player.removeAttribute('state');
}, false);
this.audio.addEventListener('timeupdate', function() {
if(!self.item.seekable)
return;
PlayerStore.set('stream.' + self.item.stream + '.pos',
self.audio.currentTime);
}, false);
this.audio.addEventListener('ended', function() {
PlayerStore.set('streams.' + self.item.stream + '.pos')
single = self.player.querySelector('input.single');
if(!single.checked)
self.next(true);
}, false);
},
__init_playlists: function() {
this.playlists = this.player.querySelector('.playlists');
this.live = new Playlist(
'live',
" {% trans "live" %}",
[ {% for sound in live_streams %}
{ title: "{{ sound.title }}",
url: "{{ sound.url }}",
stream: "{{ sound.url }}",
info: "{{ sound.info }}",
seekable: false,
}, {% endfor %} ]
);
this.recents = new Playlist(
'recents', '{% trans "recents" %}',
[ {% for sound in recents %}
{ title: "{{ sound.title }}",
url: "{{ sound.url }}",
{% if sound.related.embed %}
embed: "{{ sound.related.embed }}",
{% else %}
stream: "{{ sound.related.url|safe }}",
{% endif %}
info: "{{ sound.related.duration|date:"H:i:s" }}",
}, {% endfor %} ]
);
this.favorites = new Playlist(
'favorites', '★ {% trans "favorites" %}', null, true
);
this.playlist = new Playlist(
'playlist', '☰ {% trans "playlist" %}', null, true
);
this.select(this.live.items[0], false);
this.select_playlist(this.recents);
this.update_on_air();
},
load: function() {
var data = PlayerStore.get('Player');
if(!data)
return;
if(data.playlist)
this.select_playlist(this[data.selected_playlist]);
if(data.stream) {
item = this.playlist.find(data.stream, true);
item && this.select(item, false);
}
this.controls.single.checked = data.single
},
save: function() {
PlayerStore.set('player', {
'selected_playlist': this.__playlist && this.__playlist.name,
'stream': this.item && this.item.stream,
'single': this.controls.single.checked,
});
},
/** Player actions **/
/// play a given item { title, src }
play: function() {
var audio = this.audio;
if(audio.paused)
audio.play();
else
audio.pause();
},
__ask_to_seek(item) {
if(!item.seekable)
return;
var key = 'stream.' + item.stream + '.pos'
var pos = PlayerStore.get(key);
if(!pos)
return
if(confirm("{% trans "restart from the last position?" %}"))
this.audio.currentTime = Math.max(pos - 5, 0);
PlayerStore.set(key);
},
/// select the current track to play, and start playing it
select: function(item, play = true) {
var audio = this.audio;
var player = this.player;
if(this.item && this.item.playlist)
this.item.playlist.unselect(this.item);
audio.pause();
audio.src = item.stream;
audio.load();
this.item = item;
if(this.item && this.item.playlist)
this.item.playlist.select(this.item);
player.querySelectorAll('#simple-player .title')[0]
.innerHTML = item.title;
if(this.item.seekable)
player.setAttribute('seekable', true);
else
player.removeAttribute('seekable', true);
if(play) {
this.__ask_to_seek(item);
this.play();
}
this.save();
},
/// 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);
if(index == -1)
return;
index--;
if(index >= 0)
this.select(this.__playlist.items[index], play);
},
/// remove selection using the given selector.
__unselect: function (selector) {
v = this.player.querySelectorAll(selector);
if(v)
for(var i = 0; i < v.length; i++)
v[i].removeAttribute('selected');
},
/// select current playlist to show
select_playlist: function(playlist) {
this.__unselect('.playlists nav .tab[selected]');
this.__unselect('.playlists .playlist[selected]');
this.__playlist = playlist;
if(playlist) {
playlist.playlist.setAttribute('selected', 'true');
playlist.tab.setAttribute('selected', 'true');
}
},
/** utility & actions **/
/// update on air informations
update_on_air: function() {
rq = Request('{% url exp.name key="on_air" %}').get()
.select({
title: '.title',
url: ['.url', 'href'],
})
.map(this.player.querySelector('.on_air'))
.send();
window.setTimeout(function() {
Player.update_on_air();
}, 60000*5);
},
}
Player.init('player');
</script>
{% endblock %}

View File

@ -1,6 +0,0 @@
def duration_to_str(duration):
return duration.strftime(
'%H:%M:%S' if duration.hour else '%M:%S'
)