From 897272a689095d6895b442a2ef6e25bad10eb958 Mon Sep 17 00:00:00 2001 From: MEHRSHAD MIRSHEKARY Date: Sun, 18 Aug 2024 21:12:54 +0330 Subject: [PATCH] :sparkles::zap::bulb:feat: add Email-Notifier & log_and_notify Introduced an EmailHandler to send email notifications for specific log levels. Configurable log levels for triggering email notifications, allowing granular control over email alerts. Added the log_and_notify method to log a message and send an email notification simultaneously: Uses the logger to create a LogRecord with the correct module, file, and line number. Handles the log record with the logger's handlers and sends an email using the EmailHandler. Supports customizable log messages with additional context data, such as request information. Includes error handling to raise a ValueError if logging fails due to invalid parameters. Asynchronously sends an email with the formatted log message to the administrator. Updated: Log Config: Added log_email_notifier_enable to enable or disable the email notifier feature. Added log_email_notifier_log_levels to specify which log levels should trigger email notifications. Updated the LogConfig class initializer to include new parameters for email notifier settings. apps.py: Introduced logic to fetch and apply email notifier settings from LOG_EMAIL_NOTIFIER. Set up conditions for enabling email notifications for ERROR and CRITICAL log levels based on settings. Updated the set_logging method call to pass the email notifier settings to the logging config. Docstrings: Included comprehensive docstrings for the email notifier feature, log_and_notify method, and configuration updates. Closes #8 --- django_logging/apps.py | 8 +++- django_logging/handlers/__init__.py | 1 + django_logging/handlers/email_handler.py | 47 ++++++++++++++++++++++++ django_logging/settings/conf.py | 18 +++++++++ django_logging/utils/email/__init__.py | 1 + django_logging/utils/email/notifier.py | 36 ++++++++++++++++++ django_logging/utils/log_and_notify.py | 42 +++++++++++++++++++++ django_logging/utils/setup_conf.py | 7 +++- 8 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 django_logging/handlers/__init__.py create mode 100644 django_logging/handlers/email_handler.py create mode 100644 django_logging/utils/email/__init__.py create mode 100644 django_logging/utils/email/notifier.py create mode 100644 django_logging/utils/log_and_notify.py diff --git a/django_logging/apps.py b/django_logging/apps.py index 6634da2..9970470 100644 --- a/django_logging/apps.py +++ b/django_logging/apps.py @@ -18,6 +18,12 @@ def ready(self): "LOG_FILE_LEVELS", ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] ) log_dir = log_settings.get("LOG_DIR", os.path.join(os.getcwd(), "logs")) + log_email_notifier = log_settings.get("LOG_EMAIL_NOTIFIER", {}) + log_email_notifier_enable = log_email_notifier.get("ENABLE", False) + log_email_notifier_log_levels = [ + "ERROR" if log_email_notifier.get("NOTIFY_ERROR", False) else None, + "CRITICAL" if log_email_notifier.get("NOTIFY_CRITICAL", False) else None, + ] # Set the logging configuration - set_logging(log_levels, log_dir) + set_logging(log_levels, log_dir, log_email_notifier_enable, log_email_notifier_log_levels) diff --git a/django_logging/handlers/__init__.py b/django_logging/handlers/__init__.py new file mode 100644 index 0000000..dd5e3a9 --- /dev/null +++ b/django_logging/handlers/__init__.py @@ -0,0 +1 @@ +from .email_handler import EmailHandler diff --git a/django_logging/handlers/email_handler.py b/django_logging/handlers/email_handler.py new file mode 100644 index 0000000..25399fa --- /dev/null +++ b/django_logging/handlers/email_handler.py @@ -0,0 +1,47 @@ +import logging + +from django.conf import settings +from django.template import engines +from django.utils.timezone import now +from django_logging.utils.email.notifier import send_email_async +from django_logging.middleware import RequestLogMiddleware + + +class EmailHandler(logging.Handler): + def __init__(self, include_html=False, *args, **kwargs): + super().__init__(*args, **kwargs) + self.include_html = include_html + + def emit(self, record): + try: + request = getattr(record, "request", None) + log_entry = self.format(record) + + if self.include_html: + email_body = self.render_template(log_entry, request) + else: + email_body = log_entry # Use plain text format if HTML is not included + + subject = f"New Log Record: {record.levelname}" + send_email_async(subject, email_body, [settings.ADMIN_EMAIL]) + + except Exception as e: + self.handleError(record) + + @staticmethod + def render_template(log_entry, request=None, template_path="email_notifier_template.html"): + django_engine = engines["django"] + template = django_engine.get_template(template_path) + + # Fetch IP address and user agent using middleware methods + ip_address = RequestLogMiddleware.get_ip_address(request) if request else "Unknown" + user_agent = RequestLogMiddleware.get_user_agent(request) if request else "Unknown" + + context = { + "message": log_entry, + "time": now(), + "browser_type": user_agent, + "ip_address": ip_address, + } + + return template.render(context) diff --git a/django_logging/settings/conf.py b/django_logging/settings/conf.py index 60ff671..f2eaa97 100644 --- a/django_logging/settings/conf.py +++ b/django_logging/settings/conf.py @@ -19,9 +19,14 @@ def __init__( self, log_levels: List[str], log_dir: str, + log_email_notifier_enable: bool, + log_email_notifier_log_levels: List[str], ) -> None: + self.log_levels = log_levels self.log_dir = log_dir + self.email_notifier_enable = log_email_notifier_enable + self.email_notifier_log_levels = log_email_notifier_log_levels class LogManager: @@ -79,6 +84,19 @@ def set_conf(self) -> None: "level": "DEBUG", } + email_handler = { + f"email_{level.lower()}": { + "class": "django_logging.handlers.EmailHandler", + "level": level, + "filters": [level.lower()], + } + for level in self.log_config.email_notifier_log_levels + if level + } + + if self.log_config.email_notifier_enable: + handlers.update(email_handler) + filters = { level.lower(): { "()": LoggingLevelFilter, diff --git a/django_logging/utils/email/__init__.py b/django_logging/utils/email/__init__.py new file mode 100644 index 0000000..3e9b62e --- /dev/null +++ b/django_logging/utils/email/__init__.py @@ -0,0 +1 @@ +from .notifier import send_email_async diff --git a/django_logging/utils/email/notifier.py b/django_logging/utils/email/notifier.py new file mode 100644 index 0000000..9fb9eed --- /dev/null +++ b/django_logging/utils/email/notifier.py @@ -0,0 +1,36 @@ +import logging +import threading +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +from django.conf import settings + +logger = logging.getLogger(__name__) + + +def send_email_async(subject, body, recipient_list): + def send_email(): + msg = MIMEMultipart() + msg["From"] = settings.DEFAULT_FROM_EMAIL + msg["To"] = ", ".join(recipient_list) + msg["Subject"] = subject + + msg.attach(MIMEText(body, "html")) + + try: + server = smtplib.SMTP(settings.EMAIL_HOST, settings.EMAIL_PORT) + server.starttls() + server.login(settings.EMAIL_HOST_USER, settings.EMAIL_HOST_PASSWORD) + server.sendmail( + settings.DEFAULT_FROM_EMAIL, recipient_list, msg.as_string() + ) + server.quit() + logger.info(f"Log Record has been sent to ADMIN EMAIL successfully.") + + except Exception as e: + logger.warning(f"Email Notifier failed to send Log Record: {e}") + + # Start a new thread to send the email asynchronously + email_thread = threading.Thread(target=send_email) + email_thread.start() diff --git a/django_logging/utils/log_and_notify.py b/django_logging/utils/log_and_notify.py new file mode 100644 index 0000000..c432a81 --- /dev/null +++ b/django_logging/utils/log_and_notify.py @@ -0,0 +1,42 @@ +import logging +import inspect +from typing import Optional, Dict + +from django.conf import settings + +from django_logging.utils.email import send_email_async +from django_logging.handlers import EmailHandler + + +def log_and_notify(logger, level: int, message: str, extra: Optional[Dict] = None): + # Get the caller's frame to capture the correct module, file, and line number + frame = inspect.currentframe().f_back + + try: + # create a LogRecord + log_record = logger.makeRecord( + name=logger.name, + level=level, + fn=frame.f_code.co_filename, + lno=frame.f_lineno, + msg=message, + args=None, + exc_info=None, + func=frame.f_code.co_name, + extra=extra, + ) + + # Pass the LogRecord to the logger's handlers + logger.handle(log_record) + except TypeError as e: + raise ValueError( + f"Failed to log message due to invalid param. Original error: {e}" + ) + + request = extra.get("request") if extra else None + + # Render the email template with the formatted message + email_body = EmailHandler.render_template(log_record, request) + + subject = f"New Log Record: {logging.getLevelName(level)}" + send_email_async(subject, email_body, [settings.ADMIN_EMAIL]) diff --git a/django_logging/utils/setup_conf.py b/django_logging/utils/setup_conf.py index c377afa..2ab38cf 100644 --- a/django_logging/utils/setup_conf.py +++ b/django_logging/utils/setup_conf.py @@ -3,7 +3,10 @@ from typing import List -def set_logging(log_levels: List[str], log_dir: str): +def set_logging(log_levels: List[str], + log_dir: str, + log_email_notifier_enable: bool, + log_email_notifier_log_levels: List[str]): """ Sets up the logging configuration. @@ -11,7 +14,7 @@ def set_logging(log_levels: List[str], log_dir: str): log_levels (List[str]): A list of log levels to configure. log_dir (str): The directory where log files will be stored. """ - log_config = LogConfig(log_levels, log_dir) + log_config = LogConfig(log_levels, log_dir, log_email_notifier_enable, log_email_notifier_log_levels) log_manager = LogManager(log_config) log_manager.create_log_files() log_manager.set_conf()