diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..73a6d18 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/packages" # Location of the requirements.txt file + schedule: + interval: "weekly" + # If you want to target the specific file + target-branch: "main" # Replace with your default branch if different + + - package-ecosystem: "pip" + directory: "/packages" # Location of the requirements-dev.txt file + schedule: + interval: "weekly" + # If you want to target the specific file + target-branch: "main" # Replace with your default branch if different 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/filters/__init__.py b/django_logging/filters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_logging/filters/level_filter.py b/django_logging/filters/level_filter.py new file mode 100644 index 0000000..b147322 --- /dev/null +++ b/django_logging/filters/level_filter.py @@ -0,0 +1,38 @@ +import logging + + +class LoggingLevelFilter(logging.Filter): + """ + Filters log records based on their logging level. + + This filter is used to prevent log records from being written to log files + intended for lower log levels. For example, if we have separate log + files for DEBUG, INFO, WARNING, and ERROR levels, this filter ensures that + a log record with level ERROR is only written to the ERROR log file, and not + to the DEBUG, INFO or WARNING log files. + """ + + def __init__(self, logging_level: int): + """ + Initializes a LoggingLevelFilter instance. + + Args: + logging_level: The logging level to filter on (e.g. logging.DEBUG, logging.INFO, etc.). + + Returns: + None + """ + super().__init__() + self.logging_level = logging_level + + def filter(self, record: logging.LogRecord) -> bool: + """ + Filters a log record based on its level. + + Args: + record: The log record to filter. + + Returns: + True if the log record's level matches the specified logging level, False otherwise. + """ + return record.levelno == self.logging_level 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/middleware/__init__.py b/django_logging/middleware/__init__.py new file mode 100644 index 0000000..53d0413 --- /dev/null +++ b/django_logging/middleware/__init__.py @@ -0,0 +1 @@ +from .request_middleware import RequestLogMiddleware diff --git a/django_logging/middleware/request_middleware.py b/django_logging/middleware/request_middleware.py new file mode 100644 index 0000000..b523f2f --- /dev/null +++ b/django_logging/middleware/request_middleware.py @@ -0,0 +1,84 @@ +import logging +from django.contrib.auth import get_user_model +from django.http import HttpResponse, HttpRequest +from typing import Callable + +logger = logging.getLogger(__name__) + + +class RequestLogMiddleware: + """ + Middleware to log information about each incoming request. + + This middleware logs the request path, the user making the request (if authenticated), + and the user's IP address. + """ + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: + """ + Initializes the RequestLogMiddleware instance. + + Args: + get_response: A callable that returns an HttpResponse object. + """ + self.get_response = get_response + user_model = get_user_model() + self.username_field = user_model.USERNAME_FIELD + + def __call__(self, request: HttpRequest) -> HttpResponse: + """ + Processes an incoming request and logs relevant information. + + Args: + request: The incoming request object. + + Returns: + The response object returned by the view function. + """ + # Before view (and later middleware) are called. + response = self.get_response(request) + + # After view is called. + if hasattr(request, "user") and request.user.is_authenticated: + user = getattr(request.user, self.username_field, "Anonymous") + else: + user = "Anonymous" + + # Get the user's IP address + ip_address = self.get_ip_address(request) + + # Get the user agent + user_agent = self.get_user_agent(request) + + # Attach IP and user agent to the request + request.ip_address = ip_address + request.browser_type = user_agent + + logger.info( + f"Request Info: (request_path: {request.path}, user: {user}," + f"\nIP: {ip_address}, user_agent: {user_agent})" + ) + + return response + + @staticmethod + def get_ip_address(request: HttpRequest) -> str: + """ + Retrieves the client's IP address from the request object. + """ + ip_address = request.META.get("HTTP_X_FORWARDED_FOR") + if ip_address: + ip_address = ip_address.split(",")[0] + else: + ip_address = request.META.get("LIMITED_ACCESS") + if not ip_address: + ip_address = request.META.get("REMOTE_ADDR") + + return ip_address + + @staticmethod + def get_user_agent(request: HttpRequest) -> str: + """ + Retrieves the client's user agent from the request object. + """ + return request.META.get("HTTP_USER_AGENT", "Unknown User Agent") diff --git a/django_logging/settings/conf.py b/django_logging/settings/conf.py index dba4aff..f2eaa97 100644 --- a/django_logging/settings/conf.py +++ b/django_logging/settings/conf.py @@ -3,6 +3,8 @@ import os from typing import List, Dict, Optional +from django_logging.filters.level_filter import LoggingLevelFilter + class LogConfig: """ @@ -17,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: @@ -67,6 +74,7 @@ def set_conf(self) -> None: "filename": log_file, "formatter": "default", "level": level, + "filters": [level.lower()] } for level, log_file in self.log_files.items() } @@ -76,6 +84,27 @@ 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, + "logging_level": getattr(logging, level), + } + for level in self.log_config.log_levels + } + loggers = { level.lower(): { "level": level, @@ -88,6 +117,7 @@ def set_conf(self) -> None: config = { "version": 1, "handlers": handlers, + "filters": filters, "loggers": loggers, "root": {"level": "DEBUG", "handlers": list(handlers.keys())}, "disable_existing_loggers": False, 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/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()