diff --git a/aleksis/core/admin.py b/aleksis/core/admin.py index e35910f597983ea8cfaa957cd475e607c490dfa0..ae51f4bcfddec26f5b31af1fb79628ac6812476f 100644 --- a/aleksis/core/admin.py +++ b/aleksis/core/admin.py @@ -10,6 +10,7 @@ from .models import ( Announcement, AnnouncementRecipient, CustomMenuItem, + DataCheckResult, Group, Notification, Person, @@ -33,3 +34,4 @@ class AnnouncementAdmin(BaseModelAdmin, VersionAdmin): admin.site.register(Announcement, AnnouncementAdmin) +admin.site.register(DataCheckResult) diff --git a/aleksis/core/apps.py b/aleksis/core/apps.py index e88d812bde928fcdc2f38027b8323aa8f09ad07b..75feb14c3ed9e6d717d1bbd89bf0ef87070d34b3 100644 --- a/aleksis/core/apps.py +++ b/aleksis/core/apps.py @@ -1,12 +1,14 @@ from typing import Any, List, Optional, Tuple import django.apps +from django.apps import apps from django.conf import settings from django.db import OperationalError, ProgrammingError from django.http import HttpRequest from django.utils.module_loading import autodiscover_modules from dynamic_preferences.registries import preference_models +from health_check.plugins import plugin_dir from .registries import ( group_preferences_registry, @@ -52,6 +54,22 @@ class CoreConfig(AppConfig): self._refresh_authentication_backends() + self._load_data_checks() + + from .health_checks import DataChecksHealthCheckBackend + + plugin_dir.register(DataChecksHealthCheckBackend) + + @classmethod + def _load_data_checks(cls): + """Get all data checks from all loaded models.""" + from aleksis.core.data_checks import DataCheckRegistry + + data_checks = [] + for model in apps.get_models(): + data_checks += getattr(model, "data_checks", []) + DataCheckRegistry.data_checks = data_checks + @classmethod def _refresh_authentication_backends(cls): """Refresh config list of enabled authentication backends.""" diff --git a/aleksis/core/data_checks.py b/aleksis/core/data_checks.py new file mode 100644 index 0000000000000000000000000000000000000000..cb15f66713dee2e66cd4b561fa360ca8fed8641b --- /dev/null +++ b/aleksis/core/data_checks.py @@ -0,0 +1,257 @@ +import logging + +from django.contrib.contenttypes.models import ContentType +from django.db.models.aggregates import Count +from django.utils.decorators import classproperty +from django.utils.translation import gettext as _ + +import reversion +from reversion import set_comment +from templated_email import send_templated_mail + +from .util.core_helpers import celery_optional, get_site_preferences + + +class SolveOption: + """Define a solve option for one or more data checks. + + Solve options are used in order to give the data admin typical + solutions to a data issue detected by a data check. + + Example definition + + .. code-block:: python + + from aleksis.core.data_checks import SolveOption + from django.utils.translation import gettext as _ + + class DeleteSolveOption(SolveOption): + name = "delete" # has to be unqiue + verbose_name = _("Delete") # should make use of i18n + + @classmethod + def solve(cls, check_result: "DataCheckResult"): + check_result.related_object.delete() + check_result.delete() + + After the solve option has been successfully executed, + the corresponding data check result has to be deleted. + """ + + name: str = "default" + verbose_name: str = "" + + @classmethod + def solve(cls, check_result: "DataCheckResult"): + pass + + +class IgnoreSolveOption(SolveOption): + """Mark the object with data issues as solved.""" + + name = "ignore" + verbose_name = _("Ignore problem") + + @classmethod + def solve(cls, check_result: "DataCheckResult"): + """Mark the object as solved without doing anything more.""" + check_result.solved = True + check_result.save() + + +class DataCheck: + """Define a data check. + + Data checks should be used to search objects of + any type which are broken or need some extra action. + + Defining data checks + -------------------- + Data checks are defined by inheriting from the class DataCheck + and registering the inherited class in the data check registry. + + Example: + + ``data_checks.py`` + ****************** + + .. code-block:: python + + from aleksis.core.data_checks import DataCheck, DATA_CHECK_REGISTRY + from django.utils.translation import gettext as _ + + class ExampleDataCheck(DataCheck): + name = "example" # has to be unique + verbose_name = _("Ensure that there are no examples.") + problem_name = _("There is an example.") # should both make use of i18n + + solve_options = { + IgnoreSolveOption.name: IgnoreSolveOption + } + + @classmethod + def check_data(cls): + from example_app.models import ExampleModel + + wrong_examples = ExampleModel.objects.filter(wrong_value=True) + + for example in wrong_examples: + cls.register_result(example) + + ``models.py`` + ************* + + .. code-block:: python + + from .data_checks import ExampleDataCheck + + # ... + + class ExampleModel(Model): + data_checks = [ExampleDataCheck] + + + Solve options are used in order to give the data admin typical solutions to this specific issue. + They are defined by inheriting from SolveOption. + More information about defining solve options can be find there. + + The dictionary ``solve_options`` should include at least the IgnoreSolveOption, + but preferably also own solve options. The keys in this dictionary + have to be ``<YourOption>SolveOption.name`` + and the values must be the corresponding solve option classes. + + The class method ``check_data`` does the actual work. In this method + your code should find all objects with issues and should register + them in the result database using the class method ``register_result``. + + Data checks have to be registered in their corresponding model. + This can be done by adding a list ``data_checks`` + containing the data check classes. + + Executing data checks + --------------------- + The data checks can be executed by using the + celery task named ``aleksis.core.data_checks.check_data``. + We recommend to create a periodic task in the backend + which executes ``check_data`` on a regular base (e. g. every day). + + .. warning:: + To use the option described above, you must have setup celery properly. + + Notifications about results + --------------------------- + The data check tasks can notify persons via email + if there are new data issues. You can set these persons + by adding them to the preference + ``Email recipients for data checks problem emails`` in the site configuration. + + To enable this feature, you also have to activate + the preference ``Send emails if data checks detect problems``. + """ # noqa: D412 + + name: str = "" + verbose_name: str = "" + problem_name: str = "" + + solve_options = {IgnoreSolveOption.name: IgnoreSolveOption} + + @classmethod + def check_data(cls): + """Find all objects with data issues and register them.""" + pass + + @classmethod + def solve(cls, check_result: "DataCheckResult", solve_option: str): + """Execute a solve option for an object detected by this check. + + :param check_result: The result item from database + :param solve_option: The name of the solve option that should be executed + """ + with reversion.create_revision(): + solve_option_obj = cls.solve_options[solve_option] + set_comment( + _( + f"Solve option '{solve_option_obj.verbose_name}' " + f"for data check '{cls.verbose_name}'" + ) + ) + solve_option_obj.solve(check_result) + + @classmethod + def register_result(cls, instance) -> "DataCheckResult": + """Register an object with data issues in the result database. + + :param instance: The affected object + :return: The database entry + """ + from aleksis.core.models import DataCheckResult + + ct = ContentType.objects.get_for_model(instance) + result = DataCheckResult.objects.get_or_create( + check=cls.name, content_type=ct, object_id=instance.id + ) + return result + + +class DataCheckRegistry: + """Create central registry for all data checks in AlekSIS.""" + + data_checks = [] + + @classproperty + def data_checks_by_name(cls): + return {check.name: check for check in cls.data_checks} + + @classproperty + def data_checks_choices(cls): + return [(check.name, check.verbose_name) for check in cls.data_checks] + + +@celery_optional +def check_data(): + """Execute all registered data checks and send email if activated.""" + for check in DataCheckRegistry.data_checks: + logging.info(f"Run check: {check.verbose_name}") + check.check_data() + + if get_site_preferences()["general__data_checks_send_emails"]: + send_emails_for_data_checks() + + +def send_emails_for_data_checks(): + """Notify one or more recipients about new problems with data. + + Recipients can be set in dynamic preferences. + """ + from .models import DataCheckResult # noqa + + results = DataCheckResult.objects.filter(solved=False, sent=False) + + if results.exists(): + results_by_check = results.values("check").annotate(count=Count("check")) + + results_with_checks = [] + for result in results_by_check: + results_with_checks.append( + (DataCheckRegistry.data_checks_by_name[result["check"]], result["count"]) + ) + + recipient_list = [ + p.mail_sender + for p in get_site_preferences()["general__data_checks_recipients"] + if p.email + ] + + for group in get_site_preferences()["general__data_checks_recipient_groups"]: + recipient_list += [p.mail_sender for p in group.announcement_recipients if p.email] + + send_templated_mail( + template_name="data_checks", + from_email=get_site_preferences()["mail__address"], + recipient_list=recipient_list, + context={"results": results_with_checks}, + ) + + logging.info("Sent notification email because of unsent data checks") + + results.update(sent=True) diff --git a/aleksis/core/health_checks.py b/aleksis/core/health_checks.py new file mode 100644 index 0000000000000000000000000000000000000000..e3239087a17e5fed6c59441e9c6a219e86deb006 --- /dev/null +++ b/aleksis/core/health_checks.py @@ -0,0 +1,18 @@ +from django.utils.translation import gettext as _ + +from health_check.backends import BaseHealthCheckBackend + +from aleksis.core.models import DataCheckResult + + +class DataChecksHealthCheckBackend(BaseHealthCheckBackend): + """Checks whether there are unresolved data problems.""" + + critical_service = False + + def check_status(self): + if DataCheckResult.objects.filter(solved=False).exists(): + self.add_error(_("There are unresolved data problems.")) + + def identifier(self): + return self.__class__.__name__ diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py index 9b1c42e931235333fb8d4a9dca55cd7f977b47de..fc9ba63604dabf57e1c4101fc8d7f5700548cc6c 100644 --- a/aleksis/core/menus.py +++ b/aleksis/core/menus.py @@ -142,6 +142,12 @@ MENUS = { ), ], }, + { + "name": _("Data checks"), + "url": "check_data", + "icon": "done_all", + "validators": ["menu_generator.validators.is_superuser"], + }, { "name": _("Backend Admin"), "url": "admin:index", diff --git a/aleksis/core/migrations/0008_data_check_result.py b/aleksis/core/migrations/0008_data_check_result.py new file mode 100644 index 0000000000000000000000000000000000000000..3f8285e22a6af4109a67d6baddccd05e69b1bd6e --- /dev/null +++ b/aleksis/core/migrations/0008_data_check_result.py @@ -0,0 +1,63 @@ +# Generated by Django 3.1.3 on 2020-11-14 16:11 + +import django.contrib.sites.managers +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ("sites", "0002_alter_domain_unique"), + ("contenttypes", "0002_remove_content_type_name"), + ("core", "0007_dashboard_widget_order"), + ] + + operations = [ + migrations.CreateModel( + name="DataCheckResult", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("extended_data", models.JSONField(default=dict, editable=False)), + ( + "check", + models.CharField( + choices=[], max_length=255, verbose_name="Related data check task" + ), + ), + ("object_id", models.CharField(max_length=255)), + ("solved", models.BooleanField(default=False, verbose_name="Issue solved")), + ("sent", models.BooleanField(default=False, verbose_name="Notification sent")), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype" + ), + ), + ( + "site", + models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + to="sites.site", + ), + ), + ], + options={ + "permissions": ( + ("run_data_checks", "Can run data checks"), + ("solve_data_problem", "Can solve data check problems"), + ), + "verbose_name": "Data check result", + "verbose_name_plural": "Data check results", + }, + managers=[("objects", django.contrib.sites.managers.CurrentSiteManager()),], + ), + ] diff --git a/aleksis/core/models.py b/aleksis/core/models.py index 0f7a80d416cfe1dec30b57f2bf1e070e664a8966..5f5facd33939560c81fd8197271d1fd8ec9fe4ec 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -26,6 +26,8 @@ from model_utils.models import TimeStampedModel from phonenumber_field.modelfields import PhoneNumberField from polymorphic.models import PolymorphicModel +from aleksis.core.data_checks import DataCheck, DataCheckRegistry + from .managers import ( CurrentSiteManagerWithoutMigrations, GroupManager, @@ -843,3 +845,38 @@ class GroupPreferenceModel(PerInstancePreferenceModel, PureDjangoModel): class Meta: app_label = "core" + + +class DataCheckResult(ExtensibleModel): + """Save the result of a data check for a specific object.""" + + check = models.CharField( + max_length=255, + verbose_name=_("Related data check task"), + choices=DataCheckRegistry.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")) + sent = models.BooleanField(default=False, verbose_name=_("Notification sent")) + + @property + def related_check(self) -> DataCheck: + return DataCheckRegistry.data_checks_by_name[self.check] + + def solve(self, solve_option: str = "default"): + self.related_check.solve(self, solve_option) + + def __str__(self): + return f"{self.related_object}: {self.related_check.problem_name}" + + class Meta: + verbose_name = _("Data check result") + verbose_name_plural = _("Data check results") + permissions = ( + ("run_data_checks", _("Can run data checks")), + ("solve_data_problem", _("Can solve data check problems")), + ) diff --git a/aleksis/core/preferences.py b/aleksis/core/preferences.py index eba67c7220314823b0f74efa986cbe09c24d1f43..a8cbe1ef56deee73e82c5d0a77983078de186dac 100644 --- a/aleksis/core/preferences.py +++ b/aleksis/core/preferences.py @@ -5,13 +5,15 @@ from django.utils.translation import gettext_lazy as _ from dynamic_preferences.preferences import Section from dynamic_preferences.types import ( + BooleanPreference, ChoicePreference, FilePreference, + ModelMultipleChoicePreference, MultipleChoicePreference, StringPreference, ) -from .models import Person +from .models import Group, Person from .registries import person_preferences_registry, site_preferences_registry from .util.notifications import get_notification_choices_lazy @@ -209,3 +211,35 @@ class AvailableLanguages(MultipleChoicePreference): verbose_name = _("Available languages") field_attribute = {"initial": []} choices = settings.LANGUAGES + + +@site_preferences_registry.register +class DataChecksSendEmails(BooleanPreference): + """Enable email sending if data checks detect problems.""" + + section = general + name = "data_checks_send_emails" + default = False + verbose_name = _("Send emails if data checks detect problems") + + +@site_preferences_registry.register +class DataChecksEmailsRecipients(ModelMultipleChoicePreference): + """Email recipients for data check problem emails.""" + + section = general + name = "data_checks_recipients" + default = [] + model = Person + verbose_name = _("Email recipients for data checks problem emails") + + +@site_preferences_registry.register +class DataChecksEmailsRecipientGroups(ModelMultipleChoicePreference): + """Email recipient groups for data check problem emails.""" + + section = general + name = "data_checks_recipient_groups" + default = [] + model = Group + verbose_name = _("Email recipient groups for data checks problem emails") diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py index 2e36bc08bf3a307c0b8a116a5731f0f4793f2475..8ad48fbf18db6cac37270680792aa784a49eb77c 100644 --- a/aleksis/core/rules.py +++ b/aleksis/core/rules.py @@ -279,6 +279,21 @@ view_group_stats_predicate = has_person & ( ) rules.add_perm("core.view_group_stats", view_group_stats_predicate) +# View data check results +view_data_check_results_predicate = has_person & has_global_perm("core.view_datacheckresult") +rules.add_perm("core.view_datacheckresults", view_data_check_results_predicate) + +# Run data checks +run_data_checks_predicate = ( + has_person & view_data_check_results_predicate & has_global_perm("core.run_data_checks") +) +rules.add_perm("core.run_data_checks", run_data_checks_predicate) + +# Solve data problems +solve_data_problem_predicate = ( + has_person & view_data_check_results_predicate & has_global_perm("core.solve_data_problem") +) +rules.add_perm("core.solve_data_problem", solve_data_problem_predicate) view_dashboard_widget_predicate = has_person & has_global_perm("core.view_dashboardwidget") rules.add_perm("core.view_dashboardwidget", view_dashboard_widget_predicate) diff --git a/aleksis/core/static/style.scss b/aleksis/core/static/style.scss index 81f443f75ab79c34121390bf03da9ab1e611dd5b..a7b29299653c8e40d7b98304dcf2ea6f68ee4de2 100644 --- a/aleksis/core/static/style.scss +++ b/aleksis/core/static/style.scss @@ -543,6 +543,10 @@ main .alert p:first-child, main .alert div:first-child { height: 100%; } +.btn-margin { + margin-bottom: 5px; +} + /* Dashboard */ diff --git a/aleksis/core/templates/core/data_check/list.html b/aleksis/core/templates/core/data_check/list.html new file mode 100644 index 0000000000000000000000000000000000000000..5a510fdd92f550ad1b7ff6b0b81983ea225420ba --- /dev/null +++ b/aleksis/core/templates/core/data_check/list.html @@ -0,0 +1,103 @@ +{# -*- 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="{% url "data_check_run" %}"> + <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 %} + <div class="card"> + <div class="card-content"> + <div class="card-title">{% trans "Detected problems" %}</div> + <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 btn-margin waves-effect waves-light" + href="{% url "data_check_solve" result.pk option_name %}"> + {{ option.verbose_name }} + </a> + {% endfor %} + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + {% endif %} + </div> + + <div class="card hundred-percent"> + <div class="card-content"> + <div class="card-title">{% trans "Registered checks" %}</div> + <div class="alert primary"> + <div> + <i class="material-icons left">info</i> + {% blocktrans %} + The system will check for the following problems: + {% endblocktrans %} + </div> + </div> + <ul class="collection"> + {% for check in registered_checks %} + <li class="collection-item"> + <i class="material-icons left">check</i> + {{ check.verbose_name }} + </li> + {% endfor %} + </ul> + </div> + </div> +{% endblock %} diff --git a/aleksis/core/templates/templated_email/data_checks.css b/aleksis/core/templates/templated_email/data_checks.css new file mode 100644 index 0000000000000000000000000000000000000000..b385b185ecfe66dcca070e8eb024bbb091917f04 --- /dev/null +++ b/aleksis/core/templates/templated_email/data_checks.css @@ -0,0 +1,16 @@ +body { + line-height: 1.5; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-weight: normal; + color: rgba(0, 0, 0, 0.87); +} + +.count { + text-align: right; + font-family: monospace; + font-size: 14pt; +} + +td, th { + padding: 10px; +} diff --git a/aleksis/core/templates/templated_email/data_checks.email b/aleksis/core/templates/templated_email/data_checks.email new file mode 100644 index 0000000000000000000000000000000000000000..0f9b5a5307fba9152683d4e03b0c41a2105629c7 --- /dev/null +++ b/aleksis/core/templates/templated_email/data_checks.email @@ -0,0 +1,44 @@ +{% load i18n %} + +{% block subject %} + {% trans "The system detected some new problems with your data." %} +{% endblock %} + +{% block plain %} + {% trans "Hello," %} + + {% blocktrans %} + the system detected some new problems with your data. + Please take some time to inspect them and solve the issues or mark them as ignored. + {% endblocktrans %} + + {% for result in results %} + {{ result.0.problem_name }}: {{ result.1 }} + {% endfor %} +{% endblock %} + +{% block html %} + <style> + {% include "templated_email/data_checks.css" %} + </style> + <p>{% trans "Hello," %}</p> + <p> + {% blocktrans %} + the system detected some new problems with your data. + Please take some time to inspect them and solve the issues or mark them as ignored. + {% endblocktrans %} + </p> + + <table> + <tr> + <th>{% trans "Problem description" %}</th> + <th>{% trans "Count of objects with new problems" %}</th> + </tr> + {% for result in results %} + <tr> + <td>{{ result.0.problem_name }}</td> + <td class="count">{{ result.1 }}</td> + </tr> + {% endfor %} + </table> +{% endblock %} diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index 7f0f81b0960174c8df67fb862a38e4d2bdd38164..930b3fbc8e8bec19bd32cadb6a6fd6bd7833f0f7 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -154,6 +154,13 @@ urlpatterns = [ name="preferences_group", ), path("health/", include(health_urls)), + path("data_check/", views.DataCheckView.as_view(), name="check_data",), + path("data_check/run/", views.RunDataChecks.as_view(), name="data_check_run",), + path( + "data_check/<int:pk>/<str:solve_option>/", + views.SolveDataCheckView.as_view(), + name="data_check_solve", + ), path("dashboard_widgets/", views.DashboardWidgetListView.as_view(), name="dashboard_widgets"), path( "dashboard_widgets/<int:pk>/edit/", diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py index bef457feee2b0dd53f72591523387d912c4038b1..fcbf1b65f7b20aea5c2bf1d7b5098c922c12dd1f 100644 --- a/aleksis/core/util/core_helpers.py +++ b/aleksis/core/util/core_helpers.py @@ -195,6 +195,12 @@ def celery_optional(orig: Callable) -> Callable: If Celery is configured and available, it wraps the function in a Task and calls its delay method when invoked; if not, it leaves it untouched and it is executed synchronously. + + The wrapped function returns a tuple with either + the return value of the task's delay method and False + if the method has been executed asynchronously + or the return value of the executed method and True + if the method has been executed synchronously. """ if is_celery_enabled(): from ..celery import app # noqa @@ -203,9 +209,9 @@ def celery_optional(orig: Callable) -> Callable: def wrapped(*args, **kwargs): if is_celery_enabled(): - task.delay(*args, **kwargs) + return task.delay(*args, **kwargs), False else: - orig(*args, **kwargs) + return orig(*args, **kwargs), True return wrapped diff --git a/aleksis/core/views.py b/aleksis/core/views.py index 14cbf2dbeafc7dd84edfadcd5375e97560159011..016b49052f9272bd3743c78eb86689a0395194bd 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -5,6 +5,7 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import PermissionDenied from django.core.paginator import Paginator +from django.db.models import QuerySet from django.forms.models import BaseModelForm, modelform_factory from django.http import HttpRequest, HttpResponse, HttpResponseNotFound from django.shortcuts import get_object_or_404, redirect, render @@ -13,6 +14,8 @@ from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ from django.views.decorators.cache import never_cache from django.views.generic.base import View +from django.views.generic.detail import DetailView +from django.views.generic.list import ListView import reversion from django_tables2 import RequestConfig, SingleTableView @@ -23,8 +26,11 @@ from haystack.query import SearchQuerySet from haystack.views import SearchView from health_check.views import MainView from reversion import set_user +from reversion.views import RevisionMixin from rules.contrib.views import PermissionRequiredMixin, permission_required +from aleksis.core.data_checks import DataCheckRegistry, check_data + from .filters import GroupFilter, PersonFilter from .forms import ( AnnouncementForm, @@ -46,6 +52,7 @@ from .models import ( Announcement, DashboardWidget, DashboardWidgetOrder, + DataCheckResult, Group, GroupType, Notification, @@ -702,6 +709,62 @@ def delete_group_type(request: HttpRequest, id_: int) -> HttpResponse: return redirect("group_types") +class DataCheckView(PermissionRequiredMixin, ListView): + permission_required = "core.view_datacheckresults" + model = DataCheckResult + template_name = "core/data_check/list.html" + context_object_name = "results" + + def get_queryset(self) -> QuerySet: + return DataCheckResult.objects.filter(solved=False).order_by("check") + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["registered_checks"] = DataCheckRegistry.data_checks + return context + + +class RunDataChecks(PermissionRequiredMixin, View): + permission_required = "core.run_data_checks" + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + if not check_data()[1]: + messages.success( + request, + _( + "The data check has been started. Please note that it may take " + "a while before you are able to fetch the data on this page." + ), + ) + else: + messages.success(request, _("The data check has finished.")) + return redirect("check_data") + + +class SolveDataCheckView(PermissionRequiredMixin, RevisionMixin, DetailView): + queryset = DataCheckResult.objects.all() + permission_required = "core.solve_data_problem" + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + solve_option = self.kwargs["solve_option"] + result = self.get_object() + 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}' " + f"has been executed on the object '{result.related_object}' " + f"(type: {result.related_object._meta.verbose_name})." + ) + + result.solve(solve_option) + + messages.success(request, msg) + return redirect("check_data") + else: + return HttpResponseNotFound() + + class DashboardWidgetListView(SingleTableView, PermissionRequiredMixin): """Table of all dashboard widgets."""