diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d128a05d..bf3ba77a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to ### Added +- ✨(domains) add notification when domain status changes +- ✨(domains) add periodic tasks to fetch domain status +- 🧑‍💻(docker) add celery beat to manage periodic tasks - ✨(dimail) management command to fetch domain status ### Changed diff --git a/Makefile b/Makefile index 97e150c0c..54107b358 100644 --- a/Makefile +++ b/Makefile @@ -109,6 +109,7 @@ run: ## start the wsgi (production) and development server @$(COMPOSE) up --force-recreate -d nginx @$(COMPOSE) up --force-recreate -d app-dev @$(COMPOSE) up --force-recreate -d celery-dev + @$(COMPOSE) up --force-recreate -d celery-beat-dev @$(COMPOSE) up --force-recreate -d keycloak @$(COMPOSE) up -d dimail @echo "Wait for postgresql to be up..." diff --git a/docker-compose.yml b/docker-compose.yml index d00cc52a4..f5e74db28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,22 @@ services: depends_on: - app-dev + celery-beat-dev: + user: ${DOCKER_USER:-1000} + image: people:backend-development + command: ["celery", "-A", "people.celery_app", "beat", "-l", "DEBUG"] + environment: + - DJANGO_CONFIGURATION=Development + env_file: + - env.d/development/common + - env.d/development/postgresql + volumes: + - ./src/backend:/app + - ./data/media:/data/media + - ./data/static:/data/static + depends_on: + - app-dev + app: build: context: . diff --git a/src/backend/mailbox_manager/models.py b/src/backend/mailbox_manager/models.py index 77ba8e5db..4f9dd78f5 100644 --- a/src/backend/mailbox_manager/models.py +++ b/src/backend/mailbox_manager/models.py @@ -73,6 +73,17 @@ def get_abilities(self, user): "manage_accesses": is_owner_or_admin, } + def send_notification(self, subject, message): + """ + Notify owners and admins of the domain + """ + for access in self.accesses.filter( + role__in=[MailDomainRoleChoices.OWNER, MailDomainRoleChoices.ADMIN] + ).all(): + access.user.email_user( + subject=subject, message=message, from_email=settings.DEFAULT_FROM_EMAIL + ) + class MailDomainAccess(BaseModel): """Allow to manage users' accesses to mail domains.""" diff --git a/src/backend/mailbox_manager/tasks.py b/src/backend/mailbox_manager/tasks.py new file mode 100644 index 000000000..8cf11e945 --- /dev/null +++ b/src/backend/mailbox_manager/tasks.py @@ -0,0 +1,57 @@ +"""Mailbox manager tasks.""" + +import logging + +import requests +from celery.schedules import crontab + +from mailbox_manager.models import MailDomain, MailDomainStatusChoices +from mailbox_manager.utils.dimail import DimailAPIClient +from people.celery_app import app as celery_app + +logger = logging.getLogger(__name__) + + +@celery_app.on_after_finalize.connect +def setup_periodic_tasks(sender, **kwargs): + """ + Setup periodic tasks. + """ + sender.add_periodic_task( + crontab(minute="*/5"), fetch_domains_status.s(), name="Fetch domains status" + ) + + +@celery_app.task +def fetch_domains_status(): + """ + Call dimail to check and update domains status. + """ + client = DimailAPIClient() + # do not fetch status of disabled domains + domains = MailDomain.objects.exclude(status=MailDomainStatusChoices.DISABLED) + update_count, check_count = 0, 0 + for domain in domains: + old_status = domain.status + try: + client.fetch_domain_status(domain) + except requests.exceptions.HTTPError as err: + logger.error("Failed to fetch status for domain %s: %s", domain.name, err) + else: + if old_status != domain.status: + update_count += 1 + # Send notification to owners and admins of the domain + # when its status changes to failed or enabled + if domain.status == MailDomainStatusChoices.FAILED: + domain.send_notification( + subject="Domain status changed", + message=f"Domain {domain.name} is down", + ) + elif domain.status == MailDomainStatusChoices.ENABLED: + domain.send_notification( + subject="Domain status changed", + message=f"Domain {domain.name} is up", + ) + else: + check_count += 1 + return f"Domains processed: {update_count} updated, {check_count} checked" diff --git a/src/backend/mailbox_manager/tests/test_tasks.py b/src/backend/mailbox_manager/tests/test_tasks.py new file mode 100644 index 000000000..77969948a --- /dev/null +++ b/src/backend/mailbox_manager/tests/test_tasks.py @@ -0,0 +1,136 @@ +""" +Unit tests for mailbox manager tasks. +""" + +import json +import re +from unittest import mock + +from django.conf import settings + +import pytest +import responses + +from mailbox_manager import enums, factories, tasks + +from .fixtures.dimail import CHECK_DOMAIN_BROKEN, CHECK_DOMAIN_OK + +pytestmark = pytest.mark.django_db + + +@responses.activate +def test_fetch_domain_status_task_success(): # pylint: disable=too-many-locals + """Test fetch domain status from dimail task""" + + domain_enabled1 = factories.MailDomainEnabledFactory() + domain_enabled2 = factories.MailDomainEnabledFactory() + owner_domain_enabled2 = factories.MailDomainAccessFactory( + domain=domain_enabled2, role=enums.MailDomainRoleChoices.OWNER + ).user + admin_domain_enabled2 = factories.MailDomainAccessFactory( + domain=domain_enabled2, role=enums.MailDomainRoleChoices.ADMIN + ).user + domain_disabled = factories.MailDomainFactory( + status=enums.MailDomainStatusChoices.DISABLED + ) + domain_failed = factories.MailDomainFactory( + status=enums.MailDomainStatusChoices.FAILED + ) + owner_domain_failed = factories.MailDomainAccessFactory( + domain=domain_failed, role=enums.MailDomainRoleChoices.OWNER + ).user + admin_domain_failed = factories.MailDomainAccessFactory( + domain=domain_failed, role=enums.MailDomainRoleChoices.ADMIN + ).user + body_content_ok1 = CHECK_DOMAIN_OK.copy() + body_content_ok1["name"] = domain_enabled1.name + + body_content_broken = CHECK_DOMAIN_BROKEN.copy() + body_content_broken["name"] = domain_enabled2.name + + body_content_ok2 = CHECK_DOMAIN_OK.copy() + body_content_ok2["name"] = domain_disabled.name + + body_content_ok3 = CHECK_DOMAIN_OK.copy() + body_content_ok3["name"] = domain_failed.name + for domain, body_content in [ + (domain_enabled1, body_content_ok1), + (domain_enabled2, body_content_broken), + (domain_failed, body_content_ok3), + ]: + # Mock dimail API with success response + responses.add( + responses.GET, + re.compile(rf".*/domains/{domain.name}/check/"), + body=json.dumps(body_content), + status=200, + content_type="application/json", + ) + with mock.patch("django.core.mail.send_mail") as mock_send: + tasks.fetch_domains_status() + domain_enabled1.refresh_from_db() + domain_enabled2.refresh_from_db() + domain_disabled.refresh_from_db() + domain_failed.refresh_from_db() + # Nothing change for the first domain enable + assert domain_enabled1.status == enums.MailDomainStatusChoices.ENABLED + # Status of the second activated domain has changed to failure + assert domain_enabled2.status == enums.MailDomainStatusChoices.FAILED + # Status of the failed domain has changed to enabled + assert domain_failed.status == enums.MailDomainStatusChoices.ENABLED + # Check notification was sent to owners and admins + assert mock_send.call_count == 4 + calls = [ + mock.call( + "Domain status changed", + f"Domain {domain_enabled2.name} is down", + settings.DEFAULT_FROM_EMAIL, + [owner_domain_enabled2.email], + ), + mock.call( + "Domain status changed", + f"Domain {domain_enabled2.name} is down", + settings.DEFAULT_FROM_EMAIL, + [admin_domain_enabled2.email], + ), + mock.call( + "Domain status changed", + f"Domain {domain_failed.name} is up", + settings.DEFAULT_FROM_EMAIL, + [owner_domain_failed.email], + ), + mock.call( + "Domain status changed", + f"Domain {domain_failed.name} is up", + settings.DEFAULT_FROM_EMAIL, + [admin_domain_failed.email], + ), + ] + mock_send.assert_has_calls(calls, any_order=True) + # Disabled domain was excluded + assert domain_disabled.status == enums.MailDomainStatusChoices.DISABLED + + +@responses.activate +def test_fetch_domains_status_error_handling(caplog): + """Test fetch domain status from dimail task with error""" + caplog.set_level("ERROR") + + domain = factories.MailDomainEnabledFactory() + + # Mock dimail API with error response + responses.add( + responses.GET, + re.compile(rf".*/domains/{domain.name}/check/"), + body=json.dumps({"error": "Internal Server Error"}), + status=500, + content_type="application/json", + ) + + tasks.fetch_domains_status() + domain.refresh_from_db() + + # Domain status should remain unchanged + assert domain.status == enums.MailDomainStatusChoices.ENABLED + # Check that error was logged + assert f"Failed to fetch status for domain {domain.name}" in caplog.text diff --git a/src/backend/people/__init__.py b/src/backend/people/__init__.py index 467db8f5e..85d814aec 100644 --- a/src/backend/people/__init__.py +++ b/src/backend/people/__init__.py @@ -1 +1,5 @@ """People module.""" + +from .celery_app import app as celery_app + +__all__ = ("celery_app",)