* started workin on 'navbar' module

* changed bootstrap theme to bootswatch/Simplex

* big work on navbar logic

* starting creating menus using navbar

* converted app views to new Wepage decorator, updated navbar

* reimplemented DernieresMaraudes as a dropdown instead of ContextMixin

* reorganised static files, minor code cleanups

* turned Link.href into lazy-evaluated property

* collapsed 'navbar' module into 'website', dynamic building of ApplicationMenu subclasses

* minor cleanup

* blah blah blah

* added way to add admin/non-admin links

* minor style change : red border for active page instead of all dropdowns

* deleted file

* prepare adding removing menu templates files, being replaced by code

* essayé de généraliser le code pour les modaux bootstrap, non testé git status

* more preparation and thinking on navbar app_menus logic...

* added LinkManager and DropdownManager, getting closer...

* small fix in DropdownManager.__get__

* boosted up work: keep it simple so it can be merged fast, major layout changes

* added month filter on maraudes:liste

* added 'as_icon' filter to display boolean/null values as bootstrap icons

* remove inactive user from planning selection

* removed all unused 'menu' templates

* set up django_select2 to use static files

* small fix after review
This commit is contained in:
artus40
2017-02-11 18:20:13 +01:00
committed by GitHub
parent 288ca2cc20
commit 0be59a61a7
61 changed files with 665 additions and 525 deletions

View File

@@ -1,39 +1,93 @@
from .mixins import *
from .mixins import (WebsiteTemplateMixin, WebsiteAjaxTemplateMixin,
SpecialUserRequiredMixin)
from .navbar import ApplicationMenu
def _insert_bases(cls, bases):
""" Insert new bases in given view class """
old_bases = cls.__bases__
new_bases = tuple(bases) + old_bases
cls.__bases__ = new_bases
def app_config(**options):
""" Insert per-application configuration options :
-- name : name of the app to register under in navbar
-- groups : user groups needed to access this application
-- menu : user menu templates to be used
-- admin_menu : admin menu templates, only appear for superuser
-- ajax : view will return content_template for Ajax requests
class Webpage:
""" Webpage configurator. It is used as a decorator.
The constructor takes one positionnal argument:
- app_name : name of the application where this view shall be categorized.
and keyword arguments:
- defaults : mapping of default options.
- menu : does it register a menu ? default is True
- icon : bootstrap name of menu header icon, ignored if 'menu' is False.
Options are :
- title: tuple of (header, header_small), header_small is optionnal.
- restricted: set of group to which access is restricted.
- ajax: can this view be called as ajax ?
"""
name = options.pop('name', None)
groups = options.pop('groups', []) #Transition from app_users
menu = options.pop('menu', [])
admin_menu = options.pop('admin_menu', [])
ajax = options.pop('ajax', False)
new_bases = []
if ajax:
new_bases.append(WebsiteAjaxTemplateMixin)
else:
new_bases.append(WebsiteTemplateMixin)
options = [
('title', ('Unset', 'small header')),
('restricted', []),
('ajax', False)
]
if groups: #TODO: use group instaed of user class
new_bases.append(SpecialUserRequiredMixin)
def __init__(self, app_name, icon=None, defaults={}, menu=True):
self.app_name = app_name
def class_decorator(cls):
_insert_bases(cls, new_bases)
cls._user_menu = menu
cls._admin_menu = admin_menu
cls.app_name = name
cls.app_users = groups.copy()
return cls
if menu: # Build ApplicationMenu subclass
app_menu = type(
app_name.title() + "Menu",
(ApplicationMenu,),
{'name': app_name,
'header': (app_name.title(), '%s:index' % app_name, icon),
'_links': [],
'_dropdowns': [],
}
)
self.app_menu = app_menu
else:
self.app_menu = None
return class_decorator
self._defaults = {}
self._updated = {} # Store updated options
# Set all default options
for opt_name, opt_default in self.options:
self._set_option(opt_name, defaults.get(opt_name, opt_default))
def __getattr__(self, attr):
""" Return the overriden value if any, default overwise """
return self._updated.get(attr, self._defaults[attr])
def _set_option(self, attr, value):
""" Set the default value if there is none already, updated overwise """
if not attr in self._defaults:
self._defaults[attr] = value
else:
if attr in self._updated:
raise RuntimeError(attr, 'has already been updated !')
self._updated[attr] = value
def __call__(self, view_cls):
""" Setup the view and return it """
bases_to_add = []
if self.ajax: bases_to_add.append(WebsiteAjaxTemplateMixin)
else: bases_to_add.append(WebsiteTemplateMixin)
if self.restricted: bases_to_add.append(SpecialUserRequiredMixin)
_insert_bases(view_cls, bases_to_add)
# Setup configuration. ISSUE: defaults values will be overriden !
view_cls.app_name = self.app_name
view_cls.header = self.title
view_cls.app_users = self.restricted
self._updated = {} # Reset updated attributes to avoid misbehavior
return view_cls
def using(self, **kwargs):
""" Overrides defaults options with the values given """
for opt_name, _ in self.options:
if opt_name in kwargs:
self._set_option(opt_name, kwargs[opt_name])
return self

View File

@@ -1,151 +1,64 @@
import datetime
from django.utils import timezone
from django.core.exceptions import ImproperlyConfigured
from django.apps import apps
from django.contrib.auth.decorators import user_passes_test
from django.template import Template, Context
from django.views.generic.base import ContextMixin, TemplateResponseMixin
from django.views.generic.base import TemplateResponseMixin
## Mixins ##
def special_user_required(authorized_users):
valid_cls = tuple(authorized_users)
def check_special_user(user):
print('check user is instance of', valid_cls)
if isinstance(user, valid_cls):
return True
else:
return False
return user_passes_test(check_special_user)
class SpecialUserRequiredMixin(object):
""" Requires that the User is an instance of some class """
app_users = []
@classmethod
def as_view(cls, **initkwargs):
view = super().as_view(**initkwargs)
return special_user_required(cls.app_users)(view)
return cls.special_user_required(cls.app_users)(view)
@staticmethod
def special_user_required(authorized_users):
valid_cls = tuple(authorized_users)
if not valid_cls: # No restriction usually means misconfiguration !
raise ImproperlyConfigured(
'A view was configured as "restricted" with no restricting parameters !')
class TemplateFieldsMetaclass(type):
""" Loads Template objects with given string for
header, header_small, title, ...
Theses strings shall be found in cls.Template
"""
def __init__(cls, bases, Dict):
pass
def check_special_user(user):
if isinstance(user, valid_cls):
return True
else:
return False
return user_passes_test(check_special_user)
def user_processor(request, context):
context['user_group'] = request.user.__class__.__qualname__
return context
class NavbarMixin(object):
registered_apps = ['maraudes', 'suivi']
app_name = None
def header_processor(header, context):
context['page_header'] = Template(header[0]).render(context)
context['page_header_small'] = Template(header[1]).render(context) if len(header) == 2 else ''
context['page_title'] = " - ".join((context['page_header'], context['page_header_small']))
return context
def get_apps_config(self):
""" Load additionnal config data on each app registered in navbar
Add :
- menu_icon : glyphicon in sidebar
- disabled : show/hide in sidebar
"""
## Utils ##
APP_ICONS = {
'maraudes': 'road',
'suivi': 'eye-open',
}
app_names = self.registered_apps
self._apps = []
for name in app_names:
app_config = apps.get_app_config(name)
app_config.menu_icon = APP_ICONS[name]
#TODO: Seems unsafe (only need module perm)
app_config.disabled = not self.request.user.has_module_perms(name)
self._apps.append(app_config)
return self._apps
@property
def apps(self):
if not hasattr(self, '_apps'):
self._apps = self.get_apps_config()
return self._apps
def get_active_app(self):
if not self.app_name:
self.app_name = self.__class__.__module__.split(".")[0]
# If app is website, there is no "active" application
if self.app_name == "website":
return None
active_app = apps.get_app_config(self.app_name)
if not active_app in self.apps: #TODO: how do we deal with this ?
raise ValueError("%s must be registered in Configuration.navbar_apps" % active_app)
return active_app
@property
def active_app(self):
if not hasattr(self, '_active_app'):
self._active_app = self.get_active_app()
return self._active_app
@property
def menu(self):
""" Renvoie la liste des templates utilisés comme menu pour l'application
active
"""
if not self.request.user.is_superuser:
return self._user_menu
return self._user_menu + self._admin_menu
class WebsiteTemplateMixin(NavbarMixin, TemplateResponseMixin):
class WebsiteTemplateMixin(TemplateResponseMixin):
""" Mixin for easy integration of 'website' templates
Each child can specify:
- title : title of the page
- header : header of the page
- header_small : sub-header of the page
If 'content_template' is not defined, value will fallback to template_name
in child view.
"""
base_template = "base_site.html"
content_template = None
_user_menu = []
_admin_menu = []
_groups = []
app_name = None
class Configuration:
stylesheets = ['base.css']
page_blocks = ['header', 'header_small', 'title']
stylesheets = ['css/base.css']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user = None
self._page_blocks = []
if not hasattr(self, "PageInfo"):
raise ImproperlyConfigured("You must define a PageInfo on ", self)
for attr, val in self.PageInfo.__dict__.items():
if attr[0] is not "_" and type(val) is str:
setattr(self, attr, Template(val))
self._page_blocks.append(attr)
def get_template_names(self):
""" Ensure same template for all children views. """
@@ -159,30 +72,20 @@ class WebsiteTemplateMixin(NavbarMixin, TemplateResponseMixin):
raise ImproperlyConfigured(self, "has no template defined !")
return self.content_template
def _update_context_with_rendered_blocks(self, context):
""" Render text for existing PageInfo attributes.
See Configuration.page_blocks for valid attribute names """
render_context = Context(context)
for attr in self._page_blocks:
name = "page_%s" % attr
context[name] = getattr(self, attr).render(render_context)
return context
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
self._update_context_with_rendered_blocks(context)
context = Context(super().get_context_data(**kwargs))
#Website processor
context['stylesheets'] = self.Configuration.stylesheets
context['apps'] = self.apps
context['active_app'] = self.active_app
context['active_app'] = self.app_name # Set by Webpage decorator
# User processor
context = header_processor(self.header, context)
context = user_processor(self.request, context)
#Webpage
context['content_template'] = self.get_content_template()
context['app_menu'] = self.menu
return context
class WebsiteAjaxTemplateMixin(WebsiteTemplateMixin):
""" Mixin that returns content_template instead of base_template when
request is Ajax.

102
website/navbar.py Normal file
View File

@@ -0,0 +1,102 @@
""" Draft for navbar application menu
"""
from django.urls import reverse
registered = []
class Link:
""" Navbar link
Constructor takes one required argument :
- text : text to display for the link
and two optional arguments :
- target : str or tuple (str, dict)
- icon : bootstrap icon name, no validation
"""
def __init__(self, text, target="#", icon=None):
self.text = text
self._target = target
self.icon = icon
@property
def href(self):
""" Lazy creation of html 'href' value, using 'target' instance attribute """
if not hasattr(self, '_href'):
if self._target == "#": #Create void link
self._href = "#"
else:
try:
target, kwargs = self._target
except ValueError:
target = self._target
kwargs = {}
assert type(target) == str
assert type(kwargs) == dict
self._href = reverse(target, **kwargs)
return self._href
class LinkManager:
""" Per-class manager of links """
def __init__(self):
self.items = []
def __get__(self, instance, owner):
if instance: #Filtering done at each call, not optimized at all !
if not instance.user.is_superuser:
return [link for link in self.items if link.admin_link == False]
else:
return self.items.copy()
return self
def add(self, link):
self.items.append(link)
def __repr__(self):
return '<LinkManager: [' + ', '.join((l.text for l in self.items)) + ']>'
class MenuRegistry(type):
""" Metaclass that registers subclass into module level variable 'registered' """
def __new__(metacls, name, bases, attrs):
cls = type.__new__(metacls, name, bases, attrs)
if name != "ApplicationMenu":
print('registering menu', cls)
registered.append(cls)
# Create Link instance for header attributes
try:
header, target, icon = cls.header
except ValueError:
header = cls.header
target = "#"
icon = None
cls.header = Link(header, target, icon)
cls.links = LinkManager()
return cls
class ApplicationMenu(metaclass=MenuRegistry):
name = None
header = None
def __init__(self, view, user):
self.view = view
self.user = user
self.is_active = self.name == self.view.app_name
@classmethod
def add_link(cls, link, admin=False):
if not isinstance(link, Link):
link = Link(*link)
link.admin_link = admin
cls.links.add(link)

File diff suppressed because one or more lines are too long

View File

@@ -1,23 +1,19 @@
#menu {
border: none;
border-right: 4px solid #980300;
}
.dropdown-toggle{
border-right: 4px solid #980300 !important;
}
.dropdown-menu {
border-bottom: none !important;
}
.navbar-fixed-side .navbar-nav>li>a {
border-bottom: none;
font-variant: small-caps;
color: #fff;
}
#menu {
border: none;
border-right: 4px solid #980300;
background-color: #121212;
}
@media (max-width:768px){
#menu { border: none; }
}
.app-menu {
background-color: #121212;
@@ -25,10 +21,12 @@
}
@media (max-width:768px){
#menu { border: none; }
.active{
border-right: 2px solid #980300 !important;
}
.jumbotron {
background-color: #fefefe;
.dropdown-menu {
border-bottom: none !important;
}

File diff suppressed because one or more lines are too long

View File

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

1
website/static/css/select2.min.css vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,21 +0,0 @@
/* Maraudes application base stylesheet */
.rencontre {
background-color: #efefef;
width:100%;
border: 5px dotted white;
margin:2px;
margin-bottom:8px;
font-family: Arial;
}
.header td {
color: white;
background-color: black;
text-align:center;
font-weight:bold;
font-family: Arial;
padding: 2px;
}

View File

@@ -0,0 +1,43 @@
/* Lier les boutons de création
* Thanks to Derek Morgan, https://dmorgan.info/posts/django-views-bootstrap-modals/
*/
;(function($) {
var formAjaxSubmit = function(form, modal)
{
$(form).submit(function (e) {
e.preventDefault();
$.ajax({
type: $(this).attr('method'),
url: $(this).attr('action'),
data: $(this).serialize(),
success: function (xhr, ajaxOptions, thrownError) {
if ( $(xhr).find('.has-error').length > 0 || $(xhr).find('.alert-danger').length > 0) {
$(modal).find('.modal-body').html(xhr);
formAjaxSubmit(form, modal);
} else {
$(modal).modal('toggle');
// Reload page ?
location.reload()
}
},
error: function (xhr, ajaxOptions, thrownError) {
// handle response errors here
}
});
});
};
$.fn.openModalEvent = function(id, href, title)
{
$('#'+id).click(function() {
$('#form-modal-body').load(href, function()
{
$('.modal-title').text(title);
$('#form-modal').modal('toggle');
formAjaxSubmit("#form-modal-body form", "#form-modal");
});
});
};
})(jQuery);

5
website/static/scripts/jquery.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
website/static/scripts/select2.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +1,11 @@
<!DOCTYPE html>
{% load staticfiles %} {% load bootstrap3 %}
{% load staticfiles %} {% load bootstrap3 %} {% load navbar %}
<html lang="fr">
<head>
<title>{% block title %}La maraude{% endblock %}</title>
{% bootstrap_css %}{% bootstrap_javascript %}
<!-- Side Navbar from http://www.samrayner.com/bootstrap-side-navbar/inverse.html -->
<link href="/static/bootstrap/navbar-fixed-side.css" rel="stylesheet" />
<link href="/static/css/bootstrap/navbar-fixed-side.css" rel="stylesheet" />
{% if stylesheets %}{% for stylesheet in stylesheets %}
<link rel="stylesheet" type="text/css" href="{% static stylesheet %}" />{% endfor %}{% endif %}
</head>
@@ -13,7 +13,7 @@
<div class="container-fluid">
<div class="row">
<div class="col-md-3 col-lg-2">
{% include "navbar.html" %}
{% navbar %}
</div>
<div class="col-md-9 col-lg-10">
<h1 class="page-header">{% block page_header %}{% endblock %}</h1>

View File

@@ -0,0 +1,11 @@
{% load bootstrap3 %}
<li {% if active %}class="active"{%endif%}>
<a href="{{header.href}}">{% if header.icon %}{% bootstrap_icon header.icon %} &middot; {% endif %}<strong>{{header.text}}</strong></a>
</li>
{% if active %}{% for link in links %}
<li class="app-menu">
<a href="{{link.href}}">{{ link.text }}
{% if link.icon %}<span class="pull-right">{% bootstrap_icon link.icon %}</span>{% endif %}
</a>
</li>
{% endfor %}{% endif %}

View File

@@ -0,0 +1,52 @@
{% load bootstrap3 %}{% load staticfiles %}{% load navbar %}
<nav class="navbar navbar-inverse navbar-fixed-side" role="navigation" id="menu">
<div class="container">
<div class="navbar-header">
<button class="navbar-toggle" data-target=".navbar-collapse" data-toggle="collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="{% url 'index' %}">La Maraude ALSA</a>
</div>
<div class="collapse navbar-collapse">
<ul class="nav navbar-nav">{% for app in apps %}
{% navbar_menu app %}
{% endfor %}</ul>
<ul class="nav navbar-nav navbar-right">
<li class="dropdown">
<a id="UserMenu" href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<strong style="color:#fff;">{% bootstrap_icon "user" %} &middot; {{user}} </strong>&middot; {{ user_group }}<span class="caret"></span></a>
<ul class="dropdown-menu" aria-labelledby="UserMenu">
{% if next %}
<p class="well-sm text-center"><strong style="color:#980300;">Vous n'avez pas l'autorisation<br/> d'accéder à cette page.</strong></p>
{%endif%}
{% if user.is_authenticated %}
{% if user.is_superuser %}<li><a href="{% url 'admin:index' %}">{% bootstrap_icon "wrench" %} Administration</a></li>{% endif %}
<li><a href="{% url 'logout' %}">{% bootstrap_icon "log-out" %} Déconnecter</a></li>
{% else %}
<li>
<form class="navbar-form navbar-left" method="post" action="/login/">{% csrf_token %}
{% if next %}<input name="next" value="{{next}}" hidden />{% endif %}
<div class="form-group form-horizontal">
<div class="form-group">
<label class="col-md-2 sr-only control-label" for="id_username">Username</label>
<div class="col-md-10">
<input autofocus="" class="form-control" id="id_username" maxlength="254" name="username" placeholder="Username" title="" type="text" required />
</div>
</div>
<div class="form-group">
<label class="col-md-2 sr-only control-label" for="id_password">Password</label>
<div class="col-md-10"><input class="form-control" id="id_password" name="password" placeholder="Password" title="" type="password" required />
</div>
</div>
</div>
<div class="text-center"><button class="btn btn-primary navbar-button" type="submit">Connexion</button></div>
</form>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>

View File

@@ -0,0 +1,14 @@
from django import template
from django.utils.html import format_html
register = template.Library()
@register.filter
def as_icon(value):
icons = {True: "ok",
False: "remove",
None: "asterisk"
}
if not value in icons:
raise ValueError(value, 'is not a boolean or empty value !')
else:
return format_html('<span class="glyphicon glyphicon-{}"></span>', icons[value])

View File

@@ -0,0 +1,51 @@
#-*- coding:utf-8 -*-
from django import template
from django.urls import reverse
register = template.Library()
class NavbarNode(template.Node):
_apps = None
def get_menus(self, view, user):
if not self._apps:
from website.navbar import registered
if not registered:
print('WARNING: No app registered into "navbar" module')
self._apps = registered.copy()
return [app_menu(view, user) for app_menu in self._apps]
def get_template(self):
return template.loader.get_template('navbar/layout.html')
def render(self, context):
request = context.get('request')
user, view = context.get('user'), context.get('view')
apps = self.get_menus(view, user)
# Add user menu
context = template.Context({
'apps': apps,
'user': user,
'user_group': context.get('user_group', None),
'next': context.get('next', None),
})
return self.get_template().render(context, request)
@register.tag
def navbar(parser, token):
return NavbarNode()
@register.inclusion_tag("navbar/app-menu.html")
def navbar_menu(app_menu):
return {
'active': app_menu.is_active,
'header': app_menu.header,
'links': app_menu.links,
}

View File

@@ -6,6 +6,7 @@ from .views import Index, login_view
from maraudes import urls as maraudes_urls
from suivi import urls as suivi_urls
from sujets import urls as sujets_urls
from utilisateurs import urls as utilisateurs_urls
urlpatterns = [
# Authentification
@@ -19,4 +20,5 @@ urlpatterns = [
url(r'^maraudes/', include(maraudes_urls, namespace="maraudes")),
url(r'^suivi/', include(suivi_urls, namespace="suivi")),
url(r'^sujets/', include(sujets_urls, namespace="sujets")),
url(r'^utilisateurs/', include(utilisateurs_urls, namespace="utilisateurs")),
]

View File

@@ -10,7 +10,7 @@ class Index(WebsiteTemplateMixin, views.generic.TemplateView):
template_name = "main.html"
app_menu = None
header = ('La Maraude ALSA', 'accueil')
class PageInfo:
title = "La maraude ALSA"
header = "La Maraude ALSA"