Refactored 'users' module

- allows editing users (currently the groups and username)
- allows any logged-in user to change the passwords of any other users
- improved url highlighting of subsubmenu
This commit is contained in:
fonfon 2014-12-09 20:49:13 +01:00
parent 733a4fd139
commit 4b3b3c666a
14 changed files with 455 additions and 194 deletions

View File

@ -19,9 +19,16 @@
Plinth module to manage users
"""
from . import users
from .users import init
from gettext import gettext as _
from plinth import cfg
__all__ = ['users', 'init']
def init():
"""Intialize the user module"""
menu = cfg.main_menu.get('system:index')
menu.add_urlname(_('Users and Groups'), 'glyphicon-user', 'users:index',
15)
__all__ = ['init']
depends = ['plinth.modules.system']

View File

@ -0,0 +1,30 @@
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from django import forms
from django.contrib.auth.models import User
class UserForm(forms.ModelForm):
"""Form to change one user"""
class Meta:
model = User
fields = ('username', 'groups')
widgets = {
'username': forms.widgets.TextInput(attrs={'style': 'width: 50%'}),
'groups': forms.widgets.CheckboxSelectMultiple(),
}

View File

@ -0,0 +1,46 @@
{% extends "base.html" %}
{% comment %}
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
{% endcomment %}
{% load bootstrap %}
{% block content %}
<h3>Change password of {{ form.user.username }}</h3>
<div class="row">
<div class="col-sm-6">
<form class="form" method="post">
{% csrf_token %}
{{ form|bootstrap }}
<input type="submit" class="btn btn-primary" value="Save Password"/>
</form>
</div>
</div>
{% endblock %}
{% block page_js %}
<script>
$('#id_password1').focus();
</script>
{% endblock %}

View File

@ -22,14 +22,18 @@
{% block content %}
<form class="form" method="post">
{% csrf_token %}
<div class="row">
<div class="col-sm-8">
<form class="form" method="post">
{% csrf_token %}
{{ form|bootstrap }}
{{ form|bootstrap }}
<input type="submit" class="btn btn-primary" value="Add User"/>
<input type="submit" class="btn btn-primary" value="Create User"/>
</form>
</form>
</div>
</div>
{% endblock %}
@ -37,7 +41,7 @@
<div class="well sidebar">
<h3>Add User</h3>
<h3>Create User</h3>
<p>Adding a user via this administrative
interface <strong>might</strong> create a system user. For
@ -48,3 +52,9 @@
</div>
{% endblock %}
{% block page_js %}
<script>
$('#id_username').focus();
</script>
{% endblock %}

View File

@ -22,17 +22,19 @@
{% block content %}
<h3>Delete Users</h3>
<p>Deleting users is permanent!</p>
<h3>Delete User {{ object.username }}</h3>
<p>
Deleting is permanent. Are you sure?
</p>
<form class="form" method="post">
{% csrf_token %}
{{ form|bootstrap }}
<input type="submit" class="btn btn-primary" value="Delete User"/>
<input type="submit" class="btn btn-md btn-primary"
value="Delete {{ object.username }}"/>
<a href="{% url 'users:index' %}" role="button"
class="btn btn-md btn-primary">
Cancel
</a>
</form>
{% endblock %}

View File

@ -0,0 +1,52 @@
{% extends "base.html" %}
{% comment %}
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
{% endcomment %}
{% load bootstrap %}
{% block content %}
<h3>Users</h3>
<p>
You can edit or delete users here.
</p>
<div class="row">
<div class="col-sm-4">
<div class="list-group">
{% for user in object_list %}
<div class="list-group-item">
<a href="{% url 'users:edit' user.username %}"
style="display:inline-block; width:75%;"
title="Edit user {{ user.username }}">
{{ user.username }}
</a>
<a href="{% url 'users:delete' user.username %}"
class="btn btn-default btn-xs pull-right"
role="button" title="Delete user {{ user.username }}">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
</a>
</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% comment %}
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
{% endcomment %}
{% load bootstrap %}
{% block page_head %}
<style type="text/css">
.multiple-checkbox li {
list-style-type: none;
}
</style>
{% endblock %}
{% block content %}
<h3>Edit {{ object.username }}</h3>
<p style='font-size:larger'>
Use the
<a href='{% url 'users:change_password' object.username %}'>
Change password form
</a> to change the password of {{ object.username }}.
</p>
<hr>
<form class="form" method="post">
{% csrf_token %}
{{ form|bootstrap }}
<input type="submit" class="btn btn-primary" value="Save Changes"/>
</form>
{% endblock %}
{% block page_js %}
<script>
$('#id_username').focus();
</script>
{% endblock %}

View File

@ -20,15 +20,17 @@ URLs for the Users module
"""
from django.conf.urls import patterns, url
from django.views.generic import RedirectView
from . import views
urlpatterns = patterns( # pylint: disable-msg=C0103
'plinth.modules.users.users',
# create an index page (that only forwards) to have correct highlighting
# of submenu items
url(r'^sys/users/$', RedirectView.as_view(pattern_name='users:add'),
name='index'),
url(r'^sys/users/add/$', 'add', name='add'),
url(r'^sys/users/edit/$', 'edit', name='edit'),
urlpatterns = patterns(
'plinth.modules.users.views',
url(r'^sys/users/$', views.UserList.as_view(), name='index'),
url(r'^sys/users/create/$', views.UserCreate.as_view(), name='create'),
url(r'^sys/users/edit/(?P<slug>[\w.@+-]+)$', views.UserUpdate.as_view(),
name='edit'),
url(r'^sys/users/delete/(?P<slug>[\w.@+-]+)$', views.UserDelete.as_view(),
name='delete'),
url(r'^sys/users/change_password/(?P<slug>[\w.@+-]+)$',
views.UserChangePassword.as_view(), name='change_password'),
)

View File

@ -1,160 +0,0 @@
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from django import forms
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.core import validators
from django.core.urlresolvers import reverse_lazy
from django.template.response import TemplateResponse
from gettext import gettext as _
import logging
from plinth import cfg
from plinth.modules.lib.auth import add_user
LOGGER = logging.getLogger(__name__)
subsubmenu = {'title': _('Users and Groups'),
'items': [{'url': reverse_lazy('users:add'),
'text': _('Add User')},
{'url': reverse_lazy('users:edit'),
'text': _('Delete Users')}]}
def init():
"""Intialize the module"""
menu = cfg.main_menu.get('system:index')
menu.add_urlname(_('Users and Groups'), 'glyphicon-user', 'users:index',
15)
class UserAddForm(forms.Form): # pylint: disable-msg=W0232
"""Form to add a new user"""
username = forms.CharField(
label=_('Username'),
help_text=_('Must be lower case alphanumeric and start with \
and alphabet'),
validators=[
validators.RegexValidator(r'^[a-z][a-z0-9]*$',
_('Invalid username'))])
password = forms.CharField(label=_('Password'),
widget=forms.PasswordInput())
full_name = forms.CharField(label=_('Full name'), required=False)
email = forms.EmailField(label=_('Email'), required=False)
@login_required
def add(request):
"""Serve the form"""
form = None
if request.method == 'POST':
form = UserAddForm(request.POST, prefix='user')
# pylint: disable-msg=E1101
if form.is_valid():
_add_user(request, form.cleaned_data)
form = UserAddForm(prefix='user')
else:
form = UserAddForm(prefix='user')
return TemplateResponse(request, 'users_add.html',
{'title': _('Add User'),
'form': form,
'subsubmenu': subsubmenu})
def _add_user(request, data):
"""Add a user"""
if User.objects.filter(username=data['username']).exists():
messages.error(request, _('User "{username}" already exists').format(
username=data['username']))
return
add_user(data['username'], data['password'], data['full_name'],
data['email'], False)
messages.success(request, _('User "{username}" added').format(
username=data['username']))
class UserEditForm(forms.Form): # pylint: disable-msg=W0232
"""Form to edit/delete a user"""
def __init__(self, *args, **kwargs):
# pylint: disable-msg=E1002
super(forms.Form, self).__init__(*args, **kwargs)
for user in User.objects.all():
label = '%s (%s)' % (user.first_name, user.username)
field = forms.BooleanField(label=label, required=False)
# pylint: disable-msg=E1101
self.fields['delete_user_' + user.username] = field
@login_required
def edit(request):
"""Serve the edit form"""
form = None
if request.method == 'POST':
form = UserEditForm(request.POST, prefix='user')
# pylint: disable-msg=E1101
if form.is_valid():
_apply_edit_changes(request, form.cleaned_data)
form = UserEditForm(prefix='user')
else:
form = UserEditForm(prefix='user')
return TemplateResponse(request, 'users_edit.html',
{'title': _('Delete Users'),
'form': form,
'subsubmenu': subsubmenu})
def _apply_edit_changes(request, data):
"""Apply form changes"""
for field, value in data.items():
if not value:
continue
if not field.startswith('delete_user_'):
continue
username = field.split('delete_user_')[1]
requesting_user = request.user.username
LOGGER.info('%s asked to delete %s', requesting_user, username)
if username == requesting_user:
messages.error(
request, _('Can not delete current account - "%s"') % username)
continue
if not User.objects.filter(username=username).exists():
messages.error(request, _('User "%s" does not exist') % username)
continue
try:
User.objects.filter(username=username).delete()
messages.success(request, _('User "%s" deleted') % username)
except IOError as exception:
messages.error(request, _('Error deleting "%s" - %s') %
(username, exception))

View File

@ -0,0 +1,128 @@
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from django.contrib import messages
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.forms import UserCreationForm, AdminPasswordChangeForm
from django.contrib.auth.models import User
from django.contrib.messages.views import SuccessMessageMixin
from django.core.urlresolvers import reverse, reverse_lazy
from django.views.generic.edit import (CreateView, DeleteView, UpdateView,
FormView)
from django.views.generic import ListView
from gettext import gettext as _
from .forms import UserForm
# TODO: we do not use the title anymore, and 'items' is also a python keyword.
# For all subsubmenus: let's remove title and just use the items list directly.
# Make sure to update the tests too.
subsubmenu = {'title': _('Users and Groups'),
'items': [{'url': reverse_lazy('users:index'),
'text': _('Users')},
{'url': reverse_lazy('users:create'),
'text': _('Create User')}]}
class PlinthContextMixin():
"""Add 'subsubmenu' and 'title' to the context of class-based views"""
def get_context_data(self, **kwargs):
"""Use self.title and the module-level subsubmenu"""
context = super(PlinthContextMixin, self).get_context_data(**kwargs)
context['subsubmenu'] = subsubmenu
context['title'] = getattr(self, 'title', '')
return context
class Meta:
abstract = True
class UserCreate(PlinthContextMixin, SuccessMessageMixin, CreateView):
form_class = UserCreationForm
template_name = 'users_create.html'
model = User
success_message = "%(username)s was created successfully"
success_url = reverse_lazy('users:create')
title = _('Create User')
class UserList(PlinthContextMixin, ListView):
model = User
template_name = 'users_list.html'
title = _('Edit or Delete User')
class UserUpdate(PlinthContextMixin, SuccessMessageMixin, UpdateView):
template_name = 'users_update.html'
form_class = UserForm
model = User
slug_field = "username"
success_message = "User %(username)s was changed successfully"
fields = ['username', 'password']
exclude = ('last_login', 'email', 'first_name', 'last_name')
title = _('Edit User')
def get_success_url(self):
return reverse('users:edit', kwargs={'slug': self.object.username})
class UserDelete(PlinthContextMixin, DeleteView):
"""Handle deleting users, showing a confirmation dialog first
On GET, display a confirmation page
on POST, delete the user
"""
template_name = 'users_delete.html'
model = User
slug_field = "username"
success_url = reverse_lazy('users:index')
title = _('Delete User')
def delete(self, *args, **kwargs):
"""Set the success message of deleting the user.
The SuccessMessageMixin doesn't work with the DeleteView on Django1.7,
so set the success message manually here.
"""
message = _("User %s was deleted" % self.kwargs['slug'])
output = super(UserDelete, self).delete(*args, **kwargs)
messages.success(self.request, message)
return output
class UserChangePassword(PlinthContextMixin, SuccessMessageMixin, FormView):
template_name = 'users_change_password.html'
form_class = AdminPasswordChangeForm
slug_field = "username"
model = User
title = _('Create User')
success_message = _("The password was changed successfully")
def get_form_kwargs(self):
"""Make the user object available to the form"""
kwargs = super(UserChangePassword, self).get_form_kwargs()
kwargs['user'] = User.objects.get(username=self.kwargs['slug'])
return kwargs
def get_success_url(self):
return reverse('users:edit', kwargs={'slug': self.kwargs['slug']})
def form_valid(self, form):
if form.user == self.request.user:
update_session_auth_hash(self.request, form.user)
return super(UserChangePassword, self).form_valid(form)

View File

@ -1,4 +1,6 @@
{% load static %}
{% load plinth_extras %}
{% comment %}
#
# This file is part of Plinth.
@ -145,14 +147,7 @@
{% block subsubmenu %}
{% if subsubmenu %}
<ul class="nav nav-tabs">
{% for urlitem in subsubmenu.items %}
<li {% if urlitem.url == request.path %} class="active"{% endif %}
role="presentation">
<a href="{{ urlitem.url }}">{{ urlitem.text }}</a>
</li>
{% endfor %}
</ul>
{% show_subsubmenu subsubmenu %}
{% endif %}
{% endblock %}

View File

@ -0,0 +1,27 @@
{% comment %}
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
{% endcomment %}
<ul class="nav nav-tabs">
{% for urlitem in subsubmenu.items %}
<li {% if urlitem.active %} class="active"{% endif %}
role="presentation">
<a href="{{ urlitem.url }}">{{ urlitem.text }}</a>
</li>
{% endfor %}
</ul>

View File

View File

@ -0,0 +1,64 @@
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import os
from django import template
register = template.Library()
def mark_active_menuitem(menu, path):
"""Mark the best-matching menu item with 'active'
Input: a menu dict in the form of:
{'title': 'x',
'items': [{'url': 'a/b', 'text': 'myUrl'}, {'url': ...}]
}
Output: The same dictionary; the best-matching URL dict gets the value
'active': True. All other URL dicts get the value 'active': False.
Note: this sets the 'active' values on the menu itself, not on a copy.
"""
best_match = ''
best_match_item = None
for urlitem in menu['items']:
urlitem['active'] = False
# TODO: use a more suitable function instead of os.path.commonprefix
match = os.path.commonprefix([urlitem['url'], path])
# In case of 'xx/create' and 'xx/change' we'd have 'xx/c' as prefix.
# That's a wrong match, skip it.
match_clean = match.rpartition('/')[0]
if (len(match_clean) + 1) < len(match):
continue
if len(match_clean) > len(best_match):
best_match = match
best_match_item = urlitem
if best_match_item:
best_match_item['active'] = True
return menu
@register.inclusion_tag('subsubmenu.html', takes_context=True)
def show_subsubmenu(context, menudata):
"""Mark the active menu item and display the subsubmenu"""
menudata = mark_active_menuitem(menudata, context['request'].path)
return {'subsubmenu': menudata}