diff --git a/Pipfile b/Pipfile index ba2fc7d31..7955aa6be 100644 --- a/Pipfile +++ b/Pipfile @@ -6,6 +6,7 @@ name = "pypi" [packages] ago = "~=0.0.95" blinker = "~=1.4" +exceptiongroup = "==1.1.2" fido2 = "~=0.9" flask = "~=2.3" flask-basicauth = "~=0.2" diff --git a/Pipfile.lock b/Pipfile.lock index 6477e9356..81a6c4557 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b2e8852b009644badb4ecedfd2e3040a91c6082b6794954332edc825b02ec233" + "sha256": "d27aff981cc071e27820a959f05da0a7884d9f1a1452f8b5e74bb516b8dae7cb" }, "pipfile-spec": 6, "requires": { @@ -24,14 +24,6 @@ "index": "pypi", "version": "==0.0.95" }, - "anyio": { - "hashes": [ - "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780", - "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5" - ], - "markers": "python_version >= '3.7'", - "version": "==3.7.1" - }, "async-timeout": { "hashes": [ "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15", @@ -243,7 +235,7 @@ "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac", "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==3.2.0" }, "click": { @@ -351,11 +343,11 @@ }, "dnspython": { "hashes": [ - "sha256:46b4052a55b56beea3a3bdd7b30295c292bd6827dd442348bc116f2d35b17f0a", - "sha256:758e691dbb454d5ccf4e1b154a19e52847f79e21a42fef17b969144af29a4e6c" + "sha256:5b7488477388b8c0b70a8ce93b227c5603bc7b77f1565afe8e729c36c51447d7", + "sha256:c33971c79af5be968bb897e95c2448e11a645ee84d93b265ce0b7aabe5dfdca8" ], "markers": "python_version >= '3.8' and python_version < '4.0'", - "version": "==2.4.0" + "version": "==2.4.1" }, "docopt": { "hashes": [ @@ -383,7 +375,7 @@ "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5", "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f" ], - "markers": "python_version < '3.11'", + "index": "pypi", "version": "==1.1.2" }, "fido2": { @@ -540,22 +532,6 @@ "ref": "1299ea9e967a61ae2edebe191082fd169b864c64", "version": "==20.1.0" }, - "h11": { - "hashes": [ - "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", - "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761" - ], - "markers": "python_version >= '3.7'", - "version": "==0.14.0" - }, - "httpcore": { - "hashes": [ - "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888", - "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87" - ], - "markers": "python_version >= '3.8'", - "version": "==0.17.3" - }, "humanize": { "hashes": [ "sha256:7ca0e43e870981fa684acb5b062deb307218193bca1a01f2b2676479df849b3a", @@ -1142,14 +1118,6 @@ ], "version": "==2.0.1" }, - "sniffio": { - "hashes": [ - "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101", - "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384" - ], - "markers": "python_version >= '3.7'", - "version": "==1.3.0" - }, "texttable": { "hashes": [ "sha256:290348fb67f7746931bcdfd55ac7584ecd4e5b0846ab164333f0794b121760f2", @@ -1427,7 +1395,7 @@ "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac", "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==3.2.0" }, "cryptography": { @@ -1480,7 +1448,7 @@ "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5", "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f" ], - "markers": "python_version < '3.11'", + "index": "pypi", "version": "==1.1.2" }, "execnet": { @@ -1610,7 +1578,7 @@ "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" ], - "markers": "python_full_version >= '3.8.0'", + "markers": "python_version >= '3.8'", "version": "==3.0.0" }, "markupsafe": { @@ -1813,7 +1781,7 @@ "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==32.0.1" }, "pluggy": { @@ -1986,7 +1954,7 @@ "sha256:8f87bc7ee54675732fa66a05ebfe489e27264caeeff3728c945d25971b6485ec", "sha256:d653d6bccede5844304c605d5aac802c7cf9621efd700b46c7ec2b51ea914898" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==13.4.2" }, "s3transfer": { @@ -2033,7 +2001,7 @@ "sha256:8cc040628f3cea5d7128f2e76cf486b2251a4e543c7b938f58d9a377f6694a2d", "sha256:a54534acf9b89bc7ed264807013b505bf07f74dbe4bcfa37d32bd063870b087c" ], - "markers": "python_full_version >= '3.8.0'", + "markers": "python_version >= '3.8'", "version": "==5.1.0" }, "toml": { diff --git a/app/main/views/platform_admin.py b/app/main/views/platform_admin.py index a34ea51c4..fb4316604 100644 --- a/app/main/views/platform_admin.py +++ b/app/main/views/platform_admin.py @@ -1,4 +1,5 @@ import itertools +import json from collections import OrderedDict from datetime import datetime @@ -12,6 +13,7 @@ from app import ( notification_api_client, platform_stats_api_client, service_api_client, + user_api_client, ) from app.extensions import redis_client from app.main import main @@ -292,6 +294,32 @@ def get_billing_report(): return render_template('views/platform-admin/get-billing-report.html', form=form) +@main.route("/platform-admin/reports/get-users-report", methods=['GET', 'POST']) +@user_is_platform_admin +def get_users_report(): + headers = [ + "name", "services", "platform admin", "permissions", "password changed at", + "state" + ] + try: + result = user_api_client.get_all_users() + + except HTTPError as e: + raise e + + rows = [] + for r in result: + rows.append(_get_user_row(r)) + if rows: + return Spreadsheet.from_rows([headers] + rows).as_csv_data, 200, { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': f'attachment; filename="User Report {datetime.utcnow()}.csv"' + } + else: + flash('No results') + return render_template('views/platform-admin/get-users-report.html') + + @main.route("/platform-admin/reports/volumes-by-service", methods=['GET', 'POST']) @user_is_platform_admin def get_volumes_by_service(): @@ -540,3 +568,40 @@ def format_stats_by_service(services): 'created_at': service['created_at'], 'active': service['active'] } + + +def _get_user_row(r): + + # [{ + # 'name': 'Kenneth Kehl', + # 'organizations': [], + # 'password_changed_at': '2023-07-21 14:12:54.832850', 'permissions': { + # '672b8a66-e22e-40f6-b1e5-39cc1c6bf857': ['manage_users', 'manage_templates', 'manage_settings', 'send_texts', + # 'send_emails', 'manage_api_keys', 'view_activity']}, + # 'platform_admin': True, 'services': ['672b8a66-e22e-40f6-b1e5-39cc1c6bf857'], 'state': 'active'}] + + row = [] + row.append(r['name']) + + service_id_name_lookup = {} + services = [] + for s in r['services']: + my_service = service_api_client.get_service(s) + service_id_name_lookup[my_service['data']['id']] = my_service['data']['name'] + services.append(my_service['data']['name']) + services = str(services) + services = services.replace("[", "") + services = services.replace("]", "") + row.append(services) + row.append(r['platform_admin']) + permissions = r['permissions'] + for k, v in service_id_name_lookup.items(): + if permissions.get(k): + permissions[v] = permissions[k] + del permissions[k] + + permissions = json.dumps(permissions, indent=4) + row.append(permissions) + row.append(r['password_changed_at']) + row.append(r['state']) + return row diff --git a/app/navigation.py b/app/navigation.py index 09065c58a..625164392 100644 --- a/app/navigation.py +++ b/app/navigation.py @@ -95,6 +95,7 @@ class HeaderNavigation(Navigation): 'live_services_csv', 'notifications_sent_by_service', 'get_billing_report', + 'get_users_report', 'get_daily_volumes', 'get_daily_sms_provider_volumes', 'get_volumes_by_service', diff --git a/app/notify_client/user_api_client.py b/app/notify_client/user_api_client.py index 2d4cefaa9..55071175f 100644 --- a/app/notify_client/user_api_client.py +++ b/app/notify_client/user_api_client.py @@ -154,6 +154,10 @@ class UserApiClient(NotifyAdminAPIClient): endpoint = '/organizations/{}/users'.format(org_id) return self.get(endpoint)['data'] + def get_all_users(self): + endpoint = '/user' + return self.get(endpoint)['data'] + @cache.delete('service-{service_id}') @cache.delete('service-{service_id}-template-folders') @cache.delete('user-{user_id}') diff --git a/app/templates/views/platform-admin/get-users-report.html b/app/templates/views/platform-admin/get-users-report.html new file mode 100644 index 000000000..c26de3d04 --- /dev/null +++ b/app/templates/views/platform-admin/get-users-report.html @@ -0,0 +1,44 @@ +{% extends "views/platform-admin/_base_template.html" %} +{% from "components/form.html" import form_wrapper %} +{% from "components/table.html" import mapping_table, row, text_field %} + +{% block per_page_title %} + Billing Report +{% endblock %} + +{% block platform_admin_content %} + +

+ Users Report +

+ + {% call form_wrapper() %} + {{ form.start_date(param_extensions={"hint": {"text": "Use the format YYYY-MM-DD"}}) }} + {{ form.end_date(param_extensions={"hint": {"text": "Use the format YYYY-MM-DD"}}) }} + {{ page_footer('Download report (CSV)') }} + {% endcall %} + +

+ Data included in the report +

+
+ {% call mapping_table( + caption='Descriptions of billing report data', + field_headings=['Name', 'Description'], + field_headings_visible=True, + caption_visible=False + ) %} + {% for column_heading, description in [ + ('sms cost', 'The total cost of text messages sent after a service has used its free allowance.'), + ('sms chargeable units', 'The number of fragments sent after a service has used its free allowance. This number takes into account the cost multiplier for sending international text messages.' | safe), + ('purchase order number, contact names, contact email addresses and billing reference', 'We add this data manually based on the information we get from services. You can help by adding it to the service settings page.'), + ] %} + {% call row() %} + {{ text_field(column_heading) }} + {{ text_field(description) }} + {% endcall %} + {% endfor %} + {% endcall %} +
+ +{% endblock %} diff --git a/app/templates/views/platform-admin/reports.html b/app/templates/views/platform-admin/reports.html index 2178b1cad..fec58ff32 100644 --- a/app/templates/views/platform-admin/reports.html +++ b/app/templates/views/platform-admin/reports.html @@ -28,4 +28,7 @@

Daily SMS provider volumes Report

+

+ Users Report +

{% endblock %} diff --git a/tests/app/main/views/test_platform_admin.py b/tests/app/main/views/test_platform_admin.py index 519e81eaf..907403711 100644 --- a/tests/app/main/views/test_platform_admin.py +++ b/tests/app/main/views/test_platform_admin.py @@ -1008,6 +1008,46 @@ def test_get_daily_volumes_report_calls_api_and_download_data( ) +def test_get_users_report( + client_request, + platform_admin_user, + mocker +): + mocker.patch( + "app.main.views.platform_admin.user_api_client.get_all_users", + return_value=[{ + 'name': 'Johnny Sokko', + 'organizations': [], + 'password_changed_at': '2023-07-21 14:12:54.832850', 'permissions': { + 'test service': [ + 'manage_users', 'manage_templates', 'manage_settings', 'send_texts', + 'send_emails', 'manage_api_keys', 'view_activity']}, + 'platform_admin': True, 'services': ['test service'], 'state': 'active'} + ] + + + ) + + client_request.login(platform_admin_user) + response = client_request.post_response( + 'main.get_users_report', + _data={}, + _expected_status=200, + ) + + assert response.content_type == 'text/csv; charset=utf-8' + assert 'attachment' in response.headers['Content-Disposition'] + assert 'filename' in response.headers['Content-Disposition'] + assert 'User Report' in response.headers['Content-Disposition'] + + my_response = response.get_data(as_text=True) + + assert 'Johnny Sokko' in my_response + assert 'manage_users' in my_response + assert 'test service' in my_response + assert 'active' in my_response + + def test_get_daily_sms_provider_volumes_report_calls_api_and_download_data( client_request, platform_admin_user, diff --git a/tests/app/test_navigation.py b/tests/app/test_navigation.py index fefa3722f..7afce65f3 100644 --- a/tests/app/test_navigation.py +++ b/tests/app/test_navigation.py @@ -103,6 +103,7 @@ EXCLUDED_ENDPOINTS = tuple(map(Navigation.get_endpoint_with_blueprint, { 'find_users_by_email', 'forgot_password', 'get_billing_report', + 'get_users_report', 'get_daily_volumes', 'get_daily_sms_provider_volumes', 'get_volumes_by_service',