forked from rc/aircox
remove old cms, switch to wagtail; move website to cms
This commit is contained in:
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 ''
|
||||
|
||||
|
Reference in New Issue
Block a user