diff --git a/home/blocks.py b/home/blocks.py index f992ccb43..863c870d4 100644 --- a/home/blocks.py +++ b/home/blocks.py @@ -1,5 +1,3 @@ -from django.conf import settings -from django.forms.utils import flatatt from django.utils.html import format_html, format_html_join from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ @@ -23,7 +21,8 @@ def get_context(self, value, parent_context=None): return context class Meta: - template = 'blocks/media.html' + icon = "media" + template = "blocks/media.html" class SocialMediaLinkBlock(blocks.StructBlock): @@ -52,6 +51,9 @@ class Meta: class EmbeddedQuestionnaireBlock(blocks.StructBlock): direct_display = blocks.BooleanBlock(required=False) + class Meta: + icon = "form" + class EmbeddedPollBlock(EmbeddedQuestionnaireBlock): poll = EmbeddedQuestionnaireChooserBlock(target_model='questionnaires.Poll') @@ -119,6 +121,7 @@ def get_context(self, value, parent_context=None): return context class Meta: + icon = "link" template = 'blocks/page_button.html' @@ -137,7 +140,8 @@ def get_context(self, value, parent_context=None): return context class Meta: - template = 'blocks/article.html' + icon = "pick" + template = "blocks/article.html" class NumberedListBlock(blocks.ListBlock): @@ -152,6 +156,9 @@ def render_basic(self, value, context=None): ) return format_html("
    {0}
", children) + class Meta: + icon = "list-ol" + class RawHTMLBlock(blocks.RawHTMLBlock): def render_basic(self, value, context=None): @@ -162,20 +169,39 @@ def render_basic(self, value, context=None): class DownloadButtonBlock(blocks.StructBlock): available_text = blocks.CharBlock( - help_text=_('This text appears when it is possible for the user to install the app on their phone.')) + help_text=_( + "This text appears when it is possible for the user to install the app on" + " their phone" + ) + ) unavailable_text = blocks.CharBlock( required=False, help_text=_( - 'This text appears when the user is using a feature phone and thus cannot install the app ' - '(the button will be disabled in this case). [Currently not implemented]'), - form_classname='red-help-text') + "This text appears when the user is using a feature phone and thus cannot" + " install the app (the button will be disabled in this case)." + " [Currently not implemented]" + ), + form_classname="red-help-text", + ) offline_text = blocks.CharBlock( - required=False, help_text=_( - 'This text appears when the user is navigating the site via the offline app and ' - 'thus it doesn\'t make sense to install the offline app again ' - '(the button will be disabled in this case).')) - page = PageChooserBlock(target_model='wagtailcore.Page') - description = blocks.RichTextBlock(features=settings.WAGTAIL_RICH_TEXT_FIELD_FEATURES) + required=False, + help_text=_( + "This text appears when the user is navigating the site via the offline app" + " and thus it does not make sense to install the offline app again (the" + " button will be disabled in this case)." + ), + ) + page = PageChooserBlock(target_model="wagtailcore.Page") + description = blocks.RichTextBlock() class Meta: - template = 'blocks/download_button.html' + icon = "download" + template = "blocks/download_button.html" + + +def heading_block(): + return blocks.CharBlock( + icon="h1", + form_classname="full title", + template="blocks/heading.html", + ) diff --git a/home/migrations/0056_auto_20240603_1005.py b/home/migrations/0056_auto_20240603_1005.py new file mode 100644 index 000000000..1e858df5a --- /dev/null +++ b/home/migrations/0056_auto_20240603_1005.py @@ -0,0 +1,536 @@ +# Generated by Django 3.2.25 on 2024-06-03 10:05 + +from django.db import migrations, models +import home.blocks +import messaging.blocks +import wagtail.blocks +import wagtail.fields +import wagtail.images.blocks +import wagtailmarkdown.blocks + + +class Migration(migrations.Migration): + + dependencies = [ + ("home", "0055_enable_use_json_field"), + ] + + operations = [ + migrations.AlterField( + model_name="article", + name="body", + field=wagtail.fields.StreamField( + [ + ( + "heading", + wagtail.blocks.CharBlock( + form_classname="full title", + icon="h1", + template="blocks/heading.html", + ), + ), + ("paragraph", wagtail.blocks.RichTextBlock()), + ("markdown", wagtailmarkdown.blocks.MarkdownBlock()), + ("paragraph_v1_legacy", home.blocks.RawHTMLBlock(icon="code")), + ( + "image", + wagtail.images.blocks.ImageChooserBlock( + template="blocks/image.html" + ), + ), + ( + "list", + wagtail.blocks.ListBlock( + wagtailmarkdown.blocks.MarkdownBlock(), icon="list-ul" + ), + ), + ( + "numbered_list", + home.blocks.NumberedListBlock( + wagtailmarkdown.blocks.MarkdownBlock() + ), + ), + ( + "page_button", + wagtail.blocks.StructBlock( + [ + ("page", wagtail.blocks.PageChooserBlock()), + ( + "text", + wagtail.blocks.CharBlock( + max_length=255, required=False + ), + ), + ] + ), + ), + ( + "embedded_poll", + wagtail.blocks.StructBlock( + [ + ( + "direct_display", + wagtail.blocks.BooleanBlock(required=False), + ), + ( + "poll", + home.blocks.EmbeddedQuestionnaireChooserBlock( + page_type=["questionnaires.Poll"] + ), + ), + ] + ), + ), + ( + "embedded_survey", + wagtail.blocks.StructBlock( + [ + ( + "direct_display", + wagtail.blocks.BooleanBlock(required=False), + ), + ( + "survey", + home.blocks.EmbeddedQuestionnaireChooserBlock( + page_type=["questionnaires.Survey"] + ), + ), + ] + ), + ), + ( + "embedded_quiz", + wagtail.blocks.StructBlock( + [ + ( + "direct_display", + wagtail.blocks.BooleanBlock(required=False), + ), + ( + "quiz", + home.blocks.EmbeddedQuestionnaireChooserBlock( + page_type=["questionnaires.Quiz"] + ), + ), + ] + ), + ), + ("media", home.blocks.MediaBlock()), + ( + "chat_bot", + wagtail.blocks.StructBlock( + [ + ("subject", wagtail.blocks.CharBlock()), + ("button_text", wagtail.blocks.CharBlock()), + ("trigger_string", wagtail.blocks.CharBlock()), + ( + "channel", + messaging.blocks.ChatBotChannelChooserBlock(), + ), + ] + ), + ), + ( + "download", + wagtail.blocks.StructBlock( + [ + ( + "available_text", + wagtail.blocks.CharBlock( + help_text="This text appears when it is possible for the user to install the app on their phone" + ), + ), + ( + "unavailable_text", + wagtail.blocks.CharBlock( + form_classname="red-help-text", + help_text="This text appears when the user is using a feature phone and thus cannot install the app (the button will be disabled in this case). [Currently not implemented]", + required=False, + ), + ), + ( + "offline_text", + wagtail.blocks.CharBlock( + help_text="This text appears when the user is navigating the site via the offline app and thus it does not make sense to install the offline app again (the button will be disabled in this case).", + required=False, + ), + ), + ( + "page", + wagtail.blocks.PageChooserBlock( + page_type=["wagtailcore.Page"] + ), + ), + ("description", wagtail.blocks.RichTextBlock()), + ] + ), + ), + ], + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="homepage", + name="home_featured_content", + field=wagtail.fields.StreamField( + [ + ( + "page_button", + wagtail.blocks.StructBlock( + [ + ("page", wagtail.blocks.PageChooserBlock()), + ( + "text", + wagtail.blocks.CharBlock( + max_length=255, required=False + ), + ), + ] + ), + ), + ( + "embedded_poll", + wagtail.blocks.StructBlock( + [ + ( + "direct_display", + wagtail.blocks.BooleanBlock(required=False), + ), + ( + "poll", + home.blocks.EmbeddedQuestionnaireChooserBlock( + page_type=["questionnaires.Poll"] + ), + ), + ] + ), + ), + ( + "embedded_survey", + wagtail.blocks.StructBlock( + [ + ( + "direct_display", + wagtail.blocks.BooleanBlock(required=False), + ), + ( + "survey", + home.blocks.EmbeddedQuestionnaireChooserBlock( + page_type=["questionnaires.Survey"] + ), + ), + ] + ), + ), + ( + "embedded_quiz", + wagtail.blocks.StructBlock( + [ + ( + "direct_display", + wagtail.blocks.BooleanBlock(required=False), + ), + ( + "quiz", + home.blocks.EmbeddedQuestionnaireChooserBlock( + page_type=["questionnaires.Quiz"] + ), + ), + ] + ), + ), + ( + "article", + wagtail.blocks.StructBlock( + [ + ( + "display_section_title", + wagtail.blocks.BooleanBlock(required=False), + ), + ( + "article", + wagtail.blocks.PageChooserBlock( + page_type=["home.Article"] + ), + ), + ] + ), + ), + ( + "download", + wagtail.blocks.StructBlock( + [ + ( + "available_text", + wagtail.blocks.CharBlock( + help_text="This text appears when it is possible for the user to install the app on their phone" + ), + ), + ( + "unavailable_text", + wagtail.blocks.CharBlock( + form_classname="red-help-text", + help_text="This text appears when the user is using a feature phone and thus cannot install the app (the button will be disabled in this case). [Currently not implemented]", + required=False, + ), + ), + ( + "offline_text", + wagtail.blocks.CharBlock( + help_text="This text appears when the user is navigating the site via the offline app and thus it does not make sense to install the offline app again (the button will be disabled in this case).", + required=False, + ), + ), + ( + "page", + wagtail.blocks.PageChooserBlock( + page_type=["wagtailcore.Page"] + ), + ), + ("description", wagtail.blocks.RichTextBlock()), + ] + ), + ), + ], + blank=True, + null=True, + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="offlinecontentindexpage", + name="body", + field=wagtail.fields.StreamField( + [ + ( + "heading", + wagtail.blocks.CharBlock( + form_classname="full title", + icon="h1", + template="blocks/heading.html", + ), + ), + ("paragraph", wagtail.blocks.RichTextBlock()), + ("markdown", wagtailmarkdown.blocks.MarkdownBlock()), + ("paragraph_v1_legacy", home.blocks.RawHTMLBlock(icon="code")), + ( + "image", + wagtail.images.blocks.ImageChooserBlock( + template="blocks/image.html" + ), + ), + ( + "list", + wagtail.blocks.ListBlock( + wagtailmarkdown.blocks.MarkdownBlock(), icon="list-ul" + ), + ), + ( + "numbered_list", + home.blocks.NumberedListBlock( + wagtailmarkdown.blocks.MarkdownBlock() + ), + ), + ( + "page_button", + wagtail.blocks.StructBlock( + [ + ("page", wagtail.blocks.PageChooserBlock()), + ( + "text", + wagtail.blocks.CharBlock( + max_length=255, required=False + ), + ), + ] + ), + ), + ( + "embedded_poll", + wagtail.blocks.StructBlock( + [ + ( + "direct_display", + wagtail.blocks.BooleanBlock(required=False), + ), + ( + "poll", + home.blocks.EmbeddedQuestionnaireChooserBlock( + page_type=["questionnaires.Poll"] + ), + ), + ] + ), + ), + ( + "embedded_survey", + wagtail.blocks.StructBlock( + [ + ( + "direct_display", + wagtail.blocks.BooleanBlock(required=False), + ), + ( + "survey", + home.blocks.EmbeddedQuestionnaireChooserBlock( + page_type=["questionnaires.Survey"] + ), + ), + ] + ), + ), + ( + "embedded_quiz", + wagtail.blocks.StructBlock( + [ + ( + "direct_display", + wagtail.blocks.BooleanBlock(required=False), + ), + ( + "quiz", + home.blocks.EmbeddedQuestionnaireChooserBlock( + page_type=["questionnaires.Quiz"] + ), + ), + ] + ), + ), + ("media", home.blocks.MediaBlock()), + ( + "chat_bot", + wagtail.blocks.StructBlock( + [ + ("subject", wagtail.blocks.CharBlock()), + ("button_text", wagtail.blocks.CharBlock()), + ("trigger_string", wagtail.blocks.CharBlock()), + ( + "channel", + messaging.blocks.ChatBotChannelChooserBlock(), + ), + ] + ), + ), + ( + "download", + wagtail.blocks.StructBlock( + [ + ( + "available_text", + wagtail.blocks.CharBlock( + help_text="This text appears when it is possible for the user to install the app on their phone" + ), + ), + ( + "unavailable_text", + wagtail.blocks.CharBlock( + form_classname="red-help-text", + help_text="This text appears when the user is using a feature phone and thus cannot install the app (the button will be disabled in this case). [Currently not implemented]", + required=False, + ), + ), + ( + "offline_text", + wagtail.blocks.CharBlock( + help_text="This text appears when the user is navigating the site via the offline app and thus it does not make sense to install the offline app again (the button will be disabled in this case).", + required=False, + ), + ), + ( + "page", + wagtail.blocks.PageChooserBlock( + page_type=["wagtailcore.Page"] + ), + ), + ("description", wagtail.blocks.RichTextBlock()), + ] + ), + ), + ], + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="section", + name="body", + field=wagtail.fields.StreamField( + [ + ( + "download", + wagtail.blocks.StructBlock( + [ + ( + "available_text", + wagtail.blocks.CharBlock( + help_text="This text appears when it is possible for the user to install the app on their phone" + ), + ), + ( + "unavailable_text", + wagtail.blocks.CharBlock( + form_classname="red-help-text", + help_text="This text appears when the user is using a feature phone and thus cannot install the app (the button will be disabled in this case). [Currently not implemented]", + required=False, + ), + ), + ( + "offline_text", + wagtail.blocks.CharBlock( + help_text="This text appears when the user is navigating the site via the offline app and thus it does not make sense to install the offline app again (the button will be disabled in this case).", + required=False, + ), + ), + ( + "page", + wagtail.blocks.PageChooserBlock( + page_type=["wagtailcore.Page"] + ), + ), + ("description", wagtail.blocks.RichTextBlock()), + ] + ), + ) + ], + blank=True, + null=True, + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="sitesettings", + name="media_file_size_threshold", + field=models.IntegerField( + default=9437184, + help_text="Show warning if uploaded media file size is greater than this in bytes. Default is 9 MB (9,437,184 bytes).", + ), + ), + migrations.AlterField( + model_name="sitesettings", + name="social_media_content_sharing_button", + field=wagtail.fields.StreamField( + [ + ( + "social_media_content_sharing_button", + wagtail.blocks.StructBlock( + [ + ("platform", wagtail.blocks.CharBlock(max_length=255)), + ( + "is_active", + wagtail.blocks.BooleanBlock(required=False), + ), + ( + "image", + wagtail.images.blocks.ImageChooserBlock( + required=False, template="blocks/image.html" + ), + ), + ] + ), + ) + ], + blank=True, + null=True, + use_json_field=True, + ), + ), + ] diff --git a/home/models.py b/home/models.py index b3cd0c413..1eee28153 100644 --- a/home/models.py +++ b/home/models.py @@ -14,7 +14,6 @@ from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ from modelcluster.contrib.taggit import ClusterTaggableManager -from iogt.settings.base import WAGTAIL_CONTENT_LANGUAGES from modelcluster.fields import ParentalKey from rest_framework import status from taggit.models import TaggedItemBase @@ -25,7 +24,7 @@ ObjectList, TabbedInterface, ) -from wagtail.contrib.settings.models import BaseSetting +from wagtail.contrib.settings.models import BaseSiteSetting from wagtail.contrib.settings.registry import register_setting from wagtail import blocks from wagtail.fields import StreamField @@ -42,8 +41,18 @@ from messaging.blocks import ChatBotButtonBlock from comments.models import CommentableMixin from home.blocks import ( - MediaBlock, SocialMediaLinkBlock, SocialMediaShareButtonBlock, EmbeddedPollBlock, EmbeddedSurveyBlock, - EmbeddedQuizBlock, PageButtonBlock, NumberedListBlock, RawHTMLBlock, ArticleBlock, DownloadButtonBlock, + ArticleBlock, + DownloadButtonBlock, + EmbeddedPollBlock, + EmbeddedSurveyBlock, + EmbeddedQuizBlock, + heading_block, + MediaBlock, + PageButtonBlock, + NumberedListBlock, + RawHTMLBlock, + SocialMediaLinkBlock, + SocialMediaShareButtonBlock, ) from .forms import SectionPageForm from .mixins import PageUtilsMixin, TitleIconMixin @@ -323,20 +332,20 @@ class AbstractArticle(Page, PageUtilsMixin, CommentableMixin, TitleIconMixin): body = StreamField( [ - ('heading', blocks.CharBlock(form_classname="full title", template='blocks/heading.html')), - ('paragraph', blocks.RichTextBlock(features=settings.WAGTAIL_RICH_TEXT_FIELD_FEATURES)), - ('markdown', MarkdownBlock(icon='code')), - ('paragraph_v1_legacy', RawHTMLBlock(icon='code')), - ('image', ImageChooserBlock(template='blocks/image.html')), - ('list', blocks.ListBlock(MarkdownBlock(icon='code'))), - ('numbered_list', NumberedListBlock(MarkdownBlock(icon='code'))), - ('page_button', PageButtonBlock()), - ('embedded_poll', EmbeddedPollBlock()), - ('embedded_survey', EmbeddedSurveyBlock()), - ('embedded_quiz', EmbeddedQuizBlock()), - ('media', MediaBlock(icon='media')), - ('chat_bot', ChatBotButtonBlock()), - ('download', DownloadButtonBlock()), + ("heading", heading_block()), + ("paragraph", blocks.RichTextBlock()), + ("markdown", MarkdownBlock()), + ("paragraph_v1_legacy", RawHTMLBlock(icon='code')), + ("image", ImageChooserBlock(template='blocks/image.html')), + ("list", blocks.ListBlock(MarkdownBlock(), icon="list-ul")), + ("numbered_list", NumberedListBlock(MarkdownBlock())), + ("page_button", PageButtonBlock()), + ("embedded_poll", EmbeddedPollBlock()), + ("embedded_survey", EmbeddedSurveyBlock()), + ("embedded_quiz", EmbeddedQuizBlock()), + ("media", MediaBlock()), + ("chat_bot", ChatBotButtonBlock()), + ("download", DownloadButtonBlock()), ], use_json_field=True, ) @@ -599,7 +608,7 @@ def get_url(self, request=None, current_site=None): @register_setting -class SiteSettings(BaseSetting): +class SiteSettings(BaseSiteSetting): logo = models.ForeignKey( 'wagtailimages.Image', null=True, @@ -686,16 +695,28 @@ class SiteSettings(BaseSetting): blank=True, use_json_field=True, ) - social_media_content_sharing_button = StreamField([ - ('social_media_content_sharing_button', SocialMediaShareButtonBlock()), - ], null=True, blank=True) + social_media_content_sharing_button = StreamField( + [ + ("social_media_content_sharing_button", SocialMediaShareButtonBlock()), + ], + null=True, + blank=True, + use_json_field=True, + ) media_file_size_threshold = models.IntegerField( default=9437184, - help_text=_('Show warning if uploaded media file size is greater than this in bytes. Default is 9 MB')) + help_text=_( + "Show warning if uploaded media file size is greater than this in bytes." + " Default is 9 MB (9,437,184 bytes)." + ) + ) allow_anonymous_comment = models.BooleanField(default=False) - registration_survey = models.ForeignKey('questionnaires.Survey', null=True, - blank=True, - on_delete=models.SET_NULL) + registration_survey = models.ForeignKey( + "questionnaires.Survey", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) # Obsolete - Web Light service discontinued Dec 2022 opt_in_to_google_web_light = models.BooleanField(default=False) @@ -959,7 +980,7 @@ class ManifestSettings(models.Model): ) language = models.CharField( max_length=3, - choices=WAGTAIL_CONTENT_LANGUAGES, + choices=settings.WAGTAIL_CONTENT_LANGUAGES, default="en", verbose_name=_("Language"), help_text=_("Choose language"), @@ -1042,7 +1063,7 @@ class Meta: @register_setting -class ThemeSettings(BaseSetting): +class ThemeSettings(BaseSiteSetting): global_background_color = models.CharField( null=True, blank=True, help_text='The background color of the website', max_length=8, default='#FFFFFF') diff --git a/home/wagtail_hooks.py b/home/wagtail_hooks.py index 555ff711f..94bef83fa 100644 --- a/home/wagtail_hooks.py +++ b/home/wagtail_hooks.py @@ -10,8 +10,7 @@ from django.utils.translation import gettext_lazy as _ from wagtail import __version__ from wagtail.admin import widgets as wagtailadmin_widgets -from wagtail.admin.menu import MenuItem, SubmenuMenuItem -from wagtail.contrib.modeladmin.menus import SubMenu +from wagtail.admin.menu import Menu, MenuItem, SubmenuMenuItem from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register from wagtail import hooks from wagtail.models import Page, PageViewRestriction @@ -49,7 +48,10 @@ def update_menu_items(request, menu_items): if item.name == "forms": item.label = _("Form Data") if item.name == 'translations': - item.url = f'{TranslationEntryAdmin().url_helper.get_action_url("index")}?limited=yes' + item.url = ( + TranslationEntryAdmin().url_helper.get_action_url("index") + + "?limited=yes" + ) if item.name == 'community-comment-moderations': menu_items.remove(item) @@ -87,6 +89,7 @@ def global_admin_css(): static("css/global/admin.css"), ) + @hooks.register("insert_global_admin_css") def import_fontawesome_stylesheets(): return "\n".join( @@ -118,7 +121,7 @@ def page_listing_buttons(page, page_perms, is_parent=False, next_url=None): yield wagtailadmin_widgets.PageListingButton( _('Sort child pages'), '?ordering=ord', - attrs={'title': _("Change ordering of child pages of '%(title)s'") % {'title': page.get_admin_display_title()}}, + attrs={"title": _("Change ordering of child pages of '%(title)s'") % {'title': page.get_admin_display_title()}}, priority=60 ) @@ -138,7 +141,7 @@ def about(): return SubmenuMenuItem( label="About", - menu=SubMenu(items), + menu=Menu(items=items), icon_name="info-circle", order=999999, ) @@ -188,7 +191,11 @@ class TranslationEntryAdmin(ModelAdmin): menu_label = 'Translations' menu_icon = 'edit' list_display = ('original', 'language', 'translation',) - list_filter = ('language', LimitedTranslatableStringsFilter, MissingTranslationsFilter) + list_filter = ( + 'language', + LimitedTranslatableStringsFilter, + MissingTranslationsFilter + ) search_fields = ('original', 'translation',) index_template_name = 'modeladmin/translation_manager/translationentry/index.html' menu_order = 601 diff --git a/iogt/settings/base.py b/iogt/settings/base.py index b6d91eda1..dbddb05b8 100644 --- a/iogt/settings/base.py +++ b/iogt/settings/base.py @@ -410,15 +410,26 @@ WAGTAILMENUS_FLAT_MENU_ITEMS_RELATED_NAME = 'iogt_flat_menu_items' -WAGTAIL_RICH_TEXT_FIELD_FEATURES = [ - 'h2', 'h3', 'h4', - 'bold', 'italic', - 'ol', 'ul', - 'hr', - 'link', - 'document-link', - 'image', -] +WAGTAILADMIN_RICH_TEXT_EDITORS = { + "default": { + "WIDGET": "wagtail.admin.rich_text.DraftailRichTextArea", + "OPTIONS": { + "features": [ + "h2", + "h3", + "h4", + "bold", + "italic", + "ol", + "ul", + "hr", + "link", + "document-link", + "image", + ], + } + }, +} # Search results SEARCH_RESULTS_PER_PAGE = 10 @@ -435,10 +446,12 @@ TRANSLATIONS_PROJECT_BASE_DIR = BASE_DIR WAGTAILTRANSFER_LOOKUP_FIELDS = { + "contenttypes.contenttype": ["app_label", "model"], 'taggit.tag': ['slug'], 'wagtailcore.locale': ['language_code'], 'iogt_users.user': ['username'], } +WAGTAILTRANSFER_NO_FOLLOW_MODELS = ["wagtailcore.page", "contenttypes.contenttype"] WAGTAILTRANSFER_SECRET_KEY = os.getenv('WAGTAILTRANSFER_SECRET_KEY') WAGTAILTRANSFER_SHOW_ERROR_FOR_REFERENCED_PAGES = True WAGTAILTRANSFER_SOURCES = { diff --git a/iogt/settings/production.py b/iogt/settings/production.py index 9dc3dfbd7..c0f0f15bb 100644 --- a/iogt/settings/production.py +++ b/iogt/settings/production.py @@ -33,7 +33,7 @@ }, } -SITE_VERSION = '2.12.0' +SITE_VERSION = '2.13.0-rc.1' try: from .local import * diff --git a/iogt_users/models.py b/iogt_users/models.py index 801eee996..4c10639d1 100644 --- a/iogt_users/models.py +++ b/iogt_users/models.py @@ -1,42 +1,34 @@ -import base64 - -from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractUser from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver -from rest_framework_simplejwt.tokens import RefreshToken class User(AbstractUser): - first_name = models.CharField('first name', max_length=150, null=True, - blank=True) - last_name = models.CharField('last name', max_length=150, null=True, - blank=True) - display_name = models.CharField('display name', max_length=255, null=True, blank=True) + first_name = models.CharField( + "first name", + max_length=150, + null=True, + blank=True, + ) + last_name = models.CharField( + "last name", + max_length=150, + null=True, + blank=True, + ) + display_name = models.CharField( + "display name", + max_length=255, + null=True, + blank=True, + ) email = models.EmailField('email address', null=True, blank=True) terms_accepted = models.BooleanField(default=False) - has_filled_registration_survey = models.BooleanField(default=False) has_viewed_registration_survey = models.BooleanField(default=False) - interactive_uuid = models.CharField(max_length=255, null=True, blank=True) - - @property - def is_rapidpro_bot_user(self): - return self.groups.filter(name=settings.RAPIDPRO_BOT_GROUP_NAME).exists() - - @classmethod - def get_rapidpro_bot_auth_tokens(cls): - users = cls.objects.filter(groups__name=settings.RAPIDPRO_BOT_GROUP_NAME) - - tokens = {} - for user in users: - tokens[user.username] = f'Bearer {RefreshToken.for_user(user).access_token}' - - return tokens - read_articles = models.ManyToManyField(to='home.Article') @classmethod diff --git a/messaging/blocks.py b/messaging/blocks.py index 550370b6a..5ad8159c5 100644 --- a/messaging/blocks.py +++ b/messaging/blocks.py @@ -1,12 +1,12 @@ -from django import forms from wagtail import blocks -from .models import ChatbotChannel +from messaging.views import chatbot_channel_viewset -class ChatBotChannelChooserBlock(blocks.ChooserBlock): - target_model = ChatbotChannel - widget = forms.Select +ChatBotChannelChooserBlock = chatbot_channel_viewset.get_block_class( + name="ChatBotChannelChooserBlock", + module_path="messaging.blocks", +) class ChatBotButtonBlock(blocks.StructBlock): @@ -16,5 +16,5 @@ class ChatBotButtonBlock(blocks.StructBlock): channel = ChatBotChannelChooserBlock() class Meta: - icon = 'code' - template = 'messaging/blocks/chatbot_button.html' + icon = "mail" + template = "messaging/blocks/chatbot_button.html" diff --git a/messaging/chat.py b/messaging/chat.py index 9ed0d1c0f..ac9c2d656 100644 --- a/messaging/chat.py +++ b/messaging/chat.py @@ -6,8 +6,9 @@ from django.utils import timezone from webpush import send_user_notification -from .models import Message, Thread, UserThread +from messaging.models import Message, Thread, UserThread from messaging.rapidpro_client import RapidProClient +from messaging.utils import is_chatbot User = get_user_model() logger = logging.getLogger(__name__) @@ -81,7 +82,7 @@ def _parse_rapidpro_message(message_text): def record_reply(self, text, sender, rapidpro_message_id=None, quick_replies=None, mark_unread=True): if quick_replies is None: quick_replies = [] - if not sender.is_rapidpro_bot_user: + if not is_chatbot(sender): client = RapidProClient(self.thread) client.send_reply(text) diff --git a/messaging/management/commands/get_rapidpro_authentication_header_value.py b/messaging/management/commands/get_rapidpro_authentication_header_value.py index a5b2692fa..bb48339ff 100644 --- a/messaging/management/commands/get_rapidpro_authentication_header_value.py +++ b/messaging/management/commands/get_rapidpro_authentication_header_value.py @@ -1,15 +1,15 @@ from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand -User = get_user_model() +from messaging.utils import get_auth_tokens class Command(BaseCommand): """ - This command prints the Authorization Header Value for RapidPro + Print the authorization header for each chatbot user """ - def handle(self, *args, **options): - tokens = User.get_rapidpro_bot_auth_tokens() + tokens = get_auth_tokens() + for username, token in tokens.items(): self.stdout.write(self.style.SUCCESS(f'{username}: {token}')) diff --git a/messaging/templates/messaging/chatbotchannel/index.html b/messaging/templates/messaging/chatbotchannel/index.html index 1090ee08f..d8d9fca5d 100644 --- a/messaging/templates/messaging/chatbotchannel/index.html +++ b/messaging/templates/messaging/chatbotchannel/index.html @@ -1,39 +1,6 @@ {% extends "modeladmin/index.html" %} {% load messaging_tags %} -{% load i18n modeladmin_tags wagtailadmin_tags %} -{% block header %} -
-
-
-
- {% block h1 %} -

- {{ view.get_page_title }} -

- {% endblock %} -
- {% block search %}{% search_form %}{% endblock %} -
- {% block header_extra %} -
- {% if user_can_create %} -
- {% include 'modeladmin/includes/button.html' with button=view.button_helper.add_button %} -
- {% endif %} - {% if view.list_export %} - - {% endif %} -
- {% endblock %} -
- {% render_chatbot_auth_tokens %} -
+{% block extra_rows %} +{% chatbot_auth_tokens %} {% endblock %} diff --git a/messaging/templates/messaging/tags/chatbot_auth_tokens.html b/messaging/templates/messaging/tags/chatbot_auth_tokens.html index 1847c9c40..4aa4cb8db 100644 --- a/messaging/templates/messaging/tags/chatbot_auth_tokens.html +++ b/messaging/templates/messaging/tags/chatbot_auth_tokens.html @@ -1,18 +1,24 @@ -Chatbot Authentication Headers -

- {% for username, token in tokens.items %} -

- {{ username }}: - - +
+
+
+

Authentication headers

+
+ {% for username, token in tokens.items %} +
{{ username }}
+
+ {{ token }} + +
+ {% endfor %} +
+
- {% endfor %} -

+
\ No newline at end of file + diff --git a/messaging/templates/messaging/thread_detail.html b/messaging/templates/messaging/thread_detail.html index 439d333ff..751a04a0d 100644 --- a/messaging/templates/messaging/thread_detail.html +++ b/messaging/templates/messaging/thread_detail.html @@ -10,7 +10,7 @@

{{ thread.subject }}

{% for message in thread_messages %} - {% if message.sender.is_rapidpro_bot_user %} + {% if message.sender|is_chatbot %}
{% else %}
@@ -55,4 +55,4 @@

{{ thread.subject }}

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/messaging/templatetags/messaging_tags.py b/messaging/templatetags/messaging_tags.py index fba022be4..583e7d7d7 100644 --- a/messaging/templatetags/messaging_tags.py +++ b/messaging/templatetags/messaging_tags.py @@ -1,8 +1,10 @@ from django import template -from django.contrib.auth import get_user_model + +import messaging.utils as utils + register = template.Library() -User = get_user_model() + @register.filter def unread(thread, user): @@ -12,17 +14,20 @@ def unread(thread, user): return thread.user_threads.filter(user=user, is_read=False).exists() -@register.inclusion_tag('messaging/tags/quick_reply_form.html') +@register.inclusion_tag("messaging/tags/quick_reply_form.html") def render_quick_reply_form(thread, user, text): return { - 'thread': thread, - 'user': user, - 'text': text, + "thread": thread, + "user": user, + "text": text, } -@register.inclusion_tag('messaging/tags/chatbot_auth_tokens.html') -def render_chatbot_auth_tokens(): - return { - 'tokens': User.get_rapidpro_bot_auth_tokens(), - } +@register.inclusion_tag("messaging/tags/chatbot_auth_tokens.html") +def chatbot_auth_tokens(): + return {"tokens": utils.get_auth_tokens()} + + +@register.filter +def is_chatbot(user): + return utils.is_chatbot(user) diff --git a/messaging/utils.py b/messaging/utils.py new file mode 100644 index 000000000..7b5f22eac --- /dev/null +++ b/messaging/utils.py @@ -0,0 +1,22 @@ +from django.conf import settings +from django.contrib.auth import get_user_model +from rest_framework_simplejwt.tokens import RefreshToken + + +User = get_user_model() + + +def get_auth_tokens(): + users = User.objects.filter(groups__name=settings.RAPIDPRO_BOT_GROUP_NAME) + + return { + user.username: f"Bearer {RefreshToken.for_user(user).access_token}" + for user in users + } + + +def is_chatbot(user) -> bool: + try: + return user.groups.filter(name=settings.RAPIDPRO_BOT_GROUP_NAME).exists() + except Exception: + return False diff --git a/messaging/views.py b/messaging/views.py index 3604c803d..fbad9937c 100644 --- a/messaging/views.py +++ b/messaging/views.py @@ -1,17 +1,18 @@ from django.contrib import messages from django.contrib.auth import get_user_model +from django.contrib.auth.decorators import login_required from django.http import HttpResponseRedirect from django.shortcuts import render, redirect, get_object_or_404 from django.urls import reverse from django.utils.decorators import method_decorator from django.views import View -from django.views.generic import (DeleteView, TemplateView, ) +from django.views.generic import DeleteView, TemplateView +from wagtail.admin.viewsets.chooser import ChooserViewSet from .chat import ChatManager from .forms import MessageReplyForm, NewMessageForm from .models import Thread, UserThread -from django.contrib.auth.decorators import login_required User = get_user_model() @@ -103,3 +104,15 @@ class ThreadDeleteView(DeleteView): def delete(self, request, *args, **kwargs): self.get_object().user_threads.filter(user=request.user).update(is_active=False) return HttpResponseRedirect(reverse("messaging:inbox")) + + +class ChatbotChannelChooserViewSet(ChooserViewSet): + model = "messaging.ChatbotChannel" + icon = "code" + choose_one_text = "Choose a channel" + choose_another_text = "Choose another channel" + edit_item_text = "Edit this channel" + form_fields = ["display_name", "request_url"] + + +chatbot_channel_viewset = ChatbotChannelChooserViewSet("chatbot_channel_chooser") diff --git a/messaging/wagtail_hooks.py b/messaging/wagtail_hooks.py index d50e6536c..93bc83c11 100644 --- a/messaging/wagtail_hooks.py +++ b/messaging/wagtail_hooks.py @@ -1,6 +1,17 @@ +from wagtail import hooks from wagtail.contrib.modeladmin.options import ( - ModelAdminGroup, ModelAdmin, modeladmin_register) -from .models import ChatbotChannel + ModelAdminGroup, + ModelAdmin, + modeladmin_register, +) + +from messaging.models import ChatbotChannel +from messaging.views import chatbot_channel_viewset + + +@hooks.register("register_admin_viewset") +def register_chatbot_channel_viewset(): + return chatbot_channel_viewset class ChatbotChannelAdmin(ModelAdmin): diff --git a/questionnaires/migrations/0031_auto_20240603_1005.py b/questionnaires/migrations/0031_auto_20240603_1005.py new file mode 100644 index 000000000..ed835ba16 --- /dev/null +++ b/questionnaires/migrations/0031_auto_20240603_1005.py @@ -0,0 +1,337 @@ +# Generated by Django 3.2.25 on 2024-06-03 10:05 + +from django.db import migrations +import home.blocks +import questionnaires.blocks +import wagtail.blocks +import wagtail.fields +import wagtail.images.blocks +import wagtailmarkdown.blocks + + +class Migration(migrations.Migration): + + dependencies = [ + ("questionnaires", "0030_enable_use_json_field"), + ] + + operations = [ + migrations.AlterField( + model_name="poll", + name="description", + field=wagtail.fields.StreamField( + [ + ( + "heading", + wagtail.blocks.CharBlock( + form_classname="full title", + icon="h1", + template="blocks/heading.html", + ), + ), + ("paragraph", wagtail.blocks.RichTextBlock()), + ("paragraph_v1_legacy", home.blocks.RawHTMLBlock(icon="code")), + ( + "image", + wagtail.images.blocks.ImageChooserBlock( + template="blocks/image.html" + ), + ), + ("list", wagtailmarkdown.blocks.MarkdownBlock()), + ( + "numbered_list", + home.blocks.NumberedListBlock( + wagtailmarkdown.blocks.MarkdownBlock() + ), + ), + ( + "page_button", + wagtail.blocks.StructBlock( + [ + ("page", wagtail.blocks.PageChooserBlock()), + ( + "text", + wagtail.blocks.CharBlock( + max_length=255, required=False + ), + ), + ] + ), + ), + ], + blank=True, + null=True, + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="poll", + name="terms_and_conditions", + field=wagtail.fields.StreamField( + [ + ("paragraph", wagtail.blocks.RichTextBlock()), + ( + "page_button", + wagtail.blocks.StructBlock( + [ + ("page", wagtail.blocks.PageChooserBlock()), + ( + "text", + wagtail.blocks.CharBlock( + max_length=255, required=False + ), + ), + ] + ), + ), + ], + blank=True, + null=True, + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="poll", + name="thank_you_text", + field=wagtail.fields.StreamField( + [ + ("paragraph", wagtail.blocks.RichTextBlock()), + ("media", home.blocks.MediaBlock()), + ( + "image", + wagtail.images.blocks.ImageChooserBlock( + template="blocks/image.html" + ), + ), + ], + blank=True, + null=True, + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="quiz", + name="description", + field=wagtail.fields.StreamField( + [ + ( + "heading", + wagtail.blocks.CharBlock( + form_classname="full title", + icon="h1", + template="blocks/heading.html", + ), + ), + ("paragraph", wagtail.blocks.RichTextBlock()), + ("paragraph_v1_legacy", home.blocks.RawHTMLBlock(icon="code")), + ( + "image", + wagtail.images.blocks.ImageChooserBlock( + template="blocks/image.html" + ), + ), + ("list", wagtailmarkdown.blocks.MarkdownBlock()), + ( + "numbered_list", + home.blocks.NumberedListBlock( + wagtailmarkdown.blocks.MarkdownBlock() + ), + ), + ( + "page_button", + wagtail.blocks.StructBlock( + [ + ("page", wagtail.blocks.PageChooserBlock()), + ( + "text", + wagtail.blocks.CharBlock( + max_length=255, required=False + ), + ), + ] + ), + ), + ], + blank=True, + null=True, + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="quiz", + name="terms_and_conditions", + field=wagtail.fields.StreamField( + [ + ("paragraph", wagtail.blocks.RichTextBlock()), + ( + "page_button", + wagtail.blocks.StructBlock( + [ + ("page", wagtail.blocks.PageChooserBlock()), + ( + "text", + wagtail.blocks.CharBlock( + max_length=255, required=False + ), + ), + ] + ), + ), + ], + blank=True, + null=True, + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="quiz", + name="thank_you_text", + field=wagtail.fields.StreamField( + [ + ("paragraph", wagtail.blocks.RichTextBlock()), + ("media", home.blocks.MediaBlock()), + ( + "image", + wagtail.images.blocks.ImageChooserBlock( + template="blocks/image.html" + ), + ), + ], + blank=True, + null=True, + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="survey", + name="description", + field=wagtail.fields.StreamField( + [ + ( + "heading", + wagtail.blocks.CharBlock( + form_classname="full title", + icon="h1", + template="blocks/heading.html", + ), + ), + ("paragraph", wagtail.blocks.RichTextBlock()), + ("paragraph_v1_legacy", home.blocks.RawHTMLBlock(icon="code")), + ( + "image", + wagtail.images.blocks.ImageChooserBlock( + template="blocks/image.html" + ), + ), + ("list", wagtailmarkdown.blocks.MarkdownBlock()), + ( + "numbered_list", + home.blocks.NumberedListBlock( + wagtailmarkdown.blocks.MarkdownBlock() + ), + ), + ( + "page_button", + wagtail.blocks.StructBlock( + [ + ("page", wagtail.blocks.PageChooserBlock()), + ( + "text", + wagtail.blocks.CharBlock( + max_length=255, required=False + ), + ), + ] + ), + ), + ], + blank=True, + null=True, + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="survey", + name="terms_and_conditions", + field=wagtail.fields.StreamField( + [ + ("paragraph", wagtail.blocks.RichTextBlock()), + ( + "page_button", + wagtail.blocks.StructBlock( + [ + ("page", wagtail.blocks.PageChooserBlock()), + ( + "text", + wagtail.blocks.CharBlock( + max_length=255, required=False + ), + ), + ] + ), + ), + ], + blank=True, + null=True, + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="survey", + name="thank_you_text", + field=wagtail.fields.StreamField( + [ + ("paragraph", wagtail.blocks.RichTextBlock()), + ("media", home.blocks.MediaBlock()), + ( + "image", + wagtail.images.blocks.ImageChooserBlock( + template="blocks/image.html" + ), + ), + ], + blank=True, + null=True, + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="surveyformfield", + name="skip_logic", + field=questionnaires.blocks.SkipLogicField( + [ + ( + "skip_logic", + wagtail.blocks.StructBlock( + [ + ("choice", wagtail.blocks.CharBlock(required=False)), + ( + "skip_logic", + wagtail.blocks.ChoiceBlock( + choices=[ + ("next", "Next default question"), + ("end", "End of survey"), + ("question", "Another question"), + ], + required=False, + ), + ), + ( + "question", + questionnaires.blocks.QuestionSelectBlock( + help_text="Please save the survey as a draft to populate or update the list of questions.", + required=False, + ), + ), + ] + ), + ) + ], + blank=True, + help_text="Checkbox must include exactly 2 Skip Logic Options: true and false, in that order.", + null=True, + use_json_field=True, + verbose_name="Answer options", + ), + ), + ] diff --git a/questionnaires/models.py b/questionnaires/models.py index 0035aefbd..eb3a2394f 100644 --- a/questionnaires/models.py +++ b/questionnaires/models.py @@ -14,6 +14,7 @@ from wagtailsvg.models import Svg from home.blocks import ( + heading_block, MediaBlock, NumberedListBlock, PageButtonBlock, @@ -63,13 +64,13 @@ class QuestionnairePage(Page, PageUtilsMixin, TitleIconMixin): description = StreamField( [ - ('heading', blocks.CharBlock(form_classname="full title", template='blocks/heading.html')), - ('paragraph', blocks.RichTextBlock(features=settings.WAGTAIL_RICH_TEXT_FIELD_FEATURES)), - ('paragraph_v1_legacy', RawHTMLBlock(icon='code')), - ("image", ImageChooserBlock(template='blocks/image.html')), - ('list', MarkdownBlock(icon='code')), - ('numbered_list', NumberedListBlock(MarkdownBlock(icon='code'))), - ('page_button', PageButtonBlock()), + ("heading", heading_block()), + ("paragraph", blocks.RichTextBlock()), + ("paragraph_v1_legacy", RawHTMLBlock(icon='code')), + ("image", ImageChooserBlock(template="blocks/image.html")), + ("list", MarkdownBlock()), + ("numbered_list", NumberedListBlock(MarkdownBlock())), + ("page_button", PageButtonBlock()), ], null=True, blank=True, @@ -77,9 +78,9 @@ class QuestionnairePage(Page, PageUtilsMixin, TitleIconMixin): ) thank_you_text = StreamField( [ - ("paragraph", blocks.RichTextBlock(features=settings.WAGTAIL_RICH_TEXT_FIELD_FEATURES)), - ("media", MediaBlock(icon="media")), - ("image", ImageChooserBlock(template='blocks/image.html')), + ("paragraph", blocks.RichTextBlock()), + ("media", MediaBlock()), + ("image", ImageChooserBlock(template="blocks/image.html")), ], null=True, blank=True, @@ -107,7 +108,7 @@ class QuestionnairePage(Page, PageUtilsMixin, TitleIconMixin): terms_and_conditions = StreamField( [ - ("paragraph", blocks.RichTextBlock(features=settings.WAGTAIL_RICH_TEXT_FIELD_FEATURES)), + ("paragraph", blocks.RichTextBlock()), ('page_button', PageButtonBlock()), ], null=True, @@ -322,7 +323,7 @@ class SurveyFormField(AbstractFormField): help_text=_('Column header used during CSV export of survey ' 'responses.'), ) - skip_logic = SkipLogicField(null=True, blank=True) + skip_logic = SkipLogicField(null=True, blank=True, use_json_field=True) default_value = models.TextField( verbose_name=_('default value'), blank=True, diff --git a/requirements.dev.txt b/requirements.dev.txt index c5a62c68a..aec6c5814 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -1021,9 +1021,9 @@ urllib3[socks]==1.26.19 \ # elasticsearch # requests # selenium -wagtail==3.0.3 \ - --hash=sha256:111ed9a0a6ff26d5d881d52deb4bf52b627d79a53c43829611752dbb68a9192f \ - --hash=sha256:23b3e541401355ea183372582050ea52b049c956dd5b506197f957bb68423ab3 +wagtail==4.0.4 \ + --hash=sha256:43b35e8e29bcc83a3ddf60f08e8c22fea1e819f338bcba73a4b290771233809a \ + --hash=sha256:8799c7550cc033c8e85aeeaf6f91b5343d7dee72fecd5d4093499af9d3aeaa1d # via # -r requirements.txt # wagtail-cache @@ -1031,9 +1031,9 @@ wagtail==3.0.3 \ # wagtail-localize # wagtail-markdown # wagtailmedia -wagtail-cache==2.1.1 \ - --hash=sha256:1fe3ca20a3cdf23f31fc9df662a52f743084cd7f60bd870950094d63a87f695a \ - --hash=sha256:2ac16921d022a58240a009a19220554e41963c5172e7dfe4ef741ddd887e991f +wagtail-cache==2.2.0 \ + --hash=sha256:a231bc3941eb3ce3e8c73b9aea5d9a54e2f580009a0c87bf86ac59a6545a9609 \ + --hash=sha256:e9e6a8391795d5faab877183c025451c82905ea94b7a0d5eb4aca29ea136499f # via -r requirements.txt wagtail-factories==4.0.0 \ --hash=sha256:3e39ec1cc13b61c6e467f1bf223ce2d134e823fa9fe4dc7e32d0222cc8d350ec \ diff --git a/requirements.in b/requirements.in index 92c7a8628..5ce9dba8b 100644 --- a/requirements.in +++ b/requirements.in @@ -21,10 +21,10 @@ psycopg2~=2.9.9 pyjwt~=2.8.0 redis~=3.0 tqdm~=4.66 -wagtail-cache~=2.1.1 +wagtail-cache~=2.2.0 wagtail-localize~=1.4.0 wagtail-markdown~=0.10.0 -wagtail~=3.0.3 +wagtail~=4.0.4 wagtailmedia~=0.12.0 wagtailmenus==3.1.3 wagtailsvg==0.0.37 diff --git a/requirements.txt b/requirements.txt index d7221cae1..f048e04ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -783,18 +783,18 @@ urllib3==1.26.19 \ # via # elasticsearch # requests -wagtail==3.0.3 \ - --hash=sha256:111ed9a0a6ff26d5d881d52deb4bf52b627d79a53c43829611752dbb68a9192f \ - --hash=sha256:23b3e541401355ea183372582050ea52b049c956dd5b506197f957bb68423ab3 +wagtail==4.0.4 \ + --hash=sha256:43b35e8e29bcc83a3ddf60f08e8c22fea1e819f338bcba73a4b290771233809a \ + --hash=sha256:8799c7550cc033c8e85aeeaf6f91b5343d7dee72fecd5d4093499af9d3aeaa1d # via # -r requirements.in # wagtail-cache # wagtail-localize # wagtail-markdown # wagtailmedia -wagtail-cache==2.1.1 \ - --hash=sha256:1fe3ca20a3cdf23f31fc9df662a52f743084cd7f60bd870950094d63a87f695a \ - --hash=sha256:2ac16921d022a58240a009a19220554e41963c5172e7dfe4ef741ddd887e991f +wagtail-cache==2.2.0 \ + --hash=sha256:a231bc3941eb3ce3e8c73b9aea5d9a54e2f580009a0c87bf86ac59a6545a9609 \ + --hash=sha256:e9e6a8391795d5faab877183c025451c82905ea94b7a0d5eb4aca29ea136499f # via -r requirements.in wagtail-generic-chooser==0.5.1 \ --hash=sha256:135f8cc413d83b82bb8956f625f4b9572aa7139ab3835e2ba2d7e19f2171b354 \