remove old cms, switch to wagtail; move website to cms
This commit is contained in:
parent
4bbffa9a50
commit
ba3bf68e33
18
README.md
18
README.md
|
@ -1,30 +1,28 @@
|
|||

|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
|
247
cms/README.md
247
cms/README.md
|
@ -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
|
||||
|
||||
|
126
cms/actions.py
126
cms/actions.py
|
@ -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
|
||||
"""
|
||||
|
||||
|
104
cms/admin.py
104
cms/admin.py
|
@ -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.
|
||||
|
|
131
cms/exposures.py
131
cms/exposures.py
|
@ -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
|
||||
|
|
@ -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'))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
1007
cms/models.py
1007
cms/models.py
File diff suppressed because it is too large
Load Diff
254
cms/routes.py
254
cms/routes.py
|
@ -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)
|
||||
}
|
||||
|
649
cms/sections.py
649
cms/sections.py
|
@ -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
20
cms/settings.py
Executable 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'],
|
||||
})
|
||||
|
||||
|
|
@ -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;
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);"><</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);">></a>
|
||||
</header>
|
||||
{% endspaceless %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% spaceless %}
|
||||
<div class="content">
|
||||
<div first_weekday="{{ first_weekday }}"> </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 %}
|
||||
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 "•••" %}</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 %}
|
||||
…
|
||||
{% 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 %}
|
||||
…
|
||||
{% 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
51
cms/templates/cms/base_site.html
Normal file
51
cms/templates/cms/base_site.html
Normal 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>
|
38
cms/templates/cms/diffusion_page.html
Normal file
38
cms/templates/cms/diffusion_page.html
Normal 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 %}
|
||||
|
||||
|
||||
|
||||
|
24
cms/templates/cms/event_page.html
Normal file
24
cms/templates/cms/event_page.html
Normal 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 %}
|
||||
|
||||
|
||||
|
42
cms/templates/cms/index_page.html
Normal file
42
cms/templates/cms/index_page.html
Normal 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 %}
|
||||
|
||||
|
||||
|
60
cms/templates/cms/program_page.html
Normal file
60
cms/templates/cms/program_page.html
Normal 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 %}
|
||||
|
||||
|
91
cms/templates/cms/publication.html
Normal file
91
cms/templates/cms/publication.html
Normal 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 %}
|
||||
|
||||
|
||||
|
10
cms/templates/cms/search.html
Normal file
10
cms/templates/cms/search.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
{% extends "cms/base_site.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
|
||||
{% block title %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
|
@ -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 %}
|
||||
|
||||
|
||||
|
9
cms/templates/cms/snippets/list.html
Normal file
9
cms/templates/cms/snippets/list.html
Normal 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>
|
||||
|
16
cms/templates/cms/snippets/list_item.html
Normal file
16
cms/templates/cms/snippets/list_item.html
Normal 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>
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
22
cms/utils.py
22
cms/utils.py
|
@ -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)
|
||||
|
268
cms/views.py
268
cms/views.py
|
@ -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)
|
||||
|
||||
|
|
249
cms/website.py
249
cms/website.py
|
@ -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 ''
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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']
|
||||
|
||||
|
||||
|
|
Binary file not shown.
|
@ -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:
|
||||
|
|
|
@ -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')
|
||||
)
|
||||
|
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
"""
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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'),
|
||||
}
|
||||
|
||||
|
|
@ -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 ''
|
||||
|
||||
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
|
||||
def duration_to_str(duration):
|
||||
return duration.strftime(
|
||||
'%H:%M:%S' if duration.hour else '%M:%S'
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user