diff --git a/templates/tutorialv2/includes/shared_content_child.html b/templates/tutorialv2/includes/shared_content_child.html
new file mode 100644
index 0000000000..9450173084
--- /dev/null
+++ b/templates/tutorialv2/includes/shared_content_child.html
@@ -0,0 +1,63 @@
+{% load emarkdown %}
+{% load i18n %}
+{% load times %}
+{% load target_tree %}
+
+{% if not hide_title %}
+
+{% endif %}
+{% if child.text %}
+ {# child is an extract #}
+ {% if child.get_text.strip|length == 0 %}
+
+ {% trans "Cette section est actuellement vide." %}
+
+ {% else %}
+
+ {% endif %}
+{% else %}
+ {# child is a container #}
+
+ {% if child.has_extracts %}
+
+ {% for extract in child.children %}
+
+ {{ extract.title }}
+
+ {% endfor %}
+
+ {% elif child.has_sub_containers %}
+
+ {% for subchild in child.children %}
+
+
+
+ {% for extract in subchild.children %}
+
+
+
+ {% endfor %}
+
+
+ {% endfor %}
+
+ {% endif %}
+{% endif %}
+
+
+{% if not child.has_sub_containers %}
+
+{% endif %}
diff --git a/templates/tutorialv2/view/list_shareable_links.html b/templates/tutorialv2/view/list_shareable_links.html
new file mode 100644
index 0000000000..a95d3e5d99
--- /dev/null
+++ b/templates/tutorialv2/view/list_shareable_links.html
@@ -0,0 +1,106 @@
+{% extends "tutorialv2/base.html" %}
+{% load i18n %}
+
+{% block content %}
+
+{% blocktrans %} Liens de partage pour « {{ content }} » {% endblocktrans %}
+
+{% trans "Diffusez votre contenu en partageant un simple lien accessible sans incription sur le site." %}
+
+{% trans "Les liens de partages offrent les fonctionnalités suivantes :" %}
+
+{% blocktrans %}
+
+ partage de la dernière bêta ou du dernier brouillon ;
+ validité temporaire ou permanente ;
+ désactivation et réactivation à volonté.
+
+{% endblocktrans %}
+
+
+ {% trans "Créer un lien de partage" %}
+
+
+
+
+
+
+{% trans "Liens actifs" %}
+
+
+ {% blocktrans %}
+ Les personnes disposant d'un lien actif peuvent l'utiliser pour lire le contenu.
+ Il est possible de désactiver un lien temporairement pour en interdire son usage, et le réactiver plus tard.
+ {% endblocktrans %}
+
+
+{% if not active_links_and_forms %}
+
+ {% trans "Vous n'avez pas de liens de partage actifs." %}
+
+{% else %}
+
+
+ {% for link, edit_form in active_links_and_forms reversed %}
+ {% include 'tutorialv2/view/list_shareable_links.part.html' with link=link edit_form=edit_form content=content section="active" %}
+ {% endfor %}
+
+
+{% endif %}
+
+
+{% trans "Liens expirés" %}
+
+
+ {% blocktrans %}
+ Un lien de partage expiré ne permet pas de lire le contenu.
+ Si un lien est expiré, vous pouvez modifier sa date d'expiration pour qu'il fonctionne de nouveau.
+ {% endblocktrans %}
+
+
+{% if not expired_links_and_forms %}
+
+ {% trans "Vous n'avez pas de liens de partage expirés." %}
+
+{% else %}
+
+
+ {% for link, edit_form in expired_links_and_forms reversed %}
+ {% include 'tutorialv2/view/list_shareable_links.part.html' with link=link edit_form=edit_form content=content section="expired" %}
+ {% endfor %}
+
+
+{% endif %}
+
+{% trans "Liens inactifs" %}
+
+
+ {% blocktrans %}
+ Un lien de partage inactif ne permet pas de lire le contenu.
+ Vous pouvez le réactiver quand vous le souhaitez pour autoriser de nouveau son usage.
+ {% endblocktrans %}
+
+
+{% if not inactive_links_and_forms %}
+
+ {% trans "Vous n'avez pas de liens de partage inactifs." %}
+
+{% else %}
+
+
+ {% for link, edit_form in inactive_links_and_forms reversed %}
+ {% include 'tutorialv2/view/list_shareable_links.part.html' with link=link edit_form=edit_form content=content section="inactive" %}
+ {% endfor %}
+
+
+{% endif %}
+
+{% endblock %}
diff --git a/templates/tutorialv2/view/list_shareable_links.part.html b/templates/tutorialv2/view/list_shareable_links.part.html
new file mode 100644
index 0000000000..d8375b4371
--- /dev/null
+++ b/templates/tutorialv2/view/list_shareable_links.part.html
@@ -0,0 +1,76 @@
+{% load i18n %}
+
+
+ {{ link.description }}
+
+
+ {% if link.type == "DRAFT" %}
+ {% trans "Lien vers le dernier brouillon " %}
+ {% elif link.type == "BETA" %}
+ {% trans "Lien vers la dernière bêta " %}
+ {% else %}
+ {% trans "Lien de type inconnu " %}
+ {% endif %}
+
+
+
+ {% if link.expiration and link.expired %}
+ {% blocktrans with date=link.expiration %}
+ Lien expiré depuis le {{ date }}
+ {% endblocktrans %}
+ {% elif link.expiration and not link.expired %}
+ {% blocktrans with date=link.expiration %}
+ Valide jusqu'au {{ date }}
+ {% endblocktrans %}
+ {% else %}
+ {% trans "Valide indéfiniment " %}
+ {% endif %}
+
+
+
+ {% trans "Modifier" %}
+
+
+
+
+
+
+ {% trans "Supprimer" %}
+
+
+
+
+ {% if section == "active" or section == "expired" %}
+
+ {% else %}
+
+ {% endif %}
+
diff --git a/templates/tutorialv2/view/shared_container.html b/templates/tutorialv2/view/shared_container.html
new file mode 100644
index 0000000000..4c2dfcdc47
--- /dev/null
+++ b/templates/tutorialv2/view/shared_container.html
@@ -0,0 +1,98 @@
+{% extends "tutorialv2/base.html" %}
+{% load set %}
+{% load thumbnail %}
+{% load emarkdown %}
+{% load i18n %}
+{% load times %}
+{% load feminize %}
+{% load pluralize_fr %}
+
+{% block title %}
+ {{ container.title }} - {{ content.title }}
+{% endblock %}
+
+
+
+{% block breadcrumb %}
+
+ {% if container.parent.parent %}
+
+ {{ container.parent.parent.title }}
+
+ {% endif %}
+
+ {% if container.parent %}
+
+ {{ container.parent.title }}
+
+ {% endif %}
+
+ {{ container.title }}
+
+{% endblock %}
+
+
+{% block headline %}
+
+ {% if content.licence %}
+ {{ content.licence }}
+ {% endif %}
+
+ {{ container.title }}
+
+ {% include 'tutorialv2/includes/tags_authors.part.html' with publishablecontent=content online=False %}
+
+{% endblock %}
+
+
+{% block content %}
+
+ {% include "tutorialv2/includes/chapter_pager.part.html" with position="top" %}
+
+ {% if container.introduction and container.get_introduction %}
+ {{ container.get_introduction|emarkdown:is_js }}
+ {% endif %}
+
+ {% if container.has_extracts %}
+
+ {% endif %}
+
+ {% for child in container.children %}
+ {% include "tutorialv2/includes/shared_content_child.html" with child=child %}
+ {% empty %}
+ {% if not container.is_chapter %}
+
+
+ {{ "Ce"|feminize:container.get_level_as_string }} {{ container.get_level_as_string|lower }} {% trans " est actuellement vide." %}
+
+
+ {% endif %}
+ {% endfor %}
+
+
+
+ {% if container.conclusion and container.get_conclusion %}
+ {{ container.get_conclusion|emarkdown }}
+ {% endif %}
+
+ {% include "tutorialv2/includes/chapter_pager.part.html" with position="bottom" %}
+
+ {% if content.is_beta and container.has_extracts %}
+ {% include "tutorialv2/includes/warn_typo.part.html" with content=content %}
+ {% endif %}
+{% endblock %}
+
+
+{% block sidebar_blocks %}
+
+ {% include "tutorialv2/includes/summary.part.html" with current_container=container %}
+
+{% endblock %}
diff --git a/templates/tutorialv2/view/shared_content.html b/templates/tutorialv2/view/shared_content.html
new file mode 100644
index 0000000000..bbcaf4535f
--- /dev/null
+++ b/templates/tutorialv2/view/shared_content.html
@@ -0,0 +1,97 @@
+{% extends "tutorialv2/base_online.html" %}
+{% load i18n %}
+{% load captureas %}
+
+{% block title %}
+ {{ content.title }}
+{% endblock %}
+
+{% block breadcrumb %}
+ {{ content.title }}
+{% endblock %}
+
+{% block headline %}
+ {% if content.licence %}
+
+ {{ content.licence }}
+
+ {% endif %}
+
+
+ {% if content.image %}
+
+ {% endif %}
+ {{ content.title }}
+
+
+ {% if content.description %}
+
+ {{ content.description }}
+
+ {% endif %}
+
+ {% include 'tutorialv2/includes/tags_authors.part.html' with publishablecontent=content online=False %}
+ {% if content.is_opinion %}
+ {% if content.converted_to %}
+ {% if content.converted_to.get_absolute_url_online %}
+
+ {% blocktrans with url_article=content.converted_to.get_absolute_url_online %}
+ Ce billet a été promu en
article .
+ {% endblocktrans %}
+
+ {% elif is_staff %}
+
+ {% blocktrans with url_article=content.converted_to.get_absolute_url %}
+ Ce billet a fait l’objet d’une demande de publication
en tant qu’article . Il est donc présent dans la zone de validation en attente de prise en charge par un validateur.
+ {% endblocktrans %}
+
+ {% endif %}
+ {% endif %}
+ {% endif %}
+
+{% endblock %}
+
+{% block content %}
+
+ {% captureas content_pager %}
+ {% include "tutorialv2/includes/content_pager.part.html" with content=content %}
+ {% endcaptureas %}
+
+ {{ content_pager }}
+
+ {% if content.has_extracts %}
+ {{ content.get_content_online|safe }}
+ {% else %}
+ {% if content.introduction %}
+ {{ content.get_introduction|default:""|safe }}
+ {% endif %}
+
+ {% if not content.has_sub_containers %}
+
+ {% endif %}
+
+ {% captureas url %}
+ {% url "content:shareable-link" link.id %}
+ {% endcaptureas %}
+
+ {% for child in content.children %}
+ {% include "tutorialv2/includes/shared_content_child.html" with url=url child=child %}
+ {% endfor %}
+
+ {% if not content.has_sub_containers %}
+
+ {% endif %}
+
+
+
+
+ {% if content.conclusion %}
+ {{ content.get_conclusion_online|default:""|safe }}
+ {% endif %}
+
+ {% endif %}
+ {{ content_pager }}
+ {% include "tutorialv2/includes/alert.html" with content=content current_content_type=current_content_type %}
+ {% include "tutorialv2/includes/warn_typo.part.html" with content=content %}
+
+{% endblock %}
diff --git a/zds/tutorialv2/migrations/0036_shareablelink.py b/zds/tutorialv2/migrations/0036_shareablelink.py
new file mode 100644
index 0000000000..193f9500ce
--- /dev/null
+++ b/zds/tutorialv2/migrations/0036_shareablelink.py
@@ -0,0 +1,40 @@
+# Generated by Django 3.2.15 on 2022-09-29 22:07
+
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tutorialv2", "0035_alter_publishablecontent_goals"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ShareableLink",
+ fields=[
+ ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ("active", models.BooleanField(default=True)),
+ ("expiration", models.DateTimeField(null=True)),
+ ("description", models.CharField(default="Lien de partage", max_length=150)),
+ (
+ "type",
+ models.CharField(
+ choices=[("DRAFT", "Lien vers le dernier brouillon"), ("BETA", "Lien vers la dernière bêta")],
+ default="DRAFT",
+ max_length=10,
+ ),
+ ),
+ (
+ "content",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="tutorialv2.publishablecontent",
+ verbose_name="Contenu",
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/zds/tutorialv2/models/__init__.py b/zds/tutorialv2/models/__init__.py
index b636226aa8..2dfa21b17c 100644
--- a/zds/tutorialv2/models/__init__.py
+++ b/zds/tutorialv2/models/__init__.py
@@ -69,3 +69,8 @@
("REJECT", _("Rejeté")),
("CANCEL", _("Annulé")),
)
+
+SHAREABLE_LINK_TYPES = (
+ ("DRAFT", _("Lien vers le dernier brouillon")),
+ ("BETA", _("Lien vers la dernière bêta")),
+)
diff --git a/zds/tutorialv2/models/shareable_links.py b/zds/tutorialv2/models/shareable_links.py
new file mode 100644
index 0000000000..6c0700c52c
--- /dev/null
+++ b/zds/tutorialv2/models/shareable_links.py
@@ -0,0 +1,61 @@
+import uuid
+from datetime import datetime
+
+from django.conf import settings
+from django.db import models
+from django.db.models import Q
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+
+from zds.tutorialv2.models import SHAREABLE_LINK_TYPES
+from zds.tutorialv2.models.database import PublishableContent
+
+
+class ShareableLinkQuerySet(models.QuerySet):
+ def for_content(self, content):
+ return self.filter(content=content)
+
+ def active_and_for_content(self, content):
+ return self.for_content(content).active()
+
+ def expired_and_for_content(self, content):
+ return self.for_content(content).expired()
+
+ def inactive_and_for_content(self, content):
+ return self.for_content(content).inactive()
+
+ def active(self):
+ pivot_date = datetime.now()
+ return self.filter(Q(active=True) & (Q(expiration__gte=pivot_date) | Q(expiration=None)))
+
+ def expired(self):
+ pivot_date = datetime.now()
+ return self.filter(active=True, expiration__lt=pivot_date)
+
+ def inactive(self):
+ return self.filter(active=False)
+
+
+class ShareableLink(models.Model):
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ content = models.ForeignKey(PublishableContent, verbose_name="Contenu", on_delete=models.CASCADE)
+ active = models.BooleanField(default=True)
+ expiration = models.DateTimeField(null=True)
+ description = models.CharField(default=_("Lien de partage"), max_length=150)
+ # Types
+ # DRAFT: always points to the last draft version
+ # BETA: always points to the last beta version
+ type = models.CharField(max_length=10, choices=SHAREABLE_LINK_TYPES, default="DRAFT")
+
+ objects = ShareableLinkQuerySet.as_manager()
+
+ def full_url(self):
+ return settings.ZDS_APP["site"]["url"] + reverse("content:shareable-link", kwargs={"id": self.id})
+
+ def deactivate(self):
+ self.active = False
+ self.save()
+
+ def reactivate(self):
+ self.active = True
+ self.save()
diff --git a/zds/tutorialv2/models/versioned.py b/zds/tutorialv2/models/versioned.py
index b43753a1f7..820a42f212 100644
--- a/zds/tutorialv2/models/versioned.py
+++ b/zds/tutorialv2/models/versioned.py
@@ -387,6 +387,9 @@ def get_absolute_url(self):
"""
return self.top_container().get_absolute_url() + self.get_path(relative=True, os_sensitive=False) + "/"
+ def get_relative_url(self):
+ return self.get_path(relative=True, os_sensitive=False) + "/"
+
def get_absolute_url_online(self):
"""
@@ -950,6 +953,9 @@ def get_absolute_url(self):
"""
return f"{self.container.get_absolute_url()}#{self.position_in_parent}-{self.slug}"
+ def get_relative_url(self):
+ return f"{self.container.get_relative_url()}#{self.position_in_parent}-{self.slug}"
+
def get_absolute_url_online(self):
"""
:return: the url to access the tutorial when online
diff --git a/zds/tutorialv2/tests/tests_views/tests_shareable_links.py b/zds/tutorialv2/tests/tests_views/tests_shareable_links.py
new file mode 100644
index 0000000000..2434b39ffa
--- /dev/null
+++ b/zds/tutorialv2/tests/tests_views/tests_shareable_links.py
@@ -0,0 +1,313 @@
+from datetime import datetime
+
+from django.test import TestCase
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+
+from zds.member.factories import ProfileFactory, StaffProfileFactory
+from zds.tutorialv2.factories import PublishableContentFactory
+from zds.tutorialv2.models.shareable_links import ShareableLink
+from zds.tutorialv2.tests import TutorialTestMixin
+
+
+class ListShareableLinksTests(TutorialTestMixin, TestCase):
+ 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.url = reverse("content:list-shareable-links", kwargs={"pk": self.content.pk})
+ self.login_url = reverse("member-login") + "?next=" + self.url
+
+ def test_not_authenticated(self):
+ self.client.logout()
+ response = self.client.get(self.url)
+ self.assertRedirects(response, self.login_url)
+
+ def test_authenticated_author(self):
+ self.client.force_login(self.author)
+ response = self.client.get(self.url)
+ self.assertEqual(response.status_code, 200)
+
+ def test_authenticated_staff(self):
+ self.client.force_login(self.staff)
+ response = self.client.get(self.url)
+ self.assertEqual(response.status_code, 403)
+
+ def test_authenticated_outsider(self):
+ self.client.force_login(self.outsider)
+ response = self.client.get(self.url)
+ self.assertEqual(response.status_code, 403)
+
+ def test_no_link(self):
+ self.client.force_login(self.author)
+ response = self.client.get(self.url)
+ self.assertContains(response, _("Vous n'avez pas de liens de partage actifs."))
+ self.assertContains(response, _("Créer un lien de partage"))
+
+ def test_one_link(self):
+ self.client.force_login(self.author)
+ ShareableLink(content=self.content).save()
+ response = self.client.get(self.url)
+ self.assertContains(response, _("Liens actifs"))
+ self.assertContains(response, _("Créer un lien de partage"))
+ self.assertContains(response, '', count=1)
+
+ def test_two_links(self):
+ self.client.force_login(self.author)
+ ShareableLink(content=self.content).save()
+ ShareableLink(content=self.content).save()
+ response = self.client.get(self.url)
+ self.assertContains(response, _("Liens actifs"))
+ self.assertContains(response, _("Créer un lien de partage"))
+ self.assertContains(response, ' ', count=2)
+
+
+class CreateShareableLinkTests(TestCase):
+ 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.url = reverse("content:create-shareable-link", kwargs={"pk": self.content.pk})
+ self.redirect_url = reverse("content:list-shareable-links", kwargs={"pk": self.content.pk})
+ self.login_url = reverse("member-login") + "?next=" + self.url
+
+ def test_not_authenticated(self):
+ self.client.logout()
+ response = self.client.post(self.url)
+ self.assertRedirects(response, self.login_url)
+
+ def test_authenticated_author(self):
+ self.client.force_login(self.author)
+ n_links_before = len(ShareableLink.objects.all())
+ data = {"description": "Ceci n'est pas le lien vers La Blague", "expiration": "2042-08-01", "type": "BETA"}
+ response = self.client.post(self.url, data=data)
+ self.assertRedirects(response, self.redirect_url)
+ n_links_after = len(ShareableLink.objects.all())
+ self.assertEqual(n_links_after, n_links_before + 1)
+
+ def test_authenticated_staff(self):
+ self.client.force_login(self.staff)
+ response = self.client.post(self.url)
+ self.assertEqual(response.status_code, 403)
+
+ def test_authenticated_outsider(self):
+ self.client.force_login(self.outsider)
+ response = self.client.post(self.url)
+ self.assertEqual(response.status_code, 403)
+
+
+class EditShareableLinkTests(TestCase):
+ def setUp(self):
+ # Create users
+ self.author = ProfileFactory().user
+ self.staff = StaffProfileFactory().user
+ self.outsider = ProfileFactory().user
+
+ # Create a content and a link
+ self.content = PublishableContentFactory(author_list=[self.author])
+ self.link = ShareableLink(content=self.content)
+ self.link.save()
+
+ # Get information to be reused in tests
+ self.url = reverse("content:edit-shareable-link", kwargs={"id": self.link.id})
+ self.redirect_url = reverse("content:list-shareable-links", kwargs={"pk": self.content.pk})
+ self.login_url = reverse("member-login") + "?next=" + self.url
+
+ def test_not_authenticated(self):
+ self.client.logout()
+ response = self.client.post(self.url)
+ self.assertRedirects(response, self.login_url)
+
+ def test_authenticated_author(self):
+ self.client.force_login(self.author)
+ data = {"description": "Ceci n'est pas le lien vers La Blague", "expiration": "2042-08-01", "type": "BETA"}
+ response = self.client.post(self.url, data=data)
+ self.assertRedirects(response, self.redirect_url)
+ link = ShareableLink.objects.get(id=self.link.id)
+ self.assertEqual(link.description, data["description"])
+ self.assertEqual(link.expiration, datetime.strptime(data["expiration"], "%Y-%m-%d"))
+ self.assertEqual(link.type, data["type"])
+
+ def test_authenticated_staff(self):
+ self.client.force_login(self.staff)
+ response = self.client.post(self.url)
+ self.assertEqual(response.status_code, 403)
+
+ def test_authenticated_outsider(self):
+ self.client.force_login(self.outsider)
+ response = self.client.post(self.url)
+ self.assertEqual(response.status_code, 403)
+
+
+class DeactivateShareableLinkTests(TestCase):
+ def setUp(self):
+ # Create users
+ self.author = ProfileFactory().user
+ self.staff = StaffProfileFactory().user
+ self.outsider = ProfileFactory().user
+
+ # Create a content and a link
+ self.content = PublishableContentFactory(author_list=[self.author])
+ self.link = ShareableLink(content=self.content)
+ self.link.save()
+
+ # Get information to be reused in tests
+ self.url = reverse("content:deactivate-shareable-link", kwargs={"id": self.link.id})
+ self.redirect_url = reverse("content:list-shareable-links", kwargs={"pk": self.content.pk})
+ self.login_url = reverse("member-login") + "?next=" + self.url
+
+ def test_not_authenticated(self):
+ self.client.logout()
+ response = self.client.post(self.url)
+ self.assertRedirects(response, self.login_url)
+
+ def test_authenticated_author(self):
+ self.client.force_login(self.author)
+ response = self.client.post(self.url)
+ self.assertRedirects(response, self.redirect_url)
+ link = ShareableLink.objects.get(id=self.link.id)
+ self.assertFalse(link.active)
+
+ def test_authenticated_staff(self):
+ self.client.force_login(self.staff)
+ response = self.client.post(self.url)
+ self.assertEqual(response.status_code, 403)
+
+ def test_authenticated_outsider(self):
+ self.client.force_login(self.outsider)
+ response = self.client.post(self.url)
+ self.assertEqual(response.status_code, 403)
+
+
+class ReactivateShareableLinkTests(TestCase):
+ def setUp(self):
+ # Create users
+ self.author = ProfileFactory().user
+ self.staff = StaffProfileFactory().user
+ self.outsider = ProfileFactory().user
+
+ # Create a content and a link
+ self.content = PublishableContentFactory(author_list=[self.author])
+ self.link = ShareableLink(content=self.content)
+ self.link.save()
+
+ # Get information to be reused in tests
+ self.url = reverse("content:reactivate-shareable-link", kwargs={"id": self.link.id})
+ self.redirect_url = reverse("content:list-shareable-links", kwargs={"pk": self.content.pk})
+ self.login_url = reverse("member-login") + "?next=" + self.url
+
+ def test_not_authenticated(self):
+ self.client.logout()
+ response = self.client.post(self.url)
+ self.assertRedirects(response, self.login_url)
+
+ def test_authenticated_author(self):
+ self.client.force_login(self.author)
+ response = self.client.post(self.url)
+ self.assertRedirects(response, self.redirect_url)
+ link = ShareableLink.objects.get(id=self.link.id)
+ self.assertTrue(link.active)
+
+ def test_authenticated_staff(self):
+ self.client.force_login(self.staff)
+ response = self.client.post(self.url)
+ self.assertEqual(response.status_code, 403)
+
+ def test_authenticated_outsider(self):
+ self.client.force_login(self.outsider)
+ response = self.client.post(self.url)
+ self.assertEqual(response.status_code, 403)
+
+
+class DeleteShareableLinkTests(TestCase):
+ def setUp(self):
+ # Create users
+ self.author = ProfileFactory().user
+ self.staff = StaffProfileFactory().user
+ self.outsider = ProfileFactory().user
+
+ # Create content and links
+ self.content = PublishableContentFactory(author_list=[self.author])
+ self.link = ShareableLink(content=self.content)
+ self.link.save()
+
+ # Get information to be reused in tests
+ self.url = reverse("content:delete-shareable-link", kwargs={"id": self.link.id})
+ self.redirect_url = reverse("content:list-shareable-links", kwargs={"pk": self.content.pk})
+ self.login_url = reverse("member-login") + "?next=" + self.url
+
+ def test_not_authenticated(self):
+ self.client.logout()
+ response = self.client.post(self.url)
+ self.assertRedirects(response, self.login_url)
+
+ def test_authenticated_author(self):
+ self.client.force_login(self.author)
+ response = self.client.post(self.url)
+ self.assertRedirects(response, self.redirect_url)
+ with self.assertRaises(ShareableLink.DoesNotExist):
+ ShareableLink.objects.get(id=self.link.id)
+
+ def test_authenticated_staff(self):
+ self.client.force_login(self.staff)
+ response = self.client.post(self.url)
+ self.assertEqual(response.status_code, 403)
+
+ def test_authenticated_outsider(self):
+ self.client.force_login(self.outsider)
+ response = self.client.post(self.url)
+ self.assertEqual(response.status_code, 403)
+
+
+class DisplaySharedContentPermissionTest(TestCase):
+ def setUp(self):
+ # Create users
+ self.author = ProfileFactory().user
+ self.staff = StaffProfileFactory().user
+ self.outsider = ProfileFactory().user
+
+ # Create a content and a link
+ self.content = PublishableContentFactory(author_list=[self.author])
+ self.link = ShareableLink(content=self.content)
+ self.link.save()
+
+ # Get information to be reused in tests
+ self.link_url = reverse("content:shareable-link", kwargs={"id": self.link.id})
+
+ def test_not_authenticated(self):
+ self.client.logout()
+ response = self.client.get(self.link_url)
+ self.assertEqual(response.status_code, 200)
+
+ def test_authenticated_author(self):
+ self.client.force_login(self.author)
+ response = self.client.get(self.link_url)
+ self.assertEqual(response.status_code, 200)
+
+ def test_authenticated_staff(self):
+ self.client.force_login(self.staff)
+ response = self.client.get(self.link_url)
+ self.assertEqual(response.status_code, 200)
+
+ def test_authenticated_outsider(self):
+ self.client.force_login(self.outsider)
+ response = self.client.get(self.link_url)
+ self.assertEqual(response.status_code, 200)
+
+ def test_authenticated_author_inactive(self):
+ self.client.force_login(self.author)
+ self.link.deactivate()
+ response = self.client.get(self.link_url)
+ self.assertEqual(response.status_code, 403)
diff --git a/zds/tutorialv2/urls/urls_contents.py b/zds/tutorialv2/urls/urls_contents.py
index e3299d0db5..4388b0e9e9 100644
--- a/zds/tutorialv2/urls/urls_contents.py
+++ b/zds/tutorialv2/urls/urls_contents.py
@@ -2,11 +2,21 @@
from django.views.generic.base import RedirectView
from zds.tutorialv2.views.contents import CreateContent, EditContent, EditContentLicense, DeleteContent
+from zds.tutorialv2.views.shareable_links import (
+ ListShareableLinks,
+ CreateShareableLink,
+ DeactivateShareableLink,
+ EditShareableLink,
+ ReactivateShareableLink,
+ DeleteShareableLink,
+)
from zds.tutorialv2.views.display.container import ContainerValidationView
from zds.tutorialv2.views.display.content import ContentValidationView
from zds.tutorialv2.views.events import EventsList
from zds.tutorialv2.views.goals import EditGoals, MassEditGoals, ViewContentsByGoal
from zds.tutorialv2.views.labels import EditLabels, ViewContentsByLabel
+
+from zds.tutorialv2.views.shared_content import DisplaySharedContent, DisplaySharedContainer
from zds.tutorialv2.views.validations_contents import ActivateJSFiddleInContent
from zds.tutorialv2.views.containers_extracts import (
CreateContainer,
@@ -249,5 +259,23 @@ def get_version_pages():
# Label-based classification
path("modifier-labels//", EditLabels.as_view(), name="edit-labels"),
path("labels//", ViewContentsByLabel.as_view(), name="view-labels"),
+ # Shareable links
+ path("partage/gerer//", ListShareableLinks.as_view(), name="list-shareable-links"),
+ path("partage/creer//", CreateShareableLink.as_view(), name="create-shareable-link"),
+ path("partage/modifier//", EditShareableLink.as_view(), name="edit-shareable-link"),
+ path("partage/desactiver//", DeactivateShareableLink.as_view(), name="deactivate-shareable-link"),
+ path("partage/reactiver//", ReactivateShareableLink.as_view(), name="reactivate-shareable-link"),
+ path("partage/supprimer//", DeleteShareableLink.as_view(), name="delete-shareable-link"),
+ path("partage//", DisplaySharedContent.as_view(), name="shareable-link"),
+ path(
+ "partage////",
+ DisplaySharedContainer.as_view(),
+ name="shareable-link-container",
+ ),
+ path(
+ "partage///",
+ DisplaySharedContainer.as_view(),
+ name="shareable-link-container",
+ ),
]
)
diff --git a/zds/tutorialv2/views/shareable_links.py b/zds/tutorialv2/views/shareable_links.py
new file mode 100644
index 0000000000..89227a476f
--- /dev/null
+++ b/zds/tutorialv2/views/shareable_links.py
@@ -0,0 +1,138 @@
+from django import forms
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import PermissionDenied
+from django.shortcuts import get_object_or_404, redirect
+from django.urls import reverse
+from django.utils.decorators import method_decorator
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from django.views.generic import TemplateView
+
+from zds.member.decorator import LoginRequiredMixin
+from zds.tutorialv2.mixins import SingleContentDetailViewMixin, ModalFormView
+from zds.tutorialv2.models import SHAREABLE_LINK_TYPES
+from zds.tutorialv2.models.database import PublishableContent
+from zds.tutorialv2.models.shareable_links import ShareableLink
+
+
+class ListShareableLinks(LoginRequiredMixin, SingleContentDetailViewMixin, TemplateView):
+ template_name = "tutorialv2/view/list_shareable_links.html"
+ authorized_for_staff = False
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ content = self.get_object()
+ context["active_links_and_forms"] = self.get_active_links_and_forms(content)
+ context["expired_links_and_forms"] = self.get_expired_links_and_forms(content)
+ context["inactive_links_and_forms"] = self.get_inactive_links_and_forms(content)
+ context["create_form"] = ShareableLinkForm()
+ return context
+
+ def get_active_links_and_forms(self, content):
+ return self.get_links_and_forms(ShareableLink.objects.active_and_for_content(content))
+
+ def get_expired_links_and_forms(self, content):
+ return self.get_links_and_forms(ShareableLink.objects.expired_and_for_content(content))
+
+ def get_inactive_links_and_forms(self, content):
+ return self.get_links_and_forms(ShareableLink.objects.inactive_and_for_content(content))
+
+ @staticmethod
+ def get_links_and_forms(links):
+ edit_forms = [ShareableLinkForm(initial=initial) for initial in links.values()]
+ return list(zip(links, edit_forms))
+
+
+class ShareableLinkForm(forms.Form):
+ description = forms.CharField(label=_("Description"), initial=_("Lien de partage"))
+ expiration = forms.DateTimeField(
+ label=_("Date d'expiration (laisser vide pour une durée illimitée)"),
+ widget=forms.DateInput(attrs={"type": "date"}),
+ required=False,
+ )
+ type = forms.ChoiceField(choices=SHAREABLE_LINK_TYPES)
+
+
+class PermissionMixin:
+ http_method_names = ["post"]
+
+ @method_decorator(login_required)
+ def dispatch(self, *args, **kwargs):
+ if self.request.user not in self.content.authors.all():
+ raise PermissionDenied
+ self.redirect_url = reverse("content:list-shareable-links", kwargs={"pk": self.content.pk})
+ return super().dispatch(*args, *kwargs)
+
+
+class ContentMixin(PermissionMixin):
+ def dispatch(self, *args, **kwargs):
+ self.content = get_object_or_404(PublishableContent, pk=self.kwargs["pk"])
+ return super().dispatch(*args, *kwargs)
+
+
+class LinkMixin(PermissionMixin):
+ def dispatch(self, *args, **kwargs):
+ self.link = get_object_or_404(ShareableLink, id=self.kwargs["id"])
+ self.content = self.link.content
+ return super().dispatch(*args, *kwargs)
+
+
+class CreateShareableLink(ContentMixin, ModalFormView):
+ http_method_names = ["post"]
+ form_class = ShareableLinkForm
+ modal_form = True
+
+ def form_invalid(self, form):
+ form.previous_page_url = self.redirect_url
+ return super().form_invalid(form)
+
+ def form_valid(self, form):
+ ShareableLink(
+ content=self.content,
+ description=form.cleaned_data["description"],
+ expiration=form.cleaned_data["expiration"],
+ type=form.cleaned_data["type"],
+ ).save()
+ self.success_url = self.redirect_url
+ messages.success(self.request, _("Le lien a été créé."))
+ return super().form_valid(form)
+
+
+class EditShareableLink(LinkMixin, ModalFormView):
+ form_class = ShareableLinkForm
+ modal_form = True
+
+ def form_invalid(self, form):
+ form.previous_page_url = self.redirect_url
+ return super().form_invalid(form)
+
+ def form_valid(self, form):
+ self.link.description = form.cleaned_data["description"]
+ self.link.expiration = form.cleaned_data["expiration"]
+ self.link.type = form.cleaned_data["type"]
+ self.link.save()
+ self.success_url = self.redirect_url
+ messages.success(self.request, "Le lien a été modifié.")
+ return super().form_valid(form)
+
+
+class DeactivateShareableLink(LinkMixin, View):
+ def post(self, *args, **kwargs):
+ self.link.deactivate()
+ messages.success(self.request, "Le lien a été désactivé.")
+ return redirect(self.redirect_url)
+
+
+class ReactivateShareableLink(LinkMixin, View):
+ def post(self, *args, **kwargs):
+ self.link.reactivate()
+ messages.success(self.request, "Le lien a été réactivé.")
+ return redirect(self.redirect_url)
+
+
+class DeleteShareableLink(LinkMixin, View):
+ def post(self, *args, **kwargs):
+ self.link.delete()
+ messages.success(self.request, "Le lien a été supprimé.")
+ return redirect(self.redirect_url)
diff --git a/zds/tutorialv2/views/shared_content.py b/zds/tutorialv2/views/shared_content.py
new file mode 100644
index 0000000000..7d6c65e4f5
--- /dev/null
+++ b/zds/tutorialv2/views/shared_content.py
@@ -0,0 +1,47 @@
+from django.core.exceptions import PermissionDenied
+from django.shortcuts import get_object_or_404
+from django.views.generic import TemplateView
+
+from zds.tutorialv2.mixins import ContentTypeMixin
+from zds.tutorialv2.models.shareable_links import ShareableLink
+from zds.tutorialv2.utils import search_container_or_404
+
+
+class DisplaySharedContentMixin:
+ """
+ Base behavior for DisplaySharedContent and DisplaySharedContainer.
+ Modify this mixin to change what is common to DisplaySharedContent and DisplaySharedContainer.
+ """
+
+ def setup(self, request, *args, **kwargs):
+ super().setup(request, *args, **kwargs)
+ self.link = get_object_or_404(ShareableLink, id=kwargs["id"])
+ if not self.link.active:
+ raise PermissionDenied
+ self.content = self.link.content
+ self.versioned_content = self.content.load_version_or_404(sha=self.content.sha_draft)
+
+ def get_context_data(self, **kwargs):
+ kwargs["link"] = self.link
+ kwargs["content"] = self.versioned_content
+ return super().get_context_data(**kwargs)
+
+
+class DisplaySharedContent(DisplaySharedContentMixin, ContentTypeMixin, TemplateView):
+ """View a shared version of a content (main page)."""
+
+ template_name = "tutorialv2/view/shared_content.html"
+
+
+class DisplaySharedContainer(DisplaySharedContentMixin, ContentTypeMixin, TemplateView):
+ """View a shared version of a content (subpage)."""
+
+ template_name = "tutorialv2/view/shared_container.html"
+
+ def setup(self, request, *args, **kwargs):
+ super().setup(request, *args, **kwargs)
+ self.container = search_container_or_404(self.versioned_content, self.kwargs)
+
+ def get_context_data(self, **kwargs):
+ kwargs["container"] = self.container
+ return super().get_context_data(**kwargs)