From 19f13c830212b88f321e0f10a7d986acbf5b0f0e Mon Sep 17 00:00:00 2001 From: Arnaud-D <35631001+Arnaud-D@users.noreply.github.com> Date: Sun, 28 Apr 2024 14:33:48 +0200 Subject: [PATCH] =?UTF-8?q?Modifie=20le=20lien=20canonique=20dans=20un=20f?= =?UTF-8?q?ormulaire=20d=C3=A9di=C3=A9=20(#6611)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Supprime le lien canonique du formulaire de contenu * Ajoute un formulaire dédié au lien canonique * Formulaire modal spécifique * Enregistrement dans le journal d'événements * Ajoute des tests --- .../tutorialv2/events/descriptions.part.html | 2 + .../sidebar/editorialization.part.html | 7 + zds/tutorialv2/forms.py | 19 +-- zds/tutorialv2/models/events.py | 11 ++ zds/tutorialv2/signals.py | 4 + .../tests_editcanonicallinkview.py | 121 ++++++++++++++++++ zds/tutorialv2/urls/urls_contents.py | 3 + zds/tutorialv2/views/canonical.py | 76 +++++++++++ zds/tutorialv2/views/contents.py | 5 - zds/tutorialv2/views/display/content.py | 2 + 10 files changed, 229 insertions(+), 21 deletions(-) create mode 100644 zds/tutorialv2/tests/tests_views/tests_editcanonicallinkview.py create mode 100644 zds/tutorialv2/views/canonical.py diff --git a/templates/tutorialv2/events/descriptions.part.html b/templates/tutorialv2/events/descriptions.part.html index c2a2f6cdd8..360720290a 100644 --- a/templates/tutorialv2/events/descriptions.part.html +++ b/templates/tutorialv2/events/descriptions.part.html @@ -60,6 +60,8 @@ {% elif event.type == "tags_management" %} {{ event.performer }} a modifié les tags du contenu. +{% elif event.type == "canonical_link_management" %} + {{ event.performer }} a modifié le lien canonique du contenu. {% elif event.type == "goals_management" %} {{ event.performer }} a modifié les objectifs du contenu. diff --git a/templates/tutorialv2/includes/sidebar/editorialization.part.html b/templates/tutorialv2/includes/sidebar/editorialization.part.html index b9f94ea9a5..11f02f9ec2 100644 --- a/templates/tutorialv2/includes/sidebar/editorialization.part.html +++ b/templates/tutorialv2/includes/sidebar/editorialization.part.html @@ -12,6 +12,13 @@

Éditorialisation

{% crispy form_edit_tags %} +
  • + + {% trans "Modifier le lien canonique" %} + + {% crispy form_edit_canonical_link %} +
  • + {% if perms.tutorialv2.change_publishablecontent %}
  • diff --git a/zds/tutorialv2/forms.py b/zds/tutorialv2/forms.py index 743f8cdeb3..153613fb69 100644 --- a/zds/tutorialv2/forms.py +++ b/zds/tutorialv2/forms.py @@ -124,16 +124,6 @@ class ContentForm(ContainerForm): type = forms.ChoiceField(choices=TYPE_CHOICES, required=False) - source = forms.URLField( - label=_( - """Si votre contenu est publié en dehors de Zeste de Savoir (blog, site personnel, etc.), - indiquez le lien de la publication originale : """ - ), - max_length=PublishableContent._meta.get_field("source").max_length, - required=False, - widget=forms.TextInput(attrs={"placeholder": _("https://...")}), - ) - def _create_layout(self): self.helper.layout = Layout( IncludeEasyMDE(), @@ -158,12 +148,10 @@ def _create_layout(self): with text=form.conclusion.value %}{% endif %}' ), Field("last_hash"), - Field("source"), + Field("msg_commit"), + ButtonHolder(StrictButton("Valider", type="submit")), ) - self.helper.layout.append(Field("msg_commit")) - self.helper.layout.append(ButtonHolder(StrictButton("Valider", type="submit"))) - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -211,10 +199,9 @@ def _create_layout(self): with text=form.conclusion.value %}{% endif %}' ), Field("last_hash"), - Field("source"), Field("subcategory", template="crispy/checkboxselectmultiple.html"), Field("msg_commit"), - StrictButton("Valider", type="submit"), + ButtonHolder(StrictButton("Valider", type="submit")), ) diff --git a/zds/tutorialv2/models/events.py b/zds/tutorialv2/models/events.py index 67b8485c51..0cfa001bc8 100644 --- a/zds/tutorialv2/models/events.py +++ b/zds/tutorialv2/models/events.py @@ -6,6 +6,7 @@ from zds.tutorialv2 import signals from zds.tutorialv2.views.authors import AddAuthorToContent, RemoveAuthorFromContent from zds.tutorialv2.views.beta import ManageBetaContent +from zds.tutorialv2.views.canonical import EditCanonicalLinkView from zds.tutorialv2.views.contributors import AddContributorToContent, RemoveContributorFromContent from zds.tutorialv2.views.suggestions import AddSuggestion, RemoveSuggestion from zds.tutorialv2.views.tags import EditTags @@ -48,6 +49,7 @@ signals.beta_management: "beta_management", signals.validation_management: "validation_management", signals.tags_management: "tags_management", + signals.canonical_link_management: "canonical_link_management", signals.goals_management: "goals_management", signals.labels_management: "labels_management", signals.suggestions_management: "suggestions_management", @@ -142,6 +144,15 @@ def record_event_tags_management(sender, performer, signal, content, **_): ).save() +@receiver(signals.canonical_link_management, sender=EditCanonicalLinkView) +def record_event_canonical_link_management(sender, performer, signal, content, **_): + Event( + performer=performer, + type=types[signal], + content=content, + ).save() + + @receiver(signals.suggestions_management, sender=AddSuggestion) @receiver(signals.suggestions_management, sender=RemoveSuggestion) def record_event_suggestion_management(sender, performer, signal, content, action, **_): diff --git a/zds/tutorialv2/signals.py b/zds/tutorialv2/signals.py index dd8decfbf9..5815177dd6 100644 --- a/zds/tutorialv2/signals.py +++ b/zds/tutorialv2/signals.py @@ -34,6 +34,10 @@ # For the signal below, the arguments "performer" and "content" shall be provided. tags_management = Signal() +# Canonical link management +# For the signal below, the arguments "performer" and "content" shall be provided. +canonical_link_management = Signal() + # Suggestions management # For the signal below, the arguments "performer" and "content" shall be provided. # Action is either "add" or "remove". diff --git a/zds/tutorialv2/tests/tests_views/tests_editcanonicallinkview.py b/zds/tutorialv2/tests/tests_views/tests_editcanonicallinkview.py new file mode 100644 index 0000000000..12b3ace8e2 --- /dev/null +++ b/zds/tutorialv2/tests/tests_views/tests_editcanonicallinkview.py @@ -0,0 +1,121 @@ +from unittest.mock import patch + +from django.test import TestCase +from django.urls import reverse +from django.utils.html import escape +from django.utils.translation import gettext_lazy as _ + +from zds.tutorialv2.views.canonical import EditCanonicalLinkForm, EditCanonicalLinkView +from zds.tutorialv2.tests import TutorialTestMixin, override_for_contents +from zds.tutorialv2.tests.factories import PublishableContentFactory +from zds.member.tests.factories import ProfileFactory, StaffProfileFactory + + +@override_for_contents() +class PermissionTests(TutorialTestMixin, TestCase): + """Test permissions and associated behaviors, such as redirections and status codes.""" + + def setUp(self): + # Create users + self.author = ProfileFactory().user + self.staff = StaffProfileFactory().user + self.outsider = ProfileFactory().user + + # Create a content + self.content = PublishableContentFactory(author_list=[self.author]) + + # Get information to be reused in tests + self.form_url = reverse("content:edit-canonical-link", kwargs={"pk": self.content.pk}) + self.form_data = {"source": "https://example.com"} + self.content_data = {"pk": self.content.pk, "slug": self.content.slug} + self.content_url = reverse("content:view", kwargs=self.content_data) + self.login_url = reverse("member-login") + "?next=" + self.form_url + + def test_not_authenticated(self): + self.client.logout() # ensure no user is authenticated + response = self.client.post(self.form_url, self.form_data) + self.assertRedirects(response, self.login_url) + + def test_authenticated_author(self): + self.client.force_login(self.author) + response = self.client.post(self.form_url, self.form_data) + self.assertRedirects(response, self.content_url) + + def test_authenticated_staff(self): + self.client.force_login(self.staff) + response = self.client.post(self.form_url, self.form_data) + self.assertRedirects(response, self.content_url) + + def test_authenticated_outsider(self): + self.client.force_login(self.outsider) + response = self.client.post(self.form_url, self.form_data) + self.assertEqual(response.status_code, 403) + + +@override_for_contents() +class WorkflowTests(TutorialTestMixin, TestCase): + """Test the workflow of the form, such as validity errors and success messages.""" + + def setUp(self): + # Create a user + self.author = ProfileFactory() + + # Create a content + self.content = PublishableContentFactory(author_list=[self.author.user]) + + # Get information to be reused in tests + self.form_url = reverse("content:edit-canonical-link", kwargs={"pk": self.content.pk}) + self.error_messages = EditCanonicalLinkForm.declared_fields["source"].error_messages + self.error_messages["too_long"] = _("Assurez-vous que cette valeur comporte au plus") + self.success_message = EditCanonicalLinkView.success_message + + # Log in with an authorized user (e.g the author of the content) to perform the tests + self.client.force_login(self.author.user) + + def get_test_cases(self): + return { + "no_field": {"inputs": {}, "expected_outputs": [self.success_message]}, + "empty": {"inputs": {"source": ""}, "expected_outputs": [self.success_message]}, + "valid_1": {"inputs": {"source": "example.com"}, "expected_outputs": [self.success_message]}, + "valid_2": {"inputs": {"source": "https://example.com"}, "expected_outputs": [self.success_message]}, + "invalid": {"inputs": {"source": "invalid_url"}, "expected_outputs": [self.error_messages["invalid"]]}, + } + + def test_form_workflow(self): + test_cases = self.get_test_cases() + for case_name, case in test_cases.items(): + with self.subTest(msg=case_name): + response = self.client.post(self.form_url, case["inputs"], follow=True) + for msg in case["expected_outputs"]: + self.assertContains(response, escape(msg)) + + +@override_for_contents() +class FunctionalTests(TutorialTestMixin, TestCase): + """Test the detailed behavior of the feature, such as updates of the database or repositories.""" + + def setUp(self): + self.author = ProfileFactory() + self.content = PublishableContentFactory(author_list=[self.author.user]) + self.form_url = reverse("content:edit-canonical-link", kwargs={"pk": self.content.pk}) + + # Log in with an authorized user (e.g the author of the content) to perform the tests + self.client.force_login(self.author.user) + + @patch("zds.tutorialv2.signals.canonical_link_management") + def test_normal(self, canonical_link_management): + valid_url = "https://example.com" + self.client.post(self.form_url, data={"source": valid_url}, follow=True) + expected = {"source": valid_url, "call_count": 1} + self.check_effects(expected, canonical_link_management) + + @patch("zds.tutorialv2.signals.canonical_link_management") + def test_empty(self, canonical_link_management): + self.client.post(self.form_url, data={"source": ""}, follow=True) + expected = {"source": "", "call_count": 1} + self.check_effects(expected, canonical_link_management) + + def check_effects(self, expected_outputs, canonical_link_management): + self.content.refresh_from_db() + self.assertEqual(self.content.source, expected_outputs["source"]) + self.assertEqual(canonical_link_management.send.call_count, expected_outputs["call_count"]) diff --git a/zds/tutorialv2/urls/urls_contents.py b/zds/tutorialv2/urls/urls_contents.py index f3a8cb5769..cd9feff1f5 100644 --- a/zds/tutorialv2/urls/urls_contents.py +++ b/zds/tutorialv2/urls/urls_contents.py @@ -1,6 +1,7 @@ from django.urls import path from django.views.generic.base import RedirectView +from zds.tutorialv2.views.canonical import EditCanonicalLinkView from zds.tutorialv2.views.categories import EditCategoriesView from zds.tutorialv2.views.contents import ( CreateContent, @@ -221,6 +222,8 @@ def get_version_pages(): path("modifier-licence//", EditContentLicense.as_view(), name="edit-license"), # Modify the tags path("modifier-tags//", EditTags.as_view(), name="edit-tags"), + # Modify the canonical link + path("modifier-lien-canonique/", EditCanonicalLinkView.as_view(), name="edit-canonical-link"), # Modify the categories path("modifier-categories//", EditCategoriesView.as_view(), name="edit-categories"), # beta: diff --git a/zds/tutorialv2/views/canonical.py b/zds/tutorialv2/views/canonical.py new file mode 100644 index 0000000000..422ed3528c --- /dev/null +++ b/zds/tutorialv2/views/canonical.py @@ -0,0 +1,76 @@ +from crispy_forms.bootstrap import StrictButton +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Layout, Field, ButtonHolder +from django import forms +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.shortcuts import get_object_or_404 +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from zds.tutorialv2 import signals +from zds.tutorialv2.mixins import SingleContentFormViewMixin +from zds.tutorialv2.models.database import PublishableContent +from zds.utils import get_current_user + + +class EditCanonicalLinkForm(forms.Form): + source = forms.URLField( + label=_( + """Si votre contenu est publié en dehors de Zeste de Savoir (blog, site personnel, etc.), + indiquez le lien de la publication originale :""" + ), + max_length=PublishableContent._meta.get_field("source").max_length, + required=False, + widget=forms.TextInput(attrs={"placeholder": _("https://...")}), + error_messages={"invalid": _("Entrez un lien valide.")}, + ) + + def __init__(self, content, *args, **kwargs): + kwargs["initial"] = {"source": content.source} + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.form_method = "post" + self.helper.form_action = reverse("content:edit-canonical-link", kwargs={"pk": content.pk}) + self.helper.form_class = "modal modal-flex" + self.helper.form_id = "edit-canonical-link" + + self.helper.layout = Layout( + Field("source"), + ButtonHolder( + StrictButton(_("Valider"), type="submit"), + ), + ) + + self.previous_page_url = reverse("content:view", kwargs={"pk": content.pk, "slug": content.slug}) + + +class EditCanonicalLinkView(LoginRequiredMixin, SingleContentFormViewMixin): + model = PublishableContent + form_class = EditCanonicalLinkForm + success_message = _("Le lien canonique a bien été modifié.") + modal_form = True + http_method_names = ["post"] + + def dispatch(self, request, *args, **kwargs): + content = get_object_or_404(PublishableContent, pk=self.kwargs["pk"]) + success_url_kwargs = {"pk": content.pk, "slug": content.slug} + self.success_url = reverse("content:view", kwargs=success_url_kwargs) + return super().dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["content"] = self.object + return kwargs + + def form_invalid(self, form): + form.previous_page_url = self.success_url + return super().form_invalid(form) + + def form_valid(self, form): + self.object.source = form.cleaned_data["source"] + self.object.save() + messages.success(self.request, self.success_message) + signals.canonical_link_management.send(sender=self.__class__, performer=get_current_user(), content=self.object) + return super().form_valid(form) diff --git a/zds/tutorialv2/views/contents.py b/zds/tutorialv2/views/contents.py index 23101587cf..6ba8e9f4c9 100644 --- a/zds/tutorialv2/views/contents.py +++ b/zds/tutorialv2/views/contents.py @@ -75,7 +75,6 @@ def form_valid(self, form): self.content.description = form.cleaned_data["description"] self.content.type = form.cleaned_data["type"] self.content.licence = self.request.user.profile.licence # Use the preferred license of the user if it exists - self.content.source = form.cleaned_data["source"] self.content.creation_date = datetime.now() gallery = Gallery.objects.create( @@ -132,7 +131,6 @@ def get_initial(self): initial["introduction"] = versioned.get_introduction() initial["conclusion"] = versioned.get_conclusion() - initial["source"] = versioned.source initial["subcategory"] = self.object.subcategory.all() initial["last_hash"] = versioned.compute_hash() @@ -160,9 +158,6 @@ def form_valid(self, form): messages.error(self.request, _("Une nouvelle version a été postée avant que vous ne validiez.")) return self.form_invalid(form) - # first, update DB (in order to get a new slug if needed) - publishable.source = form.cleaned_data["source"] - publishable.update_date = datetime.now() # update image diff --git a/zds/tutorialv2/views/display/content.py b/zds/tutorialv2/views/display/content.py index bbe78d1f06..e70af96495 100644 --- a/zds/tutorialv2/views/display/content.py +++ b/zds/tutorialv2/views/display/content.py @@ -25,6 +25,7 @@ UnpickOpinionForm, PromoteOpinionToArticleForm, ) +from zds.tutorialv2.views.canonical import EditCanonicalLinkForm from zds.tutorialv2.views.contributors import ContributionForm from zds.tutorialv2.views.suggestions import SearchSuggestionForm from zds.tutorialv2.views.licence import EditContentLicenseForm @@ -101,6 +102,7 @@ def get_context_data(self, **kwargs): context["form_convert"] = PromoteOpinionToArticleForm(self.versioned_object, initial=data_form_convert) context["form_warn_typo"] = WarnTypoForm(self.versioned_object, self.versioned_object) context["form_edit_tags"] = EditTagsForm(self.versioned_object, self.object) + context["form_edit_canonical_link"] = EditCanonicalLinkForm(self.object) context["form_edit_goals"] = EditGoalsForm(self.object) context["form_edit_labels"] = EditLabelsForm(self.object) context["is_antispam"] = self.object.antispam(self.request.user)