diff --git a/docs/api_reference.rst b/docs/api_reference.rst index d8ad53b2..0b9768cc 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -22,6 +22,7 @@ API Reference api_reference/work_item api_reference/test_plan api_reference/artifact + api_reference/notification Indices and tables ------------------ diff --git a/docs/api_reference/notification.rst b/docs/api_reference/notification.rst new file mode 100644 index 00000000..d4885590 --- /dev/null +++ b/docs/api_reference/notification.rst @@ -0,0 +1,14 @@ +.. _api_notification_page: + +nisystemlink.clients.notification +========================== + +.. autoclass:: nisystemlink.clients.notification.NotificationClient + :exclude-members: __init__ + + .. automethod:: __init__ + .. automethod:: apply_dynamic_notification_strategy + +.. automodule:: nisystemlink.clients.notification.models + :members: + :imported-members: \ No newline at end of file diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 61a221cd..c6962edc 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -506,3 +506,29 @@ Create, query and delete test plan templates. .. literalinclude:: ../examples/test_plan/test_plan_templates.py :language: python :linenos: + +Notification API +---------------- + +Overview +~~~~~~~~ + +The :class:`.NotificationClient` class is the primary entry point of the Notification API. + +When constructing a :class:`.NotificationClient`, you can pass an +:class:`.HttpConfiguration` (like one retrieved from the +:class:`.HttpConfigurationManager`), or let :class:`.NotificationClient` use the +default connection. The default connection depends on your environment. + +With a :class:`.NotificationClient` object, you can: + +* Apply dynamic notification strategy using :meth:`~.NotificationClient.apply_dynamic_notification_strategy` + +Examples +~~~~~~~~ + +Apply a notification strategy + +.. literalinclude:: ../examples/notification/notification.py + :language: python + :linenos: \ No newline at end of file diff --git a/examples/notification/notification.py b/examples/notification/notification.py new file mode 100644 index 00000000..0b65d695 --- /dev/null +++ b/examples/notification/notification.py @@ -0,0 +1,119 @@ +import uuid +from datetime import datetime + +from nisystemlink.clients.alarm import AlarmClient +from nisystemlink.clients.alarm.models._alarm import Alarm, AlarmSeverityLevel +from nisystemlink.clients.alarm.models._create_or_update_alarm_request import ( + CreateOrUpdateAlarmRequest, + SetAlarmTransition, +) +from nisystemlink.clients.core import HttpConfiguration +from nisystemlink.clients.notification import NotificationClient +from nisystemlink.clients.notification.models import ( + DynamicNotificationConfiguration, + DynamicNotificationStrategy, + DynamicStrategyRequest, + SmtpAddressFields, + SmtpAddressGroup, + SmtpMessageTemplate, + SmtpMessageTemplateFields, +) + +# Server configuration is not required when used with SystemLink Client or run through Jupyter on SystemLink +server_configuration: HttpConfiguration | None = None + +# To set up the server configuration to point to your instance of SystemLink Enterprise, uncomment +# the following lines and provide your server URI and API key. +# server_configuration = HttpConfiguration( +# server_uri="https://yourserver.yourcompany.com", +# api_key="", +# ) + + +# Create request for applying strategy +def create_notification_request_for_alarm( + alarm: Alarm, + address_group: SmtpAddressGroup, + message_template: SmtpMessageTemplate, +) -> DynamicStrategyRequest: + """Creates and returns a dynamic strategy request.""" + occurred_at = alarm.most_recent_transition_occurred_at + + return DynamicStrategyRequest( + message_template_substitution_fields={ + "alarm_id": alarm.alarm_id, + "alarm_condition": alarm.condition, + "alarm_description": alarm.description, + "alarm_severity": str(alarm.current_severity_level), + "alarm_occurred_at": occurred_at.isoformat() if occurred_at else "", + }, + notification_strategy=DynamicNotificationStrategy( + notification_configurations=[ + DynamicNotificationConfiguration( + address_group=address_group, + message_template=message_template, + ) + ] + ), + ) + + +# Create clients for Notification and Alarm services +notification_client = NotificationClient(configuration=server_configuration) +alarm_client = AlarmClient(configuration=server_configuration) + +# Create a unique alarm ID for this example +alarm_id = f"example_alarm_{uuid.uuid1().hex}" + +# Create an alarm with a SET transition +create_alarm_request = CreateOrUpdateAlarmRequest( + alarm_id=alarm_id, + transition=SetAlarmTransition( + occurred_at=datetime.now(), + severity_level=AlarmSeverityLevel.HIGH, + value="85", + condition="Greater than 80", + short_text="Temperature is high", + detail_text="Temperature sensor reading is 85°C (higher than the configured threshold of 80°C)", + ), + description="Example alarm for notification", +) +id = alarm_client.create_or_update_alarm(create_alarm_request) +print("Alarm created successfully") + +# Get the alarm by its instance ID (the unique occurrence identifier) +retrieved_alarm = alarm_client.get_alarm(instance_id=id) + +# Define recipients to notify +recipients = SmtpAddressFields(toAddresses=["sample1@example.com"]) + +# Create address group +address_group = SmtpAddressGroup( + display_name="Alarm Notification Recipients", + properties={"address group": "Alarm"}, + fields=recipients, +) + +# Create mail template for alarm creation notification +alarm_creation_template = SmtpMessageTemplate( + display_name="Alarm Creation Template", + fields=SmtpMessageTemplateFields( + subject_template="Alarm Created: ", + body_template="An alarm with ID has been created.\n" + "Condition: \n" + "Description: \n" + "Current severity: \n" + "Occurred At: ", + ), +) + +# Send notification for alarm creation +notification_for_alarm_creation = create_notification_request_for_alarm( + alarm=retrieved_alarm, + address_group=address_group, + message_template=alarm_creation_template, +) +notification_client.apply_dynamic_notification_strategy( + request=notification_for_alarm_creation +) +print("Notification sent for alarm creation") diff --git a/nisystemlink/clients/notification/__init__.py b/nisystemlink/clients/notification/__init__.py new file mode 100644 index 00000000..93aa4ea6 --- /dev/null +++ b/nisystemlink/clients/notification/__init__.py @@ -0,0 +1,3 @@ +from ._notification_client import NotificationClient + +# flake8: noqa diff --git a/nisystemlink/clients/notification/_notification_client.py b/nisystemlink/clients/notification/_notification_client.py new file mode 100644 index 00000000..a373f8f3 --- /dev/null +++ b/nisystemlink/clients/notification/_notification_client.py @@ -0,0 +1,49 @@ +"""Implementation of Notification Client""" + +from nisystemlink.clients import core +from nisystemlink.clients.core._uplink._base_client import BaseClient +from nisystemlink.clients.core._uplink._methods import post +from uplink import retry + +from . import models + + +@retry( + when=retry.when.status(408, 429, 502, 503, 504), + stop=retry.stop.after_attempt(1), + on_exception=retry.CONNECTION_ERROR, +) +class NotificationClient(BaseClient): + def __init__(self, configuration: core.HttpConfiguration | None = None): + """Initialize an instance. + + Args: + configuration: Defines the web server to connect to and information about + how to connect. If not provided, the + :class:`HttpConfigurationManager ` + is used to obtain the configuration. + + Raises: + ApiException: if unable to communicate with the `/ninotification` service. + """ + if configuration is None: + configuration = core.HttpConfigurationManager.get_configuration() + + super().__init__(configuration, base_path="/ninotification/v1/") + + @post("apply-dynamic-strategy") + def apply_dynamic_notification_strategy( + self, request: models.DynamicStrategyRequest + ) -> None: + """Applies the notification strategy from the given request. + + Args: + request: Request with message template substitution fields and notification strategies. + + Returns: + None. + + Raises: + ApiException: if unable to communicate with the `/ninotification` service or provided invalid arguments. + """ + ... diff --git a/nisystemlink/clients/notification/models/__init__.py b/nisystemlink/clients/notification/models/__init__.py new file mode 100644 index 00000000..715d9a17 --- /dev/null +++ b/nisystemlink/clients/notification/models/__init__.py @@ -0,0 +1,9 @@ +from ._address_group import AddressGroup +from ._dynamic_strategy_request import DynamicStrategyRequest +from ._message_template import MessageTemplate +from ._dynamic_notification_configuration import DynamicNotificationConfiguration +from ._dynamic_notification_strategy import DynamicNotificationStrategy +from ._smtp_address_group import SmtpAddressFields, SmtpAddressGroup +from ._smtp_message_template import SmtpMessageTemplateFields, SmtpMessageTemplate + +# flake8: noqa diff --git a/nisystemlink/clients/notification/models/_address_group.py b/nisystemlink/clients/notification/models/_address_group.py new file mode 100644 index 00000000..dec29115 --- /dev/null +++ b/nisystemlink/clients/notification/models/_address_group.py @@ -0,0 +1,13 @@ +from typing import Dict, List + +from ._base_notification_metadata import BaseNotificationMetadata + + +class AddressGroup(BaseNotificationMetadata): + """Model defining notification recipients for generic service.""" + + interpreting_service_name: str + """Name of the interpreting service.""" + + fields: Dict[str, List[str]] + """Address group's fields. Requires at least one valid recipient.""" diff --git a/nisystemlink/clients/notification/models/_base_notification_metadata.py b/nisystemlink/clients/notification/models/_base_notification_metadata.py new file mode 100644 index 00000000..ae2f4922 --- /dev/null +++ b/nisystemlink/clients/notification/models/_base_notification_metadata.py @@ -0,0 +1,23 @@ +from typing import Dict, List + +from nisystemlink.clients.core._uplink._json_model import JsonModel + + +class BaseNotificationMetadata(JsonModel): + id: str | None = None + """Identifier of the notification metadata""" + + display_name: str | None = None + """Display name of the object. + + Example: "name" + """ + + properties: Dict[str, str] | None = None + """Additional properties for the base metadata. + + Example: { "property": "value" } + """ + + referencing_notification_strategies: List[str] | None = None + """List of notification strategies referencing the notification metadata""" diff --git a/nisystemlink/clients/notification/models/_dynamic_notification_configuration.py b/nisystemlink/clients/notification/models/_dynamic_notification_configuration.py new file mode 100644 index 00000000..4e092ede --- /dev/null +++ b/nisystemlink/clients/notification/models/_dynamic_notification_configuration.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from nisystemlink.clients.core._uplink._json_model import JsonModel +from nisystemlink.clients.notification.models._address_group import AddressGroup +from nisystemlink.clients.notification.models._message_template import MessageTemplate +from nisystemlink.clients.notification.models._smtp_address_group import ( + SmtpAddressGroup, +) +from nisystemlink.clients.notification.models._smtp_message_template import ( + SmtpMessageTemplate, +) +from pydantic import Field, model_validator + + +class DynamicNotificationConfiguration(JsonModel): + """Model for notification configuration defining address groups and message template for the notification. + + Requires at least one of addressGroupId or addressGroup, and one of messageTemplateId or messageTemplate. + """ + + address_group_id: str | None = None + """ID referencing the associated address group.""" + + message_template_id: str | None = None + """ID referencing the associated message template.""" + + address_group: AddressGroup | SmtpAddressGroup | None = Field(default=None) + """Address group defining notification recipients.""" + + message_template: MessageTemplate | SmtpMessageTemplate | None = Field(default=None) + """Message template defining notification content structure""" + + @model_validator(mode="after") + def validate_required_pairs(self) -> DynamicNotificationConfiguration: + """Validator to check at least one of address_group_id or address_group, and + one of message_template_id or message_template is present. + """ + if self.address_group_id is None and self.address_group is None: + raise ValueError( + "One of either AddressGroupId or AddressGroup is required." + ) + + if self.message_template_id is None and self.message_template is None: + raise ValueError( + "One of either MessageTemplateId or MessageTemplate is required." + ) + + return self diff --git a/nisystemlink/clients/notification/models/_dynamic_notification_strategy.py b/nisystemlink/clients/notification/models/_dynamic_notification_strategy.py new file mode 100644 index 00000000..06cb063f --- /dev/null +++ b/nisystemlink/clients/notification/models/_dynamic_notification_strategy.py @@ -0,0 +1,16 @@ +from typing import Annotated, List + + +from nisystemlink.clients.core._uplink._json_model import JsonModel +from pydantic import Field + +from ._dynamic_notification_configuration import DynamicNotificationConfiguration + + +class DynamicNotificationStrategy(JsonModel): + """Model for the notification strategy to be applied.""" + + notification_configurations: Annotated[ + List[DynamicNotificationConfiguration], Field(min_length=1) + ] + """Notification configurations associated with this strategy.""" diff --git a/nisystemlink/clients/notification/models/_dynamic_strategy_request.py b/nisystemlink/clients/notification/models/_dynamic_strategy_request.py new file mode 100644 index 00000000..cec4d7c6 --- /dev/null +++ b/nisystemlink/clients/notification/models/_dynamic_strategy_request.py @@ -0,0 +1,18 @@ +from typing import Dict + +from nisystemlink.clients.core._uplink._json_model import JsonModel + +from ._dynamic_notification_strategy import DynamicNotificationStrategy + + +class DynamicStrategyRequest(JsonModel): + """Request model for applying a dynamic notification strategy.""" + + message_template_substitution_fields: Dict[str, str] | None = None + """Defines the fields used for substituting values in the message template. + + Example: { "replacement": "value" } + """ + + notification_strategy: DynamicNotificationStrategy + """Notification strategy containing configurations for address groups and message templates.""" diff --git a/nisystemlink/clients/notification/models/_message_template.py b/nisystemlink/clients/notification/models/_message_template.py new file mode 100644 index 00000000..c8556be6 --- /dev/null +++ b/nisystemlink/clients/notification/models/_message_template.py @@ -0,0 +1,13 @@ +from typing import Dict + +from ._base_notification_metadata import BaseNotificationMetadata + + +class MessageTemplate(BaseNotificationMetadata): + """Model defining the notification content structure.""" + + interpreting_service_name: str + """Name of the interpreting service""" + + fields: Dict[str, str] + """Template fields for message.""" diff --git a/nisystemlink/clients/notification/models/_smtp_address_group.py b/nisystemlink/clients/notification/models/_smtp_address_group.py new file mode 100644 index 00000000..55fdcd05 --- /dev/null +++ b/nisystemlink/clients/notification/models/_smtp_address_group.py @@ -0,0 +1,52 @@ +from typing import List, Literal + +from nisystemlink.clients.core._uplink._json_model import JsonModel +from nisystemlink.clients.notification.models._base_notification_metadata import ( + BaseNotificationMetadata, +) +from pydantic import model_validator + + +class SmtpAddressFields(JsonModel): + """Recipient address fields used in SMTP messaging.""" + + toAddresses: List[str] | None = None + """List of primary recipient addresses.""" + + ccAddresses: List[str] | None = None + """List of carbon copy recipient addresses.""" + + bccAddresses: List[str] | None = None + """List of blind carbon copy recipient addresses.""" + + +class SmtpAddressGroup(BaseNotificationMetadata): + """Model defining notification recipients for SMTP service.""" + + interpreting_service_name: Literal["smtp"] = "smtp" + """Service name for SMTP-based interpretation.""" + + fields: SmtpAddressFields + """Recipient address fields used for SMTP notifications. + + Valid fields: + - toAddresses + - ccAddresses + - bccAddresses + + Example: + { + toAddresses: [ "address1@example.com" ], + ccAddresses: [ "address2@example.com" ], + bccAddresses: [ "address3@example.com" ] + } + """ + + @model_validator(mode="before") + @classmethod + def set_interpreting_service_name( + cls, data: "SmtpAddressGroup" + ) -> "SmtpAddressGroup": + if isinstance(data, dict) and "interpreting_service_name" not in data: + data["interpreting_service_name"] = "smtp" + return data diff --git a/nisystemlink/clients/notification/models/_smtp_message_template.py b/nisystemlink/clients/notification/models/_smtp_message_template.py new file mode 100644 index 00000000..d1931173 --- /dev/null +++ b/nisystemlink/clients/notification/models/_smtp_message_template.py @@ -0,0 +1,36 @@ +from typing import Literal + +from nisystemlink.clients.core._uplink._json_model import JsonModel +from nisystemlink.clients.notification.models._base_notification_metadata import ( + BaseNotificationMetadata, +) +from pydantic import model_validator + + +class SmtpMessageTemplateFields(JsonModel): + """Template fields to construct an SMTP message.""" + + subject_template: str + """Subject template of the message.""" + + body_template: str | None = None + """Body template of the message.""" + + +class SmtpMessageTemplate(BaseNotificationMetadata): + """Model defining message template for SMTP service""" + + interpreting_service_name: Literal["smtp"] = "smtp" + """Service name for SMTP-based interpretation.""" + + fields: SmtpMessageTemplateFields + """Subject and body template fields for SMTP messages.""" + + @model_validator(mode="before") + @classmethod + def set_interpreting_service_name( + cls, data: "SmtpMessageTemplate" + ) -> "SmtpMessageTemplate": + if isinstance(data, dict) and "interpreting_service_name" not in data: + data["interpreting_service_name"] = "smtp" + return data diff --git a/tests/integration/notification/test_notification_client.py b/tests/integration/notification/test_notification_client.py new file mode 100644 index 00000000..266ea77d --- /dev/null +++ b/tests/integration/notification/test_notification_client.py @@ -0,0 +1,266 @@ +import pytest +import responses +from nisystemlink.clients.core import ApiException +from nisystemlink.clients.core._http_configuration import HttpConfiguration +from nisystemlink.clients.notification import NotificationClient +from nisystemlink.clients.notification.models import ( + DynamicNotificationConfiguration, + DynamicNotificationStrategy, + DynamicStrategyRequest, + SmtpAddressFields, + SmtpAddressGroup, + SmtpMessageTemplate, + SmtpMessageTemplateFields, +) +from pydantic import ValidationError + +BASE_URL = "https://test-api.lifecyclesolutions.ni.com" + + +@pytest.fixture +def _smtp_address_group(): + """Returns the created address group.""" + return SmtpAddressGroup( + id="address_group_id", + display_name="name", + properties={"property": "value"}, + fields=SmtpAddressFields( + toAddresses=["address1@example.com"], + ccAddresses=["address2@example.com"], + bccAddresses=["address3@example.com"], + ), + referencing_notification_strategies=["reference_notification_strategy"], + ) + + +@pytest.fixture +def _smtp_message_template(): + """Returns the created message template.""" + return SmtpMessageTemplate( + id="message_template_id", + display_name="name", + properties={"property": "value"}, + fields=SmtpMessageTemplateFields( + subject_template="subject", body_template="body" + ), + referencing_notification_strategies=["reference_notification_strategy"], + ) + + +@pytest.fixture +def _notification_configuration( + _smtp_address_group: SmtpAddressGroup, _smtp_message_template: SmtpMessageTemplate +): + """Returns the created notification configuration.""" + return DynamicNotificationConfiguration( + address_group_id="address_group_id", + message_template_id="message_template_id", + address_group=_smtp_address_group, + message_template=_smtp_message_template, + ) + + +@pytest.fixture +def _notification_strategy( + _notification_configuration: DynamicNotificationConfiguration, +): + """Returns the created notification strategy.""" + return DynamicNotificationStrategy( + notification_configurations=[ + _notification_configuration, + ] + ) + + +@pytest.fixture +def request_model( + _notification_strategy: DynamicNotificationStrategy, +): + """Returns the created request.""" + return DynamicStrategyRequest( + message_template_substitution_fields={"replacement": "value"}, + notification_strategy=_notification_strategy, + ) + + +@pytest.fixture(scope="class") +def client(enterprise_config: HttpConfiguration) -> NotificationClient: + """Fixture to create a Notification client.""" + return NotificationClient(enterprise_config) + + +@pytest.mark.integration +@pytest.mark.enterprise +class TestNotificationClient: + @responses.activate + def test__apply_dynamic_strategy_with_correct_request__returns_none( + self, client: NotificationClient, request_model: DynamicStrategyRequest + ): + responses.add( + responses.POST, + f"{BASE_URL}/ninotification/v1/apply-dynamic-strategy", + status=204, + ) + assert client.apply_dynamic_notification_strategy(request=request_model) is None + + def test__apply_dynamic_strategy_with_invalid_recipient_for_smtp_service__raises_exception( + self, + client: NotificationClient, + _smtp_message_template: SmtpMessageTemplate, + ): + address_group = SmtpAddressGroup( + fields=SmtpAddressFields(toAddresses=["invalid-email"]), + ) + + request_model = DynamicStrategyRequest( + message_template_substitution_fields={"replacement": "value"}, + notification_strategy=DynamicNotificationStrategy( + notification_configurations=[ + DynamicNotificationConfiguration( + address_group=address_group, + message_template=_smtp_message_template, + ) + ] + ), + ) + + with pytest.raises(ApiException, match="Bad Request") as exc_info: + client.apply_dynamic_notification_strategy(request=request_model) + + assert exc_info.value.http_status_code == 400 + + def test__create_strategy_with_no_configurations__raises_exception(self): + with pytest.raises(ValidationError): + DynamicStrategyRequest( + message_template_substitution_fields={"replacement": "value"}, + notification_strategy=DynamicNotificationStrategy( + notification_configurations=[] + ), + ) + + def test__apply_dynamic_strategy_with_empty_subject_for_smtp_message_template__raises_exception( + self, client: NotificationClient, _smtp_address_group: SmtpAddressGroup + ): + message_template = SmtpMessageTemplate( + fields=SmtpMessageTemplateFields(subject_template=""), + ) + + request_model = DynamicStrategyRequest( + message_template_substitution_fields={"replacement": "value"}, + notification_strategy=DynamicNotificationStrategy( + notification_configurations=[ + DynamicNotificationConfiguration( + address_group=_smtp_address_group, + message_template=message_template, + ) + ] + ), + ) + + with pytest.raises(ApiException, match="Bad Request") as exc_info: + client.apply_dynamic_notification_strategy(request=request_model) + + assert exc_info.value.http_status_code == 400 + + def test__create_address_group_with_invalid_interpreting_service_name__raises_exception( + self, + ): + with pytest.raises(ValidationError): + SmtpAddressGroup( + interpreting_service_name="invalid_service", + fields=SmtpAddressFields(toAddresses=["address1@example.com"]), + ) + + @responses.activate + def test__apply_dynamic_strategy_with_no_address_group_id_and_message_template_id_in_config__returns_none( + self, + client: NotificationClient, + _smtp_address_group: SmtpAddressGroup, + _smtp_message_template: SmtpMessageTemplate, + ): + responses.add( + responses.POST, + f"{BASE_URL}/ninotification/v1/apply-dynamic-strategy", + status=204, + ) + configuration = DynamicNotificationConfiguration( + address_group=_smtp_address_group, + message_template=_smtp_message_template, + ) + + request_model = DynamicStrategyRequest( + message_template_substitution_fields={"replacement": "value"}, + notification_strategy=DynamicNotificationStrategy( + notification_configurations=[ + configuration, + ] + ), + ) + assert client.apply_dynamic_notification_strategy(request=request_model) is None + + @responses.activate + def test__apply_dynamic_strategy_with_multiple_notification_configurations__returns_none( + self, + client: NotificationClient, + _smtp_address_group: SmtpAddressGroup, + ): + responses.add( + responses.POST, + f"{BASE_URL}/ninotification/v1/apply-dynamic-strategy", + status=204, + ) + first_message_template = SmtpMessageTemplate( + fields=SmtpMessageTemplateFields( + subject_template="subject1", body_template="body1" + ), + ) + second_message_template = SmtpMessageTemplate( + fields=SmtpMessageTemplateFields( + subject_template="subject2", body_template="body2" + ), + ) + + configuration1 = DynamicNotificationConfiguration( + address_group=_smtp_address_group, + message_template=first_message_template, + ) + + configuration2 = DynamicNotificationConfiguration( + address_group=_smtp_address_group, + message_template=second_message_template, + ) + + request_model = DynamicStrategyRequest( + message_template_substitution_fields={"replacement": "value"}, + notification_strategy=DynamicNotificationStrategy( + notification_configurations=[ + configuration1, + configuration2, + ] + ), + ) + assert client.apply_dynamic_notification_strategy(request=request_model) is None + + def test__create_configuration_with_no_address_group_id_and_address_group__raise_exception( + self, _smtp_message_template: SmtpMessageTemplate + ): + with pytest.raises( + ValidationError, + match="One of either AddressGroupId or AddressGroup is required.", + ): + DynamicNotificationConfiguration( + message_template_id="message_template_id", + message_template=_smtp_message_template, + ) + + def test__create_configuration_with_no_message_template_id_and_message_template__raise_exception( + self, _smtp_address_group: SmtpAddressGroup + ): + with pytest.raises( + ValidationError, + match="One of either MessageTemplateId or MessageTemplate is required.", + ): + DynamicNotificationConfiguration( + address_group_id="address_group_id", + address_group=_smtp_address_group, + )