From 3252f7bef6f71da80226c1759ce3ecf56bf3d76a Mon Sep 17 00:00:00 2001 From: Iain-S <25081046+Iain-S@users.noreply.github.com> Date: Mon, 7 Aug 2023 10:19:50 +0100 Subject: [PATCH] Add rctab/ and tests/ --- tests/__init__.py | 0 tests/conftest.py | 234 + tests/data/example-monthly-correct.json | 247 + tests/data/example-monthly-wrong.json | 242 + tests/data/example-monthly-wrong2.json | 242 + tests/data/example.json | 4754 +++++++++++++++++ tests/test_crud/__init__.py | 0 tests/test_crud/test_auth.py | 19 + tests/test_routes/__init__.py | 0 tests/test_routes/api_calls.py | 175 + tests/test_routes/conftest.py | 57 + tests/test_routes/constants.py | 10 + tests/test_routes/data/details_data.json | 0 tests/test_routes/test_abolishment.py | 143 + tests/test_routes/test_allocations.py | 331 ++ tests/test_routes/test_approvals.py | 639 +++ tests/test_routes/test_cost_recovery.py | 712 +++ tests/test_routes/test_daily_routine_tasks.py | 183 + tests/test_routes/test_desired_states.py | 471 ++ tests/test_routes/test_email_templates.py | 583 ++ tests/test_routes/test_finances.py | 748 +++ tests/test_routes/test_frontend.py | 93 + tests/test_routes/test_persistence.py | 79 + tests/test_routes/test_routes.py | 384 ++ tests/test_routes/test_send_emails.py | 1869 +++++++ tests/test_routes/test_status.py | 700 +++ tests/test_routes/test_subscription.py | 70 + tests/test_routes/test_transactions.py | 31 + tests/test_routes/test_usage.py | 410 ++ tests/test_routes/utils.py | 40 + tests/test_settings.py | 81 + tests/utils.py | 10 + 32 files changed, 13557 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/data/example-monthly-correct.json create mode 100644 tests/data/example-monthly-wrong.json create mode 100644 tests/data/example-monthly-wrong2.json create mode 100644 tests/data/example.json create mode 100644 tests/test_crud/__init__.py create mode 100644 tests/test_crud/test_auth.py create mode 100644 tests/test_routes/__init__.py create mode 100644 tests/test_routes/api_calls.py create mode 100644 tests/test_routes/conftest.py create mode 100644 tests/test_routes/constants.py create mode 100644 tests/test_routes/data/details_data.json create mode 100644 tests/test_routes/test_abolishment.py create mode 100644 tests/test_routes/test_allocations.py create mode 100644 tests/test_routes/test_approvals.py create mode 100644 tests/test_routes/test_cost_recovery.py create mode 100644 tests/test_routes/test_daily_routine_tasks.py create mode 100644 tests/test_routes/test_desired_states.py create mode 100644 tests/test_routes/test_email_templates.py create mode 100644 tests/test_routes/test_finances.py create mode 100644 tests/test_routes/test_frontend.py create mode 100644 tests/test_routes/test_persistence.py create mode 100644 tests/test_routes/test_routes.py create mode 100644 tests/test_routes/test_send_emails.py create mode 100644 tests/test_routes/test_status.py create mode 100644 tests/test_routes/test_subscription.py create mode 100644 tests/test_routes/test_transactions.py create mode 100644 tests/test_routes/test_usage.py create mode 100644 tests/test_routes/utils.py create mode 100644 tests/test_settings.py create mode 100644 tests/utils.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6ad9f17 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,234 @@ +import datetime +import subprocess +from pathlib import Path +from typing import Any, Callable, Dict, Tuple +from uuid import UUID + +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi import FastAPI, Request + +from rctab.crud.auth import ( + token_admin_verified, + token_user_verified, + token_verified, + user_authenticated, +) +from rctab.settings import Settings +from tests.test_routes import constants + + +@pytest.fixture +def get_oauth_settings_override() -> Callable: + """Fixture to replace user details""" + + def oauth_settings() -> Any: + + username = "test@domain.com" + + class UserLogged: + """Simple user detail for auth tests""" + + async def __call__(self, request: Request) -> Dict[str, str]: + return {"preferred_username": username} + + return UserLogged() + + return oauth_settings + + +@pytest.fixture +def get_token_verified_override() -> Callable: + def _token_verified() -> Any: + class TokenVerifier: + def __init__( + self, + oid: UUID, + has_access: bool = True, + is_admin: bool = True, + auto_error: bool = True, + ): + self.auto_error = auto_error + self.oid = oid + self.has_access = has_access + self.is_admin = is_admin + + async def __call__(self) -> Any: + return TokenVerifier(oid=constants.ADMIN_UUID, auto_error=False) + + return TokenVerifier(oid=constants.ADMIN_UUID, auto_error=False) + + return _token_verified + + +# pylint: disable=W0621 +@pytest.fixture +def auth_app( + get_oauth_settings_override: Callable, get_token_verified_override: Callable +) -> FastAPI: + + # pylint: disable=import-outside-toplevel + from rctab import app + + # Override all authentication for tests + app.dependency_overrides = {} + app.dependency_overrides[user_authenticated] = get_oauth_settings_override() + app.dependency_overrides[token_verified] = get_token_verified_override() + app.dependency_overrides[token_user_verified] = get_token_verified_override() + app.dependency_overrides[token_admin_verified] = get_token_verified_override() + + return app + + +def get_public_key_and_token(app_name: str) -> Tuple[str, str]: + """Sign a JWT with private key and mock get_settings with public key field""" + token_claims: Dict[str, Any] = {"sub": app_name} + access_token_expires = datetime.timedelta(minutes=10) + + expire = datetime.datetime.utcnow() + access_token_expires + token_claims.update({"exp": expire}) + + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + + public_key = private_key.public_key() + public_key_str = public_key.public_bytes( + encoding=serialization.Encoding.OpenSSH, + format=serialization.PublicFormat.OpenSSH, + ).decode("utf-8") + + token = jwt.encode(token_claims, private_key, algorithm="RS256") # type: ignore + return public_key_str, token + + +# pylint: disable=redefined-outer-name +@pytest.fixture +def app_with_signed_billing_token( + tmp_path: Path, + mocker: Any, + get_oauth_settings_override: Callable, + get_token_verified_override: Callable, +) -> Tuple[FastAPI, str]: + + """Sign a JWT with private key and mock get_settings with public key field""" + private_key = tmp_path / "key" + public_key = tmp_path / "key.pub" + + # Create a public and private_key + _ = subprocess.check_output( + ["ssh-keygen", "-t", "rsa", "-f", private_key, "-N", ""], + universal_newlines=True, + ) + + assert private_key.exists() + assert public_key.exists() + + private_key_text = private_key.read_text() + private_key_bytes = serialization.load_ssh_private_key( # type: ignore + private_key_text.encode(), password=b"" + ) + + # Create jwt + token_claims: Dict[str, Any] = {"sub": "usage-app"} + access_token_expires = datetime.timedelta(minutes=10) + + expire = datetime.datetime.utcnow() + access_token_expires + token_claims.update({"exp": expire}) + + token = jwt.encode(token_claims, private_key_bytes, algorithm="RS256") # type: ignore + + def _get_settings() -> Settings: + return Settings( + usage_func_public_key=str(public_key.read_text()), ignore_whitelist=True + ) + + mocker.patch( + "rctab.routers.accounting.usage.get_settings", side_effect=_get_settings + ) + # pylint: disable=import-outside-toplevel + from rctab import app + + # Override all authentication for tests + app.dependency_overrides = {} + app.dependency_overrides[user_authenticated] = get_oauth_settings_override() + app.dependency_overrides[token_verified] = get_token_verified_override() + app.dependency_overrides[token_user_verified] = get_token_verified_override() + app.dependency_overrides[token_admin_verified] = get_token_verified_override() + + return app, token + + +@pytest.fixture +def app_with_signed_status_and_controller_tokens( + mocker: Any, + get_oauth_settings_override: Callable, + get_token_verified_override: Callable, +) -> Tuple[FastAPI, str, str]: + + status_public_key_str, status_token = get_public_key_and_token("status-app") + controller_public_key_str, controller_token = get_public_key_and_token( + "controller-app" + ) + + def _get_settings() -> Settings: + return Settings( + controller_func_public_key=controller_public_key_str, + status_func_public_key=status_public_key_str, + ignore_whitelist=True, + ) + + mocker.patch( + "rctab.routers.accounting.status.get_settings", side_effect=_get_settings + ) + mocker.patch( + "rctab.routers.accounting.desired_states.get_settings", + side_effect=_get_settings, + ) + + # pylint: disable=import-outside-toplevel + from rctab import app + + # Override all authentication for tests + app.dependency_overrides = {} + app.dependency_overrides[user_authenticated] = get_oauth_settings_override() + app.dependency_overrides[token_verified] = get_token_verified_override() + app.dependency_overrides[token_user_verified] = get_token_verified_override() + app.dependency_overrides[token_admin_verified] = get_token_verified_override() + + return app, status_token, controller_token + + +@pytest.fixture +def app_with_signed_status_token( + mocker: Any, + get_oauth_settings_override: Callable, + get_token_verified_override: Callable, +) -> Tuple[FastAPI, str]: + + status_public_key_str, status_token = get_public_key_and_token("status-app") + + def _get_settings() -> Settings: + return Settings( + status_func_public_key=status_public_key_str, ignore_whitelist=True + ) + + mocker.patch( + "rctab.routers.accounting.status.get_settings", side_effect=_get_settings + ) + + # pylint: disable=import-outside-toplevel + from rctab import app + + # Override all authentication for tests + app.dependency_overrides = { + user_authenticated: get_oauth_settings_override(), + token_verified: get_token_verified_override(), + token_user_verified: get_token_verified_override(), + token_admin_verified: get_token_verified_override(), + } + + return app, status_token diff --git a/tests/data/example-monthly-correct.json b/tests/data/example-monthly-correct.json new file mode 100644 index 0000000..95b2cc3 --- /dev/null +++ b/tests/data/example-monthly-correct.json @@ -0,0 +1,247 @@ +[ + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/24607b19-d663-8843-97b9-75effac9fb39", + "name": "24607b19-d663-8843-97b9-75effac9fb39", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-02", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000059, + "effective_price": 1.23342584027135, + "cost": 0.000072772124576, + "amortised_cost": 0.0, + "total_cost": 0.000072772124576, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased", + "monthly_upload": "2021-10-10" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/1538963a-0c4b-d98b-304b-3462b47dee3a", + "name": "1538963a-0c4b-d98b-304b-3462b47dee3a", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-05", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000094, + "effective_price": 1.23342584027135, + "cost": 0.000115942028986, + "amortised_cost": 0.0, + "total_cost": 0.000115942028986, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased", + "monthly_upload": "2021-10-10" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/a9fd329b-43ff-bc29-54ae-f3972876ed54", + "name": "a9fd329b-43ff-bc29-54ae-f3972876ed54", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-15", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000059, + "effective_price": 1.23342584027135, + "cost": 0.000072772124576, + "amortised_cost": 0.0, + "total_cost": 0.000072772124576, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased", + "monthly_upload": "2021-10-10" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/0496ffbc-d5d9-a8f6-400d-4ba2c8286270", + "name": "0496ffbc-d5d9-a8f6-400d-4ba2c8286270", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-29", + "product": "Azure Defender for DNS - Standard - Queries", + "part_number": "AAH-05446", + "meter_id": "58d180f0-a0c8-551f-a33b-7e576895e61a", + "quantity": 0.00007599999999999999, + "effective_price": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "unit_price": 0.5217, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Dns", + "resource_name": "Dns", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased", + "monthly_upload": "2021-10-10" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/f7b3299d-6379-8de3-3e27-fe03ec7fdb82", + "name": "f7b3299d-6379-8de3-3e27-fe03ec7fdb82", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-30", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000126, + "effective_price": 1.7299840841464302, + "cost": 0.000217977994602, + "amortised_cost": 0.0, + "total_cost": 0.000217977994602, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased", + "monthly_upload": "2021-10-10" + } +] diff --git a/tests/data/example-monthly-wrong.json b/tests/data/example-monthly-wrong.json new file mode 100644 index 0000000..1011426 --- /dev/null +++ b/tests/data/example-monthly-wrong.json @@ -0,0 +1,242 @@ +[ + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/24607b19-d663-8843-97b9-75effac9fb39", + "name": "24607b19-d663-8843-97b9-75effac9fb39", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-02", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000059, + "effective_price": 1.23342584027135, + "cost": 0.000072772124576, + "amortised_cost": 0.0, + "total_cost": 0.000072772124576, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/1538963a-0c4b-d98b-304b-3462b47dee3a", + "name": "1538963a-0c4b-d98b-304b-3462b47dee3a", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-05", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000094, + "effective_price": 1.23342584027135, + "cost": 0.000115942028986, + "amortised_cost": 0.0, + "total_cost": 0.000115942028986, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/a9fd329b-43ff-bc29-54ae-f3972876ed54", + "name": "a9fd329b-43ff-bc29-54ae-f3972876ed54", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-15", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000059, + "effective_price": 1.23342584027135, + "cost": 0.000072772124576, + "amortised_cost": 0.0, + "total_cost": 0.000072772124576, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/0496ffbc-d5d9-a8f6-400d-4ba2c8286270", + "name": "0496ffbc-d5d9-a8f6-400d-4ba2c8286270", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-30", + "product": "Azure Defender for DNS - Standard - Queries", + "part_number": "AAH-05446", + "meter_id": "58d180f0-a0c8-551f-a33b-7e576895e61a", + "quantity": 0.00007599999999999999, + "effective_price": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "unit_price": 0.5217, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Dns", + "resource_name": "Dns", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/f7b3299d-6379-8de3-3e27-fe03ec7fdb82", + "name": "f7b3299d-6379-8de3-3e27-fe03ec7fdb82", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-10-01", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000126, + "effective_price": 1.7299840841464302, + "cost": 0.000217977994602, + "amortised_cost": 0.0, + "total_cost": 0.000217977994602, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + } +] diff --git a/tests/data/example-monthly-wrong2.json b/tests/data/example-monthly-wrong2.json new file mode 100644 index 0000000..971278f --- /dev/null +++ b/tests/data/example-monthly-wrong2.json @@ -0,0 +1,242 @@ +[ + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/24607b19-d663-8843-97b9-75effac9fb39", + "name": "24607b19-d663-8843-97b9-75effac9fb39", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-02", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000059, + "effective_price": 1.23342584027135, + "cost": 0.000072772124576, + "amortised_cost": 0.0, + "total_cost": 0.000072772124576, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/1538963a-0c4b-d98b-304b-3462b47dee3a", + "name": "1538963a-0c4b-d98b-304b-3462b47dee3a", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-05", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000094, + "effective_price": 1.23342584027135, + "cost": 0.000115942028986, + "amortised_cost": 0.0, + "total_cost": 0.000115942028986, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/a9fd329b-43ff-bc29-54ae-f3972876ed54", + "name": "a9fd329b-43ff-bc29-54ae-f3972876ed54", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-15", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000059, + "effective_price": 1.23342584027135, + "cost": 0.000072772124576, + "amortised_cost": 0.0, + "total_cost": 0.000072772124576, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/0496ffbc-d5d9-a8f6-400d-4ba2c8286270", + "name": "0496ffbc-d5d9-a8f6-400d-4ba2c8286270", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-30", + "product": "Azure Defender for DNS - Standard - Queries", + "part_number": "AAH-05446", + "meter_id": "58d180f0-a0c8-551f-a33b-7e576895e61a", + "quantity": 0.00007599999999999999, + "effective_price": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "unit_price": 0.5217, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Dns", + "resource_name": "Dns", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/f7b3299d-6379-8de3-3e27-fe03ec7fdb82", + "name": "f7b3299d-6379-8de3-3e27-fe03ec7fdb82", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-30", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000126, + "effective_price": 1.7299840841464302, + "cost": 0.000217977994602, + "amortised_cost": 0.0, + "total_cost": 0.000217977994602, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + } +] diff --git a/tests/data/example.json b/tests/data/example.json new file mode 100644 index 0000000..8cf37b1 --- /dev/null +++ b/tests/data/example.json @@ -0,0 +1,4754 @@ +[ + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/24607b19-d663-8843-97b9-75effac9fb39", + "name": "24607b19-d663-8843-97b9-75effac9fb39", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-28", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000059, + "effective_price": 1.23342584027135, + "cost": 0.000072772124576, + "amortised_cost": 0.0, + "total_cost": 0.000072772124576, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/1538963a-0c4b-d98b-304b-3462b47dee3a", + "name": "1538963a-0c4b-d98b-304b-3462b47dee3a", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-23", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000094, + "effective_price": 1.23342584027135, + "cost": 0.000115942028986, + "amortised_cost": 0.0, + "total_cost": 0.000115942028986, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/a9fd329b-43ff-bc29-54ae-f3972876ed54", + "name": "a9fd329b-43ff-bc29-54ae-f3972876ed54", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-30", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000059, + "effective_price": 1.23342584027135, + "cost": 0.000072772124576, + "amortised_cost": 0.0, + "total_cost": 0.000072772124576, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/0496ffbc-d5d9-a8f6-400d-4ba2c8286270", + "name": "0496ffbc-d5d9-a8f6-400d-4ba2c8286270", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-28", + "product": "Azure Defender for DNS - Standard - Queries", + "part_number": "AAH-05446", + "meter_id": "58d180f0-a0c8-551f-a33b-7e576895e61a", + "quantity": 0.00007599999999999999, + "effective_price": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "unit_price": 0.5217, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Dns", + "resource_name": "Dns", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/f7b3299d-6379-8de3-3e27-fe03ec7fdb82", + "name": "f7b3299d-6379-8de3-3e27-fe03ec7fdb82", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-26", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000126, + "effective_price": 1.7299840841464302, + "cost": 0.000217977994602, + "amortised_cost": 0.0, + "total_cost": 0.000217977994602, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/fe5e47f0-0457-4cd4-5b66-1ce9b848d875", + "name": "fe5e47f0-0457-4cd4-5b66-1ce9b848d875", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-26", + "product": "Bandwidth - Data Transfer Out - Zone 1", + "part_number": "Q5H-00003", + "meter_id": "9995d93a-7d35-4d3f-9c69-7a7fea447ef4", + "quantity": 5e-6, + "effective_price": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "unit_price": 1.0, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/usagegeneratorfortesting/providers/Microsoft.Compute/virtualMachines/usagegenerator", + "resource_name": "usagegenerator", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "usagegeneratorfortesting", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/3b7efb1b-4356-2b03-0294-59302b2813dc", + "name": "3b7efb1b-4356-2b03-0294-59302b2813dc", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-11", + "product": "Azure Defender for Resource Manager - Standard Trial - Events", + "part_number": "AAH-05462", + "meter_id": "457e7896-15f9-5a97-8c17-ca18640a2262", + "quantity": 0.000105, + "effective_price": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "unit_price": 0.0, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/b31c66a1-473b-1280-b467-3c927dd9ae8d", + "name": "b31c66a1-473b-1280-b467-3c927dd9ae8d", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-24", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000083, + "effective_price": 1.23342584027135, + "cost": 0.000102374344743, + "amortised_cost": 0.0, + "total_cost": 0.000102374344743, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/ab5b3e12-af0e-0599-6fc4-b4cc2345cd64", + "name": "ab5b3e12-af0e-0599-6fc4-b4cc2345cd64", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-29", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000057, + "effective_price": 1.23342584027135, + "cost": 0.000070305272895, + "amortised_cost": 0.0, + "total_cost": 0.000070305272895, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/7fc7f9dc-f6fa-01be-9192-3c5e5fa9aa73", + "name": "7fc7f9dc-f6fa-01be-9192-3c5e5fa9aa73", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-25", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000132, + "effective_price": 1.23342584027135, + "cost": 0.000162812210916, + "amortised_cost": 0.0, + "total_cost": 0.000162812210916, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/e6631f3e-6ec3-f4c7-77ac-ca2c2a340187", + "name": "e6631f3e-6ec3-f4c7-77ac-ca2c2a340187", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-31", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.00026, + "effective_price": 1.23342584027135, + "cost": 0.000320690718471, + "amortised_cost": 0.0, + "total_cost": 0.000320690718471, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/a627d0b4-6fe3-9f08-dbdd-10512614b9b9", + "name": "a627d0b4-6fe3-9f08-dbdd-10512614b9b9", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-26", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000148, + "effective_price": 1.23342584027135, + "cost": 0.00018254702436, + "amortised_cost": 0.0, + "total_cost": 0.00018254702436, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/4aa4ec65-7d0e-2d06-1ef6-c6c9bbf6444a", + "name": "4aa4ec65-7d0e-2d06-1ef6-c6c9bbf6444a", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-27", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000054, + "effective_price": 1.23342584027135, + "cost": 0.00006660499537499998, + "amortised_cost": 0.0, + "total_cost": 0.00006660499537499998, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/be6eb4dc-7d98-ce45-f5fb-a4c0cf1eae80", + "name": "be6eb4dc-7d98-ce45-f5fb-a4c0cf1eae80", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-25", + "product": "Azure Defender for DNS - Standard - Queries", + "part_number": "AAH-05446", + "meter_id": "58d180f0-a0c8-551f-a33b-7e576895e61a", + "quantity": 0.000087, + "effective_price": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "unit_price": 0.5217, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Dns", + "resource_name": "Dns", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/d426d642-3881-9228-a578-b15caf66a7e9", + "name": "d426d642-3881-9228-a578-b15caf66a7e9", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-28", + "product": "Premium SSD Managed Disks - P4 - Disks - UK South", + "part_number": "AAD-18171", + "meter_id": "6a98a6aa-0245-4dd3-984e-b3f3d9f3c751", + "quantity": 0.026391, + "effective_price": 4.73768550128893, + "cost": 0.125032258064516, + "amortised_cost": 0.0, + "total_cost": 0.125032258064516, + "unit_price": 4.760000000000002, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/USAGEGENERATORFORTESTING/providers/Microsoft.Compute/disks/usagegenerator_OsDisk_1_d2d9c7b381154ce2bd2f8276caee7eb9", + "resource_name": "usagegenerator_OsDisk_1_d2d9c7b381154ce2bd2f8276caee7eb9", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "USAGEGENERATORFORTESTING", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/b8659de3-a93d-3b08-38f3-414ae394b04e", + "name": "b8659de3-a93d-3b08-38f3-414ae394b04e", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-23", + "product": "IP Addresses - Basic - Dynamic Public IP", + "part_number": "T6Z-00022", + "meter_id": "f114cb19-ea64-40b5-bcd7-aee474b62853", + "quantity": 24.0, + "effective_price": 0.002987012987013, + "cost": 0.07168831168831201, + "amortised_cost": 0.0, + "total_cost": 0.07168831168831201, + "unit_price": 0.003, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Network", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/UsageGeneratorForTesting/providers/Microsoft.Network/publicIPAddresses/usagegenerator-ip", + "resource_name": "usagegenerator-ip", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "UsageGeneratorForTesting", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/086053e6-270b-517e-f228-c64adb909872", + "name": "086053e6-270b-517e-f228-c64adb909872", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-25", + "product": "Azure Defender for servers - Standard - Nodes", + "part_number": "AAA-97320", + "meter_id": "0fad698c-40bf-4ee1-a096-565fc6f0cddd", + "quantity": 24.0, + "effective_price": 0.01484076433121, + "cost": 0.356178343949045, + "amortised_cost": 0.0, + "total_cost": 0.356178343949045, + "unit_price": 0.014900000000000002, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/VirtualMachines", + "resource_name": "VirtualMachines", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/186dd778-086a-1857-410a-835a87363a25", + "name": "186dd778-086a-1857-410a-835a87363a25", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-28", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000178, + "effective_price": 1.7299840841464302, + "cost": 0.000307937166978, + "amortised_cost": 0.0, + "total_cost": 0.000307937166978, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/79aa2b9d-6d48-7634-6475-95465f9519cc", + "name": "79aa2b9d-6d48-7634-6475-95465f9519cc", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-27", + "product": "Azure Defender for servers - Standard - Nodes", + "part_number": "AAA-97320", + "meter_id": "0fad698c-40bf-4ee1-a096-565fc6f0cddd", + "quantity": 24.0, + "effective_price": 0.01484076433121, + "cost": 0.356178343949045, + "amortised_cost": 0.0, + "total_cost": 0.356178343949045, + "unit_price": 0.014900000000000002, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/VirtualMachines", + "resource_name": "VirtualMachines", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/bd5a3430-95e6-6fc0-4086-f82fa8e88457", + "name": "bd5a3430-95e6-6fc0-4086-f82fa8e88457", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-26", + "product": "Premium SSD Managed Disks - P4 - Disks - UK South", + "part_number": "AAD-18171", + "meter_id": "6a98a6aa-0245-4dd3-984e-b3f3d9f3c751", + "quantity": 0.033336000000000005, + "effective_price": 4.73768550128893, + "cost": 0.157935483870968, + "amortised_cost": 0.0, + "total_cost": 0.157935483870968, + "unit_price": 4.760000000000002, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/USAGEGENERATORFORTESTING/providers/Microsoft.Compute/disks/usagegenerator_OsDisk_1_d2d9c7b381154ce2bd2f8276caee7eb9", + "resource_name": "usagegenerator_OsDisk_1_d2d9c7b381154ce2bd2f8276caee7eb9", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "USAGEGENERATORFORTESTING", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/e62ee0ee-f6e4-1cfe-6a30-35a10492ac1d", + "name": "e62ee0ee-f6e4-1cfe-6a30-35a10492ac1d", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-25", + "product": "Bandwidth - Data Transfer Out - Zone 1", + "part_number": "Q5H-00003", + "meter_id": "9995d93a-7d35-4d3f-9c69-7a7fea447ef4", + "quantity": 5e-6, + "effective_price": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "unit_price": 1.0, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/usagegeneratorfortesting/providers/Microsoft.Compute/virtualMachines/usagegenerator", + "resource_name": "usagegenerator", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "usagegeneratorfortesting", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/deb1f354-f8e9-23bb-48b2-87a0fba99eb8", + "name": "deb1f354-f8e9-23bb-48b2-87a0fba99eb8", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-25", + "product": "Virtual Machines Dv3/DSv3 Series - D4 v3/D4s v3 - UK South", + "part_number": "AAA-45006", + "meter_id": "23273f88-4d8c-4129-880c-d9fbdf94e6de", + "quantity": 24.0, + "effective_price": 0.17296888159399398, + "cost": 4.151253158255852, + "amortised_cost": 0.0, + "total_cost": 4.151253158255852, + "unit_price": 0.173, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/USAGEGENERATORFORTESTING/providers/Microsoft.Compute/virtualMachines/usagegenerator", + "resource_name": "usagegenerator", + "service_info1": null, + "service_info2": "Canonical", + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "USAGEGENERATORFORTESTING", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/c9977670-ffb5-d2fd-999f-23a42db1f2bd", + "name": "c9977670-ffb5-d2fd-999f-23a42db1f2bd", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-23", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000144, + "effective_price": 1.7299840841464302, + "cost": 0.00024911770811699997, + "amortised_cost": 0.0, + "total_cost": 0.00024911770811699997, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/b766a5ee-7c4c-fd8b-ba35-34e577dd9fd7", + "name": "b766a5ee-7c4c-fd8b-ba35-34e577dd9fd7", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-24", + "product": "IP Addresses - Basic - Dynamic Public IP", + "part_number": "T6Z-00022", + "meter_id": "f114cb19-ea64-40b5-bcd7-aee474b62853", + "quantity": 24.0, + "effective_price": 0.002987012987013, + "cost": 0.07168831168831201, + "amortised_cost": 0.0, + "total_cost": 0.07168831168831201, + "unit_price": 0.003, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Network", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/UsageGeneratorForTesting/providers/Microsoft.Network/publicIPAddresses/usagegenerator-ip", + "resource_name": "usagegenerator-ip", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "UsageGeneratorForTesting", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/3834d65b-0048-7bc1-6008-28ba606e589e", + "name": "3834d65b-0048-7bc1-6008-28ba606e589e", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-26", + "product": "IP Addresses - Basic - Dynamic Public IP", + "part_number": "T6Z-00022", + "meter_id": "f114cb19-ea64-40b5-bcd7-aee474b62853", + "quantity": 24.0, + "effective_price": 0.002987012987013, + "cost": 0.07168831168831201, + "amortised_cost": 0.0, + "total_cost": 0.07168831168831201, + "unit_price": 0.003, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Network", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/UsageGeneratorForTesting/providers/Microsoft.Network/publicIPAddresses/usagegenerator-ip", + "resource_name": "usagegenerator-ip", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "UsageGeneratorForTesting", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/21d8a427-5381-bf89-e628-d9457203c2e3", + "name": "21d8a427-5381-bf89-e628-d9457203c2e3", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-27", + "product": "Virtual Machines Dv3/DSv3 Series - D4 v3/D4s v3 - UK South", + "part_number": "AAA-45006", + "meter_id": "23273f88-4d8c-4129-880c-d9fbdf94e6de", + "quantity": 24.0, + "effective_price": 0.17296888159399398, + "cost": 4.151253158255852, + "amortised_cost": 0.0, + "total_cost": 4.151253158255852, + "unit_price": 0.173, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/USAGEGENERATORFORTESTING/providers/Microsoft.Compute/virtualMachines/usagegenerator", + "resource_name": "usagegenerator", + "service_info1": null, + "service_info2": "Canonical", + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "USAGEGENERATORFORTESTING", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/39a9f720-2248-6ead-7b0d-9746b99305f4", + "name": "39a9f720-2248-6ead-7b0d-9746b99305f4", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-27", + "product": "Bandwidth - Data Transfer Out - Zone 1", + "part_number": "Q5H-00003", + "meter_id": "9995d93a-7d35-4d3f-9c69-7a7fea447ef4", + "quantity": 2e-6, + "effective_price": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "unit_price": 1.0, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/usagegeneratorfortesting/providers/Microsoft.Compute/virtualMachines/usagegenerator", + "resource_name": "usagegenerator", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "usagegeneratorfortesting", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/7916b9fe-f9c9-7587-0f52-dd11d1af1f7f", + "name": "7916b9fe-f9c9-7587-0f52-dd11d1af1f7f", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-25", + "product": "Premium SSD Managed Disks - P4 - Disks - UK South", + "part_number": "AAD-18171", + "meter_id": "6a98a6aa-0245-4dd3-984e-b3f3d9f3c751", + "quantity": 0.033336000000000005, + "effective_price": 4.73768550128893, + "cost": 0.157935483870968, + "amortised_cost": 0.0, + "total_cost": 0.157935483870968, + "unit_price": 4.760000000000002, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/USAGEGENERATORFORTESTING/providers/Microsoft.Compute/disks/usagegenerator_OsDisk_1_d2d9c7b381154ce2bd2f8276caee7eb9", + "resource_name": "usagegenerator_OsDisk_1_d2d9c7b381154ce2bd2f8276caee7eb9", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "USAGEGENERATORFORTESTING", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/502982b3-b78b-0228-b32b-80ba1727732c", + "name": "502982b3-b78b-0228-b32b-80ba1727732c", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-28", + "product": "Bandwidth - Data Transfer Out - Zone 1", + "part_number": "Q5H-00003", + "meter_id": "9995d93a-7d35-4d3f-9c69-7a7fea447ef4", + "quantity": 0.000027, + "effective_price": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "unit_price": 1.0, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/usagegeneratorfortesting/providers/Microsoft.Compute/virtualMachines/usagegenerator", + "resource_name": "usagegenerator", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "usagegeneratorfortesting", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/63743937-da68-e349-0cc2-9e24d4b3e722", + "name": "63743937-da68-e349-0cc2-9e24d4b3e722", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-24", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000247, + "effective_price": 1.7299840841464302, + "cost": 0.000427306068784, + "amortised_cost": 0.0, + "total_cost": 0.000427306068784, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/f5a4f78d-d9e4-f08f-dc99-10073ec3b99e", + "name": "f5a4f78d-d9e4-f08f-dc99-10073ec3b99e", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-24", + "product": "Bandwidth - Data Transfer Out - Zone 1", + "part_number": "Q5H-00003", + "meter_id": "9995d93a-7d35-4d3f-9c69-7a7fea447ef4", + "quantity": 0.000023, + "effective_price": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "unit_price": 1.0, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/usagegeneratorfortesting/providers/Microsoft.Compute/virtualMachines/usagegenerator", + "resource_name": "usagegenerator", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "usagegeneratorfortesting", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/d95004e1-abfa-16bf-f89c-882a035b53b3", + "name": "d95004e1-abfa-16bf-f89c-882a035b53b3", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-27", + "product": "Premium SSD Managed Disks - P4 - Disks - UK South", + "part_number": "AAD-18171", + "meter_id": "6a98a6aa-0245-4dd3-984e-b3f3d9f3c751", + "quantity": 0.033336000000000005, + "effective_price": 4.73768550128893, + "cost": 0.157935483870968, + "amortised_cost": 0.0, + "total_cost": 0.157935483870968, + "unit_price": 4.760000000000002, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/USAGEGENERATORFORTESTING/providers/Microsoft.Compute/disks/usagegenerator_OsDisk_1_d2d9c7b381154ce2bd2f8276caee7eb9", + "resource_name": "usagegenerator_OsDisk_1_d2d9c7b381154ce2bd2f8276caee7eb9", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "USAGEGENERATORFORTESTING", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/54e25128-b009-3b4f-a5f2-721530a503cb", + "name": "54e25128-b009-3b4f-a5f2-721530a503cb", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-25", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000114, + "effective_price": 1.7299840841464302, + "cost": 0.000197218185593, + "amortised_cost": 0.0, + "total_cost": 0.000197218185593, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/f59273b7-e131-ea70-140f-6c3a75272415", + "name": "f59273b7-e131-ea70-140f-6c3a75272415", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-27", + "product": "IP Addresses - Basic - Dynamic Public IP", + "part_number": "T6Z-00022", + "meter_id": "f114cb19-ea64-40b5-bcd7-aee474b62853", + "quantity": 24.0, + "effective_price": 0.002987012987013, + "cost": 0.07168831168831201, + "amortised_cost": 0.0, + "total_cost": 0.07168831168831201, + "unit_price": 0.003, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Network", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/UsageGeneratorForTesting/providers/Microsoft.Network/publicIPAddresses/usagegenerator-ip", + "resource_name": "usagegenerator-ip", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "UsageGeneratorForTesting", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/af55ee29-bbe4-df93-2671-163bf8dfedb6", + "name": "af55ee29-bbe4-df93-2671-163bf8dfedb6", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-28", + "product": "Azure Defender for servers - Standard - Nodes", + "part_number": "AAA-97320", + "meter_id": "0fad698c-40bf-4ee1-a096-565fc6f0cddd", + "quantity": 20.0, + "effective_price": 0.01484076433121, + "cost": 0.296815286624204, + "amortised_cost": 0.0, + "total_cost": 0.296815286624204, + "unit_price": 0.014900000000000002, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/VirtualMachines", + "resource_name": "VirtualMachines", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/be6ab8bb-8e86-fdad-2c9e-b74c57b1bb84", + "name": "be6ab8bb-8e86-fdad-2c9e-b74c57b1bb84", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-24", + "product": "Premium SSD Managed Disks - P4 - Disks - UK South", + "part_number": "AAD-18171", + "meter_id": "6a98a6aa-0245-4dd3-984e-b3f3d9f3c751", + "quantity": 0.033336000000000005, + "effective_price": 4.73768550128893, + "cost": 0.157935483870968, + "amortised_cost": 0.0, + "total_cost": 0.157935483870968, + "unit_price": 4.760000000000002, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/USAGEGENERATORFORTESTING/providers/Microsoft.Compute/disks/usagegenerator_OsDisk_1_d2d9c7b381154ce2bd2f8276caee7eb9", + "resource_name": "usagegenerator_OsDisk_1_d2d9c7b381154ce2bd2f8276caee7eb9", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "USAGEGENERATORFORTESTING", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/8666b307-5837-3e7d-ee8d-be19b8577772", + "name": "8666b307-5837-3e7d-ee8d-be19b8577772", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-23", + "product": "Bandwidth - Data Transfer Out - Zone 1", + "part_number": "Q5H-00003", + "meter_id": "9995d93a-7d35-4d3f-9c69-7a7fea447ef4", + "quantity": 0.000032999999999999996, + "effective_price": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "unit_price": 1.0, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/usagegeneratorfortesting/providers/Microsoft.Compute/virtualMachines/usagegenerator", + "resource_name": "usagegenerator", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "usagegeneratorfortesting", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/7d7dd92d-00b1-e17a-04a8-4a78b1278262", + "name": "7d7dd92d-00b1-e17a-04a8-4a78b1278262", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-25", + "product": "IP Addresses - Basic - Dynamic Public IP", + "part_number": "T6Z-00022", + "meter_id": "f114cb19-ea64-40b5-bcd7-aee474b62853", + "quantity": 24.0, + "effective_price": 0.002987012987013, + "cost": 0.07168831168831201, + "amortised_cost": 0.0, + "total_cost": 0.07168831168831201, + "unit_price": 0.003, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Network", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/UsageGeneratorForTesting/providers/Microsoft.Network/publicIPAddresses/usagegenerator-ip", + "resource_name": "usagegenerator-ip", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "UsageGeneratorForTesting", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/03168760-73e8-cde3-12a7-c8160f369944", + "name": "03168760-73e8-cde3-12a7-c8160f369944", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-23", + "product": "Azure Defender for DNS - Standard - Queries", + "part_number": "AAH-05446", + "meter_id": "58d180f0-a0c8-551f-a33b-7e576895e61a", + "quantity": 0.00008599999999999999, + "effective_price": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "unit_price": 0.5217, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Dns", + "resource_name": "Dns", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/c86bda6d-6507-2c2f-37a0-a03dfdab06f1", + "name": "c86bda6d-6507-2c2f-37a0-a03dfdab06f1", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-27", + "product": "Azure Defender for DNS - Standard - Queries", + "part_number": "AAH-05446", + "meter_id": "58d180f0-a0c8-551f-a33b-7e576895e61a", + "quantity": 0.000087, + "effective_price": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "unit_price": 0.5217, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Dns", + "resource_name": "Dns", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/135632e4-020c-7670-57d4-2abc22c8bf11", + "name": "135632e4-020c-7670-57d4-2abc22c8bf11", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-24", + "product": "Azure Defender for servers - Standard - Nodes", + "part_number": "AAA-97320", + "meter_id": "0fad698c-40bf-4ee1-a096-565fc6f0cddd", + "quantity": 24.0, + "effective_price": 0.01484076433121, + "cost": 0.356178343949045, + "amortised_cost": 0.0, + "total_cost": 0.356178343949045, + "unit_price": 0.014900000000000002, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/VirtualMachines", + "resource_name": "VirtualMachines", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/92a69799-3942-da9b-2aa6-144d8b355a14", + "name": "92a69799-3942-da9b-2aa6-144d8b355a14", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-26", + "product": "Virtual Machines Dv3/DSv3 Series - D4 v3/D4s v3 - UK South", + "part_number": "AAA-45006", + "meter_id": "23273f88-4d8c-4129-880c-d9fbdf94e6de", + "quantity": 24.0, + "effective_price": 0.17296888159399398, + "cost": 4.151253158255852, + "amortised_cost": 0.0, + "total_cost": 4.151253158255852, + "unit_price": 0.173, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/USAGEGENERATORFORTESTING/providers/Microsoft.Compute/virtualMachines/usagegenerator", + "resource_name": "usagegenerator", + "service_info1": null, + "service_info2": "Canonical", + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "USAGEGENERATORFORTESTING", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/36571c8a-39af-3ab3-94f9-510f71a842e0", + "name": "36571c8a-39af-3ab3-94f9-510f71a842e0", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-28", + "product": "IP Addresses - Basic - Dynamic Public IP", + "part_number": "T6Z-00022", + "meter_id": "f114cb19-ea64-40b5-bcd7-aee474b62853", + "quantity": 19.0, + "effective_price": 0.002987012987013, + "cost": 0.056753246753247014, + "amortised_cost": 0.0, + "total_cost": 0.056753246753247014, + "unit_price": 0.003, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Network", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/UsageGeneratorForTesting/providers/Microsoft.Network/publicIPAddresses/usagegenerator-ip", + "resource_name": "usagegenerator-ip", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "UsageGeneratorForTesting", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/b95d3fcf-89f9-7630-24c2-b77b37acb276", + "name": "b95d3fcf-89f9-7630-24c2-b77b37acb276", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-23", + "product": "Virtual Machines Dv3/DSv3 Series - D4 v3/D4s v3 - UK South", + "part_number": "AAA-45006", + "meter_id": "23273f88-4d8c-4129-880c-d9fbdf94e6de", + "quantity": 24.0, + "effective_price": 0.17296888159399398, + "cost": 4.151253158255852, + "amortised_cost": 0.0, + "total_cost": 4.151253158255852, + "unit_price": 0.173, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/USAGEGENERATORFORTESTING/providers/Microsoft.Compute/virtualMachines/usagegenerator", + "resource_name": "usagegenerator", + "service_info1": null, + "service_info2": "Canonical", + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "USAGEGENERATORFORTESTING", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/3efc598c-40d4-05af-e347-2abea28075cc", + "name": "3efc598c-40d4-05af-e347-2abea28075cc", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-27", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000135, + "effective_price": 1.7299840841464302, + "cost": 0.00023354785136, + "amortised_cost": 0.0, + "total_cost": 0.00023354785136, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/8edf092c-69cd-8ead-27f7-d9eef8265c70", + "name": "8edf092c-69cd-8ead-27f7-d9eef8265c70", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-23", + "product": "Premium SSD Managed Disks - P4 - Disks - UK South", + "part_number": "AAD-18171", + "meter_id": "6a98a6aa-0245-4dd3-984e-b3f3d9f3c751", + "quantity": 0.033336000000000005, + "effective_price": 4.73768550128893, + "cost": 0.157935483870968, + "amortised_cost": 0.0, + "total_cost": 0.157935483870968, + "unit_price": 4.760000000000002, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/USAGEGENERATORFORTESTING/providers/Microsoft.Compute/disks/usagegenerator_OsDisk_1_d2d9c7b381154ce2bd2f8276caee7eb9", + "resource_name": "usagegenerator_OsDisk_1_d2d9c7b381154ce2bd2f8276caee7eb9", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "USAGEGENERATORFORTESTING", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/bcc30d19-d7ae-a436-d299-3ef0175c6733", + "name": "bcc30d19-d7ae-a436-d299-3ef0175c6733", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-24", + "product": "Azure Defender for DNS - Standard - Queries", + "part_number": "AAH-05446", + "meter_id": "58d180f0-a0c8-551f-a33b-7e576895e61a", + "quantity": 0.000087, + "effective_price": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "unit_price": 0.5217, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Dns", + "resource_name": "Dns", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/6fb306cc-e7e8-2f56-9453-2d202220844c", + "name": "6fb306cc-e7e8-2f56-9453-2d202220844c", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-23", + "product": "Azure Defender for servers - Standard - Nodes", + "part_number": "AAA-97320", + "meter_id": "0fad698c-40bf-4ee1-a096-565fc6f0cddd", + "quantity": 24.0, + "effective_price": 0.01484076433121, + "cost": 0.356178343949045, + "amortised_cost": 0.0, + "total_cost": 0.356178343949045, + "unit_price": 0.014900000000000002, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/VirtualMachines", + "resource_name": "VirtualMachines", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/beb0fced-83e7-8eae-6972-21655c384a01", + "name": "beb0fced-83e7-8eae-6972-21655c384a01", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-28", + "product": "Virtual Machines Dv3/DSv3 Series - D4 v3/D4s v3 - UK South", + "part_number": "AAA-45006", + "meter_id": "23273f88-4d8c-4129-880c-d9fbdf94e6de", + "quantity": 18.0, + "effective_price": 0.17296888159399398, + "cost": 3.11343986869189, + "amortised_cost": 0.0, + "total_cost": 3.11343986869189, + "unit_price": 0.173, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/USAGEGENERATORFORTESTING/providers/Microsoft.Compute/virtualMachines/usagegenerator", + "resource_name": "usagegenerator", + "service_info1": null, + "service_info2": "Canonical", + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "USAGEGENERATORFORTESTING", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/5f7d66c6-64e6-8847-11c5-f4b4190a1051", + "name": "5f7d66c6-64e6-8847-11c5-f4b4190a1051", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-26", + "product": "Azure Defender for servers - Standard - Nodes", + "part_number": "AAA-97320", + "meter_id": "0fad698c-40bf-4ee1-a096-565fc6f0cddd", + "quantity": 24.0, + "effective_price": 0.01484076433121, + "cost": 0.356178343949045, + "amortised_cost": 0.0, + "total_cost": 0.356178343949045, + "unit_price": 0.014900000000000002, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/VirtualMachines", + "resource_name": "VirtualMachines", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/16f82177-2a4c-7b11-e8c4-deb9c55e4845", + "name": "16f82177-2a4c-7b11-e8c4-deb9c55e4845", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-26", + "product": "Azure Defender for DNS - Standard - Queries", + "part_number": "AAH-05446", + "meter_id": "58d180f0-a0c8-551f-a33b-7e576895e61a", + "quantity": 0.00008899999999999998, + "effective_price": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "unit_price": 0.5217, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Dns", + "resource_name": "Dns", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/94750ec0-9314-1615-6ddc-2b16ddf10368", + "name": "94750ec0-9314-1615-6ddc-2b16ddf10368", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-24", + "product": "Virtual Machines Dv3/DSv3 Series - D4 v3/D4s v3 - UK South", + "part_number": "AAA-45006", + "meter_id": "23273f88-4d8c-4129-880c-d9fbdf94e6de", + "quantity": 24.0, + "effective_price": 0.17296888159399398, + "cost": 4.151253158255852, + "amortised_cost": 0.0, + "total_cost": 4.151253158255852, + "unit_price": 0.173, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/USAGEGENERATORFORTESTING/providers/Microsoft.Compute/virtualMachines/usagegenerator", + "resource_name": "usagegenerator", + "service_info1": null, + "service_info2": "Canonical", + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "USAGEGENERATORFORTESTING", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/fa0fb797-8ea6-a315-044e-d5fac481bb14", + "name": "fa0fb797-8ea6-a315-044e-d5fac481bb14", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-22", + "product": "IP Addresses - Basic - Dynamic Public IP", + "part_number": "T6Z-00022", + "meter_id": "f114cb19-ea64-40b5-bcd7-aee474b62853", + "quantity": 15.0, + "effective_price": 0.002987012987013, + "cost": 0.044805194805195, + "amortised_cost": 0.0, + "total_cost": 0.044805194805195, + "unit_price": 0.003, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Network", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/UsageGeneratorForTesting/providers/Microsoft.Network/publicIPAddresses/usagegenerator-ip", + "resource_name": "usagegenerator-ip", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "UsageGeneratorForTesting", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/69d31684-9b6d-4894-9da5-cd400f289860", + "name": "69d31684-9b6d-4894-9da5-cd400f289860", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-21", + "product": "Azure App Service Premium v2 Plan - P1 v2 - US East", + "part_number": "AAA-48519", + "meter_id": "66af1bd1-e3f9-465c-96c2-3e2a9ab191df", + "quantity": 24.0, + "effective_price": 0.148891881197935, + "cost": 3.5734051487504304, + "amortised_cost": 0.0, + "total_cost": 3.5734051487504304, + "unit_price": 0.149, + "billing_currency": "GBP", + "resource_location": "EastUS", + "consumed_service": "microsoft.web", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourcegroups/az-204/providers/microsoft.web/serverfarms/asp-az204-9d35", + "resource_name": "asp-az204-9d35", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "az-204", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/c47ddad6-9e61-356e-bac3-8885436dcfb6", + "name": "c47ddad6-9e61-356e-bac3-8885436dcfb6", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-18", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.00007099999999999999, + "effective_price": 1.7299840841464302, + "cost": 0.000122828869974, + "amortised_cost": 0.0, + "total_cost": 0.000122828869974, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/0c60aa0c-bfcd-6d2a-19fe-1bf9669e0780", + "name": "0c60aa0c-bfcd-6d2a-19fe-1bf9669e0780", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-22", + "product": "Azure Defender for DNS - Standard - Queries", + "part_number": "AAH-05446", + "meter_id": "58d180f0-a0c8-551f-a33b-7e576895e61a", + "quantity": 0.000044, + "effective_price": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "unit_price": 0.5217, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Dns", + "resource_name": "Dns", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/f50a3bd9-469d-e965-9a96-8935546583ee", + "name": "f50a3bd9-469d-e965-9a96-8935546583ee", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-21", + "product": "Azure Defender for App Service - Standard - Nodes", + "part_number": "AAA-97326", + "meter_id": "3e7687bd-01e0-4342-bad7-7564cdc293db", + "quantity": 24.0, + "effective_price": 0.014848484848485, + "cost": 0.356363636363636, + "amortised_cost": 0.0, + "total_cost": 0.356363636363636, + "unit_price": 0.014900000000000002, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/AppServices", + "resource_name": "AppServices", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/5ebaf9e0-404e-64ba-e051-22ba2acd0c62", + "name": "5ebaf9e0-404e-64ba-e051-22ba2acd0c62", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-22", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.00102, + "effective_price": 1.7299840841464302, + "cost": 0.0017645837658290002, + "amortised_cost": 0.0, + "total_cost": 0.0017645837658290002, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/ab44b651-5064-97ca-8586-7f1d94609d1e", + "name": "ab44b651-5064-97ca-8586-7f1d94609d1e", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-14", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.00025, + "effective_price": 1.7299840841464302, + "cost": 0.000432496021037, + "amortised_cost": 0.0, + "total_cost": 0.000432496021037, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/94a333d0-30ba-35b9-6f88-08929440aaf4", + "name": "94a333d0-30ba-35b9-6f88-08929440aaf4", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-13", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000357, + "effective_price": 1.7299840841464302, + "cost": 0.0006176043180399998, + "amortised_cost": 0.0, + "total_cost": 0.0006176043180399998, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/e83f94ac-c5ba-b110-cba9-3b4f4f78effa", + "name": "e83f94ac-c5ba-b110-cba9-3b4f4f78effa", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-20", + "product": "Azure Defender for App Service - Standard - Nodes", + "part_number": "AAA-97326", + "meter_id": "3e7687bd-01e0-4342-bad7-7564cdc293db", + "quantity": 24.0, + "effective_price": 0.014848484848485, + "cost": 0.356363636363636, + "amortised_cost": 0.0, + "total_cost": 0.356363636363636, + "unit_price": 0.014900000000000002, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/AppServices", + "resource_name": "AppServices", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/db8b270e-c6f9-2cea-dde6-c248599447ad", + "name": "db8b270e-c6f9-2cea-dde6-c248599447ad", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-22", + "product": "Azure App Service Premium v2 Plan - P1 v2 - US East", + "part_number": "AAA-48519", + "meter_id": "66af1bd1-e3f9-465c-96c2-3e2a9ab191df", + "quantity": 7.08422, + "effective_price": 0.148891881197935, + "cost": 1.05478284262003, + "amortised_cost": 0.0, + "total_cost": 1.05478284262003, + "unit_price": 0.149, + "billing_currency": "GBP", + "resource_location": "EastUS", + "consumed_service": "microsoft.web", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourcegroups/az-204/providers/microsoft.web/serverfarms/asp-az204-9d35", + "resource_name": "asp-az204-9d35", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "az-204", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/7485baa3-8d83-34ba-a5e2-8ca6cbfc1c1e", + "name": "7485baa3-8d83-34ba-a5e2-8ca6cbfc1c1e", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-20", + "product": "Azure App Service Premium v2 Plan - P1 v2 - US East", + "part_number": "AAA-48519", + "meter_id": "66af1bd1-e3f9-465c-96c2-3e2a9ab191df", + "quantity": 24.0, + "effective_price": 0.148891881197935, + "cost": 3.5734051487504304, + "amortised_cost": 0.0, + "total_cost": 3.5734051487504304, + "unit_price": 0.149, + "billing_currency": "GBP", + "resource_location": "EastUS", + "consumed_service": "microsoft.web", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourcegroups/az-204/providers/microsoft.web/serverfarms/asp-az204-9d35", + "resource_name": "asp-az204-9d35", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "az-204", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/b0866e72-89c4-651e-31ff-afac4ac2bce9", + "name": "b0866e72-89c4-651e-31ff-afac4ac2bce9", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-19", + "product": "Azure App Service Premium v2 Plan - P1 v2 - US East", + "part_number": "AAA-48519", + "meter_id": "66af1bd1-e3f9-465c-96c2-3e2a9ab191df", + "quantity": 11.406981, + "effective_price": 0.148891881197935, + "cost": 1.6984068598791002, + "amortised_cost": 0.0, + "total_cost": 1.6984068598791002, + "unit_price": 0.149, + "billing_currency": "GBP", + "resource_location": "EastUS", + "consumed_service": "microsoft.web", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourcegroups/az-204/providers/microsoft.web/serverfarms/asp-az204-9d35", + "resource_name": "asp-az204-9d35", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "az-204", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/2fc47d6a-cde5-4945-79e8-d1e7aee7dcb8", + "name": "2fc47d6a-cde5-4945-79e8-d1e7aee7dcb8", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-22", + "product": "Azure Defender for servers - Standard - Nodes", + "part_number": "AAA-97320", + "meter_id": "0fad698c-40bf-4ee1-a096-565fc6f0cddd", + "quantity": 17.0, + "effective_price": 0.01484076433121, + "cost": 0.252292993630573, + "amortised_cost": 0.0, + "total_cost": 0.252292993630573, + "unit_price": 0.014900000000000002, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/VirtualMachines", + "resource_name": "VirtualMachines", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/02fed671-ae2d-677e-9717-3474ed532bf7", + "name": "02fed671-ae2d-677e-9717-3474ed532bf7", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-12", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000072, + "effective_price": 1.7299840841464302, + "cost": 0.000124558854059, + "amortised_cost": 0.0, + "total_cost": 0.000124558854059, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/302f0a06-13a8-9889-4e7b-2920e7ef51e5", + "name": "302f0a06-13a8-9889-4e7b-2920e7ef51e5", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-19", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000275, + "effective_price": 1.7299840841464302, + "cost": 0.00047574562314, + "amortised_cost": 0.0, + "total_cost": 0.00047574562314, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/8b3d9754-deb1-4361-9dc5-3ccec82bd5f9", + "name": "8b3d9754-deb1-4361-9dc5-3ccec82bd5f9", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-16", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.00016, + "effective_price": 1.7299840841464302, + "cost": 0.000276797453463, + "amortised_cost": 0.0, + "total_cost": 0.000276797453463, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/c497a8b5-6800-9f24-0dd0-f687bc507ab2", + "name": "c497a8b5-6800-9f24-0dd0-f687bc507ab2", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-22", + "product": "Virtual Machines Dv3/DSv3 Series - D4 v3/D4s v3 - UK South", + "part_number": "AAA-45006", + "meter_id": "23273f88-4d8c-4129-880c-d9fbdf94e6de", + "quantity": 16.883351, + "effective_price": 0.17296888159399398, + "cost": 2.9202943400288404, + "amortised_cost": 0.0, + "total_cost": 2.9202943400288404, + "unit_price": 0.173, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/USAGEGENERATORFORTESTING/providers/Microsoft.Compute/virtualMachines/usagegenerator", + "resource_name": "usagegenerator", + "service_info1": null, + "service_info2": "Canonical", + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "USAGEGENERATORFORTESTING", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/a80a49f9-85c1-4dc9-ba76-c7fee3780eae", + "name": "a80a49f9-85c1-4dc9-ba76-c7fee3780eae", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-21", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000096, + "effective_price": 1.7299840841464302, + "cost": 0.000166078472078, + "amortised_cost": 0.0, + "total_cost": 0.000166078472078, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/458479c6-e46c-613f-0d7c-c3f2a3c5b500", + "name": "458479c6-e46c-613f-0d7c-c3f2a3c5b500", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-22", + "product": "Azure Defender for App Service - Standard - Nodes", + "part_number": "AAA-97326", + "meter_id": "3e7687bd-01e0-4342-bad7-7564cdc293db", + "quantity": 7.000000000000002, + "effective_price": 0.014848484848485, + "cost": 0.103939393939394, + "amortised_cost": 0.0, + "total_cost": 0.103939393939394, + "unit_price": 0.014900000000000002, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/AppServices", + "resource_name": "AppServices", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/97f5f24b-7562-f196-3f0c-d9706235ec8e", + "name": "97f5f24b-7562-f196-3f0c-d9706235ec8e", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-20", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000173, + "effective_price": 1.7299840841464302, + "cost": 0.000299287246557, + "amortised_cost": 0.0, + "total_cost": 0.000299287246557, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/045648a5-1cf3-458c-e51e-a171a1e768ea", + "name": "045648a5-1cf3-458c-e51e-a171a1e768ea", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-15", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000124, + "effective_price": 1.7299840841464302, + "cost": 0.000214518026434, + "amortised_cost": 0.0, + "total_cost": 0.000214518026434, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/a8d760fd-243e-77aa-5e47-176a6078c445", + "name": "a8d760fd-243e-77aa-5e47-176a6078c445", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-22", + "product": "Premium SSD Managed Disks - P4 - Disks - UK South", + "part_number": "AAD-18171", + "meter_id": "6a98a6aa-0245-4dd3-984e-b3f3d9f3c751", + "quantity": 0.022224, + "effective_price": 4.73768550128893, + "cost": 0.105290322580645, + "amortised_cost": 0.0, + "total_cost": 0.105290322580645, + "unit_price": 4.760000000000002, + "billing_currency": "GBP", + "resource_location": "uksouth", + "consumed_service": "Microsoft.Compute", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/resourceGroups/USAGEGENERATORFORTESTING/providers/Microsoft.Compute/disks/usagegenerator_OsDisk_1_d2d9c7b381154ce2bd2f8276caee7eb9", + "resource_name": "usagegenerator_OsDisk_1_d2d9c7b381154ce2bd2f8276caee7eb9", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": "USAGEGENERATORFORTESTING", + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/6a5b4e4e-3677-7624-b1dc-06a2c6c9291d", + "name": "6a5b4e4e-3677-7624-b1dc-06a2c6c9291d", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-17", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000203, + "effective_price": 1.7299840841464302, + "cost": 0.000351186769082, + "amortised_cost": 0.0, + "total_cost": 0.000351186769082, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/8cc9beae-5c49-e96f-f90f-c518265507c3", + "name": "8cc9beae-5c49-e96f-f90f-c518265507c3", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-19", + "product": "Azure Defender for App Service - Standard - Nodes", + "part_number": "AAA-97326", + "meter_id": "3e7687bd-01e0-4342-bad7-7564cdc293db", + "quantity": 11.0, + "effective_price": 0.014848484848485, + "cost": 0.163333333333333, + "amortised_cost": 0.0, + "total_cost": 0.163333333333333, + "unit_price": 0.014900000000000002, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/AppServices", + "resource_name": "AppServices", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/6024953f-d33a-5c33-2f05-b622e068407f", + "name": "6024953f-d33a-5c33-2f05-b622e068407f", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-06", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.00019, + "effective_price": 1.7299840841464302, + "cost": 0.000328696975988, + "amortised_cost": 0.0, + "total_cost": 0.000328696975988, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/b4438584-93dc-11d4-2eec-f6f0c8594c8d", + "name": "b4438584-93dc-11d4-2eec-f6f0c8594c8d", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-08", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000136, + "effective_price": 1.7299840841464302, + "cost": 0.000235277835444, + "amortised_cost": 0.0, + "total_cost": 0.000235277835444, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/84782c39-0020-59bd-b8b7-905c73c770db", + "name": "84782c39-0020-59bd-b8b7-905c73c770db", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-03", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000179, + "effective_price": 1.7299840841464302, + "cost": 0.000309667151062, + "amortised_cost": 0.0, + "total_cost": 0.000309667151062, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/0ddfcd29-744d-428d-a89e-2879071b54d6", + "name": "0ddfcd29-744d-428d-a89e-2879071b54d6", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-05", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000054, + "effective_price": 1.7299840841464302, + "cost": 0.000093419140544, + "amortised_cost": 0.0, + "total_cost": 0.000093419140544, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/b6f04805-0a45-650a-a40e-7de7e5d04d29", + "name": "b6f04805-0a45-650a-a40e-7de7e5d04d29", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-11", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000044, + "effective_price": 1.7299840841464302, + "cost": 0.000076119299702, + "amortised_cost": 0.0, + "total_cost": 0.000076119299702, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/cf520bbb-2e61-b632-330c-6a0b19cdc58c", + "name": "cf520bbb-2e61-b632-330c-6a0b19cdc58c", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-04", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000049, + "effective_price": 1.7299840841464302, + "cost": 0.000084769220123, + "amortised_cost": 0.0, + "total_cost": 0.000084769220123, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/90a34c06-2d3d-bedf-851d-9419bca3ace3", + "name": "90a34c06-2d3d-bedf-851d-9419bca3ace3", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-09", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000049, + "effective_price": 1.7299840841464302, + "cost": 0.000084769220123, + "amortised_cost": 0.0, + "total_cost": 0.000084769220123, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/e7f63974-64ee-8261-9a5b-f0ded2eb30af", + "name": "e7f63974-64ee-8261-9a5b-f0ded2eb30af", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-01", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000274, + "effective_price": 1.7299840841464302, + "cost": 0.000474015639056, + "amortised_cost": 0.0, + "total_cost": 0.000474015639056, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/1522468f-01ed-4706-3477-afa8fb2167fc", + "name": "1522468f-01ed-4706-3477-afa8fb2167fc", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-07", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000126, + "effective_price": 1.7299840841464302, + "cost": 0.000217977994602, + "amortised_cost": 0.0, + "total_cost": 0.000217977994602, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/ecd11ef5-6031-1656-7d08-980b28dedf8d", + "name": "ecd11ef5-6031-1656-7d08-980b28dedf8d", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-10", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000078, + "effective_price": 1.7299840841464302, + "cost": 0.000134938758563, + "amortised_cost": 0.0, + "total_cost": 0.000134938758563, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210901/providers/Microsoft.Consumption/usageDetails/10ab9dae-7380-1cb0-d9bf-7cb77448d525", + "name": "10ab9dae-7380-1cb0-d9bf-7cb77448d525", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-09-01", + "billing_period_end_date": "2021-09-30", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-09-02", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000096, + "effective_price": 1.7299840841464302, + "cost": 0.000166078472078, + "amortised_cost": 0.0, + "total_cost": 0.000166078472078, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/68294307-70a8-3d5a-6521-39d815e6fef2", + "name": "68294307-70a8-3d5a-6521-39d815e6fef2", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-14", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000057, + "effective_price": 1.23342584027135, + "cost": 0.000070305272895, + "amortised_cost": 0.0, + "total_cost": 0.000070305272895, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/ed727fcf-8397-24c4-27d3-a8b5743857ab", + "name": "ed727fcf-8397-24c4-27d3-a8b5743857ab", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-12", + "product": "Azure Defender for Resource Manager - Standard Trial - Events", + "part_number": "AAH-05462", + "meter_id": "457e7896-15f9-5a97-8c17-ca18640a2262", + "quantity": 0.000137, + "effective_price": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "unit_price": 0.0, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/29e2069e-c1dc-14ce-e8da-8cd7bb25ec83", + "name": "29e2069e-c1dc-14ce-e8da-8cd7bb25ec83", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-16", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000166, + "effective_price": 1.23342584027135, + "cost": 0.000204748689485, + "amortised_cost": 0.0, + "total_cost": 0.000204748689485, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/baf78c0e-218f-b745-9e2f-cf107225f330", + "name": "baf78c0e-218f-b745-9e2f-cf107225f330", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-22", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000064, + "effective_price": 1.23342584027135, + "cost": 0.000078939253777, + "amortised_cost": 0.0, + "total_cost": 0.000078939253777, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/4fce1ffc-696a-9152-6a02-cb812525bce1", + "name": "4fce1ffc-696a-9152-6a02-cb812525bce1", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-19", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000051, + "effective_price": 1.23342584027135, + "cost": 0.00006290471785399999, + "amortised_cost": 0.0, + "total_cost": 0.00006290471785399999, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/67844f16-616f-b08b-c49b-4d4fd0a1d99b", + "name": "67844f16-616f-b08b-c49b-4d4fd0a1d99b", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-13", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.00011, + "effective_price": 1.23342584027135, + "cost": 0.00013567684243, + "amortised_cost": 0.0, + "total_cost": 0.00013567684243, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/c9985565-f4a2-4b15-4c4b-a3179a3a2185", + "name": "c9985565-f4a2-4b15-4c4b-a3179a3a2185", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-20", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000172, + "effective_price": 1.23342584027135, + "cost": 0.000212149244527, + "amortised_cost": 0.0, + "total_cost": 0.000212149244527, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/7d48c775-e15d-8e7c-4c88-fbbdf0e30d77", + "name": "7d48c775-e15d-8e7c-4c88-fbbdf0e30d77", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-21", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000064, + "effective_price": 1.23342584027135, + "cost": 0.000078939253777, + "amortised_cost": 0.0, + "total_cost": 0.000078939253777, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/6e87c831-7cbe-8d92-a213-9e1f05fa7db9", + "name": "6e87c831-7cbe-8d92-a213-9e1f05fa7db9", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-12", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000064, + "effective_price": 1.23342584027135, + "cost": 0.000078939253777, + "amortised_cost": 0.0, + "total_cost": 0.000078939253777, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/9a3b55c8-395a-3099-8d92-a5af8a0cb3e9", + "name": "9a3b55c8-395a-3099-8d92-a5af8a0cb3e9", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-17", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000102, + "effective_price": 1.23342584027135, + "cost": 0.000125809435708, + "amortised_cost": 0.0, + "total_cost": 0.000125809435708, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/60ef5981-1028-b4f1-1228-577c3e33aa48", + "name": "60ef5981-1028-b4f1-1228-577c3e33aa48", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-15", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.00007599999999999999, + "effective_price": 1.23342584027135, + "cost": 0.000093740363861, + "amortised_cost": 0.0, + "total_cost": 0.000093740363861, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + }, + { + "id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Billing/billingPeriods/20210801/providers/Microsoft.Consumption/usageDetails/81f01970-3d2a-8a88-8c26-450bb440c0d1", + "name": "81f01970-3d2a-8a88-8c26-450bb440c0d1", + "type": "Microsoft.Consumption/usageDetails", + "tags": null, + "billing_account_id": "53246346", + "billing_account_name": "Institute", + "billing_period_start_date": "2021-08-01", + "billing_period_end_date": "2021-08-31", + "billing_profile_id": "53246346", + "billing_profile_name": "Institute", + "account_owner_id": "jdoe@domain.ac.uk", + "account_name": "JDoe", + "subscription_id": "ce0f6ae0-2032-11ec-9621-0242ac130002", + "subscription_name": "JDoe", + "date": "2021-08-18", + "product": "Azure Defender for Resource Manager - Standard - Events", + "part_number": "AAH-05467", + "meter_id": "f71b1ea3-c809-4ecb-b9d9-ae6b6fa67bd3", + "quantity": 0.000119, + "effective_price": 1.23342584027135, + "cost": 0.000146777674992, + "amortised_cost": 0.0, + "total_cost": 0.000146777674992, + "unit_price": 2.9810000000000003, + "billing_currency": "GBP", + "resource_location": "Unassigned", + "consumed_service": "Microsoft.Security", + "resource_id": "/subscriptions/ce0f6ae0-2032-11ec-9621-0242ac130002/providers/Microsoft.Security/pricings/Arm", + "resource_name": "Arm", + "service_info1": null, + "service_info2": null, + "additional_info": null, + "invoice_section": "ASG (SPF EPSRC)", + "cost_center": null, + "resource_group": null, + "reservation_id": null, + "reservation_name": null, + "product_order_id": null, + "offer_id": "MS-AZR-00184", + "is_azure_credit_eligible": true, + "term": null, + "publisher_name": null, + "publisher_type": "Azure", + "plan_name": null, + "charge_type": "Usage", + "frequency": "UsageBased" + } +] diff --git a/tests/test_crud/__init__.py b/tests/test_crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_crud/test_auth.py b/tests/test_crud/test_auth.py new file mode 100644 index 0000000..72622f4 --- /dev/null +++ b/tests/test_crud/test_auth.py @@ -0,0 +1,19 @@ +from uuid import UUID + +import pytest +from databases import Database + +from rctab.crud.auth import check_user_access +from tests.test_routes.test_routes import test_db # pylint: disable=unused-import + +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument + + +@pytest.mark.asyncio +async def test_check_user_access(test_db: Database) -> None: + """ + Test the check_user_access function in the crud module. + """ + result = await check_user_access(str(UUID(int=880)), "me@my.org", False) + assert result.username == "me@my.org" diff --git a/tests/test_routes/__init__.py b/tests/test_routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_routes/api_calls.py b/tests/test_routes/api_calls.py new file mode 100644 index 0000000..fb40b44 --- /dev/null +++ b/tests/test_routes/api_calls.py @@ -0,0 +1,175 @@ +import datetime +from typing import Optional, Tuple +from uuid import UUID, uuid4 + +from devtools import debug +from fastapi.testclient import TestClient +from httpx import Response + +from rctab.crud.schema import ( + AllSubscriptionStatus, + AllUsage, + RoleAssignment, + SubscriptionDetails, + SubscriptionState, + SubscriptionStatus, + Usage, +) +from rctab.routers.accounting.routes import PREFIX + +# pylint: disable=too-many-arguments + + +def assert_subscription_status( + client: TestClient, expected_details: SubscriptionDetails +) -> None: + result = client.get( + PREFIX + "/subscription", + params={"sub_id": str(expected_details.subscription_id)}, + ) + + assert result.status_code == 200 + result_json = result.json() + assert len(result_json) == 1 + res_details = SubscriptionDetails(**result_json[0]) + + if expected_details != res_details: + debug(res_details) + debug(expected_details) + + assert expected_details == res_details, "{} != {}".format( + expected_details, res_details + ) + + +def create_subscription(client: TestClient, subscription_id: UUID) -> Response: + return client.post( + PREFIX + "/subscription", + json={"sub_id": str(subscription_id)}, + ) + + +def create_subscription_detail( + client: TestClient, + token: str, + subscription_id: UUID, + state: SubscriptionState, + role_assignments: Optional[Tuple[RoleAssignment, ...]] = (), + display_name: str = "sub display name", +) -> Response: + return client.post( + "accounting" + "/all-status", + content=AllSubscriptionStatus( + status_list=[ + SubscriptionStatus( + subscription_id=subscription_id, + display_name=display_name, + state=state, + role_assignments=role_assignments, + ) + ] + ).json(), + headers={"authorization": "Bearer " + token}, + ) + + +def set_persistence( + client: TestClient, subscription_id: UUID, always_on: bool +) -> Response: + return client.post( + PREFIX + "/persistent", + json={"sub_id": str(subscription_id), "always_on": always_on}, + ) + + +def create_approval( + client: TestClient, + subscription_id: UUID, + ticket: str, + amount: float, + date_from: datetime.date, + date_to: datetime.date, + allocate: bool, + currency: str = "GBP", + force: bool = False, +) -> Response: + return client.post( + PREFIX + "/approve", + json={ + "sub_id": str(subscription_id), + "ticket": ticket, + "amount": amount, + "date_from": date_from.isoformat(), + "date_to": date_to.isoformat(), + "allocate": allocate, + "currency": currency, + "force": force, + }, + ) + + +def create_allocation( + client: TestClient, + subscription_id: UUID, + ticket: str, + amount: float, +) -> Response: + return client.post( + PREFIX + "/topup", + json={"sub_id": str(subscription_id), "ticket": ticket, "amount": amount}, + ) + + +def create_usage( + client: TestClient, + token: str, + subscription_id: UUID, + cost: float = 0.0, + amortised_cost: float = 0.0, + date: datetime.date = datetime.date.today(), +) -> Response: + usage = Usage( + id=str(uuid4()), + name="test", + type="", + billing_account_id="666", + billing_account_name="TestAccount", + billing_period_start_date=date - datetime.timedelta(days=30), + billing_period_end_date=date, + billing_profile_id="", + billing_profile_name="", + account_owner_id="", + account_name="", + subscription_id=subscription_id, + subscription_name="", + date=date, + product="", + part_number="", + meter_id="", + quantity=1.0, + effective_price=1.0, + cost=cost, + amortised_cost=amortised_cost, + total_cost=cost + amortised_cost, + unit_price=1.0, + billing_currency="", + resource_location="", + consumed_service="", + resource_id="", + resource_name="", + invoice_section="", + offer_id="", + is_azure_credit_eligible=True, + publisher_type="", + charge_type="", + frequency="", + monthly_upload=None, + ) + + post_data = AllUsage(usage_list=[usage]) + + return client.post( + "usage/all-usage", + content=post_data.json(), + headers={"authorization": "Bearer " + token}, + ) diff --git a/tests/test_routes/conftest.py b/tests/test_routes/conftest.py new file mode 100644 index 0000000..0fd7f8a --- /dev/null +++ b/tests/test_routes/conftest.py @@ -0,0 +1,57 @@ +from typing import Any + +from sqlalchemy import create_engine, delete, insert, text +from sqlalchemy.engine import Connection + +from rctab.crud.models import DATABASE_URL, user_rbac +from tests.test_routes import constants + +engine = create_engine(DATABASE_URL) + + +def pytest_configure(config: Any) -> None: # pylint: disable=unused-argument + """Allows plugins and conftest files to perform initial configuration. + + This hook is called for every plugin and initial conftest + file after command line options have been parsed.""" + + conn = engine.connect() + + conn.execute( + insert(user_rbac).values( + (str(constants.ADMIN_UUID), constants.ADMIN_NAME, True, True) + ) + ) + + conn.close() + + +def pytest_unconfigure(config: Any) -> None: # pylint: disable=unused-argument + """Called before test process is exited.""" + + conn = engine.connect() + + clean_up(conn) + + conn.execute(delete(user_rbac).where(user_rbac.c.oid == str(constants.ADMIN_UUID))) + + conn.close() + + +def clean_up(conn: Connection) -> None: + """Deletes data from all accounting tables.""" + + for table_name in ( + "status", + "usage", + "allocations", + "approvals", + "persistence", + "emails", + "cost_recovery", + "finance", + "finance_history", + "subscription", + "cost_recovery_log", + ): + conn.execute(text(f"truncate table accounting.{table_name} cascade")) diff --git a/tests/test_routes/constants.py b/tests/test_routes/constants.py new file mode 100644 index 0000000..46a522b --- /dev/null +++ b/tests/test_routes/constants.py @@ -0,0 +1,10 @@ +import uuid +from typing import Final + +ADMIN_UUID: Final = uuid.UUID("a75693be-1d36-11ec-9621-0242ac130002") +ADMIN_NAME: Final = "Tester" +ADMIN_DICT: Final = {"admin": ADMIN_UUID} + +# TEST_SUB_2_UUID exists in tests/data/example.json +TEST_SUB_UUID: Final = uuid.UUID("3fbe12f6-1d39-11ec-9621-0242ac130002") +TEST_SUB_2_UUID: Final = uuid.UUID("ce0f6ae0-2032-11ec-9621-0242ac130002") diff --git a/tests/test_routes/data/details_data.json b/tests/test_routes/data/details_data.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_routes/test_abolishment.py b/tests/test_routes/test_abolishment.py new file mode 100644 index 0000000..d6dfe52 --- /dev/null +++ b/tests/test_routes/test_abolishment.py @@ -0,0 +1,143 @@ +from datetime import datetime, timedelta +from uuid import UUID + +import pytest +from databases import Database +from pytest_mock import MockerFixture +from sqlalchemy import select + +from rctab.crud.accounting_models import subscription_details +from rctab.crud.schema import SubscriptionState +from rctab.routers.accounting.abolishment import ( + adjust_budgets_to_zero, + get_inactive_subs, + send_abolishment_email, + set_abolished_flag, +) +from rctab.routers.accounting.routes import get_subscriptions_summary +from tests.test_routes import constants +from tests.test_routes.test_routes import ( # pylint: disable=unused-import + create_subscription, + test_db, +) + + +async def create_expired_subscription( + test_db: Database, # pylint: disable=redefined-outer-name +) -> UUID: + """ + Creates a subscription which has been inactive for more than 90 days. + """ + + date_91d_ago = datetime.now() - timedelta(days=91) + + approved = 100 + allocated = 80 + usage = 110 + + expired_sub_id = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Disabled"), + approved=(approved, date_91d_ago), + allocated_amount=allocated, + spent=(usage, 0), + spent_date=date_91d_ago, + ) + + await test_db.execute( + subscription_details.update() + .where(subscription_details.c.subscription_id == expired_sub_id) + .values(time_created=date_91d_ago) + ) + + return expired_sub_id + + +@pytest.mark.asyncio +async def test_abolishment( + test_db: Database, # pylint: disable=redefined-outer-name + mocker: MockerFixture, +) -> None: + + # Testing get_inactive_subs + expired_sub_id = await create_expired_subscription(test_db) + + inactive_subs = await get_inactive_subs() + + assert inactive_subs + assert len(inactive_subs) == 1 + assert inactive_subs[0] == expired_sub_id + + # Testing adjust_budgets_to_zero + adjustments = await adjust_budgets_to_zero(constants.ADMIN_UUID, inactive_subs) + + assert adjustments + assert len(adjustments) == 1 + assert adjustments[0]["subscription_id"] == expired_sub_id + assert adjustments[0]["allocation"] == 30.0 + assert adjustments[0]["approval"] == 10.0 + + sub_query = get_subscriptions_summary(execute=False).alias() + summary_qr = select([sub_query]).where( + sub_query.c.subscription_id == expired_sub_id + ) + summary = await test_db.fetch_all(summary_qr) + + assert summary + assert len(summary) == 1 + for row in summary: + assert row["total_cost"] == row["allocated"] + assert row["total_cost"] == row["approved"] + assert row["abolished"] is False + + # Testing set_abolished_flag + await set_abolished_flag(inactive_subs) + + sub_query = get_subscriptions_summary(execute=False).alias() + summary_qr = select([sub_query]).where( + sub_query.c.subscription_id == expired_sub_id + ) + summary = await test_db.fetch_all(summary_qr) + + assert summary + assert len(summary) == 1 + for row in summary: + assert row["abolished"] is True + + # Testing send_emails + email_recipients = ["test@test.com"] + mock_send_email = mocker.patch( + "rctab.routers.accounting.abolishment.send_with_sendgrid" + ) + mock_send_email.return_value = 200 + + await send_abolishment_email(email_recipients, adjustments) + + mock_send_email.assert_called_with( + "Abolishment of subscriptions", + "abolishment.html", + { + "abolishments": [ + { + "subscription_id": expired_sub_id, + "name": "a subscription", + "allocation": adjustments[0]["allocation"], + "approval": adjustments[0]["approval"], + } + ] + }, + email_recipients, + ) + + +@pytest.mark.asyncio +async def test_abolishment_no_allocation( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + + sub_id = await create_subscription(test_db, spent=(1.0, 1.0)) + + adjustments = await adjust_budgets_to_zero(constants.ADMIN_UUID, [sub_id]) + + assert len(adjustments) == 1 diff --git a/tests/test_routes/test_allocations.py b/tests/test_routes/test_allocations.py new file mode 100644 index 0000000..3c52676 --- /dev/null +++ b/tests/test_routes/test_allocations.py @@ -0,0 +1,331 @@ +import datetime +from unittest.mock import AsyncMock + +import pytest +import pytest_mock +from devtools import debug +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from rctab.crud.models import database +from rctab.crud.schema import SubscriptionDetails +from tests.test_routes import api_calls, constants + +date_from = datetime.date.today() +date_to = datetime.date.today() + datetime.timedelta(days=30) +TICKET = "T001-12" + + +def test_over_allocate(auth_app: FastAPI, mocker: pytest_mock.MockerFixture) -> None: + """Try to over-allocate credit.""" + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=True) + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket=TICKET, + amount=100.0, + date_from=date_from, + date_to=date_to, + allocate=False, + ) + + result = api_calls.create_allocation( + client, constants.TEST_SUB_UUID, TICKET, 200 + ) + + assert result.status_code == 400 + result_json = result.json() + debug(result_json) + assert ( + result_json["detail"] + == "Allocation (200.0) cannot be bigger than the unallocated budget (100.0)." + ) + + +def test_negative_allocation( + auth_app: FastAPI, mocker: pytest_mock.MockerFixture +) -> None: + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=True) + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket=TICKET, + amount=100.0, + date_from=date_from, + date_to=date_to, + allocate=False, + ) + + result = api_calls.create_allocation( + client, constants.TEST_SUB_UUID, TICKET, -50 + ) + + assert result.status_code == 400 + result_json = result.json() + debug(result_json) + assert ( + result_json["detail"] + == "Negative allocation (50.0) cannot be bigger than the unused budget (0.0)." + ) + + +def test_unknown_desired_status( + auth_app: FastAPI, mocker: pytest_mock.MockerFixture +) -> None: + """No allocations so desired status should be None""" + + expected_details = SubscriptionDetails( + subscription_id=constants.TEST_SUB_UUID, + name=None, + role_assignments=None, + status=None, + approved_from=date_from, + approved_to=date_to, + always_on=True, + approved=100.0, + allocated=0.0, + cost=0.0, + amortised_cost=0.0, + total_cost=0.0, + remaining=0.0, + # billing_status=BillingStatus.ACTIVE, + first_usage=None, + latest_usage=None, + # desired_status=True, + desired_status_info=None, + abolished=False, + ) + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=True) + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket=TICKET, + amount=100.0, + date_from=date_from, + date_to=date_to, + allocate=False, + ) + + api_calls.assert_subscription_status(client, expected_details) + + +@pytest.mark.parametrize("amount", [0.01, 50.0, 99.9999, 100.0]) +def test_successful_allocations( + auth_app: FastAPI, mocker: pytest_mock.MockerFixture, amount: float +) -> None: + expected_details = SubscriptionDetails( + subscription_id=constants.TEST_SUB_UUID, + name=None, + role_assignments=None, + status=None, + approved_from=date_from, + approved_to=date_to, + always_on=True, + approved=100.0, + allocated=amount, + cost=0.0, + amortised_cost=0.0, + total_cost=0.0, + remaining=amount, + first_usage=None, + latest_usage=None, + desired_status_info=None, + abolished=False, + ) + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=True) + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket=TICKET, + amount=100.0, + date_from=date_from, + date_to=date_to, + allocate=False, + ) + + result = api_calls.create_allocation( + client, constants.TEST_SUB_UUID, TICKET, amount + ) + + assert result.status_code == 200 + + api_calls.assert_subscription_status(client, expected_details) + + mock_send_email.assert_called_with( + database, + constants.TEST_SUB_UUID, + "new_allocation.html", + "New allocation for your Azure subscription:", + "subscription allocation", + { + "amount": amount, + "currency": "GBP", + "sub_id": constants.TEST_SUB_UUID, + "ticket": TICKET, + }, + ) + + +def test_negative_too_large( + auth_app: FastAPI, mocker: pytest_mock.MockerFixture +) -> None: + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=True) + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket=TICKET, + amount=100.0, + date_from=date_from, + date_to=date_to, + allocate=False, + ) + + api_calls.create_allocation(client, constants.TEST_SUB_UUID, TICKET, 100) + + result = api_calls.create_allocation( + client, constants.TEST_SUB_UUID, TICKET, -200 + ) + + assert result.status_code == 400 + + result_json = result.json() + debug(result_json) + assert ( + result_json["detail"] + == "Negative allocation (200.0) cannot be bigger than the unused budget (100.0)." + ) + + +def test_9(auth_app: FastAPI, mocker: pytest_mock.MockerFixture) -> None: + expected_details = SubscriptionDetails( + subscription_id=constants.TEST_SUB_UUID, + approved_from=date_from, + approved_to=date_to, + always_on=True, + approved=500.0, + allocated=130.0, + cost=0.0, + amortised_cost=0.0, + total_cost=0.0, + remaining=130.00, + first_usage=None, + latest_usage=None, + desired_status_info=None, + abolished=False, + ) + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=True) + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket=TICKET, + amount=500.0, + date_from=date_from, + date_to=date_to, + allocate=False, + ) + + api_calls.create_allocation(client, constants.TEST_SUB_UUID, TICKET, 100) + + api_calls.create_allocation(client, constants.TEST_SUB_UUID, TICKET, -20) + + api_calls.create_allocation(client, constants.TEST_SUB_UUID, TICKET, 50) + + api_calls.assert_subscription_status(client, expected_details) + + +def test_topup_refreshes_desired_states( + auth_app: FastAPI, mocker: pytest_mock.MockerFixture +) -> None: + # pylint: disable=useless-super-delegation + + with TestClient(auth_app) as client: + mock_refresh = AsyncMock() + + mocker.patch( + "rctab.routers.accounting.allocations.refresh_desired_states", mock_refresh + ) + + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription( + client, constants.TEST_SUB_UUID + ).raise_for_status() + api_calls.set_persistence( + client, constants.TEST_SUB_UUID, always_on=True + ).raise_for_status() + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket=TICKET, + amount=100.0, + date_from=date_from, + date_to=date_to, + allocate=False, + ).raise_for_status() + + api_calls.create_allocation( + client, constants.TEST_SUB_UUID, TICKET, 100.0 + ).raise_for_status() + + # Topping up a subscription should have the side effect of + # refreshing the desired states + mock_refresh.assert_called_once_with( + constants.ADMIN_UUID, [constants.TEST_SUB_UUID] + ) diff --git a/tests/test_routes/test_approvals.py b/tests/test_routes/test_approvals.py new file mode 100644 index 0000000..e7e25cd --- /dev/null +++ b/tests/test_routes/test_approvals.py @@ -0,0 +1,639 @@ +import datetime +import json +from unittest.mock import AsyncMock + +from devtools import debug +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pytest_mock import MockerFixture + +from rctab.constants import EMAIL_TYPE_SUB_APPROVAL +from rctab.crud.models import database +from rctab.crud.schema import Approval, SubscriptionDetails +from rctab.routers.accounting.routes import PREFIX +from tests.test_routes import api_calls, constants +from tests.test_routes.test_routes import test_db # pylint: disable=unused-import + + +def test_approve_date_from_in_past(auth_app: FastAPI, mocker: MockerFixture) -> None: + """Approve with date_from in the past.""" + + date_from = datetime.date.today() - datetime.timedelta(days=31) + date_to = datetime.date.today() + datetime.timedelta(days=30) + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=True) + + result = api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-12", + amount=100.0, + date_from=date_from, + date_to=date_to, + allocate=False, + ) + + assert result.status_code == 400 + + assert ( + result.json()["detail"] + == f"Date from ({date_from.isoformat()}) cannot be more than 30 days in the past. " + "This check ensures that you are not approving a subscription that has already been cancelled " + "for more than 30 days." + ) + + +def test_approve_date_from_in_past_forced( + auth_app: FastAPI, mocker: MockerFixture +) -> None: + """Approve with date_from in the past.""" + + date_from = datetime.date.today() - datetime.timedelta(days=31) + date_to = datetime.date.today() + datetime.timedelta(days=30) + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + + result = api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-12", + amount=100.0, + date_from=date_from, + date_to=date_to, + allocate=False, + force=True, + ) + + assert result.status_code == 200 + + +def test_approve_date_to_in_past(auth_app: FastAPI, mocker: MockerFixture) -> None: + """Approve with date_to in the past.""" + + date_from = datetime.date.today() + date_to = datetime.date.today() - datetime.timedelta(days=1) + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=True) + + result = api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-12", + amount=100.0, + date_from=date_from, + date_to=date_to, + allocate=False, + ) + + assert result.status_code == 400 + assert ( + result.json()["detail"] + == f"Date to ({date_to.isoformat()}) cannot be in the past" + ) + + +def test_approve_date_to_before_date_from( + auth_app: FastAPI, mocker: MockerFixture +) -> None: + """Approve with date_to before date_from.""" + + date_from = datetime.date.today() + datetime.timedelta(days=10) + date_to = datetime.date.today() + datetime.timedelta(days=9) + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=True) + + result = api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-12", + amount=100.0, + date_from=date_from, + date_to=date_to, + allocate=False, + ) + + assert result.status_code == 400 + debug(result.json()) + assert ( + result.json()["detail"] + == f"Date from ({date_from.isoformat()}) cannot be greater than date to ({date_to.isoformat()})" + ) + + +def test_successful_approval(auth_app: FastAPI, mocker: MockerFixture) -> None: + """Successful approval.""" + + date_from = datetime.date.today() + date_to = datetime.date.today() + datetime.timedelta(days=30) + + expected_details = SubscriptionDetails( + subscription_id=constants.TEST_SUB_UUID, + name=None, + role_assignments=None, + status=None, + approved_from=datetime.date.today().isoformat(), + approved_to=(datetime.date.today() + datetime.timedelta(days=30)).isoformat(), + always_on=True, + approved=100.0, + allocated=0.0, + cost=0.0, + amortised_cost=0.0, + total_cost=0.0, + remaining=0.0, + first_usage=None, + latest_usage=None, + desired_status_info=None, + abolished=False, + ) + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=True) + + result = api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-12", + amount=100.0, + date_from=date_from, + date_to=date_to, + allocate=False, + ) + + assert result.status_code == 200 + + api_calls.assert_subscription_status(client, expected_details=expected_details) + + # We will receive calls for the persistence, the approval + # and the change of desired state + mock_send_email.assert_any_call( + database, + constants.TEST_SUB_UUID, + "new_approval.html", + "New approval for your Azure subscription:", + EMAIL_TYPE_SUB_APPROVAL, + Approval( + allocate=False, + amount=100.0, + currency="GBP", + date_from=date_from, + date_to=date_to, + sub_id=constants.TEST_SUB_UUID, + ticket="T001-12", + ).dict(), + ) + + +def test_approval_wrong_currency(auth_app: FastAPI, mocker: MockerFixture) -> None: + date_from = datetime.date.today() + date_to = datetime.date.today() + datetime.timedelta(days=30) + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=True) + + result = api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-13", + amount=100.0, + currency="USD", + date_from=date_from, + date_to=date_to, + allocate=False, + ) + + assert result.status_code == 400 + assert ( + result.json()["detail"] + == "Other type of currency than GBP is not implemented yet." + ) + + +def test_multi_approvals(auth_app: FastAPI, mocker: MockerFixture) -> None: + date_from_1 = datetime.date.today() + date_to_1 = datetime.date.today() + datetime.timedelta(days=30) + + date_from_2 = datetime.date.today() + datetime.timedelta(days=2) + date_to_2 = datetime.date.today() + datetime.timedelta(days=40) + + expected_details = SubscriptionDetails( + subscription_id=constants.TEST_SUB_UUID, + name=None, + role_assignments=None, + status=None, + approved_from=date_from_1, + approved_to=date_to_2, + always_on=True, + approved=200.0, + allocated=0.0, + cost=0.0, + amortised_cost=0.0, + total_cost=0.0, + remaining=0.0, + first_usage=None, + latest_usage=None, + desired_status_info=None, + abolished=False, + ) + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=True) + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-12", + amount=100.0, + date_from=date_from_1, + date_to=date_to_1, + allocate=False, + ) + + result = api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-14", + amount=100.0, + date_from=date_from_2, + date_to=date_to_2, + allocate=False, + ) + + assert result.status_code == 200 + + api_calls.assert_subscription_status(client, expected_details=expected_details) + + +def test_approvals_overlap(auth_app: FastAPI, mocker: MockerFixture) -> None: + date_from_1 = datetime.date.today() + datetime.timedelta(days=2) + date_to_1 = datetime.date.today() + datetime.timedelta(days=40) + + date_from_2 = datetime.date.today() + datetime.timedelta(days=42) + date_to_2 = datetime.date.today() + datetime.timedelta(days=50) + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=True) + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-13", + amount=100.0, + date_from=date_from_1, + date_to=date_to_1, + allocate=False, + ) + + result = api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-14", + amount=100.0, + date_from=date_from_2, + date_to=date_to_2, + allocate=False, + ) + + assert ( + result.json()["detail"] + == f"Date from ({date_from_2.isoformat()}) should be equal or less than ({date_to_1.isoformat()})" + ) + + debug(result.json()) + + +def test_approvals_overlap2(auth_app: FastAPI, mocker: MockerFixture) -> None: + date_from_1 = datetime.date.today() + datetime.timedelta(days=2) + date_to_1 = datetime.date.today() + datetime.timedelta(days=40) + + date_from_2 = datetime.date.today() + datetime.timedelta(days=1) + date_to_2 = datetime.date.today() + datetime.timedelta(days=39) + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=True) + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-13", + amount=100.0, + date_from=date_from_1, + date_to=date_to_1, + allocate=False, + ) + + result = api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-14", + amount=100.0, + date_from=date_from_2, + date_to=date_to_2, + allocate=False, + ) + + assert ( + result.json()["detail"] + == f"Date to ({date_to_2.isoformat()}) should be equal or greater than ({date_to_1.isoformat()})" + ) + + debug(result.json()) + + +def test_negative_approval_overlap_min_max( + auth_app: FastAPI, mocker: MockerFixture +) -> None: + """Negative approvals must overlap the min and max dates of all approvals""" + date_from_1 = datetime.date.today() + date_to_1 = datetime.date.today() + datetime.timedelta(days=30) + + date_from_2 = datetime.date.today() + datetime.timedelta(days=2) + date_to_2 = datetime.date.today() + datetime.timedelta(days=40) + + date_from_3 = datetime.date.today() + datetime.timedelta(days=1) + date_to_3 = datetime.date.today() + datetime.timedelta(days=40) + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=True) + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-12", + amount=100.0, + date_from=date_from_1, + date_to=date_to_1, + allocate=False, + ) + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-14", + amount=100.0, + date_from=date_from_2, + date_to=date_to_2, + allocate=False, + ) + + result = api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-15", + amount=-50.0, + date_from=date_from_3, + date_to=date_to_3, + allocate=False, + ) + + assert result.status_code == 400 + + assert result.json()["detail"] == ( + f"Dates from and to ({date_from_3.isoformat()} - {date_to_3.isoformat()}) " + f"must align with the min-max ({date_from_1.isoformat()} - {date_to_3.isoformat()}) " + "approval period." + ) + debug(result.json()) + + +def test_negative_approval_too_large(auth_app: FastAPI, mocker: MockerFixture) -> None: + date_from_1 = datetime.date.today() + date_to_1 = datetime.date.today() + datetime.timedelta(days=30) + + date_from_2 = datetime.date.today() + datetime.timedelta(days=2) + date_to_2 = datetime.date.today() + datetime.timedelta(days=40) + + date_from_3 = datetime.date.today() + date_to_3 = datetime.date.today() + datetime.timedelta(days=40) + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=True) + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-12", + amount=100.0, + date_from=date_from_1, + date_to=date_to_1, + allocate=False, + ) + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-14", + amount=100.0, + date_from=date_from_2, + date_to=date_to_2, + allocate=False, + ) + + result = api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-15", + amount=-500.0, + date_from=date_from_3, + date_to=date_to_3, + allocate=False, + ) + + assert result.status_code == 400 + + debug(result.json()["detail"]) + assert ( + result.json()["detail"] + == "The amount of unused budget (200.0) is less than the negative allocation (500.0). Can only remove (200.0)." + ) + debug(result.json()) + + +def test_negative_approval_success(auth_app: FastAPI, mocker: MockerFixture) -> None: + date_from_1 = datetime.date.today() + date_to_1 = datetime.date.today() + datetime.timedelta(days=30) + + date_from_2 = datetime.date.today() + datetime.timedelta(days=2) + date_to_2 = datetime.date.today() + datetime.timedelta(days=40) + + date_from_3 = datetime.date.today() + date_to_3 = datetime.date.today() + datetime.timedelta(days=40) + + expected_details = SubscriptionDetails( + subscription_id=constants.TEST_SUB_UUID, + name=None, + role_assignments=None, + status=None, + approved_from=date_from_3, + approved_to=date_to_3, + always_on=True, + approved=150.0, + allocated=0.0, + cost=0.0, + amortised_cost=0.0, + total_cost=0.0, + remaining=0.0, + first_usage=None, + latest_usage=None, + desired_status_info=None, + abolished=False, + ) + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=True) + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-12", + amount=100.0, + date_from=date_from_1, + date_to=date_to_1, + allocate=False, + ) + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-14", + amount=100.0, + date_from=date_from_2, + date_to=date_to_2, + allocate=False, + ) + + result = api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-15", + amount=-50.0, + date_from=date_from_3, + date_to=date_to_3, + allocate=False, + ) + + assert result.status_code == 200 + api_calls.assert_subscription_status(client, expected_details=expected_details) + + # Check we get three records back from approvals + result = client.request( + "GET", PREFIX + "/approvals", json={"sub_id": str(constants.TEST_SUB_UUID)} + ) # type: ignore + + assert result.status_code == 200 + + result_dict = json.loads(result.content.decode("utf-8")) + + assert len(result_dict) == 3 + + +def test_post_approval_refreshes_desired_states( + auth_app: FastAPI, mocker: MockerFixture +) -> None: + with TestClient(auth_app) as client: + mock_refresh = AsyncMock() + mocker.patch( + "rctab.routers.accounting.approvals.refresh_desired_states", mock_refresh + ) + + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription( + client, constants.TEST_SUB_UUID + ).raise_for_status() + api_calls.set_persistence( + client, constants.TEST_SUB_UUID, always_on=True + ).raise_for_status() + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket="T001-12", + amount=100.0, + date_from=datetime.date.today(), + date_to=datetime.date.today() + datetime.timedelta(days=1), + allocate=False, + ).raise_for_status() + + # Posting an approval should have the side effect of + # refreshing the desired states + mock_refresh.assert_called_once_with( + constants.ADMIN_UUID, [constants.TEST_SUB_UUID] + ) diff --git a/tests/test_routes/test_cost_recovery.py b/tests/test_routes/test_cost_recovery.py new file mode 100644 index 0000000..7ab3a51 --- /dev/null +++ b/tests/test_routes/test_cost_recovery.py @@ -0,0 +1,712 @@ +import random +from datetime import date +from typing import Tuple +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest +from databases import Database +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from pytest_mock import MockerFixture +from sqlalchemy import insert, select + +from rctab.constants import ADMIN_OID +from rctab.crud import accounting_models +from rctab.crud.accounting_models import usage +from rctab.crud.schema import CostRecovery, Finance, Usage +from rctab.routers.accounting.cost_recovery import ( + CostRecoveryMonth, + calc_cost_recovery, + validate_month, +) +from rctab.routers.accounting.routes import PREFIX +from tests.test_routes import constants +from tests.test_routes.constants import ADMIN_DICT +from tests.test_routes.test_routes import test_db # pylint: disable=unused-import +from tests.test_routes.test_routes import create_subscription +from tests.test_routes.utils import no_rollback_test_db # pylint: disable=unused-import + + +def test_cost_recovery_app_route( + app_with_signed_billing_token: Tuple[FastAPI, str], + mocker: MockerFixture, +) -> None: + """Check we can cost-recover a month.""" + + auth_app, token = app_with_signed_billing_token + with TestClient(auth_app) as client: + + mock = AsyncMock() + mocker.patch("rctab.routers.accounting.cost_recovery.calc_cost_recovery", mock) + + recovery_period = CostRecoveryMonth(first_day=date(year=2001, month=1, day=1)) + result = client.post( + PREFIX + "/app-cost-recovery", + content=recovery_period.json(), + headers={"authorization": "Bearer " + token}, + ) + + mock.assert_called_once_with( + recovery_period, commit_transaction=True, admin=UUID(ADMIN_OID) + ) + + assert result.status_code == 200 + assert result.json() == { + "detail": "cost recovery calculated", + "status": "success", + } + + +def test_cost_recovery_cli_route( + auth_app: FastAPI, + mocker: MockerFixture, +) -> None: + """Check we can cost-recover a month.""" + + with TestClient(auth_app) as client: + + mock = AsyncMock() + mock.return_value = [] + mocker.patch("rctab.routers.accounting.cost_recovery.calc_cost_recovery", mock) + + recovery_period = CostRecoveryMonth(first_day=date(year=2001, month=1, day=1)) + result = client.post( + PREFIX + "/cli-cost-recovery", + content=recovery_period.json(), + ) + + mock.assert_called_once_with( + recovery_period, commit_transaction=True, admin=constants.ADMIN_UUID + ) + + assert result.status_code == 200 + assert result.json() == [] + + +def test_cost_recovery_cli_route_dry_run( + auth_app: FastAPI, + mocker: MockerFixture, +) -> None: + """Check we return the right values.""" + + with TestClient(auth_app) as client: + mock = AsyncMock() + mock.return_value = [] + mocker.patch("rctab.routers.accounting.cost_recovery.calc_cost_recovery", mock) + + recovery_period = CostRecoveryMonth(first_day=date(year=2001, month=1, day=1)) + result = client.request( + "GET", + PREFIX + "/cli-cost-recovery", + content=recovery_period.json(), + ) + + mock.assert_called_once_with( + recovery_period, commit_transaction=False, admin=constants.ADMIN_UUID + ) + + assert result.status_code == 200 + assert result.json() == [] + + +@pytest.mark.asyncio +async def test_cost_recovery_simple( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + """Check we can recover costs for a single finance row.""" + subscription_id = await create_subscription(test_db) + + for _ in range(2): + await test_db.execute( + usage.insert().values(), + Usage( + subscription_id=str(subscription_id), + id=str(UUID(int=random.randint(0, 2**32 - 1))), + total_cost=1, + invoice_section="", + date=date(2001, 1, 1), + ).dict(), + ) + + new_finance = Finance( + subscription_id=subscription_id, + ticket="test_ticket", + amount=1.5, + date_from="2001-01-01", + date_to="2001-01-31", + finance_code="test_finance", + priority=1, + ) + await test_db.execute( + insert(accounting_models.finance), {**ADMIN_DICT, **new_finance.dict()} + ) + cost_recovery_period = CostRecoveryMonth(first_day=date(year=2001, month=1, day=1)) + await calc_cost_recovery( + cost_recovery_period, commit_transaction=True, admin=constants.ADMIN_UUID + ) + + results = [ + dict(row) + for row in await test_db.fetch_all(select([accounting_models.cost_recovery])) + ] + + assert len(results) == 1 + + assert results[0]["subscription_id"] == subscription_id + # £2 used but only £1.50 is recoverable + assert results[0]["amount"] == 1.5 + assert results[0]["month"] == date(year=2001, month=1, day=1) + assert results[0]["finance_code"] == "test_finance" + + +@pytest.mark.asyncio +async def test_cost_recovery_two_finances( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + """Check we can recover costs where there are overlapping finance records.""" + subscription_id = await create_subscription(test_db) + + await test_db.execute( + usage.insert().values(), + Usage( + subscription_id=str(subscription_id), + id=str(UUID(int=random.randint(0, 2**32 - 1))), + total_cost=3, + invoice_section="", + date=date(2001, 1, 1), + ).dict(), + ) + + # Two finances in our period of interest and one outside + for date_from in ("2001-01-01", "2001-01-01", "2001-02-01"): + new_finance = Finance( + subscription_id=subscription_id, + ticket="test_ticket", + amount=1.0, + date_from=date_from, + date_to="2001-02-28", + finance_code="test_finance", + priority=1, + ) + await test_db.execute( + insert(accounting_models.finance), {**ADMIN_DICT, **new_finance.dict()} + ) + + cost_recovery_period = CostRecoveryMonth(first_day=date(year=2001, month=1, day=1)) + await calc_cost_recovery( + cost_recovery_period, commit_transaction=True, admin=constants.ADMIN_UUID + ) + + results = [ + dict(row) + for row in await test_db.fetch_all(select([accounting_models.cost_recovery])) + ] + + assert len(results) == 2 + + # Since we have two £1 finance rows, we expect to be billed for £2 + assert results[0]["amount"] == 1.0 + assert results[1]["amount"] == 1.0 + + +@pytest.mark.asyncio +async def test_cost_recovery_second_month( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + """todo""" + subscription_id = await create_subscription(test_db) + + # Jan + await test_db.execute( + usage.insert().values(), + Usage( + subscription_id=str(subscription_id), + id=str(UUID(int=random.randint(0, 2**32 - 1))), + total_cost=1, + invoice_section="", + date=date(2001, 1, 1), + ).dict(), + ) + + # Feb + await test_db.execute( + usage.insert().values(), + Usage( + subscription_id=str(subscription_id), + id=str(UUID(int=random.randint(0, 2**32 - 1))), + total_cost=2, + invoice_section="", + date=date(2001, 2, 1), + ).dict(), + ) + + # Jan - Feb + new_finance = Finance( + subscription_id=subscription_id, + ticket="test_ticket", + amount=2.0, + date_from="2001-01-01", + date_to="2001-02-28", + finance_code="test_finance", + priority=1, + ) + await test_db.execute( + insert(accounting_models.finance), {**ADMIN_DICT, **new_finance.dict()} + ) + + cost_recovery_period = CostRecoveryMonth(first_day=date(year=2001, month=1, day=1)) + await calc_cost_recovery( + cost_recovery_period, commit_transaction=True, admin=constants.ADMIN_UUID + ) + + cost_recovery_period = CostRecoveryMonth(first_day=date(year=2001, month=2, day=1)) + await calc_cost_recovery( + cost_recovery_period, commit_transaction=True, admin=constants.ADMIN_UUID + ) + + results = [ + dict(row) + for row in await test_db.fetch_all( + select([accounting_models.cost_recovery]).order_by( + accounting_models.cost_recovery.c.id + ) + ) + ] + + assert len(results) == 2 + + assert results[0]["amount"] == 1.0 + assert results[0]["month"] == date(year=2001, month=1, day=1) + + assert results[1]["amount"] == 1.0 + assert results[1]["month"] == date(year=2001, month=2, day=1) + + +@pytest.mark.asyncio +async def test_cost_recovery_two_subscriptions( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + """Check we can recover costs for two different subscriptions.""" + subscription_a = await create_subscription(test_db) + subscription_b = await create_subscription(test_db) + + # Jan + await test_db.execute( + usage.insert().values(), + Usage( + subscription_id=str(subscription_a), + id=str(UUID(int=random.randint(0, 2**32 - 1))), + total_cost=1, + invoice_section="", + date=date(2001, 1, 1), + ).dict(), + ) + + # Jan + await test_db.execute( + usage.insert().values(), + Usage( + subscription_id=str(subscription_b), + id=str(UUID(int=random.randint(0, 2**32 - 1))), + total_cost=2, + invoice_section="", + date=date(2001, 1, 1), + ).dict(), + ) + + # Jan - Feb + new_finance = Finance( + subscription_id=subscription_b, + ticket="test_ticket", + amount=2.0, + date_from="2001-01-01", + date_to="2001-02-28", + finance_code="test_finance", + priority=1, + ) + await test_db.execute( + insert(accounting_models.finance), {**ADMIN_DICT, **new_finance.dict()} + ) + + cost_recovery_period = CostRecoveryMonth(first_day=date(year=2001, month=1, day=1)) + await calc_cost_recovery( + cost_recovery_period, commit_transaction=True, admin=constants.ADMIN_UUID + ) + + results = [ + dict(row) + for row in await test_db.fetch_all( + select([accounting_models.cost_recovery]).order_by( + accounting_models.cost_recovery.c.id + ) + ) + ] + + assert len(results) == 1 + + assert results[0]["subscription_id"] == subscription_b + assert results[0]["amount"] == 2.0 + assert results[0]["month"] == date(year=2001, month=1, day=1) + + +@pytest.mark.asyncio +async def test_cost_recovery_priority_one_month( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + """Check we can recover costs where there are overlapping finance records.""" + subscription_id = await create_subscription(test_db) + + await test_db.execute( + usage.insert().values(), + Usage( + subscription_id=str(subscription_id), + id=str(UUID(int=random.randint(0, 2**32 - 1))), + total_cost=3, + invoice_section="", + date=date(2001, 1, 1), + ).dict(), + ) + + # Two finances in our period of interest and one outside + for finance_code, priority in [("f-2", 100), ("f-1", 99)]: + new_finance = Finance( + subscription_id=subscription_id, + ticket="test_ticket", + amount=2.0, + date_from="2001-01-01", + date_to="2001-02-28", + finance_code=finance_code, + priority=priority, + ) + await test_db.execute( + insert(accounting_models.finance), {**ADMIN_DICT, **new_finance.dict()} + ) + + cost_recovery_period = CostRecoveryMonth(first_day=date(year=2001, month=1, day=1)) + await calc_cost_recovery( + cost_recovery_period, commit_transaction=True, admin=constants.ADMIN_UUID + ) + + results = [ + dict(row) + for row in await test_db.fetch_all( + select([accounting_models.cost_recovery]).order_by( + accounting_models.cost_recovery.c.finance_code + ) + ) + ] + + assert len(results) == 2 + + assert results[0]["finance_code"] == "f-1" + assert results[0]["amount"] == 2.0 + + assert results[1]["finance_code"] == "f-2" + assert results[1]["amount"] == 1.0 + + +@pytest.mark.asyncio +async def test_cost_recovery_priority_two_months( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + """Check we can recover costs where there are overlapping finance records.""" + subscription_id = await create_subscription(test_db) + + await test_db.execute( + usage.insert().values(), + Usage( + subscription_id=str(subscription_id), + id=str(UUID(int=random.randint(0, 2**32 - 1))), + total_cost=3, + invoice_section="", + date=date(2001, 1, 1), + ).dict(), + ) + + # Two finances in our period of interest and one outside + for finance_code, priority in [("f-2", 100), ("f-1", 99)]: + new_finance = Finance( + subscription_id=subscription_id, + ticket="test_ticket", + amount=2.0, + date_from="2001-01-01", + date_to="2001-02-28", + finance_code=finance_code, + priority=priority, + ) + await test_db.execute( + insert(accounting_models.finance), {**ADMIN_DICT, **new_finance.dict()} + ) + + cost_recovery_period = CostRecoveryMonth(first_day=date(year=2001, month=1, day=1)) + await calc_cost_recovery( + cost_recovery_period, commit_transaction=True, admin=constants.ADMIN_UUID + ) + + results = [ + dict(row) + for row in await test_db.fetch_all( + select([accounting_models.cost_recovery]).order_by( + accounting_models.cost_recovery.c.finance_code + ) + ) + ] + + assert len(results) == 2 + + assert results[0]["finance_code"] == "f-1" + assert results[0]["amount"] == 2.0 + + assert results[1]["finance_code"] == "f-2" + assert results[1]["amount"] == 1.0 + + +@pytest.mark.asyncio +async def test_cost_recovery_validates( + mocker: MockerFixture, + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + """Check that we validate the month being recovered.""" + mock_validate = mocker.Mock() + mocker.patch("rctab.routers.accounting.cost_recovery.validate_month", mock_validate) + + cost_recovery_month = CostRecoveryMonth(first_day="2001-01-01") + + await calc_cost_recovery(cost_recovery_month, True, constants.ADMIN_UUID) + + mock_validate.assert_called_once_with(cost_recovery_month, None) + + twenty_ten = date.fromisoformat("2010-10-01") + + await test_db.execute( + insert(accounting_models.cost_recovery_log), + {**ADMIN_DICT, "month": date.fromisoformat("2010-08-01")}, + ) + await test_db.execute( + insert(accounting_models.cost_recovery_log), {**ADMIN_DICT, "month": twenty_ten} + ) + await test_db.execute( + insert(accounting_models.cost_recovery_log), + {**ADMIN_DICT, "month": date.fromisoformat("2010-09-01")}, + ) + + await calc_cost_recovery(cost_recovery_month, True, constants.ADMIN_UUID) + + mock_validate.assert_called_with( + cost_recovery_month, CostRecoveryMonth(first_day=twenty_ten) + ) + + +@pytest.mark.asyncio +async def test_cost_recovery_commit_param( + no_rollback_test_db: Database, # pylint: disable=redefined-outer-name + mocker: MockerFixture, +) -> None: + """Check the return value when we do a dry-run.""" + subscription_a = await create_subscription(no_rollback_test_db) + + # Jan + await no_rollback_test_db.execute( + usage.insert().values(), + Usage( + subscription_id=str(subscription_a), + id=str(UUID(int=random.randint(0, 2**32 - 1))), + total_cost=1, + invoice_section="", + date=date(2001, 1, 1), + ).dict(), + ) + + # Jan - Feb + new_finance = Finance( + subscription_id=subscription_a, + ticket="test_ticket", + amount=2.0, + date_from="2001-01-01", + date_to="2001-02-28", + finance_code="test_finance", + priority=1, + ) + new_finance_id = await no_rollback_test_db.execute( + insert(accounting_models.finance), {**ADMIN_DICT, **new_finance.dict()} + ) + + cost_recovery_period = CostRecoveryMonth(first_day=date(year=2001, month=1, day=1)) + + mocker.patch( + "rctab.routers.accounting.cost_recovery.database", new=no_rollback_test_db + ) + cost_recoveries = await calc_cost_recovery( + cost_recovery_period, commit_transaction=False, admin=constants.ADMIN_UUID + ) + + assert cost_recoveries == [ + CostRecovery( + subscription_id=subscription_a, + finance_id=new_finance_id, + month="2001-01-01", + finance_code="test_finance", + amount=1, + ) + ] + + results = [ + dict(row) + for row in await no_rollback_test_db.fetch_all( + select([accounting_models.cost_recovery]) + ) + ] + + # Since we used commit_transaction=False, we expect the table to be empty + assert len(results) == 0 + + results = [ + dict(row) + for row in await no_rollback_test_db.fetch_all( + select([accounting_models.cost_recovery_log]) + ) + ] + + # Since we used commit_transaction=False, we expect the table to be empty + assert len(results) == 0 + + +class PretendTimeoutError(Exception): + pass + + +@pytest.mark.asyncio +async def test_cost_recovery_rollsback( + no_rollback_test_db: Database, # pylint: disable=redefined-outer-name + mocker: MockerFixture, +) -> None: + """Check that we roll back if disconnected.""" + + subscription_a = await create_subscription(no_rollback_test_db) + + # Jan + await no_rollback_test_db.execute( + usage.insert().values(), + Usage( + subscription_id=str(subscription_a), + id=str(UUID(int=random.randint(0, 2**32 - 1))), + total_cost=1, + invoice_section="", + date=date(2001, 1, 1), + ).dict(), + ) + + # Jan - Feb + new_finance = Finance( + subscription_id=subscription_a, + ticket="test_ticket", + amount=2.0, + date_from="2001-01-01", + date_to="2001-02-28", + finance_code="test_finance", + priority=1, + ) + await no_rollback_test_db.execute( + insert(accounting_models.finance), {**ADMIN_DICT, **new_finance.dict()} + ) + + cost_recovery_period = CostRecoveryMonth(first_day=date(year=2001, month=1, day=1)) + + # Patch CostRecovery as a hack to check error handling + # (transaction should be rolled back and an exception raised) + mock = mocker.Mock() + mock.side_effect = PretendTimeoutError + mocker.patch("rctab.routers.accounting.cost_recovery.CostRecovery", mock) + + with pytest.raises(PretendTimeoutError): + mocker.patch( + "rctab.routers.accounting.cost_recovery.database", no_rollback_test_db + ) + await calc_cost_recovery( + cost_recovery_period, commit_transaction=True, admin=constants.ADMIN_UUID + ) + + results = [ + dict(row) + for row in await no_rollback_test_db.fetch_all( + select([accounting_models.cost_recovery]) + ) + ] + + # Since we used commit_transaction=False, we expect the table to be empty + assert len(results) == 0 + + +@pytest.mark.asyncio +async def test_cost_recovery_log( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + """Check that we can only recover a month once.""" + # pylint: disable=unused-argument + cost_recovery_period = CostRecoveryMonth(first_day=date(year=2000, month=1, day=2)) + + await calc_cost_recovery( + cost_recovery_period, commit_transaction=True, admin=constants.ADMIN_UUID + ) + + # We can dry-run previous months... + await calc_cost_recovery( + cost_recovery_period, commit_transaction=False, admin=constants.ADMIN_UUID + ) + + # ...but we mustn't commit + with pytest.raises(HTTPException) as exception_info: + await calc_cost_recovery( + cost_recovery_period, commit_transaction=True, admin=constants.ADMIN_UUID + ) + + assert exception_info.value.detail == "Expected 2000-02" + assert exception_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_validate_month(mocker: MockerFixture) -> None: + """Test our date validation function.""" + + # Any old month can be the first month so this shouldn't raise + cost_recovery_period = CostRecoveryMonth(first_day=date(year=2001, month=1, day=1)) + validate_month(cost_recovery_period, None) + + mock_date = mocker.Mock(wraps=date) + mock_date.today.return_value = date.fromisoformat("2001-06-01") + mocker.patch("rctab.routers.accounting.cost_recovery.date", mock_date) + + # We shouldn't recover the current month, as the usage isn't finalised... + cost_recovery_period = CostRecoveryMonth(first_day=date.fromisoformat("2001-06-01")) + with pytest.raises(HTTPException) as exception_info: + validate_month(cost_recovery_period, None) + + assert exception_info.value.detail == "Cannot recover later than 2001-05" + assert exception_info.value.status_code == 400 + + # ...even if it's next in line... + cost_recovery_period = CostRecoveryMonth(first_day="2001-06-01") + last_recovered_month = CostRecoveryMonth(first_day="2001-05-01") + with pytest.raises(HTTPException) as exception_info: + validate_month(cost_recovery_period, last_recovered_month) + + assert exception_info.value.detail == "Cannot recover later than 2001-05" + assert exception_info.value.status_code == 400 + + # ...nor future months... + cost_recovery_period = CostRecoveryMonth(first_day="2001-07-01") + with pytest.raises(HTTPException) as exception_info: + validate_month(cost_recovery_period, None) + + assert exception_info.value.detail == "Cannot recover later than 2001-05" + assert exception_info.value.status_code == 400 + + # ...nor any month other than the next un-recovered month. + cost_recovery_period = CostRecoveryMonth(first_day="2001-03-01") + last_recovered_month = CostRecoveryMonth(first_day="2001-01-01") + with pytest.raises(HTTPException) as exception_info: + validate_month(cost_recovery_period, last_recovered_month) + + assert exception_info.value.detail == "Expected 2001-02" + assert exception_info.value.status_code == 400 diff --git a/tests/test_routes/test_daily_routine_tasks.py b/tests/test_routes/test_daily_routine_tasks.py new file mode 100644 index 0000000..65958ec --- /dev/null +++ b/tests/test_routes/test_daily_routine_tasks.py @@ -0,0 +1,183 @@ +import random +from datetime import date, datetime, time, timedelta, timezone +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest +from databases import Database +from pytest_mock import MockerFixture +from sqlalchemy import insert + +from rctab.constants import EMAIL_TYPE_SUMMARY +from rctab.crud.accounting_models import emails, subscription +from rctab.daily_routine_tasks import ( + calc_how_long_to_sleep_for, + get_timestamp_last_summary_email, + routine_tasks, + send_summary_email, +) +from tests.test_routes import constants +from tests.test_routes.test_abolishment import create_expired_subscription +from tests.test_routes.test_routes import ( # pylint: disable=unused-import # noqa + create_subscription, + test_db, +) + + +@pytest.mark.asyncio +async def test_daily_task_loop( + test_db: Database, # pylint: disable=redefined-outer-name # noqa + mocker: MockerFixture, +) -> None: + class BreakLoopException(Exception): + pass + + # The summary email background task exists immediately if there are no recipients + mock_settings = mocker.patch("rctab.daily_routine_tasks.get_settings") + mock_settings.return_value.admin_email_recipients = ["me@my.org"] + + mock_sleep = AsyncMock(side_effect=BreakLoopException) + mocker.patch("rctab.daily_routine_tasks.sleep", mock_sleep) + + mock_send = AsyncMock() + mocker.patch("rctab.daily_routine_tasks.send_summary_email", mock_send) + + mock_abolish_send = mocker.patch( + "rctab.routers.accounting.abolishment.send_with_sendgrid" + ) + mock_abolish_send.return_value = 200 + + mocker.patch("rctab.daily_routine_tasks.ADMIN_OID", str(constants.ADMIN_UUID)) + + mock_now = mocker.patch("rctab.daily_routine_tasks.datetime_utcnow") + mock_now.return_value = datetime.combine( + date.today(), time(15, 59, 0), timezone.utc + ) + + subscription_id = await create_subscription(test_db) + + await create_expired_subscription(test_db) + + try: + await routine_tasks() + except BreakLoopException: + pass + + # Since we've "woken" 10 seconds before the email is due to be sent, + # we don't send email yet + mock_send.assert_not_called() + + mock_now.return_value = datetime.combine(date.today(), time(16, 1, 0), timezone.utc) + + try: + await routine_tasks() + except BreakLoopException: + pass + + # Since we've "woken" a whole 3601 seconds before the email is due to be sent, + # we should go back to sleep + mock_send.assert_called_once() + + insert_statement = insert(emails).values( + { + "subscription_id": subscription_id, + "status": 200, + "type": EMAIL_TYPE_SUMMARY, + "recipients": ";".join(["me@mail"]), + # "time_created": datetime.now() + } + ) + await test_db.execute(insert_statement) + + mock_now.return_value = datetime.combine(date.today(), time(16, 2, 0), timezone.utc) + + try: + await routine_tasks() + except BreakLoopException: + pass + + # We've already sent an email on this day, so we don't want to send another one. + mock_send.assert_called_once() + + mock_now.return_value = datetime.combine( + date.today(), time(16, 2, 0), timezone.utc + ) + timedelta(days=1) + + try: + await routine_tasks() + except BreakLoopException: + pass + + # We last sent an email yesterday so expect another + assert mock_send.call_count == 2 + + +def test_calc_how_long_to_sleep_for(mocker: MockerFixture) -> None: + mock_now = mocker.patch("rctab.daily_routine_tasks.datetime_utcnow") + mock_now.return_value = datetime.combine(date.today(), time(15, 0, 0), timezone.utc) + assert calc_how_long_to_sleep_for("16:00:00") == 3600 + assert calc_how_long_to_sleep_for("14:00:00") == 3600 * 23 + + +def test_calc_how_long_to_sleep_floor(mocker: MockerFixture) -> None: + mock_now = mocker.patch("rctab.daily_routine_tasks.datetime_utcnow") + mock_now.return_value = datetime.combine( + date.today(), time(11, 59, 59, 500000), timezone.utc + ) + #  We never want to sleep for less than one second + assert calc_how_long_to_sleep_for("12:00:00") == 1 + + +@pytest.mark.asyncio +async def test_get_timestamp_last_summary_email( + test_db: Database, # pylint: disable=redefined-outer-name # noqa +) -> None: + test_subscription_id = UUID(int=random.randint(0, (2**32) - 1)) + await test_db.execute( + subscription.insert().values(), + dict( + admin=str(constants.ADMIN_UUID), + subscription_id=str(test_subscription_id), + time_created=datetime.now(timezone.utc), + ), + ) + time_created = datetime.now(timezone.utc) - timedelta(seconds=10) + time_last_summary = await get_timestamp_last_summary_email() + assert not time_last_summary + await test_db.execute( + emails.insert().values(), + dict( + subscription_id=str(test_subscription_id), + status=1, # don't know what status means in this context or what possible values are + type=EMAIL_TYPE_SUMMARY, + recipients="Some happy email recipient", + time_created=time_created, + ), + ) + + time_last_summary = await get_timestamp_last_summary_email() + assert time_last_summary == time_created + + +@pytest.mark.asyncio +async def test_send_summary_email( + mocker: MockerFixture, + test_db: Database, # pylint: disable=redefined-outer-name # noqa +) -> None: + # pylint: disable=unused-argument + mock_prepare = AsyncMock() + mock_prepare.return_value = {"mock new subs": "return_value"} + mocker.patch("rctab.daily_routine_tasks.prepare_summary_email", mock_prepare) + + email_recipients = ["test@test.com"] + + mock_send = mocker.patch("rctab.daily_routine_tasks.send_with_sendgrid") + + await send_summary_email(email_recipients) + + mock_send.assert_called_with( + "Daily summary", + "daily_summary.html", + {"mock new subs": "return_value"}, + email_recipients, + ) diff --git a/tests/test_routes/test_desired_states.py b/tests/test_routes/test_desired_states.py new file mode 100644 index 0000000..92776cf --- /dev/null +++ b/tests/test_routes/test_desired_states.py @@ -0,0 +1,471 @@ +from datetime import date, timedelta +from typing import Tuple +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest +from databases import Database +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pytest_mock import MockerFixture +from sqlalchemy import select + +from rctab.crud.accounting_models import refresh_materialised_view +from rctab.crud.accounting_models import status as status_table +from rctab.crud.accounting_models import usage as usage_table +from rctab.crud.accounting_models import usage_view +from rctab.crud.models import database +from rctab.crud.schema import BillingStatus, DesiredState, SubscriptionState +from rctab.routers.accounting import desired_states +from rctab.routers.accounting.desired_states import refresh_desired_states +from rctab.routers.accounting.routes import PREFIX, get_subscriptions_summary +from tests.test_routes import api_calls, constants +from tests.test_routes.test_routes import ( # pylint: disable=unused-import + create_subscription, + test_db, +) + +# pylint: disable=redefined-outer-name + +date_from = date.today() +date_to = date.today() + timedelta(days=30) +TICKET = "T001-12" + + +@pytest.mark.asyncio +async def test_desired_states_budget_adjustment_applied( + test_db: Database, + mocker: MockerFixture, +) -> None: + approved = 100 + allocated = 80 + usage = 50 + + expired_sub_id = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + approved=(approved, date.today()), + allocated_amount=allocated, + spent=(usage, 0), + ) + + mock_send_emails = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_emails + ) + + await refresh_desired_states(constants.ADMIN_UUID, [expired_sub_id]) + + desired_state_rows = await test_db.fetch_all(select([status_table])) + row_dicts = [dict(row) for row in desired_state_rows] + + # The subscription expired today + assert len(row_dicts) == 1 + assert row_dicts[0]["reason"] == BillingStatus.EXPIRED + + sub_summary = await test_db.fetch_one( + get_subscriptions_summary(sub_id=expired_sub_id, execute=False) + ) + + # approved and allocation should match usage + assert sub_summary["approved"] == usage # type: ignore + assert sub_summary["allocated"] == usage # type: ignore + + +@pytest.mark.asyncio +async def test_desired_states_budget_adjustment_approved_ignored( + test_db: Database, + mocker: MockerFixture, +) -> None: + approved = 100 + allocated = 80 + usage = 90 + + expired_sub_id = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + approved=(approved, date.today()), + allocated_amount=allocated, + spent=(usage, 0), + ) + + mock_send_emails = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_emails + ) + + await refresh_desired_states(constants.ADMIN_UUID, [expired_sub_id]) + + desired_state_rows = await test_db.fetch_all(select([status_table])) + row_dicts = [dict(row) for row in desired_state_rows] + + # The subscription expired today + assert len(row_dicts) == 1 + assert row_dicts[0]["reason"] == BillingStatus.OVER_BUDGET_AND_EXPIRED + + sub_summary = await test_db.fetch_one( + get_subscriptions_summary(sub_id=expired_sub_id, execute=False) + ) + + # approved and allocation should match usage + assert sub_summary["approved"] == usage # type: ignore + assert sub_summary["allocated"] == allocated # type: ignore + + +@pytest.mark.asyncio +async def test_desired_states_budget_adjustment_ignored( + test_db: Database, + mocker: MockerFixture, +) -> None: + approved = 100 + allocated = 80 + + expired_sub_id = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + approved=(approved, date.today()), + allocated_amount=allocated, + spent=(110, 0), + ) + + mock_send_emails = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_emails + ) + + await refresh_desired_states(constants.ADMIN_UUID, [expired_sub_id]) + + desired_state_rows = await test_db.fetch_all(select([status_table])) + row_dicts = [dict(row) for row in desired_state_rows] + + # The subscription expired today + assert len(row_dicts) == 1 + assert row_dicts[0]["reason"] == BillingStatus.OVER_BUDGET_AND_EXPIRED + + sub_summary = await test_db.fetch_one( + get_subscriptions_summary(sub_id=expired_sub_id, execute=False) + ) + + # approved and allocation should match usage + assert sub_summary["approved"] == approved # type: ignore + assert sub_summary["allocated"] == allocated # type: ignore + + +def test_desired_states_disabled( + app_with_signed_status_and_controller_tokens: Tuple[FastAPI, str, str], + mocker: MockerFixture, +) -> None: + ( + auth_app, + status_token, + controller_token, + ) = app_with_signed_status_and_controller_tokens + + sub_ids = [UUID(int=0), UUID(int=1), UUID(int=2)] + with TestClient(auth_app) as client: + mock_send_emails = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_emails + ) + + # The first subscription has active==True and the second + # has no subscription_details row, so we only expect the third to be returned + + for sub_id in sub_ids: + api_calls.create_subscription( + client, subscription_id=sub_id + ).raise_for_status() + + # This subscription has no approved_to date so should be disabled + # for being expired + api_calls.create_subscription_detail( + client, + status_token, + subscription_id=UUID(int=2), + state=SubscriptionState("Enabled"), + ).raise_for_status() + + mock_refresh = AsyncMock() + mocker.patch( + "rctab.routers.accounting.desired_states.refresh_desired_states", + mock_refresh, + ) + + response = client.get( + PREFIX + "/desired-states", + headers={"authorization": "Bearer " + controller_token}, + ) + + # Getting the desired states should have the side effect of + # refreshing the desired states + mock_refresh.assert_called_once_with(UUID(desired_states.ADMIN_OID)) + + assert response.status_code == 200 + + expected = [ + DesiredState( + subscription_id=str(UUID(int=2)), + desired_state=SubscriptionState("Disabled"), + ) + ] + actual = [DesiredState(**x) for x in response.json()] + assert actual == expected + + +def test_desired_states_enabled( + app_with_signed_status_and_controller_tokens: Tuple[FastAPI, str, str], + mocker: MockerFixture, +) -> None: + ( + auth_app, + status_token, + controller_token, + ) = app_with_signed_status_and_controller_tokens + + with TestClient(auth_app) as client: + mock_send_emails = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_emails + ) + + expected = [] + + # Both of these are disabled but should be enabled + for subscription_id, subscription_state in [ + (UUID(int=4), SubscriptionState("Disabled")), + (UUID(int=5), SubscriptionState("Expired")), + (UUID(int=6), SubscriptionState("Warned")), + ]: + api_calls.create_subscription( + client, subscription_id=subscription_id + ).raise_for_status() + + api_calls.create_approval( + client, + subscription_id=subscription_id, + amount=100, + date_from=date_from, + date_to=date_to, + ticket=TICKET, + allocate=True, + currency="GBP", + ).raise_for_status() + + api_calls.create_subscription_detail( + client, + token=status_token, + subscription_id=subscription_id, + state=subscription_state, + ).raise_for_status() + + response = client.get( + PREFIX + "/desired-states", + headers={"authorization": "Bearer " + controller_token}, + ) + + assert response.status_code == 200 + + expected.append( + DesiredState( + subscription_id=subscription_id, + desired_state=SubscriptionState("Enabled"), + ) + ) + + actual = [DesiredState(**x) for x in response.json()] + assert len(actual) == len(expected) + assert set(actual) == set(expected) + + +@pytest.mark.asyncio +async def test_refresh_sends_disabled_emails( + test_db: Database, mocker: MockerFixture +) -> None: + over_budget_sub_id = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + approved=(100.0, date.today() + timedelta(days=-1)), + allocated_amount=100, + spent=(1.0, 0), + ) + + mock_send_emails = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_emails + ) + + await refresh_desired_states(constants.ADMIN_UUID, [over_budget_sub_id]) + + # We should email each disabled subscription + mock_send_emails.assert_called_with( + database, + over_budget_sub_id, + "will_be_disabled.html", + "We will turn off your Azure subscription:", + "subscription disabled", + {"reason": BillingStatus.EXPIRED}, + ) + + +@pytest.mark.asyncio +async def test_refresh_sends_enabled_emails( + test_db: Database, mocker: MockerFixture +) -> None: + within_budget_sub_id = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Disabled"), + approved=(100.0, date.today() + timedelta(days=1)), + allocated_amount=100, + spent=(99.0, 0), + ) + + mock_send_emails = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_emails + ) + + await refresh_desired_states(constants.ADMIN_UUID, [within_budget_sub_id]) + + # We should email each disabled subscription + mock_send_emails.assert_called_with( + database, + within_budget_sub_id, + "will_be_enabled.html", + "We will turn on your Azure subscription:", + "subscription enabled", + {}, + ) + + +@pytest.mark.asyncio +async def test_refresh_reason_changes(test_db: Database, mocker: MockerFixture) -> None: + """We should update the reason for disabling if that reason changes.""" + expired_sub_id = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Disabled"), + approved=(100.0, date.today()), + allocated_amount=100, + spent=(0.0, 0), + ) + + mock_send_emails = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_emails + ) + + await refresh_desired_states(constants.ADMIN_UUID, [expired_sub_id]) + + # We should email each disabled subscription + mock_send_emails.assert_called_with( + database, + expired_sub_id, + "will_be_disabled.html", + "We will turn off your Azure subscription:", + "subscription disabled", + {"reason": BillingStatus.EXPIRED}, + ) + assert mock_send_emails.call_count == 1 + + desired_state_rows = await test_db.fetch_all(select([status_table])) + row_dicts = [dict(row) for row in desired_state_rows] + + # The subscription expired today + assert len(row_dicts) == 1 + assert row_dicts[0]["reason"] == BillingStatus.EXPIRED + + await test_db.execute( + usage_table.insert().values(), + dict( + subscription_id=str(expired_sub_id), + id=str(UUID(int=7)), + total_cost=101, + invoice_section="", + date=date.today(), + ), + ) + await refresh_materialised_view(test_db, usage_view) + + await refresh_desired_states(constants.ADMIN_UUID, [expired_sub_id]) + + # If the reason for disabling changes, we don't want to send an email + assert mock_send_emails.call_count == 1 + + row_dicts = [ + dict(row) + for row in await test_db.fetch_all( + select([status_table]).order_by(status_table.c.time_created) + ) + ] + + # We should have a new row showing that there are two reasons + assert len(row_dicts) == 2 + assert row_dicts[1]["reason"] == BillingStatus.OVER_BUDGET_AND_EXPIRED + + +@pytest.mark.asyncio +async def test_refresh_reason_stays_the_same( + test_db: Database, mocker: MockerFixture +) -> None: + """Multiple calls shouldn't insert extra rows.""" + + expired_sub_id = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Disabled"), + approved=(100.0, date.today() - timedelta(days=1)), + allocated_amount=100, + spent=(0.0, 0), + ) + + mock_send_emails = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_emails + ) + + await refresh_desired_states(constants.ADMIN_UUID, [expired_sub_id]) + + desired_state_rows = await test_db.fetch_all(select([status_table])) + row_dicts = [dict(row) for row in desired_state_rows] + assert len(row_dicts) == 1 + assert row_dicts[0]["reason"] == BillingStatus.EXPIRED + + await refresh_desired_states(constants.ADMIN_UUID, [expired_sub_id]) + + desired_state_rows = await test_db.fetch_all( + select([status_table]).order_by(status_table.c.time_created) + ) + row_dicts = [dict(row) for row in desired_state_rows] + assert len(row_dicts) == 1 + + +@pytest.mark.asyncio +async def test_small_tolerance(test_db: Database, mocker: MockerFixture) -> None: + """Check that we allow subscriptions to go 0.001p over budget.""" + # pylint: disable=singleton-comparison + close_to_budget_sub_id = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + approved=(100.0, date.today() + timedelta(days=1)), + allocated_amount=100, + spent=(100.001, 0), + ) + + mock_send_emails = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_emails + ) + + await refresh_desired_states(constants.ADMIN_UUID, [close_to_budget_sub_id]) + + desired_state_rows = await test_db.fetch_all( + select([status_table]).where(status_table.c.reason == None) + ) + row_dicts = [dict(row) for row in desired_state_rows] + assert len(row_dicts) == 1 diff --git a/tests/test_routes/test_email_templates.py b/tests/test_routes/test_email_templates.py new file mode 100644 index 0000000..2e2f136 --- /dev/null +++ b/tests/test_routes/test_email_templates.py @@ -0,0 +1,583 @@ +# import random +import random +from datetime import date, datetime, timedelta, timezone +from typing import Any, AsyncGenerator, Dict, Generator +from uuid import UUID + +import pytest +from databases import Database +from jinja2 import Environment, PackageLoader, StrictUndefined + +from rctab.constants import EMAIL_TYPE_SUB_WELCOME, EMAIL_TYPE_USAGE_ALERT +from rctab.crud import accounting_models +from rctab.crud.schema import ( + Allocation, + Approval, + BillingStatus, + Finance, + RoleAssignment, + SubscriptionState, + SubscriptionStatus, +) +from rctab.routers.accounting import send_emails +from rctab.routers.accounting.routes import get_subscriptions_summary +from tests.test_routes.constants import ADMIN_DICT, ADMIN_UUID +from tests.test_routes.test_routes import ( # pylint: disable=unused-import + create_subscription, + test_db, +) + +# pylint: disable=redefined-outer-name +# pylint: disable=unexpected-keyword-arg + + +@pytest.fixture() +async def subscription_summary( + test_db: Database, +) -> AsyncGenerator[Dict[str, Any], None]: + subscription_id = await create_subscription( + db=test_db, # type: ignore + current_state=SubscriptionState("Enabled"), + allocated_amount=300, + approved=(400, date.today()), + spent=(148, 0), + ) + + sub_summary = await test_db.fetch_one( + get_subscriptions_summary(sub_id=subscription_id, execute=False) + ) + yield dict(sub_summary) # type: ignore + + +@pytest.fixture() +def jinja2_environment() -> Generator[Environment, None, None]: + yield Environment( + loader=PackageLoader("rctab", "templates/emails"), undefined=StrictUndefined + ) + + +@pytest.mark.asyncio +async def test_welcome_emails_render( + subscription_summary: Dict[str, Any], + jinja2_environment: Environment, +) -> None: + template_data = { + "summary": subscription_summary, + "rctab_url": None, + } + + template_name = "welcome.html" + template = jinja2_environment.get_template(template_name) + + html = template.render(**template_data) + + with open( + "rctab/templates/emails/" + "rendered_" + template_name, + mode="w", + encoding="utf-8", + ) as output_file: + output_file.write(html) + + +@pytest.mark.asyncio +async def test_welcome_emails_render_with_url( + subscription_summary: Dict[str, Any], + jinja2_environment: Environment, +) -> None: + template_data = {"summary": subscription_summary, "rctab_url": "https://test"} + + template_name = "welcome.html" + template = jinja2_environment.get_template(template_name) + + html = template.render(**template_data) + + with open( + "rctab/templates/emails/" + "rendered_with_url_" + template_name, + mode="w", + encoding="utf-8", + ) as output_file: + output_file.write(html) + + +@pytest.mark.asyncio +async def test_status_emails_render( + test_db: Database, + subscription_summary: Dict[str, Any], + jinja2_environment: Environment, +) -> None: + """Render made up examples of status change and role assignment change emails.""" + subscription_id = subscription_summary["subscription_id"] + + old_role_assignments = ( + RoleAssignment( + role_definition_id="123", + role_name="Sous chef", + principal_id="456", + display_name="Max Mustermann", + mail="max.mustermann@domain.com", + scope="some/scope/string", + ), + RoleAssignment( + role_definition_id="667", + role_name="Animal trainer", + principal_id="777", + display_name="Tammy Lion", + mail="tl@tl.com", + scope="some/scope/string", + ), + RoleAssignment( + role_definition_id="669", + role_name="Acrobat", + principal_id="778", + display_name="Jack Donut", + mail="jd@jd.com", + scope="some/scope/string", + ), + ) + + new_role_assignments = ( + RoleAssignment( + role_definition_id="666", + role_name="Circus director", + principal_id="776", + display_name="Tommy Thompson", + mail="tt@tt.com", + scope="some/scope/string", + ), + RoleAssignment( + role_definition_id="667", + role_name="Animal trainer", + principal_id="777", + display_name="Tammy Lion", + mail="tl@tl.com", + scope="some/scope/string", + ), + RoleAssignment( + role_definition_id="668", + role_name="Clown", + principal_id="778", + display_name="Jack Donut", + mail="jd@jd.com", + scope="some/scope/string", + ), + ) + + old_status = SubscriptionStatus( + subscription_id=subscription_id, + display_name="old display name", + state=SubscriptionState("Disabled"), + role_assignments=old_role_assignments, + ) + + new_status = SubscriptionStatus( + subscription_id=subscription_id, + display_name=subscription_summary["name"], + state=SubscriptionState("Enabled"), + role_assignments=new_role_assignments, + ) + + status_kwargs = send_emails.prepare_subscription_status_email( + test_db, new_status, old_status + ) + + status_template_data = status_kwargs["template_data"] + status_template_data["summary"] = subscription_summary + status_template_data["rctab_url"] = None + status_template_name = "status_change.html" + status_template = jinja2_environment.get_template(status_template_name) + status_html = status_template.render(**status_template_data) + # There must be a better way to do this... + with open( + "rctab/templates/emails/" + "rendered_" + status_template_name, + mode="w", + encoding="utf-8", + ) as output_file: + output_file.write(status_html) + + roles_kwargs = send_emails.prepare_roles_email(test_db, new_status, old_status) + roles_template_data = roles_kwargs["template_data"] + roles_template_data["summary"] = subscription_summary + roles_template_data["rctab_url"] = None + roles_template_name = "role_assignment_change.html" + roles_template = jinja2_environment.get_template(roles_template_name) + roles_html = roles_template.render(**roles_template_data) + # There must be a better way to do this... + with open( + "rctab/templates/emails/" + "rendered_" + roles_template_name, + mode="w", + encoding="utf-8", + ) as output_file: + output_file.write(roles_html) + + +@pytest.mark.asyncio +async def test_allocation_emails_render( + subscription_summary: Dict[str, Any], jinja2_environment: Environment +) -> None: + jinja2_environment = Environment( + loader=PackageLoader("rctab", "templates/emails"), undefined=StrictUndefined + ) + + subscription_id = subscription_summary["subscription_id"] + + template_data = Allocation( + sub_id=subscription_id, ticket="C2022-999", amount=300 + ).dict() + template_data["summary"] = subscription_summary + template_data["rctab_url"] = None + + template_name = "new_allocation.html" + template = jinja2_environment.get_template(template_name) + + html = template.render(**template_data) + + # There must be a better way to do this... + with open( + "rctab/templates/emails/" + "rendered_" + template_name, + mode="w", + encoding="utf-8", + ) as output_file: + output_file.write(html) + + +@pytest.mark.asyncio +async def test_approval_emails_render( + subscription_summary: Dict[str, Any], jinja2_environment: Environment +) -> None: + subscription_id = subscription_summary["subscription_id"] + + template_name = "new_approval.html" + template_data = Approval( + sub_id=subscription_id, + amount=9000.01, + ticket="Ticket C1", + date_from=date.today(), + date_to=date.today() + timedelta(days=20), + ).dict() + template_data["summary"] = dict(subscription_summary) # type: ignore + template_data["rctab_url"] = None + template = jinja2_environment.get_template(template_name) + + html = template.render(**template_data) + + # There must be a better way to do this... + with open( + "rctab/templates/emails/" + "rendered_" + template_name, + mode="w", + encoding="utf-8", + ) as output_file: + output_file.write(html) + + +@pytest.mark.asyncio +async def test_disabled_emails_render( + subscription_summary: Dict[str, Any], jinja2_environment: Environment +) -> None: + template_data = { + "reason": BillingStatus("OVER_BUDGET_AND_EXPIRED").value, + "summary": dict(subscription_summary), # type: ignore + "rctab_url": None, + } + + template_name = "will_be_disabled.html" + template = jinja2_environment.get_template(template_name) + + html = template.render(**template_data) + + # There must be a better way to do this... + with open( + "rctab/templates/emails/" + "rendered_" + template_name, + mode="w", + encoding="utf-8", + ) as output_file: + output_file.write(html) + + +@pytest.mark.asyncio +async def test_enabled_emails_render( + subscription_summary: Dict[str, Any], jinja2_environment: Environment +) -> None: + template_data = {"summary": subscription_summary, "rctab_url": None} # type: ignore + + template_name = "will_be_enabled.html" + template = jinja2_environment.get_template(template_name) + + html = template.render(**template_data) + + # There must be a better way to do this... + with open( + "rctab/templates/emails/" + "rendered_" + template_name, + mode="w", + encoding="utf-8", + ) as output_file: + output_file.write(html) + + +@pytest.mark.asyncio +async def test_expiry_emails_render( + subscription_summary: Dict[str, Any], jinja2_environment: Environment +) -> None: + template_data = {"days": 7, "summary": dict(subscription_summary), "rctab_url": None} # type: ignore + + template_name = "expiry_looming.html" + template = jinja2_environment.get_template(template_name) + + html = template.render(**template_data) + + # There must be a better way to do this... + with open( + "rctab/templates/emails/" + "rendered_" + template_name, + mode="w", + encoding="utf-8", + ) as output_file: + output_file.write(html) + + +@pytest.mark.asyncio +async def test_persistence_emails_render( + subscription_summary: Dict[str, Any], jinja2_environment: Environment +) -> None: + template_data = { + "old_persistence": False, + "new_persistence": True, + "summary": dict(subscription_summary), + "rctab_url": None, + } + + template_name = "persistence_change.html" + + template = jinja2_environment.get_template(template_name) + + html = template.render(**template_data) + + # There must be a better way to do this... + with open( + "rctab/templates/emails/" + "rendered_" + template_name, + mode="w", + encoding="utf-8", + ) as output_file: + output_file.write(html) + + +@pytest.mark.asyncio +async def test_usage_emails_render( + subscription_summary: Dict[str, Any], jinja2_environment: Environment +) -> None: + template_data = { + "percentage_used": 81, + "summary": dict(subscription_summary), + "rctab_url": None, + } + + template_name = "usage_alert.html" + + template = jinja2_environment.get_template(template_name) + + html = template.render(**template_data) + + # There must be a better way to do this... + with open( + "rctab/templates/emails/" + "rendered_" + template_name, + mode="w", + encoding="utf-8", + ) as output_file: + output_file.write(html) + + +def test_render_finance_email(jinja2_environment: Environment) -> None: + template_data = Finance( + subscription_id=UUID(int=random.randint(0, (2**32) - 1)), + ticket="test_ticket", + amount=0.0, + date_from=date(2022, 8, 1), + date_to=date(2022, 8, 31), + finance_code="test_finance", + priority=1, + ).dict() + + template_name = "new_finance.html" + + template = jinja2_environment.get_template(template_name) + + html = template.render(**template_data) + + with open( + "rctab/templates/emails/" + "rendered_" + template_name, + mode="w", + encoding="utf-8", + ) as output_file: + output_file.write(html) + + +@pytest.mark.asyncio +async def test_send_summary_email_render(test_db: Database) -> None: + since_this_datetime = datetime.now(timezone.utc) - timedelta(days=1) + # make a few subscriptions + test_sub_1 = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + allocated_amount=500.0, + approved=(1000.0, date.today() + timedelta(days=10)), + ) + test_sub_2 = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Disabled"), + allocated_amount=50000.0, + approved=(50000.0, date.today() + timedelta(days=10)), + ) + # create new subscription with no subscription details since datetime + # but approval for that time period + test_sub_3 = UUID(int=random.randint(0, (2**32) - 1)) + test_sub_3 = UUID(int=random.randint(0, (2**32) - 1)) + test_sub_3 = UUID(int=random.randint(0, (2**32) - 1)) + await test_db.execute( + accounting_models.subscription.insert().values(), + dict( + admin=str(ADMIN_UUID), + subscription_id=str(test_sub_3), + time_created=datetime.now(timezone.utc), + ), + ) + await test_db.execute( + accounting_models.approvals.insert().values(), + dict( + subscription_id=str(test_sub_3), + admin=str(ADMIN_UUID), + currency="GBP", + amount=76.85, + date_from=datetime.now(timezone.utc) - timedelta(days=3), + date_to=datetime.now(timezone.utc) + timedelta(days=30), + time_created=datetime.now(timezone.utc) - timedelta(seconds=3), + ), + ) + + # add role and status changes + contributor = RoleAssignment( + role_definition_id="some-role-def-id", + role_name="Billing Reader", + principal_id="some-principal-id", + display_name="SomePrincipal Display Name", + ).dict() + + await test_db.execute( + accounting_models.subscription_details.insert().values(), + dict( + subscription_id=str(test_sub_3), + state=SubscriptionState("Enabled"), + display_name="a subscription", + role_assignments=[ + contributor, + ], + time_created=datetime.now(timezone.utc) - timedelta(days=7), + ), + ) + + await test_db.execute( + accounting_models.subscription_details.insert().values(), + dict( + subscription_id=str(test_sub_1), + state=SubscriptionState("Disabled"), + display_name="test subscription 1", + role_assignments=[ + contributor, + ], + ), + ) + # notifications + await test_db.execute( + accounting_models.emails.insert().values( + { + "subscription_id": test_sub_2, + "status": 200, + "type": EMAIL_TYPE_SUB_WELCOME, + "recipients": "me@my.org", + "time_created": datetime.now(timezone.utc), + } + ) + ) + await test_db.execute( + accounting_models.emails.insert().values( + { + "subscription_id": test_sub_2, + "status": 200, + "type": EMAIL_TYPE_USAGE_ALERT, + "recipients": "me@my.org", + "time_created": datetime.now(timezone.utc), + "extra_info": str(95), + } + ) + ) + # finance entries + await test_db.execute( + accounting_models.finance.insert().values(), + dict( + subscription_id=test_sub_1, + ticket="test_ticket", + amount=1050.0, + date_from=date.today(), + date_to=date.today(), + priority=100, + finance_code="test_finance_code", + time_created=datetime.now(timezone.utc) - timedelta(minutes=60), + **ADMIN_DICT, + ), + ) + await test_db.execute( + accounting_models.finance.insert().values(), + dict( + subscription_id=test_sub_1, + ticket="test_ticket", + amount=-50.0, + date_from=date.today(), + date_to=date.today(), + priority=100, + finance_code="test_finance_code", + time_created=datetime.now(timezone.utc) - timedelta(minutes=60), + **ADMIN_DICT, + ), + ) + await test_db.execute( + accounting_models.finance.insert().values(), + dict( + subscription_id=test_sub_2, + ticket="test_ticket", + amount=250.0, + date_from=date.today(), + date_to=date.today(), + priority=100, + finance_code="test_finance_code", + time_created=datetime.now(timezone.utc) - timedelta(minutes=60), + **ADMIN_DICT, + ), + ) + + # prepare and render summary email + template_data = await send_emails.prepare_summary_email( + test_db, since_this_datetime + ) + template_name = "daily_summary.html" + + html = send_emails.render_template(template_name, template_data) + + with open( + "rctab/templates/emails/" + "rendered_" + template_name, + mode="w", + encoding="utf-8", + ) as output_file: + output_file.write(html) + + # Test that we catch rendering error for incomplete template_data + del template_data["new_approvals_and_allocations"][0]["details"] + recipients = ["test@mail"] + subject = "Daily summary" + + # We use send_with_sendgrid to since that's where we handle render exception + status = send_emails.send_with_sendgrid( + subject, + template_name, + template_data, + recipients, + ) + assert status == -999 diff --git a/tests/test_routes/test_finances.py b/tests/test_routes/test_finances.py new file mode 100644 index 0000000..09e363c --- /dev/null +++ b/tests/test_routes/test_finances.py @@ -0,0 +1,748 @@ +from datetime import date +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest +from databases import Database +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from pytest_mock import MockerFixture +from sqlalchemy import insert, select + +from rctab.crud.accounting_models import ( + cost_recovery, + cost_recovery_log, + finance, + finance_history, +) +from rctab.crud.models import user_rbac +from rctab.crud.schema import Finance, FinanceWithID +from rctab.routers.accounting.cost_recovery import CostRecoveryMonth +from rctab.routers.accounting.finances import ( + check_create_finance, + check_update_finance, + delete_finance, + get_end_month, + get_finance, + get_start_month, + get_subscription_finances, + post_finance, + update_finance, +) +from rctab.routers.accounting.routes import PREFIX, SubscriptionItem +from tests.test_routes import api_calls, constants +from tests.test_routes.constants import ADMIN_DICT +from tests.test_routes.test_routes import ( # pylint: disable=unused-import + create_subscription, + test_db, +) + + +def test_finance_route(auth_app: FastAPI) -> None: + """Check we can call the finances route when there is no data.""" + + with TestClient(auth_app) as client: + + result = client.request( + "GET", + PREFIX + "/finance", + content=SubscriptionItem(sub_id=UUID(int=33)).json(), + ) + + assert result.status_code == 200 + assert result.json() == [] + + +@pytest.mark.asyncio +async def test_empty_finance_table( + test_db: Database, # pylint: disable=redefined-outer-name,unused-argument +) -> None: + """Check we return an empty list when there are no finances.""" + finances = await get_subscription_finances( + SubscriptionItem(sub_id=UUID(int=33)), "my token" # type: ignore + ) + assert finances == [] + + +@pytest.mark.asyncio +async def test_get_correct_finance( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + """Check we return the right record.""" + sub_id_a = await create_subscription(test_db) + sub_id_b = await create_subscription(test_db) + + f_a = Finance( + subscription_id=sub_id_a, + ticket="test_ticket", + amount=0.0, + date_from="2022-08-01", + date_to="2022-08-03", + finance_code="test_finance", + priority=1, + ) + + f_b = Finance( + subscription_id=sub_id_b, + ticket="test_ticket", + amount=0.0, + date_from="2022-08-01", + date_to="2022-08-03", + finance_code="test_finance", + priority=1, + ) + await test_db.execute(finance.insert().values(), {**ADMIN_DICT, **f_a.dict()}) + await test_db.execute(finance.insert().values(), {**ADMIN_DICT, **f_b.dict()}) + + actual = await get_subscription_finances( + SubscriptionItem(sub_id=sub_id_a), "my token" # type: ignore + ) + # We strip the new Finance ID before comparison + assert [Finance(**x.dict()) for x in actual] == [f_a] + + +def test_finances_route(auth_app: FastAPI) -> None: + """Check we can post a Finance.""" + + with TestClient(auth_app) as client: + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + + f_a = Finance( + subscription_id=constants.TEST_SUB_UUID, + ticket="test_ticket", + amount=0.0, + date_from="2022-08-01", + date_to="2022-08-03", + finance_code="test_finance", + priority=1, + ) + result = client.post(PREFIX + "/finances", content=f_a.json()) + + assert result.status_code == 201 + + +@pytest.mark.asyncio +async def test_post_finance( + test_db: Database, # pylint: disable=redefined-outer-name + mocker: MockerFixture, +) -> None: + """Check that we can post a new finance.""" + + sub_id_a = await create_subscription(test_db) + f_a = Finance( + subscription_id=sub_id_a, + ticket="test_ticket", + amount=0.0, + date_from="2022-08-01", + date_to="2022-08-03", + finance_code="test_finance", + priority=1, + ) + + mock_rbac = mocker.Mock() + mock_rbac.oid = constants.ADMIN_UUID + result = await post_finance(f_a, mock_rbac) # type: ignore + + assert Finance(**result.dict()) == f_a + + +def test_get_start_month() -> None: + date_from = date(2022, 8, 15) + + start_date = get_start_month(date_from) + assert start_date == date(2022, 8, 1) + + +def test_get_end_month() -> None: + """ + Check that we return the correct last day of a month (including leap years). + """ + date_feb = date(2022, 2, 15) + end_feb = get_end_month(date_feb) + date_july = date(2022, 7, 19) + end_july = get_end_month(date_july) + date_feb_leap_year = date(2020, 2, 7) + end_feb_leap_year = get_end_month(date_feb_leap_year) + assert end_feb.day == 28 + assert end_feb_leap_year.day == 29 + assert end_july.day == 31 + + +@pytest.mark.asyncio +async def test_check_finance( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + sub_id_a = await create_subscription(test_db) + f_a = Finance( + subscription_id=sub_id_a, + ticket="test_ticket", + amount=0.0, + date_from="2022-07-19", + date_to="2022-07-23", + finance_code="test_finance", + priority=1, + ) + + await check_create_finance(f_a) + + assert f_a.date_from.day == 1 + assert f_a.date_to.day == 31 + + +@pytest.mark.asyncio +async def test_check_finance_raise_exception_dates( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + """ + Test if we raise exception if date_from is later than date_to + """ + sub_id_a = await create_subscription(test_db) + f_a = Finance( + subscription_id=sub_id_a, + ticket="test_ticket", + amount=0.0, + date_from="2022-07-19", + date_to="2022-06-30", + finance_code="test_finance", + priority=1, + ) + with pytest.raises(HTTPException) as exception_info: + await check_create_finance(f_a) + assert ( + exception_info.value.detail + == f"Date from ({str(f_a.date_from)}) cannot be greater than date to ({str(f_a.date_to)})" + ) + + +@pytest.mark.asyncio +async def test_check_finance_raise_exception_negative_amount( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + """ + Test if we raise exception if amount is < 0. + """ + sub_id_a = await create_subscription(test_db) + f_a = Finance( + subscription_id=sub_id_a, + ticket="test_ticket", + amount=-1, + date_from="2022-06-19", + date_to="2022-06-30", + finance_code="test_finance", + priority=1, + ) + with pytest.raises(HTTPException) as exception_info: + await check_create_finance(f_a) + assert exception_info.value.detail == "Amount should not be negative but was -1.0" + + +@pytest.mark.asyncio +async def test_check_finance_raise_exception_already_recovered( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + """Test if check_finance() correctly raises exceptions. + + It should raise if costs for the subscriptions have already been recovered. + """ + subscription_a = await create_subscription(test_db) + f_a = Finance( + subscription_id=subscription_a, + ticket="test_ticket", + amount=0.0, + date_from="2022-08-01", + date_to="2022-08-31", + finance_code="test_finance", + priority=1, + ) + # no exception expected if no cost recovery record found + await check_create_finance(f_a) + f_a_id = await test_db.execute( + finance.insert().values(), {**ADMIN_DICT, **f_a.dict()} + ) + + # no exception expected if no cost recovery record found for this subscription id + subscription_b = await create_subscription(test_db) + f_b = Finance( + subscription_id=subscription_b, + ticket="test_ticket", + amount=0.0, + date_from="2001-08-01", + date_to="2040-08-31", + finance_code="test_finance", + priority=1, + ) + f_b_id = await test_db.execute( + finance.insert().values(), {**ADMIN_DICT, **f_b.dict()} + ) + values = dict( + finance_id=f_b_id, + subscription_id=str(subscription_b), + month=date(2022, 8, 1), + finance_code="test_code", + amount=500.0, + admin=constants.ADMIN_UUID, + ) + await test_db.execute(cost_recovery.insert().values(), values) + await check_create_finance(f_a) + + values = dict( + finance_id=f_b_id, + subscription_id=str(subscription_a), + month=date(2022, 7, 1), + finance_code="test_code", + amount=500.0, + admin=constants.ADMIN_UUID, + ) + await test_db.execute(cost_recovery.insert().values(), values) + await check_create_finance(f_a) + + # we expect an exception when trying to add an earlier record + values = dict( + finance_id=f_a_id, # It doesn't really matter which ID we use here + subscription_id=str(subscription_a), + month=date(2022, 10, 1), + finance_code="test_code", + amount=500.0, + admin=constants.ADMIN_UUID, + ) + await test_db.execute(cost_recovery.insert().values(), values) + with pytest.raises(HTTPException) as exception_info: + await check_create_finance(f_a) + assert ( + exception_info.value.detail + == f"We have already recovered costs until {str(date(2022, 10, 1))}" + + f" for the subscription {str(f_a.subscription_id)}, " + + "please choose a later start date" + ) + + # we expect an exception if trying to add a record on the same month + values = dict( + finance_id=f_a_id, # It doesn't really matter which ID we use here + subscription_id=str(subscription_a), + month=date(2022, 8, 1), + finance_code="test_code", + amount=500.0, + admin=constants.ADMIN_UUID, + ) + await test_db.execute(cost_recovery.insert().values(), values) + with pytest.raises(HTTPException) as exception_info: + await check_create_finance(f_a) + assert ( + exception_info.value.detail + == f"We have already recovered costs until {str(date(2022, 8, 1))}" + + f" for the subscription {str(f_a.subscription_id)}, " + + "please choose a later start date" + ) + + # no exception expected as the new finance is later + values = dict( + finance_id=f_a_id, # It doesn't really matter which ID we use here + subscription_id=str(subscription_a), + month=date(2022, 6, 12), + finance_code="test_code", + amount=500.0, + admin=constants.ADMIN_UUID, + ) + await test_db.execute(cost_recovery.insert().values(), values) + await check_create_finance(f_a) + + +def test_finance_post_get_put_delete(auth_app: FastAPI) -> None: + """Check we can call the finance routes.""" + + with TestClient(auth_app) as client: + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + + f_a = Finance( + subscription_id=constants.TEST_SUB_UUID, + ticket="test_ticket", + amount=0.0, + date_from="2022-08-01", + date_to="2022-08-03", + finance_code="test_finance", + priority=1, + ) + result = client.post(PREFIX + "/finances", content=f_a.json()) + assert result.status_code == 201 + f_a_returned = FinanceWithID.parse_raw(result.content) + + f_a_returned.amount = 10.0 + result = client.put( + PREFIX + f"/finances/{f_a_returned.id}", content=f_a_returned.json() + ) + assert result.status_code == 200 + + result = client.get(PREFIX + f"/finances/{f_a_returned.id}") + assert result.status_code == 200 + assert FinanceWithID.parse_raw(result.content) == f_a_returned + + result = client.request( + "DELETE", + PREFIX + f"/finances/{f_a_returned.id}", + content=SubscriptionItem(sub_id=constants.TEST_SUB_UUID).json(), + ) + assert result.status_code == 200 + + +@pytest.mark.asyncio +async def test_finance_history_delete( + test_db: Database, mocker: MockerFixture # pylint: disable=redefined-outer-name +) -> None: + """Check that our trigger and function work for deletions.""" + + sub_id_a = await create_subscription(test_db) + f_a = Finance( + subscription_id=sub_id_a, + ticket="test_ticket", + amount=0.0, + date_from="2022-08-01", + date_to="2022-08-03", + finance_code="test_finance", + priority=1, + ) + mock_rbac = mocker.Mock() + mock_rbac.oid = constants.ADMIN_UUID + result = await post_finance(f_a, mock_rbac) # type: ignore + await delete_finance(result.id, SubscriptionItem(sub_id=sub_id_a)) + + # The finance record should have been deleted + actual = await get_subscription_finances( + SubscriptionItem(sub_id=sub_id_a), "my token" # type: ignore + ) + assert actual == [] + + rows = await test_db.fetch_all(select([finance_history])) + dicts = [dict(x) for x in rows] + + assert len(dicts) == 1 + # This is a quirk of the testing setup, + # which allows us to check that time_deleted has been populated + assert dicts[0].pop("time_deleted") == dicts[0].pop("time_created") + assert FinanceWithID(**dicts[0]) == result + + +@pytest.mark.asyncio +async def test_finance_history_update( + test_db: Database, mocker: MockerFixture # pylint: disable=redefined-outer-name +) -> None: + """Check that our trigger and function work for deletions.""" + + sub_id_a = await create_subscription(test_db) + f_a = Finance( + subscription_id=sub_id_a, + ticket="test_ticket", + amount=0.0, + date_from="2022-08-01", + date_to="2022-08-03", + finance_code="test_finance", + priority=1, + ) + + mock_rbac = mocker.Mock() + mock_rbac.oid = constants.ADMIN_UUID + f_b = await post_finance(f_a, mock_rbac) # type: ignore + + f_c = FinanceWithID(**f_b.dict()) + f_c.amount = 100 + + await update_finance(f_c.id, f_c, mock_rbac) + + # The finance record should have been deleted + actual = await get_subscription_finances( + SubscriptionItem(sub_id=sub_id_a), "my token" # type: ignore + ) + assert len(actual) == 1 + + rows = await test_db.fetch_all(select([finance_history])) + dicts = [dict(x) for x in rows] + + assert len(dicts) == 1 + # This is a quirk of the testing setup, + # which allows us to check that time_deleted has been populated + assert dicts[0].pop("time_deleted") == dicts[0].pop("time_created") + assert FinanceWithID(**dicts[0]) == f_b + + +@pytest.mark.asyncio +async def test_delete_finance_raises( + test_db: Database, mocker: MockerFixture # pylint: disable=redefined-outer-name +) -> None: + """Check that the delete route checks for matching IDs.""" + + sub_id_a = await create_subscription(test_db) + f_a = Finance( + subscription_id=sub_id_a, + ticket="test_ticket", + amount=0.0, + date_from="2022-08-01", + date_to="2022-08-03", + finance_code="test_finance", + priority=1, + ) + + mock_rbac = mocker.Mock() + mock_rbac.oid = constants.ADMIN_UUID + result = await post_finance(f_a, mock_rbac) # type: ignore + + # We should raise if the finance ID doesn't exist + with pytest.raises(HTTPException) as exception_info: + await delete_finance(result.id + 1, SubscriptionItem(sub_id=sub_id_a)) + + assert exception_info.value.detail == "Finance not found" + + # We should raise if the finance row's subscription doesn't match the one supplied + with pytest.raises(HTTPException) as exception_info: + await delete_finance( + result.id, SubscriptionItem(sub_id=UUID(int=sub_id_a.int + 1)) + ) + + assert exception_info.value.detail == "Subscription ID does not match" + + values = dict( + finance_id=result.id, + subscription_id=str(sub_id_a), + month=date(2001, 1, 1), + finance_code="test_code", + amount=0.0, + admin=constants.ADMIN_UUID, + ) + await test_db.execute(cost_recovery.insert().values(), values) + + # We should raise if this finance ID has been fully or partially recovered + with pytest.raises(HTTPException) as exception_info: + await delete_finance(result.id, SubscriptionItem(sub_id=sub_id_a)) + + assert exception_info.value.detail == "Costs have already been recovered" + + +@pytest.mark.asyncio +async def test_get_finance_raises( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + """Check that we return a 400 status code if not found.""" + + with pytest.raises(HTTPException): + await get_finance(1, "a token") # type: ignore + + del test_db + + +def test_finance_can_update(auth_app: FastAPI) -> None: + """Check that we can update some fields, even after cost recovery.""" + + with TestClient(auth_app) as client: + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + + f_a = Finance( + subscription_id=constants.TEST_SUB_UUID, + ticket="test_ticket", + amount=0.0, + date_from="2022-08-01", + date_to="2022-10-31", + finance_code="test_finance", + priority=1, + ) + result = client.post(PREFIX + "/finances", content=f_a.json()) + assert result.status_code == 201 + f_a_returned = FinanceWithID.parse_raw(result.content) + + result = client.post( + PREFIX + "/cli-cost-recovery", + content=CostRecoveryMonth(first_day="2022-09-01").json(), + ) + assert result.status_code == 200 + + f_a_returned.amount = 10.0 + result = client.put( + PREFIX + f"/finances/{f_a_returned.id}", content=f_a_returned.json() + ) + assert result.status_code == 200 + + +@pytest.mark.asyncio +async def test_update_finance_checks( + test_db: Database, mocker: MockerFixture # pylint: disable=redefined-outer-name +) -> None: + """Check that we call the check_update_finance function.""" + + mock_check = AsyncMock() + mocker.patch("rctab.routers.accounting.finances.check_update_finance", mock_check) + + f_a = FinanceWithID( + id=1, + subscription_id=UUID(int=1), + ticket="test ticket", + amount=0, + priority=0, + finance_code="", + date_from=date.today(), + date_to=date.today(), + ) + + mock_rbac = mocker.Mock() + mock_rbac.oid = constants.ADMIN_UUID + + await update_finance(1, f_a, mock_rbac) + mock_check.assert_called_once_with(f_a) + + del test_db + + +@pytest.mark.asyncio +async def test_check_update_finance( + test_db: Database, mocker: MockerFixture # pylint: disable=redefined-outer-name +) -> None: + """Check that we validate updates.""" + + sub_id_a = await create_subscription(test_db) + f_a = Finance( + subscription_id=sub_id_a, + ticket="test_ticket", + amount=0.0, + date_from="2000-01-01", + date_to="2000-06-30", + finance_code="test_finance", + priority=1, + ) + + mock_rbac = mocker.Mock() + mock_rbac.oid = constants.ADMIN_UUID + + # f_b is the same as f_a but has an ID + f_b = await post_finance(f_a, mock_rbac) # type: ignore + + f_c = FinanceWithID(**f_b.dict()) + + # Subscription IDs should match + f_c.subscription_id = UUID(int=101) + + with pytest.raises(HTTPException) as exception_info: + await check_update_finance(f_c) + + assert exception_info.value.status_code == 400 + assert exception_info.value.detail == "Subscription IDs should match" + + # date_to should come after date_from + f_d = FinanceWithID(**f_b.dict()) + f_d.date_to = f_d.date_from + + with pytest.raises(HTTPException) as exception_info: + await check_update_finance(f_d) + + assert exception_info.value.status_code == 400 + assert exception_info.value.detail == "date_to <= date_from" + + # Amount should be >= 0 + f_e = FinanceWithID(**f_b.dict()) + f_e.amount = -0.1 + + with pytest.raises(HTTPException) as exception_info: + await check_update_finance(f_e) + + assert exception_info.value.status_code == 400 + assert exception_info.value.detail == "amount < 0" + + # Can't insert new.date_from if that month has been recovered + await test_db.execute( + insert(cost_recovery_log), + {"month": date.fromisoformat("1999-12-01"), "admin": constants.ADMIN_UUID}, + ) + f_f = FinanceWithID(**{**f_b.dict(), **{"date_from": "1999-11-01"}}) + + with pytest.raises(HTTPException) as exception_info: + await check_update_finance(f_f) + + assert exception_info.value.status_code == 400 + assert exception_info.value.detail == "new.date_from has been recovered" + + # Can't change old.date_from if that month has been recovered + await test_db.execute( + insert(cost_recovery_log), + {"month": date.fromisoformat("2000-04-01"), "admin": constants.ADMIN_UUID}, + ) + f_g = FinanceWithID(**{**f_b.dict(), **{"date_from": "2000-02-01"}}) + + with pytest.raises(HTTPException) as exception_info: + await check_update_finance(f_g) + + assert exception_info.value.status_code == 400 + assert exception_info.value.detail == "old.date_from has been recovered" + + # Can't change new.date_to if that month has been recovered + f_h = FinanceWithID(**{**f_b.dict(), **{"date_to": "2000-03-31"}}) + + with pytest.raises(HTTPException) as exception_info: + await check_update_finance(f_h) + + assert exception_info.value.status_code == 400 + assert exception_info.value.detail == "new.date_to has been recovered" + + # Can't change old.date_to if that month has been recovered + await test_db.execute( + insert(cost_recovery_log), + {"month": date.fromisoformat("2000-07-01"), "admin": constants.ADMIN_UUID}, + ) + f_i = FinanceWithID(**{**f_b.dict(), **{"date_to": "2000-08-30"}}) + + with pytest.raises(HTTPException) as exception_info: + await check_update_finance(f_i) + + assert exception_info.value.status_code == 400 + assert exception_info.value.detail == "old.date_to has been recovered" + + +@pytest.mark.asyncio +async def test_check_update_finance_admin( + test_db: Database, mocker: MockerFixture # pylint: disable=redefined-outer-name +) -> None: + """Check that we update the admin column when we update a finance.""" + + creator_oid = UUID(int=1) + await test_db.execute( + insert(user_rbac), + { + "oid": creator_oid, + "username": "creator", + "has_access": True, + "is_admin": True, + }, + ) + + updater_oid = UUID(int=2) + await test_db.execute( + insert(user_rbac), + { + "oid": updater_oid, + "username": "creator", + "has_access": True, + "is_admin": True, + }, + ) + + sub_id_a = await create_subscription(test_db) + f_a = Finance( + subscription_id=sub_id_a, + ticket="test_ticket", + amount=0.0, + date_from="2000-01-01", + date_to="2000-06-30", + finance_code="test_finance", + priority=1, + ) + + mock_rbac = mocker.Mock() + mock_rbac.oid = creator_oid + + # f_b is the same as f_a but has an ID + f_b = await post_finance(f_a, mock_rbac) # type: ignore + + mock_rbac.oid = updater_oid + await update_finance(f_b.id, f_b, mock_rbac) + + rows = await test_db.fetch_all(select([finance])) + updated_finances = [dict(row) for row in rows] + assert len(updated_finances) == 1 + assert updated_finances[0]["admin"] == updater_oid diff --git a/tests/test_routes/test_frontend.py b/tests/test_routes/test_frontend.py new file mode 100644 index 0000000..db5e4de --- /dev/null +++ b/tests/test_routes/test_frontend.py @@ -0,0 +1,93 @@ +import random +from unittest.mock import AsyncMock +from uuid import UUID + +import jwt +import pytest +from databases import Database +from fastapi import HTTPException +from pytest_mock import MockerFixture + +from rctab.crud.accounting_models import subscription, subscription_details +from rctab.crud.schema import RoleAssignment, SubscriptionState, UserRBAC +from rctab.routers.frontend import home +from tests.test_routes import constants +from tests.test_routes.test_routes import test_db # pylint: disable=unused-import + +# pylint: disable=redefined-outer-name + + +@pytest.mark.asyncio +async def test_no_email_raises(mocker: MockerFixture) -> None: + """We want to be explicit if this ever happens because we think it shouldn't.""" + + mock_request = mocker.Mock() + + mock_user = mocker.Mock() + # We expect the token to always have a valid email as the unique_name + mock_user.token = { + "access_token": jwt.encode({"unique_name": None, "name": "My Name"}, "my key") + } + + with pytest.raises(HTTPException): + await home(mock_request, mock_user) + + +@pytest.mark.asyncio +async def test_no_username_no_subscriptions( + mocker: MockerFixture, test_db: Database +) -> None: + """Check that users without usernames can't see any subscriptions.""" + + subscription_id = UUID(int=random.randint(0, (2**32) - 1)) + + await test_db.execute( + subscription.insert().values(), + dict( + admin=str(constants.ADMIN_UUID), + subscription_id=str(subscription_id), + ), + ) + + await test_db.execute( + subscription_details.insert().values(), + dict( + subscription_id=str(subscription_id), + state=SubscriptionState("Enabled"), + display_name="a subscription", + role_assignments=[ + RoleAssignment( + role_definition_id="123", + role_name="Sous chef", + principal_id="456", + display_name="Max Mustermann", + # Note the missing email address, which does sometimes happen + mail=None, + scope="some/scope/string", + ).dict() + ], + ), + ) + + mock_request = mocker.Mock() + + mock_user = mocker.Mock() + mock_user.token = { + "access_token": jwt.encode( + {"unique_name": "me@my.org", "name": "My Name"}, "my key" + ) + } + mock_user.oid = str(UUID(int=434)) + + mock_templates = mocker.patch("rctab.routers.frontend.templates") + + mock_check_access = AsyncMock() + mock_check_access.return_value = UserRBAC( + oid=UUID(int=111), has_access=True, is_admin=False + ) + mocker.patch("rctab.routers.frontend.check_user_access", mock_check_access) + + await home(mock_request, mock_user) + + # Check that no subscriptions are passed to the template + assert mock_templates.TemplateResponse.call_args.args[1]["azure_sub_data"] == [] diff --git a/tests/test_routes/test_persistence.py b/tests/test_routes/test_persistence.py new file mode 100644 index 0000000..2688569 --- /dev/null +++ b/tests/test_routes/test_persistence.py @@ -0,0 +1,79 @@ +from unittest.mock import AsyncMock + +import pytest_mock +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from rctab.crud.models import database +from rctab.crud.schema import SubscriptionDetails +from tests.test_routes import api_calls, constants + + +def test_post_persistent(auth_app: FastAPI, mocker: pytest_mock.MockerFixture) -> None: + """Set subscription to always on""" + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + + result = api_calls.set_persistence( + client, constants.TEST_SUB_UUID, always_on=True + ) + + assert result.status_code == 200 + + mock_send_email.assert_called_once_with( + database, + constants.TEST_SUB_UUID, + "persistence_change.html", + "Persistence change for your Azure subscription:", + "subscription persistence", + {"sub_id": constants.TEST_SUB_UUID, "always_on": True}, + ) + + +def test_get_persistent(auth_app: FastAPI, mocker: pytest_mock.MockerFixture) -> None: + """Check we're now persistent""" + + expected_details = SubscriptionDetails( + subscription_id=constants.TEST_SUB_UUID, + name=None, + role_assignments=None, + status=None, + approved_from=None, + approved_to=None, + always_on=True, + approved=0.0, + allocated=0.0, + cost=0.0, + amortised_cost=0.0, + total_cost=0.0, + remaining=0.0, + first_usage=None, + latest_usage=None, + desired_status_info=None, + abolished=False, + ) + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=True) + api_calls.assert_subscription_status(client, expected_details=expected_details) + + mock_send_email.assert_called_once_with( + database, + constants.TEST_SUB_UUID, + "persistence_change.html", + "Persistence change for your Azure subscription:", + "subscription persistence", + {"sub_id": constants.TEST_SUB_UUID, "always_on": True}, + ) diff --git a/tests/test_routes/test_routes.py b/tests/test_routes/test_routes.py new file mode 100644 index 0000000..64daab4 --- /dev/null +++ b/tests/test_routes/test_routes.py @@ -0,0 +1,384 @@ +# pylint: disable=redefined-outer-name, +import random +from datetime import date, timedelta +from typing import Any, AsyncGenerator, Callable, Coroutine, Dict, Optional, Tuple +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest +from databases import Database +from mypy_extensions import KwArg, VarArg +from pytest_mock import MockerFixture +from sqlalchemy import and_, func, select +from sqlalchemy.engine import ResultProxy +from sqlalchemy.engine.base import Engine + +from rctab.crud.accounting_models import ( + allocations, + approvals, + persistence, + refresh_materialised_view, + status, + subscription, + subscription_details, + usage, + usage_view, +) +from rctab.crud.models import database +from rctab.crud.schema import ( + RoleAssignment, + SubscriptionState, + SubscriptionStatus, + Usage, +) +from rctab.routers.accounting.desired_states import refresh_desired_states +from tests.test_routes import constants + + +@pytest.fixture(scope="function") +async def test_db() -> AsyncGenerator[Database, None]: + """Connect before & disconnect after each test.""" + await database.connect() + yield database + await database.disconnect() + + +async def create_subscription( + db: Database, + always_on: Optional[bool] = None, + current_state: Optional[SubscriptionState] = None, + allocated_amount: Optional[float] = None, + approved: Optional[Tuple[float, date]] = None, + spent: Optional[Tuple[float, float]] = None, + spent_date: Optional[date] = None, +) -> UUID: + """Convenience function for testing. + + db: a databases Database + always_on: if None then no row in persistence + current_state: if None then no row in subscription_details + allocated_amount: if None then no row in allocations + (approved_amount, approved_to): if None then no row in approvals + (normal_cost, amortised_cost): the amount spent thus far + """ + # pylint: disable=too-many-arguments, invalid-name + + # We don't guard against subscription_id clash + subscription_id = UUID(int=random.randint(0, (2**32) - 1)) + + await db.execute( + subscription.insert().values(), + dict( + admin=str(constants.ADMIN_UUID), + subscription_id=str(subscription_id), + ), + ) + if always_on is not None: + await db.execute( + persistence.insert().values(), + dict( + admin=str(constants.ADMIN_UUID), + subscription_id=str(subscription_id), + always_on=always_on, + ), + ) + + if current_state is not None: + await db.execute( + subscription_details.insert().values(), + SubscriptionStatus( + subscription_id=str(subscription_id), + state=current_state, + display_name="a subscription", + role_assignments=( + RoleAssignment( + role_definition_id="some-role-def-id", + role_name="Billing Reader", + principal_id="some-principal-id", + display_name="SomePrincipal Display Name", + ), + ), + ).dict(), + ) + + if allocated_amount is not None: + await db.execute( + allocations.insert().values(), + dict( + subscription_id=str(subscription_id), + admin=str(constants.ADMIN_UUID), + amount=allocated_amount, + currency="GBP", + ), + ) + + if approved is not None: + await db.execute( + approvals.insert().values(), + dict( + subscription_id=str(subscription_id), + admin=str(constants.ADMIN_UUID), + amount=approved[0], + date_to=approved[1], + date_from=date.today() - timedelta(days=365), + currency="GBP", + ), + ) + + if spent: + await db.execute( + usage.insert().values(), + Usage( + subscription_id=str(subscription_id), + id=str(UUID(int=random.randint(0, 2**32 - 1))), + cost=spent[0], + amortised_cost=spent[1], + total_cost=sum(spent), + invoice_section="", + date=spent_date if spent_date else date.today(), + ).dict(), + ) + await refresh_materialised_view(db, usage_view) + + return subscription_id + + +def make_async_execute( + connection: Engine, +) -> Callable[ + [VarArg(Tuple[Any, ...]), KwArg(Dict[str, Any])], Coroutine[Any, Any, ResultProxy] +]: + """We need an async function to patch database.execute() with + but connection.execute() is synchronous so make a wrapper for it.""" + + async def async_execute( + *args: Tuple[Any, ...], **kwargs: Dict[str, Any] + ) -> ResultProxy: + """An async wrapper around connection.execute().""" + return connection.execute(*args, **kwargs) # type: ignore + + return async_execute + + +@pytest.mark.asyncio +async def test_refresh_desired_states_disable( + test_db: Database, mocker: MockerFixture +) -> None: + """Check that refresh_desired_states disables when it should.""" + # pylint: disable=singleton-comparison + + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + no_approval_sub_id = await create_subscription( + test_db, always_on=False, current_state=SubscriptionState("Enabled") + ) + + expired_yesterday_sub_id = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + approved=(100.0, date.today() - timedelta(days=1)), + ) + + over_budget_sub_id = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + approved=(100.0, date.today() + timedelta(days=1)), + spent=(101.0, 0), + ) + + over_time_and_over_budget_sub_id = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + approved=(100.0, date.today() - timedelta(days=1)), + spent=(101.0, 0), + ) + + not_always_on_sub_id = await create_subscription(test_db, always_on=None) + + await refresh_desired_states( + constants.ADMIN_UUID, + [ + no_approval_sub_id, + expired_yesterday_sub_id, + over_budget_sub_id, + not_always_on_sub_id, + over_time_and_over_budget_sub_id, + ], + ) + + rows = await test_db.fetch_all(select([status]).order_by(status.c.subscription_id)) + disabled_subscriptions = [ + (row["subscription_id"], row["reason"]) + for row in rows + if row["active"] is False + ] + disabled_subscriptions_set = set(disabled_subscriptions) + + # The subscription_ids are generated at random so we can't use list comparisons + assert len(disabled_subscriptions) == len(disabled_subscriptions_set) + assert disabled_subscriptions_set == { + (no_approval_sub_id, "EXPIRED"), + (expired_yesterday_sub_id, "EXPIRED"), + (over_budget_sub_id, "OVER_BUDGET"), + (not_always_on_sub_id, "EXPIRED"), + (over_time_and_over_budget_sub_id, "OVER_BUDGET_AND_EXPIRED"), + } + + +@pytest.mark.asyncio +async def test_refresh_desired_states_enable( + test_db: Database, mocker: MockerFixture +) -> None: + """Check that refresh_desired_states enables when it should.""" + # pylint: disable=singleton-comparison + + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + # Allocations default to 0, not NULL, so we don't expect this + # sub to be disabled since 0 usage is not > 0 allocated budget + no_allocation_sub_id = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Disabled"), + approved=(100.0, date.today() + timedelta(days=1)), + ) + + always_on_sub_id = await create_subscription( + test_db, + always_on=True, + current_state=SubscriptionState("Disabled"), + approved=(100.0, date.today() - timedelta(days=1)), + spent=(101.0, 0), + ) + + # E.g. we have just allocated more budget + currently_disabled_sub_id = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Disabled"), + approved=(200.0, date.today() + timedelta(days=1)), + allocated_amount=200.0, + spent=(101.0, 0), + ) + await test_db.execute( + status.insert().values(), + dict( + subscription_id=str(currently_disabled_sub_id), + admin=str(constants.ADMIN_UUID), + active=False, + ), + ) + + # Q) Can we presume that status, persistence, approvals and allocations + # are made during subscription creation? + await refresh_desired_states( + constants.ADMIN_UUID, + [always_on_sub_id, no_allocation_sub_id, currently_disabled_sub_id], + ) + + rows = await test_db.fetch_all(select([status]).order_by(status.c.subscription_id)) + + enabled_subscriptions = [ + row["subscription_id"] for row in rows if row["active"] is True + ] + enabled_subscriptions_set = set(enabled_subscriptions) + + # The subscription_ids are generated at random so we can't use list comparisons + assert len(enabled_subscriptions) == len(enabled_subscriptions_set) + assert enabled_subscriptions_set == { + always_on_sub_id, + no_allocation_sub_id, + currently_disabled_sub_id, + } + + +@pytest.mark.asyncio +async def test_refresh_desired_states_doesnt_duplicate( + test_db: Database, mocker: MockerFixture +) -> None: + """Check that refresh_desired_states only inserts when necessary.""" + # pylint: disable=singleton-comparison + + always_on_sub_id = await create_subscription( + test_db, + always_on=True, + current_state=SubscriptionState("Disabled"), + approved=(100.0, date.today() - timedelta(days=1)), + spent=(101.0, 0), + ) + await test_db.execute( + status.insert().values(), + dict( + subscription_id=str(always_on_sub_id), + admin=str(constants.ADMIN_UUID), + active=True, + ), + ) + + # We want this subscription to stay disabled. + over_budget_sub_id = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + approved=(100.0, date.today() + timedelta(days=1)), + spent=(101.0, 0), + ) + await test_db.execute( + status.insert().values(), + dict( + subscription_id=str(over_budget_sub_id), + admin=str(constants.ADMIN_UUID), + active=False, + ), + ) + + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + # Note: here we check that, by default, refresh_desired_states() + # will refresh all subscriptions + await refresh_desired_states( + constants.ADMIN_UUID, + ) + + latest_status_id = ( + select([status.c.subscription_id, func.max(status.c.id).label("max_id")]) + .group_by(status.c.subscription_id) + .alias() + ) + + latest_status = select([status.c.subscription_id, status.c.active]).select_from( + status.join( + latest_status_id, + and_( + status.c.subscription_id == latest_status_id.c.subscription_id, + status.c.id == latest_status_id.c.max_id, + ), + ) + ) + + rows = await test_db.fetch_all(latest_status) + enabled_subscriptions = [ + row["subscription_id"] for row in rows if row["active"] is True + ] + assert enabled_subscriptions == [ + always_on_sub_id, + ] + + disabled_subscriptions = [ + row["subscription_id"] for row in rows if row["active"] is False + ] + assert disabled_subscriptions == [ + over_budget_sub_id, + ] diff --git a/tests/test_routes/test_send_emails.py b/tests/test_routes/test_send_emails.py new file mode 100644 index 0000000..7438f42 --- /dev/null +++ b/tests/test_routes/test_send_emails.py @@ -0,0 +1,1869 @@ +# pylint: disable=too-many-lines +import random +from datetime import date, datetime, timedelta, timezone +from typing import Generator +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest +import pytest_mock +from asyncpg import Record +from databases import Database +from jinja2 import Environment, PackageLoader, StrictUndefined +from pytest_mock import MockerFixture +from sqlalchemy import insert, select +from sqlalchemy.sql import Select + +from rctab.constants import ( + EMAIL_TYPE_SUB_APPROVAL, + EMAIL_TYPE_SUB_WELCOME, + EMAIL_TYPE_SUMMARY, + EMAIL_TYPE_TIMEBASED, + EMAIL_TYPE_USAGE_ALERT, +) +from rctab.crud import accounting_models +from rctab.crud.accounting_models import ( + allocations, + approvals, + emails, + finance, + refresh_materialised_view, + subscription, + subscription_details, + usage, + usage_view, +) +from rctab.crud.schema import AllUsage, RoleAssignment, SubscriptionState, Usage +from rctab.routers.accounting import send_emails +from rctab.routers.accounting.send_emails import ( + MissingEmailParamsError, + UsageEmailContextManager, + get_allocations_since, + get_approvals_since, + get_emails_sent_since, + get_finance_entries_since, + get_new_subscriptions_since, + get_subscription_details_since, + prepare_summary_email, +) +from rctab.routers.accounting.usage import post_usage +from tests.test_routes import constants +from tests.test_routes.constants import ADMIN_DICT +from tests.test_routes.test_routes import test_db # pylint: disable=unused-import +from tests.test_routes.test_routes import create_subscription +from tests.utils import print_list_diff + +USAGE_DICT = { + "additional_properties": {}, + "name": str(UUID(int=random.randint(0, (2**32) - 1))), + "type": "Usage type", + "tags": None, + "kind": "legacy", + "billing_account_id": "01234567", + "billing_account_name": "My billing account name", + "billing_period_start_date": datetime(2021, 9, 1, 0, 0), + "billing_period_end_date": datetime(2021, 9, 30, 0, 0), + "billing_profile_id": "01234567", + "billing_profile_name": "My institution", + "account_owner_id": "account_owner@myinstitution", + "account_name": "My account", + "subscription_id": str(UUID(int=random.randint(0, (2**32) - 1))), + "subscription_name": "My susbcription", + "date": datetime(2021, 9, 1, 0, 0), + "product": "Some Azure product", + "part_number": "PART-NUM-1", + "meter_id": str(UUID(int=random.randint(0, (2**32) - 1))), + "meter_details": None, + "quantity": 0.1, + "effective_price": 0.0, + "cost": 0.0, # This is the important entry + "total_cost": 0.0, + "unit_price": 2.0, + "billing_currency": "GBP", + "resource_location": "Resource location", + "consumed_service": "A service", + "resource_id": "some-resource-id", + "resource_name": "Resource name", + "service_info1": None, + "service_info2": None, + "additional_info": None, + "invoice_section": "Invoice section", + "cost_center": None, + "resource_group": None, + "reservation_id": None, + "reservation_name": None, + "product_order_id": None, + "product_order_name": None, + "offer_id": "OFFER-ID", + "is_azure_credit_eligible": True, + "term": None, + "publisher_name": None, + "publisher_type": "Azure", + "plan_name": None, + "charge_type": "Usage", + "frequency": "UsageBased", +} + + +@pytest.fixture() +def jinja2_environment() -> Generator[Environment, None, None]: + yield Environment( + loader=PackageLoader("rctab", "templates/emails"), undefined=StrictUndefined + ) + + +@pytest.mark.asyncio +async def test_usage_emails( + mocker: pytest_mock.MockerFixture, + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + """Test that we send the right emails to the right Azure users.""" + + thirty_percent = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + allocated_amount=100.0, + approved=(100.0, date.today() + timedelta(days=10)), + spent=(20.0, 0), + ) + + ninety_percent = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + allocated_amount=100.0, + approved=(100.0, date.today() + timedelta(days=10)), + spent=(80.0, 0), + ) + + ninety_five_percent = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + allocated_amount=100.0, + approved=(100.0, date.today() + timedelta(days=10)), + spent=(85.0, 0), + ) + + ninety_percent_usage = USAGE_DICT.copy() + ninety_percent_usage["subscription_id"] = ninety_percent # type: ignore + ninety_percent_usage["cost"] = 10 # This should push us up to the 90% threshold + ninety_percent_usage["total_cost"] = 10 + ninety_percent_usage["id"] = "a" + + thirty_percent_usage = USAGE_DICT.copy() + thirty_percent_usage["subscription_id"] = thirty_percent # type: ignore + thirty_percent_usage["cost"] = 10 # This only gets us to 80% + thirty_percent_usage["total_cost"] = 10 + thirty_percent_usage["id"] = "b" + + ninety_five_percent_usage = USAGE_DICT.copy() + ninety_five_percent_usage["subscription_id"] = ninety_five_percent # type: ignore + ninety_five_percent_usage["cost"] = 10 # This takes us to 95%% + ninety_five_percent_usage["total_cost"] = 10 + ninety_five_percent_usage["id"] = "c" + + post_data = AllUsage( + usage_list=[ + Usage(**ninety_percent_usage), + Usage(**thirty_percent_usage), + Usage(**ninety_five_percent_usage), + ] + ) + + mock_send = AsyncMock() + mocker.patch("rctab.routers.accounting.send_emails.send_generic_email", mock_send) + + mock_refresh = AsyncMock() + mocker.patch("rctab.routers.accounting.usage.refresh_desired_states", mock_refresh) + + resp = await post_usage(post_data, {"": ""}) + + assert resp.status == "successfully uploaded 3 rows" + + # Posting the usage data should have the side effect of sending emails + try: + expected = [ + mocker.call( + test_db, + ninety_percent, + "usage_alert.html", + "90.0% of allocated budget used by your Azure subscription:", + EMAIL_TYPE_USAGE_ALERT, + {"percentage_used": 90.0, "extra_info": str(90.0)}, + ), + mocker.call( + test_db, + ninety_five_percent, + "usage_alert.html", + "95.0% of allocated budget used by your Azure subscription:", + EMAIL_TYPE_USAGE_ALERT, + {"percentage_used": 95.0, "extra_info": str(95.0)}, + ), + ] + mock_send.assert_has_calls(expected) + except AssertionError as e: + print_list_diff(expected, mock_send.call_args_list) + raise e + + +def test_send_with_sendgrid(mocker: MockerFixture) -> None: + """Test the send_with_sendgrid function.""" + + mail = mocker.patch("rctab.routers.accounting.send_emails.Mail") + client_class = mocker.patch( + "rctab.routers.accounting.send_emails.SendGridAPIClient" + ) + get_settings = mocker.patch("rctab.routers.accounting.send_emails.get_settings") + get_settings.return_value.sendgrid_api_key = "sendgridkey123" + get_settings.return_value.sendgrid_sender_email = "myemail@myorg" + get_settings.return_value.testing = False # Bypass the safety feature + + mock_sg_client = mocker.Mock() + mock_sg_client.send.return_value = mocker.Mock(status_code=11) + client_class.return_value = mock_sg_client + + status_code = send_emails.send_with_sendgrid( + "my-subject", + "blank.html", + {}, + ["rse1@myorg", "rse2@myorg"], + ) + mail.assert_called_once_with( + from_email="myemail@myorg", + to_emails=["rse1@myorg", "rse2@myorg"], + html_content="", + subject="my-subject", + ) + mock_sg_client.send.assert_called_once_with(mail.return_value) + assert status_code == 11 + + +def test_no_sendgrid_api_key(mocker: MockerFixture) -> None: + """We shouldn't try to send emails if we're missing an API key or sender email.""" + + mocker.patch("rctab.routers.accounting.send_emails.Mail") + client_class = mocker.patch( + "rctab.routers.accounting.send_emails.SendGridAPIClient" + ) + get_settings = mocker.patch("rctab.routers.accounting.send_emails.get_settings") + get_settings.return_value.testing = False # Bypass the safety feature + + mock_sg_client = mocker.Mock() + mock_sg_client.send.return_value = mocker.Mock(status_code=11) + client_class.return_value = mock_sg_client + + get_settings.return_value.sendgrid_api_key = None + get_settings.return_value.sendgrid_sender_email = "me@myco.com" + + with pytest.raises(MissingEmailParamsError): + send_emails.send_with_sendgrid( + "my-subject", + "blank.html", + {}, + ["rse1@myorg", "rse2@myorg"], + ) + + get_settings.return_value.sendgrid_api_key = "sendgrid_key_1234" + get_settings.return_value.sendgrid_sender_email = None + + with pytest.raises(MissingEmailParamsError): + send_emails.send_with_sendgrid( + "my-subject", + "blank.html", + {}, + ["rse1@myorg", "rse2@myorg"], + ) + + +@pytest.mark.asyncio +async def test_get_sub_email_recipients( + test_db: Database, # pylint: disable=redefined-outer-name + mocker: MockerFixture, +) -> None: + subscription_id = UUID(int=random.randint(0, (2**32) - 1)) + + await test_db.execute( + subscription.insert().values(), + dict( + admin=str(constants.ADMIN_UUID), + subscription_id=str(subscription_id), + ), + ) + + # In case it is called before the status function app has run + rbac_list = await send_emails.get_sub_email_recipients(test_db, subscription_id) + assert rbac_list == [] + + billing_reader = RoleAssignment( + mail="johndoe@myorg", + scope=f"/subscriptions/{subscription_id}", + role_name="Billing Reader", + display_name="John Doe", + principal_id="1", + role_definition_id="some_role_def_id", + ).dict() + contributor_a = RoleAssignment( + mail="janedoe@myorg", + scope=f"/subscriptions/{subscription_id}", + role_name="Contributor", + display_name="Jane Doe", + principal_id="2", + role_definition_id="some_other_role_def_id", + ).dict() + group_contributor = RoleAssignment( + mail=None, + scope=f"/subscriptions/{subscription_id}", + role_name="Contributor", + display_name="The_Does", + principal_id="3", + role_definition_id="some_other_role_def_id", + ).dict() + reader = RoleAssignment( + mail="jimdoe@myorg", + scope=f"/subscriptions/{subscription_id}", + role_name="Reader", + display_name="Jim Doe", + principal_id="4", + role_definition_id="some_other_role_def_id", + ).dict() + contributor_b = RoleAssignment( + mail="joedoe@myorg", + scope="/", + role_name="Contributor", + display_name="Joe Doe", + principal_id="5", + role_definition_id="some_other_role_def_id", + ).dict() + + mock_get_settings = mocker.Mock() + mock_get_settings.return_value.notifiable_roles = ["Reader", "Contributor"] + mocker.patch("rctab.routers.accounting.send_emails.get_settings", mock_get_settings) + + await test_db.execute( + subscription_details.insert().values(), + dict( + subscription_id=str(subscription_id), + state=SubscriptionState("Enabled"), + display_name="a subscription", + role_assignments=[ + billing_reader, + contributor_a, + group_contributor, + reader, + contributor_b, + ], + ), + ) + rbac_list = await send_emails.get_sub_email_recipients(test_db, subscription_id) + assert rbac_list == ["janedoe@myorg", "jimdoe@myorg"] + + +@pytest.mark.asyncio +async def test_send_generic_emails( + test_db: Database, mocker: MockerFixture # pylint: disable=redefined-outer-name +) -> None: + approved_to = date.today() + timedelta(days=10) + subscription_id = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + allocated_amount=100.0, + approved=(100.0, approved_to), + spent=(0.0, 0.0), + ) + + mock_sendgrid = mocker.patch( + "rctab.routers.accounting.send_emails.send_with_sendgrid" + ) + + mock_get_recipients = AsyncMock() + mock_get_recipients.return_value = ["user1@myorg"] + mocker.patch( + "rctab.routers.accounting.send_emails.get_sub_email_recipients", + mock_get_recipients, + ) + + mock_get_settings = mocker.patch( + "rctab.routers.accounting.send_emails.get_settings" + ) + mock_get_settings.return_value.ignore_whitelist = True + mock_get_settings.return_value.stack = "teststack" + mock_get_settings.return_value.website_hostname = ( + "https://rctab-t1-teststack.azurewebsites.net/" + ) + + await send_emails.send_generic_email( + test_db, + subscription_id, + "new_approval.html", + "New approval for your Azure subscription:", + EMAIL_TYPE_SUB_APPROVAL, + {"approval_amount": 1010}, + ) + mock_sendgrid.assert_called_once_with( + "New approval for your Azure subscription: a subscription", + "new_approval.html", + { + "approval_amount": 1010, + "summary": { + "subscription_id": subscription_id, + "abolished": False, + "name": "a subscription", + "role_assignments": [ + { + "display_name": "SomePrincipal Display " "Name", + "mail": None, + "principal_id": "some-principal-id", + "role_definition_id": "some-role-def-id", + "role_name": "Billing Reader", + "scope": None, + }, + ], + "status": "Enabled", + "approved_from": date.today() - timedelta(days=365), + "approved_to": approved_to, + "approved": 100.0, + "allocated": 100.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "first_usage": date.today(), + "latest_usage": date.today(), + "always_on": False, + "desired_status": None, + "desired_status_info": None, + }, + "rctab_url": "https://rctab-t1-teststack.azurewebsites.net/", + }, + mock_get_recipients.return_value, + ) + + email_query = select([accounting_models.emails]).where( + accounting_models.emails.c.type == EMAIL_TYPE_SUB_APPROVAL + ) + email_results = await test_db.fetch_all(email_query) + email_list = [tuple(x) for x in email_results] + assert len(email_list) == 1 + + +@pytest.mark.asyncio +async def test_send_generic_emails_no_name( + test_db: Database, mocker: MockerFixture # pylint: disable=redefined-outer-name +) -> None: + """Test that we can handle subscriptions without names.""" + + approved_to = date.today() + timedelta(days=10) + subscription_id = await create_subscription( + test_db, + always_on=False, + current_state=None, # no state = no name + allocated_amount=100.0, + approved=(100.0, approved_to), + spent=(0.0, 0.0), + ) + + mock_sendgrid = mocker.patch( + "rctab.routers.accounting.send_emails.send_with_sendgrid" + ) + + mock_get_recipients = AsyncMock() + mock_get_recipients.return_value = ["user1@myorg"] + mocker.patch( + "rctab.routers.accounting.send_emails.get_sub_email_recipients", + mock_get_recipients, + ) + + mock_get_settings = mocker.patch( + "rctab.routers.accounting.send_emails.get_settings" + ) + mock_get_settings.return_value.ignore_whitelist = True + mock_get_settings.return_value.website_hostname = ( + "https://rctab-t1-teststack.azurewebsites.net/" + ) + + await send_emails.send_generic_email( + test_db, + subscription_id, + "new_approval.html", + "New approval for your Azure subscription:", + EMAIL_TYPE_SUB_APPROVAL, + {}, + ) + + mock_sendgrid.assert_called_once_with( + f"New approval for your Azure subscription: {subscription_id}", + "new_approval.html", + { + "summary": { + "subscription_id": subscription_id, + "abolished": False, + "name": None, + "role_assignments": None, + "status": None, + "approved_from": date.today() - timedelta(days=365), + "approved_to": approved_to, + "approved": 100.0, + "allocated": 100.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "first_usage": date.today(), + "latest_usage": date.today(), + "always_on": False, + "desired_status": None, + "desired_status_info": None, + }, + "rctab_url": "https://rctab-t1-teststack.azurewebsites.net/", + }, + mock_get_recipients.return_value, + ) + + +@pytest.mark.asyncio +async def test_send_generic_emails_no_recipients( + test_db: Database, mocker: MockerFixture # pylint: disable=redefined-outer-name +) -> None: + subscription_id = UUID(int=55) + + mock_sendgrid = mocker.patch( + "rctab.routers.accounting.send_emails.send_with_sendgrid" + ) + + mock_get_recipients = AsyncMock() + mock_get_recipients.return_value = [] + mocker.patch( + "rctab.routers.accounting.send_emails.get_sub_email_recipients", + mock_get_recipients, + ) + + # Add the subscription to the database, but add no role assignments. + await test_db.execute( + subscription.insert().values(), + dict( + admin=str(constants.ADMIN_UUID), + subscription_id=str(subscription_id), + ), + ) + await test_db.execute( + subscription_details.insert().values(), + dict( + subscription_id=str(subscription_id), + state=SubscriptionState("Enabled"), + display_name="a subscription", + role_assignments=[], + ), + ) + + # This call should do nothing because the subscription is not on the whitelist. + await send_emails.send_generic_email( + test_db, + subscription_id, + "new_approval.html", + "has a new approval", + EMAIL_TYPE_SUB_APPROVAL, + {"approval_amount": 1010}, + ) + mock_sendgrid.assert_not_called() + + # Disable the email whitelist, and try again. This time there should be a fallback + # email to admin email. + get_settings = mocker.patch("rctab.routers.accounting.send_emails.get_settings") + get_settings.return_value.ignore_whitelist = True + get_settings.return_value.admin_email_recipients = ["admin@mail"] + get_settings.return_value.website_hostname = ( + "https://rctab-t1-teststack.azurewebsites.net/" + ) + + await send_emails.send_generic_email( + test_db, + subscription_id, + "new_approval.html", + "New approval for your Azure subscription:", + EMAIL_TYPE_SUB_APPROVAL, + {"approval_amount": 1010}, + ) + mock_sendgrid.assert_called_once_with( + ( + "RCTab undeliverable: " + "New approval for your Azure subscription: " + "a subscription" + ), + "new_approval.html", + { + "approval_amount": 1010, + "summary": { + "subscription_id": subscription_id, + "abolished": False, + "name": "a subscription", + "role_assignments": [], + "status": "Enabled", + "approved_from": None, + "approved_to": None, + "approved": 0.0, + "allocated": 0.0, + "cost": 0.0, + "amortised_cost": 0.0, + "total_cost": 0.0, + "first_usage": None, + "latest_usage": None, + "always_on": None, + "desired_status": None, + "desired_status_info": None, + }, + "rctab_url": "https://rctab-t1-teststack.azurewebsites.net/", + }, + ["admin@mail"], + ) + + +@pytest.mark.asyncio +async def test_check_subs_nearing_expiry( + test_db: Database, mocker: MockerFixture # pylint: disable=redefined-outer-name +) -> None: + one_day = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState.ENABLED, + allocated_amount=100.0, + approved=(100.0, date.today() + timedelta(days=1)), + spent=(70.0, 0), + ) + + thirty_days = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState.ENABLED, + allocated_amount=100.0, + approved=(100.0, date.today() + timedelta(days=30)), + spent=(70.0, 0), + ) + + # forty days + await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState.ENABLED, + allocated_amount=100.0, + approved=(100.0, date.today() + timedelta(days=40)), + spent=(70.0, 0), + ) + + mock_expiry_looming = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_expiry_looming_emails", + mock_expiry_looming, + ) + + await send_emails.check_for_subs_nearing_expiry(test_db) + + mock_expiry_looming.assert_called_once_with( + test_db, + [ + ( + one_day, + date.today() + timedelta(days=1), + SubscriptionState.ENABLED.value, + ), + ( + thirty_days, + date.today() + timedelta(days=30), + SubscriptionState.ENABLED.value, + ), + ], + ) + + +@pytest.mark.asyncio +async def test_check_for_overbudget_subs( + test_db: Database, mocker: MockerFixture # pylint: disable=redefined-outer-name +) -> None: + sub_1 = await create_subscription( + test_db, + always_on=True, + current_state=SubscriptionState.ENABLED, + allocated_amount=100.0, + approved=(100.0, date.today() + timedelta(days=10)), + spent=(170.0, 0), + ) + + await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState.ENABLED, + allocated_amount=100.0, + approved=(100.0, date.today() + timedelta(days=10)), + spent=(90.0, 0), + ) + + await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState.DISABLED, + allocated_amount=100.0, + approved=(100.0, date.today() + timedelta(days=10)), + spent=(100.0, 0), + ) + + await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState.DISABLED, + allocated_amount=100.0, + approved=(100.0, date.today() + timedelta(days=10)), + spent=(170.0, 0), + ) + + mock_overbudget = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_overbudget_emails", mock_overbudget + ) + + await send_emails.check_for_overbudget_subs(test_db) + + mock_overbudget.assert_called_once_with( + test_db, + [ + (sub_1, 170.0), + ], + ) + + +@pytest.mark.asyncio +async def test_get_most_recent_emails( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + """Check that we can get the most recent email for a subscription.""" + + async def fetch_one_or_fail(query: Select) -> Record: + row = await test_db.fetch_one(query) + if not row: + raise RuntimeError("No row returned") + return row + + seven_days = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + allocated_amount=100.0, + approved=(100.0, date.today() + timedelta(days=7)), + spent=(70.0, 0), + ) + the_sub = await fetch_one_or_fail(select([accounting_models.subscription])) + sub_time = the_sub["time_created"] + + insert_statement = insert(accounting_models.emails) + await test_db.execute( + insert_statement.values( + { + "subscription_id": seven_days, + "status": 200, + "type": EMAIL_TYPE_TIMEBASED, + "recipients": "me@my.org", + "time_created": sub_time + timedelta(days=1), + } + ) + ) + await test_db.execute( + insert_statement.values( + { + "subscription_id": seven_days, + "status": 200, + "type": EMAIL_TYPE_TIMEBASED, + "recipients": "me@my.org", + "time_created": sub_time + timedelta(days=2), + } + ) + ) + await test_db.execute( + insert_statement.values( + { + "subscription_id": seven_days, + "status": 200, + "type": "budget-based", + "recipients": "me@my.org", + } + ) + ) + + email_query = send_emails.sub_time_based_emails() + rows = await test_db.fetch_all(email_query) + assert len(rows) == 1 + assert rows[0]["subscription_id"] == seven_days + assert rows[0]["time_created"] == sub_time + timedelta(days=2) + + +@pytest.mark.asyncio +async def test_send_expiry_looming_emails( + test_db: Database, mocker: MockerFixture # pylint: disable=redefined-outer-name +) -> None: + seven_days = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + allocated_amount=100.0, + approved=(100.0, date.today() + timedelta(days=7)), + spent=(70.0, 0), + ) + + mock_send = mocker.patch("rctab.routers.accounting.send_emails.send_generic_email") + + await send_emails.send_expiry_looming_emails( + test_db, + [ + ( + seven_days, + date.today() + timedelta(days=7), + SubscriptionState.ENABLED, + ) + ], + ) + + mock_send.assert_called_once_with( + test_db, + seven_days, + "expiry_looming.html", + "7 days until the expiry of your Azure subscription:", + EMAIL_TYPE_TIMEBASED, + {"days": (timedelta(days=7)).days, "extra_info": str((timedelta(days=7)).days)}, + ) + + +@pytest.mark.asyncio +async def test_send_overbudget_emails( + test_db: Database, mocker: MockerFixture # pylint: disable=redefined-outer-name +) -> None: + sub_1 = await create_subscription( + test_db, + always_on=True, + current_state=SubscriptionState.ENABLED, + allocated_amount=100.0, + approved=(100.0, date.today() + timedelta(days=10)), + spent=(170.0, 0), + ) + + mock_send = mocker.patch("rctab.routers.accounting.send_emails.send_with_sendgrid") + mock_get_settings = mocker.patch( + "rctab.routers.accounting.send_emails.get_settings" + ) + + mock_get_settings.return_value.ignore_whitelist = True + mock_get_settings.return_value.admin_email_recipients = ["admin@mail"] + + async def send_over_budget() -> None: + await send_emails.send_overbudget_emails( + test_db, + [ + ( + sub_1, + 170.0, + ) + ], + ) + + await send_over_budget() + + assert len(mock_send.call_args_list) == 1 + assert ( + mock_send.call_args_list[0].args[0] + == "RCTab undeliverable: 170.0% of allocated budget used by your Azure subscription: a subscription" + ) + assert mock_send.call_args_list[0].args[1] == "usage_alert.html" + assert mock_send.call_args_list[0].args[2]["percentage_used"] == 170.0 + assert mock_send.call_args_list[0].args[3] == ["admin@mail"] + + await send_over_budget() + assert len(mock_send.call_args_list) == 1 + + +@pytest.mark.asyncio +async def test_expiry_looming_doesnt_resend( + test_db: Database, mocker: MockerFixture # pylint: disable=redefined-outer-name +) -> None: + """Check that we take no action if not necessary.""" + + seven_days = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + allocated_amount=100.0, + approved=(100.0, date.today() + timedelta(days=7)), + spent=(70.0, 0), + ) + + insert_statement = insert(accounting_models.emails) + await test_db.execute( + insert_statement.values( + { + "subscription_id": seven_days, + "status": 200, + "type": EMAIL_TYPE_TIMEBASED, + "recipients": "me@my.org", + } + ) + ) + + mock_send = mocker.patch("rctab.routers.accounting.send_emails.send_with_sendgrid") + + await send_emails.send_expiry_looming_emails( + test_db, + [ + ( + seven_days, + date.today() + timedelta(days=7), + SubscriptionState.DISABLED, + ) + ], + ) + + mock_send.assert_not_called() + + +def test_should_send_expiry_email() -> None: + # pylint: disable=using-constant-test + # pylint: disable=invalid-name + + # If the expiry is a long way away, we don't want to send an email + date_of_expiry = date.today() + timedelta(days=31) + date_of_last_email = None + assert ( + send_emails.should_send_expiry_email( + date_of_expiry, date_of_last_email, SubscriptionState.ENABLED + ) + is False + ) + + # If we have already sent an email during this period, + # i.e. between 30 and 7 days before expiry, + # we should not send an email + date_of_expiry = date.today() + timedelta(days=30) + date_of_last_email = date.today() + assert ( + send_emails.should_send_expiry_email( + date_of_expiry, date_of_last_email, SubscriptionState.ENABLED + ) + is False + ) + + # If we haven't already sent an email, we should send one... + date_of_expiry = date.today() + timedelta(days=30) + date_of_last_email = None + assert ( + send_emails.should_send_expiry_email( + date_of_expiry, date_of_last_email, SubscriptionState.ENABLED + ) + is True + ) + + # ...unless the subscription is disabled already for some other reason. + date_of_expiry = date.today() + timedelta(days=30) + date_of_last_email = None + assert ( + send_emails.should_send_expiry_email( + date_of_expiry, date_of_last_email, SubscriptionState.DISABLED + ) + is False + ) + + # After the 30-day email, we want a reminder at 7 days + date_of_expiry = date.today() + timedelta(days=7) + date_of_last_email = date.today() - timedelta(days=1) + assert ( + send_emails.should_send_expiry_email( + date_of_expiry, date_of_last_email, SubscriptionState.ENABLED + ) + is True + ) + + # After the 7-day email, we also want a reminder at 1 day + date_of_expiry = date.today() + timedelta(days=1) + date_of_last_email = date.today() - timedelta(days=1) + assert ( + send_emails.should_send_expiry_email( + date_of_expiry, date_of_last_email, SubscriptionState.ENABLED + ) + is True + ) + + # If the subscription has already expired, we should not send an email + # because other emails will alert the owners... + date_of_expiry = date.today() - timedelta(days=1) + date_of_last_email = None + assert ( + send_emails.should_send_expiry_email( + date_of_expiry, date_of_last_email, SubscriptionState.DISABLED + ) + is False + ) + + # ...unless this is an "always on" subscription, in which case they should + # be emailed daily because they ought to put in an approval request... + date_of_expiry = date.today() - timedelta(days=1) + date_of_last_email = date.today() - timedelta(days=1) + assert ( + send_emails.should_send_expiry_email( + date_of_expiry, date_of_last_email, SubscriptionState.ENABLED + ) + is True + ) + + # ...which should also hold if there is no previous email... + date_of_expiry = date.today() - timedelta(days=1) + date_of_last_email = None + assert ( + send_emails.should_send_expiry_email( + date_of_expiry, date_of_last_email, SubscriptionState.ENABLED + ) + is True + ) + + # ...but they should only receive one email per day... + date_of_expiry = date.today() - timedelta(days=1) + date_of_last_email = date.today() + assert ( + send_emails.should_send_expiry_email( + date_of_expiry, date_of_last_email, SubscriptionState.ENABLED + ) + is False + ) + + # ...and none on the day of expiry + date_of_expiry = date.today() + date_of_last_email = date.today() - timedelta(days=1) + assert ( + send_emails.should_send_expiry_email( + date_of_expiry, date_of_last_email, SubscriptionState.ENABLED + ) + is False + ) + + # If we have sent an email, but it is now out of date + # e.g. as a result of a new approval, + # we should send an email + date_of_expiry = date.today() + timedelta(days=30) + date_of_last_email = date.today() - timedelta(days=1) + assert ( + send_emails.should_send_expiry_email( + date_of_expiry, date_of_last_email, SubscriptionState.ENABLED + ) + is True + ) + + if False: + # Visualise! + table = [] + for x in range(32): + row = [] + for y in range(32): + date_of_expiry = date.today() + timedelta(days=x) + date_of_last_email = date.today() - timedelta(days=y) + row.append( + send_emails.should_send_expiry_email( + date_of_expiry, date_of_last_email, SubscriptionState.DISABLED + ) + ) + table.append(row) + + print("\n") + for row in table: + string = ["True " if x else "False" for x in row] + print(string) + + +@pytest.mark.asyncio +async def test_usage_email_context_manager( + test_db: Database, mocker: MockerFixture # pylint: disable=redefined-outer-name +) -> None: + subscription_ids = [] + # These should be 20 below each threshold + starting_usages = (30, 55, 70, 75) + for starting_usage in starting_usages: + subscription_ids.append( + await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + allocated_amount=100.0, + approved=(100.0, date.today() + timedelta(days=7)), + spent=(starting_usage, 0), + ) + ) + + mock_send = AsyncMock() + mocker.patch("rctab.routers.accounting.send_emails.send_generic_email", mock_send) + + async with UsageEmailContextManager(test_db): + for subscription_id in subscription_ids: + await test_db.execute( + usage.insert().values(), + dict( + subscription_id=str(subscription_id), + id=str(UUID(int=random.randint(0, 2**32 - 1))), + total_cost=21.0, # To put us over the next highest threshold + invoice_section="", + date=date.today(), + ), + ) + await refresh_materialised_view(test_db, usage_view) + + expected = [ + mocker.call( + test_db, + subscription_id, + "usage_alert.html", + str(starting_usage + 20.0) + + "% of allocated budget " + + "used by your Azure subscription:", + EMAIL_TYPE_USAGE_ALERT, + { + "percentage_used": starting_usage + 20.0, + "extra_info": str(starting_usage + 20.0), + }, + ) + for subscription_id, starting_usage in zip( + subscription_ids, starting_usages + ) + ] + try: + mock_send.assert_has_calls(expected) + except AssertionError as e: + print_list_diff(expected, mock_send.call_args_list) + raise e + + +@pytest.mark.asyncio +async def test_catches_params_missing( + test_db: Database, mocker: MockerFixture # pylint: disable=redefined-outer-name +) -> None: + """If the key email params are missing, we should log instead.""" + + mock_logger = mocker.patch("rctab.routers.accounting.send_emails.logger") + + get_settings = mocker.patch("rctab.routers.accounting.send_emails.get_settings") + get_settings.return_value.ignore_whitelist = True + + get_sub_email_recipients = mocker.patch( + "rctab.routers.accounting.send_emails.get_sub_email_recipients" + ) + get_sub_email_recipients.return_value = ["user1@mail.com", "user2@mail.com"] + + mock_send = mocker.patch("rctab.routers.accounting.send_emails.send_with_sendgrid") + mock_send.side_effect = MissingEmailParamsError( + subject="TestMissingEmail", + recipients=["user1@mail.com", "user2@mail.com"], + from_email="", + message="test-message", + ) + + test_subscription_id = UUID(int=786) + + # add subscriptions to database + await test_db.execute( + subscription.insert().values(), + dict( + admin=str(constants.ADMIN_UUID), + subscription_id=str(test_subscription_id), + time_created=datetime.now(timezone.utc), + ), + ) + + # add row to database + await send_emails.send_generic_email( + test_db, + test_subscription_id, + "test-template-name", + "test-subject-prefix", + "test-email-type", + {}, + ) + + # check the last row added is as expected + last_row_query = select([accounting_models.failed_emails]).order_by( + accounting_models.failed_emails.c.id.desc() + ) + last_row_result = await test_db.fetch_one(last_row_query) + assert last_row_result is not None + last_row = {x: last_row_result[x] for x in last_row_result} + + # check log message for row added + mock_logger.error.assert_called_with( + "'%s' email failed to send to subscription '%s' due to missing " + "api_key or send email address.\n" + "It has been logged in the 'failed_emails' table with id=%s.\n" + "Use 'get_failed_emails.py' to retrieve it to send manually.", + "test-email-type", + test_subscription_id, + last_row["id"], + ) + + # check last row in database contains the MissingEmailParamsError params + assert len(last_row) == 9 + assert last_row["subscription_id"] == test_subscription_id + assert last_row["type"] == "test-email-type" + assert last_row["subject"] == "TestMissingEmail" + assert last_row["from_email"] == "" + assert last_row["recipients"] == "user1@mail.com;user2@mail.com" + assert last_row["time_updated"] is None + assert last_row["message"] == "test-message" + + +@pytest.mark.asyncio +async def test_get_new_subscriptions_since( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + todays_subscription_id = UUID(int=random.randint(0, (2**32) - 1)) + yesterdays_subscription_id = UUID(int=random.randint(0, (2**32) - 1)) + + new_subscriptions = await get_new_subscriptions_since( + test_db, datetime.now(timezone.utc) - timedelta(seconds=1) + ) + assert not new_subscriptions + + await test_db.execute( + subscription.insert().values(), + dict( + admin=str(constants.ADMIN_UUID), + subscription_id=str(yesterdays_subscription_id), + time_created=datetime.now(timezone.utc) - timedelta(days=1), + ), + ) + + await test_db.execute( + subscription.insert().values(), + dict( + admin=str(constants.ADMIN_UUID), + subscription_id=str(todays_subscription_id), + time_created=datetime.now(timezone.utc), + ), + ) + + new_subscriptions = await get_new_subscriptions_since( + test_db, datetime.now(timezone.utc) - timedelta(seconds=1) + ) + assert new_subscriptions[0]["subscription_id"] == todays_subscription_id + assert len(new_subscriptions) == 1 + + +@pytest.mark.asyncio +async def test_get_status_changes_since( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + test_subscription_id = UUID(int=random.randint(0, (2**32) - 1)) + await test_db.execute( + subscription.insert().values(), + dict( + admin=str(constants.ADMIN_UUID), + subscription_id=str(test_subscription_id), + time_created=datetime.now(timezone.utc) - timedelta(days=1), + ), + ) + + await test_db.execute( + subscription_details.insert().values(), + dict( + subscription_id=test_subscription_id, + display_name="my test subscription", + state="Enabled", + time_created=datetime.now(timezone.utc) - timedelta(seconds=5), + ), + ) + + await test_db.execute( + subscription_details.insert().values(), + dict( + subscription_id=test_subscription_id, + display_name="my test subscription", + state="Disabled", + ), + ) + + await test_db.execute( + subscription_details.insert().values(), + dict( + subscription_id=test_subscription_id, + display_name="my test subscription", + state="Enabled", + ), + ) + status_changes = await get_subscription_details_since( + test_db, test_subscription_id, datetime.now(timezone.utc) - timedelta(seconds=1) + ) + assert status_changes and len(status_changes) == 2 + assert status_changes[0]["id"] <= status_changes[1]["id"] + assert status_changes[0]["state"] == "Disabled" + assert status_changes[1]["state"] == "Enabled" + + +@pytest.mark.asyncio +async def test_get_emails_sent_since( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + test_subscription_id = UUID(int=random.randint(0, (2**32) - 1)) + another_test_subscription_id = UUID(int=random.randint(0, (2**32) - 1)) + + # add subscriptions and details to db + await test_db.execute( + subscription.insert().values(), + dict( + admin=str(constants.ADMIN_UUID), + subscription_id=str(test_subscription_id), + time_created=datetime.now(timezone.utc) - timedelta(days=1), + ), + ) + await test_db.execute( + subscription.insert().values(), + dict( + admin=str(constants.ADMIN_UUID), + subscription_id=str(another_test_subscription_id), + time_created=datetime.now(timezone.utc) - timedelta(days=1), + ), + ) + await test_db.execute( + subscription_details.insert().values(), + dict( + subscription_id=test_subscription_id, + display_name="my test subscription", + state="Enabled", + ), + ) + await test_db.execute( + subscription_details.insert().values(), + dict( + subscription_id=another_test_subscription_id, + display_name="i love testing", + state="Enabled", + ), + ) + # add entries to email table + await test_db.execute( + emails.insert().values(), + dict( + subscription_id=str(test_subscription_id), + status=200, # sendgrid return code + type=EMAIL_TYPE_SUB_WELCOME, + recipients="Some happy email recipient", + time_created=datetime.now(timezone.utc) - timedelta(seconds=1), + ), + ) + + await test_db.execute( + emails.insert().values(), + dict( + subscription_id=str(test_subscription_id), + status=200, # sendgrid return code + type=EMAIL_TYPE_SUMMARY, + recipients="Some happy email recipient", + time_created=datetime.now(timezone.utc) - timedelta(seconds=1), + ), + ) + await test_db.execute( + emails.insert().values(), + dict( + subscription_id=str(another_test_subscription_id), + status=200, # sendgrid return code + type=EMAIL_TYPE_USAGE_ALERT, + recipients="Some happy email recipient", + time_created=datetime.now(timezone.utc) - timedelta(seconds=120), + extra_info="75.0", + ), + ) + await test_db.execute( + emails.insert().values(), + dict( + subscription_id=str(another_test_subscription_id), + status=200, # sendgrid return code + type=EMAIL_TYPE_SUB_APPROVAL, + recipients="Some happy email recipient", + time_created=datetime.now(timezone.utc) - timedelta(seconds=500), + ), + ) + # no emails sent for queried time perios + emails_sent = await get_emails_sent_since(test_db, datetime.now(timezone.utc)) + assert not emails_sent + + # we only have emails sent within last 10 seconds for one subscription id + emails_sent = await get_emails_sent_since( + test_db, datetime.now(timezone.utc) - timedelta(seconds=10) + ) + assert len(emails_sent) == 1 + assert emails_sent[0]["name"] == "my test subscription" + assert emails_sent[0]["subscription_id"] == test_subscription_id + assert len(emails_sent[0]["emails_sent"]) == 1 + + # we have emails sent within last 200 seconds for two subscription ids + emails_sent = await get_emails_sent_since( + test_db, datetime.now(timezone.utc) - timedelta(seconds=200) + ) + emails_another_test_sub = list( + filter( + lambda l: l["subscription_id"] == another_test_subscription_id, emails_sent + ) + ) + assert emails_another_test_sub[0]["emails_sent"][0]["extra_info"] == "75.0" + + # we have emails sent within last 600 seconds for two subscription ids + emails_sent = await get_emails_sent_since( + test_db, datetime.now(timezone.utc) - timedelta(seconds=600) + ) + assert len(emails_sent) == 2 + assert emails_sent[0]["subscription_id"] in [ + test_subscription_id, + another_test_subscription_id, + ] + assert emails_sent[1]["subscription_id"] in [ + test_subscription_id, + another_test_subscription_id, + ] + emails_test_sub = list( + filter(lambda l: l["subscription_id"] == test_subscription_id, emails_sent) + ) + assert len(emails_test_sub[0]["emails_sent"]) == 1 + emails_another_test_sub = list( + filter( + lambda l: l["subscription_id"] == another_test_subscription_id, emails_sent + ) + ) + assert len(emails_another_test_sub[0]["emails_sent"]) == 2 + + +@pytest.mark.asyncio +async def test_get_finance_entries_since( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + test_subscription_id = await create_subscription( + test_db, current_state=SubscriptionState("Enabled") + ) + another_test_subscription_id = await create_subscription( + test_db, current_state=SubscriptionState("Enabled") + ) + # insert entries to finance table + await test_db.execute( + finance.insert().values(), + dict( + subscription_id=test_subscription_id, + ticket="test_ticket", + amount=-50.0, + date_from=date.today(), + date_to=date.today(), + priority=100, + finance_code="test_finance_code", + time_created=datetime.now(timezone.utc) - timedelta(minutes=60), + **ADMIN_DICT, + ), + ) + + await test_db.execute( + finance.insert().values(), + dict( + subscription_id=test_subscription_id, + ticket="test_ticket", + amount=3000.0, + date_from=date.today(), + date_to=date.today(), + priority=100, + finance_code="test_finance_code", + time_created=datetime.now(timezone.utc) - timedelta(days=1), + **ADMIN_DICT, + ), + ) + + # no entries for time since + entries = await get_finance_entries_since( + test_db, datetime.now(timezone.utc) - timedelta(seconds=10) + ) + assert not entries + + # entries for one subscription + entries = await get_finance_entries_since( + test_db, datetime.now(timezone.utc) - timedelta(days=2) + ) + assert len(entries) == 1 + assert len(entries[0]["finance_entry"]) == 2 + assert sum(x["amount"] for x in entries[0]["finance_entry"]) == 2950.0 + + # add an entry for another subscription + await test_db.execute( + finance.insert().values(), + dict( + subscription_id=another_test_subscription_id, + ticket="another_test_ticket", + amount=900.0, + date_from=date.today(), + date_to=date.today(), + priority=100, + finance_code="another_test_finance_code", + time_created=datetime.now(timezone.utc) - timedelta(days=1), + **ADMIN_DICT, + ), + ) + + entries = await get_finance_entries_since( + test_db, datetime.now(timezone.utc) - timedelta(days=1.5) + ) + assert len(entries) == 2 + assert entries[0]["subscription_id"] in [ + test_subscription_id, + another_test_subscription_id, + ] + assert entries[1]["subscription_id"] in [ + test_subscription_id, + another_test_subscription_id, + ] + + +@pytest.mark.asyncio +async def test_prepare_summary_email( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + test_subscription_id = UUID(int=random.randint(0, (2**32) - 1)) + another_test_subscription_id = UUID(int=random.randint(0, (2**32) - 1)) + yet_another_test_subscription_id = UUID(int=random.randint(0, (2**32) - 1)) + # add subscriptions to database + await test_db.execute( + subscription.insert().values(), + dict( + admin=str(constants.ADMIN_UUID), + subscription_id=str(test_subscription_id), + time_created=datetime.now(timezone.utc), + ), + ) + await test_db.execute( + subscription.insert().values(), + dict( + admin=str(constants.ADMIN_UUID), + subscription_id=str(another_test_subscription_id), + time_created=datetime.now(timezone.utc), + ), + ) + await test_db.execute( + subscription.insert().values(), + dict( + admin=str(constants.ADMIN_UUID), + subscription_id=str(yet_another_test_subscription_id), + time_created=datetime.now(timezone.utc), + ), + ) + # add details for created test subscriptions + await test_db.execute( + subscription_details.insert().values(), + dict( + subscription_id=test_subscription_id, + display_name="my test subscription", + state="Enabled", + ), + ) + + await test_db.execute( + subscription_details.insert().values(), + dict( + subscription_id=another_test_subscription_id, + display_name="my other test subscription", + state="Enabled", + ), + ) + await test_db.execute( + subscription_details.insert().values(), + dict( + subscription_id=yet_another_test_subscription_id, + display_name="my latest test subscription", + state="Enabled", + ), + ) + + summary_data = await prepare_summary_email( + test_db, datetime.now(timezone.utc) - timedelta(seconds=5) + ) + + assert len(summary_data["new_subscriptions"]) == 3 # number of new subscriptions + assert sorted([x["name"] for x in summary_data["new_subscriptions"]]) == sorted( + [ + "my test subscription", + "my other test subscription", + "my latest test subscription", + ] + ) + + # for testing status changes + await test_db.execute( + subscription_details.insert().values(), + dict( + subscription_id=yet_another_test_subscription_id, + display_name="my latest test subscription", + state="Disabled", + ), + ) + + summary_data = await prepare_summary_email( + test_db, datetime.now(timezone.utc) - timedelta(seconds=5) + ) + + assert len(summary_data["status_changes"]) == 1 + assert summary_data["status_changes"][0]["old_status"]["state"] == "Enabled" + assert summary_data["status_changes"][0]["new_status"]["state"] == "Disabled" + + await test_db.execute( + subscription_details.insert().values(), + dict( + subscription_id=another_test_subscription_id, + display_name="my latest test subscription", + state="Enabled", + ), + ) + + summary_data = await prepare_summary_email( + test_db, datetime.now(timezone.utc) - timedelta(seconds=5) + ) + assert len(summary_data["status_changes"]) == 2 + changes_another_test = [ + x + for x in summary_data["status_changes"] + if x["new_status"].get("subscription_id") == another_test_subscription_id + ] + + assert ( + changes_another_test[0]["old_status"]["display_name"] + == "my other test subscription" + ) + assert ( + changes_another_test[0]["new_status"]["display_name"] + == "my latest test subscription" + ) + + # add some test data to approvals and allocations table + await test_db.execute( + approvals.insert().values(), + dict( + subscription_id=str(test_subscription_id), + admin=str(constants.ADMIN_UUID), + currency="GBP", + amount=100.0, + date_from=datetime.now(timezone.utc) - timedelta(days=3), + date_to=datetime.now(timezone.utc) + timedelta(days=30), + time_created=datetime.now(timezone.utc) - timedelta(seconds=3), + ), + ) + await test_db.execute( + approvals.insert().values(), + dict( + subscription_id=str(test_subscription_id), + admin=str(constants.ADMIN_UUID), + currency="GBP", + amount=50.0, + date_from=datetime.now(timezone.utc) - timedelta(days=3), + date_to=datetime.now(timezone.utc) + timedelta(days=30), + time_created=datetime.now(timezone.utc), + ), + ) + + await test_db.execute( + allocations.insert().values(), + dict( + subscription_id=str(test_subscription_id), + admin=str(constants.ADMIN_UUID), + currency="GBP", + amount=80.0, + time_created=datetime.now(timezone.utc), + ), + ) + + summary_data = await prepare_summary_email( + test_db, datetime.now(timezone.utc) - timedelta(seconds=5) + ) + assert len(summary_data["new_approvals_and_allocations"][0]["approvals"]) == 2 + assert sum(summary_data["new_approvals_and_allocations"][0]["approvals"]) == 150 + assert ( + summary_data["new_approvals_and_allocations"][0]["details"]["name"] + == "my test subscription" + ) + assert sum(summary_data["new_approvals_and_allocations"][0]["allocations"]) == 80 + + # add some test data to emails table + await test_db.execute( + emails.insert().values(), + dict( + subscription_id=str(test_subscription_id), + status=1, + type=EMAIL_TYPE_SUMMARY, + recipients="Some happy email recipient", + time_created=datetime.now(timezone.utc) - timedelta(seconds=1), + ), + ) + await test_db.execute( + emails.insert().values(), + dict( + subscription_id=str(test_subscription_id), + status=1, + type="welcome", + recipients="Some happy email recipient", + time_created=datetime.now(timezone.utc) - timedelta(seconds=1), + ), + ) + + summary_data = await prepare_summary_email( + test_db, datetime.now(timezone.utc) - timedelta(seconds=5) + ) + + assert len(summary_data["notifications_sent"]) == 1 + assert summary_data["notifications_sent"][0]["emails_sent"][0]["type"] == "welcome" + + +@pytest.mark.asyncio +async def test_get_allocations_since( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + time_since_last_summary_email = datetime.now(timezone.utc) - timedelta(days=1) + # make new subscription + test_subscription = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + ) + another_test_subscription = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + ) + + # insert allocation information predating last summary email + await test_db.execute( + allocations.insert().values(), + dict( + subscription_id=str(test_subscription), + admin=str(constants.ADMIN_UUID), + currency="GBP", + amount=100.0, + time_created=datetime.now(timezone.utc) - timedelta(days=3), + ), + ) + + # no allocation data found + allocations_data = await get_allocations_since( + test_db, test_subscription, time_since_last_summary_email + ) + assert not allocations_data + + # insert allocation + await test_db.execute( + allocations.insert().values(), + dict( + subscription_id=str(test_subscription), + admin=str(constants.ADMIN_UUID), + currency="GBP", + amount=200.0, + time_created=datetime.now(timezone.utc) - timedelta(minutes=300), + ), + ) + # one allocation found + allocations_data = await get_allocations_since( + test_db, test_subscription, time_since_last_summary_email + ) + assert len(allocations_data) == 1 + assert sum(allocations_data) == 200 + + # insert negative allocation + await test_db.execute( + allocations.insert().values(), + dict( + subscription_id=str(test_subscription), + admin=str(constants.ADMIN_UUID), + amount=-50.0, + currency="GBP", + time_created=datetime.now(timezone.utc) - timedelta(minutes=240), + ), + ) + allocations_data = await get_allocations_since( + test_db, test_subscription, time_since_last_summary_email + ) + assert len(allocations_data) == 2 + assert sum(allocations_data) == 150 + + # insert allocation for other subscription + await test_db.execute( + allocations.insert().values(), + dict( + subscription_id=str(another_test_subscription), + admin=str(constants.ADMIN_UUID), + amount=-50.0, + currency="GBP", + time_created=datetime.now(timezone.utc) - timedelta(minutes=240), + ), + ) + allocations_data = await get_allocations_since( + test_db, test_subscription, time_since_last_summary_email + ) + assert len(allocations_data) == 2 + assert sum(allocations_data) == 150 + + +@pytest.mark.asyncio +async def test_get_approvals_since( + test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + time_since_last_summary_email = datetime.now(timezone.utc) - timedelta(days=1) + test_subscription = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + ) + another_test_subscription = await create_subscription( + test_db, + always_on=False, + current_state=SubscriptionState("Enabled"), + ) + + # insert approval information predating last summary email + await test_db.execute( + approvals.insert().values(), + dict( + subscription_id=str(test_subscription), + admin=str(constants.ADMIN_UUID), + currency="GBP", + amount=100.0, + date_from=datetime.now(timezone.utc) - timedelta(days=3), + date_to=datetime.now(timezone.utc) + timedelta(days=30), + time_created=datetime.now(timezone.utc) - timedelta(days=3), + ), + ) + approvals_data = await get_approvals_since( + test_db, test_subscription, time_since_last_summary_email + ) + assert len(approvals_data) == 0 + + # add a more recent approval + await test_db.execute( + approvals.insert().values(), + dict( + subscription_id=str(test_subscription), + admin=str(constants.ADMIN_UUID), + currency="GBP", + amount=200.0, + date_from=datetime.now(timezone.utc) - timedelta(minutes=60), + date_to=datetime.now(timezone.utc) + timedelta(days=30), + time_created=datetime.now(timezone.utc) - timedelta(minutes=60), + ), + ) + approvals_data = await get_approvals_since( + test_db, test_subscription, time_since_last_summary_email + ) + assert len(approvals_data) == 1 + assert sum(approvals_data) == 200 + + # and another approval BUT for a different subscription + await test_db.execute( + approvals.insert().values(), + dict( + subscription_id=str(another_test_subscription), + admin=str(constants.ADMIN_UUID), + currency="GBP", + amount=500.0, + date_from=datetime.now(timezone.utc) - timedelta(minutes=60), + date_to=datetime.now(timezone.utc) + timedelta(days=30), + time_created=datetime.now(timezone.utc) - timedelta(minutes=60), + ), + ) + approvals_data = await get_approvals_since( + test_db, test_subscription, time_since_last_summary_email + ) + assert len(approvals_data) == 1 + assert sum(approvals_data) == 200 diff --git a/tests/test_routes/test_status.py b/tests/test_routes/test_status.py new file mode 100644 index 0000000..e848ef9 --- /dev/null +++ b/tests/test_routes/test_status.py @@ -0,0 +1,700 @@ +import datetime +from typing import Any, Dict, List, Tuple +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest +import pytest_mock +from databases import Database +from fastapi import FastAPI +from fastapi.testclient import TestClient +from hypothesis import given, settings +from hypothesis import strategies as st +from sqlalchemy import select + +from rctab.constants import ADMIN_OID +from rctab.crud.accounting_models import subscription_details +from rctab.crud.schema import ( + AllSubscriptionStatus, + Approval, + RoleAssignment, + SubscriptionState, + SubscriptionStatus, +) +from rctab.routers.accounting import status +from rctab.routers.accounting.approvals import post_approval +from rctab.routers.accounting.routes import get_subscriptions_summary +from rctab.routers.accounting.status import post_status +from tests.test_routes import constants +from tests.test_routes.test_routes import test_db # pylint: disable=unused-import + + +@settings(deadline=None, max_examples=10) +@given(st.lists(st.builds(SubscriptionStatus), min_size=2, max_size=20, unique=True)) +def test_post_status( + app_with_signed_status_token: Tuple[FastAPI, str], + mocker: pytest_mock.MockerFixture, + status_list: List[SubscriptionStatus], +) -> None: + auth_app, token = app_with_signed_status_token + + with TestClient(auth_app) as client: + all_status = AllSubscriptionStatus(status_list=status_list) + + mock_refresh = AsyncMock() + mocker.patch( + "rctab.routers.accounting.status.refresh_desired_states", mock_refresh + ) + + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + resp = client.post( + "accounting/all-status", + content=all_status.json(), + headers={"authorization": "Bearer " + token}, + ) + assert resp.status_code == 200 + + # Posting the status data should have the side effect of + # refreshing the desired states + mock_refresh.assert_called_once_with( + UUID(ADMIN_OID), [x.subscription_id for x in all_status.status_list] + ) + + # Check that we can POST the same status again without issue (idempotency). + resp = client.post( + "accounting/all-status", + content=all_status.json(), + headers={"authorization": "Bearer " + token}, + ) + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_post_status_sends_welcome( + test_db: Database, # pylint: disable=redefined-outer-name + mocker: pytest_mock.MockerFixture, +) -> None: + # pylint: disable=unexpected-keyword-arg, too-many-statements + + mock_get_settings = mocker.patch( + "rctab.routers.accounting.send_emails.get_settings" + ) + mock_get_settings.return_value.ignore_whitelist = True + # These values should be the same as the defaults + mock_get_settings.return_value.notifiable_roles = ["Contributor"] + mock_get_settings.return_value.roles_filter = ["Contributor"] + mock_get_settings.return_value.ticker = "t1" + mock_get_settings.return_value.website_hostname = ( + "https://rctab-t1-teststack.azurewebsites.net/" + ) + + mock_send_email = mocker.patch( + "rctab.routers.accounting.send_emails.send_with_sendgrid" + ) + mock_send_email.return_value = 200 + + mock_refresh = AsyncMock() + mocker.patch("rctab.routers.accounting.status.refresh_desired_states", mock_refresh) + + new_detail = SubscriptionStatus( + subscription_id=constants.TEST_SUB_UUID, + display_name="old-name", + state=SubscriptionState("Enabled"), + # We need some assignments so there's + # someone to send a welcome email to + role_assignments=( + RoleAssignment( + role_definition_id=99, + role_name="Contributor", + principal_id=1010, + display_name="MyUser", + mail="myuser@example.com", + scope=f"/subscription_id/{constants.TEST_SUB_UUID}", + ), + ), + ) + + await post_status( + AllSubscriptionStatus(status_list=[new_detail]), {"fake": "authentication"} + ) + + template_data: Dict[str, Any] = {} + sub_summary = await test_db.fetch_one( + get_subscriptions_summary(sub_id=new_detail.subscription_id, execute=False) + ) + if sub_summary: + template_data["summary"] = dict(sub_summary) + template_data["rctab_url"] = "https://rctab-t1-teststack.azurewebsites.net/" + # The first status object should send a welcome email... + assert mock_send_email.call_count == 1 + mock_send_email.assert_called_once_with( + "You have a new subscription on the Azure platform: " + "old-name", + "welcome.html", + template_data, + ["myuser@example.com"], + ) + + # ...and re-POSTing it shouldn't send one... + await post_status( + AllSubscriptionStatus(status_list=[new_detail]), {"fake": "authentication"} + ) + assert mock_send_email.call_count == 1 + + +@pytest.mark.asyncio +async def test_post_status_sends_status_change_name( + test_db: Database, # pylint: disable=redefined-outer-name + mocker: pytest_mock.MockerFixture, +) -> None: + mock_get_settings = mocker.patch( + "rctab.routers.accounting.send_emails.get_settings" + ) + mock_get_settings.return_value.ignore_whitelist = True + # These values should be the same as the defaults + mock_get_settings.return_value.notifiable_roles = ["Contributor"] + mock_get_settings.return_value.roles_filter = ["Contributor"] + mock_get_settings.return_value.ticker = "t1" + mock_get_settings.return_value.website_hostname = ( + "https://rctab-t1-teststack.azurewebsites.net/" + ) + + mock_send_email = mocker.patch( + "rctab.routers.accounting.send_emails.send_with_sendgrid" + ) + mock_send_email.return_value = 200 + + mock_refresh = AsyncMock() + mocker.patch("rctab.routers.accounting.status.refresh_desired_states", mock_refresh) + + first_assignment = RoleAssignment( + role_definition_id=99, + role_name="Contributor", + principal_id=1010, + display_name="MyUser", + mail="myuser@example.com", + scope=f"/subscription_id/{constants.TEST_SUB_UUID}", + ) + + new_detail = SubscriptionStatus( + subscription_id=constants.TEST_SUB_UUID, + display_name="old-name", + state=SubscriptionState("Enabled"), + # We need some assignments so there's + # someone to send a welcome email to + role_assignments=(first_assignment,), + ) + await post_status( + AllSubscriptionStatus(status_list=[new_detail]), {"fake": "authentication"} + ) + + status_change_detail = SubscriptionStatus( + subscription_id=constants.TEST_SUB_UUID, + display_name="new-name", + state=SubscriptionState("Enabled"), + role_assignments=(first_assignment,), + ) + + await post_status( + AllSubscriptionStatus(status_list=[status_change_detail]), + {"fake": "authentication"}, + ) + + template_data: Dict[str, Any] = {} + sub_summary = await test_db.fetch_one( + get_subscriptions_summary(sub_id=new_detail.subscription_id, execute=False) + ) + if sub_summary: + template_data["summary"] = dict(sub_summary) + template_data["old_status"] = new_detail + template_data["new_status"] = status_change_detail + template_data["rctab_url"] = "https://rctab-t1-teststack.azurewebsites.net/" + + # ...subsequent ones should send status update emails... + assert mock_send_email.call_count == 2 + mock_send_email.assert_called_with( + "There has been a status change for your Azure subscription: " + "new-name", + "status_change.html", + template_data, + ["myuser@example.com"], + ) + + +@pytest.mark.asyncio +async def test_post_status_sends_status_change_roles( + test_db: Database, # pylint: disable=redefined-outer-name + mocker: pytest_mock.MockerFixture, +) -> None: + mock_get_settings = mocker.patch( + "rctab.routers.accounting.send_emails.get_settings" + ) + mock_get_settings.return_value.ignore_whitelist = True + # These values should be the same as the defaults + mock_get_settings.return_value.notifiable_roles = ["Contributor"] + mock_get_settings.return_value.roles_filter = ["Contributor"] + mock_get_settings.return_value.ticker = "t1" + mock_get_settings.return_value.website_hostname = ( + "https://rctab-t1-teststack.azurewebsites.net/" + ) + + mock_send_email = mocker.patch( + "rctab.routers.accounting.send_emails.send_with_sendgrid" + ) + mock_send_email.return_value = 200 + + mock_refresh = AsyncMock() + mocker.patch("rctab.routers.accounting.status.refresh_desired_states", mock_refresh) + + first_assignment = RoleAssignment( + role_definition_id=99, + role_name="Contributor", + principal_id=1010, + display_name="MyUser", + mail="myuser@example.com", + scope=f"/subscription_id/{constants.TEST_SUB_UUID}", + ) + + new_detail = SubscriptionStatus( + subscription_id=constants.TEST_SUB_UUID, + display_name="name", + state=SubscriptionState("Enabled"), + # We need some assignments so there's + # someone to send a welcome email to + role_assignments=(first_assignment,), + ) + await post_status( + AllSubscriptionStatus(status_list=[new_detail]), {"fake": "authentication"} + ) + + second_assignment = RoleAssignment( + role_definition_id="666", + role_name="Contributor", + principal_id="777", + display_name="Leif Erikson", + mail="leif@poee.org", + scope=f"a/scope/string/{constants.TEST_SUB_UUID}", + ) + # add a new role assignment + await post_status( + AllSubscriptionStatus( + status_list=[ + SubscriptionStatus( + subscription_id=constants.TEST_SUB_UUID, + display_name="name", + state=SubscriptionState("Enabled"), + role_assignments=(first_assignment, second_assignment), + ) + ] + ), + {"fake": "authentication"}, + ) + template_data = { + "removed_from_rbac": [], + "added_to_rbac": [ + { + x: getattr(second_assignment, x) + for x in ("role_name", "display_name", "mail") + } + ], + } + sub_summary = await test_db.fetch_one( + get_subscriptions_summary(sub_id=new_detail.subscription_id, execute=False) + ) + if sub_summary: + template_data["summary"] = dict(sub_summary) + template_data["rctab_url"] = "https://rctab-t1-teststack.azurewebsites.net/" + assert mock_send_email.call_count == 2 + mock_send_email.assert_called_with( + "The user roles have changed for your Azure subscription: " + "name", + "role_assignment_change.html", + template_data, + ["myuser@example.com", "leif@poee.org"], + ) + # remove one of the role assignements + await post_status( + AllSubscriptionStatus( + status_list=[ + SubscriptionStatus( + subscription_id=constants.TEST_SUB_UUID, + display_name="name", + state=SubscriptionState("Enabled"), + role_assignments=(second_assignment,), + ) + ] + ), + {"fake": "authentication"}, + ) + + template_data = { + "removed_from_rbac": [ + { + x: getattr(first_assignment, x) + for x in ("role_name", "display_name", "mail") + } + ], + "added_to_rbac": [], + } + + sub_summary = await test_db.fetch_one( + get_subscriptions_summary(sub_id=new_detail.subscription_id, execute=False) + ) + if sub_summary: + template_data["summary"] = dict(sub_summary) + template_data["rctab_url"] = "https://rctab-t1-teststack.azurewebsites.net/" + + assert mock_send_email.call_count == 3 + mock_send_email.assert_called_with( + "The user roles have changed for your Azure subscription: " + "name", + "role_assignment_change.html", + template_data, + ["leif@poee.org"], + ) + + +@pytest.mark.asyncio +async def test_post_status_sends_looming( + test_db: Database, # pylint: disable=redefined-outer-name + mocker: pytest_mock.MockerFixture, +) -> None: + # pylint: disable=unexpected-keyword-arg + days_remaining = 10 + + mock_get_settings = mocker.patch( + "rctab.routers.accounting.send_emails.get_settings" + ) + mock_get_settings.return_value.ignore_whitelist = True + # These values should be the same as the defaults + mock_get_settings.return_value.notifiable_roles = ["Contributor"] + mock_get_settings.return_value.roles_filter = ["Contributor"] + mock_get_settings.return_value.ticker = "t1" + mock_get_settings.return_value.website_hostname = ( + "https://rctab-t1-teststack.azurewebsites.net/" + ) + + mock_send_email = mocker.patch( + "rctab.routers.accounting.send_emails.send_with_sendgrid" + ) + mock_send_email.return_value = 200 + + mock_refresh = AsyncMock() + mocker.patch("rctab.routers.accounting.status.refresh_desired_states", mock_refresh) + mocker.patch( + "rctab.routers.accounting.approvals.refresh_desired_states", mock_refresh + ) + + # Post status to create the subscription + new_detail = SubscriptionStatus( + subscription_id=constants.TEST_SUB_UUID, + display_name="name", + state=SubscriptionState("Enabled"), + # We need an assignment so there's + # someone to send a welcome email to + role_assignments=( + RoleAssignment( + role_definition_id=99, + role_name="Contributor", + principal_id=1010, + display_name="MyUser", + mail="myuser@example.com", + scope=f"/subscription_id/{constants.TEST_SUB_UUID}", + ), + ), + ) + await post_status( + AllSubscriptionStatus(status_list=[new_detail]), {"fake": "authentication"} + ) + + template_data: Dict[str, Any] = {} + sub_summary = await test_db.fetch_one( + get_subscriptions_summary(sub_id=new_detail.subscription_id, execute=False) + ) + if sub_summary: + template_data["summary"] = dict(sub_summary) + template_data["rctab_url"] = "https://rctab-t1-teststack.azurewebsites.net/" + assert mock_send_email.call_count == 1 + welcome_call = mocker.call( + "You have a new subscription on the Azure platform: " + "name", + "welcome.html", + template_data, + ["myuser@example.com"], + ) + + # The first status call should have sent a welcome email + mock_send_email.assert_has_calls([welcome_call]) + + #  So that we have an expiry date + today = datetime.datetime.now() + new_approval = Approval( + sub_id=constants.TEST_SUB_UUID, + ticket="N/A", + amount=0, + date_from=today, + date_to=today + datetime.timedelta(days=days_remaining), + ) + mock_user = mocker.MagicMock() + mock_user.oid = ADMIN_OID + await post_approval(new_approval, mock_user) + + assert mock_send_email.call_count == 2 + approval_data: Dict[str, Any] = {} + sub_summary = await test_db.fetch_one( + get_subscriptions_summary(sub_id=new_detail.subscription_id, execute=False) + ) + if sub_summary: + approval_data["summary"] = dict(sub_summary) + approval_data = {**new_approval.dict(), **approval_data} + approval_data["rctab_url"] = "https://rctab-t1-teststack.azurewebsites.net/" + new_approval_call = mocker.call( + "New approval for your Azure subscription: name", + "new_approval.html", + approval_data, + ["myuser@example.com"], + ) + + # The approval call should have sent a new approval email + mock_send_email.assert_has_calls([welcome_call, new_approval_call]) + + # Post status again to catch the fact that there's now an expiry date + await post_status( + AllSubscriptionStatus(status_list=[new_detail]), {"fake": "authentication"} + ) + + expiry_data: Dict[str, Any] = {} + sub_summary = await test_db.fetch_one( + get_subscriptions_summary(sub_id=new_detail.subscription_id, execute=False) + ) + if sub_summary: + expiry_data["summary"] = dict(sub_summary) + expiry_data = { + "days": days_remaining, + "extra_info": str(days_remaining), + **expiry_data, + } + expiry_data["rctab_url"] = "https://rctab-t1-teststack.azurewebsites.net/" # type: ignore + expiry_call = mocker.call( + f"{days_remaining} days until the expiry of your Azure subscription: name", + "expiry_looming.html", + expiry_data, + ["myuser@example.com"], + ) + + assert mock_send_email.call_count == 3 + # mock_send_email.assert_has_calls([welcome_call, new_approval_call, expiry_call]) + mock_send_email.assert_has_calls([welcome_call, new_approval_call, expiry_call]) + + +@pytest.mark.asyncio +async def test_post_status_filters_roles( + test_db: Database, # pylint: disable=redefined-outer-name + mocker: pytest_mock.MockerFixture, +) -> None: + # pylint: disable=unexpected-keyword-arg + + mock_send_email = mocker.Mock() + mock_send_email.return_value = 200 + mocker.patch( + "rctab.routers.accounting.send_emails.send_with_sendgrid", mock_send_email + ) + + mock_get_settings = mocker.Mock() + mock_get_settings.return_value.roles_filter = ["IncludeRole"] + mock_get_settings.return_value.notifiable_roles = ["IncludeRole"] + mock_get_settings.return_value.ignore_whitelist = True + mock_get_settings.return_value.website_hostname = ( + "https://rctab-t1-teststack.azurewebsites.net/" + ) + mocker.patch("rctab.routers.accounting.status.get_settings", mock_get_settings) + mocker.patch("rctab.routers.accounting.send_emails.get_settings", mock_get_settings) + + # We don't want to worry about which emails this function will send out + mock_refresh = AsyncMock() + mocker.patch("rctab.routers.accounting.status.refresh_desired_states", mock_refresh) + + sub_id = UUID(int=373758) + old_status = SubscriptionStatus( + subscription_id=sub_id, + display_name="display_name", + state=SubscriptionState("Enabled"), + role_assignments=( + RoleAssignment( + role_definition_id="my-role-def-id-1", + role_name="IncludeRole", + principal_id="my-principal-id-1", + display_name="MyPrincipal 1 Display Name", + mail="principal1@example.com", + scope=f"/subscription_id/{sub_id}", + ), + RoleAssignment( + role_definition_id="my-role-def-id-1", + role_name="ExcludeRole", + principal_id="my-principal-id-2", + display_name="MyPrincipal 2 Display Name", + mail="principal2@example.com", + scope=f"/subscription_id/{sub_id}", + ), + ), + ) + await status.post_status( + AllSubscriptionStatus(status_list=[SubscriptionStatus(**old_status.dict())]), + {"fake": "authentication"}, + ) + + template_data: Dict[str, Any] = {} + sub_summary = await test_db.fetch_one( + get_subscriptions_summary(sub_id=old_status.subscription_id, execute=False) + ) + if sub_summary: + template_data["summary"] = dict(sub_summary) + template_data["rctab_url"] = "https://rctab-t1-teststack.azurewebsites.net/" + assert mock_send_email.call_count == 1 + welcome_call = mocker.call( + "You have a new subscription on the Azure platform: " + "display_name", + "welcome.html", + template_data, + ["principal1@example.com"], + ) + mock_send_email.assert_has_calls([welcome_call]) + + # Shouldn't trigger an email but should be inserted + newer_status = SubscriptionStatus( + subscription_id=sub_id, + display_name="display_name", + state=SubscriptionState("Enabled"), + role_assignments=( + RoleAssignment( + role_definition_id="my-role-def-id-1", + role_name="IncludeRole", + principal_id="my-principal-id-1", + display_name="MyPrincipal 1 Display Name", + mail="principal1@example.com", + scope=f"/subscription_id/{sub_id}", + ), + RoleAssignment( + role_definition_id="my-role-def-id-1", + role_name="ExcludeRole", + principal_id="my-principal-id-2", + display_name="MyPrincipal 2 Display Name", + mail="principle2@example.com", + scope=f"/subscription_id/{sub_id}", + ), + # An extra role assignment has been added + RoleAssignment( + role_definition_id="my-role-def-id-1", + role_name="ExcludeRole", + principal_id="my-principal-id-3", + display_name="MyPrincipal 3 Display Name", + mail="principle3@example.com", + scope=f"/subscription_id/{sub_id}", + ), + ), + ) + + await status.post_status( + AllSubscriptionStatus(status_list=[SubscriptionStatus(**newer_status.dict())]), + {"fake": "authentication"}, + ) + # No new emails expected + mock_send_email.assert_has_calls([welcome_call]) + + results = await test_db.fetch_all(select([subscription_details])) + actual = [SubscriptionStatus(**dict(result)) for result in results] + + expected = [old_status, newer_status] + assert expected == actual + + # Should trigger an email and be inserted + newest_status = SubscriptionStatus( + subscription_id=sub_id, + display_name="display_name", + state=SubscriptionState("Enabled"), + role_assignments=( + RoleAssignment( + role_definition_id="my-role-def-id-1", + role_name="IncludeRole", + principal_id="my-principal-id-1", + display_name="MyPrincipal 1 Display Name", + mail="principle1@example.com", + scope=f"/subscription_id/{sub_id}", + ), + # An extra role assignment has been added + RoleAssignment( + role_definition_id="my-role-def-id-1", + role_name="IncludeRole", + principal_id="my-principal-id-4", + display_name="MyPrincipal 4 Display Name", + mail="principle4@example.com", + scope=f"/subscription_id/{sub_id}", + ), + RoleAssignment( + role_definition_id="my-role-def-id-1", + role_name="ExcludeRole", + principal_id="my-principal-id-3", + display_name="MyPrincipal 3 Display Name", + scope=f"/subscription_id/{sub_id}", + ), + ), + ) + + await status.post_status( + AllSubscriptionStatus(status_list=[SubscriptionStatus(**newest_status.dict())]), + {"fake": "authentication"}, + ) + + sub_summary = await test_db.fetch_one( + get_subscriptions_summary(sub_id=newest_status.subscription_id, execute=False) + ) + if sub_summary: + template_data["summary"] = dict(sub_summary) + template_data["removed_from_rbac"] = [ + { + "role_name": "IncludeRole", + "display_name": "MyPrincipal 1 Display Name", + "mail": "principal1@example.com", + }, + { + "role_name": "ExcludeRole", + "display_name": "MyPrincipal 2 Display Name", + "mail": "principle2@example.com", + }, + { + "role_name": "ExcludeRole", + "display_name": "MyPrincipal 3 Display Name", + "mail": "principle3@example.com", + }, + ] + template_data["added_to_rbac"] = [ + { + "role_name": "IncludeRole", + "display_name": "MyPrincipal 1 Display Name", + "mail": "principle1@example.com", + }, + { + "role_name": "IncludeRole", + "display_name": "MyPrincipal 4 Display Name", + "mail": "principle4@example.com", + }, + { + "role_name": "ExcludeRole", + "display_name": "MyPrincipal 3 Display Name", + "mail": None, + }, + ] + template_data["rctab_url"] = "https://rctab-t1-teststack.azurewebsites.net/" + # New email expected + mock_send_email.assert_called_with( + "The user roles have changed for your Azure subscription: display_name", + "role_assignment_change.html", + template_data, + ["principle1@example.com", "principle4@example.com"], + ) + + results = await test_db.fetch_all( + select([subscription_details]).order_by(subscription_details.c.id) + ) + actual = [SubscriptionStatus(**dict(result)) for result in results] + + expected = [old_status, newer_status, newest_status] + assert expected == actual diff --git a/tests/test_routes/test_subscription.py b/tests/test_routes/test_subscription.py new file mode 100644 index 0000000..43deec6 --- /dev/null +++ b/tests/test_routes/test_subscription.py @@ -0,0 +1,70 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from rctab.crud.schema import SubscriptionDetails +from rctab.routers.accounting.routes import PREFIX +from tests.test_routes import api_calls, constants + + +def test_get_subscription(auth_app: FastAPI) -> None: + """Getting a subscription that doesn't exist.""" + + with TestClient(auth_app) as client: + result = client.request( + "GET", + PREFIX + "/subscription", + json={"sub_id": str(constants.TEST_SUB_UUID)}, + ) + + assert result.status_code == 404 + + +def test_post_subscription(auth_app: FastAPI) -> None: + """Register a subscription""" + + with TestClient(auth_app) as client: + result = api_calls.create_subscription(client, constants.TEST_SUB_UUID) + assert result.status_code == 200 + + +def test_post_subscription_twice(auth_app: FastAPI) -> None: + """Can't register it if it already exists""" + + with TestClient(auth_app) as client: + + result = api_calls.create_subscription(client, constants.TEST_SUB_UUID) + assert result.status_code == 200 + + result = api_calls.create_subscription(client, constants.TEST_SUB_UUID) + assert result.status_code == 409 + + +def test_get_subscription_summary(auth_app: FastAPI) -> None: + """Get subscription information""" + + expected_details = SubscriptionDetails( + subscription_id=constants.TEST_SUB_UUID, + name=None, + role_assignments=None, + status=None, + approved_from=None, + approved_to=None, + always_on=None, + approved=0.0, + allocated=0.0, + cost=0.0, + amortised_cost=0.0, + total_cost=0.0, + remaining=0.0, + first_usage=None, + latest_usage=None, + desired_status_info=None, + abolished=False, + ) + + with TestClient(auth_app) as client: + + result = api_calls.create_subscription(client, constants.TEST_SUB_UUID) + assert result.status_code == 200 + + api_calls.assert_subscription_status(client, expected_details=expected_details) diff --git a/tests/test_routes/test_transactions.py b/tests/test_routes/test_transactions.py new file mode 100644 index 0000000..dd510a7 --- /dev/null +++ b/tests/test_routes/test_transactions.py @@ -0,0 +1,31 @@ +import pytest +from databases import Database +from sqlalchemy import select + +from rctab.crud.accounting_models import subscription +from rctab.settings import get_settings +from tests.test_routes.test_routes import create_subscription +from tests.test_routes.utils import no_rollback_test_db # pylint: disable=unused-import + +settings = get_settings() + + +@pytest.mark.asyncio +async def test_databases_rollback( + no_rollback_test_db: Database, # pylint: disable=redefined-outer-name +) -> None: + """Check that our databases library allows rollbacks.""" + + # This bug only shows up after at least one statement has been executed + await no_rollback_test_db.execute("select 1") + + transaction = await no_rollback_test_db.transaction() + + await create_subscription(no_rollback_test_db) + + # Should remove the subscription + await transaction.rollback() + + results = await no_rollback_test_db.fetch_all(select([subscription])) + + assert len(results) == 0 diff --git a/tests/test_routes/test_usage.py b/tests/test_routes/test_usage.py new file mode 100644 index 0000000..716db83 --- /dev/null +++ b/tests/test_routes/test_usage.py @@ -0,0 +1,410 @@ +import datetime +import json +from pathlib import Path +from typing import List, Tuple, Union +from unittest.mock import AsyncMock +from uuid import UUID + +import numpy as np +import pytest +import pytest_mock +import requests +from databases import Database +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pytest_mock import MockerFixture + +from rctab.constants import ADMIN_OID, EMAIL_TYPE_USAGE_ALERT +from rctab.crud.accounting_models import usage_view +from rctab.crud.models import database +from rctab.crud.schema import ( + AllCMUsage, + AllUsage, + BillingStatus, + CMUsage, + SubscriptionDetails, +) +from rctab.routers.accounting.usage import post_usage +from tests.test_routes import api_calls, constants +from tests.test_routes.test_routes import test_db # pylint: disable=unused-import +from tests.utils import print_list_diff + +date_from = datetime.date.today() +date_to = datetime.date.today() + datetime.timedelta(days=30) +TICKET = "T001-12" + + +def test_post_usage( + app_with_signed_billing_token: Tuple[FastAPI, str], + mocker: pytest_mock.MockerFixture, +) -> None: + auth_app, token = app_with_signed_billing_token + example_usage_file = Path("tests/data/example.json") + + example_usage_data = json.loads(example_usage_file.read_text(encoding="utf-8")) + + post_data = AllUsage(usage_list=example_usage_data) + + with TestClient(auth_app) as client: + mock_refresh = AsyncMock() + mocker.patch( + "rctab.routers.accounting.usage.refresh_desired_states", mock_refresh + ) + + resp = client.post( + "usage/all-usage", + content=post_data.json(), + headers={"authorization": "Bearer " + token}, + ) + + assert resp.status_code == 200 + + # Posting the usage data should have the side effect of + # refreshing the desired states + mock_refresh.assert_called_once_with( + UUID(ADMIN_OID), list({x.subscription_id for x in post_data.usage_list}) + ) + + get_resp = client.get( + "usage/all-usage", + headers={"authorization": "Bearer " + token}, + ) + + assert get_resp.status_code == 200 + + resp_data = get_resp.json() + assert np.isclose( + sum(i["total_cost"] for i in resp_data), + sum(i.total_cost for i in post_data.usage_list), + ) + + +def test_write_usage( + app_with_signed_billing_token: Tuple[FastAPI, str], mocker: MockerFixture +) -> None: + auth_app, token = app_with_signed_billing_token + expected_details = SubscriptionDetails( + subscription_id=constants.TEST_SUB_UUID, + approved_from=date_from, + approved_to=date_to, + always_on=False, + approved=500.0, + allocated=130.0, + cost=75.34, + amortised_cost=0.0, + total_cost=75.34, + first_usage=datetime.date.today(), + latest_usage=datetime.date.today(), + remaining=130.0 - 75.34, + desired_status_info=None, + abolished=False, + ) + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=False) + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket=TICKET, + amount=500.0, + date_from=date_from, + date_to=date_to, + allocate=False, + ) + + api_calls.create_allocation(client, constants.TEST_SUB_UUID, TICKET, 100) + + api_calls.create_allocation(client, constants.TEST_SUB_UUID, TICKET, -20) + + api_calls.create_allocation(client, constants.TEST_SUB_UUID, TICKET, 50) + + assert ( + api_calls.create_usage( + client, token, constants.TEST_SUB_UUID, cost=50.0 + ).status_code + == 200 + ) + + assert ( + api_calls.create_usage( + client, token, constants.TEST_SUB_UUID, cost=20.0 + ).status_code + == 200 + ) + + assert ( + api_calls.create_usage( + client, token, constants.TEST_SUB_UUID, cost=5.34 + ).status_code + == 200 + ) + + api_calls.assert_subscription_status(client, expected_details) + + +def test_greater_budget( + app_with_signed_billing_token: Tuple[FastAPI, str], mocker: MockerFixture +) -> None: + auth_app, token = app_with_signed_billing_token + expected_details = SubscriptionDetails( + subscription_id=constants.TEST_SUB_UUID, + approved_from=date_from, + approved_to=date_to, + always_on=False, + approved=500.0, + allocated=130.0, + cost=150.0, + amortised_cost=0.0, + total_cost=150.0, + remaining=130.0 - 150.0, + first_usage=datetime.date.today(), + latest_usage=datetime.date.today(), + desired_status_info=BillingStatus.OVER_BUDGET, + abolished=False, + ) + + with TestClient(auth_app) as client: + mock_send_email = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_email + ) + + api_calls.create_subscription(client, constants.TEST_SUB_UUID) + api_calls.set_persistence(client, constants.TEST_SUB_UUID, always_on=False) + + api_calls.create_approval( + client, + constants.TEST_SUB_UUID, + ticket=TICKET, + amount=500.0, + date_from=date_from, + date_to=date_to, + allocate=False, + ) + + api_calls.create_allocation(client, constants.TEST_SUB_UUID, TICKET, 100) + + api_calls.create_allocation(client, constants.TEST_SUB_UUID, TICKET, -20) + + api_calls.create_allocation(client, constants.TEST_SUB_UUID, TICKET, 50) + + assert ( + api_calls.create_usage( + client, token, constants.TEST_SUB_UUID, cost=100.0 + ).status_code + == 200 + ) + + assert ( + api_calls.create_usage( + client, token, constants.TEST_SUB_UUID, cost=50.0 + ).status_code + == 200 + ) + + api_calls.assert_subscription_status(client, expected_details) + + +def _post_costmanagement( + client: Union[requests.Session, TestClient], + token: str, + data: List[CMUsage], +) -> requests.Response: + all_usage = AllCMUsage(cm_usage_list=data) + post_client = client.post( + "/usage/all-cm-usage", + headers={"authorization": "Bearer " + token}, + content=all_usage.json(), + ) # type: ignore + return post_client # type: ignore + + +def _get_costmanagement( + client: Union[requests.Session, TestClient], token: str +) -> requests.Response: + return client.get( + "/usage/all-cm-usage", headers={"authorization": "Bearer " + token} + ) # type: ignore + + +def test_write_read_costmanagement( + app_with_signed_billing_token: Tuple[FastAPI, str] +) -> None: + """POST some cost-management data, GET it back, and check that the response matches + the input. Do it twice, because the first time inserts new subscriptions, whereas + the second updates existing ones. + """ + auth_app, token = app_with_signed_billing_token + end_date = datetime.datetime.now().date() + start_date = end_date - datetime.timedelta(days=364) + sub_data_in = [ + CMUsage( + subscription_id=constants.TEST_SUB_UUID, + name="sub1", + start_datetime=start_date, + end_datetime=end_date, + cost=12.0, + billing_currency="GBP", + ), + CMUsage( + subscription_id=constants.TEST_SUB_2_UUID, + name="sub2", + start_datetime=start_date, + end_datetime=end_date, + cost=144.0, + billing_currency="GBP", + ), + ] + with TestClient(auth_app) as client: + for _ in range(2): + response = _post_costmanagement(client, token, sub_data_in) + assert response.status_code == 200 + response = _get_costmanagement(client, token) + assert response.status_code == 200 + sub_data_out = response.json() + sub_data_out = [CMUsage(**d) for d in sub_data_out] + assert len(sub_data_in) == len(sub_data_out) + assert sub_data_out == sub_data_in + + +def test_post_monthly_usage( + app_with_signed_billing_token: Tuple[FastAPI, str], + mocker: pytest_mock.MockerFixture, +) -> None: + auth_app, token = app_with_signed_billing_token + example_1_file = Path("tests/data/example-monthly-wrong.json") + example_1_data = json.loads(example_1_file.read_text(encoding="utf-8")) + + example_2_file = Path("tests/data/example-monthly-wrong2.json") + example_2_data = json.loads(example_2_file.read_text(encoding="utf-8")) + + example_3_file = Path("tests/data/example-monthly-correct.json") + example_3_data = json.loads(example_3_file.read_text(encoding="utf-8")) + + post_example_1_data = AllUsage(usage_list=example_1_data) + post_example_2_data = AllUsage(usage_list=example_2_data) + post_example_3_data = AllUsage(usage_list=example_3_data) + + with TestClient(auth_app) as client: + mock_refresh = AsyncMock() + mocker.patch( + "rctab.routers.accounting.usage.refresh_desired_states", mock_refresh + ) + + resp = client.post( + "usage/monthly-usage", + content=post_example_1_data.json(), + headers={"authorization": "Bearer " + token}, + ) + + assert resp.status_code == 400 + + resp = client.post( + "usage/monthly-usage", + content=post_example_2_data.json(), + headers={"authorization": "Bearer " + token}, + ) + + assert resp.status_code == 400 + + resp = client.post( + "usage/monthly-usage", + content=post_example_3_data.json(), + headers={"authorization": "Bearer " + token}, + ) + + assert resp.status_code == 200 + + # Posting the usage data should have the side effect of + # refreshing the desired states + mock_refresh.assert_called_once_with( + UUID(ADMIN_OID), + list({x.subscription_id for x in post_example_3_data.usage_list}), + ) + + get_resp = client.get( + "usage/all-usage", + headers={"authorization": "Bearer " + token}, + ) + + assert get_resp.status_code == 200 + + resp_data = get_resp.json() + assert np.isclose( + sum(i["total_cost"] for i in resp_data), + sum(i.total_cost for i in post_example_3_data.usage_list), + ) + + +@pytest.mark.asyncio +async def test_post_usage_refreshes_view( + test_db: Database, mocker: MockerFixture # pylint: disable=redefined-outer-name +) -> None: + """Check that we refresh the view.""" + + mock_refresh = AsyncMock() + mocker.patch( + "rctab.routers.accounting.usage.refresh_materialised_view", mock_refresh + ) + + await post_usage(AllUsage(usage_list=[]), {"mock": "authentication"}) + + mock_refresh.assert_called_once_with(test_db, usage_view) + + +def test_post_usage_emails( + app_with_signed_billing_token: Tuple[FastAPI, str], + mocker: pytest_mock.MockerFixture, +) -> None: + """Check that we send the correct emails.""" + + auth_app, token = app_with_signed_billing_token + example_usage_file = Path("tests/data/example.json") + example_usage_data = json.loads(example_usage_file.read_text(encoding="utf-8")) + post_data = AllUsage(usage_list=example_usage_data) + + with TestClient(auth_app) as client: + mock_send_emails = AsyncMock() + mocker.patch( + "rctab.routers.accounting.send_emails.send_generic_email", mock_send_emails + ) + + mock_refresh = AsyncMock() + mocker.patch( + "rctab.routers.accounting.usage.refresh_desired_states", mock_refresh + ) + + resp = client.post( + "usage/all-usage", + content=post_data.json(), + headers={"authorization": "Bearer " + token}, + ) + assert resp.status_code == 200 + + unique_subs = list({x.subscription_id for x in post_data.usage_list}) + # These subs have no allocations at all so any usage should be over-budget + try: + expected = [ + mocker.call( + database, + subscription_id, + "usage_alert.html", + "95.0% of allocated budget used by your Azure subscription:", + EMAIL_TYPE_USAGE_ALERT, + {"percentage_used": 95.0, "extra_info": str(95.0)}, + ) + for subscription_id in unique_subs + ] + mock_send_emails.assert_has_calls(expected) + except AssertionError as e: + print_list_diff(expected, mock_send_emails.call_args_list) + raise e + + mock_refresh.assert_called_once_with(UUID(ADMIN_OID), unique_subs) diff --git a/tests/test_routes/utils.py b/tests/test_routes/utils.py new file mode 100644 index 0000000..91d6913 --- /dev/null +++ b/tests/test_routes/utils.py @@ -0,0 +1,40 @@ +from typing import AsyncGenerator + +import pytest +from databases import Database + +from rctab.settings import get_settings + + +@pytest.fixture(scope="function") +async def no_rollback_test_db() -> AsyncGenerator[Database, None]: + """Connect before & disconnect after each test.""" + + # For a small number of tests, we want to ensure the same + # rollback behaviour as on the live db as we are specifically + # testing that transactions and rollbacks are handled well. + database = Database(str(get_settings().postgres_dsn), force_rollback=False) + + await database.connect() + yield database + await clean_up(database) + await database.disconnect() + + +async def clean_up(conn: Database) -> None: + """Deletes data from all accounting tables.""" + + for table_name in ( + "status", + "usage", + "allocations", + "approvals", + "persistence", + "emails", + "cost_recovery", + "finance", + "finance_history", + "subscription", + "cost_recovery_log", + ): + await conn.execute(f"delete from accounting.{table_name}") diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..0c5a24d --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,81 @@ +import logging +from uuid import UUID + +import pytest + +from rctab.settings import Settings, get_settings + +# pylint: disable=unexpected-keyword-arg +# pylint: disable=use-implicit-booleaness-not-comparison + + +def test_settings() -> None: + """Check testing is set to true. If not the tests will write to database""" + assert get_settings().testing, "Set the TESTING env var to True" + + +def test_minimal_settings() -> None: + """Check that we can make a new settings with the minimal required values.""" + settings = Settings( # type: ignore + db_user="my_db_user", + db_password="my_db_password", + db_host="my_db_host", + # To stop any local .env files influencing the test + _env_file=None, + ) + + # Check the defaults + assert settings.db_port == 5432 + assert settings.db_name == "" + assert settings.ssl_required is False + assert settings.testing is True # Tricky one to test + assert settings.log_level == logging.getLevelName(logging.WARNING) + assert settings.ignore_whitelist is False + assert settings.whitelist == [] + assert settings.notifiable_roles == ["Contributor"] + assert settings.roles_filter == ["Contributor"] + assert settings.admin_email_recipients == [] + + +def test_settings_raises() -> None: + """Check that we raise an error if a PostgreSQL DSN is provided.""" + with pytest.raises(ValueError): + Settings( # type: ignore + db_user="my_db_user", + db_password="my_db_password", + db_host="my_db_host", + postgres_dsn="postgresql://user:password@0.0.0.0:6000/mypostgresdb", + # To stop any local .env files influencing the test + _env_file=None, + ) + + +def test_maximal_settings() -> None: + """Check that we can make a new Settings with all known values.""" + settings = Settings( # type: ignore + db_user="my_db_user", + db_password="my_db_password", + db_host="my_db_host", + db_port=5432, + db_name="my_db_name", + ssl_required=False, + testing=False, + sendgrid_api_key="sendgrid_key1234", + sendgrid_sender_email="myemail@myorg.com", + notifiable_roles=["Contributor", "Billing Reader"], + roles_filter=["Owner", "Contributor"], + log_level=logging.getLevelName(logging.INFO), + ignore_whitelist=False, + whitelist=[UUID(int=786)], + usage_func_public_key="3456", + status_func_public_key="2345", + controller_func_public_key="1234", + admin_email_recipients=["myemail@myorg.com"], + # To stop any local .env files influencing the test + _env_file=None, + ) + + assert ( + settings.postgres_dsn + == "postgresql://my_db_user:my_db_password@my_db_host:5432/my_db_name" + ) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..e5c75ab --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,10 @@ +"""Test helper classes.""" +from typing import Any, List + + +def print_list_diff(expected: List[Any], actual: List[Any]) -> None: + print("") + for i, call in enumerate(expected): + print("expected:", call) + if i < len(actual): + print("actual: ", actual[i])