+ {% endif %}
+{% endmacro %}
From 340fd021bbd93b59d0b2d0e62ad8cfe9aaa296ac Mon Sep 17 00:00:00 2001
From: Pea Tyczynska
Date: Mon, 25 Feb 2019 16:17:48 +0000
Subject: [PATCH 2/6] Move field definitions before form definitions
---
app/main/forms.py | 144 +++++++++++++++++++++++-----------------------
1 file changed, 72 insertions(+), 72 deletions(-)
diff --git a/app/main/forms.py b/app/main/forms.py
index eee4082e1..9d18b5a06 100644
--- a/app/main/forms.py
+++ b/app/main/forms.py
@@ -255,6 +255,78 @@ def organisation_type():
)
+class FieldWithNoneOption():
+
+ # This is a special value that is specific to our forms. This is
+ # more expicit than casting `None` to a string `'None'` which can
+ # have unexpected edge cases
+ NONE_OPTION_VALUE = '__NONE__'
+
+ # When receiving Python data, eg when instantiating the form object
+ # we want to convert that data to our special value, so that it gets
+ # recognised as being one of the valid choices
+ def process_data(self, value):
+ self.data = self.NONE_OPTION_VALUE if value is None else value
+
+ # After validation we want to convert it back to a Python `None` for
+ # use elsewhere, eg posting to the API
+ def post_validate(self, form, validation_stopped):
+ if self.data == self.NONE_OPTION_VALUE and not validation_stopped:
+ self.data = None
+
+
+class RadioFieldWithNoneOption(FieldWithNoneOption, RadioField):
+ pass
+
+
+class NestedFieldMixin:
+ def children(self):
+ # start map with root option as a single child entry
+ child_map = {None: [option for option in self
+ if option.data == self.NONE_OPTION_VALUE]}
+
+ # add entries for all other children
+ for option in self:
+ if option.data == self.NONE_OPTION_VALUE:
+ child_ids = [
+ folder['id'] for folder in self.all_template_folders
+ if folder['parent_id'] is None]
+ key = self.NONE_OPTION_VALUE
+ else:
+ child_ids = [
+ folder['id'] for folder in self.all_template_folders
+ if folder['parent_id'] == option.data]
+ key = option.data
+
+ child_map[key] = [option for option in self if option.data in child_ids]
+
+ return child_map
+
+
+class NestedRadioField(RadioFieldWithNoneOption, NestedFieldMixin):
+ pass
+
+
+class NestedCheckboxesField(SelectMultipleField, NestedFieldMixin):
+ NONE_OPTION_VALUE = None
+
+
+class HiddenFieldWithNoneOption(FieldWithNoneOption, HiddenField):
+ pass
+
+
+class RadioFieldWithRequiredMessage(RadioField):
+ def __init__(self, *args, required_message='Not a valid choice', **kwargs):
+ self.required_message = required_message
+ super().__init__(*args, **kwargs)
+
+ def pre_validate(self, form):
+ try:
+ return super().pre_validate(form)
+ except ValueError:
+ raise ValueError(self.required_message)
+
+
class StripWhitespaceForm(Form):
class Meta:
def bind_field(self, form, unbound_field, options):
@@ -813,78 +885,6 @@ class ServiceSwitchChannelForm(ServiceOnOffSettingForm):
super().__init__(name, *args, **kwargs)
-class FieldWithNoneOption():
-
- # This is a special value that is specific to our forms. This is
- # more expicit than casting `None` to a string `'None'` which can
- # have unexpected edge cases
- NONE_OPTION_VALUE = '__NONE__'
-
- # When receiving Python data, eg when instantiating the form object
- # we want to convert that data to our special value, so that it gets
- # recognised as being one of the valid choices
- def process_data(self, value):
- self.data = self.NONE_OPTION_VALUE if value is None else value
-
- # After validation we want to convert it back to a Python `None` for
- # use elsewhere, eg posting to the API
- def post_validate(self, form, validation_stopped):
- if self.data == self.NONE_OPTION_VALUE and not validation_stopped:
- self.data = None
-
-
-class RadioFieldWithNoneOption(FieldWithNoneOption, RadioField):
- pass
-
-
-class NestedFieldMixin:
- def children(self):
- # start map with root option as a single child entry
- child_map = {None: [option for option in self
- if option.data == self.NONE_OPTION_VALUE]}
-
- # add entries for all other children
- for option in self:
- if option.data == self.NONE_OPTION_VALUE:
- child_ids = [
- folder['id'] for folder in self.all_template_folders
- if folder['parent_id'] is None]
- key = self.NONE_OPTION_VALUE
- else:
- child_ids = [
- folder['id'] for folder in self.all_template_folders
- if folder['parent_id'] == option.data]
- key = option.data
-
- child_map[key] = [option for option in self if option.data in child_ids]
-
- return child_map
-
-
-class NestedRadioField(RadioFieldWithNoneOption, NestedFieldMixin):
- pass
-
-
-class NestedCheckboxesField(SelectMultipleField, NestedFieldMixin):
- pass
-
-
-class HiddenFieldWithNoneOption(FieldWithNoneOption, HiddenField):
- pass
-
-
-class RadioFieldWithRequiredMessage(RadioField):
- def __init__(self, *args, required_message='Not a valid choice', **kwargs):
- self.required_message = required_message
- super().__init__(*args, **kwargs)
-
- def pre_validate(self, form):
- try:
- return super().pre_validate(form)
- except ValueError:
- raise ValueError(self.required_message)
-
-
class ServiceSetEmailBranding(StripWhitespaceForm):
branding_style = RadioFieldWithNoneOption(
From 7413423243a1ae909d2dd70b19637f32125e65be Mon Sep 17 00:00:00 2001
From: Pea Tyczynska
Date: Mon, 25 Feb 2019 16:23:28 +0000
Subject: [PATCH 3/6] Display nested folders permissions form on user
permissions page
We're reusing the logic for the `move_to` nested radios field for the
user folder permissions nested checkboxes.
The main difference between the two forms (aside from the different
input type) is that "Move" form contains the root "Templates" as an
option, whereas the folder permissions doesn't.
It turns out that, because of the way NestedFieldMixin.children and
select_nested macro are implemented the easiest way to get the desired
folder permissions behaviour is to add the root folder as a choice with
a `None` value and `NONE_OPTION_VALUE = None` set on the field, which
allows the `child_map` to be constructed but doesn't display the root
folder checkbox itself since it gets overwritten in the final `child_map`.
---
app/main/forms.py | 12 +++++++++++-
app/main/views/manage_users.py | 6 +++++-
app/templates/components/checkbox.html | 7 +++++++
app/templates/components/radios.html | 15 +++++----------
app/templates/views/manage-users/permissions.html | 8 ++++++--
.../views/service-settings/contact_link.html | 5 +++--
tests/app/main/views/test_manage_users.py | 8 +++++++-
7 files changed, 44 insertions(+), 17 deletions(-)
diff --git a/app/main/forms.py b/app/main/forms.py
index 9d18b5a06..d3e410bd2 100644
--- a/app/main/forms.py
+++ b/app/main/forms.py
@@ -418,6 +418,15 @@ PermissionsAbstract = type("PermissionsAbstract", (StripWhitespaceForm,), {
class PermissionsForm(PermissionsAbstract):
+ def __init__(self, all_template_folders=None, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if all_template_folders:
+ self.folder_permissions.all_template_folders = all_template_folders
+ self.folder_permissions.choices = [
+ (item['id'], item['name']) for item in ([{'name': 'Templates', 'id': None}] + all_template_folders)
+ ]
+
+ folder_permissions = NestedCheckboxesField('Folder permissions')
login_authentication = RadioField(
'Sign in using',
@@ -437,8 +446,9 @@ class PermissionsForm(PermissionsAbstract):
return (getattr(self, permission) for permission, _ in permissions)
@classmethod
- def from_user(cls, user, service_id):
+ def from_user(cls, user, service_id, **kwargs):
return cls(
+ **kwargs,
**{
role: user.has_permission_for_service(service_id, role)
for role in roles.keys()
diff --git a/app/main/views/manage_users.py b/app/main/views/manage_users.py
index feef22c2e..56ebdcb32 100644
--- a/app/main/views/manage_users.py
+++ b/app/main/views/manage_users.py
@@ -84,7 +84,11 @@ def edit_user_permissions(service_id, user_id):
if user.mobile_number:
mobile_number = redact_mobile_number(user.mobile_number, " ")
- form = PermissionsForm.from_user(user, service_id)
+ form = PermissionsForm.from_user(
+ user,
+ service_id,
+ all_template_folders=current_service.all_template_folders
+ )
if form.validate_on_submit():
user_api_client.set_user_permissions(
diff --git a/app/templates/components/checkbox.html b/app/templates/components/checkbox.html
index 0c6fcc843..0a9e0907b 100644
--- a/app/templates/components/checkbox.html
+++ b/app/templates/components/checkbox.html
@@ -1,3 +1,5 @@
+{% from "components/select-input.html" import select_nested %}
+
{% macro checkbox(
field,
hint=False,
@@ -17,6 +19,11 @@
{% endmacro %}
+{% macro checkboxes_nested(field, child_map, hint=None, disable=[], option_hints={}, hide_legend=False) %}
+ {{ select_nested(field, child_map, hint=None, disable=[], option_hints={}, hide_legend=False, input="checkbox") }}
+{% endmacro %}
+
+
{% macro checkbox_input(id, name, data=None, value="y") %}
+{% if current_service.has_permission("edit_folder_permissions") %}
+ {{ checkboxes_nested(form.folder_permissions, form.folder_permissions.children()) }}
+{% endif %}
+
{% if service_has_email_auth %}
{% if not mobile_number %}
{{ radios(
diff --git a/app/templates/views/service-settings/contact_link.html b/app/templates/views/service-settings/contact_link.html
index d4b5bb385..7e6fb3171 100644
--- a/app/templates/views/service-settings/contact_link.html
+++ b/app/templates/views/service-settings/contact_link.html
@@ -1,7 +1,8 @@
{% extends "withnav_template.html" %}
{% from "components/textbox.html" import textbox %}
{% from "components/page-footer.html" import page_footer %}
-{% from "components/radios.html" import radio, radios_wrapper %}
+{% from "components/radios.html" import radio %}
+{% from "components/select-input.html" import select_wrapper %}
{% from "components/form.html" import form_wrapper %}
{% block service_page_title %}
@@ -21,7 +22,7 @@
{% call form_wrapper() %}
- {% call radios_wrapper(form.contact_details_type, hide_legend=true) %}
+ {% call select_wrapper(form.contact_details_type, hide_legend=true) %}
{% for option in form.contact_details_type %}
{% set data_target = option.data.replace('_', '-') ~ "-type" %}
diff --git a/tests/app/main/views/test_manage_users.py b/tests/app/main/views/test_manage_users.py
index 46c48f60f..122f58e9b 100644
--- a/tests/app/main/views/test_manage_users.py
+++ b/tests/app/main/views/test_manage_users.py
@@ -228,6 +228,7 @@ def test_service_with_no_email_auth_hides_auth_type_options(
auth_options_hidden,
service_one,
mock_get_users_by_service,
+ mock_get_template_folders
):
if service_has_email_auth:
service_one['permissions'].append('email_auth')
@@ -249,6 +250,7 @@ def test_service_with_no_email_auth_hides_auth_type_options(
def test_service_without_caseworking_doesnt_show_admin_vs_caseworker(
client_request,
mock_get_users_by_service,
+ mock_get_template_folders,
endpoint,
service_has_caseworking,
extra_args,
@@ -304,6 +306,7 @@ def test_manage_users_page_shows_member_auth_type_if_service_has_email_auth_acti
def test_user_with_no_mobile_number_cant_be_set_to_sms_auth(
client_request,
mock_get_users_by_service,
+ mock_get_template_folders,
user,
sms_option_disabled,
expected_label,
@@ -353,6 +356,7 @@ def test_user_with_no_mobile_number_cant_be_set_to_sms_auth(
def test_should_show_page_for_one_user(
client_request,
mock_get_users_by_service,
+ mock_get_template_folders,
endpoint,
extra_args,
expected_checkboxes,
@@ -419,6 +423,7 @@ def test_edit_user_permissions(
mock_get_users_by_service,
mock_get_invites_for_service,
mock_set_user_permissions,
+ mock_get_template_folders,
fake_uuid,
submitted_permissions,
permissions_sent_to_api,
@@ -474,7 +479,8 @@ def test_edit_user_permissions_including_authentication_with_email_auth_service(
mock_set_user_permissions,
mock_update_user_attribute,
service_one,
- auth_type
+ auth_type,
+ mock_get_template_folders
):
service_one['permissions'].append('email_auth')
From 3ba4a22b7c5517d5ac7dd3b3e5399431c2bb232e Mon Sep 17 00:00:00 2001
From: Alexey Bezhan
Date: Wed, 27 Feb 2019 15:44:52 +0000
Subject: [PATCH 4/6] Add checkboxes-nested CSS rules
---
.../stylesheets/components/checkboxes.scss | 44 +++++++++++++++++++
app/assets/stylesheets/main.scss | 1 +
2 files changed, 45 insertions(+)
create mode 100644 app/assets/stylesheets/components/checkboxes.scss
diff --git a/app/assets/stylesheets/components/checkboxes.scss b/app/assets/stylesheets/components/checkboxes.scss
new file mode 100644
index 000000000..40ca600d4
--- /dev/null
+++ b/app/assets/stylesheets/components/checkboxes.scss
@@ -0,0 +1,44 @@
+.checkboxes-nested {
+
+ margin-bottom: 10px;
+
+ .multiple-choice {
+
+ $circle-diameter: 39px;
+ $border-thickness: 4px;
+ $border-indent: ($circle-diameter / 2) - ($border-thickness / 2);
+ $border-colour: $border-colour;
+
+ float: none;
+ position: relative;
+
+ &:before {
+ content: "";
+ position: absolute;
+ bottom: 0;
+ left: $border-indent;
+ width: $border-thickness;
+ height: 100%;
+ background: $border-colour;
+ }
+
+ label {
+ float: none;
+ }
+
+ [type=checkbox]+label::before {
+ // To overlap the grey inset line
+ background: $white;
+ }
+
+ ul {
+ // To equalise the spacing between the line and the top/bottom of
+ // the radio
+ margin-top: 5px;
+ margin-bottom: -5px;
+ padding-left: 12px;
+ }
+
+ }
+
+}
diff --git a/app/assets/stylesheets/main.scss b/app/assets/stylesheets/main.scss
index 1db1fcff1..a12340dca 100644
--- a/app/assets/stylesheets/main.scss
+++ b/app/assets/stylesheets/main.scss
@@ -47,6 +47,7 @@ $path: '/static/images/';
@import 'components/api-key';
@import 'components/vendor/previous-next-navigation';
@import 'components/radios';
+@import 'components/checkboxes';
@import 'components/pill';
@import 'components/secondary-button';
@import 'components/show-more';
From 6fa975e867e2ebfe546c87329409adf06c3fae17 Mon Sep 17 00:00:00 2001
From: Alexey Bezhan
Date: Wed, 27 Feb 2019 15:45:18 +0000
Subject: [PATCH 5/6] Send updated user folder permissions to the API
Integrates the folder permissions form with the updated API endpoint
to store changes in the user folders.
Since user folder permissions are returned in the full list of template
folders for the service we need to invalidate the cache key for it each
time we update user permissions.
---
app/main/forms.py | 2 +-
app/main/views/manage_users.py | 8 ++++
app/notify_client/user_api_client.py | 8 +++-
.../views/manage-users/permissions.html | 2 +-
tests/app/main/views/test_manage_users.py | 43 ++++++++++++++++++-
5 files changed, 58 insertions(+), 5 deletions(-)
diff --git a/app/main/forms.py b/app/main/forms.py
index d3e410bd2..ba9f2dbbd 100644
--- a/app/main/forms.py
+++ b/app/main/forms.py
@@ -420,7 +420,7 @@ PermissionsAbstract = type("PermissionsAbstract", (StripWhitespaceForm,), {
class PermissionsForm(PermissionsAbstract):
def __init__(self, all_template_folders=None, *args, **kwargs):
super().__init__(*args, **kwargs)
- if all_template_folders:
+ if all_template_folders is not None:
self.folder_permissions.all_template_folders = all_template_folders
self.folder_permissions.choices = [
(item['id'], item['name']) for item in ([{'name': 'Templates', 'id': None}] + all_template_folders)
diff --git a/app/main/views/manage_users.py b/app/main/views/manage_users.py
index 56ebdcb32..8c954b767 100644
--- a/app/main/views/manage_users.py
+++ b/app/main/views/manage_users.py
@@ -87,6 +87,10 @@ def edit_user_permissions(service_id, user_id):
form = PermissionsForm.from_user(
user,
service_id,
+ folder_permissions=[
+ f['id'] for f in current_service.all_template_folders
+ if user_id in f.get('users_with_permission', [])
+ ],
all_template_folders=current_service.all_template_folders
)
@@ -94,6 +98,10 @@ def edit_user_permissions(service_id, user_id):
user_api_client.set_user_permissions(
user_id, service_id,
permissions=form.permissions,
+ folder_permissions=(
+ form.folder_permissions.data
+ if current_service.has_permission('edit_folder_permissions') else None
+ ),
)
if service_has_email_auth:
user_api_client.update_user_attribute(user_id, auth_type=form.login_authentication.data)
diff --git a/app/notify_client/user_api_client.py b/app/notify_client/user_api_client.py
index 3bb007bf2..619468f4e 100644
--- a/app/notify_client/user_api_client.py
+++ b/app/notify_client/user_api_client.py
@@ -161,13 +161,17 @@ class UserApiClient(NotifyAdminAPIClient):
resp = self.post('/organisations/{}/users/{}'.format(org_id, user_id), data={})
return User(resp['data'], max_failed_login_count=self.max_failed_login_count)
+ @cache.delete('service-{service_id}-template-folders')
@cache.delete('user-{user_id}')
- def set_user_permissions(self, user_id, service_id, permissions):
+ def set_user_permissions(self, user_id, service_id, permissions, folder_permissions=None):
# permissions passed in are the combined admin roles, not db permissions
data = {
- 'permissions': [{'permission': x} for x in translate_permissions_from_admin_roles_to_db(permissions)]
+ 'permissions': [{'permission': x} for x in translate_permissions_from_admin_roles_to_db(permissions)],
}
+ if folder_permissions is not None:
+ data['folder_permissions'] = folder_permissions
+
endpoint = '/user/{}/service/{}/permission'.format(user_id, service_id)
self.post(endpoint, data=data)
diff --git a/app/templates/views/manage-users/permissions.html b/app/templates/views/manage-users/permissions.html
index 8b2e54f2c..50648da5e 100644
--- a/app/templates/views/manage-users/permissions.html
+++ b/app/templates/views/manage-users/permissions.html
@@ -14,7 +14,7 @@
All team members can see sent messages.
-{% if current_service.has_permission("edit_folder_permissions") %}
+{% if current_service.has_permission("edit_folder_permissions") and form.folder_permissions.all_template_folders %}
{{ checkboxes_nested(form.folder_permissions, form.folder_permissions.children()) }}
{% endif %}
diff --git a/tests/app/main/views/test_manage_users.py b/tests/app/main/views/test_manage_users.py
index 122f58e9b..0ddf73223 100644
--- a/tests/app/main/views/test_manage_users.py
+++ b/tests/app/main/views/test_manage_users.py
@@ -447,6 +447,45 @@ def test_edit_user_permissions(
fake_uuid,
SERVICE_ONE_ID,
permissions=permissions_sent_to_api,
+ folder_permissions=None
+ )
+
+
+def test_edit_user_folder_permissions(
+ client_request,
+ mocker,
+ service_one,
+ mock_get_users_by_service,
+ mock_get_invites_for_service,
+ mock_set_user_permissions,
+ mock_get_template_folders,
+ fake_uuid,
+):
+ service_one['permissions'] = ['edit_folder_permissions']
+ mock_get_template_folders.return_value = [
+ {'id': 'folder-id-1', 'name': 'folder_one', 'parent_id': None, 'users_with_permission': []},
+ {'id': 'folder-id-2', 'name': 'folder_one', 'parent_id': None, 'users_with_permission': []},
+ {'id': 'folder-id-3', 'name': 'folder_one', 'parent_id': 'folder-id-1', 'users_with_permission': []},
+ ]
+ client_request.post(
+ 'main.edit_user_permissions',
+ service_id=SERVICE_ONE_ID,
+ user_id=fake_uuid,
+ _data=dict(
+ folder_permissions=['folder-id-1', 'folder-id-3']
+ ),
+ _expected_status=302,
+ _expected_redirect=url_for(
+ 'main.manage_users',
+ service_id=SERVICE_ONE_ID,
+ _external=True,
+ ),
+ )
+ mock_set_user_permissions.assert_called_with(
+ fake_uuid,
+ SERVICE_ONE_ID,
+ permissions=set(),
+ folder_permissions=['folder-id-1', 'folder-id-3']
)
@@ -508,7 +547,8 @@ def test_edit_user_permissions_including_authentication_with_email_auth_service(
'manage_templates',
'manage_service',
'manage_api_keys',
- }
+ },
+ folder_permissions=None
)
mock_update_user_attribute.assert_called_with(
str(active_user_with_permissions.id),
@@ -1021,6 +1061,7 @@ def test_edit_user_permissions_page_displays_redacted_mobile_number_and_change_l
client_request,
active_user_with_permissions,
mock_get_users_by_service,
+ mock_get_template_folders,
service_one,
mocker
):
From a2389fe2ca2516f33e19ce23b80d216590e6350a Mon Sep 17 00:00:00 2001
From: Alexey Bezhan
Date: Tue, 5 Mar 2019 11:45:50 +0000
Subject: [PATCH 6/6] Make folder permissions form label more descriptive
---
app/main/forms.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/main/forms.py b/app/main/forms.py
index ba9f2dbbd..aa5c54ab8 100644
--- a/app/main/forms.py
+++ b/app/main/forms.py
@@ -426,7 +426,7 @@ class PermissionsForm(PermissionsAbstract):
(item['id'], item['name']) for item in ([{'name': 'Templates', 'id': None}] + all_template_folders)
]
- folder_permissions = NestedCheckboxesField('Folder permissions')
+ folder_permissions = NestedCheckboxesField('Folders this team member can see')
login_authentication = RadioField(
'Sign in using',