diff --git a/.docker/Procfile b/.docker/Procfile index e386ef56..02ccf946 100644 --- a/.docker/Procfile +++ b/.docker/Procfile @@ -1,2 +1,3 @@ web: python manage.py runserver 0.0.0.0:8000 +scheduler: python manage.py scheduler frontend: npm run start:reload diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e6d8f051..47d76399 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ default_language_version: repos: # Python linting and formatting - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.7.1' # keep version in sync with pyproject.toml + rev: 'v0.7.4' # keep version in sync with pyproject.toml hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/.pylintrc b/.pylintrc index ec09010b..aa563177 100644 --- a/.pylintrc +++ b/.pylintrc @@ -437,6 +437,7 @@ disable=raw-checker-failed, too-many-ancestors, too-few-public-methods, missing-class-docstring, + missing-function-docstring, fixme # note: @@ -445,6 +446,7 @@ disable=raw-checker-failed, # - too-many-ancestors: because of our and Wagtail's use of mixins # - too-few-public-methods: because of Django Meta classes # - missing-class-docstring: mostly because of Django classes. +# - missing-function-docstring: because we can't disable it for trivial functions. # - fixme: because we want to leave TODO notes for future features. # Enable the message, report, category or checker with the given id(s). You can diff --git a/cms/analysis/models.py b/cms/analysis/models.py index 158dbf53..0fc597ab 100644 --- a/cms/analysis/models.py +++ b/cms/analysis/models.py @@ -14,6 +14,7 @@ from wagtail.search import index from cms.analysis.blocks import AnalysisStoryBlock +from cms.bundles.models import BundledPageMixin from cms.core.blocks import HeadlineFiguresBlock from cms.core.fields import StreamField from cms.core.models import BasePage @@ -75,7 +76,7 @@ def previous_releases(self, request: "HttpRequest") -> "TemplateResponse": return response -class AnalysisPage(BasePage): # type: ignore[django-manager-missing] +class AnalysisPage(BundledPageMixin, BasePage): # type: ignore[django-manager-missing] """The analysis page model.""" parent_page_types: ClassVar[list[str]] = ["AnalysisSeries"] @@ -124,6 +125,7 @@ class AnalysisPage(BasePage): # type: ignore[django-manager-missing] show_cite_this_page = models.BooleanField(default=True) content_panels: ClassVar[list["Panel"]] = [ + *BundledPageMixin.panels, MultiFieldPanel( [ TitleFieldPanel("title", help_text=_("Also known as the release edition. e.g. 'November 2024'.")), diff --git a/cms/bundles/__init__.py b/cms/bundles/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cms/bundles/admin_forms.py b/cms/bundles/admin_forms.py new file mode 100644 index 00000000..291b8a9d --- /dev/null +++ b/cms/bundles/admin_forms.py @@ -0,0 +1,43 @@ +from typing import TYPE_CHECKING, Any + +from django import forms +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ + +from .models import Bundle, BundledPageMixin +from .viewsets import BundleChooserWidget + +if TYPE_CHECKING: + from wagtail.models import Page + + +class AddToBundleForm(forms.Form): + """Administrative form used in the 'add to bundle' view.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.page_to_add: Page = kwargs.pop("page_to_add") + + super().__init__(*args, **kwargs) + + self.fields["bundle"] = forms.ModelChoiceField( + queryset=Bundle.objects.editable(), + widget=BundleChooserWidget(), + label=_("Bundle"), + help_text=_("Select a bundle for this page."), + ) + + def clean(self) -> None: + super().clean() + + if not isinstance(self.page_to_add, BundledPageMixin): + # While this form is used the "add to bundle" view which already checks for this, + # it doesn't hurt to trust but verify. + raise ValidationError(_("Pages of this type cannot be added.")) + + bundle = self.cleaned_data.get("bundle") + if bundle and bundle.bundled_pages.filter(page=self.page_to_add).exists(): + message = _("Page '%(page)s' is already in bundle '%(bundle)s'") % { + "page": self.page_to_add.get_admin_display_title(), # type: ignore[attr-defined] + "bundle": bundle, + } + raise ValidationError({"bundle": message}) diff --git a/cms/bundles/admin_urls.py b/cms/bundles/admin_urls.py new file mode 100644 index 00000000..872ceae2 --- /dev/null +++ b/cms/bundles/admin_urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from . import views + +app_name = "bundles" +urlpatterns = [ + path("add//", views.add_to_bundle, name="add_to_bundle"), +] diff --git a/cms/bundles/apps.py b/cms/bundles/apps.py new file mode 100644 index 00000000..2e984971 --- /dev/null +++ b/cms/bundles/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class BundlesAppConfig(AppConfig): + """The bundles app config.""" + + default_auto_field = "django.db.models.AutoField" + name = "cms.bundles" diff --git a/cms/bundles/enums.py b/cms/bundles/enums.py new file mode 100644 index 00000000..4090a914 --- /dev/null +++ b/cms/bundles/enums.py @@ -0,0 +1,18 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class BundleStatus(models.TextChoices): + """The bundle statuses.""" + + PENDING = "PENDING", _("Pending") + IN_REVIEW = "IN_REVIEW", _("In Review") + APPROVED = "APPROVED", _("Approved") + RELEASED = "RELEASED", _("Released") + + +ACTIVE_BUNDLE_STATUSES = [BundleStatus.PENDING, BundleStatus.IN_REVIEW, BundleStatus.APPROVED] +ACTIVE_BUNDLE_STATUS_CHOICES = [ + (BundleStatus[choice].value, BundleStatus[choice].label) for choice in ACTIVE_BUNDLE_STATUSES +] +EDITABLE_BUNDLE_STATUSES = [BundleStatus.PENDING, BundleStatus.IN_REVIEW] diff --git a/cms/bundles/forms.py b/cms/bundles/forms.py new file mode 100644 index 00000000..8c54cd9b --- /dev/null +++ b/cms/bundles/forms.py @@ -0,0 +1,98 @@ +from typing import TYPE_CHECKING, Any + +from django import forms +from django.core.exceptions import ValidationError +from django.utils import timezone +from django.utils.translation import gettext as _ +from wagtail.admin.forms import WagtailAdminModelForm + +from cms.bundles.enums import ACTIVE_BUNDLE_STATUS_CHOICES, EDITABLE_BUNDLE_STATUSES, BundleStatus + +if TYPE_CHECKING: + from .models import Bundle + + +class BundleAdminForm(WagtailAdminModelForm): + """The Bundle admin form used in the add/edit interface.""" + + instance: "Bundle" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Helps the form initialisation. + + - Hides the "Released" status choice as that happens on publish + - disabled/hide the approved at/by fields + """ + super().__init__(*args, **kwargs) + # hide the "Released" status choice + if self.instance.status in EDITABLE_BUNDLE_STATUSES: + self.fields["status"].choices = ACTIVE_BUNDLE_STATUS_CHOICES + elif self.instance.status == BundleStatus.APPROVED.value: + for field_name in self.fields: + if field_name != "status": + self.fields[field_name].disabled = True + + # fully hide and disable the approved_at/by fields to prevent form tampering + self.fields["approved_at"].disabled = True + self.fields["approved_at"].widget = forms.HiddenInput() + self.fields["approved_by"].disabled = True + self.fields["approved_by"].widget = forms.HiddenInput() + + self.original_status = self.instance.status + + def _validate_bundled_pages(self) -> None: + """Validates and tidies up related pages. + + - if we have an empty page reference, remove it form the form data + - ensure the selected page is not in another active bundle. + """ + for idx, form in enumerate(self.formsets["bundled_pages"].forms): + if not form.is_valid(): + continue + + page = form.clean().get("page") + if page is None: + # tidy up in case the page reference is empty + self.formsets["bundled_pages"].forms[idx].cleaned_data["DELETE"] = True + else: + page = page.specific + if page.in_active_bundle and page.active_bundle != self.instance and not form.cleaned_data["DELETE"]: + raise ValidationError( + _("'%(page)s' is already in an active bundle (%(bundle)s)") + % { + "page": page, + "bundle": page.active_bundle, + } + ) + + def clean(self) -> dict[str, Any] | None: + """Validates the form. + + - the bundle cannot be self-approved. That is, someone other than the bundle creator must approve it. + - tidies up/ populates approved at/by + """ + cleaned_data: dict[str, Any] = super().clean() + + self._validate_bundled_pages() + + status = cleaned_data["status"] + if self.instance.status != status: + # the status has changed, let's check + if status == BundleStatus.APPROVED: + if self.instance.created_by_id == self.for_user.pk: + cleaned_data["status"] = self.instance.status + self.add_error("status", ValidationError("You cannot self-approve your own bundle!")) + else: + # the approver is different from the creator, so let's populate the relevant fields. + cleaned_data["approved_at"] = timezone.now() + cleaned_data["approved_by"] = self.for_user + elif self.instance.status == BundleStatus.APPROVED: + cleaned_data["approved_at"] = None + cleaned_data["approved_by"] = None + + if self.cleaned_data["release_calendar_page"] and self.cleaned_data["publication_date"]: + error = _("You must choose either a Release Calendar page or a Publication date, not both.") + self.add_error("release_calendar_page", error) + self.add_error("publication_date", error) + + return cleaned_data diff --git a/cms/bundles/management/__init__.py b/cms/bundles/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cms/bundles/management/commands/__init__.py b/cms/bundles/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cms/bundles/management/commands/publish_bundles.py b/cms/bundles/management/commands/publish_bundles.py new file mode 100644 index 00000000..75ccb008 --- /dev/null +++ b/cms/bundles/management/commands/publish_bundles.py @@ -0,0 +1,121 @@ +import logging +import time +import uuid +from typing import TYPE_CHECKING, Any + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.db import transaction +from django.urls import reverse +from django.utils import timezone +from wagtail.log_actions import log + +from cms.bundles.enums import BundleStatus +from cms.bundles.models import Bundle +from cms.bundles.notifications import notify_slack_of_publication_start, notify_slack_of_publish_end +from cms.release_calendar.enums import ReleaseStatus + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from django.core.management.base import CommandParser + + +class Command(BaseCommand): + """The management command class for bundled publishing.""" + + base_url: str = "" + + def add_arguments(self, parser: "CommandParser") -> None: + parser.add_argument( + "--dry-run", + action="store_true", + dest="dry_run", + default=False, + help="Dry run -- don't change anything.", + ) + + def _update_related_release_calendar_page(self, bundle: Bundle) -> None: + """Updates the release calendar page related to the bundle with the pages in the bundle.""" + content = [] + pages = [] + for page in bundle.get_bundled_pages(): + pages.append( + { + "id": uuid.uuid4(), + "type": "item", + "value": {"page": page.pk, "title": "", "description": "", "external_url": ""}, + } + ) + if pages: + content.append({"type": "release_content", "value": {"title": "Publications", "links": pages}}) + + page = bundle.release_calendar_page + page.content = content + page.status = ReleaseStatus.PUBLISHED + revision = page.save_revision(log_action=True) + revision.publish() + + # TODO: revisit after discussion. + @transaction.atomic + def handle_bundle(self, bundle: Bundle) -> None: + """Manages the bundle publication. + + - published related pages + - updates the release calendar entry + """ + # only provide a URL if we can generate a full one + inspect_url = self.base_url + reverse("bundle:inspect", args=(bundle.pk,)) if self.base_url else None + + logger.info("Publishing bundle=%d", bundle.id) + start_time = time.time() + notify_slack_of_publication_start(bundle, url=inspect_url) + for page in bundle.get_bundled_pages(): + if (revision := page.scheduled_revision) is None: + continue + # just run publish for the revision -- since the approved go + # live datetime is before now it will make the object live + revision.publish(log_action="wagtail.publish.scheduled") + + # update the related release calendar and publish + if bundle.release_calendar_page_id: + self._update_related_release_calendar_page(bundle) + + bundle.status = BundleStatus.RELEASED + bundle.save() + publish_duration = time.time() - start_time + logger.info("Published bundle=%d duration=%.3fms", bundle.id, publish_duration * 1000) + + notify_slack_of_publish_end(bundle, publish_duration, url=inspect_url) + + log(action="wagtail.publish.scheduled", instance=bundle) + + def handle(self, *args: Any, **options: dict[str, Any]) -> None: + dry_run = False + if options["dry_run"]: + self.stdout.write("Will do a dry run.") + dry_run = True + + self.base_url = getattr(settings, "WAGTAILADMIN_BASE_URL", "") + + bundles_to_publish = Bundle.objects.filter(status=BundleStatus.APPROVED, release_date__lte=timezone.now()) + if dry_run: + self.stdout.write("\n---------------------------------") + if bundles_to_publish: + self.stdout.write("Bundles to be published:") + for bundle in bundles_to_publish: + self.stdout.write(f"- {bundle.name}") + bundled_pages = [ + f"{page.get_admin_display_title()} ({page.__class__.__name__})" + for page in bundle.get_bundled_pages().specific() + ] + self.stdout.write(f' Pages: {"\n\t ".join(bundled_pages)}') + + else: + self.stdout.write("No bundles to go live.") + else: + for bundle in bundles_to_publish: + try: + self.handle_bundle(bundle) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("Publish failed bundle=%d", bundle.id) diff --git a/cms/bundles/management/commands/publish_scheduled_without_bundles.py b/cms/bundles/management/commands/publish_scheduled_without_bundles.py new file mode 100644 index 00000000..26e150c5 --- /dev/null +++ b/cms/bundles/management/commands/publish_scheduled_without_bundles.py @@ -0,0 +1,105 @@ +from typing import TYPE_CHECKING, Any + +from django.apps import apps +from django.core.management.base import BaseCommand +from django.utils import timezone +from wagtail.models import DraftStateMixin, Page, Revision + +from cms.bundles.models import BundledPageMixin + +if TYPE_CHECKING: + from django.core.management.base import CommandParser + + +class Command(BaseCommand): + """A copy of Wagtail's publish_scheduled management command that excludes bundled objects. + + @see https://github.com/wagtail/wagtail/blob/main/wagtail/management/commands/publish_scheduled.py + """ + + def add_arguments(self, parser: "CommandParser") -> None: + parser.add_argument( + "--dry-run", + action="store_true", + dest="dry-run", + default=False, + help="Dry run -- don't change anything.", + ) + + def handle(self, *args: Any, **options: dict[str, Any]) -> None: + dry_run = False + if options["dry-run"]: + self.stdout.write("Will do a dry run.") + dry_run = True + + self._unpublish_expired(dry_run) + self._publish_scheduled_without_bundles(dry_run) + + def _unpublish_expired(self, dry_run: bool) -> None: + models = [Page] + models += [ + model for model in apps.get_models() if issubclass(model, DraftStateMixin) and not issubclass(model, Page) + ] + # 1. get all expired objects with live = True + expired_objects = [] + for model in models: + expired_objects += [model.objects.filter(live=True, expire_at__lt=timezone.now()).order_by("expire_at")] + if dry_run: + self.stdout.write("\n---------------------------------") + if expired_objects: + self.stdout.write("Expired objects to be deactivated:") + self.stdout.write("Expiry datetime\t\tModel\t\tSlug\t\tName") + self.stdout.write("---------------\t\t-----\t\t----\t\t----") + for queryset in expired_objects: + if queryset.model is Page: + for obj in queryset: + self.stdout.write( + f'{obj.expire_at.strftime("%Y-%m-%d %H:%M")}\t' + f"{obj.specific_class.__name__}\t{obj.slug}\t{obj.title}" + ) + else: + for obj in queryset: + self.stdout.write( + f'{obj.expire_at.strftime("%Y-%m-%d %H:%M")}\t' + f"{queryset.model.__name__}\t\t\t{obj!s}" + ) + else: + self.stdout.write("No expired objects to be deactivated found.") + else: + # Unpublish the expired objects + for queryset in expired_objects: + # Cast to list to make sure the query is fully evaluated + # before unpublishing anything + for obj in list(queryset): + obj.unpublish(set_expired=True, log_action="wagtail.unpublish.scheduled") + + def _publish_scheduled_without_bundles(self, dry_run: bool) -> None: + # 2. get all revisions that need to be published + preliminary_revs_for_publishing = Revision.objects.filter(approved_go_live_at__lt=timezone.now()).order_by( + "approved_go_live_at" + ) + revs_for_publishing = [] + for rev in preliminary_revs_for_publishing: + content_object = rev.as_object() + if not isinstance(content_object, BundledPageMixin) or not content_object.in_active_bundle: + revs_for_publishing.append(rev) + if dry_run: + self.stdout.write("\n---------------------------------") + if revs_for_publishing: + self.stdout.write("Revisions to be published:") + self.stdout.write("Go live datetime\tModel\t\tSlug\t\tName") + self.stdout.write("----------------\t-----\t\t----\t\t----") + for rp in revs_for_publishing: + model = rp.content_type.model_class() + rev_data = rp.content + self.stdout.write( + f'{rp.approved_go_live_at.strftime("%Y-%m-%d %H:%M")}\t' + f'{model.__name__}\t{rev_data.get("slug", "")}\t\t{rev_data.get("title", rp.object_str)}' + ) + else: + self.stdout.write("No objects to go live.") + else: + for rp in revs_for_publishing: + # just run publish for the revision -- since the approved go + # live datetime is before now it will make the object live + rp.publish(log_action="wagtail.publish.scheduled") diff --git a/cms/bundles/migrations/0001_initial.py b/cms/bundles/migrations/0001_initial.py new file mode 100644 index 00000000..2ec91320 --- /dev/null +++ b/cms/bundles/migrations/0001_initial.py @@ -0,0 +1,88 @@ +# Generated by Django 5.1.3 on 2024-11-14 12:49 + +import django.db.models.deletion +import modelcluster.fields +import wagtail.search.index +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("release_calendar", "0002_create_releasecalendarindex"), + ("wagtailcore", "0094_alter_page_locale"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Bundle", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ("name", models.CharField(max_length=255, unique=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("approved_at", models.DateTimeField(blank=True, null=True)), + ("publication_date", models.DateTimeField(blank=True, null=True)), + ("status", models.CharField(default="PENDING", max_length=32)), + ( + "approved_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="approved_bundles", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="bundles", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "release_calendar_page", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="bundles", + to="release_calendar.releasecalendarpage", + ), + ), + ], + options={ + "abstract": False, + }, + bases=(wagtail.search.index.Indexed, models.Model), + ), + migrations.CreateModel( + name="BundlePage", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ("sort_order", models.IntegerField(blank=True, editable=False, null=True)), + ( + "page", + models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="wagtailcore.page" + ), + ), + ( + "parent", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, related_name="bundled_pages", to="bundles.bundle" + ), + ), + ], + options={ + "ordering": ["sort_order"], + "abstract": False, + }, + ), + ] diff --git a/cms/bundles/migrations/__init__.py b/cms/bundles/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cms/bundles/models.py b/cms/bundles/models.py new file mode 100644 index 00000000..425fde08 --- /dev/null +++ b/cms/bundles/models.py @@ -0,0 +1,196 @@ +from typing import TYPE_CHECKING, Any, ClassVar, Optional, Self + +from django.db import models +from django.db.models import F, QuerySet +from django.db.models.functions import Coalesce +from django.utils.functional import cached_property +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from modelcluster.fields import ParentalKey +from modelcluster.models import ClusterableModel +from wagtail.admin.panels import FieldPanel, FieldRowPanel, InlinePanel +from wagtail.models import Orderable, Page +from wagtail.search import index + +from .enums import ACTIVE_BUNDLE_STATUSES, EDITABLE_BUNDLE_STATUSES, BundleStatus +from .forms import BundleAdminForm +from .panels import BundleNotePanel, PageChooserWithStatusPanel + +if TYPE_CHECKING: + import datetime + + from wagtail.admin.panels import Panel + + +class BundlePage(Orderable): + parent = ParentalKey("Bundle", related_name="bundled_pages", on_delete=models.CASCADE) + page = models.ForeignKey( # type: ignore[var-annotated] + "wagtailcore.Page", blank=True, null=True, on_delete=models.SET_NULL + ) + + panels: ClassVar[list["Panel"]] = [ + PageChooserWithStatusPanel("page", ["analysis.AnalysisPage"]), + ] + + def __str__(self) -> str: + return f"BundlePage: page {self.page_id} in bundle {self.parent_id}" + + +class BundlesQuerySet(QuerySet): + def active(self) -> Self: + """Provides a pre-filtered queryset for active bundles. Usage: Bundle.objects.active().""" + return self.filter(status__in=ACTIVE_BUNDLE_STATUSES) + + def editable(self) -> Self: + """Provides a pre-filtered queryset for editable bundles. Usage: Bundle.objects.editable().""" + return self.filter(status__in=EDITABLE_BUNDLE_STATUSES) + + +# note: mypy doesn't cope with dynamic base classes and fails with: +# 'Unsupported dynamic base class "models.Manager.from_queryset" [misc]' +# @see https://github.com/python/mypy/issues/2477 +class BundleManager(models.Manager.from_queryset(BundlesQuerySet)): # type: ignore[misc] + def get_queryset(self) -> BundlesQuerySet: + """Augments the queryset to order it by the publication date, then name, then reverse id.""" + queryset: BundlesQuerySet = super().get_queryset() + queryset = queryset.alias( + release_date=Coalesce("publication_date", "release_calendar_page__release_date") + ).order_by(F("release_date").desc(nulls_last=True), "name", "-pk") + return queryset # note: not returning directly to placate no-any-return + + +class Bundle(index.Indexed, ClusterableModel, models.Model): # type: ignore[django-manager-missing] + base_form_class = BundleAdminForm + + name = models.CharField(max_length=255, unique=True) + created_at = models.DateTimeField(auto_now_add=True) + created_by = models.ForeignKey( + "users.User", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="bundles", + ) + # See https://docs.wagtail.org/en/stable/advanced_topics/reference_index.html + created_by.wagtail_reference_index_ignore = True # type: ignore[attr-defined] + + approved_at = models.DateTimeField(blank=True, null=True) + approved_by = models.ForeignKey( + "users.User", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="approved_bundles", + ) + approved_by.wagtail_reference_index_ignore = True # type: ignore[attr-defined] + + publication_date = models.DateTimeField(blank=True, null=True) + release_calendar_page = models.ForeignKey( + "release_calendar.ReleaseCalendarPage", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="bundles", + ) + status = models.CharField(choices=BundleStatus.choices, default=BundleStatus.PENDING, max_length=32) + + objects = BundleManager() + + panels: ClassVar[list["Panel"]] = [ + FieldPanel("name"), + FieldRowPanel( + [ + FieldPanel("release_calendar_page", heading="Release Calendar page"), + FieldPanel("publication_date", heading="or Publication date"), + ], + heading=_("Scheduling"), + icon="calendar", + ), + FieldPanel("status"), + InlinePanel("bundled_pages", heading=_("Bundled pages"), icon="doc-empty", label=_("Page")), + # these are handled by the form + FieldPanel("approved_by", classname="hidden w-hidden"), + FieldPanel("approved_at", classname="hidden w-hidden"), + ] + + search_fields: ClassVar[list[index.SearchField | index.AutocompleteField]] = [ + index.SearchField("name"), + index.AutocompleteField("name"), + ] + + def __str__(self) -> str: + return str(self.name) + + @cached_property + def scheduled_publication_date(self) -> Optional["datetime.datetime"]: + """Returns the direct publication date or the linked release calendar page, if set.""" + date: datetime.datetime | None = self.publication_date + if not date and self.release_calendar_page_id: + date = self.release_calendar_page.release_date # type: ignore[union-attr] + return date + + @property + def can_be_approved(self) -> bool: + """Determines whether the bundle can be approved (i.e. is not already approved or released). + + Note: strictly speaking, the bundle should be in "in review" in order for it to be approved. + """ + return self.status in [BundleStatus.PENDING, BundleStatus.IN_REVIEW] + + def get_bundled_pages(self) -> QuerySet[Page]: + pages: QuerySet[Page] = Page.objects.filter(pk__in=self.bundled_pages.values_list("page__pk", flat=True)) + return pages + + def save(self, **kwargs: Any) -> None: # type: ignore[override] + """Adds additional behaviour on bundle saving. + + For non-released bundles, we update the publication date for related pages if needed. + """ + super().save(**kwargs) + + if self.status == BundleStatus.RELEASED: + return + + if self.scheduled_publication_date and self.scheduled_publication_date >= now(): + # Schedule publishing for related pages. + # ignoring [attr-defined] because Wagtail is not fully typed and mypy is confused. + for bundled_page in self.get_bundled_pages().defer_streamfields().specific(): # type: ignore[attr-defined] + if bundled_page.go_live_at == self.scheduled_publication_date: + continue + + # note: this could use a custom log action for history + bundled_page.go_live_at = self.scheduled_publication_date + revision = bundled_page.save_revision() + revision.publish() + + +class BundledPageMixin: + """A helper page mixin for bundled content. + + Add it to Page classes that should be in bundles. + """ + + panels: ClassVar[list["Panel"]] = [BundleNotePanel(heading="Bundle", icon="boxes-stacked")] + + @cached_property + def bundles(self) -> QuerySet[Bundle]: + """Return all bundles this instance belongs to.""" + queryset: QuerySet[Bundle] = Bundle.objects.none() + if self.pk: # type: ignore[attr-defined] + queryset = Bundle.objects.filter( + pk__in=self.bundlepage_set.all().values_list("parent", flat=True) # type: ignore[attr-defined] + ) + return queryset + + @cached_property + def active_bundles(self) -> QuerySet[Bundle]: + """Returns the active bundles this instance belongs to. In theory, it should be only one.""" + return self.bundles.filter(status__in=ACTIVE_BUNDLE_STATUSES) + + @cached_property + def active_bundle(self) -> Bundle | None: + return self.active_bundles.first() + + @cached_property + def in_active_bundle(self) -> bool: + return self.active_bundle is not None diff --git a/cms/bundles/notifications.py b/cms/bundles/notifications.py new file mode 100644 index 00000000..cfea1b31 --- /dev/null +++ b/cms/bundles/notifications.py @@ -0,0 +1,104 @@ +import logging +from http import HTTPStatus +from typing import TYPE_CHECKING, Optional + +from django.conf import settings +from slack_sdk.webhook import WebhookClient + +from cms.bundles.models import Bundle + +if TYPE_CHECKING: + from cms.users.models import User + + +logger = logging.getLogger("cms.bundles") + + +def notify_slack_of_status_change( + bundle: Bundle, old_status: str, user: Optional["User"] = None, url: str | None = None +) -> None: + """Sends a Slack notification for Bundle status changes.""" + if (webhook_url := settings.SLACK_NOTIFICATIONS_WEBHOOK_URL) is None: + return + + client = WebhookClient(webhook_url) + + fields = [ + {"title": "Title", "value": bundle.name, "short": True}, + {"title": "Changed by", "value": user.get_full_name() if user else "System", "short": True}, + {"title": "Old status", "value": old_status, "short": True}, + {"title": "New status", "value": bundle.get_status_display(), "short": True}, + ] + if url: + fields.append( + {"title": "Link", "value": url, "short": False}, + ) + + response = client.send( + text="Bundle status changed", + attachments=[{"color": "good", "fields": fields}], + unfurl_links=False, + unfurl_media=False, + ) + + if response.status_code != HTTPStatus.OK: + logger.error("Unable to notify Slack of bundle status change: %s", response.body) + + +def notify_slack_of_publication_start(bundle: Bundle, user: Optional["User"] = None, url: str | None = None) -> None: + """Sends a Slack notification for Bundle publication start.""" + if (webhook_url := settings.SLACK_NOTIFICATIONS_WEBHOOK_URL) is None: + return + + client = WebhookClient(webhook_url) + + fields = [ + {"title": "Title", "value": bundle.name, "short": True}, + {"title": "User", "value": user.get_full_name() if user else "System", "short": True}, + {"title": "Pages", "value": bundle.get_bundled_pages().count(), "short": True}, + ] + if url: + fields.append( + {"title": "Link", "value": url, "short": False}, + ) + + response = client.send( + text="Starting bundle publication", + attachments=[{"color": "good", "fields": fields}], + unfurl_links=False, + unfurl_media=False, + ) + + if response.status_code != HTTPStatus.OK: + logger.error("Unable to notify Slack of bundle publication start: %s", response.body) + + +def notify_slack_of_publish_end( + bundle: Bundle, elapsed: float, user: Optional["User"] = None, url: str | None = None +) -> None: + """Sends a Slack notification for Bundle publication end.""" + if (webhook_url := settings.SLACK_NOTIFICATIONS_WEBHOOK_URL) is None: + return + + client = WebhookClient(webhook_url) + + fields = [ + {"title": "Title", "value": bundle.name, "short": True}, + {"title": "User", "value": user.get_full_name() if user else "System", "short": True}, + {"title": "Pages", "value": bundle.get_bundled_pages().count(), "short": True}, + {"title": "Total time", "value": f"{elapsed:.3f} seconds"}, + ] + if url: + fields.append( + {"title": "Link", "value": url, "short": False}, + ) + + response = client.send( + text="Finished bundle publication", + attachments=[{"color": "good", "fields": fields}], + unfurl_links=False, + unfurl_media=False, + ) + + if response.status_code != HTTPStatus.OK: + logger.error("Unable to notify Slack of bundle publication finish: %s", response.body) diff --git a/cms/bundles/panels.py b/cms/bundles/panels.py new file mode 100644 index 00000000..bac515e8 --- /dev/null +++ b/cms/bundles/panels.py @@ -0,0 +1,53 @@ +from typing import TYPE_CHECKING, Any, Union + +from django.utils.html import format_html, format_html_join +from django.utils.translation import gettext as _ +from wagtail.admin.panels import HelpPanel, PageChooserPanel + +if TYPE_CHECKING: + from django.db.models import Model + from django.utils.safestring import SafeString + + +class BundleNotePanel(HelpPanel): + """An extended HelpPanel class.""" + + class BoundPanel(HelpPanel.BoundPanel): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.content = self._content_for_instance(self.instance) + + def _content_for_instance(self, instance: "Model") -> Union[str, "SafeString"]: + if not hasattr(instance, "bundles"): + return "" + + if bundles := instance.bundles: + content_html = format_html_join( + "\n", + "
  • {} (Status: {})
  • ", + ( + ( + bundle.name, + bundle.get_status_display(), + ) + for bundle in bundles + ), + ) + + content = format_html( + "

    {}

    ", _("This page is in the following bundle(s):"), content_html + ) + else: + content = format_html("

    {}

    ", _("This page is not part of any bundles.")) + return content + + +class PageChooserWithStatusPanel(PageChooserPanel): + """A custom page chooser panel that includes the page workflow status.""" + + class BoundPanel(PageChooserPanel.BoundPanel): + def __init__(self, **kwargs: Any) -> None: + """Sets the panel heading to the page verbose name to help differentiate page types.""" + super().__init__(**kwargs) + if page := self.instance.page: + self.heading = page.specific.get_verbose_name() diff --git a/cms/bundles/templates/bundles/wagtailadmin/add_to_bundle.html b/cms/bundles/templates/bundles/wagtailadmin/add_to_bundle.html new file mode 100644 index 00000000..1342a4ee --- /dev/null +++ b/cms/bundles/templates/bundles/wagtailadmin/add_to_bundle.html @@ -0,0 +1,29 @@ +{% extends "wagtailadmin/base.html" %} +{% load i18n wagtailadmin_tags %} +{% block titletag %}{% blocktrans trimmed with title=page_to_move.specific_deferred.get_admin_display_title %}Add {{ title }} to a bundle{% endblocktrans %}{% endblock %} +{% block content %} + {% include "wagtailadmin/shared/header.html" with title=_("Add") subtitle=page_to_move.specific_deferred.get_admin_display_title icon="boxes-stacked" %} + +
    +
    + {% csrf_token %} + {% if next %}{% endif %} + +
      +
    • {% formattedfield form.bundle %}
    • +
    + + +
    +
    +{% endblock %} + +{% block extra_js %} + {{ block.super }} + {{ form.media.js }} +{% endblock %} + +{% block extra_css %} + {{ block.super }} + {{ form.media.css }} +{% endblock %} diff --git a/cms/bundles/templates/bundles/wagtailadmin/edit.html b/cms/bundles/templates/bundles/wagtailadmin/edit.html new file mode 100644 index 00000000..87a17f61 --- /dev/null +++ b/cms/bundles/templates/bundles/wagtailadmin/edit.html @@ -0,0 +1,14 @@ +{% extends "wagtailadmin/generic/form.html" %} +{% load i18n %} + +{% block actions %} + {{ block.super }} + {% if show_save_and_approve %} + + {% elif show_publish %} + + {% endif %} + {% if delete_url %} + {{ delete_item_label }} + {% endif %} +{% endblock %} diff --git a/cms/bundles/templates/bundles/wagtailadmin/inspect.html b/cms/bundles/templates/bundles/wagtailadmin/inspect.html new file mode 100644 index 00000000..140d6860 --- /dev/null +++ b/cms/bundles/templates/bundles/wagtailadmin/inspect.html @@ -0,0 +1,5 @@ +{% extends "wagtailadmin/generic/inspect.html" %} +{% load i18n %} + +{% block footer %} +{% endblock %} diff --git a/cms/bundles/templates/bundles/wagtailadmin/panels/latest_bundles.html b/cms/bundles/templates/bundles/wagtailadmin/panels/latest_bundles.html new file mode 100644 index 00000000..1a8cde48 --- /dev/null +++ b/cms/bundles/templates/bundles/wagtailadmin/panels/latest_bundles.html @@ -0,0 +1,59 @@ +{% load i18n wagtailcore_tags wagtailadmin_tags %} +{% if is_shown %} + {% panel id="latest-bundles" heading=_("Latest active bundles") %} + {% help_block status="info" %} +

    Bundles are collections of pages and datasets to publish together.

    + {% endhelp_block %} +

    + {% icon name="plus" wrapped=1 %}{% trans "Add bundle" %} + View all bundles +

    + {% if bundles %} + + + + + + + {# add class="w-sr-only" to make this visible for screen readers only #} + + + + + + + + + + {% for bundle in bundles %} + + + + + + + + {% endfor %} + +
    {% trans "Title" %}{% trans "Status" %}{% trans "Scheduled publication date" %}{% trans "Added" %}{% trans "Added by" %}
    + + + + {{ bundle.get_status_display }} + + {{ bundle.scheduled_publication_date|default_if_none:"" }} + {% human_readable_date bundle.created_at %}{% include "wagtailadmin/shared/user_avatar.html" with user=bundle.created_by username=bundle.created_by.get_full_name|default:bundle.created_by.get_username %}
    + {% else %} +

    There are currently no active bundles.

    + {% endif %} + {% endpanel %} +{% endif %} diff --git a/cms/bundles/tests/__init__.py b/cms/bundles/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cms/bundles/tests/factories.py b/cms/bundles/tests/factories.py new file mode 100644 index 00000000..16d314fd --- /dev/null +++ b/cms/bundles/tests/factories.py @@ -0,0 +1,77 @@ +import factory +from django.utils import timezone + +from cms.bundles.enums import BundleStatus +from cms.bundles.models import Bundle, BundlePage +from cms.users.tests.factories import UserFactory + + +class BundleFactory(factory.django.DjangoModelFactory): + """Factory for Bundle model.""" + + class Meta: + model = Bundle + + name = factory.Faker("sentence", nb_words=4) + created_at = factory.LazyFunction(timezone.now) + created_by = factory.SubFactory(UserFactory) + status = BundleStatus.PENDING + + class Params: + """Defines custom factory traits. + + Usage: BundlFactory(approved=True) or BundlFactory(released=True) + """ + + in_review = factory.Trait( + status=BundleStatus.IN_REVIEW, + publication_date=factory.LazyFunction(lambda: timezone.now() + timezone.timedelta(days=1)), + ) + + # Trait for approved bundles + approved = factory.Trait( + status=BundleStatus.APPROVED, + approved_at=factory.LazyFunction(timezone.now), + approved_by=factory.SubFactory(UserFactory), + publication_date=factory.LazyFunction(lambda: timezone.now() + timezone.timedelta(days=1)), + ) + + # Trait for released bundles + released = factory.Trait( + status=BundleStatus.RELEASED, + approved_at=factory.LazyFunction(lambda: timezone.now() - timezone.timedelta(days=1)), + approved_by=factory.SubFactory(UserFactory), + publication_date=factory.LazyFunction(timezone.now), + ) + + @factory.post_generation + def bundled_pages(self, create, extracted, **kwargs): + """Creates BundlePage instances for the bundle. + + Usage: + # Create a bundle with no pages + bundle = BundleFactory() + + # Create a bundle with specific pages + bundle = BundleFactory(bundled_pages=[page1, page2]) + + # Create an approved bundle with pages + bundle = BundleFactory(approved=True, bundled_pages=[page1, page2]) + """ + if not create: + return + + if extracted: + for page in extracted: + BundlePageFactory(parent=self, page=page) + + +class BundlePageFactory(factory.django.DjangoModelFactory): + """Factory for BundlePage orderable model.""" + + class Meta: + model = BundlePage + + parent = factory.SubFactory(BundleFactory) + page = factory.SubFactory("cms.analysis.tests.factories.AnalysisPageFactory") + sort_order = factory.Sequence(lambda n: n) diff --git a/cms/bundles/tests/test_forms.py b/cms/bundles/tests/test_forms.py new file mode 100644 index 00000000..7822a886 --- /dev/null +++ b/cms/bundles/tests/test_forms.py @@ -0,0 +1,161 @@ +from typing import Any + +from django import forms +from django.test import TestCase +from django.utils import timezone +from wagtail.admin.panels import get_edit_handler +from wagtail.test.utils.form_data import inline_formset, nested_form_data + +from cms.analysis.tests.factories import AnalysisPageFactory +from cms.bundles.admin_forms import AddToBundleForm +from cms.bundles.enums import ACTIVE_BUNDLE_STATUS_CHOICES, BundleStatus +from cms.bundles.models import Bundle +from cms.bundles.tests.factories import BundleFactory, BundlePageFactory +from cms.bundles.viewsets import BundleChooserWidget +from cms.release_calendar.tests.factories import ReleaseCalendarPageFactory +from cms.users.tests.factories import UserFactory + + +class AddToBundleFormTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.bundle = BundleFactory(name="First Bundle") + cls.non_editable_bundle = BundleFactory(approved=True) + cls.page = AnalysisPageFactory(title="The Analysis") + + def test_form_init(self): + """Checks the form gets a bundle form field on init.""" + form = AddToBundleForm(page_to_add=self.page) + self.assertIn("bundle", form.fields) + self.assertIsInstance(form.fields["bundle"].widget, BundleChooserWidget) + self.assertQuerySetEqual( + form.fields["bundle"].queryset, + Bundle.objects.filter(pk=self.bundle.pk), + ) + + def test_form_clean__validates_page_not_in_bundle(self): + """Checks that we cannot add the page to a new bundle if already in an active one.""" + BundlePageFactory(parent=self.bundle, page=self.page) + form = AddToBundleForm(page_to_add=self.page, data={"bundle": self.bundle.pk}) + + self.assertFalse(form.is_valid()) + self.assertFormError( + form, "bundle", [f"Page '{self.page.get_admin_display_title()}' is already in bundle 'First Bundle'"] + ) + + def test_form_clean__validates_page_is_bundleable(self): + """Checks the given page inherits from BundlePageMixin.""" + form = AddToBundleForm(page_to_add=ReleaseCalendarPageFactory(), data={"bundle": self.bundle.pk}) + self.assertFalse(form.is_valid()) + self.assertFormError(form, None, ["Pages of this type cannot be added."]) + + +class BundleAdminFormTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.bundle = BundleFactory(name="First Bundle") + cls.page = AnalysisPageFactory(title="The Analysis") + cls.form_class = get_edit_handler(Bundle).get_form_class() + + def setUp(self): + self.form_data = nested_form_data(self.raw_form_data()) + + def raw_form_data(self) -> dict[str, Any]: + """Returns raw form data.""" + return { + "name": "First Bundle", + "status": BundleStatus.IN_REVIEW, + "bundled_pages": inline_formset([]), + } + + def test_form_init__status_choices(self): + """Checks status choices variation.""" + cases = [ + (BundleStatus.PENDING, ACTIVE_BUNDLE_STATUS_CHOICES), + (BundleStatus.IN_REVIEW, ACTIVE_BUNDLE_STATUS_CHOICES), + (BundleStatus.APPROVED, BundleStatus.choices), + (BundleStatus.RELEASED, BundleStatus.choices), + ] + for status, choices in cases: + with self.subTest(status=status, choices=choices): + self.bundle.status = status + form = self.form_class(instance=self.bundle) + self.assertEqual(form.fields["status"].choices, choices) + + def test_form_init__approved_by_at_are_disabled(self): + """Checks that approved_at and approved_by are disabled. They are programmatically set.""" + form = self.form_class(instance=self.bundle) + self.assertTrue(form.fields["approved_at"].disabled) + self.assertTrue(form.fields["approved_by"].disabled) + + self.assertIsInstance(form.fields["approved_by"].widget, forms.HiddenInput) + self.assertIsInstance(form.fields["approved_at"].widget, forms.HiddenInput) + + def test_form_init__fields_disabled_if_status_is_approved(self): + """Checks that all but the status field are disabled once approved to prevent further editing.""" + self.bundle.status = BundleStatus.APPROVED + form = self.form_class(instance=self.bundle) + fields = {field: True for field in form.fields} + fields["status"] = False + + for field, expected in fields.items(): + with self.subTest(field=field, expected=expected): + self.assertEqual(form.fields[field].disabled, expected) + + def test_clean__removes_deleted_page_references(self): + """Checks that we clean up references to pages that may have been deleted since being added to the bundle.""" + raw_data = self.raw_form_data() + raw_data["bundled_pages"] = inline_formset([{"page": ""}]) + data = nested_form_data(raw_data) + + form = self.form_class(instance=self.bundle, data=data) + + self.assertTrue(form.is_valid()) + formset = form.formsets["bundled_pages"] + self.assertTrue(formset.forms[0].cleaned_data["DELETE"]) + + def test_clean__validates_added_page_not_in_another_bundle(self): + """Should validate that the page is not in the active bundle.""" + another_bundle = BundleFactory(name="Another Bundle") + BundlePageFactory(parent=another_bundle, page=self.page) + + raw_data = self.raw_form_data() + raw_data["bundled_pages"] = inline_formset([{"page": self.page.id}]) + data = nested_form_data(raw_data) + + form = self.form_class(instance=self.bundle, data=data) + self.assertFalse(form.is_valid()) + self.assertFormError(form, None, ["'The Analysis' is already in an active bundle (Another Bundle)"]) + + def test_clean__sets_approved_by_and_approved_at(self): + approver = UserFactory() + + data = self.form_data + data["status"] = BundleStatus.APPROVED + form = self.form_class(instance=self.bundle, data=data, for_user=approver) + + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data["approved_by"], approver) + + def test_clean__doesnt_set_approved_by_and_approved_at_if_self_approving(self): + data = self.form_data + data["status"] = BundleStatus.APPROVED + form = self.form_class(instance=self.bundle, data=data, for_user=self.bundle.created_by) + + self.assertFalse(form.is_valid()) + self.assertFormError(form, "status", ["You cannot self-approve your own bundle!"]) + self.assertIsNone(form.cleaned_data["approved_by"]) + self.assertIsNone(form.cleaned_data["approved_at"]) + + def test_clean__validates_release_calendar_page_or_publication_date(self): + release_calendar_page = ReleaseCalendarPageFactory() + data = self.form_data + data["release_calendar_page"] = release_calendar_page.id + data["publication_date"] = timezone.now() + + form = self.form_class(data=data) + + self.assertFalse(form.is_valid()) + error = "You must choose either a Release Calendar page or a Publication date, not both." + self.assertFormError(form, "release_calendar_page", [error]) + self.assertFormError(form, "publication_date", [error]) diff --git a/cms/bundles/tests/test_management_commands.py b/cms/bundles/tests/test_management_commands.py new file mode 100644 index 00000000..24845291 --- /dev/null +++ b/cms/bundles/tests/test_management_commands.py @@ -0,0 +1,205 @@ +from datetime import timedelta +from io import StringIO +from unittest.mock import patch + +from django.core.management import call_command +from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils import timezone +from wagtail.models import ModelLogEntry, PageLogEntry + +from cms.analysis.tests.factories import AnalysisPageFactory +from cms.bundles.enums import BundleStatus +from cms.bundles.tests.factories import BundleFactory, BundlePageFactory +from cms.home.models import HomePage +from cms.release_calendar.enums import ReleaseStatus +from cms.release_calendar.tests.factories import ReleaseCalendarPageFactory + + +class PublishBundlesCommandTestCase(TestCase): + def setUp(self): + self.stdout = StringIO() + self.stderr = StringIO() + + self.publication_date = timezone.now() - timedelta(minutes=1) + self.analysis_page = AnalysisPageFactory(title="Test Analysis", live=False) + self.analysis_page.save_revision(approved_go_live_at=self.publication_date) + + self.bundle = BundleFactory(approved=True, name="Test Bundle", publication_date=self.publication_date) + + def call_command(self, *args, **kwargs): + """Helper to call the management command.""" + call_command( + "publish_bundles", + *args, + stdout=self.stdout, + stderr=self.stderr, + **kwargs, + ) + + def test_dry_run_with_no_bundles(self): + """Test dry run output when there are no bundles to publish.""" + self.bundle.publication_date = timezone.now() + timedelta(minutes=10) + self.bundle.save(update_fields=["publication_date"]) + + self.call_command(dry_run=True) + + output = self.stdout.getvalue() + self.assertIn("Will do a dry run", output) + self.assertIn("No bundles to go live", output) + + def test_dry_run_with_bundles(self): + """Test dry run output when there are bundles to publish.""" + BundlePageFactory(parent=self.bundle, page=self.analysis_page) + + self.call_command(dry_run=True) + + output = self.stdout.getvalue() + self.assertIn("Will do a dry run", output) + self.assertIn("Bundles to be published:", output) + self.assertIn(f"- {self.bundle.name}", output) + self.assertIn( + f"{self.analysis_page.get_admin_display_title()} ({self.analysis_page.__class__.__name__})", output + ) + + @override_settings(SLACK_NOTIFICATIONS_WEBHOOK_URL="https://slack.example.com") + @patch("cms.bundles.management.commands.publish_bundles.notify_slack_of_publication_start") + @patch("cms.bundles.management.commands.publish_bundles.notify_slack_of_publish_end") + def test_publish_bundle(self, mock_notify_end, mock_notify_start): + """Test publishing a bundle.""" + # Sanity checks + self.assertFalse(self.analysis_page.live) + self.assertFalse(ModelLogEntry.objects.filter(action="wagtail.publish.scheduled").exists()) + self.assertFalse(PageLogEntry.objects.filter(action="wagtail.publish.scheduled").exists()) + + # add another page, but publish in the meantime. + another_page = AnalysisPageFactory(title="Test Analysis", live=False) + another_page.save_revision().publish() + BundlePageFactory(parent=self.bundle, page=self.analysis_page) + BundlePageFactory(parent=self.bundle, page=another_page) + + self.call_command() + + self.bundle.refresh_from_db() + self.assertEqual(self.bundle.status, BundleStatus.RELEASED) + + self.analysis_page.refresh_from_db() + self.assertTrue(self.analysis_page.live) + + # Check notifications were sent + self.assertTrue(mock_notify_start.called) + self.assertTrue(mock_notify_end.called) + + # Check that we have a log entry + self.assertEqual(ModelLogEntry.objects.filter(action="wagtail.publish.scheduled").count(), 1) + self.assertEqual(PageLogEntry.objects.filter(action="wagtail.publish.scheduled").count(), 1) + + def test_publish_bundle_with_release_calendar(self): + """Test publishing a bundle with an associated release calendar page.""" + release_page = ReleaseCalendarPageFactory(release_date=self.publication_date) + BundlePageFactory(parent=self.bundle, page=self.analysis_page) + self.bundle.publication_date = None + self.bundle.release_calendar_page = release_page + self.bundle.save(update_fields=["publication_date", "release_calendar_page"]) + + self.call_command() + + # Check release calendar was updated + release_page.refresh_from_db() + self.assertEqual(release_page.status, ReleaseStatus.PUBLISHED) + + content = release_page.content[0].value + self.assertEqual(content["title"], "Publications") + self.assertEqual(len(content["links"]), 1) + self.assertEqual(content["links"][0]["page"].pk, self.analysis_page.pk) + + @override_settings(SLACK_NOTIFICATIONS_WEBHOOK_URL="https://slack.ons.gov.uk") + @patch("cms.bundles.management.commands.publish_bundles.logger") + def test_publish_bundle_error_handling(self, mock_logger): + """Test error handling during bundle publication.""" + BundlePageFactory(parent=self.bundle, page=self.analysis_page) + + # Mock an error during publication + with patch( + "cms.bundles.management.commands.publish_bundles.notify_slack_of_publication_start", + side_effect=Exception("Test error"), + ): + self.call_command() + + # Check error was logged + mock_logger.exception.assert_called_with("Publish failed bundle=%d", self.bundle.id) + + # Check bundle status wasn't changed due to error + self.bundle.refresh_from_db() + self.assertEqual(self.bundle.status, BundleStatus.APPROVED) + + @override_settings(WAGTAILADMIN_BASE_URL="https://test.ons.gov.uk") + @override_settings(SLACK_NOTIFICATIONS_WEBHOOK_URL="https://slack.ons.gov.uk") + @patch("cms.bundles.management.commands.publish_bundles.notify_slack_of_publication_start") + def test_publish_bundle_with_base_url(self, mock_notify): + """Test publishing with a configured base URL.""" + self.call_command() + + # Verify notification was called with correct URL + mock_notify.assert_called_once() + call_kwargs = mock_notify.call_args[1] + + self.assertEqual( + call_kwargs["url"], "https://test.ons.gov.uk" + reverse("bundle:inspect", args=(self.bundle.pk,)) + ) + self.assertIn(str(self.bundle.pk), call_kwargs["url"]) + + +class PublishScheduledWithoutBundlesCommandTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.home = HomePage.objects.first() + cls.analysis_page = AnalysisPageFactory(title="Test Analysis", live=False) + cls.bundle = BundleFactory(name="Test Bundle", bundled_pages=[cls.analysis_page]) + + cls.publication_date = timezone.now() - timedelta(minutes=1) + cls.analysis_page.save_revision(approved_go_live_at=cls.publication_date) + + def setUp(self): + self.stdout = StringIO() + self.stderr = StringIO() + + def call_command(self, *args, **kwargs): + """Helper to call the management command.""" + call_command( + "publish_scheduled_without_bundles", + *args, + stdout=self.stdout, + stderr=self.stderr, + **kwargs, + ) + + def test_dry_run(self): + """Test dry run doesn't include our bundled page.""" + self.call_command(dry_run=True) + + output = self.stdout.getvalue() + self.assertIn("Will do a dry run.", output) + self.assertIn("No objects to go live.", output) + + def test_dry_run__with_a_scheduled_page(self): + """Test dry run doesn't include our bundled page.""" + self.home.save_revision(approved_go_live_at=self.publication_date) + + self.call_command(dry_run=True) + + output = self.stdout.getvalue() + self.assertIn("Will do a dry run.", output) + + self.assertIn("Revisions to be published:", output) + self.assertIn(self.home.title, output) + + self.assertFalse(PageLogEntry.objects.filter(action="wagtail.publish.scheduled").exists()) + + def test_publish_scheduled_without_bundles__happy_path(self): + """Checks only a scheduled non-bundled page has been published.""" + self.home.save_revision(approved_go_live_at=self.publication_date) + + self.call_command() + + self.assertEqual(PageLogEntry.objects.filter(action="wagtail.publish.scheduled").count(), 1) diff --git a/cms/bundles/tests/test_models.py b/cms/bundles/tests/test_models.py new file mode 100644 index 00000000..b026c3bd --- /dev/null +++ b/cms/bundles/tests/test_models.py @@ -0,0 +1,131 @@ +from datetime import timedelta + +from django.test import TestCase +from django.utils import timezone + +from cms.analysis.tests.factories import AnalysisPageFactory +from cms.bundles.enums import BundleStatus +from cms.bundles.tests.factories import BundleFactory, BundlePageFactory +from cms.release_calendar.tests.factories import ReleaseCalendarPageFactory + + +class BundleModelTestCase(TestCase): + """Test Bundle model properties and methods.""" + + def setUp(self): + self.bundle = BundleFactory(name="The bundle") + self.analysis_page = AnalysisPageFactory(title="PSF") + + def test_str(self): + """Test string representation.""" + self.assertEqual(str(self.bundle), self.bundle.name) + + def test_scheduled_publication_date__direct(self): + """Test scheduled_publication_date returns direct publication date.""" + del self.bundle.scheduled_publication_date # clear the cached property + now = timezone.now() + self.bundle.publication_date = now + self.assertEqual(self.bundle.scheduled_publication_date, now) + + def test_scheduled_publication_date__via_release(self): + """Test scheduled_publication_date returns release calendar date.""" + release_page = ReleaseCalendarPageFactory() + self.bundle.release_calendar_page = release_page + self.assertEqual(self.bundle.scheduled_publication_date, release_page.release_date) + + def test_can_be_approved(self): + """Test can_be_approved property.""" + test_cases = [ + (BundleStatus.PENDING, True), + (BundleStatus.IN_REVIEW, True), + (BundleStatus.APPROVED, False), + (BundleStatus.RELEASED, False), + ] + + for status, expected in test_cases: + with self.subTest(status=status): + self.bundle.status = status + self.assertEqual(self.bundle.can_be_approved, expected) + + def test_get_bundled_pages(self): + """Test get_bundled_pages returns correct queryset.""" + BundlePageFactory(parent=self.bundle, page=self.analysis_page) + page_ids = self.bundle.get_bundled_pages().values_list("pk", flat=True) + self.assertEqual(len(page_ids), 1) + self.assertEqual(page_ids[0], self.analysis_page.pk) + + def test_save_updates_page_publication_dates(self): + """Test save method updates page publication dates.""" + BundlePageFactory(parent=self.bundle, page=self.analysis_page) + del self.bundle.scheduled_publication_date # clear the cached property + future_date = timezone.now() + timedelta(days=1) + self.bundle.publication_date = future_date + self.bundle.save() + + self.analysis_page.refresh_from_db() + self.assertEqual(self.analysis_page.go_live_at, future_date) + + def test_save_doesnt_update_dates_when_released(self): + """Test save method doesn't update dates for released bundles.""" + future_date = timezone.now() + timedelta(days=1) + self.bundle.status = BundleStatus.RELEASED + self.bundle.publication_date = future_date + BundlePageFactory(parent=self.bundle, page=self.analysis_page) + + self.bundle.save() + self.analysis_page.refresh_from_db() + + self.assertNotEqual(self.analysis_page.go_live_at, future_date) + + def test_bundlepage_orderable_str(self): + """Checks the BundlePage model __str__ method.""" + bundle_page = BundlePageFactory(parent=self.bundle, page=self.analysis_page) + + self.assertEqual(str(bundle_page), f"BundlePage: page {self.analysis_page.pk} in bundle {self.bundle.id}") + + +class BundledPageMixinTestCase(TestCase): + """Test BundledPageMixin properties and methods.""" + + def setUp(self): + self.page = AnalysisPageFactory() + self.bundle = BundleFactory() + self.bundle_page = BundlePageFactory(parent=self.bundle, page=self.page) + + def test_bundles_property(self): + """Test bundles property returns correct queryset.""" + self.assertEqual(self.page.bundles.count(), 1) + self.assertEqual(self.page.bundles.first(), self.bundle) + + def test_active_bundles_property(self): + """Test active_bundles property returns correct queryset.""" + self.bundle.status = BundleStatus.RELEASED + self.bundle.save(update_fields=["status"]) + + self.assertEqual(self.page.active_bundles.count(), 0) + + self.bundle.status = BundleStatus.PENDING + self.bundle.save(update_fields=["status"]) + + self.assertEqual(self.page.active_bundles.count(), 1) + + def test_in_active_bundle_property(self): + """Test in_active_bundle property.""" + self.assertTrue(self.page.in_active_bundle) + + self.bundle.status = BundleStatus.RELEASED + self.bundle.save(update_fields=["status"]) + + del self.page.in_active_bundle # clear the cached property + del self.page.active_bundle # clear the cached property + self.assertFalse(self.page.in_active_bundle) + + def test_active_bundle_property(self): + """Test active_bundle property returns most recent active bundle.""" + self.assertEqual(self.page.active_bundle, self.bundle) + + self.bundle.status = BundleStatus.RELEASED + self.bundle.save(update_fields=["status"]) + + del self.page.active_bundle # cleared cached property + self.assertIsNone(self.page.active_bundle) diff --git a/cms/bundles/tests/test_notifications.py b/cms/bundles/tests/test_notifications.py new file mode 100644 index 00000000..d70f19bc --- /dev/null +++ b/cms/bundles/tests/test_notifications.py @@ -0,0 +1,170 @@ +from http import HTTPStatus +from unittest.mock import Mock, patch + +from django.test import RequestFactory, TestCase, override_settings +from django.urls import reverse + +from cms.analysis.tests.factories import AnalysisPageFactory +from cms.bundles.enums import BundleStatus +from cms.bundles.notifications import ( + notify_slack_of_publication_start, + notify_slack_of_publish_end, + notify_slack_of_status_change, +) +from cms.bundles.tests.factories import BundleFactory +from cms.users.tests.factories import UserFactory + + +class SlackNotificationsTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.bundle = BundleFactory(name="First Bundle", bundled_pages=[AnalysisPageFactory()]) + cls.user = UserFactory(first_name="Publishing", last_name="Officer") + request = RequestFactory().get("/") + cls.inspect_url = request.build_absolute_uri(reverse("bundle:inspect", args=(cls.bundle.pk,))) + + def setUp(self): + self.mock_response = Mock() + self.mock_response.status_code = HTTPStatus.OK + self.mock_response.body = "" + + @override_settings(SLACK_NOTIFICATIONS_WEBHOOK_URL="https://slack.ons.gov.uk") + @patch("cms.bundles.notifications.WebhookClient") + def test_notify_slack_of_status_change__happy_path(self, mock_client): + """Should send notification with correct fields.""" + mock_client.return_value.send.return_value = self.mock_response + + self.bundle.status = BundleStatus.IN_REVIEW + notify_slack_of_status_change(self.bundle, BundleStatus.PENDING.label, self.user) + + mock_client.return_value.send.assert_called_once() + call_kwargs = mock_client.return_value.send.call_args[1] + + self.assertEqual(call_kwargs["text"], "Bundle status changed") + + self.assertListEqual( + call_kwargs["attachments"][0]["fields"], + [ + {"title": "Title", "value": "First Bundle", "short": True}, + {"title": "Changed by", "value": "Publishing Officer", "short": True}, + {"title": "Old status", "value": BundleStatus.PENDING.label, "short": True}, + {"title": "New status", "value": BundleStatus.IN_REVIEW.label, "short": True}, + ], + ) + + @patch("cms.bundles.notifications.WebhookClient") + def test_notify_slack_of_status_change__no_webhook_url(self, mock_client): + """Should return early if no webhook URL is configured.""" + notify_slack_of_status_change(self.bundle, BundleStatus.PENDING, self.user) + mock_client.assert_not_called() + + @override_settings(SLACK_NOTIFICATIONS_WEBHOOK_URL="https://slack.ons.gov.uk") + @patch("cms.bundles.notifications.WebhookClient") + def test_notify_slack_of_status_change__error_logging(self, mock_client): + """Should log error if Slack request fails.""" + with self.assertLogs("cms.bundles") as logs_recorder: + self.mock_response.status_code = HTTPStatus.BAD_REQUEST + self.mock_response.body = "Error message" + mock_client.return_value.send.return_value = self.mock_response + + self.bundle.status = BundleStatus.IN_REVIEW + notify_slack_of_status_change(self.bundle, BundleStatus.PENDING.label, self.user, self.inspect_url) + call_kwargs = mock_client.return_value.send.call_args[1] + self.assertListEqual( + call_kwargs["attachments"][0]["fields"], + [ + {"title": "Title", "value": "First Bundle", "short": True}, + {"title": "Changed by", "value": "Publishing Officer", "short": True}, + {"title": "Old status", "value": BundleStatus.PENDING.label, "short": True}, + {"title": "New status", "value": BundleStatus.IN_REVIEW.label, "short": True}, + {"title": "Link", "value": self.inspect_url, "short": False}, + ], + ) + + self.assertIn("Unable to notify Slack of bundle status change: Error message", logs_recorder.output[0]) + + @override_settings(SLACK_NOTIFICATIONS_WEBHOOK_URL="https://slack.ons.gov.uk") + @patch("cms.bundles.notifications.WebhookClient") + def test_notify_slack_of_publication_start__happy_path(self, mock_client): + """Should send notification with correct fields.""" + mock_client.return_value.send.return_value = self.mock_response + + notify_slack_of_publication_start(self.bundle, self.user, self.inspect_url) + + mock_client.return_value.send.assert_called_once() + call_kwargs = mock_client.return_value.send.call_args[1] + + self.assertEqual(call_kwargs["text"], "Starting bundle publication") + self.assertListEqual( + call_kwargs["attachments"][0]["fields"], + [ + {"title": "Title", "value": "First Bundle", "short": True}, + {"title": "User", "value": "Publishing Officer", "short": True}, + {"title": "Pages", "value": 1, "short": True}, + {"title": "Link", "value": self.inspect_url, "short": False}, + ], + ) + + def test_notify_slack_of_publication_start__no_webhook_url(self): + """Should return early if no webhook URL is configured.""" + with patch("slack_sdk.webhook.WebhookClient") as mock_client: + notify_slack_of_publication_start(self.bundle, self.user) + mock_client.assert_not_called() + + @override_settings(SLACK_NOTIFICATIONS_WEBHOOK_URL="https://slack.ons.gov.uk") + @patch("cms.bundles.notifications.WebhookClient") + def test_notify_slack_of_publish_start__error_logging(self, mock_client): + """Should log error if Slack request fails.""" + with self.assertLogs("cms.bundles") as logs_recorder: + self.mock_response.status_code = HTTPStatus.BAD_REQUEST + self.mock_response.body = "Error message" + mock_client.return_value.send.return_value = self.mock_response + + notify_slack_of_publication_start(self.bundle, self.user) + + self.assertIn("Unable to notify Slack of bundle publication start: Error message", logs_recorder.output[0]) + + @override_settings(SLACK_NOTIFICATIONS_WEBHOOK_URL="https://slack.ons.gov.uk") + @patch("cms.bundles.notifications.WebhookClient") + def test_notify_slack_of_publish_end__happy_path(self, mock_client): + """Should send notification with correct fields.""" + mock_client.return_value.send.return_value = self.mock_response + + notify_slack_of_publish_end(self.bundle, 1.234, self.user, self.inspect_url) + + mock_client.return_value.send.assert_called_once() + call_kwargs = mock_client.return_value.send.call_args[1] + + self.assertEqual(call_kwargs["text"], "Finished bundle publication") + fields = call_kwargs["attachments"][0]["fields"] + + self.assertListEqual( + fields, + [ + {"title": "Title", "value": "First Bundle", "short": True}, + {"title": "User", "value": "Publishing Officer", "short": True}, + {"title": "Pages", "value": 1, "short": True}, + {"title": "Total time", "value": "1.234 seconds"}, + {"title": "Link", "value": self.inspect_url, "short": False}, + ], + ) + self.assertEqual(len(fields), 5) # Including URL and elapsed time + + def test_notify_slack_of_publish_end__no_webhook_url(self): + """Should return early if no webhook URL is configured.""" + with patch("slack_sdk.webhook.WebhookClient") as mock_client: + notify_slack_of_publish_end(self.bundle, 1.234, self.user) + mock_client.assert_not_called() + + @override_settings(SLACK_NOTIFICATIONS_WEBHOOK_URL="https://slack.ons.gov.uk") + @patch("cms.bundles.notifications.WebhookClient") + def test_notify_slack_of_publish_end__error_logging(self, mock_client): + """Should log error if Slack request fails.""" + with self.assertLogs("cms.bundles") as logs_recorder: + self.mock_response.status_code = HTTPStatus.BAD_REQUEST + self.mock_response.body = "Error message" + mock_client.return_value.send.return_value = self.mock_response + + notify_slack_of_publish_end(self.bundle, 1.234, self.user, self.inspect_url) + + self.assertIn("Unable to notify Slack of bundle publication finish: Error message", logs_recorder.output[0]) diff --git a/cms/bundles/tests/test_panels.py b/cms/bundles/tests/test_panels.py new file mode 100644 index 00000000..f78b95e9 --- /dev/null +++ b/cms/bundles/tests/test_panels.py @@ -0,0 +1,51 @@ +from typing import TYPE_CHECKING + +from django.test import TestCase +from wagtail.test.utils import WagtailTestUtils + +from cms.analysis.tests.factories import AnalysisPageFactory +from cms.bundles.enums import BundleStatus +from cms.bundles.panels import BundleNotePanel +from cms.bundles.tests.factories import BundleFactory, BundlePageFactory + +if TYPE_CHECKING: + from wagtail.models import Page + + +class BundleNotePanelTestCase(WagtailTestUtils, TestCase): + """Test BundleNotePanel functionality.""" + + @classmethod + def setUpTestData(cls): + cls.superuser = cls.create_superuser(username="admin") + cls.page = AnalysisPageFactory() + cls.bundle = BundleFactory(name="Test Bundle", status=BundleStatus.PENDING) + cls.panel = BundleNotePanel() + + def get_bound_panel(self, page: "Page") -> BundleNotePanel.BoundPanel: + """Binds the panel to the given page.""" + return self.panel.bind_to_model(page._meta.model).get_bound_panel(instance=page) + + def test_panel_content_without_bundles(self): + """Test panel content when page is not in any bundles.""" + self.assertIn("This page is not part of any bundles", self.get_bound_panel(self.page).content) + + def test_panel_content_with_bundles(self): + """Test panel content when page is in bundles.""" + BundlePageFactory(parent=self.bundle, page=self.page) + + content = self.get_bound_panel(self.page).content + + self.assertIn("This page is in the following bundle(s):", content) + self.assertIn("Test Bundle", content) + self.assertIn("Status: Pending", content) + + def test_panel_content_non_bundled_model(self): + """Test panel content for non-bundled models.""" + + class DummyModel: + pass + + panel = self.panel.bind_to_model(DummyModel) + bound_panel = panel.get_bound_panel(instance=DummyModel()) + self.assertEqual(bound_panel.content, "") diff --git a/cms/bundles/tests/test_views.py b/cms/bundles/tests/test_views.py new file mode 100644 index 00000000..0c15a24e --- /dev/null +++ b/cms/bundles/tests/test_views.py @@ -0,0 +1,87 @@ +from http import HTTPStatus + +from django.test import TestCase +from django.urls import reverse +from wagtail.test.utils.wagtail_tests import WagtailTestUtils + +from cms.analysis.tests.factories import AnalysisPageFactory +from cms.bundles.admin_forms import AddToBundleForm +from cms.bundles.models import Bundle +from cms.bundles.tests.factories import BundleFactory, BundlePageFactory +from cms.bundles.tests.utils import grant_all_bundle_permissions, grant_all_page_permissions, make_bundle_viewer +from cms.users.tests.factories import GroupFactory, UserFactory + + +class AddToBundleViewTestCase(WagtailTestUtils, TestCase): + @classmethod + def setUpTestData(cls): + cls.superuser = cls.create_superuser(username="admin") + + cls.publishing_group = GroupFactory(name="Publishing Officers", access_admin=True) + grant_all_bundle_permissions(cls.publishing_group) + grant_all_page_permissions(cls.publishing_group) + cls.publishing_officer = UserFactory(username="publishing_officer") + cls.publishing_officer.groups.add(cls.publishing_group) + + cls.bundle_viewer = UserFactory(username="bundle.viewer", access_admin=True) + make_bundle_viewer(cls.bundle_viewer) + + def setUp(self): + self.bundle = BundleFactory(name="First Bundle", created_by=self.publishing_officer) + self.analysis_page = AnalysisPageFactory(title="November 2024", parent__title="PSF") + self.add_url = reverse("bundles:add_to_bundle", args=[self.analysis_page.id]) + self.bundle_index_url = reverse("bundle:index") + + self.client.force_login(self.publishing_officer) + + def test_dispatch__happy_path(self): + """Dispatch should not complain about anything.""" + response = self.client.get(f"{self.add_url}?next={self.bundle_index_url}") + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertTemplateUsed(response, "bundles/wagtailadmin/add_to_bundle.html") + + self.assertEqual(response.context["page_to_add"], self.analysis_page) + self.assertEqual(response.context["next"], self.bundle_index_url) + self.assertIsInstance(response.context["form"], AddToBundleForm) + self.assertEqual(response.context["form"].page_to_add, self.analysis_page) + + def test_dispatch__returns_404_for_wrong_page_id(self): + """The page must exist in the first place.""" + url = reverse("bundles:add_to_bundle", args=[99999]) + response = self.client.get(url) + self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND) + + def test_dispatch__returns_404_for_non_bundleable_page(self): + """Only pages with BundledPageMixin can be added to a bundle.""" + url = reverse("bundles:add_to_bundle", args=[self.analysis_page.get_parent().id]) + response = self.client.get(url) + self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND) + + def test_dispatch__returns_404_if_user_doesnt_have_access(self): + """Only users that can edit see the page are allowed to add it to the bundle.""" + self.client.force_login(self.bundle_viewer) + response = self.client.get(self.add_url, follow=True) + self.assertRedirects(response, "/admin/") + self.assertContains(response, "Sorry, you do not have permission to access this area.") + + def test_dispatch__doesnt_allow_adding_page_already_in_active_bundle(self): + """Tests that we get redirected away with a corresponding message when the page we try to add to the bundle is + already in a different bundle. + """ + another_bundle = BundleFactory(name="Another Bundle") + BundlePageFactory(parent=another_bundle, page=self.analysis_page) + response = self.client.get(self.add_url, follow=True) + self.assertRedirects(response, "/admin/") + self.assertContains(response, "PSF: November 2024 is already in a bundle") + + def test_post__successful(self): + """Checks that on successful post, the page is added to the bundle and + we get redirected to the valid next URL. + """ + response = self.client.post( + f"{self.add_url}?next={self.bundle_index_url}", data={"bundle": self.bundle.id}, follow=True + ) + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertContains(response, "Page 'PSF: November 2024' added to bundle 'First Bundle'") + self.assertQuerySetEqual(self.analysis_page.bundles, Bundle.objects.all()) diff --git a/cms/bundles/tests/test_viewsets.py b/cms/bundles/tests/test_viewsets.py new file mode 100644 index 00000000..f093ec2d --- /dev/null +++ b/cms/bundles/tests/test_viewsets.py @@ -0,0 +1,235 @@ +from http import HTTPStatus +from unittest import mock + +from django.test import TestCase +from django.urls import reverse +from wagtail.test.utils import WagtailTestUtils +from wagtail.test.utils.form_data import inline_formset, nested_form_data + +from cms.analysis.tests.factories import AnalysisPageFactory +from cms.bundles.enums import BundleStatus +from cms.bundles.models import Bundle +from cms.bundles.tests.factories import BundleFactory +from cms.bundles.tests.utils import grant_all_bundle_permissions, make_bundle_viewer +from cms.users.tests.factories import GroupFactory, UserFactory + + +class BundleViewSetTestCase(WagtailTestUtils, TestCase): + """Test Bundle viewset functionality.""" + + @classmethod + def setUpTestData(cls): + cls.superuser = cls.create_superuser(username="admin") + + cls.publishing_group = GroupFactory(name="Publishing Officers", access_admin=True) + grant_all_bundle_permissions(cls.publishing_group) + cls.publishing_officer = UserFactory(username="publishing_officer") + cls.publishing_officer.groups.add(cls.publishing_group) + + cls.bundle_viewer = UserFactory(username="bundle.viewer", access_admin=True) + make_bundle_viewer(cls.bundle_viewer) + + # a regular generic_user that can only access the Wagtail admin + cls.generic_user = UserFactory(username="generic.generic_user", access_admin=True) + + cls.bundle_index_url = reverse("bundle:index") + cls.bundle_add_url = reverse("bundle:add") + + cls.released_bundle = BundleFactory(released=True, name="Release Bundle") + cls.released_bundle_edit_url = reverse("bundle:edit", args=[cls.released_bundle.id]) + + cls.approved_bundle = BundleFactory(approved=True, name="Approve Bundle") + cls.approved_bundle_edit_url = reverse("bundle:edit", args=[cls.approved_bundle.id]) + + def setUp(self): + self.bundle = BundleFactory(name="Original bundle", created_by=self.publishing_officer) + self.analysis_page = AnalysisPageFactory(title="PSF") + + self.edit_url = reverse("bundle:edit", args=[self.bundle.id]) + + self.client.force_login(self.publishing_officer) + + def test_bundle_index__unhappy_paths(self): + """Test bundle list view permissions.""" + self.client.logout() + response = self.client.get(self.bundle_index_url) + self.assertEqual(response.status_code, HTTPStatus.FOUND) + self.assertRedirects(response, f"/admin/login/?next={self.bundle_index_url}") + + self.client.force_login(self.generic_user) + response = self.client.get(self.bundle_index_url, follow=True) + self.assertRedirects(response, "/admin/") + self.assertContains(response, "Sorry, you do not have permission to access this area.") + + def test_bundle_index__happy_path(self): + """Users with bundle permissions can see the index.""" + for user in [self.bundle_viewer, self.publishing_officer, self.superuser]: + with self.subTest(user=user): + self.client.force_login(user) + response = self.client.get(self.bundle_index_url) + self.assertEqual(response.status_code, HTTPStatus.OK) + + def test_bundle_add_view(self): + """Test bundle creation.""" + response = self.client.post( + self.bundle_add_url, + { + "name": "A New Bundle", + "status": BundleStatus.PENDING, + "bundled_pages-TOTAL_FORMS": "1", + "bundled_pages-INITIAL_FORMS": "0", + "bundled_pages-MIN_NUM_FORMS": "0", + "bundled_pages-MAX_NUM_FORMS": "1000", + "bundled_pages-0-page": str(self.analysis_page.id), + "bundled_pages-0-ORDER": "0", + }, + ) + + self.assertEqual(response.status_code, 302) + self.assertTrue(Bundle.objects.filter(name="A New Bundle").exists()) + + def test_bundle_add_view__with_page_already_in_a_bundle(self): + """Test bundle creation.""" + response = self.client.post( + self.bundle_add_url, + { + "name": "A New Bundle", + "status": BundleStatus.PENDING, + "bundled_pages-TOTAL_FORMS": "1", + "bundled_pages-INITIAL_FORMS": "0", + "bundled_pages-MIN_NUM_FORMS": "0", + "bundled_pages-MAX_NUM_FORMS": "1000", + "bundled_pages-0-page": str(self.analysis_page.id), + "bundled_pages-0-ORDER": "0", + }, + ) + + self.assertEqual(response.status_code, 302) + self.assertTrue(Bundle.objects.filter(name="A New Bundle").exists()) + + def test_bundle_add_view__without_permissions(self): + """Checks that users without permission cannot access the add bundle page.""" + for user in [self.generic_user, self.bundle_viewer]: + with self.subTest(user=user): + self.client.force_login(user) + response = self.client.get(self.bundle_add_url, follow=True) + self.assertRedirects(response, "/admin/") + self.assertContains(response, "Sorry, you do not have permission to access this area.") + + def test_bundle_edit_view(self): + """Test bundle editing.""" + response = self.client.post( + self.edit_url, + nested_form_data( + { + "name": "Updated Bundle", + "status": self.bundle.status, + "bundled_pages": inline_formset([{"page": self.analysis_page.id}]), + } + ), + ) + + self.assertEqual(response.status_code, 302) + self.bundle.refresh_from_db() + self.assertEqual(self.bundle.name, "Updated Bundle") + + def test_bundle_edit_view__redirects_to_index_for_released_bundles(self): + """Released bundles should no longer be editable.""" + response = self.client.get(self.released_bundle_edit_url) + self.assertRedirects(response, self.bundle_index_url) + + def test_bundle_edit_view__updates_approved_fields_on_save_and_approve(self): + """Checks the fields are populated if the user clicks the 'Save and approve' button.""" + self.client.force_login(self.superuser) + self.client.post( + self.edit_url, + nested_form_data( + { + "name": "Updated Bundle", + "status": self.bundle.status, # correct. "save and approve" should update the status directly + "bundled_pages": inline_formset([]), + "action-save-and-approve": "save-and-approve", + } + ), + ) + self.bundle.refresh_from_db() + self.assertEqual(self.bundle.status, BundleStatus.APPROVED) + self.assertIsNotNone(self.bundle.approved_at) + self.assertEqual(self.bundle.approved_by, self.superuser) + + @mock.patch("cms.bundles.viewsets.notify_slack_of_status_change") + def test_bundle_approval__happy_path(self, mock_notify_slack): + """Test bundle approval workflow.""" + self.client.force_login(self.superuser) + response = self.client.post( + self.edit_url, + { + "name": self.bundle.name, + "status": BundleStatus.APPROVED, + "bundled_pages-TOTAL_FORMS": "1", + "bundled_pages-INITIAL_FORMS": "1", + "bundled_pages-MIN_NUM_FORMS": "0", + "bundled_pages-MAX_NUM_FORMS": "1000", + "bundled_pages-0-id": "", + "bundled_pages-0-page": str(self.analysis_page.id), + "bundled_pages-0-ORDER": "0", + }, + ) + + self.assertEqual(response.status_code, 302) + self.bundle.refresh_from_db() + self.assertEqual(self.bundle.status, BundleStatus.APPROVED) + self.assertIsNotNone(self.bundle.approved_at) + self.assertEqual(self.bundle.approved_by, self.superuser) + + self.assertTrue(mock_notify_slack.called) + + @mock.patch("cms.bundles.viewsets.notify_slack_of_status_change") + def test_bundle_approval__cannot__self_approve(self, mock_notify_slack): + """Test bundle approval workflow.""" + self.client.force_login(self.publishing_officer) + original_status = self.bundle.status + + response = self.client.post( + self.edit_url, + { + "name": self.bundle.name, + "status": BundleStatus.APPROVED, + "bundled_pages-TOTAL_FORMS": "1", + "bundled_pages-INITIAL_FORMS": "1", + "bundled_pages-MIN_NUM_FORMS": "0", + "bundled_pages-MAX_NUM_FORMS": "1000", + "bundled_pages-0-id": "", + "bundled_pages-0-page": str(self.analysis_page.id), + "bundled_pages-0-ORDER": "0", + }, + follow=True, + ) + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(response.context["request"].path, self.edit_url) + self.assertContains(response, "You cannot self-approve your own bundle!") + + form = response.context["form"] + self.assertIsNone(form.cleaned_data["approved_by"]) + self.assertIsNone(form.cleaned_data["approved_at"]) + self.assertIsNone(form.fields["approved_by"].initial) + self.assertIsNone(form.fields["approved_at"].initial) + + self.bundle.refresh_from_db() + self.assertEqual(self.bundle.status, original_status) + self.assertIsNone(self.bundle.approved_at) + self.assertIsNone(self.bundle.approved_by) + + self.assertFalse(mock_notify_slack.called) + + def test_index_view(self): + """Checks the content of the index page.""" + response = self.client.get(self.bundle_index_url) + self.assertContains(response, self.edit_url) + self.assertContains(response, self.approved_bundle_edit_url) + self.assertNotContains(response, self.released_bundle_edit_url) + + self.assertContains(response, "Pending", 2) # status + status filter + self.assertContains(response, "Released", 2) # status + status filter + self.assertContains(response, "Approved", 5) # status + status filter, approved at/by diff --git a/cms/bundles/tests/test_wagtail_hooks.py b/cms/bundles/tests/test_wagtail_hooks.py new file mode 100644 index 00000000..25d908c6 --- /dev/null +++ b/cms/bundles/tests/test_wagtail_hooks.py @@ -0,0 +1,183 @@ +from datetime import timedelta + +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from wagtail.test.utils.wagtail_tests import WagtailTestUtils + +from cms.analysis.tests.factories import AnalysisPageFactory +from cms.bundles.models import Bundle +from cms.bundles.tests.factories import BundleFactory, BundlePageFactory +from cms.bundles.tests.utils import grant_all_bundle_permissions, grant_all_page_permissions, make_bundle_viewer +from cms.bundles.wagtail_hooks import LatestBundlesPanel +from cms.release_calendar.tests.factories import ReleaseCalendarPageFactory +from cms.users.tests.factories import GroupFactory, UserFactory + + +class WagtailHooksTestCase(WagtailTestUtils, TestCase): + @classmethod + def setUpTestData(cls): + cls.superuser = cls.create_superuser(username="admin") + + cls.publishing_group = GroupFactory(name="Publishing Officers", access_admin=True) + grant_all_page_permissions(cls.publishing_group) + grant_all_bundle_permissions(cls.publishing_group) + cls.publishing_officer = UserFactory(username="publishing_officer") + cls.publishing_officer.groups.add(cls.publishing_group) + + cls.bundle_viewer = UserFactory(username="bundle.viewer", access_admin=True) + make_bundle_viewer(cls.bundle_viewer) + + # a regular generic_user that can only access the Wagtail admin + cls.generic_user = UserFactory(username="generic.generic_user", access_admin=True) + + cls.bundle_index_url = reverse("bundle:index") + cls.bundle_add_url = reverse("bundle:add") + cls.dashboard_url = reverse("wagtailadmin_home") + + cls.pending_bundle = BundleFactory(name="Pending Bundle", created_by=cls.bundle_viewer) + cls.in_review_bundle = BundleFactory(in_review=True, name="Bundle In review", created_by=cls.superuser) + cls.approved_bundle = BundleFactory(name="Approved Bundle", approved=True, created_by=cls.publishing_officer) + cls.released_bundle = BundleFactory(released=True, name="Released Bundle") + + cls.analysis_page = AnalysisPageFactory(title="November 2024", parent__title="PSF") + cls.analysis_edit_url = reverse("wagtailadmin_pages:edit", args=[cls.analysis_page.id]) + cls.analysis_parent_url = reverse("wagtailadmin_explore", args=[cls.analysis_page.get_parent().id]) + cls.add_to_bundle_url = reverse("bundles:add_to_bundle", args=[cls.analysis_page.id]) + + def test_latest_bundles_panel_is_shown(self): + """Checks that the latest bundles dashboard panel is shown to relevant users.""" + cases = [ + (self.generic_user, 0), + (self.bundle_viewer, 1), + (self.publishing_officer, 1), + (self.superuser, 1), + ] + for user, shown in cases: + with self.subTest(user=user.username, shown=shown): + self.client.force_login(user) + response = self.client.get(self.dashboard_url) + self.assertContains(response, "Latest active bundles", count=shown) + + def test_latest_bundles_panel_content(self): + """Checks that the latest bundles dashboard panel content only shows active bundles.""" + self.client.force_login(self.publishing_officer) + response = self.client.get(self.dashboard_url) + + panels = response.context["panels"] + self.assertIsInstance(panels[0], LatestBundlesPanel) + self.assertIs(panels[0].permission_policy.model, Bundle) + + self.assertContains(response, self.bundle_add_url) + self.assertContains(response, self.bundle_index_url) + self.assertContains(response, "View all bundles") + + for bundle in [self.pending_bundle, self.in_review_bundle, self.approved_bundle]: + self.assertContains(response, bundle.name) + self.assertContains(response, bundle.created_by.get_full_name()) + self.assertContains(response, bundle.status.label) + self.assertNotContains(response, self.released_bundle.status.label) + + def test_preset_golive_date__happy_path(self): + """Checks we update the page go live at on page edit , if in the future and doesn't match the bundle date.""" + self.client.force_login(self.publishing_officer) + + BundlePageFactory(parent=self.pending_bundle, page=self.analysis_page) + + # set to +15 minutes as the check is on now() < scheduled_publication_date & page.go_live_at != scheduled + nowish = timezone.now() + timedelta(minutes=15) + bundle_date = nowish + timedelta(hours=1) + + cases = [ + # bundle publication date, page go_live_at, expected change, case description + (nowish - timedelta(hours=1), nowish, nowish, "Go live unchanged as bundle date in the past"), + (bundle_date, bundle_date, bundle_date, "Go live unchanged as it matches bundle"), + (bundle_date, nowish + timedelta(days=1), bundle_date, "Go live updated to match bundle"), + ] + for bundle_publication_date, go_live_at, expected, case in cases: + with self.subTest(go_live_at=go_live_at, expected=expected, case=case): + self.pending_bundle.publication_date = bundle_publication_date + self.pending_bundle.save(update_fields=["publication_date"]) + + self.analysis_page.go_live_at = go_live_at + self.analysis_page.save(update_fields=["go_live_at"]) + + response = self.client.get(self.analysis_edit_url) + context_page = response.context["page"] + self.assertEqual(context_page.go_live_at, expected) + + def test_preset_golive_date__updates_only_if_page_in_active_bundle(self): + """Checks the go live at update only happens if the page is in active bundle.""" + self.client.force_login(self.publishing_officer) + + nowish = timezone.now() + timedelta(minutes=15) + self.pending_bundle.publication_date = nowish + timedelta(hours=1) + self.pending_bundle.save(update_fields=["publication_date"]) + + self.analysis_page.go_live_at = nowish + self.analysis_page.save(update_fields=["go_live_at"]) + + response = self.client.get(self.analysis_edit_url) + context_page = response.context["page"] + self.assertEqual(context_page.go_live_at, nowish) + + def test_preset_golive_date__updates_only_if_page_is_bundleable(self): + """Checks the go live at change happens only for bundleable pages..""" + self.client.force_login(self.publishing_officer) + + nowish = timezone.now() + timedelta(minutes=15) + self.pending_bundle.publication_date = nowish + timedelta(hours=1) + self.pending_bundle.save(update_fields=["publication_date"]) + + page = ReleaseCalendarPageFactory() + page.go_live_at = nowish + page.save(update_fields=["go_live_at"]) + + response = self.client.get(reverse("wagtailadmin_pages:edit", args=[page.id])) + context_page = response.context["page"] + self.assertEqual(context_page.go_live_at, nowish) + + def test_add_to_bundle_buttons(self): + """Tests that the 'Add to Bundle' button appears in appropriate contexts.""" + # Test both header and listing contexts + contexts = [(self.analysis_edit_url, "header"), (self.analysis_parent_url, "listing")] + + for user in [self.generic_user, self.bundle_viewer]: + for url, context in contexts: + with self.subTest(user=user.username, context=context): + self.client.force_login(user) + response = self.client.get(url, follow=True) + self.assertEqual(response.context["request"].path, reverse("wagtailadmin_home")) + self.assertContains(response, "Sorry, you do not have permission to access this area.") + + cases_with_access = [ + (self.publishing_officer, 1), # Has all permissions, should see button + (self.superuser, 1), # Has all permissions, should see button + ] + for user, expected_count in cases_with_access: + for url, context in contexts: + with self.subTest(user=user.username, context=context, expected=expected_count): + self.client.force_login(user) + response = self.client.get(url) + + # Check if button appears in response + self.assertContains(response, "Add to Bundle", count=expected_count) + self.assertContains(response, "boxes-stacked") # icon name + self.assertContains(response, self.add_to_bundle_url) + + def test_add_to_bundle_buttons__doesnt_show_for_pages_in_bundle(self): + """Checks that the button doesn't appear for pages already in a bundle.""" + release_calendar_page = ReleaseCalendarPageFactory() + contexts = [ + (reverse("wagtailadmin_pages:edit", args=[release_calendar_page.id]), "header"), + (reverse("wagtailadmin_explore", args=[release_calendar_page.get_parent().id]), "listing"), + ] + BundlePageFactory(parent=self.pending_bundle, page=self.analysis_page) + + self.client.force_login(self.publishing_officer) + + for url, context in contexts: + with self.subTest(msg=f"Non-bundleable page - {context}"): + response = self.client.get(url) + self.assertNotContains(response, "Add to Bundle") + self.assertNotContains(response, self.add_to_bundle_url) diff --git a/cms/bundles/tests/utils.py b/cms/bundles/tests/utils.py new file mode 100644 index 00000000..08baed0b --- /dev/null +++ b/cms/bundles/tests/utils.py @@ -0,0 +1,56 @@ +from typing import TYPE_CHECKING + +from django.contrib.auth.models import Permission +from django.db.models import QuerySet +from wagtail.models import GroupPagePermission + +from cms.home.models import HomePage + +if TYPE_CHECKING: + from django.contrib.auth.models import Group + + from cms.users.models import User + + +def get_all_bundle_permissions() -> QuerySet[Permission]: + """Gets all bundle permissions.""" + return Permission.objects.filter( + codename__in=[ + "add_bundle", + "change_bundle", + "delete_bundle", + "view_bundle", + ] + ) + + +def get_view_bundle_permission() -> Permission: + """Returns the view bundle permission.""" + return Permission.objects.get(codename="view_bundle") + + +def make_bundle_manager(user: "User") -> None: + """Givess all the bundle permissions to the given user.""" + user.user_permissions.add(*get_all_bundle_permissions()) + + +def make_bundle_viewer(user: "User") -> None: + """Gives the view bundle permission to the given user.""" + user.user_permissions.add(get_view_bundle_permission()) + + +def grant_all_bundle_permissions(group: "Group") -> None: + """Adds all the bundle permissions to the given group.""" + group.permissions.add(*get_all_bundle_permissions()) + + +def grant_view_bundle_permissions(group: "Group") -> None: + """Adds the view bundle permission to the given group.""" + group.permissions.add(get_view_bundle_permission()) + + +def grant_all_page_permissions(group: "Group") -> None: + """Adds all the page permissions to the given group.""" + home = HomePage.objects.first() + for permission_type in ["add", "change", "delete", "view"]: + GroupPagePermission.objects.create(group=group, page=home, permission_type=permission_type) diff --git a/cms/bundles/views.py b/cms/bundles/views.py new file mode 100644 index 00000000..98d9e4e4 --- /dev/null +++ b/cms/bundles/views.py @@ -0,0 +1,106 @@ +from typing import TYPE_CHECKING, Any + +from django.core.exceptions import PermissionDenied +from django.http import Http404 +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse +from django.utils.http import url_has_allowed_host_and_scheme +from django.utils.text import get_text_list +from django.utils.translation import gettext as _ +from django.views.generic import FormView +from wagtail.admin import messages +from wagtail.models import Page +from wagtail.permission_policies import ModelPermissionPolicy + +from .admin_forms import AddToBundleForm +from .models import Bundle, BundledPageMixin, BundlePage + +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponseBase, HttpResponseRedirect + + +class AddToBundleView(FormView): + form_class = AddToBundleForm + template_name = "bundles/wagtailadmin/add_to_bundle.html" + + page_to_add: Page = None + goto_next: str | None = None + + def dispatch(self, request: "HttpRequest", *args: Any, **kwargs: Any) -> "HttpResponseBase": + self.page_to_add = get_object_or_404( + Page.objects.specific().defer_streamfields(), id=self.kwargs["page_to_add_id"] + ) + + if not isinstance(self.page_to_add, BundledPageMixin): + raise Http404(_("Cannot add this page type to a bundle")) + + page_perms = self.page_to_add.permissions_for_user(request.user) # type: ignore[attr-defined] + # TODO: add the relevant permission checks + if not (page_perms.can_edit() or page_perms.can_publish()): + raise PermissionDenied + + permission_policy = ModelPermissionPolicy(Bundle) + if not permission_policy.user_has_permission(request.user, "change"): + raise PermissionDenied + + self.goto_next = None + redirect_to = request.GET.get("next", "") + if url_has_allowed_host_and_scheme(url=redirect_to, allowed_hosts={self.request.get_host()}): + self.goto_next = redirect_to + + if self.page_to_add.in_active_bundle: + messages.warning( + request, + _("Page %(title)s is already in a bundle ('%(bundles)s')") + % { + "title": self.page_to_add.get_admin_display_title(), # type: ignore[attr-defined] + "bundles": get_text_list( + list(self.page_to_add.active_bundles.values_list("name", flat=True)), last_word="and" + ), + }, + ) + if self.goto_next: + return redirect(self.goto_next) + + return redirect("wagtailadmin_home") + + return super().dispatch(request, *args, **kwargs) + + def get_form_kwargs(self) -> dict[str, Any]: + kwargs = super().get_form_kwargs() + kwargs.update({"page_to_add": self.page_to_add}) + return kwargs + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + context_data = super().get_context_data(**kwargs) + context_data.update( + { + "page_to_add": self.page_to_add, + "next": self.goto_next, + } + ) + return context_data + + def form_valid(self, form: AddToBundleForm) -> "HttpResponseRedirect": + bundle: Bundle = form.cleaned_data["bundle"] # the 'bundle' field is required in the form. + bundle.bundled_pages.add(BundlePage(page=self.page_to_add)) + bundle.save() + + messages.success( + self.request, + f"Page '{self.page_to_add.get_admin_display_title()}' added to bundle '{bundle}'", + buttons=[ + messages.button( + reverse("wagtailadmin_pages:edit", args=(self.page_to_add.id,)), + _("Edit"), + ) + ], + ) + redirect_to = self.request.POST.get("next", "") + if url_has_allowed_host_and_scheme(url=redirect_to, allowed_hosts={self.request.get_host()}): + return redirect(redirect_to) + + return redirect("wagtailadmin_explore", self.page_to_add.get_parent().id) + + +add_to_bundle = AddToBundleView.as_view() diff --git a/cms/bundles/viewsets.py b/cms/bundles/viewsets.py new file mode 100644 index 00000000..8da0ce60 --- /dev/null +++ b/cms/bundles/viewsets.py @@ -0,0 +1,312 @@ +import time +from functools import cached_property +from typing import TYPE_CHECKING, Any, ClassVar + +from django.db.models import QuerySet +from django.http import HttpRequest +from django.shortcuts import redirect +from django.urls import reverse +from django.utils import timezone +from django.utils.html import format_html, format_html_join +from django.utils.translation import gettext as _ +from wagtail.admin.ui.tables import Column, DateColumn, UpdatedAtColumn, UserColumn +from wagtail.admin.views.generic import CreateView, EditView, IndexView, InspectView +from wagtail.admin.views.generic.chooser import ChooseView +from wagtail.admin.viewsets.chooser import ChooserViewSet +from wagtail.admin.viewsets.model import ModelViewSet +from wagtail.log_actions import log + +from .enums import BundleStatus +from .models import Bundle +from .notifications import notify_slack_of_publication_start, notify_slack_of_publish_end, notify_slack_of_status_change + +if TYPE_CHECKING: + from django.db.models.fields import Field + from django.http import HttpResponseBase + from django.utils.safestring import SafeString + + +class BundleCreateView(CreateView): + """The Bundle create view class.""" + + def save_instance(self) -> Bundle: + """Automatically set the creating user on Bundle creation.""" + instance: Bundle = super().save_instance() + instance.created_by = self.request.user + instance.save(update_fields=["created_by"]) + return instance + + +class BundleEditView(EditView): + """The Bundle edit view class.""" + + actions: ClassVar[list[str]] = ["edit", "save-and-approve", "publish"] + template_name = "bundles/wagtailadmin/edit.html" + has_content_changes: bool = False + start_time: float | None = None + + def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> "HttpResponseBase": + if (instance := self.get_object()) and instance.status == BundleStatus.RELEASED: + return redirect(self.index_url_name) + + response: HttpResponseBase = super().dispatch(request, *args, **kwargs) + return response + + def get_form_kwargs(self) -> dict: + kwargs: dict = super().get_form_kwargs() + if self.request.method == "POST": + data = self.request.POST.copy() + if "action-save-and-approve" in self.request.POST: + data["status"] = BundleStatus.APPROVED.value + data["approved_at"] = timezone.now() + data["approved_by"] = self.request.user + kwargs["data"] = data + elif "action-publish" in self.request.POST: + data["status"] = BundleStatus.RELEASED.value + kwargs["data"] = data + return kwargs + + def save_instance(self) -> Bundle: + instance: Bundle = self.form.save() + self.has_content_changes = self.form.has_changed() + + if not self.has_content_changes: + return instance + + log(action="wagtail.edit", instance=instance, content_changed=True, data={"fields": self.form.changed_data}) + + if "status" not in self.form.changed_data: + return instance + + kwargs: dict = {"content_changed": self.has_content_changes} + original_status = BundleStatus[self.form.original_status].label + url = self.request.build_absolute_uri(reverse("bundle:inspect", args=(instance.pk,))) + + if instance.status == BundleStatus.APPROVED: + action = "bundles.approve" + kwargs["data"] = {"old": original_status} + notify_slack_of_status_change(instance, original_status, user=self.request.user, url=url) + elif instance.status == BundleStatus.RELEASED.value: + action = "wagtail.publish" + self.start_time = time.time() + else: + action = "bundles.update_status" + kwargs["data"] = { + "old": original_status, + "new": instance.get_status_display(), + } + notify_slack_of_status_change(instance, original_status, user=self.request.user, url=url) + + # now log the status change + log( + action=action, + instance=instance, + **kwargs, + ) + + return instance + + def run_after_hook(self) -> None: + """This method allows calling hooks or additional logic after an action has been executed. + + In our case, we want to send a Slack notification if manually published, and approve any of the + related pages that are in a Wagtail workflow. + """ + if self.action == "publish" or (self.action == "edit" and self.object.status == BundleStatus.RELEASED): + notify_slack_of_publication_start(self.object, user=self.request.user) + start_time = self.start_time or time.time() + for page in self.object.get_bundled_pages(): + if page.current_workflow_state: + page.current_workflow_state.current_task_state.approve(user=self.request.user) + + notify_slack_of_publish_end(self.object, time.time() - start_time, user=self.request.user) + + def get_context_data(self, **kwargs: Any) -> dict: + """Updates the template context. + + Show the "save and approve" button if the bundle has the right status, and we have a different user + than the creator + """ + context: dict = super().get_context_data(**kwargs) + + context["show_save_and_approve"] = ( + self.object.can_be_approved and self.form.for_user.pk != self.object.created_by_id + ) + context["show_publish"] = ( + self.object.status == BundleStatus.APPROVED and not self.object.scheduled_publication_date + ) + + return context + + +class BundleInspectView(InspectView): + """The Bundle inspect view class.""" + + template_name = "bundles/wagtailadmin/inspect.html" + + def get_fields(self) -> list[str]: + """Returns the list of fields to include in the inspect view.""" + return ["name", "status", "created_at", "created_by", "approved", "scheduled_publication", "pages"] + + def get_field_label(self, field_name: str, field: "Field") -> str: + match field_name: + case "approved": + return _("Approval status") + case "scheduled_publication": + return _("Scheduled publication") + case "pages": + return _("Pages") + case _: + return super().get_field_label(field_name, field) # type: ignore[no-any-return] + + def get_field_display_value(self, field_name: str, field: "Field") -> Any: + """Allows customising field display in the inspect class. + + This allows us to use get_FIELDNAME_display_value methods. + """ + value_func = getattr(self, f"get_{field_name}_display_value", None) + if value_func is not None: + return value_func() + + return super().get_field_display_value(field_name, field) + + def get_approved_display_value(self) -> str: + """Custom approved by formatting. Varies based on status, and approver/time of approval.""" + if self.object.status in [BundleStatus.APPROVED, BundleStatus.RELEASED]: + if self.object.approved_by_id and self.object.approved_at: + return f"{self.object.approved_by} on {self.object.approved_at}" + return _("Unknown approval data") + return _("Pending approval") + + def get_scheduled_publication_display_value(self) -> str: + """Displays the scheduled publication date, if set.""" + return self.object.scheduled_publication_date or _("No scheduled publication") + + def get_pages_display_value(self) -> "SafeString": + """Returns formatted markup for Pages linked to the Bundle.""" + pages = self.object.get_bundled_pages().specific() + data = ( + ( + reverse("wagtailadmin_pages:edit", args=(page.pk,)), + page.get_admin_display_title(), + page.get_verbose_name(), + ( + page.current_workflow_state.current_task_state.task.name + if page.current_workflow_state + else "not in a workflow" + ), + reverse("wagtailadmin_pages:view_draft", args=(page.pk,)), + ) + for page in pages + ) + + page_data = format_html_join( + "\n", + '{}{}{} ' + 'Preview', + data, + ) + + return format_html( + "" + "{}
    TitleTypeStatusActions
    ", + page_data, + ) + + +class BundleIndexView(IndexView): + """The Bundle index view class. + + We adjust the queryset and change the edit URL based on the bundle status. + """ + + model = Bundle + + def get_queryset(self) -> QuerySet[Bundle]: + """Modifies the Bundle queryset with the related created_by ForeignKey selected to avoid N+1 queries.""" + queryset: QuerySet[Bundle] = super().get_queryset() + + return queryset.select_related("created_by") + + def get_edit_url(self, instance: Bundle) -> str | None: + """Override the default edit url to disable the edit URL for released bundles.""" + if instance.status != BundleStatus.RELEASED: + edit_url: str | None = super().get_edit_url(instance) + return edit_url + return None + + def get_copy_url(self, instance: Bundle) -> str | None: + """Disables the bundle copy.""" + return None + + @cached_property + def columns(self) -> list[Column]: + """Defines the list of desired columns in the listing.""" + return [ + self._get_title_column("__str__"), + Column("scheduled_publication_date"), + Column("get_status_display", label=_("Status")), + UpdatedAtColumn(), + DateColumn(name="created_at", label=_("Added"), sort_key="created_at"), + UserColumn("created_by", label=_("Added by")), + DateColumn(name="approved_at", label=_("Approved at"), sort_key="approved_at"), + UserColumn("approved_by"), + ] + + +class BundleChooseView(ChooseView): + """The Bundle choose view class. Used in choosers.""" + + icon = "boxes-stacked" + + @property + def columns(self) -> list[Column]: + """Defines the list of desired columns in the chooser.""" + return [ + *super().columns, + Column("scheduled_publication_date"), + UserColumn("created_by"), + ] + + def get_object_list(self) -> QuerySet[Bundle]: + """Overrides the default object list to only fetch the fields we're using.""" + queryset: QuerySet[Bundle] = Bundle.objects.select_related("created_by").only("name", "created_by") + return queryset + + +class BundleChooserViewSet(ChooserViewSet): + """Defines the chooser viewset for Bundles.""" + + model = Bundle + icon = "boxes-stacked" + choose_view_class = BundleChooseView + + def get_object_list(self) -> QuerySet[Bundle]: + """Only return editable bundles.""" + queryset: QuerySet[Bundle] = self.model.objects.editable() + return queryset + + +class BundleViewSet(ModelViewSet): + """The viewset class for Bundle. + + We extend the generic ModelViewSet to add our customisations. + @see https://docs.wagtail.org/en/stable/reference/viewsets.html#modelviewset + """ + + model = Bundle + icon = "boxes-stacked" + add_view_class = BundleCreateView + edit_view_class = BundleEditView + inspect_view_class = BundleInspectView + index_view_class = BundleIndexView + chooser_viewset_class = BundleChooserViewSet + list_filter: ClassVar[list[str]] = ["status", "created_by"] + add_to_admin_menu = True + inspect_view_enabled = True + + +bundle_viewset = BundleViewSet("bundle") +bundle_chooser_viewset = BundleChooserViewSet("bundle_chooser") + +BundleChooserWidget = bundle_chooser_viewset.widget_class diff --git a/cms/bundles/wagtail_hooks.py b/cms/bundles/wagtail_hooks.py new file mode 100644 index 00000000..12d9a7db --- /dev/null +++ b/cms/bundles/wagtail_hooks.py @@ -0,0 +1,199 @@ +from functools import cached_property +from typing import TYPE_CHECKING, Any, Union + +from django.db.models import QuerySet +from django.urls import include, path +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from wagtail import hooks +from wagtail.admin.ui.components import Component +from wagtail.admin.widgets import PageListingButton +from wagtail.log_actions import LogFormatter +from wagtail.permission_policies import ModelPermissionPolicy + +from . import admin_urls +from .models import Bundle, BundledPageMixin +from .viewsets import bundle_chooser_viewset, bundle_viewset + +if TYPE_CHECKING: + from django.http import HttpRequest + from django.urls import URLPattern + from django.urls.resolvers import URLResolver + from laces.typing import RenderContext + from wagtail.log_actions import LogActionRegistry + from wagtail.models import ModelLogEntry, Page + + from cms.users.models import User + + +@hooks.register("register_admin_viewset") +def register_viewset() -> list: + """Registers the bundle viewsets. + + @see https://docs.wagtail.org/en/stable/reference/hooks.html#register-admin-viewset + """ + return [bundle_viewset, bundle_chooser_viewset] + + +class PageAddToBundleButton(PageListingButton): + """Defines the 'Add to Bundle' button to use in different contexts in the admin.""" + + label = _("Add to Bundle") + icon_name = "boxes-stacked" + aria_label_format = _("Add '%(title)s' to a bundle") + url_name = "bundles:add_to_bundle" + + @property + def permission_policy(self) -> ModelPermissionPolicy: + """Informs the permission policy to use Bundle-derived model permissions.""" + return ModelPermissionPolicy(Bundle) + + @property + def show(self) -> bool: + """Determines whether the button should be shown. + + We only want it for pages inheriting from BundledPageMixin that are not in an active bundle. + """ + if not isinstance(self.page, BundledPageMixin): + return False + + if self.page.in_active_bundle: + return False + + # Note: limit to pages that are not in an active bundle + can_show: bool = ( + self.page_perms.can_edit() or self.page_perms.can_publish() + ) and self.permission_policy.user_has_any_permission(self.user, ["add", "change", "delete"]) + return can_show + + +@hooks.register("register_page_header_buttons") +def page_header_buttons(page: "Page", user: "User", view_name: str, next_url: str | None = None) -> PageListingButton: # pylint: disable=unused-argument + """Registers the add to bundle button in the buttons shown in the page add/edit header. + + @see https://docs.wagtail.org/en/stable/reference/hooks.html#register-page-header-buttons. + """ + yield PageAddToBundleButton(page=page, user=user, priority=10, next_url=next_url) + + +@hooks.register("register_page_listing_buttons") +def page_listing_buttons(page: "Page", user: "User", next_url: str | None = None) -> PageListingButton: + """Registers the add to bundle button in the buttons shown in the page listing. + + @see https://docs.wagtail.org/en/stable/reference/hooks.html#register_page_listing_buttons. + """ + yield PageAddToBundleButton(page=page, user=user, priority=10, next_url=next_url) + + +@hooks.register("register_admin_urls") +def register_admin_urls() -> list[Union["URLPattern", "URLResolver"]]: + """Registers the admin urls for Bundles. + + @see https://docs.wagtail.org/en/stable/reference/hooks.html#register-admin-urls. + """ + return [path("bundles/", include(admin_urls))] + + +@hooks.register("before_edit_page") +def preset_golive_date(request: "HttpRequest", page: "Page") -> None: + """Implements the before_edit_page to preset the golive date on pages in active bundles. + + @see https://docs.wagtail.org/en/stable/reference/hooks.html#before-edit-page. + """ + if not isinstance(page, BundledPageMixin): + return + + if not page.in_active_bundle: + return + + scheduled_date = page.active_bundle.scheduled_publication_date # type: ignore[union-attr] + # note: ignoring union-attr because we already check that the page is in an active bundle. + if not scheduled_date: + return + + # note: ignoring + # - attr-defined because mypy thinks page is only a BundledPageMixin class, rather than Page and BundledPageMixin. + # - union-attr because active_bundle can be none, but we check for that above + if now() < scheduled_date and scheduled_date != page.go_live_at: # type: ignore[attr-defined] + # pre-set the scheduled publishing time + page.go_live_at = scheduled_date # type: ignore[attr-defined] + + +class LatestBundlesPanel(Component): + """The admin dashboard panel for showing the latest bundles.""" + + name = "latest_bundles" + order = 150 + template_name = "bundles/wagtailadmin/panels/latest_bundles.html" + + def __init__(self, request: "HttpRequest") -> None: + self.request = request + self.permission_policy = ModelPermissionPolicy(Bundle) + + @cached_property + def is_shown(self) -> bool: + """Determine if the panel is shown based on whether the user can modify it.""" + has_permission: bool = self.permission_policy.user_has_any_permission( + self.request.user, {"add", "change", "delete", "view"} + ) + return has_permission + + def get_latest_bundles(self) -> QuerySet[Bundle]: + """Returns the latest 10 bundles if the panel is shown.""" + queryset: QuerySet[Bundle] = Bundle.objects.none() + if self.is_shown: + queryset = Bundle.objects.active().select_related("created_by")[:10] + + return queryset + + def get_context_data(self, parent_context: "RenderContext") -> "RenderContext": + """Adds the request, the latest bundles and whether the panel is shown to the panel context.""" + context = super().get_context_data(parent_context) + context["request"] = self.request + context["bundles"] = self.get_latest_bundles() + context["is_shown"] = self.is_shown + return context + + +@hooks.register("construct_homepage_panels") +def add_latest_bundles_panel(request: "HttpRequest", panels: list[Component]) -> None: + """Adds the LatestBundlesPanel to the list of Wagtail admin dashboard panels. + + @see https://docs.wagtail.org/en/stable/reference/hooks.html#construct-homepage-panels + """ + panels.append(LatestBundlesPanel(request)) + + +@hooks.register("register_log_actions") +def register_bundle_log_actions(actions: "LogActionRegistry") -> None: + """Registers custom logging actions. + + @see https://docs.wagtail.org/en/stable/extending/audit_log.html + @see https://docs.wagtail.org/en/stable/reference/hooks.html#register-log-actions + """ + + @actions.register_action("bundles.update_status") + class ChangeBundleStatus(LogFormatter): # pylint: disable=unused-variable + """LogFormatter class for the bundle status change actions.""" + + label = _("Change bundle status") + + def format_message(self, log_entry: "ModelLogEntry") -> Any: + """Returns the formatted log message.""" + try: + return _(f"Changed the bundle status from '{log_entry.data["old"]}' to '{log_entry.data["new"]}'") + except KeyError: + return _("Changed the bundle status") + + @actions.register_action("bundles.approve") + class ApproveBundle(LogFormatter): # pylint: disable=unused-variable + """LogFormatter class for the bundle approval actions.""" + + label = _("Approve bundle") + + def format_message(self, log_entry: "ModelLogEntry") -> Any: + """Returns the formatted log message.""" + try: + return _(f"Approved the bundle. (Old status: '{log_entry.data["old"]}')") + except KeyError: + return _("Approved the bundle") diff --git a/cms/core/wagtail_hooks.py b/cms/core/wagtail_hooks.py index 59543867..2ae12adb 100644 --- a/cms/core/wagtail_hooks.py +++ b/cms/core/wagtail_hooks.py @@ -14,6 +14,7 @@ def register_icons(icons: list[str]) -> list[str]: """ return [ *icons, + "boxes-stacked.svg", "data-analysis.svg", "identity.svg", "news.svg", diff --git a/cms/jinja2/assets/icons/boxes-stacked.svg b/cms/jinja2/assets/icons/boxes-stacked.svg new file mode 100644 index 00000000..bd09df78 --- /dev/null +++ b/cms/jinja2/assets/icons/boxes-stacked.svg @@ -0,0 +1 @@ + diff --git a/cms/release_calendar/tests/test_forms.py b/cms/release_calendar/tests/test_forms.py index 63a967a5..5283db24 100644 --- a/cms/release_calendar/tests/test_forms.py +++ b/cms/release_calendar/tests/test_forms.py @@ -75,7 +75,7 @@ def test_form_clean__validates_notice(self): form = self.form_class(instance=self.page, data=data) self.assertFalse(form.is_valid()) - self.assertListEqual(form.errors["notice"], ["The notice field is required when the release is cancelled"]) + self.assertFormError(form, "notice", ["The notice field is required when the release is cancelled"]) def test_form_clean__validates_release_date_when_confirmed(self): """Validates that the release date must be set if the release is confirmed.""" @@ -95,8 +95,9 @@ def test_form_clean__validates_release_date_when_confirmed(self): self.assertEqual(form.is_valid(), is_valid) if not is_valid: - self.assertListEqual( - form.errors["release_date"], + self.assertFormError( + form, + "release_date", ["The release date field is required when the release is confirmed"], ) @@ -118,8 +119,9 @@ def test_form_clean__validates_release_date_text(self): self.assertEqual(form.is_valid(), is_valid) if not is_valid: - self.assertListEqual( - form.errors["release_date_text"], + self.assertFormError( + form, + "release_date_text", ["The release date text must be in the 'Month YYYY' or 'Month YYYY to Month YYYY' format."], ) @@ -130,7 +132,7 @@ def test_form_clean__validates_release_date_text_start_end_dates(self): form = self.form_class(instance=self.page, data=data) self.assertFalse(form.is_valid()) - self.assertListEqual(form.errors["release_date_text"], ["The end month must be after the start month."]) + self.assertFormError(form, "release_date_text", ["The end month must be after the start month."]) def test_form_clean__can_add_release_date_when_confirming(self): """Checks that we can set a new release date when the release is confirmed, if previously it was empty.""" @@ -157,8 +159,9 @@ def test_form_clean__validates_changes_to_release_date_must_be_filled(self): form = self.form_class(instance=self.page, data=data) self.assertFalse(form.is_valid()) - self.assertListEqual( - form.errors["changes_to_release_date"], + self.assertFormError( + form, + "changes_to_release_date", [ "If a confirmed calendar entry needs to be rescheduled, " "the 'Changes to release date' field must be filled out." @@ -192,8 +195,8 @@ def test_form_clean__validates_either_release_date_or_text(self): self.assertFalse(form.is_valid()) message = ["Please enter the release date or the release date text, not both."] - self.assertListEqual(form.errors["release_date"], message) - self.assertListEqual(form.errors["release_date_text"], message) + self.assertFormError(form, "release_date", message) + self.assertFormError(form, "release_date_text", message) def test_form_clean__validates_either_next_release_date_or_text(self): """Checks that editors can enter either the next release date or the text, not both.""" @@ -206,8 +209,8 @@ def test_form_clean__validates_either_next_release_date_or_text(self): self.assertFalse(form.is_valid()) message = ["Please enter the next release date or the next release text, not both."] - self.assertListEqual(form.errors["next_release_date"], message) - self.assertListEqual(form.errors["next_release_text"], message) + self.assertFormError(form, "next_release_date", message) + self.assertFormError(form, "next_release_text", message) def test_form_clean__validates_next_release_date_is_after_release_date(self): """Checks that editors enter a release that that is after the release date.""" @@ -219,4 +222,4 @@ def test_form_clean__validates_next_release_date_is_after_release_date(self): self.assertFalse(form.is_valid()) message = ["The next release date must be after the release date."] - self.assertListEqual(form.errors["next_release_date"], message) + self.assertFormError(form, "next_release_date", message) diff --git a/cms/settings/base.py b/cms/settings/base.py index cda9fbb2..2eb11493 100644 --- a/cms/settings/base.py +++ b/cms/settings/base.py @@ -57,6 +57,7 @@ INSTALLED_APPS = [ "cms.analysis", + "cms.bundles", "cms.core", "cms.documents", "cms.home", @@ -841,3 +842,6 @@ # ONS Cookie banner settings ONS_COOKIE_BANNER_SERVICE_NAME = env.get("ONS_COOKIE_BANNER_SERVICE_NAME", "www.ons.gov.uk") MANAGE_COOKIE_SETTINGS_URL = env.get("MANAGE_COOKIE_SETTINGS_URL", "https://www.ons.gov.uk/cookies") + + +SLACK_NOTIFICATIONS_WEBHOOK_URL = env.get("SLACK_NOTIFICATIONS_WEBHOOK_URL") diff --git a/cms/settings/test.py b/cms/settings/test.py index aee994b5..dbd96ecc 100644 --- a/cms/settings/test.py +++ b/cms/settings/test.py @@ -45,3 +45,7 @@ # Read replica should mirror the default database during tests. # https://docs.djangoproject.com/en/stable/topics/testing/advanced/#tests-and-multiple-databases DATABASES["read_replica"].setdefault("TEST", {"MIRROR": "default"}) # noqa: F405 + + +# Silence Slack notifications by default +SLACK_NOTIFICATIONS_WEBHOOK_URL = None diff --git a/cms/users/tests/__init__.py b/cms/users/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cms/users/tests/factories.py b/cms/users/tests/factories.py new file mode 100644 index 00000000..0b4afd43 --- /dev/null +++ b/cms/users/tests/factories.py @@ -0,0 +1,59 @@ +import factory +from django.contrib.auth.hashers import make_password +from django.contrib.auth.models import Group, Permission +from factory.django import DjangoModelFactory + +from cms.users.models import User + + +class GroupFactory(DjangoModelFactory): + class Meta: + model = Group + django_get_or_create = ("name",) + + @factory.post_generation + def access_admin(self, create, extracted, **kwargs): + """Creates BundlePage instances for the bundle. + + Usage: + # Create a Django generic_user group + group = GroupFactory() + + # Create a Django generic_user group with Wagtail admin access + group = GroupFactory(access_admin=True) + """ + if not create: + return + + if extracted: + admin_permission = Permission.objects.get(content_type__app_label="wagtailadmin", codename="access_admin") + self.permissions.add(admin_permission) + + +class UserFactory(DjangoModelFactory): + username = factory.Faker("user_name") + email = factory.Faker("email") + password = factory.LazyFunction(lambda: make_password("password")) + first_name = factory.Faker("first_name") + last_name = factory.Faker("last_name") + + class Meta: + model = User + + @factory.post_generation + def access_admin(self, create, extracted, **kwargs): + """Creates BundlePage instances for the bundle. + + Usage: + # Create a Django generic_user group + generic_user = UserFactory() + + # Create a Django generic_user group with Wagtail admin access + generic_user = UserFactory(access_admin=True) + """ + if not create: + return + + if extracted: + admin_permission = Permission.objects.get(content_type__app_label="wagtailadmin", codename="access_admin") + self.user_permissions.add(admin_permission) diff --git a/heroku.yml b/heroku.yml index 6497029b..83df8d5c 100644 --- a/heroku.yml +++ b/heroku.yml @@ -7,8 +7,8 @@ release: image: web command: - django-admin check --deploy && django-admin createcachetable && django-admin migrate --noinput -#run: -# scheduler: -# image: web -# command: -# - django-admin scheduler +run: + scheduler: + image: web + command: + - django-admin scheduler diff --git a/poetry.lock b/poetry.lock index ca01e41b..03da1f8b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -13,28 +13,27 @@ files = [ [[package]] name = "apscheduler" -version = "3.10.4" +version = "3.11.0" description = "In-process task scheduler with Cron-like capabilities" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "APScheduler-3.10.4-py3-none-any.whl", hash = "sha256:fb91e8a768632a4756a585f79ec834e0e27aad5860bac7eaa523d9ccefd87661"}, - {file = "APScheduler-3.10.4.tar.gz", hash = "sha256:e6df071b27d9be898e486bc7940a7be50b4af2e9da7c08f0744a96d4bd4cef4a"}, + {file = "APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da"}, + {file = "apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133"}, ] [package.dependencies] -pytz = "*" -six = ">=1.4.0" -tzlocal = ">=2.0,<3.dev0 || >=4.dev0" +tzlocal = ">=3.0" [package.extras] -doc = ["sphinx", "sphinx-rtd-theme"] +doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"] +etcd = ["etcd3", "protobuf (<=3.21.0)"] gevent = ["gevent"] mongodb = ["pymongo (>=3.0)"] redis = ["redis (>=3.0)"] rethinkdb = ["rethinkdb (>=2.4.0)"] sqlalchemy = ["sqlalchemy (>=1.4)"] -testing = ["pytest", "pytest-asyncio", "pytest-cov", "pytest-tornado5"] +test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6", "anyio (>=4.5.2)", "gevent", "pytest", "pytz", "twisted"] tornado = ["tornado (>=4.3)"] twisted = ["twisted"] zookeeper = ["kazoo"] @@ -87,17 +86,17 @@ lxml = ["lxml"] [[package]] name = "boto3" -version = "1.35.54" +version = "1.35.72" description = "The AWS SDK for Python" optional = false python-versions = ">=3.8" files = [ - {file = "boto3-1.35.54-py3-none-any.whl", hash = "sha256:2d5e160b614db55fbee7981001c54476cb827c441cef65b2fcb2c52a62019909"}, - {file = "boto3-1.35.54.tar.gz", hash = "sha256:7d9c359bbbc858a60b51c86328db813353c8bd1940212cdbd0a7da835291c2e1"}, + {file = "boto3-1.35.72-py3-none-any.whl", hash = "sha256:410bb4ec676c57ee9c3c7824b7b1a3721584f18f8ee8ccc8e8ecdf285136b77f"}, + {file = "boto3-1.35.72.tar.gz", hash = "sha256:f9fc94413a959c388b1654c6687a5193293f3c69f8d0af3b86fd48b4096a23f3"}, ] [package.dependencies] -botocore = ">=1.35.54,<1.36.0" +botocore = ">=1.35.72,<1.36.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.10.0,<0.11.0" @@ -106,13 +105,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.35.54" +version = "1.35.72" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.8" files = [ - {file = "botocore-1.35.54-py3-none-any.whl", hash = "sha256:9cca1811094b6cdc144c2c063a3ec2db6d7c88194b04d4277cd34fc8e3473aff"}, - {file = "botocore-1.35.54.tar.gz", hash = "sha256:131bb59ce59c8a939b31e8e647242d70cf11d32d4529fa4dca01feea1e891a76"}, + {file = "botocore-1.35.72-py3-none-any.whl", hash = "sha256:7412877c3f766a1bfd09236e225ce1f0dc2c35e47949ae423e56e2093c8fa23a"}, + {file = "botocore-1.35.72.tar.gz", hash = "sha256:6b5fac38ef7cfdbc7781a751e0f78833ccb9149ba815bc238b1dbb75c90fbae5"}, ] [package.dependencies] @@ -368,73 +367,73 @@ test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] [[package]] name = "coverage" -version = "7.6.4" +version = "7.6.8" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" files = [ - {file = "coverage-7.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f8ae553cba74085db385d489c7a792ad66f7f9ba2ee85bfa508aeb84cf0ba07"}, - {file = "coverage-7.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8165b796df0bd42e10527a3f493c592ba494f16ef3c8b531288e3d0d72c1f6f0"}, - {file = "coverage-7.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c8b95bf47db6d19096a5e052ffca0a05f335bc63cef281a6e8fe864d450a72"}, - {file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ed9281d1b52628e81393f5eaee24a45cbd64965f41857559c2b7ff19385df51"}, - {file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0809082ee480bb8f7416507538243c8863ac74fd8a5d2485c46f0f7499f2b491"}, - {file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d541423cdd416b78626b55f123412fcf979d22a2c39fce251b350de38c15c15b"}, - {file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58809e238a8a12a625c70450b48e8767cff9eb67c62e6154a642b21ddf79baea"}, - {file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9b8e184898ed014884ca84c70562b4a82cbc63b044d366fedc68bc2b2f3394a"}, - {file = "coverage-7.6.4-cp310-cp310-win32.whl", hash = "sha256:6bd818b7ea14bc6e1f06e241e8234508b21edf1b242d49831831a9450e2f35fa"}, - {file = "coverage-7.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:06babbb8f4e74b063dbaeb74ad68dfce9186c595a15f11f5d5683f748fa1d172"}, - {file = "coverage-7.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b"}, - {file = "coverage-7.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25"}, - {file = "coverage-7.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546"}, - {file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b"}, - {file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e"}, - {file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718"}, - {file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db"}, - {file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522"}, - {file = "coverage-7.6.4-cp311-cp311-win32.whl", hash = "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf"}, - {file = "coverage-7.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19"}, - {file = "coverage-7.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2"}, - {file = "coverage-7.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117"}, - {file = "coverage-7.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613"}, - {file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27"}, - {file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52"}, - {file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2"}, - {file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1"}, - {file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5"}, - {file = "coverage-7.6.4-cp312-cp312-win32.whl", hash = "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17"}, - {file = "coverage-7.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08"}, - {file = "coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9"}, - {file = "coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba"}, - {file = "coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c"}, - {file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06"}, - {file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f"}, - {file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b"}, - {file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21"}, - {file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a"}, - {file = "coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e"}, - {file = "coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963"}, - {file = "coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f"}, - {file = "coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806"}, - {file = "coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11"}, - {file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3"}, - {file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a"}, - {file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc"}, - {file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70"}, - {file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef"}, - {file = "coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e"}, - {file = "coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1"}, - {file = "coverage-7.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9cb7fa111d21a6b55cbf633039f7bc2749e74932e3aa7cb7333f675a58a58bf3"}, - {file = "coverage-7.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11a223a14e91a4693d2d0755c7a043db43d96a7450b4f356d506c2562c48642c"}, - {file = "coverage-7.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a413a096c4cbac202433c850ee43fa326d2e871b24554da8327b01632673a076"}, - {file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00a1d69c112ff5149cabe60d2e2ee948752c975d95f1e1096742e6077affd376"}, - {file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f76846299ba5c54d12c91d776d9605ae33f8ae2b9d1d3c3703cf2db1a67f2c0"}, - {file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fe439416eb6380de434886b00c859304338f8b19f6f54811984f3420a2e03858"}, - {file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0294ca37f1ba500667b1aef631e48d875ced93ad5e06fa665a3295bdd1d95111"}, - {file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6f01ba56b1c0e9d149f9ac85a2f999724895229eb36bd997b61e62999e9b0901"}, - {file = "coverage-7.6.4-cp39-cp39-win32.whl", hash = "sha256:bc66f0bf1d7730a17430a50163bb264ba9ded56739112368ba985ddaa9c3bd09"}, - {file = "coverage-7.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:c481b47f6b5845064c65a7bc78bc0860e635a9b055af0df46fdf1c58cebf8e8f"}, - {file = "coverage-7.6.4-pp39.pp310-none-any.whl", hash = "sha256:3c65d37f3a9ebb703e710befdc489a38683a5b152242664b973a7b7b22348a4e"}, - {file = "coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73"}, + {file = "coverage-7.6.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b39e6011cd06822eb964d038d5dff5da5d98652b81f5ecd439277b32361a3a50"}, + {file = "coverage-7.6.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:63c19702db10ad79151a059d2d6336fe0c470f2e18d0d4d1a57f7f9713875dcf"}, + {file = "coverage-7.6.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3985b9be361d8fb6b2d1adc9924d01dec575a1d7453a14cccd73225cb79243ee"}, + {file = "coverage-7.6.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:644ec81edec0f4ad17d51c838a7d01e42811054543b76d4ba2c5d6af741ce2a6"}, + {file = "coverage-7.6.8-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f188a2402f8359cf0c4b1fe89eea40dc13b52e7b4fd4812450da9fcd210181d"}, + {file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e19122296822deafce89a0c5e8685704c067ae65d45e79718c92df7b3ec3d331"}, + {file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:13618bed0c38acc418896005732e565b317aa9e98d855a0e9f211a7ffc2d6638"}, + {file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:193e3bffca48ad74b8c764fb4492dd875038a2f9925530cb094db92bb5e47bed"}, + {file = "coverage-7.6.8-cp310-cp310-win32.whl", hash = "sha256:3988665ee376abce49613701336544041f2117de7b7fbfe91b93d8ff8b151c8e"}, + {file = "coverage-7.6.8-cp310-cp310-win_amd64.whl", hash = "sha256:f56f49b2553d7dd85fd86e029515a221e5c1f8cb3d9c38b470bc38bde7b8445a"}, + {file = "coverage-7.6.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:86cffe9c6dfcfe22e28027069725c7f57f4b868a3f86e81d1c62462764dc46d4"}, + {file = "coverage-7.6.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d82ab6816c3277dc962cfcdc85b1efa0e5f50fb2c449432deaf2398a2928ab94"}, + {file = "coverage-7.6.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13690e923a3932e4fad4c0ebfb9cb5988e03d9dcb4c5150b5fcbf58fd8bddfc4"}, + {file = "coverage-7.6.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4be32da0c3827ac9132bb488d331cb32e8d9638dd41a0557c5569d57cf22c9c1"}, + {file = "coverage-7.6.8-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44e6c85bbdc809383b509d732b06419fb4544dca29ebe18480379633623baafb"}, + {file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:768939f7c4353c0fac2f7c37897e10b1414b571fd85dd9fc49e6a87e37a2e0d8"}, + {file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e44961e36cb13c495806d4cac67640ac2866cb99044e210895b506c26ee63d3a"}, + {file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ea8bb1ab9558374c0ab591783808511d135a833c3ca64a18ec927f20c4030f0"}, + {file = "coverage-7.6.8-cp311-cp311-win32.whl", hash = "sha256:629a1ba2115dce8bf75a5cce9f2486ae483cb89c0145795603d6554bdc83e801"}, + {file = "coverage-7.6.8-cp311-cp311-win_amd64.whl", hash = "sha256:fb9fc32399dca861584d96eccd6c980b69bbcd7c228d06fb74fe53e007aa8ef9"}, + {file = "coverage-7.6.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e683e6ecc587643f8cde8f5da6768e9d165cd31edf39ee90ed7034f9ca0eefee"}, + {file = "coverage-7.6.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1defe91d41ce1bd44b40fabf071e6a01a5aa14de4a31b986aa9dfd1b3e3e414a"}, + {file = "coverage-7.6.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7ad66e8e50225ebf4236368cc43c37f59d5e6728f15f6e258c8639fa0dd8e6d"}, + {file = "coverage-7.6.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fe47da3e4fda5f1abb5709c156eca207eacf8007304ce3019eb001e7a7204cb"}, + {file = "coverage-7.6.8-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:202a2d645c5a46b84992f55b0a3affe4f0ba6b4c611abec32ee88358db4bb649"}, + {file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4674f0daa1823c295845b6a740d98a840d7a1c11df00d1fd62614545c1583787"}, + {file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:74610105ebd6f33d7c10f8907afed696e79c59e3043c5f20eaa3a46fddf33b4c"}, + {file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cda8712145917105e07aab96388ae76e787270ec04bcb9d5cc786d7cbb8443"}, + {file = "coverage-7.6.8-cp312-cp312-win32.whl", hash = "sha256:9e89d5c8509fbd6c03d0dd1972925b22f50db0792ce06324ba069f10787429ad"}, + {file = "coverage-7.6.8-cp312-cp312-win_amd64.whl", hash = "sha256:379c111d3558272a2cae3d8e57e6b6e6f4fe652905692d54bad5ea0ca37c5ad4"}, + {file = "coverage-7.6.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b0c69f4f724c64dfbfe79f5dfb503b42fe6127b8d479b2677f2b227478db2eb"}, + {file = "coverage-7.6.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c15b32a7aca8038ed7644f854bf17b663bc38e1671b5d6f43f9a2b2bd0c46f63"}, + {file = "coverage-7.6.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63068a11171e4276f6ece913bde059e77c713b48c3a848814a6537f35afb8365"}, + {file = "coverage-7.6.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f4548c5ead23ad13fb7a2c8ea541357474ec13c2b736feb02e19a3085fac002"}, + {file = "coverage-7.6.8-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4b4299dd0d2c67caaaf286d58aef5e75b125b95615dda4542561a5a566a1e3"}, + {file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9ebfb2507751f7196995142f057d1324afdab56db1d9743aab7f50289abd022"}, + {file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c1b4474beee02ede1eef86c25ad4600a424fe36cff01a6103cb4533c6bf0169e"}, + {file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9fd2547e6decdbf985d579cf3fc78e4c1d662b9b0ff7cc7862baaab71c9cc5b"}, + {file = "coverage-7.6.8-cp313-cp313-win32.whl", hash = "sha256:8aae5aea53cbfe024919715eca696b1a3201886ce83790537d1c3668459c7146"}, + {file = "coverage-7.6.8-cp313-cp313-win_amd64.whl", hash = "sha256:ae270e79f7e169ccfe23284ff5ea2d52a6f401dc01b337efb54b3783e2ce3f28"}, + {file = "coverage-7.6.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de38add67a0af869b0d79c525d3e4588ac1ffa92f39116dbe0ed9753f26eba7d"}, + {file = "coverage-7.6.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b07c25d52b1c16ce5de088046cd2432b30f9ad5e224ff17c8f496d9cb7d1d451"}, + {file = "coverage-7.6.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62a66ff235e4c2e37ed3b6104d8b478d767ff73838d1222132a7a026aa548764"}, + {file = "coverage-7.6.8-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b9f848b28081e7b975a3626e9081574a7b9196cde26604540582da60235fdf"}, + {file = "coverage-7.6.8-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:093896e530c38c8e9c996901858ac63f3d4171268db2c9c8b373a228f459bbc5"}, + {file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a7b8ac36fd688c8361cbc7bf1cb5866977ece6e0b17c34aa0df58bda4fa18a4"}, + {file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:38c51297b35b3ed91670e1e4efb702b790002e3245a28c76e627478aa3c10d83"}, + {file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2e4e0f60cb4bd7396108823548e82fdab72d4d8a65e58e2c19bbbc2f1e2bfa4b"}, + {file = "coverage-7.6.8-cp313-cp313t-win32.whl", hash = "sha256:6535d996f6537ecb298b4e287a855f37deaf64ff007162ec0afb9ab8ba3b8b71"}, + {file = "coverage-7.6.8-cp313-cp313t-win_amd64.whl", hash = "sha256:c79c0685f142ca53256722a384540832420dff4ab15fec1863d7e5bc8691bdcc"}, + {file = "coverage-7.6.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ac47fa29d8d41059ea3df65bd3ade92f97ee4910ed638e87075b8e8ce69599e"}, + {file = "coverage-7.6.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:24eda3a24a38157eee639ca9afe45eefa8d2420d49468819ac5f88b10de84f4c"}, + {file = "coverage-7.6.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4c81ed2820b9023a9a90717020315e63b17b18c274a332e3b6437d7ff70abe0"}, + {file = "coverage-7.6.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd55f8fc8fa494958772a2a7302b0354ab16e0b9272b3c3d83cdb5bec5bd1779"}, + {file = "coverage-7.6.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f39e2f3530ed1626c66e7493be7a8423b023ca852aacdc91fb30162c350d2a92"}, + {file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:716a78a342679cd1177bc8c2fe957e0ab91405bd43a17094324845200b2fddf4"}, + {file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:177f01eeaa3aee4a5ffb0d1439c5952b53d5010f86e9d2667963e632e30082cc"}, + {file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:912e95017ff51dc3d7b6e2be158dedc889d9a5cc3382445589ce554f1a34c0ea"}, + {file = "coverage-7.6.8-cp39-cp39-win32.whl", hash = "sha256:4db3ed6a907b555e57cc2e6f14dc3a4c2458cdad8919e40b5357ab9b6db6c43e"}, + {file = "coverage-7.6.8-cp39-cp39-win_amd64.whl", hash = "sha256:428ac484592f780e8cd7b6b14eb568f7c85460c92e2a37cb0c0e5186e1a0d076"}, + {file = "coverage-7.6.8-pp39.pp310-none-any.whl", hash = "sha256:5c52a036535d12590c32c49209e79cabaad9f9ad8aa4cbd875b68c4d67a9cbce"}, + {file = "coverage-7.6.8.tar.gz", hash = "sha256:8b2b8503edb06822c86d82fa64a4a5cb0760bb8f31f26e138ec743f422f37cfc"}, ] [package.extras] @@ -442,51 +441,53 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "43.0.3" +version = "44.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false -python-versions = ">=3.7" -files = [ - {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, - {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, - {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, - {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, - {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, - {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, - {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, - {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, - {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, - {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, - {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, - {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, - {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, - {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, - {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, - {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, - {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, - {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, - {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, - {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, - {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, - {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, - {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, - {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, - {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, - {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, - {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, +python-versions = "!=3.9.0,!=3.9.1,>=3.7" +files = [ + {file = "cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:60eb32934076fa07e4316b7b2742fa52cbb190b42c2df2863dbc4230a0a9b385"}, + {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e"}, + {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e"}, + {file = "cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053"}, + {file = "cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd"}, + {file = "cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9abcc2e083cbe8dde89124a47e5e53ec38751f0d7dfd36801008f316a127d7ba"}, + {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64"}, + {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285"}, + {file = "cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417"}, + {file = "cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:37d76e6863da3774cd9db5b409a9ecfd2c71c981c38788d3fcfaf177f447b731"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:f677e1268c4e23420c3acade68fac427fffcb8d19d7df95ed7ad17cdef8404f4"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f5e7cb1e5e56ca0933b4873c0220a78b773b24d40d186b6738080b73d3d0a756"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:8b3e6eae66cf54701ee7d9c83c30ac0a1e3fa17be486033000f2a73a12ab507c"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:be4ce505894d15d5c5037167ffb7f0ae90b7be6f2a98f9a5c3442395501c32fa"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:62901fb618f74d7d81bf408c8719e9ec14d863086efe4185afd07c352aee1d2c"}, + {file = "cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02"}, ] [package.dependencies] cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} [package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] -docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] -nox = ["nox"] -pep8test = ["check-sdist", "click", "mypy", "ruff"] -sdist = ["build"] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0)"] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test = ["certifi (>=2024)", "cryptography-vectors (==44.0.0)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] [[package]] @@ -965,13 +966,13 @@ doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] [[package]] name = "faker" -version = "30.8.2" +version = "33.1.0" description = "Faker is a Python package that generates fake data for you." optional = false python-versions = ">=3.8" files = [ - {file = "Faker-30.8.2-py3-none-any.whl", hash = "sha256:4a82b2908cd19f3bba1a4da2060cc4eb18a40410ccdf9350d071d79dc92fe3ce"}, - {file = "faker-30.8.2.tar.gz", hash = "sha256:aa31b52cdae3673d6a78b4857c7bcdc0e98f201a5cb77d7827fa9e6b5876da94"}, + {file = "Faker-33.1.0-py3-none-any.whl", hash = "sha256:d30c5f0e2796b8970de68978365247657486eb0311c5abe88d0b895b68dff05d"}, + {file = "faker-33.1.0.tar.gz", hash = "sha256:1c925fc0e86a51fc46648b504078c88d0cd48da1da2595c4e712841cab43a1e4"}, ] [package.dependencies] @@ -1058,22 +1059,22 @@ colors = ["colorama (>=0.4.6)"] [[package]] name = "jedi" -version = "0.19.1" +version = "0.19.2" description = "An autocompletion tool for Python that can be used for text editors." optional = false python-versions = ">=3.6" files = [ - {file = "jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"}, - {file = "jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd"}, + {file = "jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"}, + {file = "jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0"}, ] [package.dependencies] -parso = ">=0.8.3,<0.9.0" +parso = ">=0.8.4,<0.9.0" [package.extras] docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] -testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] +testing = ["Django", "attrs", "colorama", "docopt", "pytest (<9.0.0)"] [[package]] name = "jinja2" @@ -1219,13 +1220,13 @@ files = [ [[package]] name = "moto" -version = "5.0.21" +version = "5.0.22" description = "" optional = false python-versions = ">=3.8" files = [ - {file = "moto-5.0.21-py3-none-any.whl", hash = "sha256:1235b2ae3666459c9cc44504a5e73d35f4959b45e5876b2f6df2e5f4889dfb4f"}, - {file = "moto-5.0.21.tar.gz", hash = "sha256:52f63291daeff9444ef5eb14fbf69b24264567b79f184ae6aee4945d09845f06"}, + {file = "moto-5.0.22-py3-none-any.whl", hash = "sha256:defae32e834ba5674f77cbbe996b41dc248dd81289af8032fa3e847284409b29"}, + {file = "moto-5.0.22.tar.gz", hash = "sha256:daf47b8a1f5f190cd3eaa40018a643f38e542277900cf1db7f252cedbfed998f"}, ] [package.dependencies] @@ -1342,13 +1343,13 @@ et-xmlfile = "*" [[package]] name = "packaging" -version = "24.1" +version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, - {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] [[package]] @@ -1460,57 +1461,57 @@ xmp = ["defusedxml"] [[package]] name = "pillow-heif" -version = "0.20.0" +version = "0.21.0" description = "Python interface for libheif library" optional = false python-versions = ">=3.9" files = [ - {file = "pillow_heif-0.20.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:ef2ad418f42adc9ef5d5e709547e799fb32141543856cb14f04fa4b22f83bfd7"}, - {file = "pillow_heif-0.20.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:af229e214ec23053bea1f162972645495bfb12f2c3b5ece463bd8a01aefda17a"}, - {file = "pillow_heif-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f446a78a9d84ef75761638a7e72a477aadeffb282ac70ffe67360a98d54775b1"}, - {file = "pillow_heif-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4a77c6e78756948a2a5fc8ec7341184fca1bc7316c11f6df0cf3fe9732e1688"}, - {file = "pillow_heif-0.20.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d30d19b8ba9c384a06523c3d419c46d62c823abdb6d75581ffd5328503f6d3aa"}, - {file = "pillow_heif-0.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0ec02ebbe88e2af0f093e80c95b716f54479a32b037da6b1c12b9f4024eab359"}, - {file = "pillow_heif-0.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:86a8920ea3a3b3923c827629afc850c1ee9f753b71346180c226882545028e06"}, - {file = "pillow_heif-0.20.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:8e3bc0bda64cce72e41f6c20a5cf3e24af308a09e146df78d31acb337a8ff58b"}, - {file = "pillow_heif-0.20.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a35e9e17d112573e9568d07c0e2c5cb81218a8f4c0da84a428618c7a746c4d98"}, - {file = "pillow_heif-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:618d63338afb9f49f1fb7b9a421aff6ad71ceb8092855e5988c05ab10dc21152"}, - {file = "pillow_heif-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6174d31580081d53f4eadc2428c699a5e47d111e64f136945951d12a9a277936"}, - {file = "pillow_heif-0.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7d106a1c87587838e9099bbfef9ddc7eef0dd3e77e9b1b8a1292a5f9dc4ad5a2"}, - {file = "pillow_heif-0.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba9e11f56a5e1fd1d559a1fd60d498f343922affc0e118fb3d4e808902fee1a9"}, - {file = "pillow_heif-0.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:daf209dd79ad21b21f7b2bbd575f331702d2f1dd0b529c12cdbee00d62c24254"}, - {file = "pillow_heif-0.20.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:0919f7738b886ed88367b9d0247132b1cbe5d40411bac5d7536d1876980af23e"}, - {file = "pillow_heif-0.20.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:146e55436b4feafcd086bd40211d5c3159b4d488b7f4918921560c9718c62dc9"}, - {file = "pillow_heif-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70564e6e301498b484e467d96d25065c8102b8bba6227959dcff2df68d888d82"}, - {file = "pillow_heif-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d14de6325eff7840d223c27fc974af28de0bb098b7678e05efe7e5cbf345e6b"}, - {file = "pillow_heif-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ff0d429d01ac1d4b54358bc3e10ac8aea7b04913e118800641394261d4430a3"}, - {file = "pillow_heif-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ccb16e488fc700029da111547039ac21e7cab9cae47f127ad2866824569a7a4c"}, - {file = "pillow_heif-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:ce6fb39f5d62d8a72ec2718ee110c49db529d9a1171c6ef243d7d66cfa17edc2"}, - {file = "pillow_heif-0.20.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:aadf4151095753b823b2ab061a51bfd4f5e56e69d6a1e125d12083eab639fd16"}, - {file = "pillow_heif-0.20.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4f3fac5a22946ec8df9c45a9f2d50a99407d798b2e7dce24bd2ef53b039f7f02"}, - {file = "pillow_heif-0.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6244a4934b21978c33651a77bdf446a9e9ae2450c332426bd2901a2523737938"}, - {file = "pillow_heif-0.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47fbfbd5b87c3ee2e165de8f43260c5cea45bb282f291ef09ae8a21fdd284467"}, - {file = "pillow_heif-0.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a3e9f2a87ba24468d1717c1403ceed7b6bc6c7f82023a8b888169ae494ee33d3"}, - {file = "pillow_heif-0.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cc5d116713657b12becd8a2241a5c70ec28a34053fcbd58164ca08b26b23970a"}, - {file = "pillow_heif-0.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:b4e9162f1265ed808af872abe894398ba2b5f2297221b03031f48870638cf491"}, - {file = "pillow_heif-0.20.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:5647cda2566da6786f0c090fd61c268b6d530d3a2c88361ed630f5ed2bd52766"}, - {file = "pillow_heif-0.20.0-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:9ae1a75deb8ffca01ae389593af6112a721415ff8a6ccc2676bb1da71186f13b"}, - {file = "pillow_heif-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:309d37303378ceb93d8408e26b67917a2091bc1e136fe0afb7c72610954de635"}, - {file = "pillow_heif-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0ccaade96a8a7d8614374b6d5c1b259e62040e33180fadfef336089b4919ed5"}, - {file = "pillow_heif-0.20.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1dffa961f316a9cb5a495087c17e41f2fdc83a8cbdf6d845716cbf2c9eb244bf"}, - {file = "pillow_heif-0.20.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7da99aa51bc80c24bc70fffcaa8e17c4944c4d4babdca0c38c82d5a69f7b8fa2"}, - {file = "pillow_heif-0.20.0-cp39-cp39-win_amd64.whl", hash = "sha256:3f88de96b1ade76d408b4d490cd2f0de31c4790e4cf573e90503d9715082811c"}, - {file = "pillow_heif-0.20.0-pp310-pypy310_pp73-macosx_12_0_x86_64.whl", hash = "sha256:0a1a4ecaf150b569ad7d5fdeafde713e18d70e1a0d15395cdf96069818eae913"}, - {file = "pillow_heif-0.20.0-pp310-pypy310_pp73-macosx_14_0_arm64.whl", hash = "sha256:a8938faf7a48289601a5413078b2f21551228e1d1b203c41aaf7638ce156e073"}, - {file = "pillow_heif-0.20.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9430a33f69965d067be7e5c15dc70f1e43d5e3c8b5e9dc16c8c8d52179ce1cc"}, - {file = "pillow_heif-0.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da749d087ae3a7538af73d7a676cf332f81d1e6da9a6dea083aa382290d2d172"}, - {file = "pillow_heif-0.20.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:039f0c82ab3c0b364947979583d53ec9aad42d22159b9497e3c20ddde92c99bd"}, - {file = "pillow_heif-0.20.0-pp39-pypy39_pp73-macosx_12_0_x86_64.whl", hash = "sha256:9d42d164f378cf3ba1ddd00b2379360604a8461cee54eeebd67aac341f27ccac"}, - {file = "pillow_heif-0.20.0-pp39-pypy39_pp73-macosx_14_0_arm64.whl", hash = "sha256:740ef7652c7b278f24ead94e4098f0d1baf679a1e7373135e2820ce1c34a1bc5"}, - {file = "pillow_heif-0.20.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:adc56caf280e39f0540d40df925cde2cd960d2ee2492f856224e2e399f4a7590"}, - {file = "pillow_heif-0.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:623c0b777b15773605eeed811b23658923b4e4d822172fb62d4cbe983e5a8722"}, - {file = "pillow_heif-0.20.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2f9515e21aa2f112252238175bbe3a1daad7a0c1020fc4ed52eae7805651431c"}, - {file = "pillow_heif-0.20.0.tar.gz", hash = "sha256:cac19c4434ab776f833160d61f3cbeddb347bd8ed2f82205b243eba5c572fa33"}, + {file = "pillow_heif-0.21.0-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:f54609401164b0cb58000bd2516a88516b5e3e9b2f9c52ad9500575f1851da5e"}, + {file = "pillow_heif-0.21.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:d0a68246340d4fad4f10721a1a50b87a7011f1bd18d0a7b7d231e196776d0260"}, + {file = "pillow_heif-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:208b066bc7349b1ea1447199668edb6e2f74f36df54c86457ecb0131db8294df"}, + {file = "pillow_heif-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cea6f1519a9c486baf3bdf63487fa3f699402724895d64841bb4636258a87c90"}, + {file = "pillow_heif-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7f9e939cd8e343237800fe998e26558a82cb25496b74d7674f29e75dc87eb636"}, + {file = "pillow_heif-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8b30fbbb672a3413413bcfc726f9994e495c647c6b96ab9f832dccb61b67fb2f"}, + {file = "pillow_heif-0.21.0-cp310-cp310-win_amd64.whl", hash = "sha256:9807c955ea7ed2caa5d105aea7d870d8c0958079ed2aba39a6ace7ef82aad402"}, + {file = "pillow_heif-0.21.0-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:0c3ffa486f56f52fe790d3b1bd522d93d2f59e22ce86045641cd596adc3c5273"}, + {file = "pillow_heif-0.21.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:c46be20058d72a5a158ffc65e6158279a4bcb337707a29b312c5293846bd5b8a"}, + {file = "pillow_heif-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06663c825a3d71779e51df02080467761b74d515e59fce9d780220cd75de7dd0"}, + {file = "pillow_heif-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23efab69a03a9a3a9ff07043d8c8bf0d15ffd661ecc5c7bff59b386eb25f0466"}, + {file = "pillow_heif-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5eebb73268b806d3c801271126382da4f556b756990f87590c843c5a8ec14e2"}, + {file = "pillow_heif-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3456b4cdb4da485f27c53a91c81f0488b44dc99c0be6870f6a1dc5ac85709894"}, + {file = "pillow_heif-0.21.0-cp311-cp311-win_amd64.whl", hash = "sha256:d36441100756122b9d401502e39b60d0df9d876a929f5db858a4b7d05cc02e88"}, + {file = "pillow_heif-0.21.0-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:0aaea6ea45257cf74e76666b80b6109f8f56217009534726fa7f6a5694ebd563"}, + {file = "pillow_heif-0.21.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f28c2c934f547823de3e204e48866c571d81ebb6b3e8646c32fe2104c570c7b2"}, + {file = "pillow_heif-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e10ab63559346fc294b9612502221ddd6bfac8cd74091ace7328fefc1163a167"}, + {file = "pillow_heif-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da2a015cfe4afec75551190d93c99dda13410aec89dc468794885b90f870f657"}, + {file = "pillow_heif-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:41693f5d87ed2b5fd01df4a6215045aff14d148a750aa0708c77e71139698154"}, + {file = "pillow_heif-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8b27031c561ee3485a119c769fc2ef41d81fae1de530857beef935683e09615e"}, + {file = "pillow_heif-0.21.0-cp312-cp312-win_amd64.whl", hash = "sha256:60196c08e9c256e81054c5da468eb5a0266c931b8564c96283a43e5fd2d7ce0e"}, + {file = "pillow_heif-0.21.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:9e67aae3c22a90bc7dfd42c9f0033c53a7d358e0f0d5d29aa42f2f193162fb01"}, + {file = "pillow_heif-0.21.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ee2d68cbc0df8ba6fd9103ac6b550ebafcaa3a179416737a96becf6e5f079586"}, + {file = "pillow_heif-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e5c0df7b8c84e4a8c249ba45ceca2453f205028d8a6525612ec6dd0553d925d"}, + {file = "pillow_heif-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaedb7f16f3f18fbb315648ba576d0d7bb26b18b50c16281665123c38f73101e"}, + {file = "pillow_heif-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6724d6a2561f36b06e14e1cd396c004d32717e81528cb03565491ac8679ed760"}, + {file = "pillow_heif-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf2e2b0abad455a0896118856e82a8d5358dfe5480bedd09ddd6a04b23773899"}, + {file = "pillow_heif-0.21.0-cp313-cp313-win_amd64.whl", hash = "sha256:1b6ba6c3c4de739a1abf4f7fe0cdd04acd9e0c7fc661985b9a5288d94893a4b1"}, + {file = "pillow_heif-0.21.0-cp39-cp39-macosx_13_0_x86_64.whl", hash = "sha256:2448e180150b1ecb6576cc5030a6d14a179a7fa430b2b54d976f3beb3c5628ae"}, + {file = "pillow_heif-0.21.0-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:fa9a91d6e390e78fe5670ff6083f26d13c6f1cabfaf0f61d0b272f50b5651c81"}, + {file = "pillow_heif-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc919aa10fe97cb2134043d6e2d0d7fdbe17d7a2a833b202437e53be39fa7eae"}, + {file = "pillow_heif-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d2fec1715ec77c2622e1eb52a6b30b58cea437b66dc45cfd28515dcb70bcc99"}, + {file = "pillow_heif-0.21.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:55cba67787dfabb20e3fe0f54e4e768ca42c0ac5aa74c6b293b3407c7782fc87"}, + {file = "pillow_heif-0.21.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04e824c087934bfd09605a992788db3c461f045a903dbc9f14b20eba0df0c6ac"}, + {file = "pillow_heif-0.21.0-cp39-cp39-win_amd64.whl", hash = "sha256:c2d2ec026094c919ce010921586192968abe9dfd2528b38bce905c74cac9b9c6"}, + {file = "pillow_heif-0.21.0-pp310-pypy310_pp73-macosx_13_0_x86_64.whl", hash = "sha256:9305aa837ce77d98a8b5e7bc8f86eeaefb52237686d84d60de11d55bad541d7f"}, + {file = "pillow_heif-0.21.0-pp310-pypy310_pp73-macosx_14_0_arm64.whl", hash = "sha256:fc9bfc50f55267d13b0abf63bd7d141b92a39e09812dadee1a88b5863d9b8808"}, + {file = "pillow_heif-0.21.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03273b94a7548ba615f6bfc1031137f1a025b657226de6c3f09f84945295f565"}, + {file = "pillow_heif-0.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6576c9c7713e33150395cdc6e9cf59efd8f42c5783cf0764092ba50a048ee2c6"}, + {file = "pillow_heif-0.21.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2813c34cdd3f07e406b6a2cb216019409eb62270e6799088ddf3d4cb08a0d503"}, + {file = "pillow_heif-0.21.0-pp39-pypy39_pp73-macosx_13_0_x86_64.whl", hash = "sha256:b06125d594ca71c9af3bf69118c661b8f82a3a7ce2d2ea5302328d91ebef36cb"}, + {file = "pillow_heif-0.21.0-pp39-pypy39_pp73-macosx_14_0_arm64.whl", hash = "sha256:22a73ed7ca5c2c8ef1b4872827dc7d8a6875938e9e791fff2db92fb4ca60f560"}, + {file = "pillow_heif-0.21.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:121451d016c450bfb4d926fe08274e165553679917eb8c85d41fcadfda5f3b2e"}, + {file = "pillow_heif-0.21.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5680a00519e5f3c7c1c51dfd41e7f1c632793dfde57a9620339ba4cc70cf9196"}, + {file = "pillow_heif-0.21.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a39d1043ec74afdeef00086c8d24b3cc30095927817182ae5bc960ddb3422d9c"}, + {file = "pillow_heif-0.21.0.tar.gz", hash = "sha256:07aee1bff05e5d61feb989eaa745ae21b367011fd66ee48f7732931f8a12b49b"}, ] [package.dependencies] @@ -1609,17 +1610,17 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pylint" -version = "3.3.1" +version = "3.3.2" description = "python code static checker" optional = false python-versions = ">=3.9.0" files = [ - {file = "pylint-3.3.1-py3-none-any.whl", hash = "sha256:2f846a466dd023513240bc140ad2dd73bfc080a5d85a710afdb728c420a5a2b9"}, - {file = "pylint-3.3.1.tar.gz", hash = "sha256:9f3dcc87b1203e612b78d91a896407787e708b3f189b5fa0b307712d49ff0c6e"}, + {file = "pylint-3.3.2-py3-none-any.whl", hash = "sha256:77f068c287d49b8683cd7c6e624243c74f92890f767f106ffa1ddf3c0a54cb7a"}, + {file = "pylint-3.3.2.tar.gz", hash = "sha256:9ec054ec992cd05ad30a6df1676229739a73f8feeabf3912c995d17601052b01"}, ] [package.dependencies] -astroid = ">=3.3.4,<=3.4.0-dev0" +astroid = ">=3.3.5,<=3.4.0-dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = {version = ">=0.3.7", markers = "python_version >= \"3.12\""} isort = ">=4.2.5,<5.13.0 || >5.13.0,<6" @@ -1825,40 +1826,40 @@ jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] [[package]] name = "ruff" -version = "0.7.2" +version = "0.7.4" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.7.2-py3-none-linux_armv6l.whl", hash = "sha256:b73f873b5f52092e63ed540adefc3c36f1f803790ecf2590e1df8bf0a9f72cb8"}, - {file = "ruff-0.7.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5b813ef26db1015953daf476202585512afd6a6862a02cde63f3bafb53d0b2d4"}, - {file = "ruff-0.7.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:853277dbd9675810c6826dad7a428d52a11760744508340e66bf46f8be9701d9"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21aae53ab1490a52bf4e3bf520c10ce120987b047c494cacf4edad0ba0888da2"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ccc7e0fc6e0cb3168443eeadb6445285abaae75142ee22b2b72c27d790ab60ba"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd77877a4e43b3a98e5ef4715ba3862105e299af0c48942cc6d51ba3d97dc859"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e00163fb897d35523c70d71a46fbaa43bf7bf9af0f4534c53ea5b96b2e03397b"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3c54b538633482dc342e9b634d91168fe8cc56b30a4b4f99287f4e339103e88"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b792468e9804a204be221b14257566669d1db5c00d6bb335996e5cd7004ba80"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dba53ed84ac19ae4bfb4ea4bf0172550a2285fa27fbb13e3746f04c80f7fa088"}, - {file = "ruff-0.7.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b19fafe261bf741bca2764c14cbb4ee1819b67adb63ebc2db6401dcd652e3748"}, - {file = "ruff-0.7.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28bd8220f4d8f79d590db9e2f6a0674f75ddbc3847277dd44ac1f8d30684b828"}, - {file = "ruff-0.7.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9fd67094e77efbea932e62b5d2483006154794040abb3a5072e659096415ae1e"}, - {file = "ruff-0.7.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:576305393998b7bd6c46018f8104ea3a9cb3fa7908c21d8580e3274a3b04b691"}, - {file = "ruff-0.7.2-py3-none-win32.whl", hash = "sha256:fa993cfc9f0ff11187e82de874dfc3611df80852540331bc85c75809c93253a8"}, - {file = "ruff-0.7.2-py3-none-win_amd64.whl", hash = "sha256:dd8800cbe0254e06b8fec585e97554047fb82c894973f7ff18558eee33d1cb88"}, - {file = "ruff-0.7.2-py3-none-win_arm64.whl", hash = "sha256:bb8368cd45bba3f57bb29cbb8d64b4a33f8415d0149d2655c5c8539452ce7760"}, - {file = "ruff-0.7.2.tar.gz", hash = "sha256:2b14e77293380e475b4e3a7a368e14549288ed2931fce259a6f99978669e844f"}, + {file = "ruff-0.7.4-py3-none-linux_armv6l.whl", hash = "sha256:a4919925e7684a3f18e18243cd6bea7cfb8e968a6eaa8437971f681b7ec51478"}, + {file = "ruff-0.7.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfb365c135b830778dda8c04fb7d4280ed0b984e1aec27f574445231e20d6c63"}, + {file = "ruff-0.7.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:63a569b36bc66fbadec5beaa539dd81e0527cb258b94e29e0531ce41bacc1f20"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d06218747d361d06fd2fdac734e7fa92df36df93035db3dc2ad7aa9852cb109"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0cea28d0944f74ebc33e9f934238f15c758841f9f5edd180b5315c203293452"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80094ecd4793c68b2571b128f91754d60f692d64bc0d7272ec9197fdd09bf9ea"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:997512325c6620d1c4c2b15db49ef59543ef9cd0f4aa8065ec2ae5103cedc7e7"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00b4cf3a6b5fad6d1a66e7574d78956bbd09abfd6c8a997798f01f5da3d46a05"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7dbdc7d8274e1422722933d1edddfdc65b4336abf0b16dfcb9dedd6e6a517d06"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e92dfb5f00eaedb1501b2f906ccabfd67b2355bdf117fea9719fc99ac2145bc"}, + {file = "ruff-0.7.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3bd726099f277d735dc38900b6a8d6cf070f80828877941983a57bca1cd92172"}, + {file = "ruff-0.7.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2e32829c429dd081ee5ba39aef436603e5b22335c3d3fff013cd585806a6486a"}, + {file = "ruff-0.7.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:662a63b4971807623f6f90c1fb664613f67cc182dc4d991471c23c541fee62dd"}, + {file = "ruff-0.7.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:876f5e09eaae3eb76814c1d3b68879891d6fde4824c015d48e7a7da4cf066a3a"}, + {file = "ruff-0.7.4-py3-none-win32.whl", hash = "sha256:75c53f54904be42dd52a548728a5b572344b50d9b2873d13a3f8c5e3b91f5cac"}, + {file = "ruff-0.7.4-py3-none-win_amd64.whl", hash = "sha256:745775c7b39f914238ed1f1b0bebed0b9155a17cd8bc0b08d3c87e4703b990d6"}, + {file = "ruff-0.7.4-py3-none-win_arm64.whl", hash = "sha256:11bff065102c3ae9d3ea4dc9ecdfe5a5171349cdd0787c1fc64761212fc9cf1f"}, + {file = "ruff-0.7.4.tar.gz", hash = "sha256:cd12e35031f5af6b9b93715d8c4f40360070b2041f81273d0527683d5708fce2"}, ] [[package]] name = "s3transfer" -version = "0.10.3" +version = "0.10.4" description = "An Amazon S3 Transfer Manager" optional = false python-versions = ">=3.8" files = [ - {file = "s3transfer-0.10.3-py3-none-any.whl", hash = "sha256:263ed587a5803c6c708d3ce44dc4dfedaab4c1a32e8329bab818933d79ddcf5d"}, - {file = "s3transfer-0.10.3.tar.gz", hash = "sha256:4f50ed74ab84d474ce614475e0b8d5047ff080810aac5d01ea25231cfc944b0c"}, + {file = "s3transfer-0.10.4-py3-none-any.whl", hash = "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e"}, + {file = "s3transfer-0.10.4.tar.gz", hash = "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7"}, ] [package.dependencies] @@ -1921,23 +1922,23 @@ tornado = ["tornado (>=6)"] [[package]] name = "setuptools" -version = "75.3.0" +version = "75.6.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "setuptools-75.3.0-py3-none-any.whl", hash = "sha256:f2504966861356aa38616760c0f66568e535562374995367b4e69c7143cf6bcd"}, - {file = "setuptools-75.3.0.tar.gz", hash = "sha256:fba5dd4d766e97be1b1681d98712680ae8f2f26d7881245f2ce9e40714f1a686"}, + {file = "setuptools-75.6.0-py3-none-any.whl", hash = "sha256:ce74b49e8f7110f9bf04883b730f4765b774ef3ef28f722cce7c273d253aaf7d"}, + {file = "setuptools-75.6.0.tar.gz", hash = "sha256:8199222558df7c86216af4f84c30e9b34a61d8ba19366cc914424cdbd28252f6"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] -core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.7.0)"] +core = ["importlib_metadata (>=6)", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.12.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (>=1.12,<1.14)", "pytest-mypy"] [[package]] name = "six" @@ -1950,6 +1951,20 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "slack-sdk" +version = "3.33.4" +description = "The Slack API Platform SDK for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "slack_sdk-3.33.4-py2.py3-none-any.whl", hash = "sha256:9f30cb3c9c07b441c49d53fc27f9f1837ad1592a7e9d4ca431f53cdad8826cc6"}, + {file = "slack_sdk-3.33.4.tar.gz", hash = "sha256:5e109847f6b6a22d227609226ba4ed936109dc00675bddeb7e0bee502d3ee7e0"}, +] + +[package.extras] +optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=9.1,<14)"] + [[package]] name = "soupsieve" version = "2.6" @@ -1963,13 +1978,13 @@ files = [ [[package]] name = "sqlparse" -version = "0.5.1" +version = "0.5.2" description = "A non-validating SQL parser." optional = false python-versions = ">=3.8" files = [ - {file = "sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4"}, - {file = "sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e"}, + {file = "sqlparse-0.5.2-py3-none-any.whl", hash = "sha256:e99bc85c78160918c3e1d9230834ab8d80fc06c59d03f8db2618f65f65dda55e"}, + {file = "sqlparse-0.5.2.tar.gz", hash = "sha256:9e37b35e16d1cc652a2545f0997c1deb23ea28fa1f3eefe609eee3063c3b105f"}, ] [package.extras] @@ -2013,13 +2028,43 @@ files = [ [[package]] name = "tomli" -version = "2.0.2" +version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" files = [ - {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, - {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] [[package]] @@ -2143,13 +2188,13 @@ dev = ["black", "pytest"] [[package]] name = "wagtail" -version = "6.3" +version = "6.3.1" description = "A Django content management system." optional = false python-versions = ">=3.9" files = [ - {file = "wagtail-6.3-py3-none-any.whl", hash = "sha256:627e4d5c2a47cd8533994503f473da231a782dbd0f1f8f53641e0d994556a0f2"}, - {file = "wagtail-6.3.tar.gz", hash = "sha256:98d94d12183b8fc689a0186ab095e6056bf116c21d94f245bdfd02ed4775442a"}, + {file = "wagtail-6.3.1-py3-none-any.whl", hash = "sha256:d7d4e4fcb5edb4a5d0aaff5de72eaf33a51e4fa26c2d3a57801c58c4da35b209"}, + {file = "wagtail-6.3.1.tar.gz", hash = "sha256:93876cc7a3bfcfff4c0393949562cbf249f244c4ca653d58a2dbab737f455715"}, ] [package.dependencies] @@ -2323,4 +2368,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "59b64fc792937ff825025268f708987a8b9ae9cc16a054549ee19190997d189c" +content-hash = "d6f3cd9d5d16ec644f0e0abfe25470f9d0baa2897b861f71ddcafa7987095dd3" diff --git a/pyproject.toml b/pyproject.toml index 2169494e..a7808c98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ apscheduler = "^3.10.4" wagtail-storages = "~2.0" wagtailmath = "~1.3.0" whitenoise = "~6.8" +slack-sdk = "^3.33.3" django-cache-memoize = "^0.2.0" django-iam-dbauth = "^0.2.1" @@ -37,7 +38,7 @@ mypy = "^1.13.0" # keep version in sync with .pre-commit-config.yaml django-stubs = { version="^5.1.1", extras=["compatible-mypy"]} pylint = "^3.3.1" pylint-django = "^2.0.0" -ruff = "^0.7.1" # keep version in sync with .pre-commit-config.yaml +ruff = "0.7.4" # keep version in sync with .pre-commit-config.yaml wagtail-factories = "^4.1.0" coverage = "^7.6.4"