diff --git a/server/vbv_lernwelt/core/admin.py b/server/vbv_lernwelt/core/admin.py index 4fdb1a9b..f2a65d3b 100644 --- a/server/vbv_lernwelt/core/admin.py +++ b/server/vbv_lernwelt/core/admin.py @@ -2,9 +2,29 @@ from django.contrib import admin from django.contrib.auth import admin as auth_admin, get_user_model from django.utils.translation import gettext_lazy as _ +from vbv_lernwelt.core.models import JobLog +from vbv_lernwelt.core.utils import pretty_print_json + User = get_user_model() +class LogAdmin(admin.ModelAdmin): + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False + + def get_readonly_fields(self, request, obj=None): + # set all fields read only + return [field.name for field in self.opts.local_fields] + [ + field.name for field in self.opts.local_many_to_many + ] + + def pretty_print_json(self, json_string): + return pretty_print_json(json_string) + + @admin.register(User) class UserAdmin(auth_admin.UserAdmin): fieldsets = ( @@ -34,3 +54,27 @@ class UserAdmin(auth_admin.UserAdmin): "sso_id", ] search_fields = ["first_name", "last_name", "email", "username", "sso_id"] + + +@admin.register(JobLog) +class JobLogAdmin(LogAdmin): + date_hierarchy = "started" + list_display = ( + "job_name", + "started", + "ended", + "runtime", + "success", + ) + list_filter = ["job_name", "success"] + + def json_data_pretty(self, instance): + return self.pretty_print_json(instance.json_data) + + def get_readonly_fields(self, request, obj=None): + return super().get_readonly_fields(request, obj) + ["json_data_pretty"] + + def runtime(self, obj): + if obj.ended: + return (obj.ended - obj.started).seconds // 60 + return None diff --git a/server/vbv_lernwelt/core/base.py b/server/vbv_lernwelt/core/base.py new file mode 100644 index 00000000..3eaa12a8 --- /dev/null +++ b/server/vbv_lernwelt/core/base.py @@ -0,0 +1,38 @@ +import sys +import traceback + +import structlog +from django.core.management.base import BaseCommand +from django.utils import timezone + +from vbv_lernwelt.core.models import JobLog + +logger = structlog.get_logger(__name__) + + +# pylint: disable=abstract-method +class LoggedCommand(BaseCommand): + def execute(self, *args, **options): + name = getattr(self, "name", "") + if not name: + name = sys.argv[1] + + logger.info("start LoggedCommand", job_name=name, label="job_log") + + self.job_log = JobLog.objects.create(job_name=name) + try: + super(LoggedCommand, self).execute(*args, **options) + self.job_log.refresh_from_db() + self.job_log.ended = timezone.now() + self.job_log.success = True + self.job_log.save() + logger.info( + "LoggedCommand successfully ended", job_name=name, label="job_log" + ) + except Exception as e: + self.job_log.refresh_from_db() + self.job_log.error_message = str(e) + self.job_log.stack_trace = traceback.format_exc() + self.job_log.save() + logger.error("LoggedCommand failed", job_name=name, label="job_log") + raise e diff --git a/server/vbv_lernwelt/core/migrations/0002_joblog.py b/server/vbv_lernwelt/core/migrations/0002_joblog.py new file mode 100644 index 00000000..f36059f0 --- /dev/null +++ b/server/vbv_lernwelt/core/migrations/0002_joblog.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.20 on 2023-08-25 15:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="JobLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("started", models.DateTimeField(auto_now_add=True)), + ("ended", models.DateTimeField(blank=True, null=True)), + ("job_name", models.CharField(max_length=255)), + ("success", models.BooleanField(default=False)), + ("error_message", models.TextField(blank=True, default="")), + ("stack_trace", models.TextField(blank=True, default="")), + ("json_data", models.JSONField(default=dict)), + ], + ), + ] diff --git a/server/vbv_lernwelt/core/models.py b/server/vbv_lernwelt/core/models.py index 8f8bcb75..524a2133 100644 --- a/server/vbv_lernwelt/core/models.py +++ b/server/vbv_lernwelt/core/models.py @@ -41,3 +41,16 @@ class SecurityRequestResponseLog(models.Model): response_status_code = models.CharField(max_length=255, blank=True, default="") additional_json_data = JSONField(default=dict, blank=True) + + +class JobLog(models.Model): + started = models.DateTimeField(auto_now_add=True) + ended = models.DateTimeField(blank=True, null=True) + job_name = models.CharField(max_length=255) + success = models.BooleanField(default=False) + error_message = models.TextField(blank=True, default="") + stack_trace = models.TextField(blank=True, default="") + json_data = models.JSONField(default=dict) + + def __str__(self): + return "{job_name} {started:%H:%M %d.%m.%Y}".format(**self.__dict__) diff --git a/server/vbv_lernwelt/core/utils.py b/server/vbv_lernwelt/core/utils.py index 1f4e16fc..75b3e517 100644 --- a/server/vbv_lernwelt/core/utils.py +++ b/server/vbv_lernwelt/core/utils.py @@ -38,3 +38,16 @@ def replace_whitespace(text, replacement=" "): def get_django_content_type(obj): return obj._meta.app_label + "." + type(obj).__name__ + + +def pretty_print_json(json_string): + try: + parsed = json_string + if isinstance(json_string, str): + parsed = json.loads(json_string) + return mark_safe( + "
{}
".format(json.dumps(parsed, indent=4, sort_keys=True)) + ) + # pylint: disable=broad-except + except Exception: + return json_string diff --git a/server/vbv_lernwelt/course_session/migrations/0005_auto_20230825_1723.py b/server/vbv_lernwelt/course_session/migrations/0005_auto_20230825_1723.py new file mode 100644 index 00000000..5840c77a --- /dev/null +++ b/server/vbv_lernwelt/course_session/migrations/0005_auto_20230825_1723.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.20 on 2023-08-25 15:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("course_session", "0004_coursesessionedoniqtest"), + ] + + operations = [ + migrations.AlterModelOptions( + name="coursesessionassignment", + options={"ordering": ["course_session", "submission_deadline__start"]}, + ), + migrations.AlterModelOptions( + name="coursesessionattendancecourse", + options={"ordering": ["course_session", "due_date__start"]}, + ), + migrations.AlterModelOptions( + name="coursesessionedoniqtest", + options={"ordering": ["course_session", "deadline__start"]}, + ), + ] diff --git a/server/vbv_lernwelt/notify/management/commands/send_attendance_course_reminders.py b/server/vbv_lernwelt/notify/management/commands/send_attendance_course_reminders.py index 2cd8ae45..19c81b8a 100644 --- a/server/vbv_lernwelt/notify/management/commands/send_attendance_course_reminders.py +++ b/server/vbv_lernwelt/notify/management/commands/send_attendance_course_reminders.py @@ -1,10 +1,10 @@ from datetime import timedelta -import djclick as click import structlog from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from vbv_lernwelt.core.base import LoggedCommand from vbv_lernwelt.core.models import User from vbv_lernwelt.course.models import CourseSessionUser from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse @@ -55,6 +55,8 @@ def attendance_course_reminder_notification_job(): logger.info("No attendance courses found") -@click.command() -def command(): - attendance_course_reminder_notification_job() +class Command(LoggedCommand): + help = "Sends attendance course reminder notifications to participants" + + def handle(self, *args, **options): + attendance_course_reminder_notification_job()