diff --git a/fixtures/goals.yaml b/fixtures/goals.yaml
new file mode 100644
index 0000000000..cc9ec284dc
--- /dev/null
+++ b/fixtures/goals.yaml
@@ -0,0 +1,28 @@
+- model: tutorialv2.Goal
+ pk: 1
+ fields:
+ name: Comprendre
+ description: Publications pour comprendre quelque chose à quelque chose d'autre.
+ position: 0
+ slug: comprendre
+- model: tutorialv2.Goal
+ pk: 2
+ fields:
+ name: Apprendre
+ description: Qui n'apprend rien n'a rien.
+ position: 1
+ slug: apprendre
+- model: tutorialv2.Goal
+ pk: 3
+ fields:
+ name: Exprimer une opinion
+ description: Ayez un avis sur tout.
+ position: 2
+ slug: exprimer-opinion
+- model: tutorialv2.Goal
+ pk: 4
+ fields:
+ name: Nouvelles de la communauté
+ description: Nous sommes des communards, après tout.
+ position: 3
+ slug: nouvelles-communaute
diff --git a/templates/tutorialv2/events/descriptions.part.html b/templates/tutorialv2/events/descriptions.part.html
index 550e7774c9..e28e2c5463 100644
--- a/templates/tutorialv2/events/descriptions.part.html
+++ b/templates/tutorialv2/events/descriptions.part.html
@@ -61,6 +61,10 @@
{{ event.performer }} a modifié les tags du contenu.
+{% elif event.type == "goals_management" %}
+ {{ event.performer }} a modifié les objectifs du contenu.
+
+
{% elif event.type == "suggestions_management" %}
{% if event.action == "add" %}
{{ event.performer }} a ajouté une suggestion de contenu.
diff --git a/templates/tutorialv2/includes/editorialization.part.html b/templates/tutorialv2/includes/editorialization.part.html
index 1fe002df0c..c3636daa84 100644
--- a/templates/tutorialv2/includes/editorialization.part.html
+++ b/templates/tutorialv2/includes/editorialization.part.html
@@ -5,11 +5,20 @@
Éditorialisation
-
- Modifier les tags
+ {% trans "Modifier les tags" %}
{% crispy form_edit_tags %}
+ {% if is_staff %}
+ -
+
+ {% trans "Modifier les objectifs" %}
+
+ {% crispy form_edit_goals %}
+
+ {% endif %}
+
{% if is_staff and not content.is_opinion %}
-
diff --git a/zds/tutorialv2/admin.py b/zds/tutorialv2/admin.py
index f696cacbe6..c84ec1426c 100644
--- a/zds/tutorialv2/admin.py
+++ b/zds/tutorialv2/admin.py
@@ -11,6 +11,7 @@
ContentContributionRole,
)
from zds.tutorialv2.models.events import Event
+from zds.tutorialv2.models.goals import Goal
from zds.tutorialv2.models.help_requests import HelpWriting
@@ -112,6 +113,11 @@ class ContentReviewTypeAdmin(admin.ModelAdmin):
ordering = ["position"]
+class GoalAdmin(admin.ModelAdmin):
+ list_display = ["name", "description"]
+ ordering = ["position"]
+
+
admin.site.register(PublishableContent, PublishableContentAdmin)
admin.site.register(PublishedContent, PublishedContentAdmin)
admin.site.register(Validation, ValidationAdmin)
@@ -122,3 +128,4 @@ class ContentReviewTypeAdmin(admin.ModelAdmin):
admin.site.register(ContentContributionRole, ContentReviewTypeAdmin)
admin.site.register(HelpWriting)
admin.site.register(Event)
+admin.site.register(Goal, GoalAdmin)
diff --git a/zds/tutorialv2/migrations/0034_goals.py b/zds/tutorialv2/migrations/0034_goals.py
new file mode 100644
index 0000000000..be69f65a72
--- /dev/null
+++ b/zds/tutorialv2/migrations/0034_goals.py
@@ -0,0 +1,34 @@
+# Generated by Django 3.2.13 on 2022-07-15 09:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tutorialv2", "0033_move_helpwriting"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Goal",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("name", models.CharField(max_length=80, verbose_name="Nom")),
+ ("description", models.TextField(blank=True, verbose_name="Description")),
+ ("position", models.IntegerField(db_index=True, default=0, verbose_name="Position")),
+ ("slug", models.SlugField(max_length=80, unique=True)),
+ ],
+ options={
+ "verbose_name": "Objectif",
+ "verbose_name_plural": "Objectifs",
+ },
+ ),
+ migrations.AddField(
+ model_name="publishablecontent",
+ name="goals",
+ field=models.ManyToManyField(
+ blank=True, db_index=True, to="tutorialv2.Goal", verbose_name="Objectifs du contenu"
+ ),
+ ),
+ ]
diff --git a/zds/tutorialv2/models/database.py b/zds/tutorialv2/models/database.py
index 60bd30c379..f29cc4d723 100644
--- a/zds/tutorialv2/models/database.py
+++ b/zds/tutorialv2/models/database.py
@@ -33,6 +33,7 @@
)
from zds.tutorialv2.managers import PublishedContentManager, PublishableContentManager, ReactionManager
from zds.tutorialv2.models import TYPE_CHOICES, STATUS_CHOICES, CONTENT_TYPES_REQUIRING_VALIDATION, PICK_OPERATIONS
+from zds.tutorialv2.models.goals import Goal
from zds.tutorialv2.models.mixins import TemplatableContentModelMixin, OnlineLinkableContentMixin
from zds.tutorialv2.models.versioned import NotAPublicVersion
from zds.tutorialv2.utils import get_content_from_json, BadManifestError, get_blob
@@ -71,8 +72,9 @@ class Meta:
authors = models.ManyToManyField(User, verbose_name="Auteurs", db_index=True)
old_pk = models.IntegerField(db_index=True, default=0)
subcategory = models.ManyToManyField(SubCategory, verbose_name="Sous-Catégorie", blank=True, db_index=True)
-
tags = models.ManyToManyField(Tag, verbose_name="Tags du contenu", blank=True, db_index=True)
+ goals = models.ManyToManyField(Goal, verbose_name="Objectifs du contenu", blank=True, db_index=True)
+
# store the thumbnail for tutorial or article
image = models.ForeignKey(Image, verbose_name="Image du tutoriel", blank=True, null=True, on_delete=models.SET_NULL)
diff --git a/zds/tutorialv2/models/events.py b/zds/tutorialv2/models/events.py
index 211837b2df..de03874bf0 100644
--- a/zds/tutorialv2/models/events.py
+++ b/zds/tutorialv2/models/events.py
@@ -8,6 +8,7 @@
from zds.tutorialv2.views.beta import ManageBetaContent
from zds.tutorialv2.views.contributors import AddContributorToContent, RemoveContributorFromContent
from zds.tutorialv2.views.editorialization import EditContentTags, AddSuggestion, RemoveSuggestion
+from zds.tutorialv2.views.goals import EditGoals
from zds.tutorialv2.views.help import ChangeHelp
from zds.tutorialv2.views.validations_contents import (
ReserveValidation,
@@ -25,6 +26,7 @@
# * Addition
# 1. Add a key in `types`.
# 2. Modify the template "events/description.part.html" so that it is displayed properly.
+# 3. Add the appropriate receiver.
#
# * Deletion
# 1. Remove the key in `types` and the corresponding `@receiver`.
@@ -44,6 +46,7 @@
signals.beta_management: "beta_management",
signals.validation_management: "validation_management",
signals.tags_management: "tags_management",
+ signals.goals_management: "goals_management",
signals.suggestions_management: "suggestions_management",
signals.help_management: "help_management",
signals.jsfiddle_management: "jsfiddle_management",
@@ -147,6 +150,15 @@ def record_event_suggestion_management(sender, performer, signal, content, actio
).save()
+@receiver(signals.goals_management, sender=EditGoals)
+def record_event_goals_management(sender, performer, signal, content, **_):
+ Event(
+ performer=performer,
+ type=types[signal],
+ content=content,
+ ).save()
+
+
@receiver(signals.help_management, sender=ChangeHelp)
def record_event_help_management(sender, performer, signal, content, **_):
Event(
diff --git a/zds/tutorialv2/models/goals.py b/zds/tutorialv2/models/goals.py
new file mode 100644
index 0000000000..2e8cecf28d
--- /dev/null
+++ b/zds/tutorialv2/models/goals.py
@@ -0,0 +1,22 @@
+from django.db import models
+
+
+class Goal(models.Model):
+ """
+ This model represents the categories used for the goal-based classification of publications.
+ The goals are categories like "understand", "discover", "learn", "give an opinion", etc.
+ They are thus distinct from the thematic categories and subcategories (physics,
+ computer science, etc.) or the tags (even more precise).
+ """
+
+ class Meta:
+ verbose_name = "Objectif"
+ verbose_name_plural = "Objectifs"
+
+ name = models.CharField("Nom", max_length=80)
+ description = models.TextField("Description", blank=True)
+ position = models.IntegerField("Position", default=0, db_index=True)
+ slug = models.SlugField(max_length=80, unique=True)
+
+ def __str__(self):
+ return self.name
diff --git a/zds/tutorialv2/signals.py b/zds/tutorialv2/signals.py
index 5ce8ee726d..f07c3d66f7 100644
--- a/zds/tutorialv2/signals.py
+++ b/zds/tutorialv2/signals.py
@@ -36,6 +36,10 @@
# Action is either "add" or "remove".
suggestions_management = Signal()
+# Goals management
+# For the signal below, the arguments "performer" and "content" shall be provided.
+goals_management = Signal()
+
# Help management
# For the signal below, the arguments "performer" and "content" shall be provided.
help_management = Signal()
diff --git a/zds/tutorialv2/tests/factories.py b/zds/tutorialv2/tests/factories.py
index 3b2fdcb19a..9898258f8a 100644
--- a/zds/tutorialv2/tests/factories.py
+++ b/zds/tutorialv2/tests/factories.py
@@ -7,6 +7,7 @@
from zds.forum.tests.factories import PostFactory, TopicFactory
from zds.gallery.tests.factories import GalleryFactory, UserGalleryFactory
+from zds.tutorialv2.models.goals import Goal
from zds.tutorialv2.models.help_requests import HelpWriting
from zds.utils import old_slugify
from zds.utils.tests.factories import LicenceFactory, SubCategoryFactory
@@ -299,3 +300,15 @@ def _create(cls, target_class, *args, **kwargs):
kwargs.pop("fixture_image_path", None)
return super()._create(target_class, *args, **kwargs)
+
+
+class GoalFactory(factory.django.DjangoModelFactory):
+ """Factory that create a goal for use in tests."""
+
+ class Meta:
+ model = Goal
+
+ name = factory.Sequence("Mon objectif n°{}".format)
+ description = factory.Sequence("Très belle description n°{}".format)
+ position = factory.Sequence(lambda n: n)
+ slug = factory.Sequence("mon-objectif-{}".format)
diff --git a/zds/tutorialv2/tests/tests_views/tests_editgoals.py b/zds/tutorialv2/tests/tests_views/tests_editgoals.py
new file mode 100644
index 0000000000..6e85dac46e
--- /dev/null
+++ b/zds/tutorialv2/tests/tests_views/tests_editgoals.py
@@ -0,0 +1,91 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+from django.urls import reverse
+
+from zds.member.tests.factories import ProfileFactory, StaffProfileFactory
+from zds.tutorialv2.tests.factories import PublishableContentFactory, GoalFactory
+
+
+class EditGoalsPermissionTests(TestCase):
+ def setUp(self):
+ self.user = ProfileFactory().user
+ self.author = ProfileFactory().user
+ self.staff = StaffProfileFactory().user
+ self.content = PublishableContentFactory()
+ self.content.authors.add(self.author)
+ self.good_url = reverse("content:edit-goals", kwargs={"pk": self.content.pk})
+ self.bad_url = reverse("content:edit-goals", kwargs={"pk": 42})
+ self.content_url = reverse("content:view", kwargs={"pk": self.content.pk, "slug": self.content.slug})
+ self.success_url = self.content_url
+
+ def test_display(self):
+ """We shall display the form only for staff, not for authors."""
+ fragment = "Modifier les objectifs"
+
+ self.client.force_login(self.author)
+ response = self.client.get(self.content_url)
+ self.assertNotContains(response, fragment)
+
+ self.client.force_login(self.staff)
+ response = self.client.get(self.content_url)
+ self.assertContains(response, fragment)
+
+ def test_get_method(self):
+ """
+ GET is forbidden, since the view processes the form but do not display anything.
+ Actually, all methods except POST are forbidden, but the test is good enough as is.
+ """
+ self.client.force_login(self.staff)
+ response = self.client.get(self.good_url)
+ self.assertEqual(response.status_code, 405)
+
+ def test_unauthenticated_not_existing_pk(self):
+ """Invalid pks in URL"""
+ self.client.logout()
+ response = self.client.post(self.bad_url)
+ self.assertEqual(response.status_code, 404)
+
+ def test_unauthenticated_redirected(self):
+ """As login is required, unauthenticated users shall be redirected to the login page."""
+ self.client.logout()
+ response = self.client.post(self.good_url)
+ self.login_url = f"{reverse('member-login')}?next={self.good_url}"
+ self.assertRedirects(response, self.login_url)
+
+ def test_simple_user_forbidden(self):
+ """Simple users shall not be able to access to the view."""
+ self.client.force_login(self.user)
+ response = self.client.post(self.good_url)
+ self.assertEqual(response.status_code, 403)
+
+ def test_staff_authorized(self):
+ """Staff shall have access to the view."""
+ self.client.force_login(self.staff)
+ response = self.client.post(self.good_url)
+ self.assertRedirects(response, self.success_url)
+
+
+class EditGoalsFunctionalTests(TestCase):
+ def setUp(self):
+ self.staff = StaffProfileFactory().user
+ self.content = PublishableContentFactory()
+ self.content = PublishableContentFactory()
+ self.url = reverse("content:edit-goals", kwargs={"pk": self.content.pk})
+ self.goals = [GoalFactory() for _ in range(3)]
+
+ @patch("zds.tutorialv2.signals.goals_management")
+ def test_goals_updated(self, goals_management):
+ self.client.force_login(self.staff)
+ response = self.client.post(self.url, {"goals": [goal.pk for goal in self.goals]}, follow=True)
+ self.assertEqual(list(self.content.goals.all()), self.goals)
+ self.assertContains(response, "alert-box success")
+ self.assertEqual(goals_management.send.call_count, 1)
+
+ @patch("zds.tutorialv2.signals.goals_management")
+ def test_invalid_parameters(self, goals_management):
+ self.client.force_login(self.staff)
+ response = self.client.post(self.url, {"goals": [42]}, follow=True)
+ self.assertEqual(list(self.content.goals.all()), [])
+ self.assertContains(response, "alert-box alert")
+ self.assertFalse(goals_management.send.called)
diff --git a/zds/tutorialv2/urls/urls_contents.py b/zds/tutorialv2/urls/urls_contents.py
index b04473af69..afae0622db 100644
--- a/zds/tutorialv2/urls/urls_contents.py
+++ b/zds/tutorialv2/urls/urls_contents.py
@@ -3,6 +3,7 @@
from zds.tutorialv2.views.contents import DisplayContent, CreateContent, EditContent, EditContentLicense, DeleteContent
from zds.tutorialv2.views.events import EventsList
+from zds.tutorialv2.views.goals import EditGoals
from zds.tutorialv2.views.validations_contents import ActivateJSFiddleInContent
from zds.tutorialv2.views.containers_extracts import (
CreateContainer,
@@ -184,4 +185,6 @@
path("", RedirectView.as_view(pattern_name="publication:list", permanent=True), name="list"),
# Journal of events
path("evenements//", EventsList.as_view(), name="events"),
+ # Goal-based classification
+ path("modifier-objectifs//", EditGoals.as_view(), name="edit-goals"),
]
diff --git a/zds/tutorialv2/views/contents.py b/zds/tutorialv2/views/contents.py
index 753014f720..bb8cea9ff9 100644
--- a/zds/tutorialv2/views/contents.py
+++ b/zds/tutorialv2/views/contents.py
@@ -42,6 +42,7 @@
from zds.tutorialv2.models.database import PublishableContent, Validation, ContentContribution, ContentSuggestion
from zds.tutorialv2.utils import init_new_repo
from zds.tutorialv2.views.authors import RemoveAuthorFromContent
+from zds.tutorialv2.views.goals import EditGoalsForm
from zds.utils.models import get_hat_from_settings
from zds.mp.utils import send_mp, send_message_mp
from zds.utils.uuslug_wrapper import slugify
@@ -180,6 +181,7 @@ def get_forms(self, context):
context["formJs"] = form_js
context["form_edit_license"] = EditContentLicenseForm(self.versioned_object)
context["form_edit_tags"] = EditContentTagsForm(self.versioned_object, self.object)
+ context["form_edit_goals"] = EditGoalsForm(self.object)
if self.versioned_object.requires_validation:
context["formPublication"] = PublicationForm(self.versioned_object, initial={"source": self.object.source})
diff --git a/zds/tutorialv2/views/display.py b/zds/tutorialv2/views/display.py
index 54c58073da..ce4ce58bee 100644
--- a/zds/tutorialv2/views/display.py
+++ b/zds/tutorialv2/views/display.py
@@ -29,6 +29,7 @@
from zds.tutorialv2.utils import search_container_or_404, last_participation_is_old, mark_read
from zds.tutorialv2.views.containers_extracts import DisplayContainer
from zds.tutorialv2.views.contents import DisplayContent
+from zds.tutorialv2.views.goals import EditGoalsForm
from zds.utils.models import CommentVote
from zds.utils.paginator import make_pagination
@@ -115,6 +116,7 @@ def get_context_data(self, **kwargs):
)
context["form_edit_tags"] = EditContentTagsForm(self.versioned_object, self.object)
+ context["form_edit_goals"] = EditGoalsForm(self.object)
# pagination of comments
make_pagination(
diff --git a/zds/tutorialv2/views/goals.py b/zds/tutorialv2/views/goals.py
new file mode 100644
index 0000000000..192b71b6c7
--- /dev/null
+++ b/zds/tutorialv2/views/goals.py
@@ -0,0 +1,71 @@
+from crispy_forms.bootstrap import StrictButton
+from crispy_forms.helper import FormHelper
+from crispy_forms.layout import HTML, Layout, Field, ButtonHolder
+from django.contrib import messages
+from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
+from django.forms import forms, ModelMultipleChoiceField, CheckboxSelectMultiple
+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.tutorialv2.models.goals import Goal
+from zds.utils import get_current_user
+
+
+class EditGoalsForm(forms.Form):
+ goals = ModelMultipleChoiceField(
+ label=_("Objectifs de la publication :"),
+ queryset=Goal.objects.all(),
+ required=False,
+ widget=CheckboxSelectMultiple,
+ )
+
+ def __init__(self, content, *args, **kwargs):
+ kwargs["initial"] = {"goals": content.goals.all()}
+ super().__init__(*args, **kwargs)
+
+ self.helper = FormHelper()
+ self.helper.form_class = "content-wrapper"
+ self.helper.form_method = "post"
+ self.helper.form_id = "edit-goals"
+ self.helper.form_class = "modal modal-flex"
+ self.helper.form_action = reverse("content:edit-goals", kwargs={"pk": content.pk})
+ self.helper.layout = Layout(
+ Field("goals"),
+ ButtonHolder(StrictButton("Valider", type="submit")),
+ )
+
+
+class EditGoals(LoginRequiredMixin, PermissionRequiredMixin, SingleContentFormViewMixin):
+ permission_required = "tutorialv2.change_publishablecontent"
+ model = PublishableContent
+ form_class = EditGoalsForm
+ success_message = _("Les objectifs ont bien été modifiés.")
+ 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.goals.clear()
+ new_goals = Goal.objects.filter(id__in=form.cleaned_data["goals"])
+ self.object.goals.add(*new_goals)
+ messages.success(self.request, self.success_message)
+ signals.goals_management.send(sender=self.__class__, performer=get_current_user(), content=self.object)
+ return super().form_valid(form)