From 2461ecde028b5490ee946d2dc7b73cc9717400f6 Mon Sep 17 00:00:00 2001
From: Benjamin Dauvergne <bdauvergne@entrouvert.com>
Date: Fri, 18 Jul 2014 20:31:53 +0200
Subject: [PATCH 4/5] manager: implement all views

---
 authentic2/manager/app_settings.py                 |   18 ++
 authentic2/manager/fields.py                       |   28 +++
 authentic2/manager/forms.py                        |   26 ++
 .../manager/css/icon-personnes-hover.png           |  Bin 0 -> 1588 bytes
 .../authentic2/manager/css/icon-personnes.png      |  Bin 0 -> 1609 bytes
 .../authentic2/manager/css/icon-user-hover.png     |  Bin 0 -> 1187 bytes
 .../static/authentic2/manager/css/icon-user.png    |  Bin 0 -> 489 bytes
 .../manager/static/authentic2/manager/css/info.png |  Bin 0 -> 1659 bytes
 .../static/authentic2/manager/css/style.css        |  167 +++++++++++++
 .../static/authentic2/manager/js/manager.js        |   76 ++++++
 authentic2/manager/tables.py                       |   19 ++
 .../manager/templates/authentic2/manager/base.html |   35 +++
 .../templates/authentic2/manager/delete.html       |   23 ++
 .../manager/templates/authentic2/manager/form.html |   42 ++++
 .../templates/authentic2/manager/homepage.html     |   24 ++
 .../manager/templates/authentic2/manager/role.html |   23 ++
 .../authentic2/manager/role_users_table.html       |   10 +
 .../templates/authentic2/manager/roles.html        |   64 +++++
 .../templates/authentic2/manager/table.html        |   58 +++++
 .../templates/authentic2/manager/users.html        |   26 ++
 authentic2/manager/urls.py                         |   23 ++
 authentic2/manager/utils.py                        |   58 +++++
 authentic2/manager/views.py                        |  256 ++++++++++++++++++++
 authentic2/settings.py                             |    3 +
 authentic2/urls.py                                 |    1 +
 requirements.txt                                   |    2 +
 setup.py                                           |    5 +-
 27 files changed, 986 insertions(+), 1 deletion(-)
 create mode 100644 authentic2/manager/__init__.py
 create mode 100644 authentic2/manager/app_settings.py
 create mode 100644 authentic2/manager/fields.py
 create mode 100644 authentic2/manager/forms.py
 create mode 100644 authentic2/manager/models.py
 create mode 100644 authentic2/manager/static/authentic2/manager/css/icon-personnes-hover.png
 create mode 100644 authentic2/manager/static/authentic2/manager/css/icon-personnes.png
 create mode 100644 authentic2/manager/static/authentic2/manager/css/icon-user-hover.png
 create mode 100644 authentic2/manager/static/authentic2/manager/css/icon-user.png
 create mode 100644 authentic2/manager/static/authentic2/manager/css/info.png
 create mode 100644 authentic2/manager/static/authentic2/manager/css/style.css
 create mode 100644 authentic2/manager/static/authentic2/manager/js/manager.js
 create mode 100644 authentic2/manager/tables.py
 create mode 100644 authentic2/manager/templates/authentic2/manager/base.html
 create mode 100644 authentic2/manager/templates/authentic2/manager/delete.html
 create mode 100644 authentic2/manager/templates/authentic2/manager/form.html
 create mode 100644 authentic2/manager/templates/authentic2/manager/homepage.html
 create mode 100644 authentic2/manager/templates/authentic2/manager/role.html
 create mode 100644 authentic2/manager/templates/authentic2/manager/role_users_table.html
 create mode 100644 authentic2/manager/templates/authentic2/manager/roles.html
 create mode 100644 authentic2/manager/templates/authentic2/manager/table.html
 create mode 100644 authentic2/manager/templates/authentic2/manager/users.html
 create mode 100644 authentic2/manager/urls.py
 create mode 100644 authentic2/manager/utils.py
 create mode 100644 authentic2/manager/views.py

diff --git a/authentic2/manager/__init__.py b/authentic2/manager/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/authentic2/manager/app_settings.py b/authentic2/manager/app_settings.py
new file mode 100644
index 0000000..94af283
--- /dev/null
+++ b/authentic2/manager/app_settings.py
@@ -0,0 +1,18 @@
+import sys
+
+class AppSettings(object):
+    __PREFIX = 'A2_MANAGER_'
+    __DEFAULTS = {
+            'HOMEPAGE_URL': None,
+            'LOGOUT_URL': None,
+    }
+
+    def __getattr__(self, name):
+        from django.conf import settings
+        if name not in self.__DEFAULTS:
+            raise AttributeError
+        return getattr(settings, self.__PREFIX + name, self.__DEFAULTS[name])
+
+app_settings = AppSettings()
+app_settings.__name__ = __name__
+sys.modules[__name__] = app_settings
diff --git a/authentic2/manager/fields.py b/authentic2/manager/fields.py
new file mode 100644
index 0000000..24935e1
--- /dev/null
+++ b/authentic2/manager/fields.py
@@ -0,0 +1,28 @@
+from django_select2 import AutoSelect2Field, AutoModelSelect2MultipleField, NO_ERR_RESP
+
+from django.contrib.auth.models import Group
+
+from . import utils
+
+class ChooseUserField(AutoSelect2Field):
+    def security_check(self, request, *args, **kwargs):
+        return True
+
+    def get_results(self, request, term, page, context):
+        return (NO_ERR_RESP, False, [(u.ref, u.name) for u in utils.search_user(term)])
+
+    def get_val_txt(self, value):
+        """
+        The problem of issue #66 was here. I was not overriding this.
+        When using AutoSelect2MultipleField you should implement get_val_txt in this case.
+        I think that this is because there should be an unique correspondence between
+        the referenced value and the shown value
+        In this particular example, the referenced value and the shown value are the same
+        """
+        return unicode(value)
+
+class GroupsField(AutoModelSelect2MultipleField):
+    queryset = Group.objects
+    search_fields = [
+            'name__icontains',
+    ]
diff --git a/authentic2/manager/forms.py b/authentic2/manager/forms.py
new file mode 100644
index 0000000..2e785b1
--- /dev/null
+++ b/authentic2/manager/forms.py
@@ -0,0 +1,26 @@
+from django.utils.translation import ugettext_lazy as _
+from django import forms
+
+from authentic2.compat import get_user_model
+
+from . import utils, fields
+
+class RoleAddForm(forms.Form):
+    name = forms.CharField(
+            label=_('Role name'))
+
+    def save(self):
+        return utils.role_add(self.cleaned_data['name'])
+
+
+class ChooseUserForm(forms.Form):
+    ref = fields.ChooseUserField(label=_('user'))
+
+
+class UserEditForm(forms.ModelForm):
+    groups = fields.GroupsField(required=False)
+
+    class Meta:
+        model = get_user_model()
+        fields = [ 'username', 'first_name', 'last_name', 'email', 'groups']
+
diff --git a/authentic2/manager/models.py b/authentic2/manager/models.py
new file mode 100644
index 0000000..e69de29
diff --git a/authentic2/manager/static/authentic2/manager/css/icon-personnes-hover.png b/authentic2/manager/static/authentic2/manager/css/icon-personnes-hover.png
new file mode 100644
index 0000000000000000000000000000000000000000..7e8ec4a93e3d4f848574a78e514915d16d19ffe4
GIT binary patch
diff --git a/authentic2/manager/static/authentic2/manager/css/icon-personnes.png b/authentic2/manager/static/authentic2/manager/css/icon-personnes.png
new file mode 100644
index 0000000000000000000000000000000000000000..af29363dfbee2d7bb65c2b4aa99b88f4c5f10f34
GIT binary patch
diff --git a/authentic2/manager/static/authentic2/manager/css/icon-user-hover.png b/authentic2/manager/static/authentic2/manager/css/icon-user-hover.png
new file mode 100644
index 0000000000000000000000000000000000000000..739126b01cefbe109590617dcbf0710f0ed724c4
GIT binary patch
diff --git a/authentic2/manager/static/authentic2/manager/css/icon-user.png b/authentic2/manager/static/authentic2/manager/css/icon-user.png
new file mode 100644
index 0000000000000000000000000000000000000000..397233cd1007c2ff0849bf369a9cc34efb234f1a
GIT binary patch
diff --git a/authentic2/manager/static/authentic2/manager/css/info.png b/authentic2/manager/static/authentic2/manager/css/info.png
new file mode 100644
index 0000000000000000000000000000000000000000..2ef9a4cde7417c4adcdba484cc1e78170d6a30a2
GIT binary patch
diff --git a/authentic2/manager/static/authentic2/manager/css/style.css b/authentic2/manager/static/authentic2/manager/css/style.css
new file mode 100644
index 0000000..d0cc456
--- /dev/null
+++ b/authentic2/manager/static/authentic2/manager/css/style.css
@@ -0,0 +1,167 @@
+
+div#sidebar {
+	width: 15%
+	float: left;
+	text-align: justify;
+	padding-right: 10px;
+}
+
+div#sidebar {
+	text-align: left;
+}
+
+div#sidebar input[type=text] {
+       width: 95%;
+}
+
+div#sidebar button {
+       width: 100%;
+}
+
+
+#sidebar ul.roles {
+	padding: 0;
+	text-align: left;
+	list-style: none;
+}
+
+#sidebar ul.roles li {
+	margin-bottom: 1ex;
+}
+
+#sidebar ul.roles li a {
+	text-decoration: none;
+}
+
+#sidebar ul.roles a.active {
+	font-weight: bold;
+}
+
+div.role-info h3 {
+}
+
+form#add-user-role {
+	margin-top: 2em;
+	margin-bottom: 2em;
+}
+
+form#add-user-role div.select2-container {
+	width: 30%;
+	display: inline-block;
+	position: relative;
+	top: 2px;
+	margin-left: 1ex;
+	margin-right: 1ex;
+}
+
+table.feeds td.url {
+	text-align: left;
+	padding-left: 1em;
+}
+
+form#errors label span,
+form#smtp label span {
+        width: 16em;
+        display: inline-block;
+}
+
+form#errors button, form#smtp button {
+	margin-left: 15.4em;
+}
+
+div#user-edit div.left {
+        float: left;
+        width: 400px;
+}
+
+div#user-edit div.left button {
+        margin-top: 2em;
+}
+
+div#user-edit div.right {
+        float: right;
+        width: 150px;
+}
+
+div#user-edit div.right strong {
+        display: block;
+        margin-bottom: 1ex;
+}
+
+div#user-edit div.right button {
+        width: 100%;
+        text-align: left;
+        padding: 0.5ex 1ex;
+}
+
+table.users tbody tr td:nth-child(2) {
+	color: #FF7800;
+}
+
+table.users tbody tr td:nth-child(2):hover {
+	text-decoration: underline;
+	border-bottom: 1px solid transparent;
+}
+
+/* bdauvergne additions */
+
+#delete-form {
+  float: right;
+}
+
+.ui-dialog-content .helptext {
+  display: none;
+}
+
+.form-inner-container {
+  width: 500px;
+}
+
+.other_actions {
+  float: right;
+  position: relative;
+  max-width: 35%;
+}
+
+.other_actions input[type=submit] {
+  display: block;
+  margin: 5px;
+  width: 100%;
+}
+
+ul.messages {
+	position: fixed;
+	width: 30em;
+	top: 10px;
+	right: 10px;
+	padding: 0;
+	z-index: 2000;
+	margin-top: 1em;
+	list-style-type: none;
+	display: table;
+	margin: auto;
+	background: rgba(40, 40, 40, 0.7);
+	color: white;
+	text-shadow: black 1px 1px 1px;
+	border-radius: 10px;
+}
+
+ul.messages li {
+	padding: 1ex;
+	margin: 1ex;
+	border-size: 0px;
+	-moz-border-radius:7px;
+	-webkit-border-radius:7px;
+	border-radius:7px;
+	border: none;
+}
+
+input[type=submit][name=password_reset] {
+  white-space: normal;
+}
+
+li#roles a { background-image: url(icon-personnes.png); }
+li#roles a:hover { background-image: url(icon-personnes-hover.png); }
+
+li#users a { background-image: url(icon-user.png); }
+li#users a:hover { background-image: url(icon-user-hover.png); }
diff --git a/authentic2/manager/static/authentic2/manager/js/manager.js b/authentic2/manager/static/authentic2/manager/js/manager.js
new file mode 100644
index 0000000..a208ebd
--- /dev/null
+++ b/authentic2/manager/static/authentic2/manager/js/manager.js
@@ -0,0 +1,76 @@
+$(function() {
+    /* search inputs behaviours */
+    $('#search-input').change(function () {
+      var params = $.url().param();
+      if ($(this).val()) {
+        params.search = $(this).val();
+      } else {
+        if ('search' in params) {
+          delete params.search;
+        }
+      }
+      var href = $.url().attr('path')
+      if ($.param(params)) {
+        href += '?' + $.param(params);
+      }
+      window.location = href;
+    });
+
+    /* role/user table refresh */
+    function update_table(href, cb) {
+      $.get(href, function (response_text) {
+        var $response = $(response_text);
+        var $content = $response.find('.table-container');
+        if (! $content.length) {
+            $content = $response.find('table');
+        }
+        var $container = $('.table-container');
+        if (! $container.length) {
+          $container = $('table');
+        }
+        $container.replaceWith($content);
+        if (cb != undefined) {
+          cb();
+        }
+      });
+    }
+    /* paginator ajax loading */
+    $('.content').on('click', '.paginator a', function () {
+      var href = $(this).attr('href');
+      var title = $(this).text();
+      update_table(href, function () {
+        history.pushState(null, 'page ' + title, href);
+      });
+      return false;
+    });
+    /* dialog load handler */
+    $(document).on('gadjo:dialog-loaded', function (e, form) {
+      $('.messages', form).delay(3000*(1+$('.messages li', form).length)).fadeOut('slow');
+      if ($('.table-container').length) {
+        update_table(location.href);
+      }
+    });
+    /* user deletion */
+    $(document).on('click', '.js-remove-user', function (e) {
+      var $anchor = $(e.target);
+      if ($(e.target).data('confirm')) {
+        if (! confirm($(e.target).data('confirm'))) {
+          return false;
+        }
+      }
+      var $tr = $anchor.parents('tr');
+      var ref = $tr.data('ref');
+      $.post('', {'csrfmiddlewaretoken': window.csrf_token, 'action': 'remove', 'ref': ref}, function () {
+          update_table(window.location.href);
+      });
+      return false;
+    });
+    /* confirmation on submit buttons */
+    $(document).on('click', 'input[type=submit]', function (e) {
+      if ($(e.target).data('confirm')) {
+        if (! confirm($(e.target).data('confirm'))) {
+          return false;
+        }
+      }
+    })
+});
diff --git a/authentic2/manager/tables.py b/authentic2/manager/tables.py
new file mode 100644
index 0000000..873e8a6
--- /dev/null
+++ b/authentic2/manager/tables.py
@@ -0,0 +1,19 @@
+from django.utils.translation import ugettext_lazy as _
+from django.utils.safestring import mark_safe
+
+import django_tables2 as tables
+
+from authentic2.compat import get_user_model
+
+class UserTable(tables.Table):
+    username = tables.TemplateColumn(
+        '<a rel="popup" href="{% url "a2-manager-user-edit" pk=record.pk %}">{{ record.username }}</a>',
+        verbose_name=_('username'))
+    email = tables.Column(verbose_name=mark_safe(_('Email')))
+
+    class Meta:
+        model = get_user_model()
+        attrs = {'class': 'main', 'id': 'user-table'}
+        fields = ('username', 'email', 'first_name', 'last_name',
+                'is_active')
+        empty_text = _('None')
diff --git a/authentic2/manager/templates/authentic2/manager/base.html b/authentic2/manager/templates/authentic2/manager/base.html
new file mode 100644
index 0000000..f61014c
--- /dev/null
+++ b/authentic2/manager/templates/authentic2/manager/base.html
@@ -0,0 +1,35 @@
+{% extends "gadjo/base.html" %}
+{% load i18n staticfiles %}
+
+{% block page-title %}{% trans "Management" %}{% endblock %}
+
+{% block css %}
+  <link rel="stylesheet" type="text/css" media="all" href="{% static "authentic2/manager/css/style.css" %}"/>
+{% endblock %}
+
+{% block extrascripts %}
+  {% if debug %}
+    <script src="{% static "jquery/js/jquery.form.js" %}"></script>
+  {% else %}
+    <script src="{% static "jquery/js/jquery.form.min.js" %}"></script>
+  {% endif %}
+  <script type="text/javascript" src="{% static "authentic2/js/purl.js" %}"></script>
+  <script type="text/javascript" src="{% static "authentic2/manager/js/manager.js" %}"></script>
+  <script type="text/javascript" src="/static/js/select2.js"></script>
+  <script type="text/javascript" src="/static/js/heavy_data.js"></script>
+  <script>
+    window.csrf_token = '{{ csrf_token }}';
+  </script>
+{% endblock %}
+
+{% block site-url %}{{ management_homepage_url }}{% endblock %}
+{% block site-title %}{% trans "Management" %}{% endblock %}
+
+{% block logout-url %}{{ management_logout_url }}{% endblock %}
+
+{% block homepage-url %}{% url "a2-manager-homepage" %}{% endblock %}
+
+{% block appbar %}
+  <h2>{% block page_title %}{% endblock %}</h2>
+{% endblock %}
+
diff --git a/authentic2/manager/templates/authentic2/manager/delete.html b/authentic2/manager/templates/authentic2/manager/delete.html
new file mode 100644
index 0000000..388d87b
--- /dev/null
+++ b/authentic2/manager/templates/authentic2/manager/delete.html
@@ -0,0 +1,23 @@
+{% extends "authentic2/manager/sidebar.html" %}
+{% load i18n %}
+
+{% block messages %}
+{% endblock %}
+
+{% block main %}
+  {% if title %}
+    <div id="appbar"><h2>{{ title }}</h2></div>
+  {% endif %}
+  <form method="post">
+    {% csrf_token %}
+    <div class="form-inner-container">
+      {% block caption %}
+      <p>{% blocktrans %}Do you want to delete role {{ object }} ?{% endblocktrans %}</p>
+      {% endblock %}
+      <div class="buttons">
+        <button>{% trans "Delete" %}</button> 
+        <a class="cancel" href="..">{% trans "Cancel" %}</a>
+      </div>
+    </div>
+  </form>
+{% endblock %}
diff --git a/authentic2/manager/templates/authentic2/manager/form.html b/authentic2/manager/templates/authentic2/manager/form.html
new file mode 100644
index 0000000..056025c
--- /dev/null
+++ b/authentic2/manager/templates/authentic2/manager/form.html
@@ -0,0 +1,42 @@
+{% extends "authentic2/manager/sidebar.html" %}
+{% load i18n %}
+
+{% block messages %}
+{% endblock %}
+
+{% block main %}
+  <div class="content">
+    {% if title %}
+      <div id="appbar"><h2>{{ title }}</h2></div>
+    {% endif %}
+    <form method="post">
+      <div class="form-inner-container">
+        {% if messages %}
+          <ul class="messages">
+              {% for message in messages %}
+              <li{% if message.tags %} class="{{ message.tags }}"{% endif %}>
+                  {{ message }}
+              </li>
+              {% endfor %}
+          </ul>
+        {% endif %}
+        {% if other_actions %}
+          <div class="other_actions">
+            <strong>{% trans "Actions" %}</strong>
+            {% for action in other_actions %}
+              <input type="submit" name="{{ action.name }}" value="{{ action.title }}"
+                {% if action.confirm %}data-confirm="{{ action.confirm }}"{% endif %}
+              />
+            {% endfor %}
+          </div>
+        {% endif %}
+        {% csrf_token %}
+        {{ form.as_p }}
+        <div class="buttons">
+          <button>{% if action %}{{ action }}{% else %}{% trans "Create" %}{% endif %}</button> 
+          <a class="cancel" href="..">{% trans "Cancel" %}</a>
+        </div>
+      </div>
+    </form>
+  </div>
+{% endblock %}
diff --git a/authentic2/manager/templates/authentic2/manager/homepage.html b/authentic2/manager/templates/authentic2/manager/homepage.html
new file mode 100644
index 0000000..b7801db
--- /dev/null
+++ b/authentic2/manager/templates/authentic2/manager/homepage.html
@@ -0,0 +1,24 @@
+{% extends "authentic2/manager/base.html" %}
+{% load i18n %}
+
+{% block beforecontent %}
+{% endblock %}
+
+{% block appbar %}
+   <h2>{% trans "Welcome" %}</h2>
+{% endblock %}
+
+{% block content %}
+  <div id="content">
+     <div id="user-info">
+       {{ user.get_full_name }}
+       (<a href="{% url "auth_password_change" %}">{% trans "Password change" %}</a>)
+     </div>
+
+      <ul class="apps">
+        <li id="users"><a href="{% url "a2-manager-users" %}">{% trans "User management" %}</a></li>
+        <li id="roles"><a href="{% url "a2-manager-roles" %}">{% trans "Role management" %}</a></li>
+      </ul>
+      <br style="clear: both;"/>
+  </div>
+{% endblock %}
diff --git a/authentic2/manager/templates/authentic2/manager/role.html b/authentic2/manager/templates/authentic2/manager/role.html
new file mode 100644
index 0000000..1b327c0
--- /dev/null
+++ b/authentic2/manager/templates/authentic2/manager/role.html
@@ -0,0 +1,23 @@
+{% extends "authentic2/manager/roles.html" %}
+
+{% load i18n staticfiles django_tables2 %}
+
+{% block extra_scripts %}
+  {{ block.super }}
+  {{ choose_user_form.media }}
+{% endblock %}
+
+{% block main %}
+   <div class="role-info">
+     <h3 style="margin-top: 0;">{% trans "Users with role" %}: {{ active_role.name }}</h3>
+
+     {% render_table users "authentic2/manager/role_users_table.html" %}
+
+     <form method="post" id="add-user-role">
+             {% trans "Add an user to this role:" %}
+             {% csrf_token %}
+             {{ choose_user_form.ref }}
+             <button>{% trans "Add" %}</button>
+     </form>
+   </div>
+{% endblock %}
diff --git a/authentic2/manager/templates/authentic2/manager/role_users_table.html b/authentic2/manager/templates/authentic2/manager/role_users_table.html
new file mode 100644
index 0000000..ac436e9
--- /dev/null
+++ b/authentic2/manager/templates/authentic2/manager/role_users_table.html
@@ -0,0 +1,10 @@
+{% extends "authentic2/manager/table.html" %}
+
+{% load i18n %}
+
+{% block table.head.last.column %}
+<th></th>
+{% endblock %}
+{% block table.tbody.last.column %}
+<td><a class="icon-remove-sign js-remove-user" data-confirm="{% blocktrans with username=row.record.username %}Do you really want to delete user &quot;{{ username }}&quot; ?{% endblocktrans %}" href="#"></a></td>
+{% endblock %}
diff --git a/authentic2/manager/templates/authentic2/manager/roles.html b/authentic2/manager/templates/authentic2/manager/roles.html
new file mode 100644
index 0000000..bdd4dcd
--- /dev/null
+++ b/authentic2/manager/templates/authentic2/manager/roles.html
@@ -0,0 +1,64 @@
+{% extends "authentic2/manager/sidebar.html" %}
+{% load i18n staticfiles %}
+
+{% block title %}{{ block.super }} - {% trans "Roles management" %}{% endblock %}
+
+{% block page_title %}{% trans "Roles management" %}
+{% if active_role %} — {{ active_role.name }}{% endif %}
+{% endblock %}
+
+{% block appbar %}
+  {{ block.super }}
+  {% if active_role %}
+  <a rel="popup" href="{% url "a2-manager-role-delete" role_ref=active_role.ref %}" id="add-user-btn">{% trans "Delete" %}</a>
+  <a rel="popup" href="{% url "a2-manager-role-edit" role_ref=active_role.ref %}" id="add-user-btn">{% trans "Edit" %}</a>
+  {% else %}
+   <p><a href="{% url "a2-manager-role-add" %}" rel="popup">{% trans "Add role" %}</a></p>
+  {% endif %}
+{% endblock %}
+
+
+{% block sidebar %}
+   <ul class="roles">
+     {% for role in roles %}
+       <li>
+       <a href="{% url "a2-manager-role" role_ref=role.ref %}"
+           {% if role.ref == active_role.ref %}class="active"{% endif %}>
+            {{ role.name }}
+         </a>
+       </li>
+     {% endfor %}
+   </ul>
+
+   {% if active_role %}
+     <hr />
+     <div id="search-form">
+       <h3>{% trans "Search" %}</h3>
+       <input id="search-input" type="search" value="{{ request.GET.search }}">
+       <button>{% trans "Search" %}</button>
+     </div>
+   {% endif %}
+{% endblock %}
+
+{% block main %}
+   <div class="big-msg-info">
+     Utilisez les filtres sur sur la gauche de l'écran pour afficher
+     les membres du rôle correspondant.
+   </div>
+{% endblock %}
+
+{% block page-end %}
+  <div id="role-edit" style="display: none;">
+   <form>
+    <label><span>Nom :</span> <input type="text" size="30" value="Foo"/></label></br>
+   </form>
+  </div>
+
+  <div id="role-add" style="display: none;">
+   <form id="role-add-form" method="post" action="{% url "a2-manager-role-add" %}">
+    {% csrf_token %}
+    {{ role_add_form }}
+   </form>
+  </div>
+
+{% endblock %}
diff --git a/authentic2/manager/templates/authentic2/manager/table.html b/authentic2/manager/templates/authentic2/manager/table.html
new file mode 100644
index 0000000..34f0479
--- /dev/null
+++ b/authentic2/manager/templates/authentic2/manager/table.html
@@ -0,0 +1,58 @@
+{% extends "django_tables2/table.html" %}
+
+{% load django_tables2 %}
+
+{% block table.thead %}
+<thead>
+    <tr>
+    {% for column in table.columns %}
+        {% if column.orderable %}
+        <th {{ column.attrs.th.as_html }}><a href="{% querystring table.prefixed_order_by_field=column.order_by_alias.next %}">{{ column.header }}</a></th>
+        {% else %}
+        <th {{ column.attrs.th.as_html }}>{{ column.header }}</th>
+        {% endif %}
+    {% endfor %}
+    {% block table.head.last.column %}
+    {% endblock %}
+    </tr>
+</thead>
+{% endblock table.thead %}
+
+{% block table.tbody.row %}
+        <tr data-ref="{{ row.record.id }}" class="{{ forloop.counter|divisibleby:2|yesno:"even,odd" }}"> {# avoid cycle for Django 1.2-1.6 compatibility #}
+            {% for column, cell in row.items %}
+                <td {{ column.attrs.td.as_html }}>{% if column.localize == None %}{{ cell }}{% else %}{% if column.localize %}{{ cell|localize }}{% else %}{{ cell|unlocalize }}{% endif %}{% endif %}</td>
+            {% endfor %}
+            {% block table.tbody.last.column %}
+            {% endblock %}
+        </tr>
+{% endblock table.tbody.row %}
+
+{% block pagination %}
+  <p class="paginator">
+      {% if table.page.number > 1 %}
+        {% if table.page.previous_page_number != 1 %}
+          <a href="{% querystring table.prefixed_page_field=1 %}">1</a>
+          ...
+        {% endif %}
+      {% endif %}
+
+      {% if table.page.has_previous %}
+        <a href="{% querystring table.prefixed_page_field=table.page.previous_page_number %}">{{ table.page.previous_page_number }}</a>
+      {% endif %}
+
+      <span class="this-page">{{ table.page.number }}</span>
+
+      {% if table.page.has_next %}
+        <a href="{% querystring table.prefixed_page_field=table.page.next_page_number %}">{{ table.page.next_page_number }}</a>
+      {% endif %}
+      {% if  table.page.number != table.page.paginator.num_pages %}
+        {% if table.page.paginator.num_pages > 1 %}
+          {% if table.page.next_page_number != table.page.paginator.num_pages %}
+            ...
+            <a href="{% querystring table.prefixed_page_field=table.page.paginator.num_pages %}">{{ table.page.paginator.num_pages }}</a>
+          {% endif %}
+        {% endif %}
+      {% endif %}
+  </p>
+{% endblock %}
diff --git a/authentic2/manager/templates/authentic2/manager/users.html b/authentic2/manager/templates/authentic2/manager/users.html
new file mode 100644
index 0000000..ab6cdfa
--- /dev/null
+++ b/authentic2/manager/templates/authentic2/manager/users.html
@@ -0,0 +1,26 @@
+{% extends "authentic2/manager/sidebar.html" %}
+{% load i18n staticfiles django_tables2 %}
+
+{% block page-title %}{{ block.super }} - {% trans "Users management" %}{% endblock %}
+
+{% block page_title %}{% trans "Users management" %}{% endblock %}
+
+{% block appbar %}
+  {{ block.super }}
+  <a rel="popup" href="{% url "a2-manager-user-add" %}" id="add-user-btn">{% trans "Add user" %}</a>
+{% endblock %}
+
+{% block sidebar %}
+  <div>
+    <h3>{% trans "Search" %}</h3>
+     <div id="search-form">
+       <input id="search-input" type="search" value="{{ request.GET.search }}">
+       <button>{% trans "Search" %}</button>
+     </div>
+  </div>
+  <p>{{ users.count }} users</p>
+{% endblock %}
+
+{% block main %}
+  {% render_table table "authentic2/manager/table.html" %}
+{% endblock %}
diff --git a/authentic2/manager/urls.py b/authentic2/manager/urls.py
new file mode 100644
index 0000000..281d641
--- /dev/null
+++ b/authentic2/manager/urls.py
@@ -0,0 +1,23 @@
+from django.conf.urls import patterns, url, include
+
+from . import views
+
+urlpatterns = patterns('authentic2.views', 
+        url(r'^$', views.homepage, name='a2-manager-homepage'),
+        url(r'^roles/$', views.roles, name='a2-manager-roles'),
+        url(r'^roles/add/$', views.role_add,
+            name='a2-manager-role-add'),
+        url(r'^roles/(?P<role_ref>[^/]*)/$', views.role,
+            name='a2-manager-role'),
+        url(r'^roles/(?P<role_ref>[^/]*)/edit/$', views.role_edit,
+            name='a2-manager-role-edit'),
+        url(r'^roles/(?P<role_ref>[^/]*)/delete/$',
+            views.role_delete, name='a2-manager-role-delete'),
+        url(r'^users/$', views.users, name='a2-manager-users'),
+        url(r'^users/add/$', views.user_add,
+            name='a2-manager-user-add'),
+        url(r'^users/(?P<pk>[^/]*)/$', views.user_edit,
+            name='a2-manager-user-edit'),
+
+        url(r'^', include('django_select2.urls')),
+   )
diff --git a/authentic2/manager/utils.py b/authentic2/manager/utils.py
new file mode 100644
index 0000000..c877bbe
--- /dev/null
+++ b/authentic2/manager/utils.py
@@ -0,0 +1,58 @@
+from django.contrib.auth.models import Group, User
+from django.db.models.query import Q
+
+
+class Role(object):
+    def __init__(self, name, ref):
+        self.name = name
+        self.ref = ref
+
+class RoleUser(Role):
+    pass
+
+
+def get_roles():
+    return [Role(g.name, g.id) for g in Group.objects.order_by('name')]
+
+def get_role(ref):
+    g = Group.objects.get(id=ref)
+    return Role(g.name, g.id)
+
+def filter_user(qs, search):
+    return qs.filter(Q(username__icontains=search)
+            | Q(first_name__icontains=search)
+            | Q(last_name__icontains=search)
+            | Q(email__icontains=search))
+
+def get_role_users(role, search=None):
+    qs = User.objects.filter(groups__id=role.ref)
+    if search:
+        qs = filter_user(qs, search)
+    return qs
+
+def get_users(search=None):
+    qs = User.objects.order_by('username')
+    if search:
+        qs = filter_user(qs, search)
+    return qs
+
+def role_add(name):
+    g, created = Group.objects.get_or_create(name=name)
+    return g.id
+
+def search_user(term):
+    return [RoleUser(u.get_full_name().strip() or u.username, u.id) for u in filter_user(User.objects.all(), term)[:10]]
+
+def add_user_to_role(role, user):
+    u = User.objects.get(id=user)
+    if u.groups.filter(id=role.ref).exists():
+        return False
+    else:
+        u.groups.add(Group.objects.get(id=role.ref))
+        return True
+
+def remove_user_from_role(role, user):
+    User.objects.get(id=user).groups.remove(Group.objects.get(id=role.ref))
+
+def delete_role(role):
+    Group.objects.filter(id=role.ref).delete()
diff --git a/authentic2/manager/views.py b/authentic2/manager/views.py
new file mode 100644
index 0000000..0546e30
--- /dev/null
+++ b/authentic2/manager/views.py
@@ -0,0 +1,256 @@
+import json
+
+from django.views.generic import (TemplateView, FormView, UpdateView,
+        CreateView, DeleteView)
+from django.http import HttpResponse, HttpResponseRedirect
+from django.shortcuts import redirect
+from django.utils.translation import ugettext_lazy as _
+from django.forms import models as model_forms
+from django.core.urlresolvers import reverse
+
+from django.contrib.auth.models import Group
+from django.contrib.auth.forms import PasswordResetForm
+from django.contrib.auth.tokens import default_token_generator
+from django.contrib.auth.decorators import (permission_required,
+    login_required)
+
+from django.contrib import messages
+
+from django_tables2 import RequestConfig
+
+from authentic2.compat import get_user_model
+
+from . import app_settings, utils, tables, forms
+
+class Action(object):
+    def __init__(self, name, title, confirm=None):
+        self.name = name
+        self.title = title
+        self.confirm = confirm
+
+class ManagerMixin(object):
+    def get_context_data(self, **kwargs):
+        ctx = super(ManagerMixin, self).get_context_data(**kwargs)
+        ctx['management_homepage_url'] = app_settings.HOMEPAGE_URL or reverse('auth_homepage')
+        ctx['management_logout_url'] = app_settings.LOGOUT_URL or reverse('auth_logout')
+        return ctx
+
+class RolesMixin(ManagerMixin):
+    def get_context_data(self, **kwargs):
+        ctx = super(ManagerMixin, self).get_context_data(**kwargs)
+        ctx['roles'] = utils.get_roles()
+        ctx['role_add_form'] = forms.RoleAddForm()
+        return ctx
+
+class AjaxFormViewMixin(object):
+    success_url = '.'
+
+    def form_valid(self, form):
+        if hasattr(form, 'save'):
+            self.form_result = form.save()
+        return super(AjaxFormViewMixin, self).form_valid(form)
+
+    def dispatch(self, request, *args, **kwargs):
+        response = super(AjaxFormViewMixin, self).dispatch(request, *args, **kwargs)
+        if not request.is_ajax():
+            return response
+        data = {}
+        if 'Location' in response:
+            data['location'] = response['Location']
+        if hasattr(response, 'render'):
+            response.render()
+            data['content'] = response.content
+        return HttpResponse(json.dumps(data), content_type='application/json')
+
+class RolesView(RolesMixin, TemplateView):
+    template_name = 'authentic2/manager/roles.html'
+
+class TitleMixin(object):
+    title = None
+
+    def get_context_data(self, **kwargs):
+        ctx = super(TitleMixin, self).get_context_data(**kwargs)
+        if self.title:
+            ctx['title'] = self.title
+        return ctx
+
+class ActionMixin(object):
+    action = None
+
+    def get_context_data(self, **kwargs):
+        ctx = super(ActionMixin, self).get_context_data(**kwargs)
+        if self.action:
+            ctx['action'] = self.action
+        return ctx
+
+class OtherActionsMixin(object):
+    other_actions = None
+
+    def get_context_data(self, **kwargs):
+        ctx = super(OtherActionsMixin, self).get_context_data(**kwargs)
+        ctx['other_actions'] = tuple(self.get_other_actions())
+        return ctx
+
+    def get_other_actions(self):
+        return self.other_actions or ()
+
+    def post(self, request, *args, **kwargs):
+        self.object = self.get_object()
+        for action in self.get_other_actions():
+            if action.name in request.POST:
+                method = getattr(self, 'action_' + action.name, None)
+                if method:
+                    response = method(request, *args, **kwargs)
+                    if response:
+                        return response
+                self.request.method = 'GET'
+                return self.get(request, *args, **kwargs)
+        return super(OtherActionsMixin, self).post(request, *args, **kwargs)
+
+
+class RoleAddView(TitleMixin, AjaxFormViewMixin, FormView):
+    template_name = 'authentic2/manager/form.html'
+    form_class = forms.RoleAddForm
+    title = _('Add new role')
+
+    def form_valid(self, form):
+        super(RoleAddView, self).form_valid(form)
+        return redirect('a2-manager-role', role_ref=self.form_result)
+
+class RoleDeleteView(TitleMixin, AjaxFormViewMixin, DeleteView):
+    template_name = 'authentic2/manager/delete.html'
+    model = Group
+    title = _('Delete role')
+    pk_url_kwarg = 'role_ref'
+    success_url = '..'
+
+
+class RoleEditView(TitleMixin, AjaxFormViewMixin, UpdateView):
+    template_name = 'authentic2/manager/form.html'
+    title = _('Edit role')
+    model = Group
+    pk_url_kwarg = 'role_ref'
+    fields = ['name']
+
+    def get_form_class(self):
+        return model_forms.modelform_factory(self.model, fields=self.fields)
+
+
+class RoleView(RolesMixin, TemplateView):
+    template_name = 'authentic2/manager/role.html'
+
+    def get_role(self):
+        return utils.get_role(self.kwargs['role_ref'])
+
+    def get_context_data(self, **kwargs):
+        ctx = super(RoleView, self).get_context_data(**kwargs)
+        ctx['active_role'] = self.get_role()
+        kwargs = {}
+        if 'search' in self.request.GET:
+            kwargs = {'search': self.request.GET['search']}
+        users = utils.get_role_users(ctx['active_role'], **kwargs)
+        table = tables.UserTable(users)
+        RequestConfig(self.request).configure(table)
+        ctx['users'] = table
+        ctx['choose_user_form'] = forms.ChooseUserForm()
+        return ctx
+
+    def post(self, request, *args, **kwargs):
+        role = self.get_role()
+        ref = request.POST.get('ref')
+        if ref:
+            action = request.POST.get('action', 'add')
+            if action == 'add':
+                if not utils.add_user_to_role(role, ref):
+                    messages.warning(request, _('User already in '
+                        'this role'))
+            elif action == 'remove':
+                utils.remove_user_from_role(role, ref)
+        if 'delete' in request.GET:
+            utils.delete_role(role)
+            return HttpResponseRedirect('..')
+        return HttpResponseRedirect('')
+
+
+
+roles = permission_required('group.add', raise_exception=True)(RolesView.as_view())
+role_add = permission_required('group.add', raise_exception=True)(RoleAddView.as_view())
+role_edit = permission_required('group.change', raise_exception=True)(RoleEditView.as_view())
+role_delete = permission_required('group.delete', raise_exception=True)(RoleDeleteView.as_view())
+role = permission_required('group.delete', raise_exception=True)(RoleView.as_view())
+
+class UsersView(RolesMixin, TemplateView):
+    template_name = 'authentic2/manager/users.html'
+
+    def get_context_data(self, **kwargs):
+        ctx = super(UsersView, self).get_context_data(**kwargs)
+        if 'search' in self.request.GET:
+            kwargs = {'search': self.request.GET['search']}
+        users = utils.get_users(**kwargs)
+        ctx['users'] = users
+        table = tables.UserTable(users)
+        RequestConfig(self.request).configure(table)
+        ctx['table'] = table
+        return ctx
+
+class UserMixin(object):
+    model = get_user_model()
+    template_name = 'authentic2/manager/form.html'
+    fields = ['username', 'first_name', 'last_name', 'email', 'is_active']
+    form_class = forms.UserEditForm
+
+class UserAddView(UserMixin, ActionMixin, TitleMixin,
+        AjaxFormViewMixin, CreateView):
+    title = _('Create user')
+    action = _('Create')
+
+class UserEditView(UserMixin, OtherActionsMixin, ActionMixin, TitleMixin,
+        AjaxFormViewMixin, UpdateView):
+    title = _('Edit user')
+    action = _('Edit')
+    fields = ['username', 'first_name', 'last_name', 'email']
+
+    def get_other_actions(self):
+        yield Action('password_reset', _('Reset password'))
+        if self.object.is_active:
+            yield Action('deactivate', _('Deactivate'))
+        else:
+            yield Action('activate', _('Activate'))
+        yield Action('delete', 
+                _('Delete'), 
+                _('Do you really want to delete "%s" ?') % self.object.username)
+
+    def action_activate(self, request, *args, **kwargs):
+        self.object.is_active = True
+        self.object.save()
+
+    def action_deactivate(self, request, *args, **kwargs):
+        self.object.is_active = False
+        self.object.save()
+
+    def action_delete(self, request, *args, **kwargs):
+        self.object.delete()
+        return HttpResponseRedirect('.')
+
+    def action_password_reset(self, request, *args, **kwargs):
+        # FIXME: a bit hacky, could break if PasswordResetForm implementation changes
+        # copied from django.contrib.auth.views and django.contrib.auth.forms
+        form = PasswordResetForm()
+        form.users_cache = [self.object]
+        opts = {
+            'use_https': request.is_secure(),
+            'token_generator': default_token_generator,
+            'request': request,
+        }
+        form.save(**opts)
+        messages.info(request, _('A mail was sent to %s') % self.object.email)
+
+
+users = permission_required('user.delete', raise_exception=True)(UsersView.as_view())
+user_add = permission_required('user.add', raise_exception=True)(UserAddView.as_view())
+user_edit = permission_required('user.change', raise_exception=True)(UserEditView.as_view())
+
+class HomepageView(ManagerMixin, TemplateView):
+    template_name = 'authentic2/manager/homepage.html'
+
+homepage = login_required(HomepageView.as_view())
diff --git a/authentic2/settings.py b/authentic2/settings.py
index 326629b..5c9659c 100644
--- a/authentic2/settings.py
+++ b/authentic2/settings.py
@@ -175,6 +175,8 @@ INSTALLED_APPS = (
     'admin_tools.dashboard',
     'django.contrib.admin',
     'registration',
+    'django_select2',
+    'django_tables2',
     'authentic2.nonce',
     'authentic2.saml',
     'authentic2.idp',
@@ -182,6 +184,7 @@ INSTALLED_APPS = (
     'authentic2.auth2_auth',
     'authentic2.attribute_aggregator',
     'authentic2.disco_service',
+    'authentic2.manager',
     'authentic2',
 )
 
diff --git a/authentic2/urls.py b/authentic2/urls.py
index 5b4c108..1d7ed96 100644
--- a/authentic2/urls.py
+++ b/authentic2/urls.py
@@ -23,6 +23,7 @@ not_homepage_patterns += patterns('',
     url(r'^admin/', include(admin.site.urls)),
     url(r'^admin_tools/', include('admin_tools.urls')),
     url(r'^idp/', include('authentic2.idp.urls')),
+    url(r'^manager/', include('authentic2.manager.urls')),
 )
 
 if getattr(settings, 'AUTH_OPENID', False):
diff --git a/requirements.txt b/requirements.txt
index 61018e0..9c44b4e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -8,3 +8,5 @@ django-debug-toolbar<1.0.0
 --allow-unverified django-admin-tools
 django-admin-tools>=0.5.1
 dnspython
+django-select2
+django-tables2
diff --git a/setup.py b/setup.py
index e8f394f..2f8f2c6 100755
--- a/setup.py
+++ b/setup.py
@@ -120,7 +120,10 @@ setup(name="authentic2",
         'django-registration>=1',
         'django-admin-tools>=0.5.1',
         'django-debug-toolbar<1.0.0',
-        'dnspython',],
+        'dnspython',
+        'django-select2',
+        'django-tables2',
+      ],
       zip_safe=False,
       classifiers=[
           "Development Status :: 5 - Production/Stable",
-- 
1.7.10.4

