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)