diff --git a/django_logging/apps.py b/django_logging/apps.py index 6634da2..2085c00 100644 --- a/django_logging/apps.py +++ b/django_logging/apps.py @@ -18,6 +18,29 @@ 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") + 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) + 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) + set_logging( + log_levels, + log_dir, + log_file_formats, + console_level, + console_format, + colorize_console, + 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/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/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/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/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..53ea22a --- /dev/null +++ b/django_logging/handlers/email_handler.py @@ -0,0 +1,60 @@ +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 + + 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) + log_entry = self.format(record) + + if self.include_html: + email_body = self.render_template(log_entry, request) + else: + email_body = log_entry + + 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/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) diff --git a/django_logging/settings/conf.py b/django_logging/settings/conf.py index 60ff671..e741c05 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,9 +20,75 @@ 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]], + colorize_console: bool, + 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.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: + 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] + + colored_format = resolved_formats[level] + resolved_formats[level] = self.remove_ansi_escape_sequences(colored_format) + + return resolved_formats + + @staticmethod + 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) -> 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] + + # 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 class LogManager: @@ -67,7 +134,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()] } @@ -76,8 +143,20 @@ 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) filters = { level.lower(): { @@ -87,6 +166,27 @@ 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, + } + if self.log_config.colorize_console: + formatters["console"].update( + {"()": "django_logging.formatters.ColorizedFormatter"} + ) + + formatters["email"] = { + "format": self.log_config.email_notifier_log_format, + "datefmt": self.log_config.log_date_format, + } + loggers = { level.lower(): { "level": level, @@ -98,6 +198,7 @@ def set_conf(self) -> None: config = { "version": 1, + "formatters": formatters, "handlers": handlers, "filters": filters, "loggers": loggers, 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.

+
+
+ + 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/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..d336f1c 100644 --- a/django_logging/utils/setup_conf.py +++ b/django_logging/utils/setup_conf.py @@ -1,9 +1,20 @@ 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): +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]], + colorize_console: bool, + 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. @@ -11,7 +22,18 @@ 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_file_formats, + console_level, + console_format, + colorize_console, + 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()