diff --git a/app/main/__init__.py b/app/main/__init__.py index 7510e236a..c42d86d65 100644 --- a/app/main/__init__.py +++ b/app/main/__init__.py @@ -26,6 +26,7 @@ from app.main.views import ( # noqa invites, feedback, providers, + find_users, platform_admin, letter_jobs, email_branding, diff --git a/app/main/forms.py b/app/main/forms.py index 826080376..f0f7d790a 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -853,6 +853,16 @@ class SearchTemplatesForm(StripWhitespaceForm): search = SearchField('Search by name') +class SearchUsersByEmailForm(StripWhitespaceForm): + + search = SearchField( + 'Search by name or email address', + validators=[ + DataRequired("You need to enter full or partial email address to search by.") + ], + ) + + class SearchUsersForm(StripWhitespaceForm): search = SearchField('Search by name or email address') diff --git a/app/main/views/find_users.py b/app/main/views/find_users.py new file mode 100644 index 000000000..23a961afe --- /dev/null +++ b/app/main/views/find_users.py @@ -0,0 +1,36 @@ +from flask import render_template, request +from flask_login import login_required + +from app import user_api_client +from app.main import main +from app.main.forms import SearchUsersByEmailForm +from app.utils import user_is_platform_admin + + +@main.route("/find-users-by-email", methods=['GET', 'POST']) +@login_required +@user_is_platform_admin +def find_users_by_email(): + form = SearchUsersByEmailForm() + users_found = None + status = 200 + if form.validate_on_submit(): + users_found = user_api_client.find_users_by_full_or_partial_email(form.search.data)['data'] + elif request.method == 'POST': + status = 400 + return render_template( + 'views/find-users/find-users-by-email.html', + form=form, + users_found=users_found + ), status + + +@main.route("/users/", methods=['GET']) +@login_required +@user_is_platform_admin +def user_information(user_id): + user = user_api_client.get_user(user_id) + return render_template( + 'views/find-users/user-information.html', + user=user + ) diff --git a/app/navigation.py b/app/navigation.py index 4860e4adf..076af3d12 100644 --- a/app/navigation.py +++ b/app/navigation.py @@ -76,15 +76,17 @@ class HeaderNavigation(Navigation): 'add_organisation', 'create_email_branding', 'email_branding', + 'find_users_by_email', 'live_services', 'organisations', 'platform_admin', + 'platform_admin_list_complaints', 'suspend_service', 'trial_services', 'update_email_branding', + 'user_information', 'view_provider', 'view_providers', - 'platform_admin_list_complaints', }, 'sign-in': { 'sign_in', @@ -399,6 +401,7 @@ class MainNavigation(Navigation): 'error', 'features', 'feedback', + 'find_users_by_email', 'forgot_password', 'get_example_csv', 'get_notifications_as_json', @@ -472,6 +475,7 @@ class MainNavigation(Navigation): 'two_factor_email', 'two_factor_email_sent', 'update_email_branding', + 'user_information', 'user_profile', 'user_profile_email', 'user_profile_email_authenticate', @@ -567,6 +571,7 @@ class CaseworkNavigation(Navigation): 'error', 'features', 'feedback', + 'find_users_by_email', 'forgot_password', 'get_example_csv', 'get_notifications_as_json', @@ -687,6 +692,7 @@ class CaseworkNavigation(Navigation): 'two_factor_email_sent', 'update_email_branding', 'usage', + 'user_information', 'user_profile', 'user_profile_email', 'user_profile_email_authenticate', @@ -789,6 +795,7 @@ class OrgNavigation(Navigation): 'error', 'features', 'feedback', + 'find_users_by_email', 'forgot_password', 'get_example_csv', 'get_notifications_as_json', @@ -908,6 +915,7 @@ class OrgNavigation(Navigation): 'two_factor_email_sent', 'update_email_branding', 'usage', + 'user_information', 'user_profile', 'user_profile_email', 'user_profile_email_authenticate', diff --git a/app/notify_client/models.py b/app/notify_client/models.py index 3ccdce48b..c3d2564a2 100644 --- a/app/notify_client/models.py +++ b/app/notify_client/models.py @@ -59,6 +59,7 @@ class User(UserMixin): self.failed_login_count = fields.get('failed_login_count') self.state = fields.get('state') self.max_failed_login_count = max_failed_login_count + self.logged_in_at = fields.get('logged_in_at') self.platform_admin = fields.get('platform_admin') self.current_session_id = fields.get('current_session_id') self.services = fields.get('services', []) diff --git a/app/notify_client/user_api_client.py b/app/notify_client/user_api_client.py index c0e3a28ef..02b9bfd5b 100644 --- a/app/notify_client/user_api_client.py +++ b/app/notify_client/user_api_client.py @@ -179,6 +179,12 @@ class UserApiClient(NotifyAdminAPIClient): data = {'email': email_address} self.post(endpoint, data=data) + def find_users_by_full_or_partial_email(self, email_address): + endpoint = '/user/find-users-by-email' + data = {'email': email_address} + users = self.post(endpoint, data=data) + return users + def is_email_already_in_use(self, email_address): if self.get_user_by_email_or_none(email_address): return True diff --git a/app/templates/views/find-users/find-users-by-email.html b/app/templates/views/find-users/find-users-by-email.html new file mode 100644 index 000000000..2237b70c5 --- /dev/null +++ b/app/templates/views/find-users/find-users-by-email.html @@ -0,0 +1,51 @@ +{% extends "views/platform-admin/_base_template.html" %} +{% from "components/page-footer.html" import page_footer %} + +{% block per_page_title %} + Find users by email +{% endblock %} + +{% block platform_admin_content %} + +

+ Find users by email +

+ +
+
+ {{ textbox( + form.search, + width='1-1', + label='Find users by email, or by partial email' + ) }} +
+
+ + +
+
+ +
+ +
+ + {% if users_found %} + + {% elif users_found == [] %} +

No users found.

+ {% endif %} +{% endblock %} diff --git a/app/templates/views/find-users/user-information.html b/app/templates/views/find-users/user-information.html new file mode 100644 index 000000000..5c9281615 --- /dev/null +++ b/app/templates/views/find-users/user-information.html @@ -0,0 +1,43 @@ +{% extends "views/platform-admin/_base_template.html" %} +{% from "components/page-footer.html" import page_footer %} + +{% block per_page_title %} + User information for {{ user.name }} +{% endblock %} + +{% block platform_admin_content %} +
+
+

+ {{ user.name }} +

+

{{ user.email_address }}

+

{{ user.mobile_number }}

+

Services

+ +

Last login

+ {% if not user.logged_in_at %} +

This person has never logged in

+ {% else %} +

Last logged in + +

+ {% endif %} + {% if user.failed_login_count > 0 %} +

+ {{ user.failed_login_count }} failed login attempts +

+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/views/platform-admin/_base_template.html b/app/templates/views/platform-admin/_base_template.html index 4434534f0..7ea7ce43c 100644 --- a/app/templates/views/platform-admin/_base_template.html +++ b/app/templates/views/platform-admin/_base_template.html @@ -20,6 +20,7 @@ ('Email branding', url_for('main.email_branding')), ('Letter jobs', url_for('main.letter_jobs')), ('Inbound SMS numbers', url_for('main.inbound_sms_admin')), + ('Find users by email', url_for('main.find_users_by_email')), ('Email Complaints', url_for('main.platform_admin_list_complaints')) ] %}
  • diff --git a/tests/app/main/views/test_find_users.py b/tests/app/main/views/test_find_users.py new file mode 100644 index 000000000..6f2f51c4d --- /dev/null +++ b/tests/app/main/views/test_find_users.py @@ -0,0 +1,133 @@ +from flask import url_for +from lxml import html + +from app.notify_client.user_api_client import User +from tests import user_json + + +def test_find_users_by_email_page_loads_correctly(client_request, platform_admin_user): + client_request.login(platform_admin_user) + document = client_request.get('main.find_users_by_email') + + assert document.h1.text.strip() == 'Find users by email' + assert len(document.find_all('input', {'type': 'search'})) > 0 + + +def test_find_users_by_email_displays_users_found( + client_request, + platform_admin_user, + mocker +): + client_request.login(platform_admin_user) + mocker.patch( + 'app.user_api_client.find_users_by_full_or_partial_email', + return_value={"data": [user_json()]}, + autospec=True, + ) + document = client_request.post( + 'main.find_users_by_email', + _data={"search": "twilight.sparkle"}, + _expected_status=200 + ) + + assert any(element.text.strip() == 'test@gov.uk' for element in document.find_all( + 'a', {'class': 'browse-list-link'}, href=True) + ) + assert any(element.text.strip() == 'Test User' for element in document.find_all('p', {'class': 'browse-list-hint'})) + + assert document.find('a', {'class': 'browse-list-link'}).text.strip() == 'test@gov.uk' + assert document.find('p', {'class': 'browse-list-hint'}).text.strip() == 'Test User' + + +def test_find_users_by_email_displays_multiple_users( + client_request, + platform_admin_user, + mocker +): + client_request.login(platform_admin_user) + mocker.patch( + 'app.user_api_client.find_users_by_full_or_partial_email', + return_value={"data": [user_json(name="Apple Jack"), user_json(name="Apple Bloom")]}, + autospec=True, + ) + document = client_request.post('main.find_users_by_email', _data={"search": "apple"}, _expected_status=200) + + assert any( + element.text.strip() == 'Apple Jack' for element in document.find_all('p', {'class': 'browse-list-hint'}) + ) + assert any( + element.text.strip() == 'Apple Bloom' for element in document.find_all('p', {'class': 'browse-list-hint'}) + ) + + +def test_find_users_by_email_displays_message_if_no_users_found( + client_request, + platform_admin_user, + mocker +): + client_request.login(platform_admin_user) + mocker.patch('app.user_api_client.find_users_by_full_or_partial_email', return_value={"data": []}, autospec=True) + document = client_request.post( + 'main.find_users_by_email', _data={"search": "twilight.sparkle"}, _expected_status=200 + ) + + assert document.find('p', {'class': 'browse-list-hint'}).text.strip() == 'No users found.' + + +def test_find_users_by_email_validates_against_empty_search_submission( + client_request, + platform_admin_user, + mocker +): + client_request.login(platform_admin_user) + document = client_request.post('main.find_users_by_email', _data={"search": ""}, _expected_status=400) + + expected_message = "You need to enter full or partial email address to search by." + assert document.find('span', {'class': 'error-message'}).text.strip() == expected_message + + +def test_user_information_page_shows_information_about_user( + client, + platform_admin_user, + mocker +): + mocker.patch('app.user_api_client.get_user', side_effect=[ + platform_admin_user, + User(user_json(name="Apple Bloom", services=[ + {"id": 1, "name": "Fresh Orchard Juice"}, + {"id": 2, "name": "Nature Therapy"}, + ])) + ], autospec=True) + client.login(platform_admin_user) + response = client.get(url_for('main.user_information', user_id=345)) + assert response.status_code == 200 + + document = html.fromstring(response.get_data(as_text=True)) + + assert document.xpath("//h1/text()[normalize-space()='Apple Bloom']") + assert document.xpath("//p/text()[normalize-space()='test@gov.uk']") + assert document.xpath("//p/text()[normalize-space()='+447700900986']") + + assert document.xpath("//h2/text()[normalize-space()='Services']") + assert document.xpath("//p/text()[normalize-space()='Fresh Orchard Juice']") + assert document.xpath("//p/text()[normalize-space()='Nature Therapy']") + + assert document.xpath("//h2/text()[normalize-space()='Last login']") + assert not document.xpath("//p/text()[normalize-space()='0 failed login attempts']") + + +def test_user_information_page_displays_if_there_are_failed_login_attempts( + client, + platform_admin_user, + mocker +): + mocker.patch('app.user_api_client.get_user', side_effect=[ + platform_admin_user, + User(user_json(name="Apple Bloom", failed_login_count=2)) + ], autospec=True) + client.login(platform_admin_user) + response = client.get(url_for('main.user_information', user_id=345)) + assert response.status_code == 200 + + document = html.fromstring(response.get_data(as_text=True)) + assert document.xpath("//p/text()[normalize-space()='2 failed login attempts']")