diff --git a/aleksis/apps/alsijil/data_checks.py b/aleksis/apps/alsijil/data_checks.py new file mode 100644 index 0000000000000000000000000000000000000000..a3e3edd1cff1900e4a404c7894efe25e322f85b9 --- /dev/null +++ b/aleksis/apps/alsijil/data_checks.py @@ -0,0 +1,96 @@ +import logging + +from django.contrib.contenttypes.models import ContentType +from django.db.models import F +from django.utils.translation import gettext as _ + +import reversion +from calendarweek import CalendarWeek + + +class SolveOption: + name: str = "default" + verbose_name: str = "" + + @classmethod + def solve(cls, check_result: "DataCheckResult"): + pass + + +class IgnoreSolveOption(SolveOption): + name = "ignore" + verbose_name = _("Ignore problem") + + @classmethod + def solve(cls, check_result: "DataCheckResult"): + check_result.solved = True + check_result.save() + + +class DataCheck: + name: str = "" + verbose_name: str = "" + problem_name: str = "" + + solve_options = {IgnoreSolveOption.name: IgnoreSolveOption} + + @classmethod + def check_data(cls): + pass + + @classmethod + def solve(cls, check_result: "DataCheckResult", solve_option: str = "default"): + with reversion.create_revision(): + cls.solve_options[solve_option].solve(check_result) + + +class DeleteRelatedObjectSolveOption(SolveOption): + name = "delete" + verbose_name = _("Delete object") + + @classmethod + def solve(cls, check_result: "DataCheckResult"): + check_result.related_object.delete() + check_result.delete() + + +class NoPersonalNotesInCancelledLessonsDataCheck(DataCheck): + name = "no_personal_notes_in_cancelled_lessons" + verbose_name = _("Ensure that there are no personal notes in cancelled lessons") + problem_name = _("The personal note is related to a cancelled lesson.") + solve_options = { + DeleteRelatedObjectSolveOption.name: DeleteRelatedObjectSolveOption, + IgnoreSolveOption.name: IgnoreSolveOption, + } + + @classmethod + def check_data(cls): + from .models import PersonalNote, DataCheckResult + + ct = ContentType.objects.get_for_model(PersonalNote) + + personal_notes = PersonalNote.objects.filter( + lesson_period__substitutions__cancelled=True, + lesson_period__substitutions__week=F("week"), + lesson_period__substitutions__year=F("year"), + ).prefetch_related("lesson_period", "lesson_period__substitutions") + + for note in personal_notes: + logging.info(f"Check personal note {note}") + sub = note.lesson_period.get_substitution( + CalendarWeek(week=note.week, year=note.year) + ) + result = DataCheckResult.objects.get_or_create( + check=cls.name, content_type=ct, object_id=note.id + ) + + +DATA_CHECKS = [NoPersonalNotesInCancelledLessonsDataCheck] +DATA_CHECKS_BY_NAME = {check.name: check for check in DATA_CHECKS} +DATA_CHECKS_CHOICES = [(check.name, check.verbose_name) for check in DATA_CHECKS] + + +def check_data(): + for check in DATA_CHECKS: + logging.info(f"Run check: {check.verbose_name}") + check.check_data() diff --git a/aleksis/apps/alsijil/menus.py b/aleksis/apps/alsijil/menus.py index 8b51bb206862364dbed6c57ff9af25d598b48e33..45ca37a2d51b62e11a08ebb169ec653a2c5df166 100644 --- a/aleksis/apps/alsijil/menus.py +++ b/aleksis/apps/alsijil/menus.py @@ -60,6 +60,12 @@ MENUS = { "icon": "label", "validators": ["menu_generator.validators.is_superuser"], }, + { + "name": _("Check data"), + "url": "check_data", + "icon": "label", + "validators": ["menu_generator.validators.is_superuser"], + }, ], } ] diff --git a/aleksis/apps/alsijil/models.py b/aleksis/apps/alsijil/models.py index ea951fd97a4a7676d775070440a77e8b1b68783e..b584083b4b0842610f4503a59cd15d72cac531ea 100644 --- a/aleksis/apps/alsijil/models.py +++ b/aleksis/apps/alsijil/models.py @@ -1,9 +1,17 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.db import models +from django.urls import reverse from django.utils.formats import date_format from django.utils.translation import gettext_lazy as _ from calendarweek import CalendarWeek +from aleksis.apps.alsijil.data_checks import ( + DATA_CHECKS_BY_NAME, + DATA_CHECKS_CHOICES, + DataCheck, +) from aleksis.apps.chronos.mixins import WeekRelatedMixin from aleksis.apps.chronos.models import LessonPeriod from aleksis.apps.chronos.util.date import get_current_year @@ -102,6 +110,15 @@ class PersonalNote(ExtensibleModel, WeekRelatedMixin): def __str__(self): return f"{date_format(self.date)}, {self.lesson_period}, {self.person}" + def get_absolute_url(self): + return ( + reverse( + "lesson_by_week_and_period", + args=[self.year, self.week, self.lesson_period.pk], + ) + + "#personal-notes" + ) + class Meta: verbose_name = _("Personal note") verbose_name_plural = _("Personal notes") @@ -208,3 +225,28 @@ class ExtraMark(ExtensibleModel): ordering = ["short_name"] verbose_name = _("Extra mark") verbose_name_plural = _("Extra marks") + + +class DataCheckResult(ExtensibleModel): + check = models.CharField( + max_length=255, + verbose_name=_("Related data check task"), + choices=DATA_CHECKS_CHOICES, + ) + + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.CharField(max_length=255) + related_object = GenericForeignKey("content_type", "object_id") + + solved = models.BooleanField(default=False, verbose_name=_("Issue solved")) + + @property + def related_check(self) -> DataCheck: + return DATA_CHECKS_BY_NAME[self.check] + + def solve(self, solve_option: str = "default"): + self.related_check.solve(self, solve_option) + + class Meta: + verbose_name = _("Data check result") + verbose_name_plural = _("Data check results") diff --git a/aleksis/apps/alsijil/templates/alsijil/data_check/list.html b/aleksis/apps/alsijil/templates/alsijil/data_check/list.html new file mode 100644 index 0000000000000000000000000000000000000000..3d84d259528f6f11923d2400d0d871079a29d1e6 --- /dev/null +++ b/aleksis/apps/alsijil/templates/alsijil/data_check/list.html @@ -0,0 +1,76 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} +{% load data_helpers %} + +{% load i18n %} +{% load render_table from django_tables2 %} + +{% block browser_title %}{% blocktrans %}Data checks{% endblocktrans %}{% endblock %} +{% block page_title %}{% blocktrans %}Data checks{% endblocktrans %}{% endblock %} + +{% block content %} + <a class="btn green waves-effect waves-light" href="#"> + <i class="material-icons left">refresh</i> + {% trans "Check data again" %} + </a> + + {% if results %} + <div class="card"> + <div class="card-content"> + <i class="material-icons left medium red-text">warning</i> + <span class="card-title">{% trans "The system detected some problems with your data." %}</span> + <p>{% blocktrans %}Please go through all data and check whether some extra action is + needed.{% endblocktrans %}</p> + </div> + </div> + {% else %} + <div class="card"> + <div class="card-content"> + <i class="material-icons left medium green-text">check_circle</i> + <span class="card-title">{% trans "Everything is fine." %}</span> + <p>{% blocktrans %}The system hasn't detected any problems with your data.{% endblocktrans %}</p> + </div> + </div> + {% endif %} + + {% if results %} + <table> + <thead> + <tr> + <th></th> + <th colspan="2">{% trans "Affected object" %}</th> + <th>{% trans "Detected problem" %}</th> + <th>{% trans "Show details" %}</th> + <th>{% trans "Options to solve the problem" %}</th> + </tr> + </thead> + <tbody> + {% for result in results %} + <tr> + <td> + <code>{{ result.id }}</code> + </td> + <td>{% verbose_name_object result.related_object %}</td> + <td>{{ result.related_object }}</td> + + + <td>{{ result.related_check.problem_name }}</td> + <td> + <a class="btn-flat waves-effect waves-light" href="{{ result.related_object.get_absolute_url }}"> + {% trans "Show object" %} + </a> + </td> + <td> + {% for option_name, option in result.related_check.solve_options.items %} + <a class="btn waves-effect waves-light" href="{% url "data_check_solve" result.pk option_name %}"> + {{ option.verbose_name }} + </a> + {% endfor %} + </td> + </tr> + {% endfor %} + </tbody> + </table> + {% endif %} +{% endblock %} diff --git a/aleksis/apps/alsijil/urls.py b/aleksis/apps/alsijil/urls.py index af972edd5be09307c83ad550fa2385fe4d088669..1fab7d4e76210ac1dd094cc06382ff061d0924d0 100644 --- a/aleksis/apps/alsijil/urls.py +++ b/aleksis/apps/alsijil/urls.py @@ -68,4 +68,10 @@ urlpatterns = [ views.ExcuseTypeDeleteView.as_view(), name="delete_excuse_type", ), + path("data_check/", views.DataCheckView.as_view(), name="check_data",), + path( + "data_check/<int:id_>/<str:solve_option>/", + views.solve_data_check_view, + name="data_check_solve", + ), ] diff --git a/aleksis/apps/alsijil/views.py b/aleksis/apps/alsijil/views.py index b6b76985ecfc6a9cfc4495e8950a721058751dda..875207aaaaae85f5e184da0360cbd0e912a6f4e4 100644 --- a/aleksis/apps/alsijil/views.py +++ b/aleksis/apps/alsijil/views.py @@ -1,13 +1,13 @@ from datetime import date, datetime, timedelta -from typing import Optional +from typing import Any, Dict, Optional from django.core.exceptions import PermissionDenied -from django.db.models import Count, Exists, OuterRef, Q, Subquery, Sum +from django.db.models import Count, Exists, OuterRef, Q, QuerySet, Subquery, Sum from django.http import Http404, HttpRequest, HttpResponse, HttpResponseNotFound from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse, reverse_lazy from django.utils.translation import ugettext as _ -from django.views.generic import DetailView +from django.views.generic import DetailView, ListView import reversion from calendarweek import CalendarWeek @@ -15,6 +15,7 @@ from django_tables2 import SingleTableView from reversion.views import RevisionMixin from rules.contrib.views import PermissionRequiredMixin +from aleksis.apps.alsijil.data_checks import check_data from aleksis.apps.chronos.managers import TimetableType from aleksis.apps.chronos.models import LessonPeriod, TimePeriod from aleksis.apps.chronos.util.chronos_helpers import get_el_by_pk @@ -32,7 +33,13 @@ from .forms import ( RegisterAbsenceForm, SelectForm, ) -from .models import ExcuseType, ExtraMark, LessonDocumentation, PersonalNote +from .models import ( + DataCheckResult, + ExcuseType, + ExtraMark, + LessonDocumentation, + PersonalNote, +) from .tables import ExcuseTypeTable, ExtraMarkTable @@ -751,3 +758,36 @@ class ExcuseTypeDeleteView(AdvancedDeleteView, PermissionRequiredMixin, Revision template_name = "core/pages/delete.html" success_url = reverse_lazy("excuse_types") success_message = _("The excuse type has been deleted.") + + +class DataCheckView(ListView): + model = DataCheckResult + template_name = "alsijil/data_check/list.html" + context_object_name = "results" + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + check_data() + return context + + def get_queryset(self) -> QuerySet: + return DataCheckResult.objects.filter(solved=False).order_by("check") + + +def solve_data_check_view( + request: HttpRequest, id_: int, solve_option: str = "default" +): + result = get_object_or_404(DataCheckResult, pk=id_) + if solve_option in result.related_check.solve_options: + solve_option_obj = result.related_check.solve_options[solve_option] + + msg = _( + f"The solve option '{solve_option_obj.verbose_name}' has been affected on the object '{result.related_object}' (type: {result.related_object._meta.verbose_name})." + ) + + result.solve(solve_option) + + messages.success(request, msg) + return redirect("check_data") + else: + return HttpResponseNotFound()