From 412629efea9012228536052b054bf26e0fec5f45 Mon Sep 17 00:00:00 2001 From: Payam Date: Fri, 6 Mar 2026 15:23:16 +0400 Subject: [PATCH] feat: #55 metrics protocol --- .../jobs/deliver_contents_job.py | 9 +- django_email_learning/models.py | 13 +++ .../personalised/api/views.py | 9 ++ .../ports/metric_recorder_protocol.py | 34 ++++++ .../services/command_models/enroll_command.py | 4 + .../command_models/send_lesson_command.py | 7 ++ .../command_models/send_quiz_command.py | 7 ++ .../command_models/unsubscribe_command.py | 7 ++ .../verify_enrollment_command.py | 5 + .../defaults/log_based_metric_recorder.py | 78 +++++++++++++ .../services/email_sender_service.py | 7 +- .../services/metrics_service.py | 58 ++++++++++ ...test_deliver_contents_job_configuration.py | 29 +++++ tests/services/test_email_sender_service.py | 82 ++++++++++++++ tests/services/test_metrics_service.py | 103 ++++++++++++++++++ 15 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 django_email_learning/ports/metric_recorder_protocol.py create mode 100644 django_email_learning/services/defaults/log_based_metric_recorder.py create mode 100644 django_email_learning/services/metrics_service.py create mode 100644 tests/jobs/test_deliver_contents_job_configuration.py create mode 100644 tests/services/test_email_sender_service.py create mode 100644 tests/services/test_metrics_service.py diff --git a/django_email_learning/jobs/deliver_contents_job.py b/django_email_learning/jobs/deliver_contents_job.py index 68847b17..1dab0728 100644 --- a/django_email_learning/jobs/deliver_contents_job.py +++ b/django_email_learning/jobs/deliver_contents_job.py @@ -62,7 +62,14 @@ def get_delivery_queue(self) -> DeliveryQueueProtocol: settings, "DJANGO_EMAIL_LEARNING", {} ) try: - return import_string(DJANGO_EMAIL_LEARNING_SETTINGS["DELIVERY_QUEUE"]) + configured_delivery_queue = import_string( + DJANGO_EMAIL_LEARNING_SETTINGS["DELIVERY_QUEUE"] + ) + return ( + configured_delivery_queue() + if isinstance(configured_delivery_queue, type) + else configured_delivery_queue + ) except KeyError: from django_email_learning.services.defaults.database_delivery_queue import ( DatabaseDeliveryQueue, diff --git a/django_email_learning/models.py b/django_email_learning/models.py index 41d0beaf..2d21aab2 100644 --- a/django_email_learning/models.py +++ b/django_email_learning/models.py @@ -17,6 +17,7 @@ MinLengthValidator, ) from django_email_learning.services.email_sender_service import EmailSenderService +from django_email_learning.services.metrics_service import MetricsService from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from django.core.exceptions import ImproperlyConfigured @@ -60,6 +61,9 @@ class DeliveryStatus(StrEnum): BLOCKED = "blocked" +METRIC_SERVICE = MetricsService() + + def is_domain_or_ip(value: str) -> None: """ Validate if the given value is a valid domain name or IP address. @@ -557,6 +561,10 @@ def graduate(self) -> None: ) self.status = EnrollmentStatus.COMPLETED self.final_state_at = timezone.now() + METRIC_SERVICE.user_completed_course( + course_slug=self.course.slug, + organization_id=self.course.organization.id, + ) logger.info( f"Learner ID {self.learner.id} has completed the course {self.course.title}." ) @@ -609,6 +617,11 @@ def fail(self) -> None: self.status = EnrollmentStatus.DEACTIVATED self.deactivation_reason = DeactivationReason.FAILED self.final_state_at = timezone.now() + METRIC_SERVICE.user_enrollment_deactivated( + course_slug=self.course.slug, + organization_id=self.course.organization.id, + reason=DeactivationReason.FAILED, + ) logger.info( f"Learner ID {self.learner.id} has failed the course {self.course.title}." ) diff --git a/django_email_learning/personalised/api/views.py b/django_email_learning/personalised/api/views.py index 3a39177e..6d21ad26 100644 --- a/django_email_learning/personalised/api/views.py +++ b/django_email_learning/personalised/api/views.py @@ -5,6 +5,7 @@ QuizSubmissionRequest, QuestionResponse, ) +from django_email_learning.services.metrics_service import MetricsService from django_email_learning.services import jwt_service from django.utils.translation import gettext as _ from django_email_learning.models import ( @@ -19,6 +20,8 @@ import json import logging +METRIC_SERVICE = MetricsService() + logger = logging.getLogger(__name__) @@ -113,6 +116,12 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def] ) delivery.repeat_delivery_in_days(1) + METRIC_SERVICE.quiz_submitted( + course_slug=enrolment.course.slug, + organization_id=enrolment.course.organization.id, + quiz_id=quiz.id, + is_passed=passed, + ) return JsonResponse( { "score": score, diff --git a/django_email_learning/ports/metric_recorder_protocol.py b/django_email_learning/ports/metric_recorder_protocol.py new file mode 100644 index 00000000..fb0dfc1c --- /dev/null +++ b/django_email_learning/ports/metric_recorder_protocol.py @@ -0,0 +1,34 @@ +from typing import Protocol +from django_email_learning.models import DeactivationReason + + +class MetricRecorderProtocol(Protocol): + def user_enrolled_in_course(self, course_slug: str, organization_id: int) -> None: + ... + + def quiz_sent(self, course_slug: str, organization_id: int, quiz_id: int) -> None: + ... + + def lesson_sent( + self, course_slug: str, organization_id: int, lesson_id: int + ) -> None: + ... + + def user_enrollment_activated(self, course_slug: str, organization_id: int) -> None: + ... + + def user_enrollment_deactivated( + self, course_slug: str, organization_id: int, reason: DeactivationReason + ) -> None: + ... + + def user_completed_course(self, course_slug: str, organization_id: int) -> None: + ... + + def quiz_submitted( + self, course_slug: str, organization_id: int, quiz_id: int, is_passed: bool + ) -> None: + ... + + def method_executed(self, method_name: str, execution_time: int) -> None: + ... diff --git a/django_email_learning/services/command_models/enroll_command.py b/django_email_learning/services/command_models/enroll_command.py index 96bf1795..ad3c7fe3 100644 --- a/django_email_learning/services/command_models/enroll_command.py +++ b/django_email_learning/services/command_models/enroll_command.py @@ -20,6 +20,7 @@ from django_email_learning.services.utils import mask_email from django_email_learning.services.email_sender_service import EmailSenderService from django_email_learning.services import jwt_service +from django_email_learning.services.metrics_service import MetricsService from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from django.utils.translation import gettext as _ @@ -36,6 +37,7 @@ class EnrollCommand(AbstractCommand): no_verification: bool = False def execute(self) -> None: + metric_service = MetricsService() # Check if the email is blocked if BlockedEmail.objects.filter(email=self.email).exists(): self.logger.info( @@ -88,6 +90,8 @@ def execute(self) -> None: f"Enrollment Successful: Learner ID {learner.id} enrolled in course '{self.course_slug}'. Enrollment ID: {enrollment.id}" ) + metric_service.user_enrolled_in_course(self.course_slug, self.organization_id) + if self.no_verification: self.logger.info( f"Verification email skipped for Enrollment ID: {enrollment.id} as per command parameter" diff --git a/django_email_learning/services/command_models/send_lesson_command.py b/django_email_learning/services/command_models/send_lesson_command.py index 4b309570..8f892e48 100644 --- a/django_email_learning/services/command_models/send_lesson_command.py +++ b/django_email_learning/services/command_models/send_lesson_command.py @@ -3,6 +3,7 @@ ) from django_email_learning.models import Lesson, CourseContent from django_email_learning.services.email_sender_service import EmailSenderService +from django_email_learning.services.metrics_service import MetricsService from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from typing import Literal @@ -18,6 +19,7 @@ class SendLessonCommand(AbstractCommand): email: str def execute(self) -> None: + metric_service = MetricsService() content = CourseContent.objects.get(id=self.content_id) if not content.lesson: raise LessonNotFoundError( @@ -52,3 +54,8 @@ def execute(self) -> None: ) email_service.send(email_message) + metric_service.lesson_sent( + course_slug=content.course.slug, + organization_id=content.course.organization.id, + lesson_id=lesson.id, + ) diff --git a/django_email_learning/services/command_models/send_quiz_command.py b/django_email_learning/services/command_models/send_quiz_command.py index cc895df8..1c4926e3 100644 --- a/django_email_learning/services/command_models/send_quiz_command.py +++ b/django_email_learning/services/command_models/send_quiz_command.py @@ -3,6 +3,7 @@ ) from django_email_learning.models import Quiz, CourseContent from django_email_learning.services.email_sender_service import EmailSenderService +from django_email_learning.services.metrics_service import MetricsService from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from typing import Literal @@ -19,6 +20,7 @@ class SendQuizCommand(AbstractCommand): content_id: int def execute(self) -> None: + metric_service = MetricsService() content = CourseContent.objects.get(id=self.content_id) if not content.quiz: raise QuizNotFoundError( @@ -53,3 +55,8 @@ def execute(self) -> None: ) email_service.send(email_message) + metric_service.quiz_sent( + course_slug=content.course.slug, + organization_id=content.course.organization.id, + quiz_id=quiz.id, + ) diff --git a/django_email_learning/services/command_models/unsubscribe_command.py b/django_email_learning/services/command_models/unsubscribe_command.py index 9ba47504..53438876 100644 --- a/django_email_learning/services/command_models/unsubscribe_command.py +++ b/django_email_learning/services/command_models/unsubscribe_command.py @@ -8,6 +8,7 @@ DeactivationReason, ) from django.utils import timezone +from django_email_learning.services.metrics_service import MetricsService from django_email_learning.services.command_models.abstract_command import ( AbstractCommand, ) @@ -23,6 +24,7 @@ class UnsubscribeCommand(AbstractCommand): organization_id: int def execute(self) -> None: + metric_service = MetricsService() try: course = Course.objects.get( slug=self.course_slug, organization_id=self.organization_id @@ -66,3 +68,8 @@ def execute(self) -> None: deactivation_reason=DeactivationReason.CANCELED, final_state_at=timezone.now(), ) + metric_service.user_enrollment_deactivated( + course_slug=course.slug, + organization_id=course.organization.id, + reason=DeactivationReason.CANCELED, + ) diff --git a/django_email_learning/services/command_models/verify_enrollment_command.py b/django_email_learning/services/command_models/verify_enrollment_command.py index f39f760b..6721dc2b 100644 --- a/django_email_learning/services/command_models/verify_enrollment_command.py +++ b/django_email_learning/services/command_models/verify_enrollment_command.py @@ -11,6 +11,7 @@ InvalidVerificationCodeError, ) from django_email_learning.services.email_sender_service import EmailSenderService +from django_email_learning.services.metrics_service import MetricsService from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from django.utils.translation import gettext as _ @@ -24,6 +25,7 @@ class VerifyEnrollmentCommand(AbstractCommand): verification_code: str = Field(..., pattern=r"^\d{6}$") def execute(self) -> None: + metric_service = MetricsService() try: enrollment = Enrollment.objects.get( id=self.enrollment_id, status=EnrollmentStatus.UNVERIFIED @@ -58,6 +60,9 @@ def execute(self) -> None: self.logger.info( f"Content Delivery Scheduled: First content delivery scheduled for Enrollment ID {self.enrollment_id}" ) + metric_service.user_enrollment_activated( + enrollment.course.slug, enrollment.course.organization.id + ) # Send confirmation email email_service = EmailSenderService() diff --git a/django_email_learning/services/defaults/log_based_metric_recorder.py b/django_email_learning/services/defaults/log_based_metric_recorder.py new file mode 100644 index 00000000..1e057d1e --- /dev/null +++ b/django_email_learning/services/defaults/log_based_metric_recorder.py @@ -0,0 +1,78 @@ +from django_email_learning.ports.metric_recorder_protocol import MetricRecorderProtocol +from django_email_learning.models import DeactivationReason +import logging + +logger = logging.getLogger(__name__) + + +class LogBasedMetricRecorder(MetricRecorderProtocol): + def user_enrolled_in_course(self, course_slug: str, organization_id: int) -> None: + logger.info( + "User enrolled", + extra={"course_slug": course_slug, "organization_id": organization_id}, + ) + + def quiz_sent(self, course_slug: str, organization_id: int, quiz_id: int) -> None: + logger.info( + "Quiz sent", + extra={ + "course_slug": course_slug, + "organization_id": organization_id, + "quiz_id": quiz_id, + }, + ) + + def lesson_sent( + self, course_slug: str, organization_id: int, lesson_id: int + ) -> None: + logger.info( + "Lesson sent", + extra={ + "course_slug": course_slug, + "organization_id": organization_id, + "lesson_id": lesson_id, + }, + ) + + def user_enrollment_activated(self, course_slug: str, organization_id: int) -> None: + logger.info( + "User enrollment activated", + extra={"course_slug": course_slug, "organization_id": organization_id}, + ) + + def user_enrollment_deactivated( + self, course_slug: str, organization_id: int, reason: DeactivationReason + ) -> None: + logger.info( + "User enrollment deactivated", + extra={ + "course_slug": course_slug, + "organization_id": organization_id, + "reason": reason, + }, + ) + + def user_completed_course(self, course_slug: str, organization_id: int) -> None: + logger.info( + "User completed course", + extra={"course_slug": course_slug, "organization_id": organization_id}, + ) + + def quiz_submitted( + self, course_slug: str, organization_id: int, quiz_id: int, is_passed: bool + ) -> None: + logger.info( + "Quiz submitted", + extra={ + "course_slug": course_slug, + "organization_id": organization_id, + "quiz_id": quiz_id, + "is_passed": is_passed, + }, + ) + + def method_executed(self, method_name: str, execution_time: int) -> None: + logger.info( + "Method executed", + extra={"method_name": method_name, "execution_time": execution_time}, + ) diff --git a/django_email_learning/services/email_sender_service.py b/django_email_learning/services/email_sender_service.py index 9a89cb13..7e680a41 100644 --- a/django_email_learning/services/email_sender_service.py +++ b/django_email_learning/services/email_sender_service.py @@ -9,9 +9,14 @@ def __init__(self) -> None: settings, "DJANGO_EMAIL_LEARNING", {} ) try: - self.email_sender = import_string( + configured_email_sender = import_string( DJANGO_EMAIL_LEARNING_SETTINGS["EMAIL_SENDER"] ) + self.email_sender = ( + configured_email_sender() + if isinstance(configured_email_sender, type) + else configured_email_sender + ) except KeyError: from django_email_learning.services.defaults.email_sender import ( DjangoEmailSender, diff --git a/django_email_learning/services/metrics_service.py b/django_email_learning/services/metrics_service.py new file mode 100644 index 00000000..6967e33f --- /dev/null +++ b/django_email_learning/services/metrics_service.py @@ -0,0 +1,58 @@ +from django.conf import settings +from django.utils.module_loading import import_string + + +class MetricsService: + def __init__(self) -> None: + DJANGO_EMAIL_LEARNING_SETTINGS: dict = getattr( + settings, "DJANGO_EMAIL_LEARNING", {} + ) + try: + configured_metric_recorder = import_string( + DJANGO_EMAIL_LEARNING_SETTINGS["METRIC_RECORDER"] + ) + self.metric_recorder = ( + configured_metric_recorder() + if isinstance(configured_metric_recorder, type) + else configured_metric_recorder + ) + except KeyError: + from django_email_learning.services.defaults.log_based_metric_recorder import ( + LogBasedMetricRecorder, + ) + + self.metric_recorder = LogBasedMetricRecorder() + + def user_enrolled_in_course(self, course_slug: str, organization_id: int) -> None: + self.metric_recorder.user_enrolled_in_course(course_slug, organization_id) + + def quiz_sent(self, course_slug: str, organization_id: int, quiz_id: int) -> None: + self.metric_recorder.quiz_sent(course_slug, organization_id, quiz_id) + + def lesson_sent( + self, course_slug: str, organization_id: int, lesson_id: int + ) -> None: + self.metric_recorder.lesson_sent(course_slug, organization_id, lesson_id) + + def user_enrollment_activated(self, course_slug: str, organization_id: int) -> None: + self.metric_recorder.user_enrollment_activated(course_slug, organization_id) + + def user_enrollment_deactivated( + self, course_slug: str, organization_id: int, reason: str + ) -> None: + self.metric_recorder.user_enrollment_deactivated( + course_slug, organization_id, reason + ) + + def user_completed_course(self, course_slug: str, organization_id: int) -> None: + self.metric_recorder.user_completed_course(course_slug, organization_id) + + def quiz_submitted( + self, course_slug: str, organization_id: int, quiz_id: int, is_passed: bool + ) -> None: + self.metric_recorder.quiz_submitted( + course_slug, organization_id, quiz_id, is_passed + ) + + def method_executed(self, method_name: str, execution_time: int) -> None: + self.metric_recorder.method_executed(method_name, execution_time) diff --git a/tests/jobs/test_deliver_contents_job_configuration.py b/tests/jobs/test_deliver_contents_job_configuration.py new file mode 100644 index 00000000..eee2d689 --- /dev/null +++ b/tests/jobs/test_deliver_contents_job_configuration.py @@ -0,0 +1,29 @@ +from unittest.mock import Mock, patch + +from django_email_learning.jobs.deliver_contents_job import DeliverContentsJob +from tests.jobs.delivery_queue_mock import DeliveryQueueMock + + +def test_get_delivery_queue_instantiates_configured_class(settings): + settings.DJANGO_EMAIL_LEARNING = { + "DELIVERY_QUEUE": "tests.jobs.delivery_queue_mock.DeliveryQueueMock" + } + + job = DeliverContentsJob() + + assert isinstance(job.delivery_queue, DeliveryQueueMock) + + +def test_get_delivery_queue_uses_prebuilt_configured_object(settings): + settings.DJANGO_EMAIL_LEARNING = { + "DELIVERY_QUEUE": "tests.jobs.delivery_queue_mock.DeliveryQueueMock" + } + prebuilt_queue = Mock() + + with patch( + "django_email_learning.jobs.deliver_contents_job.import_string", + return_value=prebuilt_queue, + ): + job = DeliverContentsJob() + + assert job.delivery_queue is prebuilt_queue diff --git a/tests/services/test_email_sender_service.py b/tests/services/test_email_sender_service.py new file mode 100644 index 00000000..3ffc76d8 --- /dev/null +++ b/tests/services/test_email_sender_service.py @@ -0,0 +1,82 @@ +from unittest.mock import Mock, patch + +import pytest + +from django_email_learning.services.defaults.email_sender import DjangoEmailSender +from django_email_learning.services.email_sender_service import EmailSenderService + + +class ConfiguredEmailSender: + def send_email(self, email) -> None: + pass + + +def test_email_sender_service_instantiates_configured_sender_class(settings): + settings.DJANGO_EMAIL_LEARNING = { + "EMAIL_SENDER": "tests.services.test_email_sender_service.ConfiguredEmailSender", + "FROM_EMAIL": "noreply@example.com", + } + + service = EmailSenderService() + + assert isinstance(service.email_sender, ConfiguredEmailSender) + + +def test_email_sender_service_uses_prebuilt_configured_sender_object(settings): + settings.DJANGO_EMAIL_LEARNING = { + "EMAIL_SENDER": "tests.services.test_email_sender_service.ConfiguredEmailSender", + "FROM_EMAIL": "noreply@example.com", + } + prebuilt_sender = Mock() + + with patch( + "django_email_learning.services.email_sender_service.import_string", + return_value=prebuilt_sender, + ): + service = EmailSenderService() + + assert service.email_sender is prebuilt_sender + + +def test_email_sender_service_uses_default_sender_when_setting_missing(settings): + settings.DJANGO_EMAIL_LEARNING = {"FROM_EMAIL": "noreply@example.com"} + + service = EmailSenderService() + + assert isinstance(service.email_sender, DjangoEmailSender) + + +def test_email_sender_service_uses_default_from_email_when_missing_from_email(settings): + settings.DJANGO_EMAIL_LEARNING = { + "EMAIL_SENDER": "tests.services.test_email_sender_service.ConfiguredEmailSender" + } + settings.DEFAULT_FROM_EMAIL = "default@example.com" + + service = EmailSenderService() + + assert service.from_email == "default@example.com" + + +def test_email_sender_service_raises_when_no_from_email_available(settings): + settings.DJANGO_EMAIL_LEARNING = { + "EMAIL_SENDER": "tests.services.test_email_sender_service.ConfiguredEmailSender" + } + settings.DEFAULT_FROM_EMAIL = "" + + with pytest.raises(ValueError): + EmailSenderService() + + +def test_email_sender_service_send_delegates_to_sender(settings): + settings.DJANGO_EMAIL_LEARNING = { + "EMAIL_SENDER": "tests.services.test_email_sender_service.ConfiguredEmailSender", + "FROM_EMAIL": "noreply@example.com", + } + service = EmailSenderService.__new__(EmailSenderService) + sender = Mock() + service.email_sender = sender + + email = Mock() + service.send(email) + + sender.send_email.assert_called_once_with(email) diff --git a/tests/services/test_metrics_service.py b/tests/services/test_metrics_service.py new file mode 100644 index 00000000..ce993dec --- /dev/null +++ b/tests/services/test_metrics_service.py @@ -0,0 +1,103 @@ +from unittest.mock import Mock, patch + +import pytest + +from django_email_learning.services.defaults.log_based_metric_recorder import ( + LogBasedMetricRecorder, +) +from django_email_learning.services.metrics_service import MetricsService + + +class ConfiguredMetricRecorder: + def user_enrolled_in_course(self, course_slug: str, organization_id: int) -> None: + pass + + def quiz_sent(self, course_slug: str, organization_id: int, quiz_id: int) -> None: + pass + + def lesson_sent( + self, course_slug: str, organization_id: int, lesson_id: int + ) -> None: + pass + + def user_enrollment_activated(self, course_slug: str, organization_id: int) -> None: + pass + + def user_enrollment_deactivated( + self, course_slug: str, organization_id: int, reason + ) -> None: + pass + + def user_completed_course(self, course_slug: str, organization_id: int) -> None: + pass + + def quiz_submitted( + self, course_slug: str, organization_id: int, quiz_id: int, is_passed: bool + ) -> None: + pass + + def method_executed(self, method_name: str, execution_time: int) -> None: + pass + + +def test_metrics_service_uses_default_recorder_when_setting_missing(settings): + settings.DJANGO_EMAIL_LEARNING = {} + + service = MetricsService() + + assert isinstance(service.metric_recorder, LogBasedMetricRecorder) + + +def test_metrics_service_instantiates_configured_recorder_class(settings): + settings.DJANGO_EMAIL_LEARNING = { + "METRIC_RECORDER": "tests.services.test_metrics_service.ConfiguredMetricRecorder" + } + + service = MetricsService() + + assert isinstance(service.metric_recorder, ConfiguredMetricRecorder) + + +def test_metrics_service_uses_configured_recorder_object_as_is(settings): + settings.DJANGO_EMAIL_LEARNING = { + "METRIC_RECORDER": "tests.services.test_metrics_service.ConfiguredMetricRecorder" + } + prebuilt_recorder = Mock() + + with patch( + "django_email_learning.services.metrics_service.import_string", + return_value=prebuilt_recorder, + ): + service = MetricsService() + + assert service.metric_recorder is prebuilt_recorder + + +@pytest.mark.parametrize( + "method_name,args", + [ + ("user_enrolled_in_course", ("course-1", 11)), + ("quiz_sent", ("course-1", 11, 7)), + ("lesson_sent", ("course-1", 11, 9)), + ("user_enrollment_activated", ("course-1", 11)), + ("user_enrollment_deactivated", ("course-1", 11, "inactive")), + ("user_completed_course", ("course-1", 11)), + ("quiz_submitted", ("course-1", 11, 7, True)), + ("method_executed", ("deliver_contents", 123)), + ], +) +def test_metrics_service_delegates_all_calls(method_name, args): + service = MetricsService.__new__(MetricsService) + recorder = Mock() + service.metric_recorder = recorder + + getattr(service, method_name)(*args) + + getattr(recorder, method_name).assert_called_once_with(*args) + + +def test_metrics_service_raises_import_error_for_invalid_configured_path(settings): + settings.DJANGO_EMAIL_LEARNING = {"METRIC_RECORDER": "not.a.real.path.Recorder"} + + with pytest.raises(ImportError): + MetricsService()