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 %}
+
+
{% 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 %}
+
+
+
+{% 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 %}
+
+
+
+{% 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}