From 897272a689095d6895b442a2ef6e25bad10eb958 Mon Sep 17 00:00:00 2001 From: MEHRSHAD MIRSHEKARY Date: Sun, 18 Aug 2024 21:12:54 +0330 Subject: [PATCH 1/7] :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() From 598e3cfa22cda8c7f75085b08ad34b8b8c839e17 Mon Sep 17 00:00:00 2001 From: MEHRSHAD MIRSHEKARY Date: Sun, 18 Aug 2024 21:35:15 +0330 Subject: [PATCH 2/7] :zap: Update(handlers) email_handler to get 'USE_TEMPLATE' option with this option users can choose to have template email or not --- django_logging/handlers/email_handler.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/django_logging/handlers/email_handler.py b/django_logging/handlers/email_handler.py index 25399fa..53ea22a 100644 --- a/django_logging/handlers/email_handler.py +++ b/django_logging/handlers/email_handler.py @@ -10,8 +10,21 @@ class EmailHandler(logging.Handler): def __init__(self, include_html=False, *args, **kwargs): super().__init__(*args, **kwargs) + self.include_html = include_html + try: + # Attempt to retrieve the logging settings from the Django settings + logging_settings = settings.DJANGO_LOGGING + + self.include_html = logging_settings.get("LOG_EMAIL_NOTIFIER", {}).get("USE_TEMPLATE", include_html) + + except AttributeError: + logging.warning( + f"DJANGO_LOGGING settings not found. Using default include_html value: {self.include_html}.") + except Exception as e: + logging.error(f"An unexpected error occurred while initializing EmailHandler: {e}") + def emit(self, record): try: request = getattr(record, "request", None) @@ -20,7 +33,7 @@ def emit(self, 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 + email_body = log_entry subject = f"New Log Record: {record.levelname}" send_email_async(subject, email_body, [settings.ADMIN_EMAIL]) From 4edf3ce68701fdc1f500f26a664ff1298fbcce04 Mon Sep 17 00:00:00 2001 From: MEHRSHAD MIRSHEKARY Date: Sun, 18 Aug 2024 21:39:43 +0330 Subject: [PATCH 3/7] :sparkles: feat: add email_notifier_template implemented a compatible html template with css inlines --- .../templates/email_notifier_template.html | 422 ++++++++++++++++++ 1 file changed, 422 insertions(+) create mode 100644 django_logging/templates/email_notifier_template.html diff --git a/django_logging/templates/email_notifier_template.html b/django_logging/templates/email_notifier_template.html new file mode 100644 index 0000000..5f801ba --- /dev/null +++ b/django_logging/templates/email_notifier_template.html @@ -0,0 +1,422 @@ + + + + + + Log Record Notification + + + + + + +
+ + + + + + + + + + + +
+
+
+ Django Logging +
+
+

Log Message:
{{ message }}

+ {% if ip_address != "Unknown" %} +

IP Address: {{ ip_address }}

+ {% endif %} + {% if browser_type != "Unknown" %} +

Browser Type: {{ browser_type }}

+ {% endif %} +

This email was prepared to send at {{ time }}

+
+ Character +
+

This is an automated message.

Please do not reply.

+
+
+ + From 64b60d88e15b190b0e026de8c20755a51ec16f18 Mon Sep 17 00:00:00 2001 From: MEHRSHAD MIRSHEKARY Date: Sun, 18 Aug 2024 22:43:39 +0330 Subject: [PATCH 4/7] :sparkles::bulb: feat(commands): add send_logs Command to send logs dir Implemented a Django Management Command to Send Logs Directory via Email: Dynamic Log Directory Path: gets log directory path dynamically from the DJANGO_LOGGING settings, allowing for user-defined paths. Zip Log Directory: Added functionality to compress the log directory into a zip file. Email with Attachment: Created and sent an email to a specified address with the zipped log files as an attachment. Error Handling: Included error handling for cases where the log directory does not exist or email sending fails. Temporary File Management: Ensured temporary files are cleaned up after use. Email Settings Validation: Added a check to ensure all required email settings are present in the Django settings. Closes #10 --- django_logging/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/send_logs.py | 107 ++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 django_logging/management/__init__.py create mode 100644 django_logging/management/commands/__init__.py create mode 100644 django_logging/management/commands/send_logs.py diff --git a/django_logging/management/__init__.py b/django_logging/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_logging/management/commands/__init__.py b/django_logging/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_logging/management/commands/send_logs.py b/django_logging/management/commands/send_logs.py new file mode 100644 index 0000000..e7fd085 --- /dev/null +++ b/django_logging/management/commands/send_logs.py @@ -0,0 +1,107 @@ +import os +import shutil +import tempfile +import logging +from django.core.mail import EmailMessage +from django.core.management.base import BaseCommand +from django.conf import settings + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + A Django management command that zips the log directory and sends it to + the specified email address. + + This command is used to send the log files to a specified email address. + It zips the log directory, creates an email with the zipped file as an attachment, + and sends it to the specified email address. + """ + + help = "Send log folder to the specified email address" + + def add_arguments(self, parser): + """ + Add custom command arguments. + + Parameters: + parser (ArgumentParser): The argument parser to add arguments to. + """ + parser.add_argument( + "email", type=str, help="The email address to send the logs to" + ) + + def handle(self, *args, **kwargs): + """ + The main entry point for the command. + + Parameters: + args (tuple): Positional arguments. + kwargs (dict): Keyword arguments. + """ + email = kwargs["email"] + + log_dir = settings.DJANGO_LOGGING.get("LOG_DIR", os.path.join(os.getcwd(), "logs")) + + if not os.path.exists(log_dir): + self.stdout.write( + self.style.ERROR(f'Log directory "{log_dir}" does not exist.') + ) + logger.error(f'Log directory "{log_dir}" does not exist.') + return + + self.check_email_settings() + + # Create a temporary file to store the zipped logs + with tempfile.NamedTemporaryFile(delete=False) as tmp_file: + zip_path = f"{tmp_file.name}.zip" + tmp_file.close() + + # Zip the log directory + shutil.make_archive(tmp_file.name, "zip", log_dir) + + # Send the email with the zipped logs + email_subject = "Log Files" + email_body = "Please find the attached log files." + email_message = EmailMessage( + subject=email_subject, + body=email_body, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[email], + ) + email_message.attach_file(zip_path) + + try: + email_message.send() + self.stdout.write(self.style.SUCCESS(f"Logs sent successfully to {email}.")) + logger.info(f"Logs sent successfully to {email}.") + except Exception as e: + self.stdout.write(self.style.ERROR(f"Failed to send logs: {e}")) + logger.error(f"Failed to send logs: {e}") + finally: + # Clean up the temporary file + os.remove(zip_path) + logger.info("Temporary zip file cleaned up successfully.") + + def check_email_settings(self): + """ + Check if all required email settings are present in the settings file. + + Raises an exception if any of the required email settings are missing. + """ + required_settings = [ + "EMAIL_HOST", + "EMAIL_PORT", + "EMAIL_HOST_USER", + "EMAIL_HOST_PASSWORD", + "EMAIL_USE_TLS", + "DEFAULT_FROM_EMAIL", + ] + + for setting in required_settings: + if not getattr(settings, setting, None): + error_message = f"Missing required email setting: {setting}" + self.stdout.write(self.style.ERROR(error_message)) + logger.error(f"Missing required email setting: {setting}") + raise Exception(error_message) From d773ea3a60cb1b10e97c83b51e3856fc5caeb79d Mon Sep 17 00:00:00 2001 From: MEHRSHAD MIRSHEKARY Date: Sun, 18 Aug 2024 23:24:17 +0330 Subject: [PATCH 5/7] :zap::sparkles: feat: add constant format_options & Update conf settings Added constants/format_options: Format Options Constants: Added a set of predefined format options for log file formats, console logs, and email notifications. Users can now choose from these predefined formats to simplify configuration and ensure consistency. Updated LogConfig Class: New Attributes: log_file_formats: Customizable formats for log files based on log levels. console_level: Log level for console output. console_format: Format for console logs. log_date_format: Date format for log entries. log_email_notifier_log_format: Format for email notifications. New Methods: _resolve_file_formats: Resolves and applies default formats if not specified for each log level. resolve_format: Resolves format based on either an integer index or a string format. Updated LogManager Class: Formatters: Applied custom formats for file, console, and email notifications. Filters: Added LoggingLevelFilter to ensure log records are filtered based on their levels. Loggers: Configured loggers for each log level with their respective handlers and formatters. Closes #12 --- django_logging/apps.py | 17 +++++- django_logging/constants/__init__.py | 1 + django_logging/constants/format_options.py | 15 +++++ django_logging/settings/conf.py | 68 ++++++++++++++++++++-- django_logging/utils/setup_conf.py | 29 +++++++-- 5 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 django_logging/constants/__init__.py create mode 100644 django_logging/constants/format_options.py diff --git a/django_logging/apps.py b/django_logging/apps.py index 9970470..f3a3b14 100644 --- a/django_logging/apps.py +++ b/django_logging/apps.py @@ -18,12 +18,27 @@ 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_file_formats = log_settings.get("LOG_FILE_FORMATS", {}) + console_level = log_settings.get("LOG_CONSOLE_LEVEL", "DEBUG") + console_format = log_settings.get("LOG_CONSOLE_FORMAT") + log_date_format = log_settings.get("LOG_DATE_FORMAT", "%Y-%m-%d %H:%M:%S") 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, ] + log_email_notifier_log_format = log_email_notifier.get("LOG_FORMAT") # Set the logging configuration - set_logging(log_levels, log_dir, log_email_notifier_enable, log_email_notifier_log_levels) + set_logging( + log_levels, + log_dir, + log_file_formats, + console_level, + console_format, + log_date_format, + log_email_notifier_enable, + log_email_notifier_log_levels, + log_email_notifier_log_format + ) diff --git a/django_logging/constants/__init__.py b/django_logging/constants/__init__.py new file mode 100644 index 0000000..d9bac99 --- /dev/null +++ b/django_logging/constants/__init__.py @@ -0,0 +1 @@ +from .format_options import FORMAT_OPTIONS diff --git a/django_logging/constants/format_options.py b/django_logging/constants/format_options.py new file mode 100644 index 0000000..11a8fdb --- /dev/null +++ b/django_logging/constants/format_options.py @@ -0,0 +1,15 @@ +FORMAT_OPTIONS = { + 1: "%(levelname)s | %(asctime)s | %(module)s | %(message)s", + 2: "%(levelname)s | %(asctime)s | %(message)s", + 3: "%(levelname)s | %(message)s", + 4: "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + 5: "%(levelname)s | %(message)s | [in %(pathname)s:%(lineno)d]", + 6: "%(asctime)s | %(levelname)s | %(message)s", + 7: "%(levelname)s | %(asctime)s | in %(module)s: %(message)s", + 8: "%(levelname)s | %(message)s | [%(filename)s:%(lineno)d]", + 9: "[%(asctime)s] | %(levelname)s | in %(module)s: %(message)s", + 10: "%(asctime)s | %(processName)s | %(name)s | %(levelname)s | %(message)s", + 11: "%(asctime)s | %(threadName)s | %(name)s | %(levelname)s | %(message)s", + 12: "%(levelname)s | [%(asctime)s] | (%(filename)s:%(lineno)d) | %(message)s", + 13: "%(levelname)s | [%(asctime)s] | {%(name)s} | (%(filename)s:%(lineno)d): %(message)s", +} diff --git a/django_logging/settings/conf.py b/django_logging/settings/conf.py index f2eaa97..85a1d9c 100644 --- a/django_logging/settings/conf.py +++ b/django_logging/settings/conf.py @@ -1,8 +1,9 @@ import logging import logging.config import os -from typing import List, Dict, Optional +from typing import List, Dict, Optional, Union +from django_logging.constants import FORMAT_OPTIONS from django_logging.filters.level_filter import LoggingLevelFilter @@ -19,14 +20,54 @@ def __init__( self, log_levels: List[str], log_dir: str, + log_file_formats: Dict[str, Union[int, str]], + console_level: str, + console_format: Optional[Union[int, str]], + log_date_format: str, log_email_notifier_enable: bool, log_email_notifier_log_levels: List[str], + log_email_notifier_log_format: Union[int, str], ) -> None: self.log_levels = log_levels self.log_dir = log_dir + self.log_file_formats = self._resolve_file_formats(log_file_formats) + self.log_date_format = log_date_format + self.console_level = console_level + self.console_format = self.resolve_format(console_format) self.email_notifier_enable = log_email_notifier_enable self.email_notifier_log_levels = log_email_notifier_log_levels + self.email_notifier_log_format = self.resolve_format( + log_email_notifier_log_format + ) + + def _resolve_file_formats(self, log_file_formats: Dict[str, Union[int, str]]) -> Dict: + resolved_formats = {} + for level in self.log_levels: + format_option = log_file_formats.get(level, None) + if format_option: + if isinstance(format_option, int): + resolved_formats[level] = FORMAT_OPTIONS.get( + format_option, FORMAT_OPTIONS[1] + ) + else: + resolved_formats[level] = format_option + else: + resolved_formats[level] = FORMAT_OPTIONS[1] + + return resolved_formats + + @staticmethod + def resolve_format(_format: Union[int, str]) -> str: + if _format: + if isinstance(_format, int): + resolved_format = FORMAT_OPTIONS.get(_format, FORMAT_OPTIONS[1]) + else: + resolved_format = _format + else: + resolved_format = FORMAT_OPTIONS[1] + + return resolved_format class LogManager: @@ -72,7 +113,7 @@ def set_conf(self) -> None: level.lower(): { "class": "logging.FileHandler", "filename": log_file, - "formatter": "default", + "formatter": f"{level.lower()}", "level": level, "filters": [level.lower()] } @@ -81,19 +122,18 @@ def set_conf(self) -> None: handlers["console"] = { "class": "logging.StreamHandler", "formatter": "console", - "level": "DEBUG", + "level": self.log_config.console_level, } - email_handler = { f"email_{level.lower()}": { "class": "django_logging.handlers.EmailHandler", + "formatter": "email", "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) @@ -105,6 +145,23 @@ def set_conf(self) -> None: for level in self.log_config.log_levels } + formatters = { + level.lower(): { + "format": self.log_config.log_file_formats[level], + "datefmt": self.log_config.log_date_format, + } + for level in self.log_config.log_levels + } + formatters["console"] = { + "format": self.log_config.console_format, + "datefmt": self.log_config.log_date_format, + } + + formatters["email"] = { + "format": self.log_config.email_notifier_log_format, + "datefmt": self.log_config.log_date_format, + } + loggers = { level.lower(): { "level": level, @@ -116,6 +173,7 @@ def set_conf(self) -> None: config = { "version": 1, + "formatters": formatters, "handlers": handlers, "filters": filters, "loggers": loggers, diff --git a/django_logging/utils/setup_conf.py b/django_logging/utils/setup_conf.py index 2ab38cf..d81a0be 100644 --- a/django_logging/utils/setup_conf.py +++ b/django_logging/utils/setup_conf.py @@ -1,12 +1,19 @@ from django_logging.settings.conf import LogConfig, LogManager -from typing import List +from typing import List, Optional, Union, Dict -def set_logging(log_levels: List[str], - log_dir: str, - log_email_notifier_enable: bool, - log_email_notifier_log_levels: List[str]): +def set_logging( + log_levels: List[str], + log_dir: str, + log_file_formats: Dict[str, Union[int, str]], + console_level: str, + console_format: Optional[Union[int, str]], + log_date_format: str, + log_email_notifier_enable: bool, + log_email_notifier_log_levels: List[str], + log_email_notifier_log_format: Union[int, str] +): """ Sets up the logging configuration. @@ -14,7 +21,17 @@ def set_logging(log_levels: List[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_email_notifier_enable, log_email_notifier_log_levels) + log_config = LogConfig( + log_levels, + log_dir, + log_file_formats, + console_level, + console_format, + log_date_format, + log_email_notifier_enable, + log_email_notifier_log_levels, + log_email_notifier_log_format + ) log_manager = LogManager(log_config) log_manager.create_log_files() log_manager.set_conf() From 3fcda2a686987a4fe3e325e30e6f7126a5476911 Mon Sep 17 00:00:00 2001 From: MEHRSHAD MIRSHEKARY Date: Mon, 19 Aug 2024 00:09:52 +0330 Subject: [PATCH 6/7] :sparkles::zap::bulb::art: feat: add colorized logs at console Added ANSI Color Constants: Created AnsiColors class in constants/ansi_colors to define a comprehensive set of ANSI color codes for log formatting. Mapped log levels to appropriate colors using the LOG_LEVEL_COLORS dictionary. Colorization Utility: Implemented colorize_log_format in utils/colorizer, which applies color codes to log format placeholders based on log levels. Integrated colorization logic for various log format specifiers, including timestamps, log levels, filenames, and more. Colorized Formatter: Added ColorizedFormatter class in formatters/colorized_formatter, extending logging.Formatter. Applied colorization to log records dynamically while preserving the original log format. Ensured that ANSI escape sequences are removed from formats if colorization is not required. Configuration Updates: Updated the logging configuration in the LogConfig class to apply the ColorizedFormatter to the console handler when colorize_console is enabled. Implemented logic to remove ANSI escape sequences from log file formats, ensuring clean logs without color codes in files. Extended the resolve_format method to optionally enable or disable colorization based on configuration. Closes #14 --- django_logging/apps.py | 2 + django_logging/constants/ansi_colors.py | 38 +++++++++++++++++++ django_logging/formatters/__init__.py | 1 + .../formatters/colorized_formatter.py | 20 ++++++++++ django_logging/settings/conf.py | 31 +++++++++++++-- django_logging/utils/colorizer.py | 27 +++++++++++++ django_logging/utils/setup_conf.py | 2 + 7 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 django_logging/constants/ansi_colors.py create mode 100644 django_logging/formatters/__init__.py create mode 100644 django_logging/formatters/colorized_formatter.py create mode 100644 django_logging/utils/colorizer.py diff --git a/django_logging/apps.py b/django_logging/apps.py index f3a3b14..2085c00 100644 --- a/django_logging/apps.py +++ b/django_logging/apps.py @@ -21,6 +21,7 @@ def ready(self): log_file_formats = log_settings.get("LOG_FILE_FORMATS", {}) console_level = log_settings.get("LOG_CONSOLE_LEVEL", "DEBUG") console_format = log_settings.get("LOG_CONSOLE_FORMAT") + colorize_console = log_settings.get("LOG_CONSOLE_COLORIZE", True) log_date_format = log_settings.get("LOG_DATE_FORMAT", "%Y-%m-%d %H:%M:%S") log_email_notifier = log_settings.get("LOG_EMAIL_NOTIFIER", {}) log_email_notifier_enable = log_email_notifier.get("ENABLE", False) @@ -37,6 +38,7 @@ def ready(self): log_file_formats, console_level, console_format, + colorize_console, log_date_format, log_email_notifier_enable, log_email_notifier_log_levels, diff --git a/django_logging/constants/ansi_colors.py b/django_logging/constants/ansi_colors.py new file mode 100644 index 0000000..8ec502d --- /dev/null +++ b/django_logging/constants/ansi_colors.py @@ -0,0 +1,38 @@ +class AnsiColors: + BLACK = "\033[0;30m" + RED = "\033[0;31m" + RED_BACKGROUND = "\033[1;41m" + GREEN = "\033[0;32m" + YELLOW = "\033[0;33m" + BLUE = "\033[0;34m" + MAGENTA = "\033[0;35m" + CYAN = "\033[0;36m" + GRAY = "\033[0;37m" + WHITE = "\033[0;38m" + RESET = "\033[0m" + BRIGHT_BLACK = "\033[0;90m" + BRIGHT_RED = "\033[0;91m" + BRIGHT_GREEN = "\033[0;92m" + BRIGHT_YELLOW = "\033[0;93m" + BRIGHT_BLUE = "\033[0;94m" + BRIGHT_MAGENTA = "\033[0;95m" + BRIGHT_CYAN = "\033[0;96m" + BRIGHT_WHITE = "\033[0;97m" + BLACK_BACKGROUND = "\033[40m" + RED_BACKGROUND = "\033[41m" + GREEN_BACKGROUND = "\033[42m" + YELLOW_BACKGROUND = "\033[43m" + BLUE_BACKGROUND = "\033[44m" + MAGENTA_BACKGROUND = "\033[45m" + CYAN_BACKGROUND = "\033[46m" + WHITE_BACKGROUND = "\033[47m" + + +# Mapping log levels to ANSI colors +LOG_LEVEL_COLORS = { + "DEBUG": AnsiColors.BLUE, + "INFO": AnsiColors.GREEN, + "WARNING": AnsiColors.YELLOW, + "ERROR": AnsiColors.RED, + "CRITICAL": AnsiColors.RED_BACKGROUND, +} diff --git a/django_logging/formatters/__init__.py b/django_logging/formatters/__init__.py new file mode 100644 index 0000000..04cdd33 --- /dev/null +++ b/django_logging/formatters/__init__.py @@ -0,0 +1 @@ +from .colorized_formatter import ColorizedFormatter diff --git a/django_logging/formatters/colorized_formatter.py b/django_logging/formatters/colorized_formatter.py new file mode 100644 index 0000000..9ab7390 --- /dev/null +++ b/django_logging/formatters/colorized_formatter.py @@ -0,0 +1,20 @@ +import logging +from django_logging.settings.conf import LogConfig +from django_logging.utils.colorizer import colorize_log_format + + +class ColorizedFormatter(logging.Formatter): + def format(self, record): + original_format = self._style._fmt + + # checks that the format does not have any color it's self + if LogConfig.remove_ansi_escape_sequences(original_format) == original_format: + colorized_format = colorize_log_format(original_format, record.levelname) + self._style._fmt = colorized_format + + formatted_output = super().format(record) + + # Reset to the original format string + self._style._fmt = original_format + + return formatted_output diff --git a/django_logging/settings/conf.py b/django_logging/settings/conf.py index 85a1d9c..811f7b0 100644 --- a/django_logging/settings/conf.py +++ b/django_logging/settings/conf.py @@ -23,6 +23,7 @@ def __init__( log_file_formats: Dict[str, Union[int, str]], console_level: str, console_format: Optional[Union[int, str]], + colorize_console: bool, log_date_format: str, log_email_notifier_enable: bool, log_email_notifier_log_levels: List[str], @@ -34,14 +35,17 @@ def __init__( self.log_file_formats = self._resolve_file_formats(log_file_formats) self.log_date_format = log_date_format self.console_level = console_level - self.console_format = self.resolve_format(console_format) + self.colorize_console = colorize_console + self.console_format = self.resolve_format( + console_format, use_colors=self.colorize_console + ) self.email_notifier_enable = log_email_notifier_enable self.email_notifier_log_levels = log_email_notifier_log_levels self.email_notifier_log_format = self.resolve_format( log_email_notifier_log_format ) - def _resolve_file_formats(self, log_file_formats: Dict[str, Union[int, str]]) -> Dict: + def _resolve_file_formats(self, log_file_formats): resolved_formats = {} for level in self.log_levels: format_option = log_file_formats.get(level, None) @@ -55,10 +59,23 @@ def _resolve_file_formats(self, log_file_formats: Dict[str, Union[int, str]]) -> else: resolved_formats[level] = FORMAT_OPTIONS[1] + colored_format = resolved_formats[level] + resolved_formats[level] = self.remove_ansi_escape_sequences(colored_format) + return resolved_formats @staticmethod - def resolve_format(_format: Union[int, str]) -> str: + def remove_ansi_escape_sequences(log_message: str) -> str: + """ + Remove ANSI escape sequences from log messages. + """ + import re + + ansi_escape = re.compile(r"(?:\x1B[@-_][0-?]*[ -/]*[@-~])") + return ansi_escape.sub("", log_message) + + @staticmethod + def resolve_format(_format: Union[int, str], use_colors: bool = False): if _format: if isinstance(_format, int): resolved_format = FORMAT_OPTIONS.get(_format, FORMAT_OPTIONS[1]) @@ -67,6 +84,10 @@ def resolve_format(_format: Union[int, str]) -> str: else: resolved_format = FORMAT_OPTIONS[1] + # If colors are not enabled, strip out color codes, if provided in formats + if not use_colors: + resolved_format = LogConfig.remove_ansi_escape_sequences(resolved_format) + return resolved_format @@ -156,6 +177,10 @@ def set_conf(self) -> None: "format": self.log_config.console_format, "datefmt": self.log_config.log_date_format, } + if self.log_config.colorize_console: + formatters["console"].update( + {"()": "django_logging.formatters.ColorizedFormatter"} + ) formatters["email"] = { "format": self.log_config.email_notifier_log_format, diff --git a/django_logging/utils/colorizer.py b/django_logging/utils/colorizer.py new file mode 100644 index 0000000..2acc27f --- /dev/null +++ b/django_logging/utils/colorizer.py @@ -0,0 +1,27 @@ +from django_logging.constants.ansi_colors import LOG_LEVEL_COLORS, AnsiColors + + +def colorize_log_format(log_format, levelname): + color_mapping = { + "%(asctime)s": f"{AnsiColors.CYAN}%(asctime)s{AnsiColors.RESET}", + "%(created)f": f"{AnsiColors.BRIGHT_BLUE}%(created)f{AnsiColors.RESET}", + "%(relativeCreated)d": f"{AnsiColors.MAGENTA}%(relativeCreated)d{AnsiColors.RESET}", + "%(msecs)d": f"{AnsiColors.YELLOW}%(msecs)d{AnsiColors.RESET}", + "%(levelname)s": f"{LOG_LEVEL_COLORS.get(levelname, '')}%(levelname)s{AnsiColors.RESET}", + "%(levelno)d": f"{AnsiColors.RED}%(levelno)d{AnsiColors.RESET}", + "%(name)s": f"{AnsiColors.BRIGHT_MAGENTA}%(name)s{AnsiColors.RESET}", + "%(module)s": f"{AnsiColors.BRIGHT_GREEN}%(module)s{AnsiColors.RESET}", + "%(filename)s": f"{AnsiColors.YELLOW}%(filename)s{AnsiColors.RESET}", + "%(pathname)s": f"{AnsiColors.CYAN}%(pathname)s{AnsiColors.RESET}", + "%(lineno)d": f"{AnsiColors.RED}%(lineno)d{AnsiColors.RESET}", + "%(funcName)s": f"{AnsiColors.BRIGHT_BLUE}%(funcName)s{AnsiColors.RESET}", + "%(process)d": f"{AnsiColors.MAGENTA}%(process)d{AnsiColors.RESET}", + "%(thread)d": f"{AnsiColors.CYAN}%(thread)d{AnsiColors.RESET}", + "%(threadName)s": f"{AnsiColors.BRIGHT_MAGENTA}%(threadName)s{AnsiColors.RESET}", + "%(message)s": f"{AnsiColors.GRAY}%(message)s{AnsiColors.RESET}", + } + + for placeholder, colorized in color_mapping.items(): + log_format = log_format.replace(placeholder, colorized) + + return log_format diff --git a/django_logging/utils/setup_conf.py b/django_logging/utils/setup_conf.py index d81a0be..d336f1c 100644 --- a/django_logging/utils/setup_conf.py +++ b/django_logging/utils/setup_conf.py @@ -9,6 +9,7 @@ def set_logging( log_file_formats: Dict[str, Union[int, str]], console_level: str, console_format: Optional[Union[int, str]], + colorize_console: bool, log_date_format: str, log_email_notifier_enable: bool, log_email_notifier_log_levels: List[str], @@ -27,6 +28,7 @@ def set_logging( log_file_formats, console_level, console_format, + colorize_console, log_date_format, log_email_notifier_enable, log_email_notifier_log_levels, From a48af824656bcc778737fe6c4dce6452d5edf33c Mon Sep 17 00:00:00 2001 From: MEHRSHAD MIRSHEKARY Date: Mon, 19 Aug 2024 00:18:18 +0330 Subject: [PATCH 7/7] :zap: Update(settings) resolve_format method type annotations --- django_logging/settings/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/django_logging/settings/conf.py b/django_logging/settings/conf.py index 811f7b0..e741c05 100644 --- a/django_logging/settings/conf.py +++ b/django_logging/settings/conf.py @@ -45,7 +45,7 @@ def __init__( log_email_notifier_log_format ) - def _resolve_file_formats(self, log_file_formats): + def _resolve_file_formats(self, log_file_formats: Dict[str, Union[int, str]]) -> Dict: resolved_formats = {} for level in self.log_levels: format_option = log_file_formats.get(level, None) @@ -75,7 +75,7 @@ def remove_ansi_escape_sequences(log_message: str) -> str: return ansi_escape.sub("", log_message) @staticmethod - def resolve_format(_format: Union[int, str], use_colors: bool = False): + def resolve_format(_format: Union[int, str], use_colors: bool = False) -> str: if _format: if isinstance(_format, int): resolved_format = FORMAT_OPTIONS.get(_format, FORMAT_OPTIONS[1])