diff --git a/cms/README.md b/cms/README.md
index 457bff6..0a6751e 100644
--- a/cms/README.md
+++ b/cms/README.md
@@ -26,8 +26,8 @@ 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 sections used for rendering, and register them using
-website.register.
+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
@@ -50,7 +50,6 @@ class MyModelPost(RelatedPost):
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)**.
-
## 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
@@ -61,7 +60,6 @@ It is of course possible to create your own routes.
Routes are registered to a router (FIXME: it might be possible that we remove
this later)
-
## Sections
Sections are used to render part of a publication, for example to render a
playlist related to the diffusion of a program.
@@ -75,7 +73,6 @@ 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.
-
## 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
@@ -89,9 +86,52 @@ website, using instance of the Website class:
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.
-# Generated content
-## CSS
+
+# 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. While `PostDetailView` is related to a model instance, `PageView` just render its sections.
+
+`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.
+
+## 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
+There are two base template that are extended by the others:
+* **section.html**: used to render a single section;
+* **website.html**: website page layout;
+
+These both define the following blocks, with their related container (declared *inside* the block):
+* *title*: the optional title in a `
` tag;
+* *header*: the optional header in a `` tag;
+* *content*: the content itself; for *section* there is not related container, for *website* container is declared *outside* as an element of class `.content`;
+* *footer*: the footer in a `` tag;
+
+The other templates Aircox.cms uses are:
+* **details.html**: used to render post details (extends *website.html*);
+* **list.html**: used to render lists, extends the given template `base_template` (*section.html* or *website.html*);
+* **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
diff --git a/cms/admin.py b/cms/admin.py
index b6db0e4..8236be2 100644
--- a/cms/admin.py
+++ b/cms/admin.py
@@ -1,8 +1,48 @@
from django.contrib import admin
+from django.utils.translation import ugettext as _, ugettext_lazy
import aircox.cms.models as models
-admin.site.register(models.Article)
-admin.site.register(models.Comment)
+
+
+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' '.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')
+
+admin.site.register(models.Article, PostAdmin)
+admin.site.register(models.Comment, CommentAdmin)
diff --git a/cms/sections.py b/cms/sections.py
index 3b21f82..598899d 100644
--- a/cms/sections.py
+++ b/cms/sections.py
@@ -15,7 +15,44 @@ from honeypot.decorators import check_honeypot
from aircox.cms.forms import CommentForm
-class Section(View):
+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):
+ """
+ Similar to View.as_view, but instead, wrap a constructor of the
+ given class that is used as is.
+ """
+ def func(**kwargs_):
+ if kwargs_:
+ kwargs.update(kwargs_)
+ instance = cl(*args, **kwargs)
+ return instance
+ return func
+
+
+class Sections(Viewable, list):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ for i, section in enumerate(self):
+ if callable(section) or type(section) == type:
+ self[i] = section()
+
+ def render(self, *args, **kwargs):
+ return ''.join([
+ section.render(*args, **kwargs)
+ 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.
@@ -57,19 +94,6 @@ class Section(View):
object = None
kwargs = None
- @classmethod
- def as_view (cl, *args, **kwargs):
- """
- Similar to View.as_view, but instead, wrap a constructor of the
- given class that is used as is.
- """
- def func(**kwargs_):
- if kwargs_:
- kwargs.update(kwargs_)
- instance = cl(*args, **kwargs)
- return instance
- return func
-
def add_css_class(self, css_class):
if self.css_class:
if css_class not in self.css_class:
@@ -110,9 +134,9 @@ class Section(View):
'object': self.object,
}
- def get(self, request, object=None, return_context=False, **kwargs):
+ def render(self, request, object=None, context_only=False, **kwargs):
context = self.get_context_data(request=request, object=object, **kwargs)
- if return_context:
+ if context_only:
return context
if not context:
return ''
@@ -153,7 +177,6 @@ class Content(Section):
def get_content(self):
if self.content is None:
- # FIXME: markdown?
content = getattr(self.object, self.rel_attr)
content = escape(content)
content = re.sub(r'(^|\n\n)((\n?[^\n])+)', r'\2
', content)
@@ -161,7 +184,8 @@ class Content(Section):
if self.rel_image_attr and hasattr(self.object, self.rel_image_attr):
image = getattr(self.object, self.rel_image_attr)
- content = ' '.format(image.url) + content
+ if image:
+ content = ' '.format(image.url) + content
return content
return str(self.content)
@@ -330,12 +354,9 @@ class Comments(List):
messages.error(request, self.error_message, fail_silently=True)
self.comment_form = comment_form
+
class Menu(Section):
- template_name = 'aircox/cms/section.html'
tag = 'nav'
- classes = ''
- attrs = ''
- name = ''
position = '' # top, left, bottom, right, header, footer, page_top, page_bottom
sections = None
@@ -343,8 +364,8 @@ class Menu(Section):
super().__init__(*args, **kwargs)
self.add_css_class('menu')
self.add_css_class('menu_' + str(self.name or self.position))
- self.sections = [ section() if callable(section) else section
- for section in self.sections ]
+ self.sections = Sections(self.sections)
+
if not self.attrs:
self.attrs = {}
@@ -354,10 +375,7 @@ class Menu(Section):
'tag': self.tag,
'css_class': self.css_class,
'attrs': self.attrs,
- 'content': ''.join([
- section.get(request=self.request, object=self.object)
- for section in self.sections
- ])
+ 'content': self.sections.render(*args, **kwargs)
}
diff --git a/cms/templates/aircox/cms/section.html b/cms/templates/aircox/cms/section.html
index fac3ea5..1c95464 100644
--- a/cms/templates/aircox/cms/section.html
+++ b/cms/templates/aircox/cms/section.html
@@ -11,9 +11,11 @@
{% endblock %}
{% block header %}
+{% if header %}
+{% endif %}
{% endblock %}
{% block content %}
diff --git a/cms/templates/aircox/cms/website.html b/cms/templates/aircox/cms/website.html
index d09a2ef..4b430cf 100644
--- a/cms/templates/aircox/cms/website.html
+++ b/cms/templates/aircox/cms/website.html
@@ -13,7 +13,7 @@
{% if website.styles %}
{% endif %}
- {{ website.name }} {% if title %}- {{ title }} {% endif %}
+ {% if title %}{{ title }} - {% endif %}{{ website.name }}
{% block page_header %}
@@ -65,6 +65,15 @@
{% block content %}
{% endblock %}
+
+ {% block footer %}
+ {% if footer %}
+
+ {% endif %}
+ {% endblock %}
+
{% if not embed %}
@@ -78,7 +87,7 @@
{% endif %}
- {% block footer %}
+ {% block page_footer %}
{% if menus.footer %}
{{ menus.footer|safe }}
{% endif %}
diff --git a/cms/views.py b/cms/views.py
index 6ab9c4d..9084bb8 100644
--- a/cms/views.py
+++ b/cms/views.py
@@ -10,13 +10,27 @@ import aircox.cms.sections as sections
class PostBaseView:
- website = None # corresponding website
- title = '' # title of the page
- embed = False # page is embed (if True, only post content is printed
+ """
+ Base class for views.
+
+ # Request GET params:
+ * embed: view is embedded, render only the content of the view
+ """
+ website = None
+ """website that uses the view"""
+ menus = None
+ """menus used to render the view page"""
+ title = ''
+ """title of the page (used in tags and page )"""
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 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:
self.css_class += ' ' + css_class
@@ -29,17 +43,21 @@ class PostBaseView:
to self.
"""
context = {
- k: getattr(self, k)
- for k, v in PostBaseView.__dict__.items()
- if not k.startswith('__')
+ key: getattr(self, key)
+ for key in PostBaseView.__dict__.keys()
+ if not key.startswith('__')
}
- if not self.embed:
+ if 'embed' not in self.request.GET:
object = self.object if hasattr(self, 'object') else None
- context['menus'] = {
- k: v.get(self.request, object = object, **kwargs)
- for k, v in self.website.menus.items()
- }
+ if self.menus:
+ context['menus'] = {
+ k: v.render(self.request, object = object, **kwargs)
+ for k, v in self.menus.items()
+ }
+ context['embed'] = False
+ else:
+ context['embed'] = True
context['view'] = self
return context
@@ -56,7 +74,6 @@ class PostListView(PostBaseView, ListView):
route.get_queryset or self.model.objects.all()
Request.GET params:
- * embed: view is embedded, render only the list
* exclude: exclude item of the given id
* order: 'desc' or 'asc'
* page: page number
@@ -67,16 +84,16 @@ class PostListView(PostBaseView, ListView):
model = None
route = None
+ """route used to render this list"""
list = None
- css_class = None
+ """list section to use to render the list and get base context.
+ By default it is sections.List"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def dispatch(self, request, *args, **kwargs):
self.route = self.kwargs.get('route') or self.route
- if request.GET.get('embed'):
- self.embed = True
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
@@ -140,9 +157,6 @@ class PostListView(PostBaseView, ListView):
class PostDetailView(DetailView, PostBaseView):
"""
Detail view for posts and children
-
- Request.GET params:
- * embed: view is embedded, only render the content
"""
template_name = 'aircox/cms/detail.html'
@@ -155,8 +169,6 @@ class PostDetailView(DetailView, PostBaseView):
self.sections = [ section() for section in (sections or []) ]
def get_queryset(self):
- if self.request.GET.get('embed'):
- self.embed = True
if self.model:
return super().get_queryset().filter(published = True)
return []
@@ -174,9 +186,9 @@ class PostDetailView(DetailView, PostBaseView):
kwargs['object'] = self.object
context.update({
- 'title': self.object.title,
+ 'title': self.title or self.object.title,
'content': ''.join([
- section.get(request = self.request, **kwargs)
+ section.render(request = self.request, **kwargs)
for section in self.sections
]),
'css_class': self.css_class,
@@ -208,17 +220,14 @@ class PageView(TemplateView, PostBaseView):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+ self.sections = sections.Sections(self.sections)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(self.get_base_context())
-
context.update({
'title': self.title,
- 'content': ''.join([
- section.get(request = self.request, **kwargs)
- for section in self.sections
- ]),
+ 'content': self.sections.render(request=self.request,**kwargs)
})
return context
diff --git a/cms/website.py b/cms/website.py
index bc457fc..8802689 100644
--- a/cms/website.py
+++ b/cms/website.py
@@ -3,29 +3,39 @@ from django.conf.urls import url
import aircox.cms.routes as routes
import aircox.cms.views as views
+import aircox.cms.models as models
+import aircox.cms.sections as sections
+
class Website:
"""
Describe a website and all its settings that are used for its rendering.
"""
- # metadata
+ ## metadata
name = ''
domain = ''
description = 'An aircox website'
tags = 'aircox,radio,music'
- # rendering
+ ## rendering
styles = ''
+ """extra css style file"""
menus = None
+ """dict of default menus used to render website pages"""
- # user interaction
+ ## 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
+ ## components
urls = []
+ """list of urls generated thourgh registrations"""
registry = {}
+ """dict of registered models by their name"""
def __init__(self, menus = None, **kwargs):
"""
@@ -45,18 +55,18 @@ class Website:
def name_of_model(self, model):
+ """
+ Return the registered name for a given model if found.
+ """
for name, _model in self.registry.items():
if model is _model:
return name
def register_comments_routes(self):
"""
- Register routes for comments, for the moment, only:
- * ThreadRoute
+ Register routes for comments, for the moment, only
+ ThreadRoute
"""
- import aircox.cms.models as models
- import aircox.cms.sections as sections
-
self.register_list(
'comment', models.Comment,
routes = [routes.ThreadRoute],
@@ -72,7 +82,9 @@ class Website:
Register a model and return the name under which it is registered.
Raise a ValueError if another model is yet associated under this name.
"""
- if name in self.registry and self.registry[name] is not model:
+ if name in self.registry:
+ if self.registry[name] is model:
+ return name
raise ValueError('A model has yet been registered under "{}"'
.format(name))
self.registry[name] = model
@@ -85,11 +97,15 @@ class Website:
Register a model and the detail view
"""
name = self.register_model(name, model)
+ if not view_kwargs.get('menus'):
+ view_kwargs['menus'] = self.menus
+
view = view.as_view(
website = self,
model = model,
**view_kwargs
)
+
self.urls.append(routes.DetailRoute.as_url(name, view))
self.registry[name] = model
@@ -99,11 +115,15 @@ class Website:
Register a model and the given list view using the given routes
"""
name = self.register_model(name, model)
+ if not 'menus' in view_kwargs:
+ view_kwargs['menus'] = self.menus
+
view = view.as_view(
website = self,
model = model,
**view_kwargs
)
+
self.urls += [ route.as_url(name, view) for route in routes ]
self.registry[name] = model
@@ -113,6 +133,9 @@ class Website:
Register a page that is accessible to the given path. If path is None,
use a slug of the name.
"""
+ if not 'menus' in view_kwargs:
+ view_kwargs['menus'] = self.menus
+
view = view.as_view(
website = self,
**view_kwargs
diff --git a/website/admin.py b/website/admin.py
index d048aef..ad86605 100644
--- a/website/admin.py
+++ b/website/admin.py
@@ -1,8 +1,25 @@
from django.contrib import admin
+from suit.admin import SortableTabularInline, SortableModelAdmin
+import aircox.programs.models as programs
+import aircox.cms.admin as cms
import aircox.website.models as models
-
-admin.site.register(models.Program)
-admin.site.register(models.Diffusion)
+import aircox.website.forms as forms
+
+
+class TrackInline (SortableTabularInline):
+ fields = ['artist', 'name', 'tags', 'position']
+ form = forms.TrackForm
+ model = programs.Track
+ sortable = 'position'
+ extra = 10
+
+
+class DiffusionPostAdmin(cms.RelatedPostAdmin):
+ inlines = [TrackInline]
+
+
+admin.site.register(models.Program, cms.RelatedPostAdmin)
+admin.site.register(models.Diffusion, DiffusionPostAdmin)
diff --git a/website/autocomplete_light_registry.py b/website/autocomplete_light_registry.py
new file mode 100644
index 0000000..7861b39
--- /dev/null
+++ b/website/autocomplete_light_registry.py
@@ -0,0 +1,40 @@
+import autocomplete_light.shortcuts as al
+import aircox.programs.models as programs
+
+from taggit.models import Tag
+al.register(Tag)
+
+
+class OneFieldAutocomplete(al.AutocompleteModelBase):
+ choice_html_format = u'''
+ %s
+ '''
+
+ def choice_html (self, choice):
+ value = choice[self.search_fields[0]]
+ return self.choice_html_format % (self.choice_label(choice),
+ self.choice_label(value))
+
+
+ def choices_for_request(self):
+ #if not self.request.user.is_staff:
+ # self.choices = self.choices.filter(private=False)
+ filter_args = { self.search_fields[0] + '__icontains': self.request.GET['q'] }
+
+ self.choices = self.choices.filter(**filter_args)
+ self.choices = self.choices.values(self.search_fields[0]).distinct()
+ return self.choices
+
+
+class TrackArtistAutocomplete(OneFieldAutocomplete):
+ search_fields = ['artist']
+ model = programs.Track
+al.register(TrackArtistAutocomplete)
+
+
+class TrackNameAutocomplete(OneFieldAutocomplete):
+ search_fields = ['name']
+ model = programs.Track
+al.register(TrackNameAutocomplete)
+
+
diff --git a/website/forms.py b/website/forms.py
new file mode 100644
index 0000000..32e9453
--- /dev/null
+++ b/website/forms.py
@@ -0,0 +1,19 @@
+from django import forms
+
+import autocomplete_light.shortcuts as al
+from autocomplete_light.contrib.taggit_field import TaggitWidget
+
+import aircox.programs.models as programs
+
+
+class TrackForm (forms.ModelForm):
+ class Meta:
+ model = programs.Track
+ fields = ['artist', 'name', 'tags', 'position']
+ widgets = {
+ 'artist': al.TextWidget('TrackArtistAutocomplete'),
+ 'name': al.TextWidget('TrackNameAutocomplete'),
+ 'tags': TaggitWidget('TagAutocomplete'),
+ }
+
+
diff --git a/website/models.py b/website/models.py
index 0070f92..b08221a 100644
--- a/website/models.py
+++ b/website/models.py
@@ -39,7 +39,7 @@ class Diffusion (RelatedPost):
super().__init__(*args, **kwargs)
if self.thread:
if not self.title:
- self.title = _('{name} on {first_diff}').format(
+ self.title = _('{name} // {first_diff}').format(
self.related.program.name,
self.related.start.strftime('%A %d %B')
)