admin; section.get -> section.render; templates fix; menu are now per view; doc

This commit is contained in:
bkfox 2016-06-07 14:50:51 +02:00
parent b99dec05e3
commit 21f3e89101
11 changed files with 297 additions and 80 deletions

View File

@ -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. 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 Basically, for each type of publication, the user declare the corresponding
model, the routes, the sections used for rendering, and register them using model, the routes, the views used to render it, using `website.register`.
website.register.
## Posts ## Posts
**Post** is the base model for a publication. **Article** is the provided model **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 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)**. function will be called using arguments **(post, related_object)**.
## Routes ## Routes
Routes are used to generate the URLs of the website. We provide some of the 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 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 Routes are registered to a router (FIXME: it might be possible that we remove
this later) this later)
## Sections ## Sections
Sections are used to render part of a publication, for example to render a Sections are used to render part of a publication, for example to render a
playlist related to the diffusion of a program. 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, sections and configure them. This reduce the work, keep design coherent,
and reduce the risk of bugs and so on. and reduce the risk of bugs and so on.
## Website ## Website
This class is used to create the website itself and regroup all the needed 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 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. 3. Register website's URLs to Django.
4. Change templates and css styles if needed. 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 `<h1>` tag;
* *header*: the optional header in a `<header>` 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 `<footer>` 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...) * **.meta**: metadata of any item (author, date, info, tags...)
* **.info**: used to render extra information, usually in lists * **.info**: used to render extra information, usually in lists

View File

@ -1,8 +1,48 @@
from django.contrib import admin from django.contrib import admin
from django.utils.translation import ugettext as _, ugettext_lazy
import aircox.cms.models as models 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'<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')
admin.site.register(models.Article, PostAdmin)
admin.site.register(models.Comment, CommentAdmin)

View File

@ -15,7 +15,44 @@ from honeypot.decorators import check_honeypot
from aircox.cms.forms import CommentForm 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 On the contrary to Django's views, we create an instance of the view
only once, when the server is run. only once, when the server is run.
@ -57,19 +94,6 @@ class Section(View):
object = None object = None
kwargs = 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): def add_css_class(self, css_class):
if self.css_class: if self.css_class:
if css_class not in self.css_class: if css_class not in self.css_class:
@ -110,9 +134,9 @@ class Section(View):
'object': self.object, '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) context = self.get_context_data(request=request, object=object, **kwargs)
if return_context: if context_only:
return context return context
if not context: if not context:
return '' return ''
@ -153,7 +177,6 @@ class Content(Section):
def get_content(self): def get_content(self):
if self.content is None: if self.content is None:
# FIXME: markdown?
content = getattr(self.object, self.rel_attr) content = getattr(self.object, self.rel_attr)
content = escape(content) content = escape(content)
content = re.sub(r'(^|\n\n)((\n?[^\n])+)', r'<p>\2</p>', content) content = re.sub(r'(^|\n\n)((\n?[^\n])+)', r'<p>\2</p>', content)
@ -161,6 +184,7 @@ class Content(Section):
if self.rel_image_attr and hasattr(self.object, self.rel_image_attr): if self.rel_image_attr and hasattr(self.object, self.rel_image_attr):
image = getattr(self.object, self.rel_image_attr) image = getattr(self.object, self.rel_image_attr)
if image:
content = '<img src="{}">'.format(image.url) + content content = '<img src="{}">'.format(image.url) + content
return content return content
return str(self.content) return str(self.content)
@ -330,12 +354,9 @@ class Comments(List):
messages.error(request, self.error_message, fail_silently=True) messages.error(request, self.error_message, fail_silently=True)
self.comment_form = comment_form self.comment_form = comment_form
class Menu(Section): class Menu(Section):
template_name = 'aircox/cms/section.html'
tag = 'nav' tag = 'nav'
classes = ''
attrs = ''
name = ''
position = '' # top, left, bottom, right, header, footer, page_top, page_bottom position = '' # top, left, bottom, right, header, footer, page_top, page_bottom
sections = None sections = None
@ -343,8 +364,8 @@ class Menu(Section):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.add_css_class('menu') self.add_css_class('menu')
self.add_css_class('menu_' + str(self.name or self.position)) self.add_css_class('menu_' + str(self.name or self.position))
self.sections = [ section() if callable(section) else section self.sections = Sections(self.sections)
for section in self.sections ]
if not self.attrs: if not self.attrs:
self.attrs = {} self.attrs = {}
@ -354,10 +375,7 @@ class Menu(Section):
'tag': self.tag, 'tag': self.tag,
'css_class': self.css_class, 'css_class': self.css_class,
'attrs': self.attrs, 'attrs': self.attrs,
'content': ''.join([ 'content': self.sections.render(*args, **kwargs)
section.get(request=self.request, object=self.object)
for section in self.sections
])
} }

View File

@ -11,9 +11,11 @@
{% endblock %} {% endblock %}
{% block header %} {% block header %}
{% if header %}
<header> <header>
{{ header|safe }} {{ header|safe }}
</header> </header>
{% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@ -13,7 +13,7 @@
{% if website.styles %} {% if website.styles %}
<link rel="stylesheet" href="{% static website.styles %}" type="text/css"> <link rel="stylesheet" href="{% static website.styles %}" type="text/css">
{% endif %} {% endif %}
<title>{{ website.name }} {% if title %}- {{ title }} {% endif %}</title> <title>{% if title %}{{ title }} - {% endif %}{{ website.name }}</title>
</head> </head>
<body> <body>
{% block page_header %} {% block page_header %}
@ -65,6 +65,15 @@
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</div> </div>
{% block footer %}
{% if footer %}
<footer>
{{ footer|safe }}
</footer>
{% endif %}
{% endblock %}
{% if not embed %} {% if not embed %}
</main> </main>
@ -78,7 +87,7 @@
{% endif %} {% endif %}
</div> </div>
{% block footer %} {% block page_footer %}
{% if menus.footer %} {% if menus.footer %}
{{ menus.footer|safe }} {{ menus.footer|safe }}
{% endif %} {% endif %}

View File

@ -10,13 +10,27 @@ import aircox.cms.sections as sections
class PostBaseView: class PostBaseView:
website = None # corresponding website """
title = '' # title of the page Base class for views.
embed = False # page is embed (if True, only post content is printed
# 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 <title> tags and page <h1>)"""
attrs = '' # attr for the HTML element of the content 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_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): 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 self.css_class:
if css_class not in self.css_class: if css_class not in self.css_class:
self.css_class += ' ' + css_class self.css_class += ' ' + css_class
@ -29,17 +43,21 @@ class PostBaseView:
to self. to self.
""" """
context = { context = {
k: getattr(self, k) key: getattr(self, key)
for k, v in PostBaseView.__dict__.items() for key in PostBaseView.__dict__.keys()
if not k.startswith('__') if not key.startswith('__')
} }
if not self.embed: if 'embed' not in self.request.GET:
object = self.object if hasattr(self, 'object') else None object = self.object if hasattr(self, 'object') else None
if self.menus:
context['menus'] = { context['menus'] = {
k: v.get(self.request, object = object, **kwargs) k: v.render(self.request, object = object, **kwargs)
for k, v in self.website.menus.items() for k, v in self.menus.items()
} }
context['embed'] = False
else:
context['embed'] = True
context['view'] = self context['view'] = self
return context return context
@ -56,7 +74,6 @@ class PostListView(PostBaseView, ListView):
route.get_queryset or self.model.objects.all() route.get_queryset or self.model.objects.all()
Request.GET params: Request.GET params:
* embed: view is embedded, render only the list
* exclude: exclude item of the given id * exclude: exclude item of the given id
* order: 'desc' or 'asc' * order: 'desc' or 'asc'
* page: page number * page: page number
@ -67,16 +84,16 @@ class PostListView(PostBaseView, ListView):
model = None model = None
route = None route = None
"""route used to render this list"""
list = None 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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.route = self.kwargs.get('route') or self.route self.route = self.kwargs.get('route') or self.route
if request.GET.get('embed'):
self.embed = True
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_queryset(self): def get_queryset(self):
@ -140,9 +157,6 @@ class PostListView(PostBaseView, ListView):
class PostDetailView(DetailView, PostBaseView): class PostDetailView(DetailView, PostBaseView):
""" """
Detail view for posts and children Detail view for posts and children
Request.GET params:
* embed: view is embedded, only render the content
""" """
template_name = 'aircox/cms/detail.html' template_name = 'aircox/cms/detail.html'
@ -155,8 +169,6 @@ class PostDetailView(DetailView, PostBaseView):
self.sections = [ section() for section in (sections or []) ] self.sections = [ section() for section in (sections or []) ]
def get_queryset(self): def get_queryset(self):
if self.request.GET.get('embed'):
self.embed = True
if self.model: if self.model:
return super().get_queryset().filter(published = True) return super().get_queryset().filter(published = True)
return [] return []
@ -174,9 +186,9 @@ class PostDetailView(DetailView, PostBaseView):
kwargs['object'] = self.object kwargs['object'] = self.object
context.update({ context.update({
'title': self.object.title, 'title': self.title or self.object.title,
'content': ''.join([ 'content': ''.join([
section.get(request = self.request, **kwargs) section.render(request = self.request, **kwargs)
for section in self.sections for section in self.sections
]), ]),
'css_class': self.css_class, 'css_class': self.css_class,
@ -208,17 +220,14 @@ class PageView(TemplateView, PostBaseView):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.sections = sections.Sections(self.sections)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context.update(self.get_base_context()) context.update(self.get_base_context())
context.update({ context.update({
'title': self.title, 'title': self.title,
'content': ''.join([ 'content': self.sections.render(request=self.request,**kwargs)
section.get(request = self.request, **kwargs)
for section in self.sections
]),
}) })
return context return context

View File

@ -3,29 +3,39 @@ from django.conf.urls import url
import aircox.cms.routes as routes import aircox.cms.routes as routes
import aircox.cms.views as views import aircox.cms.views as views
import aircox.cms.models as models
import aircox.cms.sections as sections
class Website: class Website:
""" """
Describe a website and all its settings that are used for its rendering. Describe a website and all its settings that are used for its rendering.
""" """
# metadata ## metadata
name = '' name = ''
domain = '' domain = ''
description = 'An aircox website' description = 'An aircox website'
tags = 'aircox,radio,music' tags = 'aircox,radio,music'
# rendering ## rendering
styles = '' styles = ''
"""extra css style file"""
menus = None menus = None
"""dict of default menus used to render website pages"""
# user interaction ## user interaction
allow_comments = True allow_comments = True
"""allow comments on the website"""
auto_publish_comments = False auto_publish_comments = False
"""publish comment without human approval"""
comments_routes = True comments_routes = True
"""register list routes for the Comment model"""
# components ## components
urls = [] urls = []
"""list of urls generated thourgh registrations"""
registry = {} registry = {}
"""dict of registered models by their name"""
def __init__(self, menus = None, **kwargs): def __init__(self, menus = None, **kwargs):
""" """
@ -45,18 +55,18 @@ class Website:
def name_of_model(self, model): def name_of_model(self, model):
"""
Return the registered name for a given model if found.
"""
for name, _model in self.registry.items(): for name, _model in self.registry.items():
if model is _model: if model is _model:
return name return name
def register_comments_routes(self): def register_comments_routes(self):
""" """
Register routes for comments, for the moment, only: Register routes for comments, for the moment, only
* ThreadRoute ThreadRoute
""" """
import aircox.cms.models as models
import aircox.cms.sections as sections
self.register_list( self.register_list(
'comment', models.Comment, 'comment', models.Comment,
routes = [routes.ThreadRoute], routes = [routes.ThreadRoute],
@ -72,7 +82,9 @@ class Website:
Register a model and return the name under which it is registered. Register a model and return the name under which it is registered.
Raise a ValueError if another model is yet associated under this name. 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 "{}"' raise ValueError('A model has yet been registered under "{}"'
.format(name)) .format(name))
self.registry[name] = model self.registry[name] = model
@ -85,11 +97,15 @@ class Website:
Register a model and the detail view Register a model and the detail view
""" """
name = self.register_model(name, model) name = self.register_model(name, model)
if not view_kwargs.get('menus'):
view_kwargs['menus'] = self.menus
view = view.as_view( view = view.as_view(
website = self, website = self,
model = model, model = model,
**view_kwargs **view_kwargs
) )
self.urls.append(routes.DetailRoute.as_url(name, view)) self.urls.append(routes.DetailRoute.as_url(name, view))
self.registry[name] = model self.registry[name] = model
@ -99,11 +115,15 @@ class Website:
Register a model and the given list view using the given routes Register a model and the given list view using the given routes
""" """
name = self.register_model(name, model) name = self.register_model(name, model)
if not 'menus' in view_kwargs:
view_kwargs['menus'] = self.menus
view = view.as_view( view = view.as_view(
website = self, website = self,
model = model, model = model,
**view_kwargs **view_kwargs
) )
self.urls += [ route.as_url(name, view) for route in routes ] self.urls += [ route.as_url(name, view) for route in routes ]
self.registry[name] = model self.registry[name] = model
@ -113,6 +133,9 @@ class Website:
Register a page that is accessible to the given path. If path is None, Register a page that is accessible to the given path. If path is None,
use a slug of the name. use a slug of the name.
""" """
if not 'menus' in view_kwargs:
view_kwargs['menus'] = self.menus
view = view.as_view( view = view.as_view(
website = self, website = self,
**view_kwargs **view_kwargs

View File

@ -1,8 +1,25 @@
from django.contrib import admin 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 import aircox.website.models as models
import aircox.website.forms as forms
admin.site.register(models.Program)
admin.site.register(models.Diffusion)
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)

View File

@ -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'''
<span class="block" data-value="%s">%s</span>
'''
def choice_html (self, choice):
value = choice[self.search_fields[0]]
return self.choice_html_format % (self.choice_label(choice),
self.choice_label(value))
def choices_for_request(self):
#if not self.request.user.is_staff:
# self.choices = self.choices.filter(private=False)
filter_args = { self.search_fields[0] + '__icontains': self.request.GET['q'] }
self.choices = self.choices.filter(**filter_args)
self.choices = self.choices.values(self.search_fields[0]).distinct()
return self.choices
class TrackArtistAutocomplete(OneFieldAutocomplete):
search_fields = ['artist']
model = programs.Track
al.register(TrackArtistAutocomplete)
class TrackNameAutocomplete(OneFieldAutocomplete):
search_fields = ['name']
model = programs.Track
al.register(TrackNameAutocomplete)

19
website/forms.py Normal file
View File

@ -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'),
}

View File

@ -39,7 +39,7 @@ class Diffusion (RelatedPost):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.thread: if self.thread:
if not self.title: if not self.title:
self.title = _('{name} on {first_diff}').format( self.title = _('{name} // {first_diff}').format(
self.related.program.name, self.related.program.name,
self.related.start.strftime('%A %d %B') self.related.start.strftime('%A %d %B')
) )