From 4b3b3c666a4726e512e6e889c8d783b7ee9621d2 Mon Sep 17 00:00:00 2001 From: fonfon Date: Tue, 9 Dec 2014 20:49:13 +0100 Subject: [PATCH] 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 --- plinth/modules/users/__init__.py | 13 +- plinth/modules/users/forms.py | 30 ++++ .../templates/users_change_password.html | 46 +++++ .../{users_add.html => users_create.html} | 22 ++- .../{users_edit.html => users_delete.html} | 18 +- .../modules/users/templates/users_list.html | 52 ++++++ .../modules/users/templates/users_update.html | 58 +++++++ plinth/modules/users/urls.py | 20 ++- plinth/modules/users/users.py | 160 ------------------ plinth/modules/users/views.py | 128 ++++++++++++++ plinth/templates/base.html | 11 +- plinth/templates/subsubmenu.html | 27 +++ plinth/templatetags/__init__.py | 0 plinth/templatetags/plinth_extras.py | 64 +++++++ 14 files changed, 455 insertions(+), 194 deletions(-) create mode 100644 plinth/modules/users/forms.py create mode 100644 plinth/modules/users/templates/users_change_password.html rename plinth/modules/users/templates/{users_add.html => users_create.html} (76%) rename plinth/modules/users/templates/{users_edit.html => users_delete.html} (73%) create mode 100644 plinth/modules/users/templates/users_list.html create mode 100644 plinth/modules/users/templates/users_update.html delete mode 100644 plinth/modules/users/users.py create mode 100644 plinth/modules/users/views.py create mode 100644 plinth/templates/subsubmenu.html create mode 100644 plinth/templatetags/__init__.py create mode 100644 plinth/templatetags/plinth_extras.py diff --git a/plinth/modules/users/__init__.py b/plinth/modules/users/__init__.py index 593dea9fc..69869422d 100644 --- a/plinth/modules/users/__init__.py +++ b/plinth/modules/users/__init__.py @@ -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'] diff --git a/plinth/modules/users/forms.py b/plinth/modules/users/forms.py new file mode 100644 index 000000000..78e637dfd --- /dev/null +++ b/plinth/modules/users/forms.py @@ -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 . +# + +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(), + } diff --git a/plinth/modules/users/templates/users_change_password.html b/plinth/modules/users/templates/users_change_password.html new file mode 100644 index 000000000..aeff523c4 --- /dev/null +++ b/plinth/modules/users/templates/users_change_password.html @@ -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 . +# +{% endcomment %} + +{% load bootstrap %} + +{% block content %} + +

Change password of {{ form.user.username }}

+ +
+
+
+ {% csrf_token %} + + {{ form|bootstrap }} + + + +
+
+
+ +{% endblock %} + +{% block page_js %} + +{% endblock %} diff --git a/plinth/modules/users/templates/users_add.html b/plinth/modules/users/templates/users_create.html similarity index 76% rename from plinth/modules/users/templates/users_add.html rename to plinth/modules/users/templates/users_create.html index f275523b0..31e5b9413 100644 --- a/plinth/modules/users/templates/users_add.html +++ b/plinth/modules/users/templates/users_create.html @@ -22,14 +22,18 @@ {% block content %} -
- {% csrf_token %} +
+
+ + {% csrf_token %} - {{ form|bootstrap }} + {{ form|bootstrap }} - + - + +
+
{% endblock %} @@ -37,7 +41,7 @@ {% endblock %} + +{% block page_js %} + +{% endblock %} diff --git a/plinth/modules/users/templates/users_edit.html b/plinth/modules/users/templates/users_delete.html similarity index 73% rename from plinth/modules/users/templates/users_edit.html rename to plinth/modules/users/templates/users_delete.html index 714c996e6..5a7a88e6c 100644 --- a/plinth/modules/users/templates/users_edit.html +++ b/plinth/modules/users/templates/users_delete.html @@ -22,17 +22,19 @@ {% block content %} -

Delete Users

- -

Deleting users is permanent!

+

Delete User {{ object.username }}

+

+ Deleting is permanent. Are you sure? +

{% csrf_token %} - - {{ form|bootstrap }} - - - + + + Cancel +
{% endblock %} diff --git a/plinth/modules/users/templates/users_list.html b/plinth/modules/users/templates/users_list.html new file mode 100644 index 000000000..a4d3f5273 --- /dev/null +++ b/plinth/modules/users/templates/users_list.html @@ -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 . +# +{% endcomment %} + +{% load bootstrap %} + +{% block content %} + +

Users

+

+ You can edit or delete users here. +

+ +
+
+
+ {% for user in object_list %} + + {% endfor %} +
+
+
+ +{% endblock %} diff --git a/plinth/modules/users/templates/users_update.html b/plinth/modules/users/templates/users_update.html new file mode 100644 index 000000000..8ff430def --- /dev/null +++ b/plinth/modules/users/templates/users_update.html @@ -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 . +# +{% endcomment %} + +{% load bootstrap %} + +{% block page_head %} + +{% endblock %} + +{% block content %} + +

Edit {{ object.username }}

+ +

+ Use the + + Change password form + to change the password of {{ object.username }}. +

+
+ +
+ {% csrf_token %} + + {{ form|bootstrap }} + + + +
+ +{% endblock %} + +{% block page_js %} + +{% endblock %} diff --git a/plinth/modules/users/urls.py b/plinth/modules/users/urls.py index febb6a63c..1b3876fda 100644 --- a/plinth/modules/users/urls.py +++ b/plinth/modules/users/urls.py @@ -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[\w.@+-]+)$', views.UserUpdate.as_view(), + name='edit'), + url(r'^sys/users/delete/(?P[\w.@+-]+)$', views.UserDelete.as_view(), + name='delete'), + url(r'^sys/users/change_password/(?P[\w.@+-]+)$', + views.UserChangePassword.as_view(), name='change_password'), ) diff --git a/plinth/modules/users/users.py b/plinth/modules/users/users.py deleted file mode 100644 index 51509eeb7..000000000 --- a/plinth/modules/users/users.py +++ /dev/null @@ -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 . -# - -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)) diff --git a/plinth/modules/users/views.py b/plinth/modules/users/views.py new file mode 100644 index 000000000..3950a86f0 --- /dev/null +++ b/plinth/modules/users/views.py @@ -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 . +# + +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) diff --git a/plinth/templates/base.html b/plinth/templates/base.html index 851f8d3ed..340e9c03f 100644 --- a/plinth/templates/base.html +++ b/plinth/templates/base.html @@ -1,4 +1,6 @@ {% load static %} +{% load plinth_extras %} + {% comment %} # # This file is part of Plinth. @@ -145,14 +147,7 @@ {% block subsubmenu %} {% if subsubmenu %} - + {% show_subsubmenu subsubmenu %} {% endif %} {% endblock %} diff --git a/plinth/templates/subsubmenu.html b/plinth/templates/subsubmenu.html new file mode 100644 index 000000000..f071f471f --- /dev/null +++ b/plinth/templates/subsubmenu.html @@ -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 . +# +{% endcomment %} + + diff --git a/plinth/templatetags/__init__.py b/plinth/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/templatetags/plinth_extras.py b/plinth/templatetags/plinth_extras.py new file mode 100644 index 000000000..0b7b38698 --- /dev/null +++ b/plinth/templatetags/plinth_extras.py @@ -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 . +# + +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}