From 5a6e4cececfd4d5228c87eac44bb1867f9301de3 Mon Sep 17 00:00:00 2001 From: Arnaud-D <35631001+Arnaud-D@users.noreply.github.com> Date: Thu, 10 Jun 2021 15:46:04 +0200 Subject: [PATCH] WIP Partage par lien --- assets/scss/main.scss | 1 + assets/scss/pages/_shareable-links.scss | 117 ++++++++++++++ templates/tutorialv2/view/content.html | 7 + .../tutorialv2/view/list_shareable_links.html | 115 ++++++++++++++ .../view/list_shareable_links.part.html | 96 ++++++++++++ zds/settings/abstract_base/django.py | 2 +- .../migrations/0042_shareablelink.py | 40 +++++ zds/tutorialv2/mixins.py | 4 +- zds/tutorialv2/models/__init__.py | 5 + zds/tutorialv2/models/shareable_links.py | 67 ++++++++ .../tests_views/shareable_links/__init__.py | 0 .../tests_createshareablelinkview.py | 47 ++++++ .../tests_deactivateshareablelinkview.py | 48 ++++++ .../tests_deleteshareablelinkview.py | 48 ++++++ .../tests_editshareablelinkview.py | 53 +++++++ .../tests_listshareablelinksview.py | 66 ++++++++ .../tests_reactivateshareablelinkview.py | 48 ++++++ .../tests_sharedcontentview.py | 48 ++++++ zds/tutorialv2/urls/urls_contents.py | 30 ++++ zds/tutorialv2/views/display/config.py | 3 + .../views/display/shared_content.py | 92 +++++++++++ zds/tutorialv2/views/shareable_links.py | 148 ++++++++++++++++++ 22 files changed, 1082 insertions(+), 3 deletions(-) create mode 100644 assets/scss/pages/_shareable-links.scss create mode 100644 templates/tutorialv2/view/list_shareable_links.html create mode 100644 templates/tutorialv2/view/list_shareable_links.part.html create mode 100644 zds/tutorialv2/migrations/0042_shareablelink.py create mode 100644 zds/tutorialv2/models/shareable_links.py create mode 100644 zds/tutorialv2/tests/tests_views/shareable_links/__init__.py create mode 100644 zds/tutorialv2/tests/tests_views/shareable_links/tests_createshareablelinkview.py create mode 100644 zds/tutorialv2/tests/tests_views/shareable_links/tests_deactivateshareablelinkview.py create mode 100644 zds/tutorialv2/tests/tests_views/shareable_links/tests_deleteshareablelinkview.py create mode 100644 zds/tutorialv2/tests/tests_views/shareable_links/tests_editshareablelinkview.py create mode 100644 zds/tutorialv2/tests/tests_views/shareable_links/tests_listshareablelinksview.py create mode 100644 zds/tutorialv2/tests/tests_views/shareable_links/tests_reactivateshareablelinkview.py create mode 100644 zds/tutorialv2/tests/tests_views/shareable_links/tests_sharedcontentview.py create mode 100644 zds/tutorialv2/views/display/shared_content.py create mode 100644 zds/tutorialv2/views/shareable_links.py diff --git a/assets/scss/main.scss b/assets/scss/main.scss index 33ea86bff0..ccb66d2bae 100644 --- a/assets/scss/main.scss +++ b/assets/scss/main.scss @@ -103,6 +103,7 @@ @import "pages/content-editor"; @import "pages/content-exports"; @import "pages/mass-edit-goals"; +@import "pages/shareable-links"; /*------------------------- 11. High pixel ratio (retina) diff --git a/assets/scss/pages/_shareable-links.scss b/assets/scss/pages/_shareable-links.scss new file mode 100644 index 0000000000..ac49d3afab --- /dev/null +++ b/assets/scss/pages/_shareable-links.scss @@ -0,0 +1,117 @@ +.main .content-wrapper.shareable-link-page { + h2 { + color: $accent-700; + border: 0; + } + + .new-link-button { + button { + float: none; + } + } + + .list_of_links { + list-style: none; + + .shareable-link-frame { + border: 1px solid black; + padding: $length-10; + margin-bottom: $length-20; + background: $true-white; + border: solid $length-1 $grey-200; + border-bottom: solid $length-2 $grey-200; + + header { + display: flex; + flex-wrap: wrap; + min-height: $length-24; + + .shareable-link-description { + flex-grow: 100; + display: inline-block; + margin: 0 0 $length-6 0; + padding: 0; + } + + .shareable-link-actions { + display: flex; + flex-wrap: wrap; + + .btn-holder { + min-height: 0; + height: $length-24; + margin: 0 0 $length-6 $length-20; + + + .btn-grey, .btn-cancel, .btn-submit { + padding: 0 $length-6; + line-height: normal; + height: $length-24; + } + + } + + .activation_status { + display: flex; + width: min-content; + + .btn-grey, .btn-submit, input [type="hidden"], input{ + padding: 0 $length-6; + line-height: normal; + height: $length-24; + } + + .status_display { + display: inline-block; + margin: auto; + padding: 0; + margin: 0 $length-6 $length-6 0; + } + + .status_active { + color:green; + vertical-align: middle; + } + + .status_active::before { + content: "✓"; + margin-right: $length-4; + } + + .status_inactive { + color:gray; + vertical-align: middle; + } + + .status_inactive::before { + content: "✕"; + margin-right: $length-4; + } + } + } + } + + section { + .shareable-link-url { + margin: $length-4 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .shareable-link-info { + display: flex; + flex-wrap: wrap; + + .shareable-link-info-line { + margin: 0 $length-20 0 0; + + &.shareable-link-has-expired { + color: $red-700; + } + } + } + } + } + } +} diff --git a/templates/tutorialv2/view/content.html b/templates/tutorialv2/view/content.html index bb197bdc86..c3e31787ce 100644 --- a/templates/tutorialv2/view/content.html +++ b/templates/tutorialv2/view/content.html @@ -181,6 +181,13 @@ {% include "tutorialv2/includes/sidebar/contributors_management.part.html" %} {% endif %} + {% if display_config.draft_actions.show_shareable_link_management %} +
  • + {% url "content:import" content.pk content.slug as import_url %} + {% trans "Gérer les liens de partage" %} +
  • + {% endif %} + {% endblock %} diff --git a/templates/tutorialv2/view/list_shareable_links.html b/templates/tutorialv2/view/list_shareable_links.html new file mode 100644 index 0000000000..23049ca993 --- /dev/null +++ b/templates/tutorialv2/view/list_shareable_links.html @@ -0,0 +1,115 @@ +{% extends "tutorialv2/base.html" %} +{% load i18n %} + +{% block breadcrumb %} +
  • {{ content.title }}
  • +
  • {% trans "Liens de partage" %}
  • +{% endblock %} + +{% block content_out %} + +{% 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..237add4321 --- /dev/null +++ b/templates/tutorialv2/view/list_shareable_links.part.html @@ -0,0 +1,96 @@ +{% load i18n %} + + diff --git a/zds/settings/abstract_base/django.py b/zds/settings/abstract_base/django.py index d18d6416af..a5bbfa57cc 100644 --- a/zds/settings/abstract_base/django.py +++ b/zds/settings/abstract_base/django.py @@ -41,7 +41,7 @@ # If you set this to False, Django will not format dates, numbers and # calendars according to the current locale. -USE_L10N = False +USE_L10N = True # If you set this to False, Django will not use timezone-aware datetimes. USE_TZ = False diff --git a/zds/tutorialv2/migrations/0042_shareablelink.py b/zds/tutorialv2/migrations/0042_shareablelink.py new file mode 100644 index 0000000000..4ff78de8c9 --- /dev/null +++ b/zds/tutorialv2/migrations/0042_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", "0041_remove_must_reindex"), + ] + + 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/mixins.py b/zds/tutorialv2/mixins.py index 520309efdb..da27e8b03d 100644 --- a/zds/tutorialv2/mixins.py +++ b/zds/tutorialv2/mixins.py @@ -8,7 +8,6 @@ from django.views.generic import DetailView, FormView from django.views.generic import View -from zds.forum.models import Topic from zds.tutorialv2.models.database import PublishableContent, PublishedContent, ContentRead from zds.tutorialv2.utils import mark_read from zds.tutorialv2.models.help_requests import HelpWriting @@ -48,6 +47,7 @@ class SingleContentViewMixin: sha = None must_be_author = True authorized_for_staff = True + authorized_for_all = False # used for shareable links is_staff = False is_author = False must_redirect = False @@ -97,7 +97,7 @@ def get_versioned_object(self): is_beta = self.object.is_beta(self.sha) is_public = self.object.is_public(self.sha) and self.public_is_prioritary - if not is_beta and not is_public and not self.is_author: + if not is_beta and not is_public and not self.is_author and not self.authorized_for_all: if not self.is_staff or (not self.authorized_for_staff and self.must_be_author): raise PermissionDenied diff --git a/zds/tutorialv2/models/__init__.py b/zds/tutorialv2/models/__init__.py index e96c506d1f..d153119888 100644 --- a/zds/tutorialv2/models/__init__.py +++ b/zds/tutorialv2/models/__init__.py @@ -62,3 +62,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..3f91fcd0f9 --- /dev/null +++ b/zds/tutorialv2/models/shareable_links.py @@ -0,0 +1,67 @@ +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-view", kwargs={"id": self.id}) + + def deactivate(self): + self.active = False + self.save() + + def reactivate(self): + self.active = True + self.save() + + def valid_indefinitely(self): + return not self.expiration + + def expired(self): + return self.expiration and self.expiration < datetime.now() diff --git a/zds/tutorialv2/tests/tests_views/shareable_links/__init__.py b/zds/tutorialv2/tests/tests_views/shareable_links/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/zds/tutorialv2/tests/tests_views/shareable_links/tests_createshareablelinkview.py b/zds/tutorialv2/tests/tests_views/shareable_links/tests_createshareablelinkview.py new file mode 100644 index 0000000000..181f59afe7 --- /dev/null +++ b/zds/tutorialv2/tests/tests_views/shareable_links/tests_createshareablelinkview.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from django.urls import reverse + +from zds.member.tests.factories import ProfileFactory, StaffProfileFactory +from zds.tutorialv2.models.shareable_links import ShareableLink +from zds.tutorialv2.tests.factories import PublishableContentFactory +from zds.tutorialv2.views.shareable_links import CreateShareableLinkView + + +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 = ShareableLink.objects.all().count() + 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, target_status_code=200) + n_links_after = ShareableLink.objects.all().count() + 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) diff --git a/zds/tutorialv2/tests/tests_views/shareable_links/tests_deactivateshareablelinkview.py b/zds/tutorialv2/tests/tests_views/shareable_links/tests_deactivateshareablelinkview.py new file mode 100644 index 0000000000..0b99eec4ea --- /dev/null +++ b/zds/tutorialv2/tests/tests_views/shareable_links/tests_deactivateshareablelinkview.py @@ -0,0 +1,48 @@ +from django.test import TestCase +from django.urls import reverse + +from zds.member.tests.factories import ProfileFactory, StaffProfileFactory +from zds.tutorialv2.models.shareable_links import ShareableLink +from zds.tutorialv2.tests.factories import PublishableContentFactory +from zds.tutorialv2.views.shareable_links import DeactivateShareableLinkView + + +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, follow=True) + self.assertRedirects(response, self.redirect_url, target_status_code=200) + self.assertContains(response, DeactivateShareableLinkView.success_message) + self.link.refresh_from_db() + self.assertFalse(self.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) diff --git a/zds/tutorialv2/tests/tests_views/shareable_links/tests_deleteshareablelinkview.py b/zds/tutorialv2/tests/tests_views/shareable_links/tests_deleteshareablelinkview.py new file mode 100644 index 0000000000..0ee3f2ecce --- /dev/null +++ b/zds/tutorialv2/tests/tests_views/shareable_links/tests_deleteshareablelinkview.py @@ -0,0 +1,48 @@ +from django.test import TestCase +from django.urls import reverse + +from zds.member.tests.factories import ProfileFactory, StaffProfileFactory +from zds.tutorialv2.models.shareable_links import ShareableLink +from zds.tutorialv2.tests.factories import PublishableContentFactory +from zds.tutorialv2.views.shareable_links import DeleteShareableLinkView + + +class Tests(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, follow=True) + self.assertRedirects(response, self.redirect_url, target_status_code=200) + self.assertContains(response, DeleteShareableLinkView.success_message) + 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) diff --git a/zds/tutorialv2/tests/tests_views/shareable_links/tests_editshareablelinkview.py b/zds/tutorialv2/tests/tests_views/shareable_links/tests_editshareablelinkview.py new file mode 100644 index 0000000000..c488d8cbe9 --- /dev/null +++ b/zds/tutorialv2/tests/tests_views/shareable_links/tests_editshareablelinkview.py @@ -0,0 +1,53 @@ +from datetime import datetime + +from django.test import TestCase +from django.urls import reverse + +from zds.member.tests.factories import ProfileFactory, StaffProfileFactory +from zds.tutorialv2.models.shareable_links import ShareableLink +from zds.tutorialv2.tests.factories import PublishableContentFactory +from zds.tutorialv2.views.shareable_links import EditShareableLinkView + + +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, follow=True) + self.assertRedirects(response, self.redirect_url, target_status_code=200) + self.assertContains(response, EditShareableLinkView.success_message) + self.link.refresh_from_db() + self.assertEqual(self.link.description, data["description"]) + self.assertEqual(self.link.expiration, datetime.strptime(data["expiration"], "%Y-%m-%d")) + self.assertEqual(self.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) diff --git a/zds/tutorialv2/tests/tests_views/shareable_links/tests_listshareablelinksview.py b/zds/tutorialv2/tests/tests_views/shareable_links/tests_listshareablelinksview.py new file mode 100644 index 0000000000..df067b42af --- /dev/null +++ b/zds/tutorialv2/tests/tests_views/shareable_links/tests_listshareablelinksview.py @@ -0,0 +1,66 @@ +from django.test import TestCase +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from zds.member.tests.factories import ProfileFactory, StaffProfileFactory +from zds.tutorialv2.models.shareable_links import ShareableLink +from zds.tutorialv2.tests import TutorialTestMixin +from zds.tutorialv2.tests.factories import PublishableContentFactory + + +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, '