diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6efe90b75c7e1ae7ba4a387bcc08417172eab585..cc5a940d65188c400ca888031fe4fe18db0cfff2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -20,6 +20,7 @@ Added * Notification drawer in top nav bar * GraphQL queries and mutations for core data management +* [Dev] Introduce new mechanism to register classes over all apps. Changed ~~~~~~~ @@ -36,6 +37,7 @@ Changed * Incorporate SPDX license list for app licenses on About page * [Dev] The undocumented field `check` on `DataCheckResult` was renamed to `data_check` * Frontend bundling migrated from Webpack to Vite +* Get dashboard widgets and data checks from apps with new registration mechanism. Fixed ~~~~~ diff --git a/aleksis/core/apps.py b/aleksis/core/apps.py index 1cc493b7141192ba2cdc386b8590518061b1faf5..9b0518472c6309d33305188659de22700497aee5 100644 --- a/aleksis/core/apps.py +++ b/aleksis/core/apps.py @@ -65,8 +65,6 @@ class CoreConfig(AppConfig): preference_models.register(personpreferencemodel, person_preferences_registry) preference_models.register(grouppreferencemodel, group_preferences_registry) - self._load_data_checks() - from .health_checks import ( BackupJobHealthCheck, DataChecksHealthCheckBackend, @@ -79,16 +77,6 @@ class CoreConfig(AppConfig): plugin_dir.register(MediaBackupAgeHealthCheck) plugin_dir.register(BackupJobHealthCheck) - @classmethod - def _load_data_checks(cls): - """Get all data checks from all loaded models.""" - from aleksis.core.data_checks import DataCheckRegistry - - data_checks = set() - for model in apps.get_models(): - data_checks.update(getattr(model, "data_checks", [])) - DataCheckRegistry.data_checks = data_checks - def preference_updated( self, sender: Any, diff --git a/aleksis/core/data_checks.py b/aleksis/core/data_checks.py index 57db505e65bbf36172979cf12b6b9f46ffd83049..f16ab2e9f0b3b39ea86d9f53112a4619fb960e90 100644 --- a/aleksis/core/data_checks.py +++ b/aleksis/core/data_checks.py @@ -13,6 +13,7 @@ from django.utils.translation import gettext as _ import reversion from reversion import set_comment +from .mixins import RegistryObject from .util.celery_progress import ProgressRecorder, recorded_task from .util.core_helpers import get_site_preferences from .util.email import send_email @@ -65,7 +66,7 @@ class IgnoreSolveOption(SolveOption): check_result.save() -class DataCheck: +class DataCheck(RegistryObject): """Define a data check. Data checks should be used to search objects of @@ -155,7 +156,6 @@ class DataCheck: the preference ``Send emails if data checks detect problems``. """ # noqa: D412 - name: str = "" verbose_name: str = "" problem_name: str = "" @@ -225,25 +225,15 @@ class DataCheck: # Reset list with existing problems cls._current_results = [] - -class DataCheckRegistry: - """Create central registry for all data checks in AlekSIS.""" - - data_checks: set = set() - - @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] + return [(check.name, check.verbose_name) for check in cls.registered_objects_list] @recorded_task(run_every=timedelta(minutes=15)) def check_data(recorder: ProgressRecorder): """Execute all registered data checks and send email if activated.""" - for check in recorder.iterate(DataCheckRegistry.data_checks): + for check in recorder.iterate(DataCheck.registered_objects_list): logging.info(f"Run check: {check.verbose_name}") check.run_check_data() @@ -266,7 +256,7 @@ def send_emails_for_data_checks(): results_with_checks = [] for result in results_by_check: results_with_checks.append( - (DataCheckRegistry.data_checks_by_name[result["data_check"]], result["count"]) + (DataCheck.registered_objects_dict[result["data_check"]], result["count"]) ) recipient_list = [ @@ -347,3 +337,6 @@ def field_validation_data_check_factory(app_name: str, model_name: str, field_na FieldValidationDataCheck.__name__ = model_name + "FieldValidationDataCheck" return FieldValidationDataCheck + + +field_validation_data_check_factory("core", "CustomMenuItem", "icon") diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py index f4ae9a9d0c28f3539b1f4359785db555557c2bbc..4c581a1aaaf4be642223b515c8e339266a9c029f 100644 --- a/aleksis/core/mixins.py +++ b/aleksis/core/mixins.py @@ -2,7 +2,7 @@ import os from datetime import datetime -from typing import Any, Callable, List, Optional, Union +from typing import Any, Callable, ClassVar, List, Optional, Union from django.conf import settings from django.contrib import messages @@ -16,7 +16,7 @@ from django.db.models.fields import CharField, TextField from django.forms.forms import BaseForm from django.forms.models import ModelForm, ModelFormMetaclass from django.http import HttpResponse -from django.utils.functional import lazy +from django.utils.functional import classproperty, lazy from django.utils.translation import gettext as _ from django.views.generic import CreateView, UpdateView from django.views.generic.edit import DeleteView, ModelFormMixin @@ -543,3 +543,36 @@ class PublicFilePreferenceMixin(FilePreference): return os.path.join( self.upload_path, preferences_settings.FILE_PREFERENCE_UPLOAD_DIR, self.identifier() ) + + +class RegistryObject: + """Provide a generic registry to allow registration of subclasses over all apps.""" + + _registry: ClassVar[Optional[dict[str, "RegistryObject"]]] = None + name: ClassVar[str] = "" + + def __init_subclass__(cls): + print("AM I EXECUTED", cls) + if getattr(cls, "_registry", None) is None: + cls._registry = {} + else: + if not cls.name: + cls.name = cls.__name__ + cls._register() + + @classmethod + def _register(cls): + if cls.name and cls.name not in cls._registry: + cls._registry[cls.name] = cls + + @classproperty + def registered_objects_dict(cls): + return cls._registry + + @classproperty + def registered_objects_list(cls): + return list(cls._registry.values()) + + @classmethod + def get_object_by_name(cls, name): + cls.registered_objects_dict.get(name) diff --git a/aleksis/core/models.py b/aleksis/core/models.py index e2f348a7ac9becd40cbc64bae7194b6139eba305..5b4a885c91f1c4a5dd68cfb14c4519873e6c762f 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -55,7 +55,6 @@ from polymorphic.models import PolymorphicModel from aleksis.core.data_checks import ( BrokenDashboardWidgetDataCheck, DataCheck, - DataCheckRegistry, field_validation_data_check_factory, ) @@ -72,6 +71,7 @@ from .mixins import ( ExtensibleModel, GlobalPermissionModel, PureDjangoModel, + RegistryObject, SchoolTermRelatedExtensibleModel, ) from .tasks import send_notification @@ -915,13 +915,11 @@ class AnnouncementRecipient(ExtensibleModel): verbose_name_plural = _("Announcement recipients") -class DashboardWidget(PolymorphicModel, PureDjangoModel): +class DashboardWidget(RegistryObject, PolymorphicModel, PureDjangoModel): """Base class for dashboard widgets on the index page.""" objects = UninstallRenitentPolymorphicManager() - data_checks = [BrokenDashboardWidgetDataCheck] - @staticmethod def get_media(widgets: Union[QuerySet, Iterable]): """Return all media required to render the selected widgets.""" @@ -1074,8 +1072,6 @@ class CustomMenu(ExtensibleModel): class CustomMenuItem(ExtensibleModel): """Single item in a custom menu.""" - data_checks = [field_validation_data_check_factory("core", "CustomMenuItem", "icon")] - menu = models.ForeignKey( CustomMenu, models.CASCADE, verbose_name=_("Menu"), related_name="items" ) @@ -1171,7 +1167,7 @@ class DataCheckResult(ExtensibleModel): data_check = models.CharField( max_length=255, verbose_name=_("Related data check task"), - choices=DataCheckRegistry.data_checks_choices, + choices=DataCheck.data_checks_choices, ) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) @@ -1183,7 +1179,7 @@ class DataCheckResult(ExtensibleModel): @property def related_check(self) -> DataCheck: - return DataCheckRegistry.data_checks_by_name[self.data_check] + return DataCheck.registered_objects_dict[self.data_check] def solve(self, solve_option: str = "default"): self.related_check.solve(self, solve_option) diff --git a/aleksis/core/views.py b/aleksis/core/views.py index 27b99b2677f2d1fb1da8b998eda035daaf98f58f..60d2cd701ca8aa59ed7cdc7a02ca923ddc52d9de 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -60,7 +60,7 @@ from reversion.views import RevisionMixin from rules.contrib.views import PermissionRequiredMixin, permission_required from two_factor.views.core import LoginView as AllAuthLoginView -from aleksis.core.data_checks import DataCheckRegistry, check_data +from aleksis.core.data_checks import DataCheck, check_data from .celery import app from .decorators import pwa_cache @@ -835,12 +835,12 @@ class DataCheckView(PermissionRequiredMixin, ListView): return ( DataCheckResult.objects.filter(content_type__app_label__in=apps.app_configs.keys()) .filter(solved=False) - .order_by("check") + .order_by("data_check") ) def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) - context["registered_checks"] = DataCheckRegistry.data_checks + context["registered_checks"] = DataCheck.registered_objects_list return context @@ -858,7 +858,7 @@ class RunDataChecks(PermissionRequiredMixin, View): progress_title=_("Run data checks …"), success_message=_("The data checks were run successfully."), error_message=_("There was a problem while running data checks."), - back_url=reverse("check_data"), + back_url="/data_checks/", ) @@ -900,7 +900,7 @@ class DashboardWidgetListView(PermissionRequiredMixin, SingleTableView): context = super().get_context_data(**kwargs) context["widget_types"] = [ (ContentType.objects.get_for_model(m, False), m) - for m in DashboardWidget.__subclasses__() + for m in DashboardWidget.registered_objects_list ] return context