From 4627d10e8471d8897dde32387128fcd9d1b07299 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Mon, 28 Dec 2020 11:56:38 +0100
Subject: [PATCH] Load data checks from related models instead of using a
 custom registry

---
 aleksis/core/apps.py              | 15 ++++++++-
 aleksis/core/data_checks.py       | 51 ++++++++++++++++++-------------
 aleksis/core/models.py            |  6 ++--
 aleksis/core/util/core_helpers.py |  1 +
 aleksis/core/views.py             |  4 +--
 5 files changed, 50 insertions(+), 27 deletions(-)

diff --git a/aleksis/core/apps.py b/aleksis/core/apps.py
index bdfe3e3cb..75feb14c3 100644
--- a/aleksis/core/apps.py
+++ b/aleksis/core/apps.py
@@ -1,6 +1,7 @@
 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
@@ -41,7 +42,7 @@ class CoreConfig(AppConfig):
         super().ready()
 
         # Autodiscover various modules defined by AlekSIS
-        autodiscover_modules("form_extensions", "model_extensions", "checks", "data_checks")
+        autodiscover_modules("form_extensions", "model_extensions", "checks")
 
         sitepreferencemodel = self.get_model("SitePreferenceModel")
         personpreferencemodel = self.get_model("PersonPreferenceModel")
@@ -53,10 +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
index 192a5fc65..cb15f6671 100644
--- a/aleksis/core/data_checks.py
+++ b/aleksis/core/data_checks.py
@@ -2,6 +2,7 @@ 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
@@ -47,6 +48,7 @@ class SolveOption:
 
 class IgnoreSolveOption(SolveOption):
     """Mark the object with data issues as solved."""
+
     name = "ignore"
     verbose_name = _("Ignore problem")
 
@@ -70,12 +72,14 @@ class DataCheck:
 
     Example:
 
+    ``data_checks.py``
+    ******************
+
     .. code-block:: python
 
         from aleksis.core.data_checks import DataCheck, DATA_CHECK_REGISTRY
         from django.utils.translation import gettext as _
 
-        @DATA_CHECK_REGISTRY.register
         class ExampleDataCheck(DataCheck):
             name = "example" # has to be unique
             verbose_name = _("Ensure that there are no examples.")
@@ -94,6 +98,19 @@ class DataCheck:
                 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.
@@ -107,10 +124,9 @@ class DataCheck:
     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 the central registry.
-    This can be done by decorating the class with
-    ``@DATA_CHECK_REGISTRY.register`` or adding it later
-    by ``DATA_CHECK_REGISTRY.register(<YourCheck>DataCheck)``.
+    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
     ---------------------
@@ -180,34 +196,27 @@ class DataCheck:
 class DataCheckRegistry:
     """Create central registry for all data checks in AlekSIS."""
 
-    def __init__(self):
-        self.data_checks = []
-        self.data_checks_by_name = {}
-        self.data_checks_choices = []
+    data_checks = []
 
-    def register(self, check: DataCheck):
-        """Add a new data check to the registry."""
-        self.data_checks.append(check)
-        self.data_checks_by_name[check.name] = check
-        self.data_checks_choices.append((check.name, check.verbose_name))
-        return check
+    @classproperty
+    def data_checks_by_name(cls):
+        return {check.name: check for check in cls.data_checks}
 
-
-DATA_CHECK_REGISTRY = DataCheckRegistry()
+    @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 DATA_CHECK_REGISTRY.data_checks:
+    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()
 
-    return True
-
 
 def send_emails_for_data_checks():
     """Notify one or more recipients about new problems with data.
@@ -224,7 +233,7 @@ def send_emails_for_data_checks():
         results_with_checks = []
         for result in results_by_check:
             results_with_checks.append(
-                (DATA_CHECK_REGISTRY.data_checks_by_name[result["check"]], result["count"])
+                (DataCheckRegistry.data_checks_by_name[result["check"]], result["count"])
             )
 
         recipient_list = [
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index f82ea02ce..a5fc33a70 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -26,7 +26,7 @@ from model_utils.models import TimeStampedModel
 from phonenumber_field.modelfields import PhoneNumberField
 from polymorphic.models import PolymorphicModel
 
-from aleksis.core.data_checks import DATA_CHECK_REGISTRY, DataCheck
+from aleksis.core.data_checks import DataCheck, DataCheckRegistry
 
 from .managers import (
     CurrentSiteManagerWithoutMigrations,
@@ -851,7 +851,7 @@ class DataCheckResult(ExtensibleModel):
     check = models.CharField(
         max_length=255,
         verbose_name=_("Related data check task"),
-        choices=DATA_CHECK_REGISTRY.data_checks_choices,
+        choices=DataCheckRegistry.data_checks_choices,
     )
 
     content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
@@ -863,7 +863,7 @@ class DataCheckResult(ExtensibleModel):
 
     @property
     def related_check(self) -> DataCheck:
-        return DATA_CHECK_REGISTRY.data_checks_by_name[self.check]
+        return DataCheckRegistry.data_checks_by_name[self.check]
 
     def solve(self, solve_option: str = "default"):
         self.related_check.solve(self, solve_option)
diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py
index bef457fee..b3df40881 100644
--- a/aleksis/core/util/core_helpers.py
+++ b/aleksis/core/util/core_helpers.py
@@ -206,6 +206,7 @@ def celery_optional(orig: Callable) -> Callable:
             task.delay(*args, **kwargs)
         else:
             orig(*args, **kwargs)
+            return True
 
     return wrapped
 
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index 6cdbba24b..0e29253fa 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -29,7 +29,7 @@ from reversion import set_user
 from reversion.views import RevisionMixin
 from rules.contrib.views import PermissionRequiredMixin, permission_required
 
-from aleksis.core.data_checks import DATA_CHECK_REGISTRY, check_data
+from aleksis.core.data_checks import DataCheckRegistry, check_data
 
 from .filters import GroupFilter, PersonFilter
 from .forms import (
@@ -720,7 +720,7 @@ class DataCheckView(PermissionRequiredMixin, ListView):
 
     def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
         context = super().get_context_data(**kwargs)
-        context["registered_checks"] = DATA_CHECK_REGISTRY.data_checks
+        context["registered_checks"] = DataCheckRegistry.data_checks
         return context
 
 
-- 
GitLab