import itertools import json from collections import OrderedDict from datetime import datetime from flask import abort, flash, render_template, request, url_for from notifications_python_client.errors import HTTPError from app import ( billing_api_client, complaint_api_client, format_date_numeric, notification_api_client, platform_stats_api_client, service_api_client, user_api_client, ) from app.extensions import redis_client from app.main import main from app.main.forms import ( AdminClearCacheForm, BillingReportDateFilterForm, DateFilterForm, RequiredDateFilterForm, ) from app.statistics_utils import ( get_formatted_percentage, get_formatted_percentage_two_dp, ) from app.utils.csv import Spreadsheet from app.utils.pagination import ( generate_next_dict, generate_previous_dict, get_page_from_request, ) from app.utils.user import user_is_platform_admin COMPLAINT_THRESHOLD = 0.02 FAILURE_THRESHOLD = 3 ZERO_FAILURE_THRESHOLD = 0 @main.route("/platform-admin") @user_is_platform_admin def platform_admin_splash_page(): return render_template( "views/platform-admin/splash-page.html", ) @main.route("/platform-admin/summary") @user_is_platform_admin def platform_admin(): form = DateFilterForm(request.args, meta={"csrf": False}) api_args = {} form.validate() if form.start_date.data: api_args["start_date"] = form.start_date.data api_args["end_date"] = form.end_date.data or datetime.utcnow().date() platform_stats = platform_stats_api_client.get_aggregate_platform_stats(api_args) number_of_complaints = complaint_api_client.get_complaint_count(api_args) return render_template( "views/platform-admin/index.html", form=form, global_stats=make_columns(platform_stats, number_of_complaints), ) def is_over_threshold(number, total, threshold): percentage = number / total * 100 if total else 0 return percentage > threshold def get_status_box_data(stats, key, label, threshold=FAILURE_THRESHOLD): return { "number": "{:,}".format(stats["failures"][key]), "label": label, "failing": is_over_threshold(stats["failures"][key], stats["total"], threshold), "percentage": get_formatted_percentage(stats["failures"][key], stats["total"]), } def get_tech_failure_status_box_data(stats): stats = get_status_box_data( stats, "technical-failure", "technical failures", ZERO_FAILURE_THRESHOLD ) stats.pop("percentage") return stats def make_columns(global_stats, complaints_number): return [ # email { "black_box": { "number": global_stats["email"]["total"], "notification_type": "email", }, "other_data": [ get_tech_failure_status_box_data(global_stats["email"]), get_status_box_data( global_stats["email"], "permanent-failure", "permanent failures" ), get_status_box_data( global_stats["email"], "temporary-failure", "temporary failures" ), { "number": complaints_number, "label": "complaints", "failing": is_over_threshold( complaints_number, global_stats["email"]["total"], COMPLAINT_THRESHOLD, ), "percentage": get_formatted_percentage_two_dp( complaints_number, global_stats["email"]["total"] ), "url": url_for("main.platform_admin_list_complaints"), }, ], "test_data": { "number": global_stats["email"]["test-key"], "label": "test emails", }, }, # sms { "black_box": { "number": global_stats["sms"]["total"], "notification_type": "sms", }, "other_data": [ get_tech_failure_status_box_data(global_stats["sms"]), get_status_box_data( global_stats["sms"], "permanent-failure", "permanent failures" ), get_status_box_data( global_stats["sms"], "temporary-failure", "temporary failures" ), ], "test_data": { "number": global_stats["sms"]["test-key"], "label": "test text messages", }, }, ] @main.route("/platform-admin/live-services", endpoint="live_services") @main.route("/platform-admin/trial-services", endpoint="trial_services") @user_is_platform_admin def platform_admin_services(): form = DateFilterForm(request.args) if all( ( request.args.get("include_from_test_key") is None, request.args.get("start_date") is None, request.args.get("end_date") is None, ) ): # Default to True if the user hasn’t done any filtering, # otherwise respect their choice form.include_from_test_key.data = True include_from_test_key = form.include_from_test_key.data api_args = { "detailed": True, "only_active": False, # specifically DO get inactive services "include_from_test_key": include_from_test_key, } if form.start_date.data: api_args["start_date"] = form.start_date.data api_args["end_date"] = form.end_date.data or datetime.utcnow().date() services = filter_and_sort_services( service_api_client.get_services(api_args)["data"], trial_mode_services=request.endpoint == "main.trial_services", ) return render_template( "views/platform-admin/services.html", include_from_test_key=include_from_test_key, form=form, services=list(format_stats_by_service(services)), page_title="{} services".format( "Trial mode" if request.endpoint == "main.trial_services" else "Live" ), global_stats=create_global_stats(services), ) @main.route("/platform-admin/reports") @user_is_platform_admin def platform_admin_reports(): return render_template("views/platform-admin/reports.html") @main.route("/platform-admin/reports/live-services.csv") @user_is_platform_admin def live_services_csv(): results = service_api_client.get_live_services_data()["data"] column_names = OrderedDict( [ ("service_id", "Service ID"), ("organization_name", "Organization"), ("organization_type", "Organization type"), ("service_name", "Service name"), ("consent_to_research", "Consent to research"), ("contact_name", "Main contact"), ("contact_email", "Contact email"), ("contact_mobile", "Contact mobile"), ("live_date", "Live date"), ("sms_volume_intent", "SMS volume intent"), ("email_volume_intent", "Email volume intent"), ("sms_totals", "SMS sent this year"), ("email_totals", "Emails sent this year"), ("free_sms_fragment_limit", "Free sms allowance"), ] ) # initialise with header row live_services_data = [[x for x in column_names.values()]] for row in results: if row["live_date"]: row["live_date"] = datetime.strptime( row["live_date"], "%a, %d %b %Y %X %Z" ).strftime("%d-%m-%Y") live_services_data.append([row[api_key] for api_key in column_names.keys()]) return ( Spreadsheet.from_rows(live_services_data).as_csv_data, 200, { "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": 'inline; filename="{} live services report.csv"'.format( format_date_numeric(datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ")), ), }, ) @main.route( "/platform-admin/reports/notifications-sent-by-service", methods=["GET", "POST"] ) @user_is_platform_admin def notifications_sent_by_service(): form = RequiredDateFilterForm() if form.validate_on_submit(): start_date = form.start_date.data end_date = form.end_date.data headers = [ "date_created", "service_id", "service_name", "notification_type", "count_sending", "count_delivered", "count_technical_failure", "count_temporary_failure", "count_permanent_failure", "count_sent", ] result = notification_api_client.get_notification_status_by_service( start_date, end_date ) content_disposition = ( 'attachment; filename="{} to {} notification status ' 'per service report.csv"'.format(start_date, end_date) ) return ( Spreadsheet.from_rows([headers] + result).as_csv_data, 200, { "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": content_disposition, }, ) return render_template( "views/platform-admin/notifications_by_service.html", form=form ) @main.route("/platform-admin/reports/usage-for-all-services", methods=["GET", "POST"]) @user_is_platform_admin def get_billing_report(): form = BillingReportDateFilterForm() if form.validate_on_submit(): start_date = form.start_date.data end_date = form.end_date.data headers = [ "organization_id", "organization_name", "service_id", "service_name", "sms_cost", "sms_chargeable_units", "purchase_order_number", "contact_names", "contact_email_addresses", "billing_reference", ] try: result = billing_api_client.get_data_for_billing_report( start_date, end_date ) except HTTPError as e: message = "Date must be in a single financial year." if e.status_code == 400 and e.message == message: flash(message) return render_template( "views/platform-admin/get-billing-report.html", form=form ) else: raise e rows = [ [ r["organization_id"], r["organization_name"], r["service_id"], r["service_name"], r["sms_cost"], r["sms_chargeable_units"], r.get("purchase_order_number"), r.get("contact_names"), r.get("contact_email_addresses"), r.get("billing_reference"), ] for r in result ] if rows: return ( Spreadsheet.from_rows([headers] + rows).as_csv_data, 200, { "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": 'attachment; filename="Billing Report from {} to {}.csv"'.format( start_date, end_date ), }, ) else: flash("No results for dates") 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(): form = BillingReportDateFilterForm() if form.validate_on_submit(): start_date = form.start_date.data end_date = form.end_date.data headers = [ "organization id", "organization name", "service id", "service name", "free allowance", "sms notifications", "sms chargeable units", "email totals", ] result = billing_api_client.get_data_for_volumes_by_service_report( start_date, end_date ) rows = [ [ r["organization_id"], r["organization_name"], r["service_id"], r["service_name"], r["free_allowance"], r["sms_notifications"], r["sms_chargeable_units"], r["email_totals"], ] for r in result ] if rows: return ( Spreadsheet.from_rows([headers] + rows).as_csv_data, 200, { "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": 'attachment; filename="Volumes by service report from {} to {}.csv"'.format( start_date, end_date ), }, ) else: flash("No results for dates") return render_template( "views/platform-admin/volumes-by-service-report.html", form=form ) @main.route("/platform-admin/reports/daily-volumes-report", methods=["GET", "POST"]) @user_is_platform_admin def get_daily_volumes(): form = BillingReportDateFilterForm() if form.validate_on_submit(): start_date = form.start_date.data end_date = form.end_date.data headers = [ "day", "sms totals", "sms fragment totals", "sms chargeable units", "email totals", ] result = billing_api_client.get_data_for_daily_volumes_report( start_date, end_date ) rows = [ [ r["day"], r["sms_totals"], r["sms_fragment_totals"], r["sms_chargeable_units"], r["email_totals"], ] for r in result ] if rows: return ( Spreadsheet.from_rows([headers] + rows).as_csv_data, 200, { "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": 'attachment; filename="Daily volumes report from {} to {}.csv"'.format( start_date, end_date ), }, ) else: flash("No results for dates") return render_template("views/platform-admin/daily-volumes-report.html", form=form) @main.route( "/platform-admin/reports/daily-sms-provider-volumes-report", methods=["GET", "POST"] ) @user_is_platform_admin def get_daily_sms_provider_volumes(): form = BillingReportDateFilterForm() if form.validate_on_submit(): start_date = form.start_date.data end_date = form.end_date.data headers = [ "day", "provider", "sms totals", "sms fragment totals", "sms chargeable units", "sms cost", ] result = billing_api_client.get_data_for_daily_sms_provider_volumes_report( start_date, end_date ) rows = [ [ r["day"], r["provider"], r["sms_totals"], r["sms_fragment_totals"], r["sms_chargeable_units"], r["sms_cost"], ] for r in result ] content_disp = f'attachment; filename="Daily SMS provider volumes report from {start_date} to {end_date}.csv"' if rows: return ( Spreadsheet.from_rows([headers] + rows).as_csv_data, 200, { "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": content_disp, }, ) else: flash("No results for dates") return render_template( "views/platform-admin/daily-sms-provider-volumes-report.html", form=form ) @main.route("/platform-admin/complaints") @user_is_platform_admin def platform_admin_list_complaints(): page = get_page_from_request() if page is None: abort(404, "Invalid page argument ({}).".format(request.args.get("page"))) response = complaint_api_client.get_all_complaints(page=page) prev_page = None if response["links"].get("prev"): prev_page = generate_previous_dict( "main.platform_admin_list_complaints", None, page ) next_page = None if response["links"].get("next"): next_page = generate_next_dict( "main.platform_admin_list_complaints", None, page ) return render_template( "views/platform-admin/complaints.html", complaints=response["complaints"], page=page, prev_page=prev_page, next_page=next_page, ) @main.route("/platform-admin/clear-cache", methods=["GET", "POST"]) @user_is_platform_admin def clear_cache(): # note: `service-{uuid}-templates` cache is cleared for both services and templates. CACHE_KEYS = OrderedDict( [ ( "user", [ "user-????????-????-????-????-????????????", ], ), ( "service", [ "has_jobs-????????-????-????-????-????????????", "service-????????-????-????-????-????????????", "service-????????-????-????-????-????????????-templates", "service-????????-????-????-????-????????????-data-retention", "service-????????-????-????-????-????????????-template-folders", ], ), ( "template", [ "service-????????-????-????-????-????????????-templates", "service-????????-????-????-????-????????????-template-????????-????-????-????-????????????-version-*", # noqa "service-????????-????-????-????-????????????-template-????????-????-????-????-????????????-versions", # noqa ], ), ( "organization", [ "organizations", "domains", "live-service-and-organization-counts", "organization-????????-????-????-????-????????????-name", ], ), ] ) form = AdminClearCacheForm() form.model_type.choices = [ (key, key.replace("_", " ").title()) for key in CACHE_KEYS ] if form.validate_on_submit(): group_keys = form.model_type.data groups = map(CACHE_KEYS.get, group_keys) patterns = list(itertools.chain(*groups)) num_deleted = sum( redis_client.delete_by_pattern(pattern) for pattern in patterns ) msg = ( f"Removed {num_deleted} objects " f"across {len(patterns)} key formats " f'for {", ".join(group_keys)}' ) flash(msg, category="default") return render_template("views/platform-admin/clear-cache.html", form=form) def sum_service_usage(service): total = 0 for notification_type in service["statistics"].keys(): total += service["statistics"][notification_type]["requested"] return total def filter_and_sort_services(services, trial_mode_services=False): return [ service for service in sorted( services, key=lambda service: ( service["active"], sum_service_usage(service), service["created_at"], ), reverse=True, ) if service["restricted"] == trial_mode_services ] def create_global_stats(services): stats = { "email": {"delivered": 0, "failed": 0, "requested": 0}, "sms": {"delivered": 0, "failed": 0, "requested": 0}, } for service in services: for msg_type, status in itertools.product( ("sms", "email"), ("delivered", "failed", "requested") ): stats[msg_type][status] += service["statistics"][msg_type][status] for stat in stats.values(): stat["failure_rate"] = get_formatted_percentage( stat["failed"], stat["requested"] ) return stats def format_stats_by_service(services): for service in services: yield { "id": service["id"], "name": service["name"], "stats": service["statistics"], "restricted": service["restricted"], "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