Repository: carpentries/amy

Issue:
User password and permissions
We should allow to set up user password by admins correctly.

I'm thinking about such scenario:
1. Admins creates a user. We don't provide "password", "repeat password" fields, but in the backend we set up `person.set_unusable_password()`.
2. On person details page we provide two buttons: "Change password", "Change permissions".
3. Change password has "password", "repeat password" fields
4. Change permissions has "is superuser", "user permissions", "user groups" fields



Discussion:

Resolving patch:
diff --git a/workshops/forms.py b/workshops/forms.py
--- a/workshops/forms.py
+++ b/workshops/forms.py
@@ -209,12 +209,23 @@ class PersonForm(forms.ModelForm):
 
     class Meta:
         model = Person
-        # don't display the 'password', 'user_permissions', 'group_permissions'
-        # fields
+        # don't display the 'password', 'user_permissions',
+        # 'groups' or 'is_superuser' fields
         # + reorder fields
         fields = ['personal', 'middle', 'family', 'username', 'may_contact',
                   'email', 'gender', 'airport', 'github', 'twitter', 'url',
-                  'notes', 'is_superuser']
+                  'notes', ]
+
+
+class PersonPermissionsForm(forms.ModelForm):
+    class Meta:
+        model = Person
+        # only display 'user_permissions', 'groups' and `is_superuser` fields
+        fields = [
+            'is_superuser',
+            'user_permissions',
+            'groups',
+        ]
 
 
 class BadgeAwardForm(forms.ModelForm):
diff --git a/workshops/urls.py b/workshops/urls.py
--- a/workshops/urls.py
+++ b/workshops/urls.py
@@ -17,6 +17,8 @@
     url(r'^persons/?$', views.all_persons, name='all_persons'),
     url(r'^person/(?P<person_id>[\w\.-]+)/?$', views.person_details, name='person_details'),
     url(r'^person/(?P<person_id>[\w\.-]+)/edit$', views.PersonUpdate.as_view(), name='person_edit'),
+    url(r'^person/(?P<person_id>[\w\.-]+)/permissions$', views.PersonPermissions.as_view(), name='person_permissions'),
+    url(r'^person/(?P<person_id>[\w\.-]+)/password$', views.person_password, name='person_password'),
     url(r'^persons/add/$', views.PersonCreate.as_view(), name='person_add'),
     url(r'^persons/bulkadd/$',views.person_bulk_add, name='person_bulk_add'),
     url(r'^persons/bulkadd/template',views.person_bulk_add_template, name='person_bulk_add_template'),
diff --git a/workshops/views.py b/workshops/views.py
--- a/workshops/views.py
+++ b/workshops/views.py
@@ -6,9 +6,11 @@
 import requests
 
 from django.contrib import messages
+from django.contrib.auth import update_session_auth_hash
+from django.contrib.auth.forms import SetPasswordForm, PasswordChangeForm
 from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
 from django.core.urlresolvers import reverse
-from django.core.exceptions import ObjectDoesNotExist
+from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.conf import settings
 from django.http import Http404, HttpResponse
 from django.db import IntegrityError, transaction
@@ -33,7 +35,7 @@
 from workshops.forms import (
     SearchForm, DebriefForm, InstructorsForm, PersonForm, PersonBulkAddForm,
     EventForm, TaskForm, TaskFullForm, bootstrap_helper, bootstrap_helper_without_form,
-    BadgeAwardForm
+    BadgeAwardForm, PersonPermissionsForm
 )
 from workshops.util import (
     earth_distance, upload_person_task_csv,  verify_upload_person_task,
@@ -400,7 +402,7 @@ def person_bulk_add_confirmation(request):
 
 class PersonCreate(LoginRequiredMixin, CreateViewContext):
     model = Person
-    fields = PERSON_FIELDS
+    form_class = PersonForm
     template_name = 'workshops/generic_form.html'
 
 
@@ -411,6 +413,44 @@ class PersonUpdate(LoginRequiredMixin, UpdateViewContext):
     template_name = 'workshops/generic_form.html'
 
 
+class PersonPermissions(LoginRequiredMixin, UpdateViewContext):
+    model = Person
+    form_class = PersonPermissionsForm
+    pk_url_kwarg = 'person_id'
+    template_name = 'workshops/generic_form.html'
+
+
+@login_required
+def person_password(request, person_id):
+    user = get_object_or_404(Person, pk=person_id)
+
+    Form = PasswordChangeForm
+    if request.user.is_superuser:
+        Form = SetPasswordForm
+    elif request.user.pk != user.pk:
+        # non-superuser can only change their own password, not someone else's
+        raise PermissionDenied
+
+    if request.method == 'POST':
+        form = Form(user, request.POST)
+        if form.is_valid():
+            form.save()  # saves the password for the user
+
+            update_session_auth_hash(request, form.user)
+
+            return redirect(reverse('person_details', args=[user.id]))
+    else:
+        form = Form(user)
+
+    return render(request, 'workshops/generic_form.html', {
+        'form': form,
+        'model': Person,
+        'object': user,
+        'form_helper': bootstrap_helper,
+        'title': 'Change password',
+    })
+
+
 #------------------------------------------------------------
 
 @login_required


Test patch:
diff --git a/workshops/test/base.py b/workshops/test/base.py
--- a/workshops/test/base.py
+++ b/workshops/test/base.py
@@ -265,6 +265,9 @@ def _get_form_data(self, doc):
         inputs = dict([(i.attrib['name'], i.attrib.get('value', None))
                        for i in form.findall(".//input[@type='text']")])
 
+        passwords = dict([(i.attrib['name'], i.attrib.get('value', None))
+                          for i in form.findall(".//input[@type='password']")])
+
         hidden = dict([(i.attrib['name'], i.attrib.get('value', None))
                        for i in form.findall(".//input[@type='hidden']")])
 
@@ -277,6 +280,7 @@ def _get_form_data(self, doc):
         textareas = dict([(t.attrib['name'], t.text)
                           for t in form.findall(".//textarea")])
 
+        inputs.update(passwords)
         inputs.update(hidden)
         inputs.update(checkboxes)
         inputs.update(selects)
diff --git a/workshops/test/test_person.py b/workshops/test/test_person.py
--- a/workshops/test/test_person.py
+++ b/workshops/test/test_person.py
@@ -1,6 +1,8 @@
 # coding: utf-8
 
 from django.core.urlresolvers import reverse
+from django.contrib.auth.models import Permission, Group
+
 from ..models import Person
 from .base import TestBase
 
@@ -147,3 +149,154 @@ def test_edit_person_notes(self):
         else:
             self._check_status_code_and_parse(response, 200)
             assert False, 'expected 302 redirect after post'
+
+    def test_edit_person_permissions(self):
+        "Make sure we can set up user permissions correctly."
+
+        # make sure Hermione does not have any perms, nor groups
+        assert not self.hermione.is_superuser
+        assert self.hermione.user_permissions.count() == 0
+        assert self.hermione.groups.count() == 0
+
+        user_permissions = Permission.objects \
+            .filter(content_type__app_label='admin')
+        user_permissions_ids = user_permissions.values_list('id', flat=True) \
+            .order_by('id')
+
+        groups = Group.objects.all()
+        groups_ids = groups.values_list('id', flat=True).order_by('id')
+
+        data = {
+            'is_superuser': True,
+            'user_permissions': user_permissions_ids,
+            'groups': groups_ids,
+        }
+
+        response = self.client.post(
+            reverse('person_permissions', args=[self.hermione.id]),
+            data,
+        )
+
+        assert response.status_code == 302
+
+        self.hermione.refresh_from_db()
+        assert self.hermione.is_superuser
+        assert \
+            set(self.hermione.user_permissions.all()) == set(user_permissions)
+        assert set(self.hermione.groups.all()) == set(groups)
+
+
+class TestPersonPassword(TestBase):
+    """Separate tests for testing password setting.
+
+    They need to be in separate class that doesn't call
+    self._setUpUsersAndLogin().
+    """
+
+    def setUp(self):
+        # create a superuser
+        self.admin = Person.objects.create_superuser(
+            username='admin', personal='Super', family='User',
+            email='sudo@example.org', password='admin',
+        )
+
+        # create a normal user
+        self.user = Person.objects.create_user(
+            username='user', personal='Typical', family='User',
+            email='undo@example.org', password='user',
+        )
+
+    def test_edit_password_by_superuser(self):
+        self.client.login(username='admin', password='admin')
+        user = self.admin
+        url, form = self._get_initial_form('person_password', user.pk)
+
+        # check that correct form is rendered
+        assert 'old_password' not in form
+        assert 'new_password1' in form
+        assert 'new_password2' in form
+
+        # try incorrect form data first
+        new_password = 'new_password'
+        data = {
+            'new_password1': new_password,
+            'new_password2': 'asdf',
+        }
+        rv = self.client.post(url, data)
+        assert rv.status_code != 302
+
+        # update password
+        data['new_password2'] = new_password
+        rv = self.client.post(url, data)
+        assert rv.status_code == 302
+
+        # make sure password was updated
+        user.refresh_from_db()
+        assert user.check_password(new_password) is True
+
+    def test_edit_other_user_password_by_superuser(self):
+        self.client.login(username='admin', password='admin')
+        user = self.user
+        url, form = self._get_initial_form('person_password', user.pk)
+
+        # check that correct form is rendered
+        assert 'old_password' not in form
+        assert 'new_password1' in form
+        assert 'new_password2' in form
+
+        # try incorrect form data first
+        new_password = 'new_password'
+        data = {
+            'new_password1': new_password,
+            'new_password2': 'asdf',
+        }
+        rv = self.client.post(url, data)
+        assert rv.status_code != 302
+
+        # update password
+        data['new_password2'] = new_password
+        rv = self.client.post(url, data)
+        assert rv.status_code == 302
+
+        # make sure password was updated
+        user.refresh_from_db()
+        assert user.check_password(new_password) is True
+
+    def test_edit_password_by_normal_user(self):
+        self.client.login(username='user', password='user')
+        user = self.user
+        url, form = self._get_initial_form('person_password', user.pk)
+
+        # check that correct form is rendered
+        assert 'old_password' in form
+        assert 'new_password1' in form
+        assert 'new_password2' in form
+
+        # try incorrect form data first
+        new_password = 'new_password'
+        data = {
+            'old_password': 'asdf',
+            'new_password1': new_password,
+            'new_password2': 'asdf',
+        }
+        rv = self.client.post(url, data)
+        assert rv.status_code != 302
+
+        data['old_password'] = 'user'
+        rv = self.client.post(url, data)
+        assert rv.status_code != 302
+
+        # update password
+        data['new_password2'] = new_password
+        rv = self.client.post(url, data)
+        assert rv.status_code == 302
+
+        # make sure password was updated
+        user.refresh_from_db()
+        assert user.check_password(new_password) is True
+
+    def test_edit_other_user_password_by_normal_user(self):
+        self.client.login(username='user', password='user')
+        user = self.admin
+        rv = self.client.get(reverse('person_password', args=[user.pk]))
+        assert rv.status_code == 403