From c18d2ea1ddf51278a3dbb57cfff5b1013dbc4f15 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?P=C4=93teris=20Caune?= <cuu508@gmail.com>
Date: Wed, 1 Nov 2023 09:34:45 +0200
Subject: [PATCH] Update logging configuration to write logs to database

---
 CHANGELOG.md                       |  5 +++
 hc/logs/__init__.py                | 27 ++++++++++++++
 hc/logs/admin.py                   | 57 ++++++++++++++++++++++++++++++
 hc/logs/migrations/0001_initial.py | 45 +++++++++++++++++++++++
 hc/logs/migrations/__init__.py     |  0
 hc/logs/models.py                  | 23 ++++++++++++
 hc/logs/tests.py                   |  3 ++
 hc/settings.py                     | 21 ++++++-----
 static/css/admin/records.css       | 42 ++++++++++++++++++++++
 9 files changed, 212 insertions(+), 11 deletions(-)
 create mode 100644 hc/logs/__init__.py
 create mode 100644 hc/logs/admin.py
 create mode 100644 hc/logs/migrations/0001_initial.py
 create mode 100644 hc/logs/migrations/__init__.py
 create mode 100644 hc/logs/models.py
 create mode 100644 hc/logs/tests.py
 create mode 100644 static/css/admin/records.css

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f9d67431..42f69a1d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,11 @@
 # Changelog
 All notable changes to this project will be documented in this file.
 
+## v3.1-dev - Unreleased
+
+### Improvements
+- Update logging configuration to write logs to database (to table `logs_record`)
+
 ## v3.0.1 - 2023-10-30
 
 ### Bug Fixes
diff --git a/hc/logs/__init__.py b/hc/logs/__init__.py
new file mode 100644
index 00000000..c781acb6
--- /dev/null
+++ b/hc/logs/__init__.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+import logging
+
+from django.db import Error
+
+FORMATTER = logging.Formatter()
+
+
+class Handler(logging.Handler):
+    def emit(self, record: logging.LogRecord) -> None:
+        # Import Record now not earlier, to avoid AppRegistryNotReady exception
+        from hc.logs.models import Record
+
+        traceback = ""
+        if record.exc_info:
+            traceback = FORMATTER.formatException(record.exc_info)
+
+        try:
+            Record.objects.create(
+                name=record.name,
+                level=record.levelno,
+                message=record.getMessage(),
+                traceback=traceback,
+            )
+        except Error as e:
+            print(e)
diff --git a/hc/logs/admin.py b/hc/logs/admin.py
new file mode 100644
index 00000000..9c414905
--- /dev/null
+++ b/hc/logs/admin.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+from django.contrib import admin
+from django.contrib.admin import ModelAdmin
+from django.http import HttpRequest
+from django.utils.html import format_html
+
+from hc.logs.models import Record
+
+
+@admin.register(Record)
+class RecordsAdmin(ModelAdmin[Record]):
+    class Media:
+        css = {"all": ("css/admin/records.css",)}
+
+    search_fields = ["name", "message"]
+    readonly_fields = ("message",)
+    list_display = ("when", "logger", "message_traceback")
+    list_filter = (
+        "created",
+        "level",
+    )
+
+    def when(self, obj: Record) -> str:
+        return obj.created.strftime("%b %-d, %H:%M")
+
+    def logger(self, obj: Record) -> str:
+        level_name = obj.get_level_display()
+        level_letter = level_name[0].upper()
+        return format_html(
+            """<span class="{}">{}</span> {}""",
+            level_letter,
+            level_letter,
+            obj.name,
+        )
+
+    @admin.display(description="Message")
+    def message_traceback(self, obj: Record) -> str:
+        if not obj.traceback:
+            return obj.message
+
+        return format_html(
+            """{}<details><summary>Show traceback</summary><pre>{}</pre></details>
+
+
+            """,
+            obj.message,
+            obj.traceback,
+        )
+
+    def has_add_permission(self, request: HttpRequest) -> bool:
+        return False
+
+    def has_change_permission(
+        self, request: HttpRequest, obj: Record | None = None
+    ) -> bool:
+        return False
diff --git a/hc/logs/migrations/0001_initial.py b/hc/logs/migrations/0001_initial.py
new file mode 100644
index 00000000..c09d7a36
--- /dev/null
+++ b/hc/logs/migrations/0001_initial.py
@@ -0,0 +1,45 @@
+# Generated by Django 4.2.6 on 2023-10-31 13:37
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = []
+
+    operations = [
+        migrations.CreateModel(
+            name="Record",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("created", models.DateTimeField(default=django.utils.timezone.now)),
+                ("name", models.CharField(max_length=100)),
+                (
+                    "level",
+                    models.PositiveSmallIntegerField(
+                        choices=[
+                            (0, "notset"),
+                            (10, "debug"),
+                            (20, "info"),
+                            (30, "warning"),
+                            (40, "error"),
+                            (50, "critical"),
+                        ]
+                    ),
+                ),
+                ("message", models.TextField()),
+                ("traceback", models.TextField()),
+            ],
+        ),
+    ]
diff --git a/hc/logs/migrations/__init__.py b/hc/logs/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/hc/logs/models.py b/hc/logs/models.py
new file mode 100644
index 00000000..5bd11d69
--- /dev/null
+++ b/hc/logs/models.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+import logging
+
+from django.db import models
+from django.utils.timezone import now
+
+LEVELS = [
+    (logging.NOTSET, "notset"),
+    (logging.DEBUG, "debug"),
+    (logging.INFO, "info"),
+    (logging.WARNING, "warning"),
+    (logging.ERROR, "error"),
+    (logging.CRITICAL, "critical"),
+]
+
+
+class Record(models.Model):
+    created = models.DateTimeField(default=now)
+    name = models.CharField(max_length=100)
+    level = models.PositiveSmallIntegerField(choices=LEVELS)
+    message = models.TextField()
+    traceback = models.TextField()
diff --git a/hc/logs/tests.py b/hc/logs/tests.py
new file mode 100644
index 00000000..7ce503c2
--- /dev/null
+++ b/hc/logs/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/hc/settings.py b/hc/settings.py
index a1f863bf..4be05e1e 100644
--- a/hc/settings.py
+++ b/hc/settings.py
@@ -65,6 +65,7 @@ INSTALLED_APPS = (
     "compressor",
     "hc.api",
     "hc.front",
+    "hc.logs",
     "hc.payments",
 )
 
@@ -114,23 +115,21 @@ TEMPLATES = [
     }
 ]
 
-# Extend Django logging to log unhandled exceptions to console even when DEBUG=False
+# Extend Django logging to log unhandled exceptions
+# and all logs from hc.* loggers to the database.
 LOGGING = {
     "version": 1,
     "disable_existing_loggers": False,
-    "filters": {
-        "require_debug_false": {
-            "()": "django.utils.log.RequireDebugFalse",
-        },
-    },
     "handlers": {
-        "console_debug_false": {
-            "level": "ERROR",
-            "class": "logging.StreamHandler",
-            "filters": ["require_debug_false"],
+        "db": {
+            "level": "DEBUG",
+            "class": "hc.logs.Handler",
         },
     },
-    "loggers": {"django.request": {"handlers": ["console_debug_false"]}},
+    "loggers": {
+        "django.request": {"level": "ERROR", "handlers": ["db"]},
+        "hc": {"level": "DEBUG", "handlers": ["db"]},
+    },
 }
 
 WSGI_APPLICATION = "hc.wsgi.application"
diff --git a/static/css/admin/records.css b/static/css/admin/records.css
new file mode 100644
index 00000000..0256f0db
--- /dev/null
+++ b/static/css/admin/records.css
@@ -0,0 +1,42 @@
+.field-when {
+    white-space: nowrap;
+}
+
+.field-logger {
+    white-space: nowrap;
+    color: #444;
+}
+
+.field-logger span.N,
+.field-logger span.D,
+.field-logger span.I,
+.field-logger span.W,
+.field-logger span.E,
+.field-logger span.C {
+    display: inline-block;
+    width: 18px;
+    margin-right: 2px;
+    text-align: center;
+    font-size: 10px;
+    font-weight: bold;
+    border-radius: 2px;
+}
+
+.field-logger span.N { background: #e0e0e0; }
+.field-logger span.D { background: #b0bec5; }
+.field-logger span.I { background: #90caf9; }
+.field-logger span.W { background: #ffd54f; }
+.field-logger span.E { background: #d32f2f; color: #FFF; }
+.field-logger span.C { background: #333; color: #FFF; }
+
+.field-message_traceback {
+    width: 100%;
+}
+
+.field-message_traceback pre {
+    padding: 0;
+}
+
+.field-traceback .readonly {
+    font-family: monospace;
+}
\ No newline at end of file