diff --git a/docs/references/signals.md b/docs/references/signals.md new file mode 100644 index 0000000000..28bf5434e4 --- /dev/null +++ b/docs/references/signals.md @@ -0,0 +1,2 @@ +Hypha uses django signals to enable extensions to modify behavior, as well as execute their own code when certain things happen in hypha. This page servers as a reference to implemented signals in hypha. + diff --git a/extensions/django_hooks/README.md b/extensions/django_hooks/README.md new file mode 100644 index 0000000000..a1248c912c --- /dev/null +++ b/extensions/django_hooks/README.md @@ -0,0 +1,7 @@ +# django_hooks + +Adds the ability to have hooks injected into templates, via the `hooks_tags.hook` template tag. + +Originally from https://github.com/nitely/django-hooks but after culling everything except the template hook. The documentation on that template hook at [readthedocs](https://django-hooks.readthedocs.io/en/latest/hooks.html#templatehook) is [replicated locally](docs/templatehook.rst) + +The reason this was forked, rather than used as a module, is that the last commit from the other project was made in 2015, and can no longer be imported into a modern django project. The template hook continues to work correctly, but the other hooks most likely do not, and so were removed. diff --git a/extensions/django_hooks/__init__.py b/extensions/django_hooks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/extensions/django_hooks/docs/templatehook.rst b/extensions/django_hooks/docs/templatehook.rst new file mode 100644 index 0000000000..c8e25005e2 --- /dev/null +++ b/extensions/django_hooks/docs/templatehook.rst @@ -0,0 +1,89 @@ +TemplateHook +============ + +Adding a hook-point in ``main_app``'s template:: + + # my_main_app/templates/_base.html + + {% load hooks_tags %} + + + +
+ #... + + {% hook 'within_head' %} + + #... + + + +.. Tip:: Here we are adding a *hook-point* called ``within_head`` where *third-party* + apps will be able to insert their code. + +Creating a hook listener in a ``third_party_app``:: + + # third_party_app/template_hooks.py + + from django.template.loader import render_to_string + from django.utils.html import mark_safe, format_html + + + # Example 1 + def css_resources(context, *args, **kwargs): + return mark_safe(u'' % settings.STATIC_URL) + + + # Example 2 + def user_about_info(context, *args, **kwargs): + user = context['request'].user + return format_html( + "{name} {last_name}: {about}", + name=user.first_name, + last_name=user.last_name, + about=mark_safe(user.profile.about_html_field) # Some safe (sanitized) html data. + ) + + + # Example 3 + def a_more_complex_hook(context, *args, **kwargs): + # If you are doing this a lot, make sure to keep your templates in memory (google: django.template.loaders.cached.Loader) + return render_to_string( + template_name='templates/app_hook/head_resources.html', + context_instance=context + ) + + + # Example 4 + def an_even_more_complex_hook(context, *args, **kwargs): + articles = Article.objects.all() + return render_to_string( + template_name='templates/app_hook/my_articles.html', + dictionary={'articles': articles, }, + context_instance=context + ) + +Registering a hook listener in a ``third_party_app``:: + + # third_party_app/apps.py + + from django.apps import AppConfig + + + class MyAppConfig(AppConfig): + + name = 'myapp' + verbose_name = 'My App' + + def ready(self): + from hooks.templatehook import hook + from third_party_app.template_hooks import css_resources + + hook.register("within_head", css_resources) + +.. Tip:: Where to register your hooks: + + Use ``AppConfig.ready()``: docs_ and example_ + +.. _docs: https://docs.djangoproject.com/en/1.8/ref/applications/#django.apps.AppConfig.ready +.. _example: http://chriskief.com/2014/02/28/django-1-7-signals-appconfig/ diff --git a/extensions/django_hooks/templatehook.py b/extensions/django_hooks/templatehook.py new file mode 100644 index 0000000000..1e8cf1a768 --- /dev/null +++ b/extensions/django_hooks/templatehook.py @@ -0,0 +1,139 @@ +class TemplateHook(object): + """ + A hook for templates. This can be used directly or\ + through the :py:class:`Hook` dispatcher + + :param list providing_args: A list of the arguments\ + this hook can pass along in a :py:func:`.__call__` + """ + + def __init__(self, providing_args=None): + self.providing_args = providing_args or [] + self._registry = [] + + def __call__(self, *args, **kwargs): + """ + Collect all callbacks responses for this template hook + + :return: Responses by registered callbacks,\ + this is usually a list of HTML strings + :rtype: list + """ + return [func(*args, **kwargs) for func in self._registry] + + def register(self, func): + """ + Register a new callback + + :param callable func: A function reference used as a callback + """ + assert callable(func), "Callback func must be a callable" + + self._registry.append(func) + + def unregister(self, func): + """ + Remove a previously registered callback + + :param callable func: A function reference\ + that was registered previously + """ + try: + self._registry.remove(func) + except ValueError: + pass + + def unregister_all(self): + """ + Remove all callbacks + """ + del self._registry[:] + + +class Hook(object): + """ + Dynamic dispatcher (proxy) for :py:class:`TemplateHook` + """ + + def __init__(self): + self._registry = {} + + def __call__(self, name, *args, **kwargs): + """ + Collect all callbacks responses for this template hook.\ + The hook (name) does not need to be pre-created,\ + it may not exist at call time + + :param str name: Hook name, it must be unique,\ + prefixing it with the app label is a good idea + :return: Responses by registered callbacks + :rtype: list + """ + try: + templatehook = self._registry[name] + except KeyError: + return [] + + return templatehook(*args, **kwargs) + + def _register(self, name): + """ + @Api private + Add new :py:class:`TemplateHook` into the registry + + :param str name: Hook name + :return: Instance of :py:class:`TemplateHook` + :rtype: :py:class:`TemplateHook` + """ + templatehook = TemplateHook() + self._registry[name] = templatehook + return templatehook + + def register(self, name, func): + """ + Register a new callback.\ + When the name/id is not found\ + a new hook is created under its name,\ + meaning the hook is usually created by\ + the first registered callback + + :param str name: Hook name + :param callable func: A func reference (callback) + """ + try: + templatehook = self._registry[name] + except KeyError: + templatehook = self._register(name) + + templatehook.register(func) + + def unregister(self, name, func): + """ + Remove a previously registered callback + + :param str name: Hook name + :param callable func: A function reference\ + that was registered previously + """ + try: + templatehook = self._registry[name] + except KeyError: + return + + templatehook.unregister(func) + + def unregister_all(self, name): + """ + Remove all callbacks + + :param str name: Hook name + """ + try: + templatehook = self._registry[name] + except KeyError: + return + + templatehook.unregister_all() + + +hook = Hook() diff --git a/extensions/django_hooks/templatetags/__init__.py b/extensions/django_hooks/templatetags/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/extensions/django_hooks/templatetags/hooks_tags.py b/extensions/django_hooks/templatetags/hooks_tags.py new file mode 100644 index 0000000000..e9b98d8e8c --- /dev/null +++ b/extensions/django_hooks/templatetags/hooks_tags.py @@ -0,0 +1,62 @@ +from django import template +from django.utils.html import format_html_join + +from extensions.django_hooks.templatehook import hook + +register = template.Library() + + +@register.simple_tag(name="hook", takes_context=True) +def hook_tag(context, name, *args, **kwargs): + r""" + Hook tag to call within templates + + :param dict context: This is automatically passed,\ + contains the template state/variables + :param str name: The hook which will be dispatched + :param \*args: Positional arguments, will be passed to hook callbacks + :param \*\*kwargs: Keyword arguments, will be passed to hook callbacks + :return: A concatenation of all callbacks\ + responses marked as safe (conditionally) + :rtype: str + """ + return format_html_join( + sep="\n", + format_string="{}", + args_generator=( + (response,) for response in hook(name, context, *args, **kwargs) + ), + ) + + +def template_hook_collect(module, hook_name, *args, **kwargs): + r""" + Helper to include in your own templatetag, for static TemplateHooks + + Example:: + + import myhooks + from hooks.templatetags import template_hook_collect + + @register.simple_tag(takes_context=True) + def hook(context, name, *args, **kwargs): + return template_hook_collect(myhooks, name, context, *args, **kwargs) + + :param module module: Module containing the template hook definitions + :param str hook_name: The hook name to be dispatched + :param \*args: Positional arguments, will be passed to hook callbacks + :param \*\*kwargs: Keyword arguments, will be passed to hook callbacks + :return: A concatenation of all callbacks\ + responses marked as safe (conditionally) + :rtype: str + """ + try: + templatehook = getattr(module, hook_name) + except AttributeError: + return "" + + return format_html_join( + sep="\n", + format_string="{}", + args_generator=((response,) for response in templatehook(*args, **kwargs)), + ) diff --git a/extensions/ots/user_newsletter_signup/__init__.py b/extensions/ots/user_newsletter_signup/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/extensions/ots/user_newsletter_signup/apps.py b/extensions/ots/user_newsletter_signup/apps.py new file mode 100644 index 0000000000..b7acdf8d35 --- /dev/null +++ b/extensions/ots/user_newsletter_signup/apps.py @@ -0,0 +1,28 @@ +from django.apps import AppConfig +from django.conf import settings + + +class UserNewsletterSignupConfig(AppConfig): + name = "extensions.ots.user_newsletter_signup" + label = "extension_user_newsletter_signup" + + def ready(self): + from extensions.django_hooks.templatehook import hook + + from .template_hooks import hypha_extension_head, wagtail_user_edit + + hook.register("wagtail_user_edit", wagtail_user_edit) + hook.register("hypha_extension_head", hypha_extension_head) + + from django.forms.fields import BooleanField + + from hypha.apply.users.forms import ProfileForm + + ProfileForm.Meta.fields.append("newsletter_signup") + field = BooleanField( + required=False, + label="Yes, I'd like to receive occasional emails from %s about their mission and programs." + % settings.ORG_SHORT_NAME, + ) + field.widget.attrs.update({"class": "profile_newsletter_signup"}) + ProfileForm.base_fields["newsletter_signup"] = field diff --git a/extensions/ots/user_newsletter_signup/migrations/0024_user_newsletter_signup.py b/extensions/ots/user_newsletter_signup/migrations/0024_user_newsletter_signup.py new file mode 100644 index 0000000000..580f123d96 --- /dev/null +++ b/extensions/ots/user_newsletter_signup/migrations/0024_user_newsletter_signup.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.11 on 2024-05-16 11:49 + +from django.db import migrations, models +from hypha.apply.users.models import User + + +def add_newsletter_signup_field(apps, schema_editor): + field = models.BooleanField(default=True) + field.column = "newsletter_signup" + schema_editor.add_field( + User, + field, + ) + + +def remove_newsletter_signup_field(apps, schema_editor): + field = models.BooleanField(default=True) + field.column = "newsletter_signup" + schema_editor.remove_field( + User, + field, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0023_merge_0021_groupdesc_0022_confirmaccesstoken"), + ] + + operations = [ + migrations.RunPython( + add_newsletter_signup_field, remove_newsletter_signup_field + ) + ] diff --git a/extensions/ots/user_newsletter_signup/migrations/__init__.py b/extensions/ots/user_newsletter_signup/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/extensions/ots/user_newsletter_signup/models.py b/extensions/ots/user_newsletter_signup/models.py new file mode 100644 index 0000000000..b04c54709e --- /dev/null +++ b/extensions/ots/user_newsletter_signup/models.py @@ -0,0 +1,6 @@ +from django.db import models + +# from hypha.apply.users.forms import ProfileForm +from hypha.apply.users.models import User + +User.add_to_class("newsletter_signup", models.BooleanField(default=True)) diff --git a/extensions/ots/user_newsletter_signup/signals.py b/extensions/ots/user_newsletter_signup/signals.py new file mode 100644 index 0000000000..2458003d4f --- /dev/null +++ b/extensions/ots/user_newsletter_signup/signals.py @@ -0,0 +1,24 @@ +from django.db.models.signals import post_save, pre_save +from django.dispatch import receiver + +from hypha.apply.users.models import User + + +@receiver(post_save, sender=User) +def user_post_save(sender, instance, created, update_fields, **kwargs): + if created: + if instance.newsletter_signup: + print("User added with newsletter being signed up!") + + +@receiver(pre_save, sender=User) +def user_pre_save(sender, instance, update_fields, **kwargs): + if ( + instance.id + and User.objects.get(id=instance.id).newsletter_signup + != instance.newsletter_signup + ): + if instance.newsletter_signup: + print("Someone is opting in to the newsletter signup") + else: + print("Someone is opting out of the newsletter signup!") diff --git a/extensions/ots/user_newsletter_signup/static/css/user_newsletter_signup.css b/extensions/ots/user_newsletter_signup/static/css/user_newsletter_signup.css new file mode 100644 index 0000000000..ebbd5fa274 --- /dev/null +++ b/extensions/ots/user_newsletter_signup/static/css/user_newsletter_signup.css @@ -0,0 +1,3 @@ +input.profile_newsletter_signup { + border: 5px solid green; +} diff --git a/extensions/ots/user_newsletter_signup/static/js/user_newsletter_signup.js b/extensions/ots/user_newsletter_signup/static/js/user_newsletter_signup.js new file mode 100644 index 0000000000..87bf811c8f --- /dev/null +++ b/extensions/ots/user_newsletter_signup/static/js/user_newsletter_signup.js @@ -0,0 +1,9 @@ +"use strict"; + +(function ($) { + $(function () { + $(".profile_newsletter_signup").click(function () { + window.alert("You clicked me!"); + }); + }); +})(jQuery); diff --git a/extensions/ots/user_newsletter_signup/template_hooks.py b/extensions/ots/user_newsletter_signup/template_hooks.py new file mode 100644 index 0000000000..5252e5e971 --- /dev/null +++ b/extensions/ots/user_newsletter_signup/template_hooks.py @@ -0,0 +1,15 @@ +from django.template.loader import render_to_string + + +def wagtail_user_edit(context, *args, **kwargs): + return render_to_string( + "wagtail_user_edit.html", + context.flatten(), + ) + + +def hypha_extension_head(context, *args, **kwargs): + return render_to_string( + "hypha_extension_head.html", + context.flatten(), + ) diff --git a/extensions/ots/user_newsletter_signup/templates/hypha_extension_head.html b/extensions/ots/user_newsletter_signup/templates/hypha_extension_head.html new file mode 100644 index 0000000000..901215dded --- /dev/null +++ b/extensions/ots/user_newsletter_signup/templates/hypha_extension_head.html @@ -0,0 +1,3 @@ +{% load static %} + + diff --git a/extensions/ots/user_newsletter_signup/templates/wagtail_user_edit.html b/extensions/ots/user_newsletter_signup/templates/wagtail_user_edit.html new file mode 100644 index 0000000000..04138a67aa --- /dev/null +++ b/extensions/ots/user_newsletter_signup/templates/wagtail_user_edit.html @@ -0,0 +1 @@ +{% include "wagtailadmin/shared/field_as_li.html" with field=form.newsletter_signup %} diff --git a/extensions/ots/user_newsletter_signup/tests/__init__.py b/extensions/ots/user_newsletter_signup/tests/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/extensions/ots/user_newsletter_signup/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/extensions/ots/user_newsletter_signup/tests/test_newsletter.py b/extensions/ots/user_newsletter_signup/tests/test_newsletter.py new file mode 100644 index 0000000000..b000a20c2c --- /dev/null +++ b/extensions/ots/user_newsletter_signup/tests/test_newsletter.py @@ -0,0 +1,18 @@ +from django.test import TestCase +from django.urls import reverse + +from hypha.apply.users.tests.factories import SuperUserFactory, UserFactory + + +class TestProfileViewNewsletter(TestCase): + def test_newsletter_on_profile_page(self): + user = UserFactory() + self.client.force_login(user) + response = self.client.get(reverse("users:account"), follow=True) + self.assertContains(response, "like to receive occasional emails") + + def test_newsletter_on_user_add_page(self): + user = SuperUserFactory() + self.client.force_login(user) + response = self.client.get(reverse("wagtailusers_users:add"), follow=True) + self.assertContains(response, "newsletter") diff --git a/hypha/apply/users/migrations/0012_set_applicant_group.py b/hypha/apply/users/migrations/0012_set_applicant_group.py index 0358424960..c39463b539 100644 --- a/hypha/apply/users/migrations/0012_set_applicant_group.py +++ b/hypha/apply/users/migrations/0012_set_applicant_group.py @@ -11,7 +11,7 @@ def set_group(apps, schema_editor): User = get_user_model() applicant_group = Group.objects.get(name=APPLICANT_GROUP_NAME) - applicants = User.objects.exclude(applicationsubmission=None) + applicants = User.objects.exclude(applicationsubmission=None).only("groups") for user in applicants: if not user.is_apply_staff: user.groups.add(applicant_group) @@ -21,7 +21,9 @@ def set_group(apps, schema_editor): def unset_group(apps, schema_editor): User = get_user_model() applicant_group = Group.objects.get(name=APPLICANT_GROUP_NAME) - applicants = User.objects.filter(groups__name=APPLICANT_GROUP_NAME).all() + applicants = ( + User.objects.filter(groups__name=APPLICANT_GROUP_NAME).all().only("groups") + ) for user in applicants: user.groups.remove(applicant_group) user.save() diff --git a/hypha/apply/users/templates/wagtailusers/users/create.html b/hypha/apply/users/templates/wagtailusers/users/create.html index c0647508b3..043bba431c 100644 --- a/hypha/apply/users/templates/wagtailusers/users/create.html +++ b/hypha/apply/users/templates/wagtailusers/users/create.html @@ -1,5 +1,6 @@ {% extends "wagtailusers/users/create.html" %} {% load static %} +{% load hooks_tags %} {% block fields %} {% if form.separate_username_field %} @@ -8,6 +9,8 @@ {% include "wagtailadmin/shared/field_as_li.html" with field=form.email %} {% include "wagtailadmin/shared/field_as_li.html" with field=form.full_name %} + {% hook 'wagtail_user_edit' %} + {% comment %} First/last name hidden input with dummy values because.. Wagtail admin See hypha.apply.users.forms.CustomUserCreationForm diff --git a/hypha/apply/users/templates/wagtailusers/users/edit.html b/hypha/apply/users/templates/wagtailusers/users/edit.html index 75a4cea040..7e23c65562 100644 --- a/hypha/apply/users/templates/wagtailusers/users/edit.html +++ b/hypha/apply/users/templates/wagtailusers/users/edit.html @@ -3,6 +3,7 @@ {% load wagtailimages_tags %} {% load users_tags %} {% load static %} +{% load hooks_tags %} {% load i18n %} {% block content %} @@ -45,6 +46,7 @@ {% endif %} {% include "wagtailadmin/shared/field_as_li.html" with field=form.email %} {% include "wagtailadmin/shared/field_as_li.html" with field=form.full_name %} + {% hook 'wagtail_user_edit' %} {% block extra_fields %}{% endblock extra_fields %} {% comment %} diff --git a/hypha/settings/base.py b/hypha/settings/base.py index 1bb31dbd7a..2ec698a6e1 100644 --- a/hypha/settings/base.py +++ b/hypha/settings/base.py @@ -622,3 +622,8 @@ debug=SENTRY_DEBUG, integrations=[DjangoIntegration()], ) + +# We import so we can override settings in base.py +from .extensions import * # noqa + +INSTALLED_APPS.extend(EXTENSION_APPS) diff --git a/hypha/settings/django.py b/hypha/settings/django.py index 8682287c4d..2e48c9840f 100644 --- a/hypha/settings/django.py +++ b/hypha/settings/django.py @@ -7,6 +7,7 @@ # Application definition INSTALLED_APPS = [ "scout_apm.django", + "extensions.django_hooks", "hypha.cookieconsent", "hypha.images", "hypha.core.apps.CoreAppConfig", diff --git a/hypha/settings/extensions.py b/hypha/settings/extensions.py new file mode 100644 index 0000000000..7518a1fa3d --- /dev/null +++ b/hypha/settings/extensions.py @@ -0,0 +1,5 @@ +# Add enabled extensions here +EXTENSION_APPS = ["extensions.ots.user_newsletter_signup"] + +# Add configuration for extensions below here +WAGTAIL_USER_CUSTOM_FIELDS = ["full_name", "newsletter_signup"] diff --git a/hypha/templates/base.html b/hypha/templates/base.html index 189de7ad51..e069f526eb 100644 --- a/hypha/templates/base.html +++ b/hypha/templates/base.html @@ -1,4 +1,4 @@ -{% load i18n static wagtailcore_tags wagtailimages_tags hijack cookieconsent_tags heroicons %} +{% load i18n static wagtailcore_tags wagtailimages_tags hijack cookieconsent_tags heroicons hooks_tags %} {% wagtail_site as current_site %} {% get_current_language as LANGUAGE_CODE %} {% get_current_language_bidi as LANGUAGE_BIDI %} @@ -73,6 +73,7 @@ + {% hook 'hypha_extension_head' %} {% include "includes/head_end.html" %} diff --git a/pyproject.toml b/pyproject.toml index 942ba0d70e..a1dbcb5142 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,8 @@ python_files = [ '*_tests.py', ] testpaths = [ - "hypha" + "hypha", + "extensions/ots/user_newsletter_signup", ] filterwarnings = [ 'ignore::DeprecationWarning',