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" %}
+
+
+
+
+ {% trans "Date" %} |
+ {% trans "Description" %} |
+
+
+
+ {% for e in events %}
+
+ {{ e.date | format_date:True }} |
+ {% include 'tutorialv2/events/descriptions.part.html' with event=e %} |
+
+ {% endfor %}
+
+
+ {% 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()