diff --git a/templates/tutorialv2/events/descriptions.part.html b/templates/tutorialv2/events/descriptions.part.html new file mode 100644 index 0000000000..550e7774c9 --- /dev/null +++ b/templates/tutorialv2/events/descriptions.part.html @@ -0,0 +1,100 @@ +{% load captureas %} + +{% captureas performer_href %}{% url "member-detail" event.performer.username %}{% endcaptureas %} + + +{% if event.type == "authors_management" %} + {% captureas author_href %}{% url "member-detail" event.author.username %}{% endcaptureas %} + {% if event.action == "add" %} + {{ event.performer }} a ajouté {{ event.author }} à la liste des auteurs. + {% elif event.action == "remove" %} + {{ event.performer }} a supprimé {{ event.author }} de la liste des auteurs. + {% else %} + {{ event.performer }} a effectué une action de type « {{ event.action }} » sur la liste des auteurs. + {% endif %} + + +{% elif event.type == "contributors_management" %} + {% captureas contributor_href %}{% url "member-detail" event.contributor.username %}{% endcaptureas %} + {% if event.action == "add" %} + {{ event.performer }} a ajouté {{ event.contributor }} à la liste des contributeurs. + {% elif event.action == "remove" %} + {{ event.performer }} a supprimé {{ event.contributor }} de la liste des contributeurs. + {% else %} + {{ event.performer }} a effectué une action de type « {{ event.action }} » sur la liste des contributeurs. + {% endif %} + + +{% elif event.type == "beta_management" %} + {% captureas version_href %}{% url "content:view" event.content.pk event.content.slug %}?version={{ event.version }}{% endcaptureas %} + {% if event.action == "activate" %} + {{ event.performer }} a mis une version du contenu en bêta. + {% elif event.action == "deactivate" %} + {{ event.performer }} a désactivé la bêta. + {% else %} + {{ event.performer }} a effectué une action de type « {{ event.action }} » sur la bêta. + {% endif %} + + +{% elif event.type == "validation_management" %} + {% captureas version_href %}{% url "content:view" event.content.pk event.content.slug %}?version={{ event.version }}{% endcaptureas %} + {% if event.action == "request" %} + {{ event.performer }} a demandé la validation d'une version du contenu. + {% elif event.action == "cancel" %} + {{ event.performer }} a annulé la demande de validation du contenu. + {% elif event.action == "accept" %} + {{ event.performer }} a accepté le contenu pour publication. + {% elif event.action == "reject" %} + {{ event.performer }} a refusé le contenu pour publication. + {% elif event.action == "revoke" %} + {{ event.performer }} a dépublié le contenu. + {% elif event.action == "reserve" %} + {{ event.performer }} a réservé le contenu pour validation. + {% elif event.action == "unreserve" %} + {{ event.performer }} a annulé la réservation du contenu pour validation. + {% else %} + {{ event.performer }} a effectué une action de validation de type « {{ event.action }} ». + {% endif %} + + +{% elif event.type == "tags_management" %} + {{ event.performer }} a modifié les tags du contenu. + + +{% elif event.type == "suggestions_management" %} + {% if event.action == "add" %} + {{ event.performer }} a ajouté une suggestion de contenu. + {% elif event.action == "remove" %} + {{ event.performer }} a supprimé une suggestion de contenu. + {% else %} + {{ event.performer }} a effectué une action de type « {{ event.action }} » sur les suggestions de contenu. + {% endif %} + + +{% elif event.type == "help_management" %} + {{ event.performer }} a modifié les demandes d'aide. + + +{% elif event.type == "jsfiddle_management" %} + {% if event.action == "activate" %} + {{ event.performer }} a activé JSFiddle. + {% elif event.action == "deactivate" %} + {{ event.performer }} a désactivé JSFiddle. + {% else %} + {{ event.performer }} a effectué une action de type « {{ event.action }} » sur la gestion de JS Fiddle. + {% endif %} + + +{% elif event.type == "opinions_management" %} + {% if event.action == "publish" %} + {{ event.performer }} a publié le billet. + {% elif event.action == "unpublish" %} + {{ event.performer }} a dépublié le billet. + {% else %} + {{ event.performer }} a effectué une action de type « {{ event.action }} » sur le billet. + {% endif %} + + +{% else %} + {{ event.performer }} a déclenché un événement de type « {{ event.type }} ». +{% endif %} diff --git a/templates/tutorialv2/events/list.html b/templates/tutorialv2/events/list.html new file mode 100644 index 0000000000..13ebfeb6d5 --- /dev/null +++ b/templates/tutorialv2/events/list.html @@ -0,0 +1,74 @@ +{% extends "tutorialv2/base.html" %} +{% load profile %} +{% load thumbnail %} +{% load date %} +{% load i18n %} + + +{% block title %} + {% blocktrans with title=content.title %} + Journal des événements de "{{ title }}" + {% endblocktrans %} +{% endblock %} + + + +{% block breadcrumb %} +
  • {{ content.title }}
  • +
  • {% trans "Journal des événements" %}
  • +{% endblock %} + + + +{% block headline %} + {% if content.licence %} +

    + {{ content.licence }} +

    + {% endif %} + +

    + {% if content.image %} + + {% endif %} + {% blocktrans with title=content.title %} + Journal des événements de "{{ title }}" + {% endblocktrans %} +

    + + {% if content.description %} +

    + {{ content.description }} +

    + {% endif %} + + {% include 'tutorialv2/includes/tags_authors.part.html' with content=content online=False %} +{% endblock %} + + + +{% block content %} + {% if events %} + {% include "misc/paginator.html" with position="top" %} + + + + + + + + + + {% for e in events %} + + + + + {% endfor %} + +
    {% trans "Date" %}{% trans "Description" %}
    {{ e.date | format_date:True }}{% include 'tutorialv2/events/descriptions.part.html' with event=e %}
    + {% include "misc/paginator.html" with position="bottom" %} + {% else %} +

    {% trans "Aucun événement n'a été enregistré pour ce contenu." %}

    + {% endif %} +{% endblock %} diff --git a/templates/tutorialv2/view/content.html b/templates/tutorialv2/view/content.html index 5c981f7e28..482327a488 100644 --- a/templates/tutorialv2/view/content.html +++ b/templates/tutorialv2/view/content.html @@ -556,6 +556,14 @@

    {# END HISTORY #} + {# EVENTS #} +
  • + + {% trans "Journal des événements" %} + +
  • + {# END EVENTS #} + {# VALIDATION OR PUBLICATION (NO VALIDATION BEFORE) #} {% if content.requires_validation %} {# Validation (require validation before publication) #} diff --git a/zds/notification/__init__.py b/zds/notification/__init__.py index e69de29bb2..c04d0e7f6b 100644 --- a/zds/notification/__init__.py +++ b/zds/notification/__init__.py @@ -0,0 +1 @@ +default_app_config = "zds.notification.apps.NotificationConfig" diff --git a/zds/notification/apps.py b/zds/notification/apps.py new file mode 100644 index 0000000000..5b0fe993bc --- /dev/null +++ b/zds/notification/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class NotificationConfig(AppConfig): + name = "zds.notification" + + def ready(self): + from . import receivers # noqa diff --git a/zds/notification/models.py b/zds/notification/models.py index 436ce7eb20..e38093e35a 100644 --- a/zds/notification/models.py +++ b/zds/notification/models.py @@ -450,8 +450,3 @@ class Meta: def __str__(self): return f'' - - -# used to fix Django 1.9 Warning -# https://github.com/zestedesavoir/zds-site/issues/3451 -from . import receivers # noqa diff --git a/zds/tutorialv2/admin.py b/zds/tutorialv2/admin.py index 0cdc09a961..7c96537600 100644 --- a/zds/tutorialv2/admin.py +++ b/zds/tutorialv2/admin.py @@ -10,6 +10,7 @@ PublicationEvent, ContentContributionRole, ) +from zds.tutorialv2.models.events import Event class PublishableContentAdmin(admin.ModelAdmin): @@ -118,3 +119,4 @@ class ContentReviewTypeAdmin(admin.ModelAdmin): admin.site.register(ContentRead, ContentReadAdmin) admin.site.register(PublicationEvent, PublicationEventAdmin) admin.site.register(ContentContributionRole, ContentReviewTypeAdmin) +admin.site.register(Event) diff --git a/zds/tutorialv2/migrations/0032_event.py b/zds/tutorialv2/migrations/0032_event.py new file mode 100644 index 0000000000..3bb430e9a3 --- /dev/null +++ b/zds/tutorialv2/migrations/0032_event.py @@ -0,0 +1,58 @@ +# Generated by Django 2.2.24 on 2021-10-03 17:48 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("tutorialv2", "0031_source_is_url"), + ] + + operations = [ + migrations.CreateModel( + name="Event", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("date", models.DateTimeField(auto_now_add=True)), + ("type", models.CharField(max_length=100)), + ("action", models.CharField(max_length=100)), + ("version", models.CharField(max_length=80, null=True)), + ( + "author", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="event_author", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "content", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="tutorialv2.PublishableContent"), + ), + ( + "contributor", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="event_contributor", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "performer", + models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "verbose_name": "Événement sur un contenu", + "verbose_name_plural": "Événements sur un contenu", + }, + ), + ] diff --git a/zds/tutorialv2/models/events.py b/zds/tutorialv2/models/events.py new file mode 100644 index 0000000000..211837b2df --- /dev/null +++ b/zds/tutorialv2/models/events.py @@ -0,0 +1,177 @@ +from django.db import models +from django.contrib.auth.models import User +from django.dispatch import receiver + +from zds.tutorialv2.models.database import PublishableContent +from zds.tutorialv2 import signals +from zds.tutorialv2.views.authors import AddAuthorToContent, RemoveAuthorFromContent +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.help import ChangeHelp +from zds.tutorialv2.views.validations_contents import ( + ReserveValidation, + AskValidationForContent, + CancelValidation, + RejectValidation, + AcceptValidation, + RevokeValidation, + ActivateJSFiddleInContent, +) +from zds.tutorialv2.views.validations_opinions import PublishOpinion, UnpublishOpinion + +# Notes on addition/deletion/update of managed signals +# +# * Addition +# 1. Add a key in `types`. +# 2. Modify the template "events/description.part.html" so that it is displayed properly. +# +# * Deletion +# 1. Remove the key in `types` and the corresponding `@receiver`. +# This will make it impossible to record new events coming from this signal. +# 2. Do not modify the template, so that older events in the database keep being displayed properly. +# +# * Update +# If a type name was to be updated for some reason, two options are possible : +# - cleaner: update the production database to replace the old name with the new and also update the template +# - simpler: update the template so that it knows the new name as well as the old name. + + +# Map signals to event types +types = { + signals.authors_management: "authors_management", + signals.contributors_management: "contributors_management", + signals.beta_management: "beta_management", + signals.validation_management: "validation_management", + signals.tags_management: "tags_management", + signals.suggestions_management: "suggestions_management", + signals.help_management: "help_management", + signals.jsfiddle_management: "jsfiddle_management", + signals.opinions_management: "opinions_management", +} + + +class Event(models.Model): + class Meta: + verbose_name = "Événement sur un contenu" + verbose_name_plural = "Événements sur un contenu" + + # Base fields + date = models.DateTimeField(auto_now_add=True) + performer = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) + type = models.CharField(max_length=100) + content = models.ForeignKey(PublishableContent, on_delete=models.CASCADE) + action = models.CharField(max_length=100) + + # Field used by author events + author = models.ForeignKey(User, related_name="event_author", on_delete=models.SET_NULL, null=True) + + # Field used by contributor events + contributor = models.ForeignKey(User, related_name="event_contributor", on_delete=models.SET_NULL, null=True) + + # Field used by beta and validation events + version = models.CharField(null=True, max_length=80) + + +# Event recorders + + +@receiver(signals.beta_management, sender=ManageBetaContent) +def record_event_beta_management(sender, performer, signal, content, version, action, **_): + Event( + performer=performer, + type=types[signal], + content=content, + version=version, + action=action, + ).save() + + +@receiver(signals.authors_management, sender=AddAuthorToContent) +@receiver(signals.authors_management, sender=RemoveAuthorFromContent) +def record_event_author_management(sender, performer, signal, content, author, action, **_): + Event( + performer=performer, + type=types[signal], + content=content, + author=author, + action=action, + ).save() + + +@receiver(signals.contributors_management, sender=AddContributorToContent) +@receiver(signals.contributors_management, sender=RemoveContributorFromContent) +def record_event_contributor_management(sender, performer, signal, content, contributor, action, **_): + Event( + performer=performer, + type=types[signal], + content=content, + contributor=contributor, + action=action, + ).save() + + +@receiver(signals.validation_management, sender=AskValidationForContent) +@receiver(signals.validation_management, sender=CancelValidation) +@receiver(signals.validation_management, sender=AcceptValidation) +@receiver(signals.validation_management, sender=RejectValidation) +@receiver(signals.validation_management, sender=RevokeValidation) +@receiver(signals.validation_management, sender=ReserveValidation) +def record_event_validation_management(sender, performer, signal, content, version, action, **_): + Event( + performer=performer, + type=types[signal], + content=content, + version=version, + action=action, + ).save() + + +@receiver(signals.tags_management, sender=EditContentTags) +def record_event_tags_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, **_): + Event( + performer=performer, + type=types[signal], + content=content, + action=action, + ).save() + + +@receiver(signals.help_management, sender=ChangeHelp) +def record_event_help_management(sender, performer, signal, content, **_): + Event( + performer=performer, + type=types[signal], + content=content, + ).save() + + +@receiver(signals.jsfiddle_management, sender=ActivateJSFiddleInContent) +def record_event_jsfiddle_management(sender, performer, signal, content, action, **_): + Event( + performer=performer, + type=types[signal], + content=content, + action=action, + ).save() + + +@receiver(signals.opinions_management, sender=PublishOpinion) +@receiver(signals.opinions_management, sender=UnpublishOpinion) +def record_event_opinion_publication_management(sender, performer, signal, content, action, **_): + Event( + performer=performer, + type=types[signal], + content=content, + action=action, + ).save() diff --git a/zds/tutorialv2/signals.py b/zds/tutorialv2/signals.py index 724e1bbe20..5ce8ee726d 100644 --- a/zds/tutorialv2/signals.py +++ b/zds/tutorialv2/signals.py @@ -1,7 +1,51 @@ from django.dispatch.dispatcher import Signal +# Display management +content_read = Signal(providing_args=["instance", "user", "target"]) + # Publication events content_published = Signal(providing_args=["instance", "user", "by_email"]) content_unpublished = Signal(providing_args=["instance", "target", "moderator"]) -content_read = Signal(providing_args=["instance", "user", "target"]) +# Authors management +# For the signal below, the arguments "performer", "content", "author" and "action" shall be provided. +# Action is either "add" or "remove". +authors_management = Signal() + +# Contributors management +# For the signal below, the arguments "performer", "content", "contributor" and "action" shall be provided. +# Action is either "add" or "remove". +contributors_management = Signal() + +# Beta management +# For the signal below, the arguments "performer", "content", "version" and "action" shall be provided. +# Action is either "activate" or "deactivate". +beta_management = Signal() + +# Validation management +# For the signal below, the arguments "performer", "content", "version" and "action" shall be provided. +# Action is either "request", "cancel", "accept", "reject", "revoke", "reserve" or "unreserve". +validation_management = Signal() + +# Tags management +# For the signal below, the arguments "performer" and "content" shall be provided. +tags_management = Signal() + +# Suggestions management +# For the signal below, the arguments "performer" and "content" shall be provided. +# Action is either "add" or "remove". +suggestions_management = Signal() + +# Help management +# For the signal below, the arguments "performer" and "content" shall be provided. +help_management = Signal() + +# JSFiddle management +# For the signal below, the arguments "performer", "content" and "action" shall be provided. +# Action is either "activate" or "deactivate". +jsfiddle_management = Signal() + +# Opinion publication management +# For the signal below, the arguments "performer", "content" and "action" shall be provided. +# Action is either "publish" or "unpublish". +opinions_management = Signal() diff --git a/zds/tutorialv2/tests/models/__init__.py b/zds/tutorialv2/tests/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/zds/tutorialv2/tests/tests_models.py b/zds/tutorialv2/tests/models/tests_models.py similarity index 100% rename from zds/tutorialv2/tests/tests_models.py rename to zds/tutorialv2/tests/models/tests_models.py diff --git a/zds/tutorialv2/tests/tests_opinion_views.py b/zds/tutorialv2/tests/tests_opinion_views.py index 03ce57e855..140b654250 100644 --- a/zds/tutorialv2/tests/tests_opinion_views.py +++ b/zds/tutorialv2/tests/tests_opinion_views.py @@ -1,4 +1,6 @@ import datetime +from unittest.mock import patch + from django.conf import settings from django.core.management import call_command from django.urls import reverse @@ -35,7 +37,8 @@ def setUp(self): self.user_staff = StaffProfileFactory().user self.user_guest = ProfileFactory().user - def test_opinion_publication_author(self): + @patch("zds.tutorialv2.signals.opinions_management") + def test_opinion_publication_author(self, opinions_management): """ Test the publication of PublishableContent where type is OPINION (with author). """ @@ -68,7 +71,8 @@ def test_opinion_publication_author(self): self.assertIsNotNone(opinion.public_version) self.assertEqual(opinion.public_version.sha_public, opinion_draft.current_version) - def test_publish_content_change_title_before_watchdog(self): + @patch("zds.tutorialv2.signals.opinions_management") + def test_publish_content_change_title_before_watchdog(self, opinions_management): """ Test we can publish a content, change its title and publish it again right away, before the publication watchdog processed the first @@ -122,6 +126,8 @@ def test_publish_content_change_title_before_watchdog(self): ) self.assertEqual(result.status_code, 302) self.assertEqual(PublishedContent.objects.count(), 1) + self.assertEqual(opinions_management.send.call_count, 1) + self.assertEqual(opinions_management.send.call_args[1]["action"], "publish") opinion = PublishableContent.objects.get(pk=opinion.pk) self.assertIsNotNone(opinion.public_version) @@ -207,7 +213,8 @@ def test_opinion_publication_staff(self): self.assertIsNotNone(opinion.public_version) self.assertEqual(opinion.public_version.sha_public, opinion_draft.current_version) - def test_opinion_publication_guest(self): + @patch("zds.tutorialv2.signals.opinions_management") + def test_opinion_publication_guest(self, opinions_management): """ Test the publication of PublishableContent where type is OPINION (with guest => 403). """ @@ -233,10 +240,12 @@ def test_opinion_publication_guest(self): follow=False, ) self.assertEqual(result.status_code, 403) + self.assertEqual(opinions_management.send.call_count, 0) self.assertEqual(PublishedContent.objects.count(), 0) - def test_opinion_unpublication(self): + @patch("zds.tutorialv2.signals.opinions_management") + def test_opinion_unpublication(self, opinions_management): """ Test the unpublication of PublishableContent where type is OPINION (with author). """ @@ -268,6 +277,8 @@ def test_opinion_unpublication(self): self.assertEqual(result.status_code, 302) self.assertEqual(PublishedContent.objects.count(), 1) + self.assertEqual(opinions_management.send.call_count, 1) + self.assertEqual(opinions_management.send.call_args[1]["action"], "publish") opinion = PublishableContent.objects.get(pk=opinion.pk) self.assertIsNotNone(opinion.public_version) @@ -282,6 +293,8 @@ def test_opinion_unpublication(self): self.assertEqual(result.status_code, 302) self.assertEqual(PublishedContent.objects.count(), 0) + self.assertEqual(opinions_management.send.call_count, 2) + self.assertEqual(opinions_management.send.call_args[1]["action"], "unpublish") opinion = PublishableContent.objects.get(pk=opinion.pk) self.assertIsNone(opinion.public_version) diff --git a/zds/tutorialv2/tests/tests_views/tests_addcontributor.py b/zds/tutorialv2/tests/tests_views/tests_addcontributor.py index 5191d9d791..2cd7471ecc 100644 --- a/zds/tutorialv2/tests/tests_views/tests_addcontributor.py +++ b/zds/tutorialv2/tests/tests_views/tests_addcontributor.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from django.conf import settings from django.test import TestCase from django.urls import reverse @@ -102,12 +104,21 @@ def setUp(self): self.form_url = reverse("content:add-contributor", kwargs={"pk": self.content.pk}) self.error_message_author_contributor = _("Un auteur ne peut pas être désigné comme contributeur") self.error_message_empty_user = _("Veuillez renseigner l'utilisateur") - self.comment = "What an mischievious person!" + self.comment = "What a mischievious person!" # Log in with an authorized user to perform the tests self.client.force_login(self.author) - def test_correct(self): + def check_signal(self, contributors_management, emitted): + """Assert whether the signal is appropriately emitted.""" + if emitted: + self.assertEqual(contributors_management.send.call_count, 1) + self.assertEqual(contributors_management.send.call_args[1]["action"], "add") + else: + self.assertFalse(contributors_management.send.called) + + @patch("zds.tutorialv2.signals.contributors_management") + def test_correct(self, contributors_management): form_data = { "username": self.contributor, "contribution_role": self.role.pk, @@ -122,7 +133,10 @@ def test_correct(self): ).first() self.assertEqual(list(ContentContribution.objects.all()), [contribution]) - def test_empty_user(self): + self.check_signal(contributors_management, emitted=True) + + @patch("zds.tutorialv2.signals.contributors_management") + def test_empty_user(self, contributors_management): form_data = { "username": "", "contribution_role": self.role.pk, @@ -130,16 +144,20 @@ def test_empty_user(self): response = self.client.post(self.form_url, form_data, follow=True) self.assertContains(response, escape(ContributionForm.declared_fields["username"].error_messages["required"])) self.assertEqual(list(ContentContribution.objects.all()), []) + self.check_signal(contributors_management, emitted=False) - def test_no_user(self): + @patch("zds.tutorialv2.signals.contributors_management") + def test_no_user(self, contributors_management): form_data = { "contribution_role": self.role.pk, } response = self.client.post(self.form_url, form_data, follow=True) self.assertContains(response, escape(ContributionForm.declared_fields["username"].error_messages["required"])) self.assertEqual(list(ContentContribution.objects.all()), []) + self.check_signal(contributors_management, emitted=False) - def test_invalid_user(self): + @patch("zds.tutorialv2.signals.contributors_management") + def test_invalid_user(self, contributors_management): form_data = { "username": "this pseudo does not exist", "contribution_role": self.role.pk, @@ -147,8 +165,10 @@ def test_invalid_user(self): response = self.client.post(self.form_url, form_data, follow=True) self.assertContains(response, escape(self.error_message_empty_user)) self.assertEqual(list(ContentContribution.objects.all()), []) + self.check_signal(contributors_management, emitted=False) - def test_author_contributor(self): + @patch("zds.tutorialv2.signals.contributors_management") + def test_author_contributor(self, contributors_management): form_data = { "username": self.author, "contribution_role": self.role.pk, @@ -156,8 +176,10 @@ def test_author_contributor(self): response = self.client.post(self.form_url, form_data, follow=True) self.assertContains(response, escape(self.error_message_author_contributor)) self.assertEqual(list(ContentContribution.objects.all()), []) + self.check_signal(contributors_management, emitted=False) - def test_empty_role(self): + @patch("zds.tutorialv2.signals.contributors_management") + def test_empty_role(self, contributors_management): form_data = { "username": self.contributor, "contribution_role": "", @@ -167,8 +189,10 @@ def test_empty_role(self): response, escape(ContributionForm.declared_fields["contribution_role"].error_messages["required"]) ) self.assertEqual(list(ContentContribution.objects.all()), []) + self.check_signal(contributors_management, emitted=False) - def test_no_role(self): + @patch("zds.tutorialv2.signals.contributors_management") + def test_no_role(self, contributors_management): form_data = { "username": self.contributor, } @@ -177,8 +201,10 @@ def test_no_role(self): response, escape(ContributionForm.declared_fields["contribution_role"].error_messages["required"]) ) self.assertEqual(list(ContentContribution.objects.all()), []) + self.check_signal(contributors_management, emitted=False) - def test_invalid_role(self): + @patch("zds.tutorialv2.signals.contributors_management") + def test_invalid_role(self, contributors_management): form_data = { "username": self.contributor, "contribution_role": 3150, # must be an invalid pk, integer or not @@ -188,3 +214,4 @@ def test_invalid_role(self): response, escape(ContributionForm.declared_fields["contribution_role"].error_messages["invalid_choice"]) ) self.assertEqual(list(ContentContribution.objects.all()), []) + self.check_signal(contributors_management, emitted=False) diff --git a/zds/tutorialv2/tests/tests_views/tests_addsuggestion.py b/zds/tutorialv2/tests/tests_views/tests_addsuggestion.py index 47be012bba..257e68ad65 100644 --- a/zds/tutorialv2/tests/tests_views/tests_addsuggestion.py +++ b/zds/tutorialv2/tests/tests_views/tests_addsuggestion.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from django.test import TestCase from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -101,13 +103,24 @@ def setUp(self): # Log in with an authorized user to perform the tests self.client.force_login(self.staff) - def test_published_simple(self): + def check_signal(self, suggestions_management, emitted, count=1): + """Assert whether the signal is appropriately emitted.""" + if emitted: + self.assertEqual(suggestions_management.send.call_count, count) + self.assertEqual(suggestions_management.send.call_args[1]["action"], "add") + else: + self.assertFalse(suggestions_management.send.called) + + @patch("zds.tutorialv2.signals.suggestions_management") + def test_published_simple(self, suggestions_management): response = self.client.post(self.form_url, {"options": self.suggestable_content_1.pk}, follow=True) self.assertContains(response, escape(self.success_message_fragment)) suggestion = ContentSuggestion.objects.get(publication=self.content, suggestion=self.suggestable_content_1) self.assertEqual(list(ContentSuggestion.objects.all()), [suggestion]) + self.check_signal(suggestions_management, emitted=True) - def test_published_multiple(self): + @patch("zds.tutorialv2.signals.suggestions_management") + def test_published_multiple(self, suggestions_management): response = self.client.post( self.form_url, {"options": [self.suggestable_content_1.pk, self.suggestable_content_2.pk]}, follow=True ) @@ -115,37 +128,52 @@ def test_published_multiple(self): suggestion_1 = ContentSuggestion.objects.get(publication=self.content, suggestion=self.suggestable_content_1) suggestion_2 = ContentSuggestion.objects.get(publication=self.content, suggestion=self.suggestable_content_2) self.assertEqual(list(ContentSuggestion.objects.all()), [suggestion_1, suggestion_2]) + self.check_signal(suggestions_management, emitted=True, count=2) - def test_already_suggested(self): + @patch("zds.tutorialv2.signals.suggestions_management") + def test_already_suggested(self, suggestions_management): suggestion = ContentSuggestion(publication=self.content, suggestion=self.suggestable_content_1) suggestion.save() response = self.client.post(self.form_url, {"options": self.suggestable_content_1.pk}, follow=True) self.assertContains(response, escape(self.error_message_fragment_already_suggested)) self.assertEqual(list(ContentSuggestion.objects.all()), [suggestion]) + self.check_signal(suggestions_management, emitted=False) - def test_self(self): + @patch("zds.tutorialv2.signals.suggestions_management") + def test_self(self, suggestions_management): response = self.client.post(self.form_url, {"options": self.content.pk}, follow=True) self.assertContains(response, escape(self.error_message_fragment_self)) self.assertQuerysetEqual(ContentSuggestion.objects.all(), []) + self.check_signal(suggestions_management, emitted=False) - def test_not_picked_opinion(self): + @patch("zds.tutorialv2.signals.suggestions_management") + def test_not_picked_opinion(self, suggestions_management): response = self.client.post(self.form_url, {"options": self.not_picked_opinion.pk}, follow=True) self.assertContains(response, escape(self.error_messge_fragment_not_picked)) self.assertQuerysetEqual(ContentSuggestion.objects.all(), []) + self.check_signal(suggestions_management, emitted=False) - def test_unpublished(self): + @patch("zds.tutorialv2.signals.suggestions_management") + def test_unpublished(self, suggestions_management): response = self.client.post(self.form_url, {"options": self.unpublished_content.pk}, follow=True) self.assertContains(response, escape(self.error_message_fragment_unpublished)) self.assertQuerysetEqual(ContentSuggestion.objects.all(), []) + self.check_signal(suggestions_management, emitted=False) - def test_invalid(self): + @patch("zds.tutorialv2.signals.suggestions_management") + def test_invalid(self, suggestions_management): response = self.client.post(self.form_url, {"options": "420"}, follow=True) # pk must not exist self.assertEqual(response.status_code, 404) + self.check_signal(suggestions_management, emitted=False) - def test_not_integer(self): + @patch("zds.tutorialv2.signals.suggestions_management") + def test_not_integer(self, suggestions_management): with self.assertRaises(ValueError): self.client.post(self.form_url, {"options": "abcd"}, follow=True) + self.check_signal(suggestions_management, emitted=False) - def test_empty(self): + @patch("zds.tutorialv2.signals.suggestions_management") + def test_empty(self, suggestions_management): with self.assertRaises(ValueError): self.client.post(self.form_url, {"options": ""}, follow=True) + self.check_signal(suggestions_management, emitted=False) diff --git a/zds/tutorialv2/tests/tests_views/tests_content.py b/zds/tutorialv2/tests/tests_views/tests_content.py index 8a5d5c23ba..236ed67d64 100644 --- a/zds/tutorialv2/tests/tests_views/tests_content.py +++ b/zds/tutorialv2/tests/tests_views/tests_content.py @@ -5,6 +5,7 @@ import os from pathlib import Path +from unittest.mock import patch from django.conf import settings from django.contrib import messages @@ -627,7 +628,8 @@ def test_basic_tutorial_workflow(self): beta_topic = Topic.objects.get(pk=beta_topic.pk) self.assertTrue(beta_topic.is_locked) - def test_beta_workflow(self): + @patch("zds.tutorialv2.signals.beta_management") + def test_beta_workflow(self, beta_management): """Test beta workflow (access and update)""" # login with guest and test the non-access @@ -657,15 +659,19 @@ def test_beta_workflow(self): tuto = PublishableContent.objects.get(pk=self.tuto.pk) self.assertEqual(result.status_code, 302) - # check if there is a new topic and a pm corresponding to the tutorial in beta + # check if there is a pm corresponding to the tutorial in beta self.assertEqual(Topic.objects.filter(forum=self.beta_forum).count(), 1) self.assertTrue(PublishableContent.objects.get(pk=self.tuto.pk).beta_topic is not None) self.assertEqual(PrivateTopic.objects.filter(author=self.user_author).count(), 1) + # check if there is a new topic beta_topic = PublishableContent.objects.get(pk=self.tuto.pk).beta_topic self.assertIsNotNone(TopicAnswerSubscription.objects.get_existing(self.user_author, beta_topic, is_active=True)) self.assertEqual(Post.objects.filter(topic=beta_topic).count(), 1) self.assertEqual(beta_topic.tags.count(), 1) self.assertEqual(beta_topic.tags.first().title, sometag.title) + # check signal is emitted + self.assertEqual(beta_management.send.call_count, 1) + self.assertEqual(beta_management.send.call_args[1]["action"], "activate") # test if second author follow the topic self.assertIsNotNone(TopicAnswerSubscription.objects.get_existing(second_author, beta_topic, is_active=True)) @@ -792,6 +798,9 @@ def test_beta_workflow(self): self.assertEqual(Post.objects.filter(topic=beta_topic).count(), 2) # a new message was added ! self.assertTrue(Topic.objects.get(pk=beta_topic.pk).is_locked) # beta was inactived, so topic is locked ! + # check signal is emitted + self.assertEqual(beta_management.send.call_count, 3) + self.assertEqual(beta_management.send.call_args[1]["action"], "deactivate") # then test for guest self.client.logout() @@ -2059,7 +2068,8 @@ def test_validation_subscription(self): ) self.client.logout() - def test_validation_workflow(self): + @patch("zds.tutorialv2.signals.validation_management") + def test_validation_workflow(self, validation_management): """test the different case of validation""" text_validation = "Valide moi ce truc, s'il te plait" @@ -2081,6 +2091,7 @@ def test_validation_workflow(self): ) self.assertEqual(result.status_code, 302) self.assertEqual(Validation.objects.count(), 0) # not working if you don't provide a text + self.assertEqual(validation_management.send.call_count, 0) result = self.client.post( reverse("validation:ask", kwargs={"pk": tuto.pk, "slug": tuto.slug}), @@ -2089,6 +2100,8 @@ def test_validation_workflow(self): ) self.assertEqual(result.status_code, 302) self.assertEqual(Validation.objects.count(), 1) + self.assertEqual(validation_management.send.call_count, 1) + self.assertEqual(validation_management.send.call_args[1]["action"], "request") validation = Validation.objects.filter(content=tuto).last() self.assertIsNotNone(validation) @@ -2144,6 +2157,8 @@ def test_validation_workflow(self): reverse("validation:reserve", kwargs={"pk": validation.pk}), {"version": validation.version}, follow=False ) self.assertEqual(result.status_code, 302) + self.assertEqual(validation_management.send.call_count, 2) + self.assertEqual(validation_management.send.call_args[1]["action"], "reserve") validation = Validation.objects.filter(pk=validation.pk).last() self.assertEqual(validation.status, "PENDING_V") @@ -2154,6 +2169,8 @@ def test_validation_workflow(self): reverse("validation:reserve", kwargs={"pk": validation.pk}), {"version": validation.version}, follow=False ) self.assertEqual(result.status_code, 302) + self.assertEqual(validation_management.send.call_count, 3) + self.assertEqual(validation_management.send.call_args[1]["action"], "unreserve") validation = Validation.objects.filter(pk=validation.pk).last() self.assertEqual(validation.status, "PENDING") @@ -2227,6 +2244,8 @@ def test_validation_workflow(self): validation = Validation.objects.filter(pk=validation.pk).last() self.assertEqual(validation.status, "REJECT") self.assertEqual(validation.comment_validator, text_reject) + self.assertEqual(validation_management.send.call_count, 6) + self.assertEqual(validation_management.send.call_args[1]["action"], "reject") self.assertIsNone(PublishableContent.objects.get(pk=tuto.pk).sha_validation) new_mp_message_nb = PrivatePost.objects.filter( @@ -2280,6 +2299,8 @@ def test_validation_workflow(self): validation = Validation.objects.filter(pk=validation.pk).last() self.assertEqual(validation.status, "ACCEPT") self.assertEqual(validation.comment_validator, text_accept) + self.assertEqual(validation_management.send.call_count, 9) + self.assertEqual(validation_management.send.call_args[1]["action"], "accept") self.assertIsNone(PublishableContent.objects.get(pk=tuto.pk).sha_validation) @@ -2330,6 +2351,8 @@ def test_validation_workflow(self): follow=False, ) self.assertEqual(result.status_code, 302) + self.assertEqual(validation_management.send.call_count, 10) + self.assertEqual(validation_management.send.call_args[1]["action"], "revoke") self.assertEqual(Validation.objects.filter(content=tuto).count(), 3) @@ -2363,6 +2386,8 @@ def test_validation_workflow(self): reverse("validation:cancel", kwargs={"pk": validation.pk}), {"text": text_cancel}, follow=False ) self.assertEqual(result.status_code, 302) + self.assertEqual(validation_management.send.call_count, 12) + self.assertEqual(validation_management.send.call_args[1]["action"], "cancel") self.assertEqual(Validation.objects.filter(content=tuto).count(), 3) @@ -2472,29 +2497,32 @@ def test_delete_while_validating(self): self.assertEqual(PrivateTopic.objects.last().author, self.user_staff) # admin has received a PM - def test_js_fiddle_activation(self): + @patch("zds.tutorialv2.signals.jsfiddle_management") + def test_js_fiddle_activation(self, jsfiddle_management): self.client.force_login(self.staff) result = self.client.post( reverse("content:activate-jsfiddle"), {"pk": self.tuto.pk, "js_support": "on"}, follow=True ) self.assertEqual(result.status_code, 200) + self.assertEqual(jsfiddle_management.send.call_count, 1) updated = PublishableContent.objects.get(pk=self.tuto.pk) self.assertTrue(updated.js_support) result = self.client.post( reverse("content:activate-jsfiddle"), - { - "pk": self.tuto.pk, - }, + {"pk": self.tuto.pk}, follow=True, ) self.assertEqual(result.status_code, 200) updated = PublishableContent.objects.get(pk=self.tuto.pk) self.assertFalse(updated.js_support) + self.assertEqual(jsfiddle_management.send.call_count, 2) self.client.logout() + self.client.force_login(self.user_author) result = self.client.post(reverse("content:activate-jsfiddle"), {"pk": self.tuto.pk, "js_support": True}) self.assertEqual(result.status_code, 403) + self.assertEqual(jsfiddle_management.send.call_count, 2) def test_validate_unexisting(self): @@ -2662,7 +2690,8 @@ def test_help_tutorials_are_sorted_by_update_date(self): self.assertEqual(contents[0], tutoriel_1) self.assertEqual(contents[1], tutoriel_2) - def test_add_author(self): + @patch("zds.tutorialv2.signals.authors_management") + def test_add_author(self, authors_management): self.client.force_login(self.user_author) result = self.client.post( reverse("content:add-author", args=[self.tuto.pk]), {"username": self.user_guest.username}, follow=False @@ -2672,14 +2701,18 @@ def test_add_author(self): gallery = UserGallery.objects.filter(gallery=self.tuto.gallery, user=self.user_guest).first() self.assertIsNotNone(gallery) self.assertEqual(GALLERY_WRITE, gallery.mode) + self.assertEqual(authors_management.send.call_count, 1) + self.assertEqual(authors_management.send.call_args[1]["action"], "add") # add unexisting user result = self.client.post( reverse("content:add-author", args=[self.tuto.pk]), {"username": "unknown"}, follow=False ) self.assertEqual(result.status_code, 302) self.assertEqual(PublishableContent.objects.get(pk=self.tuto.pk).authors.count(), 2) + self.assertEqual(authors_management.send.call_count, 1) - def test_remove_author(self): + @patch("zds.tutorialv2.signals.authors_management") + def test_remove_author(self, authors_management): self.client.force_login(self.user_author) tuto = PublishableContentFactory(author_list=[self.user_author, self.user_guest]) result = self.client.post( @@ -2687,6 +2720,8 @@ def test_remove_author(self): ) self.assertEqual(result.status_code, 302) self.assertEqual(PublishableContent.objects.get(pk=tuto.pk).authors.count(), 1) + self.assertEqual(authors_management.send.call_count, 1) + self.assertEqual(authors_management.send.call_args[1]["action"], "remove") self.assertIsNone(UserGallery.objects.filter(gallery=self.tuto.gallery, user=self.user_guest).first()) # remove unexisting user @@ -2695,14 +2730,17 @@ def test_remove_author(self): ) self.assertEqual(result.status_code, 302) self.assertEqual(PublishableContent.objects.get(pk=tuto.pk).authors.count(), 1) + self.assertEqual(authors_management.send.call_count, 1) + # remove last author must lead to no change result = self.client.post( reverse("content:remove-author", args=[tuto.pk]), {"username": self.user_author.username}, follow=False ) self.assertEqual(result.status_code, 302) self.assertEqual(PublishableContent.objects.get(pk=tuto.pk).authors.count(), 1) + self.assertEqual(authors_management.send.call_count, 1) - # re-add quest + # re-add guest result = self.client.post( reverse("content:add-author", args=[tuto.pk]), {"username": self.user_guest.username}, follow=False ) diff --git a/zds/tutorialv2/tests/tests_views/tests_editcontenttags.py b/zds/tutorialv2/tests/tests_views/tests_editcontenttags.py index d8dab90718..8b3d02a3f6 100644 --- a/zds/tutorialv2/tests/tests_views/tests_editcontenttags.py +++ b/zds/tutorialv2/tests/tests_views/tests_editcontenttags.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from django.test import TestCase from django.urls import reverse from django.utils.html import escape @@ -145,9 +147,10 @@ def test_form_function(self): test_cases = self.get_test_cases() for case_name, case in test_cases.items(): with self.subTest(msg=case_name): - self.enforce_preconditions(case["preconditions"]) - self.post_form(case["inputs"]) - self.check_effects(case["expected_outputs"]) + with patch("zds.tutorialv2.signals.tags_management") as tags_management: + self.enforce_preconditions(case["preconditions"]) + self.post_form(case["inputs"]) + self.check_effects(case["expected_outputs"], tags_management) def get_test_cases(self): """List test cases for the license editing form.""" @@ -156,17 +159,21 @@ def get_test_cases(self): "nothing": { "preconditions": {"all_tags": self.tags_name, "content_tags": []}, "inputs": {"tags": ""}, - "expected_outputs": {"all_tags": self.tags_name, "content_tags": []}, + "expected_outputs": {"all_tags": self.tags_name, "content_tags": [], "call_count": 1}, }, "existing_tag": { "preconditions": {"all_tags": self.tags_name, "content_tags": []}, "inputs": {"tags": self.tag_1.title}, - "expected_outputs": {"all_tags": self.tags_name, "content_tags": [self.tag_1.title]}, + "expected_outputs": {"all_tags": self.tags_name, "content_tags": [self.tag_1.title], "call_count": 1}, }, "new_tag": { "preconditions": {"all_tags": self.tags_name, "content_tags": []}, "inputs": {"tags": new_tag_name}, - "expected_outputs": {"all_tags": self.tags_name + [new_tag_name], "content_tags": [new_tag_name]}, + "expected_outputs": { + "all_tags": self.tags_name + [new_tag_name], + "content_tags": [new_tag_name], + "call_count": 1, + }, }, } @@ -183,10 +190,11 @@ def post_form(self, inputs): form_data = {"tags": inputs["tags"]} self.client.post(self.form_url, form_data) - def check_effects(self, expected_outputs): + def check_effects(self, expected_outputs, tags_management): """Check the effects of having sent the form.""" updated_content = PublishableContent.objects.get(pk=self.content.pk) content_tags_as_string = [tag.title for tag in updated_content.tags.all()] all_tags_as_string = [tag.title for tag in Tag.objects.all()] self.assertEqual(content_tags_as_string, expected_outputs["content_tags"]) self.assertEqual(all_tags_as_string, expected_outputs["all_tags"]) + self.assertEqual(tags_management.send.call_count, expected_outputs["call_count"]) diff --git a/zds/tutorialv2/tests/tests_views/tests_events.py b/zds/tutorialv2/tests/tests_views/tests_events.py new file mode 100644 index 0000000000..c79f2cdccd --- /dev/null +++ b/zds/tutorialv2/tests/tests_views/tests_events.py @@ -0,0 +1,49 @@ +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 +from zds.tutorialv2.tests import override_for_contents, TutorialTestMixin + + +@override_for_contents() +class EventListPermissionTests(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.events_list_url = reverse("content:events", kwargs={"pk": self.content.pk}) + self.login_url = reverse("member-login") + "?next=" + self.events_list_url + + def test_not_authenticated(self): + """Test that unauthenticated users are redirected to the login page.""" + self.client.logout() # ensure no user is authenticated + response = self.client.get(self.events_list_url) + self.assertRedirects(response, self.login_url) + + def test_authenticated_author(self): + """Test that authors have access to the page.""" + self.client.force_login(self.author) + response = self.client.get(self.events_list_url) + self.assertEqual(response.status_code, 200) + + def test_authenticated_staff(self): + """Test that staffs have access to the page.""" + self.client.force_login(self.staff) + response = self.client.get(self.events_list_url) + self.assertEqual(response.status_code, 200) + + def test_authenticated_outsider(self): + """Test that unauthorized users get a 403.""" + self.client.force_login(self.outsider) + response = self.client.get(self.events_list_url) + self.assertEquals(response.status_code, 403) diff --git a/zds/tutorialv2/tests/tests_views/tests_removecontributor.py b/zds/tutorialv2/tests/tests_views/tests_removecontributor.py index 66f9065517..22aab7c0c0 100644 --- a/zds/tutorialv2/tests/tests_views/tests_removecontributor.py +++ b/zds/tutorialv2/tests/tests_views/tests_removecontributor.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from django.test import TestCase from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -105,33 +107,53 @@ def setUp(self): # Log in with an authorized user to perform the tests self.client.force_login(self.author) - def test_existing(self): + def check_signal(self, contributors_management, emitted): + """Assert whether the signal is appropriately emitted.""" + if emitted: + self.assertEqual(contributors_management.send.call_count, 1) + self.assertEqual(contributors_management.send.call_args[1]["action"], "remove") + else: + self.assertFalse(contributors_management.send.called) + + @patch("zds.tutorialv2.signals.contributors_management") + def test_existing(self, contributors_management): response = self.client.post(self.form_url, {"pk_contribution": self.contribution.pk}, follow=True) self.assertContains(response, escape(self.success_message_fragment)) self.assertEqual(list(ContentContribution.objects.all()), []) + self.check_signal(contributors_management, emitted=True) - def test_empty(self): + @patch("zds.tutorialv2.signals.contributors_management") + def test_empty(self, contributors_management): response = self.client.post(self.form_url, {"pk_contribution": ""}, follow=True) self.assertContains(response, escape(self.error_message_fragment)) self.assertEqual(list(ContentContribution.objects.all()), [self.contribution]) + self.check_signal(contributors_management, emitted=False) - def test_invalid(self): + @patch("zds.tutorialv2.signals.contributors_management") + def test_invalid(self, contributors_management): response = self.client.post(self.form_url, {"pk_contribution": "420"}, follow=True) # pk must not exist self.assertEqual(response.status_code, 404) self.assertEqual(list(ContentContribution.objects.all()), [self.contribution]) + self.check_signal(contributors_management, emitted=False) - def test_not_integer(self): + @patch("zds.tutorialv2.signals.contributors_management") + def test_not_integer(self, contributors_management): with self.assertRaises(ValueError): self.client.post(self.form_url, {"pk_contribution": "abcd"}, follow=True) self.assertEqual(list(ContentContribution.objects.all()), [self.contribution]) + self.check_signal(contributors_management, emitted=False) - def test_no_argument(self): + @patch("zds.tutorialv2.signals.contributors_management") + def test_no_argument(self, contributors_management): response = self.client.post(self.form_url, follow=True) self.assertContains(response, escape(self.error_message_fragment)) self.assertEqual(list(ContentContribution.objects.all()), [self.contribution]) + self.check_signal(contributors_management, emitted=False) - def test_wrong_contribution(self): + @patch("zds.tutorialv2.signals.contributors_management") + def test_wrong_contribution(self, contributors_management): form_url = reverse("content:remove-contributor", kwargs={"pk": 3023}) # pk must not exist response = self.client.post(form_url, follow=True) self.assertEqual(response.status_code, 404) self.assertEqual(list(ContentContribution.objects.all()), [self.contribution]) + self.check_signal(contributors_management, emitted=False) diff --git a/zds/tutorialv2/tests/tests_views/tests_removesuggestion.py b/zds/tutorialv2/tests/tests_views/tests_removesuggestion.py index fdbf1f649f..470d1a1ffc 100644 --- a/zds/tutorialv2/tests/tests_views/tests_removesuggestion.py +++ b/zds/tutorialv2/tests/tests_views/tests_removesuggestion.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from django.test import TestCase from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -99,7 +101,16 @@ def setUp(self): # Log in with an authorized user to perform the tests self.client.force_login(self.staff) - def test_existing(self): + def check_signal(self, suggestions_management, emitted, count=1): + """Assert whether the signal is appropriately emitted.""" + if emitted: + self.assertEqual(suggestions_management.send.call_count, count) + self.assertEqual(suggestions_management.send.call_args[1]["action"], "remove") + else: + self.assertFalse(suggestions_management.send.called) + + @patch("zds.tutorialv2.signals.suggestions_management") + def test_existing(self, suggestions_management): response = self.client.post(self.form_url, {"pk_suggestion": self.suggestion_1.pk}, follow=True) # Check that we display correct message self.assertContains(response, escape(self.success_message_fragment)) @@ -107,15 +118,22 @@ def test_existing(self): with self.assertRaises(ContentSuggestion.DoesNotExist): ContentSuggestion.objects.get(pk=self.suggestion_1.pk) ContentSuggestion.objects.get(pk=self.suggestion_2.pk) # succeeds + self.check_signal(suggestions_management, emitted=True) - def test_empty(self): + @patch("zds.tutorialv2.signals.suggestions_management") + def test_empty(self, suggestions_management): response = self.client.post(self.form_url, {"pk_suggestion": ""}, follow=True) self.assertContains(response, escape(self.error_messages["required"])) + self.check_signal(suggestions_management, emitted=False) - def test_invalid(self): + @patch("zds.tutorialv2.signals.suggestions_management") + def test_invalid(self, suggestions_management): response = self.client.post(self.form_url, {"pk_suggestion": "420"}, follow=True) # pk must not exist self.assertContains(response, escape(self.error_messages["does_not_exist"])) + self.check_signal(suggestions_management, emitted=False) - def test_not_integer(self): + @patch("zds.tutorialv2.signals.suggestions_management") + def test_not_integer(self, suggestions_management): response = self.client.post(self.form_url, {"pk_suggestion": "abcd"}, follow=True) self.assertContains(response, escape(self.error_messages["invalid"])) + self.check_signal(suggestions_management, emitted=False) diff --git a/zds/tutorialv2/urls/urls_contents.py b/zds/tutorialv2/urls/urls_contents.py index ca8062bad2..e348b07ed2 100644 --- a/zds/tutorialv2/urls/urls_contents.py +++ b/zds/tutorialv2/urls/urls_contents.py @@ -2,6 +2,7 @@ from django.views.generic.base import RedirectView from zds.tutorialv2.views.contents import DisplayContent, CreateContent, EditContent, EditContentLicense, DeleteContent +from zds.tutorialv2.views.events import EventsList from zds.tutorialv2.views.validations_contents import ActivateJSFiddleInContent from zds.tutorialv2.views.containers_extracts import ( CreateContainer, @@ -189,4 +190,6 @@ # tags re_path(r"^tags/$", TagsListView.as_view(), name="tags"), re_path(r"^$", RedirectView.as_view(pattern_name="publication:list", permanent=True), name="list"), + # Journal of events + re_path(r"^evenements/(?P\d+)/$", EventsList.as_view(), name="events"), ] diff --git a/zds/tutorialv2/views/authors.py b/zds/tutorialv2/views/authors.py index 7bf2778025..9f933d69d3 100644 --- a/zds/tutorialv2/views/authors.py +++ b/zds/tutorialv2/views/authors.py @@ -9,6 +9,7 @@ from zds.gallery.models import UserGallery, GALLERY_WRITE from zds.member.decorator import LoggedWithReadWriteHability +from zds.tutorialv2 import signals from zds.tutorialv2.forms import AuthorForm, RemoveAuthorForm from zds.tutorialv2.mixins import SingleContentFormViewMixin @@ -64,6 +65,9 @@ def form_valid(self, form): hat=get_hat_from_settings("validation"), ) UserGallery(gallery=self.object.gallery, user=user, mode=GALLERY_WRITE).save() + signals.authors_management.send( + sender=self.__class__, content=self.object, performer=self.request.user, author=user, action="add" + ) self.object.save() self.success_url = self.object.get_absolute_url() @@ -135,6 +139,13 @@ def form_valid(self, form): ), hat=get_hat_from_settings("validation"), ) + signals.authors_management.send( + sender=self.__class__, + content=self.object, + performer=self.request.user, + author=user, + action="remove", + ) else: # if user is incorrect or alone messages.error( self.request, diff --git a/zds/tutorialv2/views/beta.py b/zds/tutorialv2/views/beta.py index 215aa96ced..bb86a6b599 100644 --- a/zds/tutorialv2/views/beta.py +++ b/zds/tutorialv2/views/beta.py @@ -10,6 +10,7 @@ from zds.forum.models import Topic, Forum, mark_read from zds.member.decorator import LoggedWithReadWriteHability from zds.notification.models import TopicAnswerSubscription +from zds.tutorialv2 import signals from zds.tutorialv2.forms import BetaForm from zds.tutorialv2.mixins import SingleContentFormViewMixin from zds.tutorialv2.models.database import PublishableContent @@ -93,6 +94,13 @@ def form_valid(self, form): ) send_post(self.request, topic, self.request.user, msg_post) lock_topic(topic) + signals.beta_management.send( + sender=self.__class__, + content=self.object, + performer=self.request.user, + version=sha_beta, + action="deactivate", + ) elif self.action == "set": already_in_beta = self.object.in_beta() @@ -189,6 +197,14 @@ def form_valid(self, form): topic.tags.add(tag) topic.save() + signals.beta_management.send( + sender=self.__class__, + content=self.object, + performer=self.request.user, + version=sha_beta, + action="activate", + ) + self.object.save() # we should prefer .update but it needs a huge refactoring self.success_url = self.versioned_object.get_absolute_url(version=sha_beta) diff --git a/zds/tutorialv2/views/contributors.py b/zds/tutorialv2/views/contributors.py index 39949e6943..5756a9b36e 100644 --- a/zds/tutorialv2/views/contributors.py +++ b/zds/tutorialv2/views/contributors.py @@ -13,6 +13,7 @@ from zds.member.decorator import LoggedWithReadWriteHability from zds.notification.models import NewPublicationSubscription +from zds.tutorialv2 import signals from zds.tutorialv2.forms import ContributionForm, RemoveContributionForm from zds.tutorialv2.mixins import SingleContentFormViewMixin from zds.tutorialv2.models import TYPE_CHOICES_DICT @@ -92,6 +93,9 @@ def form_valid(self, form): direct=False, leave=True, ) + signals.contributors_management.send( + sender=self.__class__, content=self.object, performer=self.request.user, contributor=user, action="add" + ) self.success_url = self.object.get_absolute_url() return super().form_valid(form) @@ -119,7 +123,9 @@ def form_valid(self, form): contribution = get_object_or_404(ContentContribution, pk=form.cleaned_data["pk_contribution"]) user = contribution.user contribution.delete() - + signals.contributors_management.send( + sender=self.__class__, content=self.object, performer=self.request.user, contributor=user, action="remove" + ) messages.success( self.request, _("Vous avez enlevé {} de la liste des contributeurs de {}.").format(user.username, _type) ) diff --git a/zds/tutorialv2/views/editorialization.py b/zds/tutorialv2/views/editorialization.py index c14a4ee8a0..82b4605b89 100644 --- a/zds/tutorialv2/views/editorialization.py +++ b/zds/tutorialv2/views/editorialization.py @@ -10,9 +10,12 @@ from zds.tutorialv2.forms import RemoveSuggestionForm, EditContentTagsForm from zds.tutorialv2.mixins import SingleContentFormViewMixin from zds.tutorialv2.models.database import ContentSuggestion, PublishableContent +import zds.tutorialv2.signals as signals +from zds.utils import get_current_user class RemoveSuggestion(PermissionRequiredMixin, SingleContentFormViewMixin): + form_class = RemoveSuggestionForm modal_form = True only_draft_version = True @@ -28,6 +31,9 @@ def dispatch(self, *args, **kwargs): def form_valid(self, form): suggestion = ContentSuggestion.objects.get(pk=form.cleaned_data["pk_suggestion"]) suggestion.delete() + signals.suggestions_management.send( + sender=self.__class__, performer=self.request.user, content=self.object, action="remove" + ) messages.success(self.request, self.get_success_message(suggestion)) return super().form_valid(form) @@ -94,6 +100,12 @@ def post(self, request, *args, **kwargs): else: obj_suggestion = ContentSuggestion(publication=publication, suggestion=suggestion) obj_suggestion.save() + signals.suggestions_management.send( + sender=self.__class__, + performer=self.request.user, + content=self.object, + action="add", + ) messages.info( self.request, _(f'Le contenu "{suggestion.title}" a été ajouté dans les suggestions de {_type}'), @@ -122,4 +134,5 @@ def form_valid(self, form): self.object.add_tags(form.cleaned_data["tags"].split(",")) self.object.save() messages.success(self.request, EditContentTags.success_message) + signals.tags_management.send(sender=self.__class__, performer=get_current_user(), content=self.object) return redirect(form.previous_page_url) diff --git a/zds/tutorialv2/views/events.py b/zds/tutorialv2/views/events.py new file mode 100644 index 0000000000..aa3d507497 --- /dev/null +++ b/zds/tutorialv2/views/events.py @@ -0,0 +1,23 @@ +from django.conf import settings +from zds.member.decorator import LoggedWithReadWriteHability +from zds.tutorialv2.mixins import SingleContentDetailViewMixin +from zds.tutorialv2.models.events import Event +from zds.utils.paginator import make_pagination + + +class EventsList(LoggedWithReadWriteHability, SingleContentDetailViewMixin): + """ + Display the list of events. + """ + + model = Event + template_name = "tutorialv2/events/list.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + events = list(Event.objects.filter(content=self.object)) + events.reverse() + make_pagination( + context, self.request, events, settings.ZDS_APP["content"]["commits_per_page"], context_list_name="events" + ) + return context diff --git a/zds/tutorialv2/views/help.py b/zds/tutorialv2/views/help.py index 9dde632d60..4f003e93e6 100644 --- a/zds/tutorialv2/views/help.py +++ b/zds/tutorialv2/views/help.py @@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _ from zds.member.decorator import LoggedWithReadWriteHability +from zds.tutorialv2 import signals from zds.tutorialv2.forms import ToggleHelpForm from zds.tutorialv2.mixins import SingleContentFormViewMixin @@ -82,6 +83,7 @@ def form_valid(self, form): else: self.object.helps.remove(data["help_wanted"]) self.object.save() + signals.help_management.send(sender=self.__class__, performer=self.request.user, content=self.object) if self.request.is_ajax(): return HttpResponse( json.dumps({"result": "ok", "help_wanted": data["activated"]}), content_type="application/json" diff --git a/zds/tutorialv2/views/validations_contents.py b/zds/tutorialv2/views/validations_contents.py index 991e396332..e66546d5ca 100644 --- a/zds/tutorialv2/views/validations_contents.py +++ b/zds/tutorialv2/views/validations_contents.py @@ -16,6 +16,7 @@ from zds.member.decorator import LoggedWithReadWriteHability from zds.mp.models import mark_read +from zds.tutorialv2 import signals from zds.tutorialv2.forms import ( AskValidationForm, RejectValidationForm, @@ -38,6 +39,7 @@ FailureDuringPublication, save_validation_state, ) +from zds.utils import get_current_user from zds.utils.models import SubCategory, get_hat_from_settings from zds.mp.utils import send_mp, send_message_mp @@ -184,6 +186,13 @@ def form_valid(self, form): self.object.save() messages.success(self.request, _("Votre demande de validation a été transmise à l'équipe.")) + signals.validation_management.send( + sender=self.__class__, + content=validation.content, + performer=self.request.user, + version=validation.version, + action="request", + ) self.success_url = self.versioned_object.get_absolute_url(version=self.sha) return super().form_valid(form) @@ -266,7 +275,13 @@ def form_valid(self, form): send_message_mp(bot, validation.content.validation_private_message, msg) messages.info(self.request, _("La validation de ce contenu a bien été annulée.")) - + signals.validation_management.send( + sender=self.__class__, + content=validation.content, + performer=self.request.user, + version=validation.version, + action="cancel", + ) self.success_url = ( reverse("content:view", args=[validation.content.pk, validation.content.slug]) + "?version=" @@ -289,6 +304,13 @@ def post(self, request, *args, **kwargs): validation.status = "PENDING" validation.save() messages.info(request, _("Ce contenu n'est plus réservé.")) + signals.validation_management.send( + sender=self.__class__, + content=validation.content, + performer=request.user, + version=validation.version, + action="unreserve", + ) return redirect(reverse("validation:list")) else: validation.validator = request.user @@ -327,6 +349,13 @@ def post(self, request, *args, **kwargs): mark_read(validation.content.validation_private_message, validation.validator) messages.info(request, _("Ce contenu a bien été réservé par {0}.").format(request.user.username)) + signals.validation_management.send( + sender=self.__class__, + content=validation.content, + performer=request.user, + version=validation.version, + action="reserve", + ) return redirect( reverse("content:view", args=[validation.content.pk, validation.content.slug]) @@ -423,6 +452,13 @@ def form_valid(self, form): messages.info(self.request, _("Le contenu a bien été refusé.")) self.success_url = reverse("validation:list") + signals.validation_management.send( + sender=self.__class__, + content=validation.content, + performer=self.request.user, + version=validation.version, + action="reject", + ) return super().form_valid(form) @@ -481,6 +517,13 @@ def form_valid(self, form): notify_update(db_object, is_update, form.cleaned_data["is_major"]) messages.success(self.request, _("Le contenu a bien été validé.")) + signals.validation_management.send( + sender=self.__class__, + content=validation.content, + performer=self.request.user, + version=validation.version, + action="accept", + ) self.success_url = published.get_absolute_url_online() return super().form_valid(form) @@ -559,7 +602,13 @@ def form_valid(self, form): messages.success(self.request, _("Le contenu a bien été dépublié.")) self.success_url = self.versioned_object.get_absolute_url() + "?version=" + validation.version - + signals.validation_management.send( + sender=self.__class__, + content=validation.content, + performer=self.request.user, + version=validation.version, + action="revoke", + ) return super().form_valid(form) @@ -599,5 +648,22 @@ def form_valid(self, form): # forbidden for content without a validation before publication if not content.load_version().requires_validation(): raise PermissionDenied - content.update(js_support=form.cleaned_data["js_support"]) + new_js_support = form.cleaned_data["js_support"] + old_js_support = content.js_support + self.send_signal(old_js_support, new_js_support, content) + content.update(js_support=new_js_support) return redirect(content.load_version().get_absolute_url()) + + def send_signal(self, old_js_support, new_js_support, content): + if old_js_support != new_js_support: + action = self.get_action(new_js_support) + signals.jsfiddle_management.send( + sender=self.__class__, performer=get_current_user(), content=content, action=action + ) + + @staticmethod + def get_action(js_support_activated): + if js_support_activated: + return "activate" + else: + return "deactivate" diff --git a/zds/tutorialv2/views/validations_opinions.py b/zds/tutorialv2/views/validations_opinions.py index b3fa2772e6..fea0470600 100644 --- a/zds/tutorialv2/views/validations_opinions.py +++ b/zds/tutorialv2/views/validations_opinions.py @@ -16,6 +16,7 @@ from zds.gallery.models import Gallery from zds.member.decorator import LoggedWithReadWriteHability +from zds.tutorialv2 import signals from zds.tutorialv2.forms import ( PublicationForm, RevokeValidationForm, @@ -76,6 +77,9 @@ def form_valid(self, form): ) notify_update(db_object, is_update, form.cleaned_data.get("is_major", False)) + signals.opinions_management.send( + sender=self.__class__, performer=self.request.user, content=self.object, action="publish" + ) messages.success(self.request, _("Le contenu a bien été publié.")) self.success_url = published.get_absolute_url_online() @@ -143,6 +147,10 @@ def form_valid(self, form): no_notification_for=[self.request.user], ) + signals.opinions_management.send( + sender=self.__class__, performer=self.request.user, content=self.object, action="unpublish" + ) + messages.success(self.request, _("Le contenu a bien été dépublié.")) self.success_url = self.versioned_object.get_absolute_url()