diff --git a/actions/email.users.for.power.testing.yaml b/actions/email.users.for.power.testing.yaml
new file mode 100644
index 000000000..5713f3dec
--- /dev/null
+++ b/actions/email.users.for.power.testing.yaml
@@ -0,0 +1,76 @@
+description: Sends an email to inform users about VMs running on flavors scheduled for power testing or decommissioning
+enabled: false
+entry_point: src/openstack_actions.py
+name: email.users.with.power.test.flavors
+parameters:
+ timeout:
+ default: 5400
+ lib_entry_point:
+ default: workflows.send_power_testing_email.send_power_testing_email
+ immutable: true
+ type: string
+ subject:
+ type: string
+ description: "Subject to add to each email"
+ required: true
+ default: "STFC Cloud Flavor Power Testing Notice"
+ smtp_account_name:
+ type: string
+ description: "Name of SMTP Account to use. Must be configured in the pack settings."
+ required: true
+ default: "default"
+ cloud_account:
+ description: "The clouds.yaml account to use whilst performing this action"
+ required: true
+ type: string
+ default: "dev"
+ enum:
+ - "dev"
+ - "prod"
+ flavor_name_list:
+ type: array
+ description: "List of flavor names to include in the power testing notice"
+ required: true
+ default: null
+ limit_by_projects:
+ type: array
+ description: "List of project names or IDs to limit action to - incompatible with all_projects"
+ required: false
+ default: null
+ all_projects:
+ type: boolean
+ description: "Search across all projects - mutually exclusive with limit_by_projects"
+ required: true
+ default: true
+ use_override:
+ type: boolean
+ description: "Redirect all emails to override address"
+ required: true
+ default: false
+ as_html:
+ type: boolean
+ description: "Send email body as HTML"
+ required: true
+ default: true
+ send_email:
+ type: boolean
+ description: "Actually send emails instead of just printing what would be sent"
+ required: true
+ default: false
+ override_email_address:
+ type: string
+ description: "Override recipient email address"
+ required: true
+ default: "cloud-support@stfc.ac.uk"
+ cc_cloud_support:
+ type: boolean
+ description: "CC cloud-support@stfc.ac.uk on all emails"
+ required: false
+ default: false
+ email_from:
+ type: string
+ description: "Email address to send email from"
+ required: true
+ default: "cloud-support@stfc.ac.uk"
+ immutable: true
+runner_type: python-script
diff --git a/lib/apis/email_api/email_template_schemas.yaml b/lib/apis/email_api/email_template_schemas.yaml
index 3a6106a28..86642c752 100644
--- a/lib/apis/email_api/email_template_schemas.yaml
+++ b/lib/apis/email_api/email_template_schemas.yaml
@@ -95,4 +95,10 @@ decom_capi_image:
html_filepath: "html/decom_capi_image.html.j2"
plaintext_filepath: "plaintext/decom_capi_image.txt.j2"
-
+power_testing:
+ schema:
+ user_name: null
+ affected_flavors_table: "No Flavors"
+ affected_servers_table: "No VMs affected"
+ html_filepath: "html/power_testing.html.j2"
+ plaintext_filepath: "plaintext/power_testing.txt.j2"
\ No newline at end of file
diff --git a/lib/apis/email_api/emailer.py b/lib/apis/email_api/emailer.py
index df92e9c5e..ebcf45d94 100644
--- a/lib/apis/email_api/emailer.py
+++ b/lib/apis/email_api/emailer.py
@@ -1,21 +1,19 @@
-import time
-import ssl
-from typing import List
-from pathlib import Path
-from smtplib import SMTP
import logging
-
+import ssl
+import time
from email.header import Header
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate
-
-from apis.email_api.template_handler import TemplateHandler
+from pathlib import Path
+from smtplib import SMTP
+from typing import List
from apis.email_api.structs.email_params import EmailParams
-from apis.email_api.structs.smtp_account import SMTPAccount
from apis.email_api.structs.email_template_details import EmailTemplateDetails
+from apis.email_api.structs.smtp_account import SMTPAccount
+from apis.email_api.template_handler import TemplateHandler
# weird issue where pylint can't find the module - works fine though
# pylint:disable=no-name-in-module
@@ -90,6 +88,54 @@ def send_emails(self, emails: List[EmailParams]):
logger.info("sending complete - time elapsed: %s seconds", time.time() - start)
+ def print_email(self, email_params: EmailParams):
+ """
+ Print a well-formatted version of the email that would be sent.
+ Useful for debugging or dry-run previews.
+ """
+ # Build the MIME email using the same function that send_email uses
+ msg = self.build_email(email_params)
+
+ # Display email headers
+ print("===== EMAIL PREVIEW =====")
+ print(f"From : {msg['From']}")
+ print(f"To : {msg['To']}")
+ print(f"Cc : {msg['Cc'] or ''}")
+ print(f"Subject: {msg['Subject']}")
+ print(f"Date : {msg['Date']}")
+ print(f"Reply-To: {msg['reply-to']}")
+ print("\n--- Body ---")
+
+ # Extract and print the body parts (could be plain or HTML)
+ for part in msg.walk():
+ content_type = part.get_content_type()
+ content_disposition = part.get("Content-Disposition", "").lower()
+ if (
+ content_type.startswith("text/")
+ and "attachment" not in content_disposition
+ ):
+ charset = part.get_content_charset() or "utf-8"
+ body = part.get_payload(decode=True).decode(charset, errors="replace")
+ print(body)
+ print("\n--- End of Body ---\n")
+
+ # List any attachments
+ attachments = []
+ for part in msg.walk():
+ content_disposition = part.get("Content-Disposition", "").lower()
+ if "attachment" in content_disposition:
+ filename = part.get_filename()
+ attachments.append(filename)
+
+ if attachments:
+ print("--- Attachments ---")
+ for filename in attachments:
+ print(f"- {filename}")
+ else:
+ print("No attachments.")
+
+ print("=========================\n")
+
def build_email(self, email_params: EmailParams) -> MIMEMultipart:
"""
Helper function to setup email as MIMEMultipart
diff --git a/lib/apis/email_api/templates/html/power_testing.html.j2 b/lib/apis/email_api/templates/html/power_testing.html.j2
new file mode 100644
index 000000000..f34f67365
--- /dev/null
+++ b/lib/apis/email_api/templates/html/power_testing.html.j2
@@ -0,0 +1,28 @@
+Dear {{ user_name }},
+
+This is a reminder of the upcoming planned electrical maintenance works.
+
+These works are planned for:
+
+ - 1st-2nd November
+ - 8th-9th November
+
+
+This will impact the following flavors — VMs using these flavors will be powered down and NOT ACCESSIBLE during the periods mentioned above:
+{{ affected_flavors_table }}
+
+We would like to request that the following VMs be shut down ahead of the first weekend, shutting down by 17:00 on Thursday 30th October 2025.
+{{ affected_servers_table }}
+
+Any impacted VMs still running will be shut down during the morning of Friday 31st October by the Cloud Operations Team.
+
+VM access will then be restored during the afternoon of Monday 3rd November, assuming all works are completed.
+
+While the power work is expected to be completed by Monday 3rd November, we plan to operate AT RISK for the period beginning Friday 31st October through to Wednesday 12th November.
+
+Any questions or concerns regarding these works can be raised via our support portal, via tickets to
+cloud-support@stfc.ac.uk, or as replies to this message.
+
+
+Best Regards,
+STFC Cloud Operations
\ No newline at end of file
diff --git a/lib/apis/email_api/templates/plaintext/power_testing.txt.j2 b/lib/apis/email_api/templates/plaintext/power_testing.txt.j2
new file mode 100644
index 000000000..c27d4537d
--- /dev/null
+++ b/lib/apis/email_api/templates/plaintext/power_testing.txt.j2
@@ -0,0 +1,23 @@
+Dear {{ user_name }},
+This is a reminder of the upcoming planned electrical maintenance works.
+
+These works are planned for:
+ - 1st-2nd November
+ - 8th-9th November
+
+This will impact the following flavors, VMs using these flavors will be powered down and NOT ACCESSIBLE during the periods mentioned above:
+{{ affected_flavors_table }}
+
+We would like to request that the following VMs be shut down ahead of the first weekend, shutting down by 17:00 on Thursday 30th October 2025.
+{{ affected_servers_table }}
+
+Any impacted VMs still running will be shut down during the morning of Friday 31st October by the Cloud Operations Team.
+
+VM access will then be restored during the afternoon of Monday 3rd November assuming all works are completed.
+
+While the power work is expected to be completed by Monday 3rd November, we plan to operate AT RISK for the period beginning Friday 31st October, through to Wednesday 12th November.
+
+Any questions or concerns regarding these works can be raised via our support portal, via tickets to cloud-support@stfc.ac.uk, or as replies to this message.
+
+Best Regards,
+STFC Cloud Operations
\ No newline at end of file
diff --git a/lib/workflows/send_power_testing_email.py b/lib/workflows/send_power_testing_email.py
new file mode 100644
index 000000000..882e2ecb6
--- /dev/null
+++ b/lib/workflows/send_power_testing_email.py
@@ -0,0 +1,155 @@
+# pylint:disable=too-many-arguments
+# pylint:disable=too-many-locals
+from typing import List, Optional, Union
+
+from apis.email_api.emailer import Emailer
+from apis.email_api.structs.email_params import EmailParams
+from apis.email_api.structs.email_template_details import EmailTemplateDetails
+from apis.email_api.structs.smtp_account import SMTPAccount
+from apis.openstack_api.enums.cloud_domains import CloudDomains
+from tabulate import tabulate
+from workflows.send_decom_flavor_email import (
+ find_servers_with_decom_flavors,
+ find_user_info,
+)
+
+
+def validate_input_arguments(
+ flavor_name_list: List[str],
+ from_projects: List[str] = None,
+ all_projects: bool = False,
+):
+ """
+ Validate input arguments for sending power testing emails.
+
+ :param flavor_name_list: List of OpenStack flavor names that are under power testing review.
+ :param planned_date_ranges: List of planned power testing dates associated with the flavors.
+ :param from_projects: (Optional) List of project names or IDs to restrict the search scope.
+ :param all_projects: If True, searches across all OpenStack projects.
+
+ :raises RuntimeError: If required arguments are missing or if both `from_projects` and `all_projects` are set.
+ """
+ if not flavor_name_list:
+ raise RuntimeError("please provide a list of flavor names to decommission")
+
+ if from_projects and all_projects:
+ raise RuntimeError(
+ "given both project list and all_projects flag - please choose only one"
+ )
+
+ if not from_projects and not all_projects:
+ raise RuntimeError(
+ "please provide either a list of project identifiers or the 'all_projects' flag"
+ )
+
+
+def build_email_params(
+ user_name: str,
+ affected_flavors_table: str,
+ affected_servers_table: str,
+ **email_kwargs,
+):
+ """
+ Construct EmailParams for notifying a user about flavors under power testing.
+
+ :param user_name: Name of the OpenStack user receiving the email.
+ :param affected_flavors_table: A rendered table (plain or HTML) listing affected flavors.
+ :param affected_servers_table: A rendered table listing the user's VMs using affected flavors.
+ :param email_kwargs: Additional keyword arguments for the EmailParams class.
+
+ :return: EmailParams object containing templated email content and metadata.
+ """
+ body = EmailTemplateDetails(
+ template_name="power_testing",
+ template_params={
+ "user_name": user_name,
+ "affected_flavors_table": affected_flavors_table,
+ "affected_servers_table": affected_servers_table,
+ },
+ )
+
+ footer = EmailTemplateDetails(template_name="footer", template_params={})
+
+ return EmailParams(email_templates=[body, footer], **email_kwargs)
+
+
+def send_power_testing_email(
+ smtp_account: SMTPAccount,
+ cloud_account: Union[CloudDomains, str],
+ flavor_name_list: List[str],
+ limit_by_projects: Optional[List[str]] = None,
+ all_projects: bool = False,
+ as_html: bool = False,
+ send_email: bool = False,
+ use_override: bool = False,
+ override_email_address: Optional[str] = "cloud-support@stfc.ac.uk",
+ cc_cloud_support: bool = False,
+ **email_params_kwargs,
+):
+ """
+ Notify users by email if they own VMs using flavors scheduled for power testing.
+
+ Each user receives a personalized email listing:
+ - Flavors under review
+ - Planned testing dates
+ - Affected VMs they own
+
+ :param smtp_account: SMTP configuration used to send the email.
+ :param cloud_account: Name of the OpenStack account (from clouds.yaml) to authenticate with.
+ :param flavor_name_list: List of flavor names that are under power testing or EOL consideration.
+ :param limit_by_projects: (Optional) List of projects to scope the search to (mutually exclusive with all_projects).
+ :param all_projects: If True, search all OpenStack projects for affected VMs.
+ :param as_html: If True, emails are formatted as HTML; otherwise, plain text is used.
+ :param send_email: If True, emails are actually sent; otherwise, the generated content is printed.
+ :param use_override: If True, all emails are redirected to the override email address.
+ :param override_email_address: Email address to use if override is enabled or if user's address is not found.
+ :param cc_cloud_support: If True, cc cloud-support@stfc.ac.uk on all outgoing emails.
+ :param email_params_kwargs: Additional arguments passed to EmailParams, such as subject or sender.
+ """
+ validate_input_arguments(flavor_name_list, limit_by_projects, all_projects)
+
+ server_query = find_servers_with_decom_flavors(
+ cloud_account, flavor_name_list, limit_by_projects
+ )
+
+ for user_id in server_query.to_props().keys():
+ # if email_address not found - send to override_email_address
+ # also send to override_email_address if override_email set
+ user_name, email_addr = find_user_info(
+ user_id, cloud_account, override_email_address
+ )
+ send_to = [email_addr]
+ if use_override:
+ send_to = [override_email_address]
+
+ if as_html:
+ affected_flavors_table = tabulate(
+ [{"Flavor": flavor} for flavor in flavor_name_list],
+ headers="keys",
+ tablefmt="html",
+ )
+ else:
+ affected_flavors_table = tabulate(
+ [{"Flavor": flavor} for flavor in flavor_name_list],
+ headers="keys",
+ tablefmt="grid",
+ )
+ email_params = build_email_params(
+ user_name=user_name,
+ affected_flavors_table=affected_flavors_table,
+ affected_servers_table=(
+ server_query.to_string(groups=[user_id])
+ if not as_html
+ else server_query.to_html(groups=[user_id])
+ ),
+ email_to=send_to,
+ as_html=as_html,
+ email_cc=("cloud-support@stfc.ac.uk",) if cc_cloud_support else None,
+ **email_params_kwargs,
+ )
+
+ if not send_email:
+ Emailer(smtp_account).print_email(email_params)
+
+ else:
+ Emailer(smtp_account).send_emails([email_params])
diff --git a/tests/lib/apis/email_api/test_emailer.py b/tests/lib/apis/email_api/test_emailer.py
index e659e7cc8..f2de06d8c 100644
--- a/tests/lib/apis/email_api/test_emailer.py
+++ b/tests/lib/apis/email_api/test_emailer.py
@@ -1,4 +1,6 @@
+from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
from pathlib import Path
from unittest.mock import MagicMock, NonCallableMock, call, mock_open, patch
@@ -113,6 +115,66 @@ def test_send_emails(mock_send_email, instance):
)
+@pytest.mark.parametrize(
+ "has_attachments, expected_attachment_output",
+ [
+ (True, "- test.txt"),
+ (False, "No attachments."),
+ ],
+)
+@patch("apis.email_api.emailer.Emailer.build_email")
+@patch("builtins.print")
+def test_print_email_outputs_correct_attachment_section(
+ mock_print, mock_build_email, instance, has_attachments, expected_attachment_output
+):
+ """
+ Tests that print_email prints the correct email structure including headers,
+ body content, and attachment names.
+ """
+ mock_email_params = MagicMock()
+
+ msg = MIMEMultipart()
+ msg["From"] = "from@example.com"
+ msg["To"] = "to@example.com"
+ msg["Cc"] = "cc@example.com"
+ msg["Subject"] = "Test Subject"
+ msg["Date"] = "Thu, 01 Jan 1970 00:00:00 -0000"
+ msg["reply-to"] = "from@example.com"
+
+ body_part = MIMEText("This is the email body.", "plain", "utf-8")
+ msg.attach(body_part)
+
+ if has_attachments:
+ attachment_part = MIMEBase("application", "octet-stream")
+ attachment_part.set_payload(b"fake-content")
+ attachment_part.add_header(
+ "Content-Disposition", "attachment", filename="test.txt"
+ )
+ msg.attach(attachment_part)
+
+ mock_build_email.return_value = msg
+
+ instance.print_email(mock_email_params)
+
+ mock_build_email.assert_called_once_with(mock_email_params)
+
+ printed_lines = [call_args[0][0] for call_args in mock_print.call_args_list]
+ normalized_lines = [line.strip() for line in printed_lines]
+
+ assert "===== EMAIL PREVIEW =====" in normalized_lines
+ assert "From : from@example.com" in normalized_lines
+ assert "To : to@example.com" in normalized_lines
+ assert "Cc : cc@example.com" in normalized_lines
+ assert "Subject: Test Subject" in normalized_lines
+ assert "Date : Thu, 01 Jan 1970 00:00:00 -0000" in normalized_lines
+ assert "Reply-To: from@example.com" in normalized_lines
+ assert "--- Body ---" in normalized_lines
+ assert "This is the email body." in normalized_lines
+ assert "--- End of Body ---" in normalized_lines
+ assert expected_attachment_output in printed_lines
+ assert "=========================" in normalized_lines
+
+
@patch("builtins.open", new_callable=mock_open, read_data="data")
@patch("apis.email_api.emailer.MIMEApplication")
def test_attach_files(mock_mime_application, mock_file, instance):
diff --git a/tests/lib/workflows/test_send_power_testing_email.py b/tests/lib/workflows/test_send_power_testing_email.py
new file mode 100644
index 000000000..5c05a2a5a
--- /dev/null
+++ b/tests/lib/workflows/test_send_power_testing_email.py
@@ -0,0 +1,306 @@
+from unittest.mock import NonCallableMock, call, patch
+
+import pytest
+from apis.email_api.structs.email_params import EmailParams
+from apis.email_api.structs.smtp_account import SMTPAccount
+from workflows.send_power_testing_email import (
+ build_email_params,
+ send_power_testing_email,
+ validate_input_arguments,
+)
+
+
+def test_validate_input_arguments_empty_flavors():
+ """Tests that validate_input_arguments raises error when passed empty flavor name list."""
+ with pytest.raises(
+ RuntimeError, match="please provide a list of flavor names to decommission"
+ ):
+ validate_input_arguments([], from_projects=["proj1"])
+
+
+def test_validate_input_arguments_projects_and_all_projects():
+ """Tests that validate_input_arguments raises error when both from_projects and all_projects are True."""
+ with pytest.raises(
+ RuntimeError,
+ match="given both project list and all_projects flag - please choose only one",
+ ):
+ validate_input_arguments(
+ ["flavor1"], from_projects=["proj1"], all_projects=True
+ )
+
+
+def test_validate_input_arguments_no_projects_or_all_projects():
+ """Tests that validate_input_arguments raises error when neither from_projects nor all_projects are provided."""
+ with pytest.raises(
+ RuntimeError,
+ match="please provide either a list of project identifiers or the 'all_projects' flag",
+ ):
+ validate_input_arguments(["flavor1"], from_projects=None, all_projects=False)
+
+
+def test_validate_input_arguments_valid_all_projects():
+ """Tests that validate_input_arguments does not raise an error for a valid input with all_projects flag."""
+ try:
+ validate_input_arguments(
+ flavor_name_list=["flavor1", "flavor2"],
+ all_projects=True,
+ )
+ except RuntimeError as e:
+ pytest.fail(f"validate_input_arguments raised an unexpected error: {e}")
+
+
+def test_validate_input_arguments_valid_projects():
+ """Tests that validate_input_arguments does not raise an error for a valid input with a project list."""
+ try:
+ validate_input_arguments(
+ flavor_name_list=["flavor1", "flavor2"],
+ from_projects=["proj1", "proj2"],
+ )
+ except RuntimeError as e:
+ pytest.fail(f"validate_input_arguments raised an unexpected error: {e}")
+
+
+@patch("workflows.send_power_testing_email.EmailTemplateDetails")
+@patch("workflows.send_power_testing_email.EmailParams")
+def test_build_email_params(mock_email_params, mock_email_template_details):
+ """
+ Test build_email_params() function constructs EmailParams with correct templates and parameters.
+ """
+ user_name = "abc111111"
+ affected_flavors_table = "Flavor Table HTML"
+ affected_servers_table = "Server Table HTML"
+ email_kwargs = {"subject": "Power Testing Notice", "email_to": ["test@example.com"]}
+
+ res = build_email_params(
+ user_name, affected_flavors_table, affected_servers_table, **email_kwargs
+ )
+
+ expected_body_params = {
+ "user_name": user_name,
+ "affected_flavors_table": affected_flavors_table,
+ "affected_servers_table": affected_servers_table,
+ }
+ mock_email_template_details.assert_has_calls(
+ [
+ call(template_name="power_testing", template_params=expected_body_params),
+ call(template_name="footer", template_params={}),
+ ]
+ )
+
+ expected_email_templates = [
+ mock_email_template_details.return_value,
+ mock_email_template_details.return_value,
+ ]
+ mock_email_params.assert_called_once_with(
+ email_templates=expected_email_templates,
+ **email_kwargs,
+ )
+
+ assert res == mock_email_params.return_value
+
+
+@patch("workflows.send_power_testing_email.validate_input_arguments")
+@patch("workflows.send_power_testing_email.find_servers_with_decom_flavors")
+@patch("workflows.send_power_testing_email.find_user_info")
+@patch("workflows.send_power_testing_email.tabulate")
+@patch("workflows.send_power_testing_email.build_email_params")
+@patch("workflows.send_power_testing_email.Emailer")
+def test_send_power_testing_email_send_html_cc(
+ mock_emailer,
+ mock_build_email_params,
+ mock_tabulate,
+ mock_find_user_info,
+ mock_find_servers,
+ mock_validate_input_arguments,
+):
+ """
+ Tests send_power_testing_email() function to ensure emails are sent (HTML format) with CC enabled.
+ """
+ flavor_name_list = ["flavor.small", "flavor.medium"]
+ limit_by_projects = ["exampleproject-id"]
+ cloud_account = NonCallableMock()
+ smtp_account = NonCallableMock(spec=SMTPAccount)
+
+ mock_kwargs = {"subject": "Testing Subject"}
+ override_email = "test-override@stfc.ac.uk"
+
+ mock_query = mock_find_servers.return_value
+ mock_query.to_props.return_value = {
+ "user_id1": [],
+ "user_id2": [],
+ }
+ mock_find_user_info.side_effect = [
+ ("User One", "user1@example.com"),
+ ("User Two", "user2@example.com"),
+ ]
+ mock_tabulate.side_effect = [
+ "HTML Flavor Table 1",
+ "HTML Flavor Table 2",
+ ]
+ mock_query.to_html.side_effect = [
+ "User1 Server Table HTML",
+ "User2 Server Table HTML",
+ ]
+ mock_email_params = NonCallableMock(spec=EmailParams)
+ mock_build_email_params.return_value = mock_email_params
+
+ send_power_testing_email(
+ smtp_account=smtp_account,
+ cloud_account=cloud_account,
+ flavor_name_list=flavor_name_list,
+ limit_by_projects=limit_by_projects,
+ all_projects=False,
+ as_html=True,
+ send_email=True,
+ use_override=False,
+ override_email_address=override_email,
+ cc_cloud_support=True,
+ **mock_kwargs,
+ )
+
+ mock_validate_input_arguments.assert_called_once_with(
+ flavor_name_list, limit_by_projects, False
+ )
+ mock_find_servers.assert_called_once_with(
+ cloud_account, flavor_name_list, limit_by_projects
+ )
+ mock_query.to_props.assert_called_once()
+ mock_find_user_info.assert_has_calls(
+ [
+ call("user_id1", cloud_account, override_email),
+ call("user_id2", cloud_account, override_email),
+ ]
+ )
+
+ expected_tabulate_data = [{"Flavor": flavor} for flavor in flavor_name_list]
+ mock_tabulate.assert_has_calls(
+ [
+ call(expected_tabulate_data, headers="keys", tablefmt="html"),
+ call(expected_tabulate_data, headers="keys", tablefmt="html"),
+ ]
+ )
+
+ mock_query.to_html.assert_has_calls(
+ [
+ call(groups=["user_id1"]),
+ call(groups=["user_id2"]),
+ ]
+ )
+ mock_query.to_string.assert_not_called()
+
+ mock_build_email_params.assert_has_calls(
+ [
+ call(
+ user_name="User One",
+ affected_flavors_table="HTML Flavor Table 1",
+ affected_servers_table="User1 Server Table HTML",
+ email_to=["user1@example.com"],
+ as_html=True,
+ email_cc=("cloud-support@stfc.ac.uk",),
+ subject="Testing Subject",
+ ),
+ call(
+ user_name="User Two",
+ affected_flavors_table="HTML Flavor Table 2",
+ affected_servers_table="User2 Server Table HTML",
+ email_to=["user2@example.com"],
+ as_html=True,
+ email_cc=("cloud-support@stfc.ac.uk",),
+ subject="Testing Subject",
+ ),
+ ]
+ )
+
+ assert mock_emailer.call_count == 2
+ mock_emailer.assert_has_calls(
+ [
+ call(smtp_account),
+ call(smtp_account),
+ ],
+ any_order=True,
+ )
+
+ mock_emailer.return_value.send_emails.assert_has_calls(
+ [
+ call([mock_email_params]),
+ call([mock_email_params]),
+ ],
+ any_order=False,
+ )
+ mock_emailer.return_value.print_email.assert_not_called()
+
+
+@patch("workflows.send_power_testing_email.validate_input_arguments")
+@patch("workflows.send_power_testing_email.find_servers_with_decom_flavors")
+@patch("workflows.send_power_testing_email.find_user_info")
+@patch("workflows.send_power_testing_email.tabulate")
+@patch("workflows.send_power_testing_email.build_email_params")
+@patch("workflows.send_power_testing_email.Emailer")
+def test_send_power_testing_email_print_plaintext_override(
+ mock_emailer,
+ mock_build_email_params,
+ mock_tabulate,
+ mock_find_user_info,
+ mock_find_servers,
+ mock_validate_input_arguments,
+):
+ """
+ Tests send_power_testing_email() function to ensure content is printed (Plaintext format)
+ and uses the override email address.
+ """
+ flavor_name_list = ["flavor.xlarge"]
+ cloud_account = NonCallableMock()
+ smtp_account = NonCallableMock(spec=SMTPAccount)
+ override_email = "override-test@stfc.ac.uk"
+
+ mock_query = mock_find_servers.return_value
+ mock_query.to_props.return_value = {
+ "user_id3": [],
+ }
+ mock_find_user_info.return_value = ("User Three", "user3@private.com")
+
+ mock_tabulate.return_value = "Plaintext Flavor Table"
+ mock_query.to_string.return_value = "User3 Server Table Plaintext"
+ mock_email_params = NonCallableMock(spec=EmailParams)
+ mock_build_email_params.return_value = mock_email_params
+
+ send_power_testing_email(
+ smtp_account=smtp_account,
+ cloud_account=cloud_account,
+ flavor_name_list=flavor_name_list,
+ limit_by_projects=None,
+ all_projects=True,
+ as_html=False,
+ send_email=False,
+ use_override=True,
+ override_email_address=override_email,
+ cc_cloud_support=False,
+ )
+
+ mock_validate_input_arguments.assert_called_once_with(flavor_name_list, None, True)
+ mock_find_servers.assert_called_once_with(cloud_account, flavor_name_list, None)
+ mock_query.to_props.assert_called_once()
+ mock_find_user_info.assert_called_once_with(
+ "user_id3", cloud_account, override_email
+ )
+
+ expected_tabulate_data = [{"Flavor": flavor} for flavor in flavor_name_list]
+ mock_tabulate.assert_called_once_with(
+ expected_tabulate_data, headers="keys", tablefmt="grid"
+ )
+
+ mock_query.to_string.assert_called_once_with(groups=["user_id3"])
+ mock_query.to_html.assert_not_called()
+
+ mock_build_email_params.assert_called_once_with(
+ user_name="User Three",
+ affected_flavors_table="Plaintext Flavor Table",
+ affected_servers_table="User3 Server Table Plaintext",
+ email_to=[override_email],
+ as_html=False,
+ email_cc=None,
+ )
+
+ mock_emailer.assert_called_once_with(smtp_account)
+ mock_emailer.return_value.print_email.assert_called_once_with(mock_email_params)
+ mock_emailer.return_value.send_emails.assert_not_called()