From 6c78b214634425b3ba8c2a5064b882d1edd90a63 Mon Sep 17 00:00:00 2001 From: "Ph. SW" <5911232+philippemilink@users.noreply.github.com> Date: Sat, 23 Oct 2021 10:46:14 +0200 Subject: [PATCH 01/23] =?UTF-8?q?G=C3=A8re=20plus=20d'erreurs=20possibles?= =?UTF-8?q?=20venant=20de=20Matomo=20(#6151)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- templates/tutorialv2/stats/index.html | 22 +++-- .../tests/tests_views/tests_stats.py | 17 ++++ zds/tutorialv2/views/statistics.py | 95 +++++++++---------- 3 files changed, 76 insertions(+), 58 deletions(-) diff --git a/templates/tutorialv2/stats/index.html b/templates/tutorialv2/stats/index.html index 4f21e0396a..3f3e8e049b 100644 --- a/templates/tutorialv2/stats/index.html +++ b/templates/tutorialv2/stats/index.html @@ -40,17 +40,19 @@

{% endblocktrans %} {% endif %}

- -
- {% trans "Pages vues" %} - {% trans "Temps moyen de lecture" %} - {% trans "Nombre de visiteurs uniques" %} -
- - {% include "misc/graph.part.html" with tab_name="tab-view-graph-content" graph_title="Évolution des pages vues sur le contenu" canvas_id="view-graph" report_key="nb_hits" y_label="Nombre de pages" %} - {% include "misc/graph.part.html" with tab_name="tab-visit-time-graph-content" graph_title="Évolution du temps moyen lecture (en secondes)" canvas_id="visit-time-graph" report_key="avg_time_on_page" y_label="Secondes" %} - {% include "misc/graph.part.html" with tab_name="tab-users-graph-content" graph_title="Évolution du nombre de visiteurs uniques" canvas_id="users-graph" report_key="nb_uniq_visitors" y_label="Nombre de visiteurs" %} + {% if reports %} + +
+ {% trans "Pages vues" %} + {% trans "Temps moyen de lecture" %} + {% trans "Nombre de visiteurs uniques" %} +
+ + {% include "misc/graph.part.html" with tab_name="tab-view-graph-content" graph_title="Évolution des pages vues sur le contenu" canvas_id="view-graph" report_key="nb_hits" y_label="Nombre de pages" %} + {% include "misc/graph.part.html" with tab_name="tab-visit-time-graph-content" graph_title="Évolution du temps moyen lecture (en secondes)" canvas_id="visit-time-graph" report_key="avg_time_on_page" y_label="Secondes" %} + {% include "misc/graph.part.html" with tab_name="tab-users-graph-content" graph_title="Évolution du nombre de visiteurs uniques" canvas_id="users-graph" report_key="nb_uniq_visitors" y_label="Nombre de visiteurs" %} + {% endif %} {% if cumulative_stats %} {% if display == 'global' %} diff --git a/zds/tutorialv2/tests/tests_views/tests_stats.py b/zds/tutorialv2/tests/tests_views/tests_stats.py index 649e579979..86e1d99e56 100644 --- a/zds/tutorialv2/tests/tests_views/tests_stats.py +++ b/zds/tutorialv2/tests/tests_views/tests_stats.py @@ -155,6 +155,23 @@ def test_access_for_author(self, mock_post): self.assertEqual(resp.context_data["urls"][0].url, self.published.content.get_absolute_url_online()) self.assertEqual(len(resp.context_data["urls"]), 3) + def test_access_for_author_matomo_error(self): + # author can access to stats, and if request to Matomo triggers an error, + # display the page with a message error, and not an ugly error 500 page: + url = reverse( + "content:stats-content", + kwargs={"pk": self.published.content_pk, "slug": self.published.content_public_slug}, + ) + self.client.force_login(self.user_author) + with self.assertLogs(level="ERROR") as error_log: + resp = self.client.get(url) + self.assertTrue( + error_log.output[0].startswith( + "ERROR:zds.tutorialv2.views.statistics:Something failed with Matomo reporting system:" + ) + ) + self.assertEqual(resp.status_code, 200) + @mock.patch("requests.post") def test_access_for_staff(self, mock_post): # staff can access to stats diff --git a/zds/tutorialv2/views/statistics.py b/zds/tutorialv2/views/statistics.py index 99af6aa913..f63fc0caca 100644 --- a/zds/tutorialv2/views/statistics.py +++ b/zds/tutorialv2/views/statistics.py @@ -81,27 +81,19 @@ def get_all_statistics(self, urls, start, end, methods): data_request.update({f"urls[{index}]": urllib.parse.urlencode(request_params)}) - try: - response_matomo = requests.post(url=self.matomo_api_url, data=data_request) - data = response_matomo.json() - if isinstance(data, dict) and data.get("result", "") == "error": - data = {} - self.logger.error(data.get("message", "Something failed with Matomo reporting system.")) - messages.error( - self.request, data.get("message", _(f"Impossible de récupérer les statistiques du site.")) - ) - + response_matomo = requests.post(url=self.matomo_api_url, data=data_request) + data = response_matomo.json() + if isinstance(data, dict) and data.get("result", "") == "error": + raise Exception(self.logger.error, data.get("message", _("Pas de message d'erreur"))) + else: for index, method_url in enumerate(itertools.product(methods, urls)): + if isinstance(data[index], dict) and data[index].get("result", "") == "error": + raise Exception(self.logger.error, data[index].get("message", _("Pas de message d'erreur"))) + method = method_url[0] data_structured[method].append(data[index]) return data_structured - except Exception: - data = {} - self.logger.exception(f"Something failed with Matomo reporting system.") - messages.error(self.request, _(f"Impossible de récupérer les statistiques du site.")) - - return data @staticmethod def get_stat_metrics(data, metric_name): @@ -214,40 +206,47 @@ def get_context_data(self, **kwargs): keywords = {} report_field = [("nb_uniq_visitors", False), ("nb_hits", False), ("avg_time_on_page", True)] - # Each function sends only one bulk request for all the urls - # Each variable is a list of dictionnaries (one for each url) - all = self.get_all_statistics( - urls, - start_date, - end_date, - ["Referrers.getReferrerType", "Referrers.getWebsites", "Referrers.getKeywords", "Actions.getPageUrl"], - ) - - all_stats = all["Actions.getPageUrl"] - all_ref_websites = all["Referrers.getWebsites"] - all_ref_types = all["Referrers.getReferrerType"] - all_ref_keyword = all["Referrers.getKeywords"] - - for index, url in enumerate(urls): - cumul_stats = self.get_cumulative(all_stats[index]) - reports[url] = {} - cumulative_stats[url] = {} - - for item, is_avg in report_field: - reports[url][item] = self.get_stat_metrics(all_stats[index], item) - if is_avg: - cumulative_stats[url][item] = 0 - if cumul_stats.get("total") > 0: - cumulative_stats[url][item] = cumul_stats.get(item, 0) / cumul_stats.get("total") - else: - cumulative_stats[url][item] = cumul_stats.get(item, 0) + try: + # Each function sends only one bulk request for all the urls + # Each variable is a list of dictionnaries (one for each url) + all_statistics = self.get_all_statistics( + urls, + start_date, + end_date, + ["Referrers.getReferrerType", "Referrers.getWebsites", "Referrers.getKeywords", "Actions.getPageUrl"], + ) + except Exception as e: + all_statistics = {} + logger_method, msg = e.args + logger_method(f"Something failed with Matomo reporting system: {msg}") + messages.error(self.request, _("Impossible de récupérer les statistiques du site ({}).").format(msg)) + + if all_statistics != {}: + all_stats = all_statistics["Actions.getPageUrl"] + all_ref_websites = all_statistics["Referrers.getWebsites"] + all_ref_types = all_statistics["Referrers.getReferrerType"] + all_ref_keyword = all_statistics["Referrers.getKeywords"] + + for index, url in enumerate(urls): + cumul_stats = self.get_cumulative(all_stats[index]) + reports[url] = {} + cumulative_stats[url] = {} + + for item, is_avg in report_field: + reports[url][item] = self.get_stat_metrics(all_stats[index], item) + if is_avg: + cumulative_stats[url][item] = 0 + if cumul_stats.get("total") > 0: + cumulative_stats[url][item] = cumul_stats.get(item, 0) / cumul_stats.get("total") + else: + cumulative_stats[url][item] = cumul_stats.get(item, 0) - referrers = self.merge_ref_to_data(referrers, self.get_ref_metrics(all_ref_websites[index])) - type_referrers = self.merge_ref_to_data(type_referrers, self.get_ref_metrics(all_ref_types[index])) - keywords = self.merge_ref_to_data(keywords, self.get_ref_metrics(all_ref_keyword[index])) + referrers = self.merge_ref_to_data(referrers, self.get_ref_metrics(all_ref_websites[index])) + type_referrers = self.merge_ref_to_data(type_referrers, self.get_ref_metrics(all_ref_types[index])) + keywords = self.merge_ref_to_data(keywords, self.get_ref_metrics(all_ref_keyword[index])) - if display_mode.lower() == "global": - reports = {NamedUrl(display_mode, "", 0): self.merge_report_to_global(reports, report_field)} + if display_mode.lower() == "global": + reports = {NamedUrl(display_mode, "", 0): self.merge_report_to_global(reports, report_field)} context.update( { From 9f7dc619d2604715d7f8cc2420aee97442f12dfa Mon Sep 17 00:00:00 2001 From: Arnaud-D <35631001+Arnaud-D@users.noreply.github.com> Date: Sat, 30 Oct 2021 11:43:22 +0200 Subject: [PATCH 02/23] =?UTF-8?q?Corrige=20deux=20tests=20mal=20nomm=C3=A9?= =?UTF-8?q?s=20et=20donc=20non=20d=C3=A9couverts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- zds/tutorialv2/tests/tests_views/tests_content.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/zds/tutorialv2/tests/tests_views/tests_content.py b/zds/tutorialv2/tests/tests_views/tests_content.py index 32e0d8fb55..411ba5cd8b 100644 --- a/zds/tutorialv2/tests/tests_views/tests_content.py +++ b/zds/tutorialv2/tests/tests_views/tests_content.py @@ -1648,7 +1648,7 @@ def test_import_in_existing_content(self): # clean up os.remove(draft_zip_path) - def import_with_bad_title(self): + def test_import_with_bad_title(self): """Tests an error case that happen when someone sends an archive that modify the content title with a string that cannont be properly slugified""" new_article = PublishableContentFactory(type="ARTICLE", title="extension", authors=[self.user_author]) @@ -3270,7 +3270,7 @@ def test_publication_make_extra_contents(self): if extra == "md": continue result = self.client.get(published.get_absolute_url_to_extra_content(extra)) - self.assertEqual(result.status_code, 200, msg="Could not read {} export".format(extra)) + self.assertEqual(result.status_code, 200, msg=f"Could not read {extra} export") def test_publication_give_pubdate_if_no_major(self): """if a content has never been published and `is_major` is not checked, still gives a publication date""" @@ -3352,7 +3352,7 @@ def test_publication_give_pubdate_if_no_major(self): tuto = PublishableContent.objects.get(pk=tuto.pk) self.assertEqual(tuto.pubdate, current_pubdate) # `is_major` in False → no update of the publication date - def no_form_not_allowed(self): + def test_no_form_not_allowed(self): """Check that author cannot access to form that he is not allowed to in the creation process, because - The container already have child container and author ask to add a child extract ; - The container already contains child extract and author ask to add a container.""" From 9caed20b4116d1de7ff63c9a7efa386c1339d768 Mon Sep 17 00:00:00 2001 From: Arnaud-D <35631001+Arnaud-D@users.noreply.github.com> Date: Sun, 31 Oct 2021 09:43:07 +0100 Subject: [PATCH 03/23] =?UTF-8?q?Corrige=20un=20test=20qui=20n'=C3=A9tait?= =?UTF-8?q?=20pas=20jou=C3=A9=20pr=C3=A9c=C3=A9demment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- zds/tutorialv2/tests/tests_views/tests_content.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/zds/tutorialv2/tests/tests_views/tests_content.py b/zds/tutorialv2/tests/tests_views/tests_content.py index 411ba5cd8b..1aa28ac807 100644 --- a/zds/tutorialv2/tests/tests_views/tests_content.py +++ b/zds/tutorialv2/tests/tests_views/tests_content.py @@ -1651,14 +1651,13 @@ def test_import_in_existing_content(self): def test_import_with_bad_title(self): """Tests an error case that happen when someone sends an archive that modify the content title with a string that cannont be properly slugified""" - new_article = PublishableContentFactory(type="ARTICLE", title="extension", authors=[self.user_author]) + new_article = PublishableContentFactory(type="ARTICLE", title="extension", author_list=[self.user_author]) self.client.force_login(self.user_author) archive_path = settings.BASE_DIR / "fixtures" / "tuto" / "BadArchive.zip" answer = self.client.post( reverse("content:import", args=[new_article.pk, new_article.slug]), { "archive": archive_path.open("rb"), - "image_archive": None, "msg_commit": "let it go, let it goooooooo ! can't hold it back anymoooooore!", }, ) From f3080ee475122fdd14fd502fbd2aff964f6a356c Mon Sep 17 00:00:00 2001 From: Arnaud-D <35631001+Arnaud-D@users.noreply.github.com> Date: Sun, 31 Oct 2021 20:48:16 +0100 Subject: [PATCH 04/23] Retire le champ de recherche (inactif) sur les profils (#6201) --- templates/member/profile.html | 8 -------- 1 file changed, 8 deletions(-) diff --git a/templates/member/profile.html b/templates/member/profile.html index f31bffab95..ad8ced65e3 100644 --- a/templates/member/profile.html +++ b/templates/member/profile.html @@ -119,14 +119,6 @@

{% endfor %}
- -
+ + {# Checks if it's an IPV6 to show the members from the same IPV6 network #} + {% if ":" in ip %} +

+ {% blocktrans %} + Liste des membres dont la dernière IP connue fait partie du bloc {{ network_ip }} + {% endblocktrans %} +

+ +
+ +
+ +

+ En IPv6, les adresses sont attribuées par bloc d'IP. Un bot de spam peut donc facilement changer d'adresse IP au sein de ce bloc. Sont affichés ici tous les membres dont l'IPv6 fait partie du même bloc que l'IP demandée. +

+ {% endif %} {% endblock %} diff --git a/zds/member/tests/tests_views.py b/zds/member/tests/tests_views.py index 0cafd05363..d2d61071f3 100644 --- a/zds/member/tests/tests_views.py +++ b/zds/member/tests/tests_views.py @@ -14,6 +14,7 @@ from django.utils.html import escape from django.utils.translation import gettext_lazy as _ +from zds.member.views import member_from_ip from zds.notification.models import TopicAnswerSubscription from zds.member.factories import ( ProfileFactory, @@ -1723,3 +1724,97 @@ def send_messages(messages): ) self.assertEqual(result.status_code, 200) self.assertIn(escape("Impossible d'envoyer l'email."), result.content.decode("utf-8")) + + +class IpListingsTests(TestCase): + """Test the member_from_ip function : listing users from a same IPV4/IPV6 address or same IPV6 network.""" + + def setUp(self) -> None: + self.staff = StaffProfileFactory().user + self.regular_user = ProfileFactory() + + self.user_ipv4_same_ip_1 = ProfileFactory(last_ip_address="155.128.92.54") + self.user_ipv4_same_ip_1.user.username = "user_ipv4_same_ip_1" + self.user_ipv4_same_ip_1.user.save() + + self.user_ipv4_same_ip_2 = ProfileFactory(last_ip_address="155.128.92.54") + self.user_ipv4_same_ip_2.user.username = "user_ipv4_same_ip_2" + self.user_ipv4_same_ip_2.user.save() + + self.user_ipv4_different_ip = ProfileFactory(last_ip_address="155.128.92.55") + self.user_ipv4_different_ip.user.username = "user_ipv4_different_ip" + self.user_ipv4_different_ip.user.save() + + self.user_ipv6_same_ip_1 = ProfileFactory(last_ip_address="2001:8f8:1425:60a0:7981:9852:1493:3721") + self.user_ipv6_same_ip_1.user.username = "user_ipv6_same_ip_1" + self.user_ipv6_same_ip_1.user.save() + + self.user_ipv6_same_ip_2 = ProfileFactory(last_ip_address="2001:8f8:1425:60a0:7981:9852:1493:3721") + self.user_ipv6_same_ip_2.user.username = "user_ipv6_same_ip_2" + self.user_ipv6_same_ip_2.user.save() + + self.user_ipv6_same_network = ProfileFactory(last_ip_address="2001:8f8:1425:60a0:9852:7981:3721:1493") + self.user_ipv6_same_network.user.username = "user_ipv6_same_network" + self.user_ipv6_same_network.user.save() + + self.user_ipv6_different_network = ProfileFactory(last_ip_address="8f8:60a0:3721:1425:7981:1493:2001:9852") + self.user_ipv6_different_network.user.username = "user_ipv6_different_network" + self.user_ipv6_different_network.user.save() + + def test_same_ipv4(self) -> None: + self.client.force_login(self.staff) + response = self.client.get(reverse(member_from_ip, args=[self.user_ipv4_same_ip_1.last_ip_address])) + self.assertContains(response, self.user_ipv4_same_ip_1.user.username) + self.assertContains(response, self.user_ipv4_same_ip_2.user.username) + self.assertContains(response, self.user_ipv4_same_ip_1.last_ip_address) + self.assertNotContains(response, self.user_ipv4_different_ip.user.username) + + def test_different_ipv4(self) -> None: + self.client.force_login(self.staff) + response = self.client.get(reverse(member_from_ip, args=[self.user_ipv4_different_ip.last_ip_address])) + self.assertContains(response, self.user_ipv4_different_ip.user.username) + self.assertContains(response, self.user_ipv4_different_ip.last_ip_address) + self.assertNotContains(response, self.user_ipv6_same_ip_1.user.username) + + def test_same_ipv6_and_same_ipv6_network(self) -> None: + self.client.force_login(self.staff) + response = self.client.get(reverse(member_from_ip, args=[self.user_ipv6_same_ip_1.last_ip_address])) + self.assertContains(response, self.user_ipv6_same_ip_1.user.username) + self.assertContains(response, self.user_ipv6_same_ip_2.user.username) + self.assertContains(response, self.user_ipv6_same_network.user.username) + self.assertNotContains(response, self.user_ipv6_different_network.user.username) + + def test_same_ipv6_network_but_different_ip(self) -> None: + self.client.force_login(self.staff) + response = self.client.get(reverse(member_from_ip, args=[self.user_ipv6_same_network.last_ip_address])) + self.assertContains(response, self.user_ipv6_same_network.user.username) + self.assertContains(response, self.user_ipv6_same_ip_1.user.username) + self.assertContains(response, self.user_ipv6_same_ip_2.user.username) + self.assertNotContains(response, self.user_ipv6_different_network.user.username) + + def test_different_ipv6_network(self) -> None: + self.client.force_login(self.staff) + response = self.client.get(reverse(member_from_ip, args=[self.user_ipv6_different_network.last_ip_address])) + self.assertContains(response, self.user_ipv6_different_network.user.username) + self.assertNotContains(response, self.user_ipv6_same_ip_1.user.username) + self.assertNotContains(response, self.user_ipv6_same_ip_2.user.username) + self.assertNotContains(response, self.user_ipv6_same_network.user.username) + + def test_access_rights_to_ip_page_as_regular_user(self) -> None: + self.client.force_login(self.regular_user.user) + response = self.client.get(reverse(member_from_ip, args=["0.0.0.0"])) + self.assertEqual(response.status_code, 403) + + def test_access_rights_to_ip_page_as_anonymous(self) -> None: + response = self.client.get(reverse(member_from_ip, args=["0.0.0.0"])) + self.assertEqual(response.status_code, 302) + + def test_access_rights_to_ip_page_as_staff(self) -> None: + self.client.force_login(self.staff) + response = self.client.get(reverse(member_from_ip, args=["0.0.0.0"])) + self.assertEqual(response.status_code, 200) + + def test_template_used_by_ip_page(self) -> None: + self.client.force_login(self.staff) + response = self.client.get(reverse(member_from_ip, args=["0.0.0.0"])) + self.assertTemplateUsed(response, "member/admin/memberip.html") diff --git a/zds/member/views.py b/zds/member/views.py index aff1c6cf02..bd237803fe 100644 --- a/zds/member/views.py +++ b/zds/member/views.py @@ -1,3 +1,4 @@ +import ipaddress import uuid from datetime import datetime, timedelta from urllib.parse import unquote @@ -1457,10 +1458,20 @@ def settings_promote(request, user_pk): @login_required @permission_required("member.change_profile", raise_exception=True) def member_from_ip(request, ip_address): - """List users connected from a particular IP.""" + """List users connected from a particular IP, and an IPV6 subnetwork.""" members = Profile.objects.filter(last_ip_address=ip_address).order_by("-last_visit") - return render(request, "member/admin/memberip.html", {"members": members, "ip": ip_address}) + members_and_ip = {"members": members, "ip": ip_address} + + if ":" in ip_address: # Check if it's an IPV6 + network_ip = ipaddress.ip_network(ip_address + "/64", strict=False).network_address # Get the network / block + # Remove the additional ":" at the end of the network adresse, so we can filter the IP adresses on this network + network_ip = str(network_ip)[:-1] + network_members = Profile.objects.filter(last_ip_address__startswith=network_ip).order_by("-last_visit") + members_and_ip["network_members"] = network_members + members_and_ip["network_ip"] = network_ip + + return render(request, "member/admin/memberip.html", members_and_ip) @login_required From 188e5bc668422d9b40cd4ee0bd29167d0cf1648c Mon Sep 17 00:00:00 2001 From: "Ph. SW" <5911232+philippemilink@users.noreply.github.com> Date: Sun, 28 Nov 2021 17:09:57 +0100 Subject: [PATCH 15/23] =?UTF-8?q?Pr=C3=A9cise=20que=20changer=20une=20imag?= =?UTF-8?q?e=20ne=20la=20changera=20pas=20l=C3=A0=20o=C3=B9=20elle=20est?= =?UTF-8?q?=20d=C3=A9j=C3=A0=20utilis=C3=A9e=20(#6209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/source/back-end/gallery.rst | 12 ++++++------ zds/gallery/forms.py | 3 +++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/doc/source/back-end/gallery.rst b/doc/source/back-end/gallery.rst index 4ec385d7a1..3a93d1b236 100644 --- a/doc/source/back-end/gallery.rst +++ b/doc/source/back-end/gallery.rst @@ -24,7 +24,7 @@ Il est ensuite possible d'uploader des images via le menu de gauche : Liens permettant d'uploader des images -Via celui-ci, on peut importer des archives contenant des images (au format ZIP) ou des images seules. Dans ce dernier cas, le formulaire d'*upload* est le suivant : +Via celui-ci, on peut importer des archives (au format ZIP) contenant des images ou des images seules. Dans ce dernier cas, le formulaire d'*upload* est le suivant : .. figure:: ../images/gallery/nouvelle-image.png :align: center @@ -33,6 +33,9 @@ Via celui-ci, on peut importer des archives contenant des images (au format ZIP) Comme on peut le voir, chaque image doit posséder au minimum un titre et peut posséder une légende, qui sera employée par la suite. Il est donc conseillé de remplir également ce second champ, bien que ce ne soit pas obligatoire. Quant à l'image elle-même, sa taille ne peut pas excéder 1024 Kio. +.. attention:: + Le titre de l'image n'entre pas en compte dans le nommage de l'image une fois cette dernière téléchargée. Afin de réduire le risque de rencontrer des conflits de noms de fichiers, ces derniers sont hashés. + Une fois l'image uploadée, il est possible d'y effectuer différentes actions sur la page qui lui est spécifique : .. figure:: ../images/gallery/gestion-image.png @@ -40,14 +43,11 @@ Une fois l'image uploadée, il est possible d'y effectuer différentes actions s Gestion d'une image -Autrement dit, +Autrement dit : -+ En modifier le titre, la légende ou encore l'image en elle-même. À noter que le titre et la légende peuvent être modifiés **sans qu'il ne soit nécessaire** d'uploader une nouvelle image. ++ En modifier le titre, la légende ou encore l'image en elle-même. À noter que le titre et la légende peuvent être modifiés **sans qu'il ne soit nécessaire** d'uploader une nouvelle image. Si une nouvelle version de l'image est uploadée, l'ancienne version de l'image n'est pas supprimée du serveur et reste accessible depuis son URL ; un nouvel identifiant (et donc une nouvelle URL) sera attribué à la nouvelle version de l'image. Cela signifie notamment que mettre à jour une image ne changera pas l'image là où elle a déjà été utilisée (tutoriel, article, message, ...). Ce comportement permet d'éviter que les images utilisées dans des contenus validés soient changées sans repasser par une validation. + Obtenir le code à insérer dans un champ de texte acceptant le Markdown pour l'image en elle-même, sa miniature ou encore la miniature accompagnée du lien vers l'image en taille réelle. -.. attention:: - Le titre de l'image n'entre pas en compte dans le nommage de l'image une fois cette dernière téléchargée. Afin de réduire le risque de rencontrer des conflits de noms de fichiers, ces derniers sont hashés. - Les utilisateurs et leurs droits -------------------------------- diff --git a/zds/gallery/forms.py b/zds/gallery/forms.py index f7f5512c18..6e6c6728dd 100644 --- a/zds/gallery/forms.py +++ b/zds/gallery/forms.py @@ -159,6 +159,9 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["physical"].required = False + self.fields["physical"].label = _( + "Changer l'image (attention : cela ne changera pas l'image là où vous l'avez déjà utilisée ; un nouvel identifiant sera attribué à la nouvelle image)" + ) self.helper = FormHelper() self.helper.form_class = "clearfix" From 9e28a6281cbb171eb5a1c4b452c99342151eea95 Mon Sep 17 00:00:00 2001 From: "Ph. SW" <5911232+philippemilink@users.noreply.github.com> Date: Sun, 28 Nov 2021 17:21:10 +0100 Subject: [PATCH 16/23] =?UTF-8?q?Utilise=20les=20commandes=20make=20dans?= =?UTF-8?q?=20la=20documentation=20pour=20contribuer=20=C3=A0=20ZdS=20(#62?= =?UTF-8?q?14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Et aussi : - supprime l'étape des tests front (il n'y en a pas/plus) - corrige quelques problèmes de formatage Co-authored-by: Arnaud-D <35631001+Arnaud-D@users.noreply.github.com> --- doc/source/contributing.rst | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/doc/source/contributing.rst b/doc/source/contributing.rst index 05aea6f295..1d19067107 100644 --- a/doc/source/contributing.rst +++ b/doc/source/contributing.rst @@ -18,11 +18,10 @@ Contribuer à Zeste De Savoir 1. Créez une branche pour contenir votre travail. 2. Faites vos modifications. 3. Ajoutez un test pour votre modification. Seules les modifications de documentation et les réusinages n'ont pas besoin de nouveaux tests. -4. Assurez-vous que vos tests passent en utilisant la commande ``python manage.py test`` (`voir la documentation `_). Lancer la commande sur tous les tests du site risque de prendre un certain temps et n'est pas nécessaire : les tests seront de toute manière lancés de manière automatisée sur votre *pull request*. -5. Si vous avez fait des modifications du _frontend_, jouez les tests associés : ``yarn test``. -6. Si vous modifiez les modèles (les fichiers ``models.py``), n'oubliez pas de créer les fichiers de migration : ``python manage.py makemigrations``. -7. Poussez votre travail et faites une *pull request*. -8. Si votre travail nécessite des actions spécifiques lors du déploiement, précisez-les dans le corps de votre *pull request*. Elles seront ajoutées au *changelog* par le mainteneur qui effectuera le *merge*. +4. Assurez-vous que vos tests passent en utilisant la commande ``make test-back`` (voyez la `page dédiée <./guides/backend-tests.html>`_ pour plus de détails). Lancer la commande sur tous les tests du site risque de prendre un certain temps et n'est pas nécessaire : les tests seront de toute manière lancés de manière automatisée sur votre *pull request*. +5. Si vous avez modifié les modèles (les fichiers ``models.py``), n'oubliez pas de créer les fichiers de migration : ``python manage.py makemigrations``. +6. Poussez votre travail et faites une *pull request*. +7. Si votre travail nécessite des actions spécifiques lors du déploiement, précisez-les dans le corps de votre *pull request*. Elles seront ajoutées au *changelog* par le mainteneur qui effectuera le *merge*. Quelques bonnes pratiques ------------------------- From a7858d810ac8c7ee22c2a2bd687b9a5d974039ed Mon Sep 17 00:00:00 2001 From: "Ph. SW" <5911232+philippemilink@users.noreply.github.com> Date: Sun, 28 Nov 2021 17:34:46 +0100 Subject: [PATCH 17/23] Grise les boutons de comparaisons de versions si elles sont identiques (#6181) Fix #6105 Co-authored-by: Arnaud-D <35631001+Arnaud-D@users.noreply.github.com> --- templates/tutorialv2/view/content.html | 57 +++++++++++++++++++------- zds/tutorialv2/mixins.py | 2 +- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/templates/tutorialv2/view/content.html b/templates/tutorialv2/view/content.html index 707b006f6c..5c981f7e28 100644 --- a/templates/tutorialv2/view/content.html +++ b/templates/tutorialv2/view/content.html @@ -498,27 +498,54 @@

{# END ONLINE VERSION #} {% if content.sha_public %} -
  • - - {% trans "Comparer avec la version en ligne" %} - -
  • + {# interpreted as: (version and (content.sha_public != version)) or ((not version) and (content.sha_public != content.sha_draft)) #} + {% if version and content.sha_public != version or not version and content.sha_public != content.sha_draft %} +
  • + + {% trans "Comparer avec la version en ligne" %} + +
  • + {% else %} +
  • + + {% trans "Comparer avec la version en ligne (identique)" %} + +
  • + {% endif %} {% endif %} {% if content.sha_beta %} -
  • - - {% trans "Comparer avec la bêta" %} - -
  • + {# interpreted as: (version and (content.sha_beta != version)) or ((not version) and (content.sha_beta != content.sha_draft)) #} + {% if version and content.sha_beta != version or not version and content.sha_beta != content.sha_draft %} +
  • + + {% trans "Comparer avec la bêta" %} + +
  • + {% else %} +
  • + + {% trans "Comparer avec la bêta (identique)" %} + +
  • + {% endif %} {% endif %} {% if content.in_validation %} -
  • - - {% trans "Comparer avec la version en validation" %} - -
  • + {# interpreted as: (version and (validation.version != version)) or ((not version) and (validation.version != content.sha_draft)) #} + {% if version and validation.version != version or not version and validation.version != content.sha_draft %} +
  • + + {% trans "Comparer avec la version en validation" %} + +
  • + {% else %} +
  • + + {% trans "Comparer avec la version en validation (identique)" %} + +
  • + {% endif %} {% endif %} {# HISTORY #} diff --git a/zds/tutorialv2/mixins.py b/zds/tutorialv2/mixins.py index 8b40e99929..83c0ac3c34 100644 --- a/zds/tutorialv2/mixins.py +++ b/zds/tutorialv2/mixins.py @@ -221,7 +221,7 @@ def get_context_data(self, **kwargs): class SingleContentDetailViewMixin(SingleContentViewMixin, DetailView): """ - This enhanced DetailView ensure, + This enhanced DetailView ensures, - by rewriting `get()`, that: * `self.object` contains the result of `get_object()` (as it must be if `get()` is not rewritten) From f061d1b716931824dc6c8fa19f5048ec79067cbe Mon Sep 17 00:00:00 2001 From: "Ph. SW" <5911232+philippemilink@users.noreply.github.com> Date: Sun, 28 Nov 2021 17:47:58 +0100 Subject: [PATCH 18/23] Petites corrections pour l'installation de l'env de dev (#6152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Met à jour pip dans l'environnement virtuel lors de l'installation Est nécessaire pour certaines dépendances en Rust, lorsque le pip utilisé est un peu trop vieux. Fix #6082 * Facilite et documente (légèrement) l'utilisation dans Docker Fix #6118 --- Makefile | 4 +-- doc/source/install/install-docker.rst | 51 +++++++++++++++++++++++++++ doc/source/install/install-linux.rst | 5 ++- scripts/install_zds.sh | 11 +++++- zds/settings/abstract_base/django.py | 7 ++-- zds/settings/dev.py | 3 +- 6 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 doc/source/install/install-docker.rst diff --git a/Makefile b/Makefile index 6cc7aefcb3..ac95851e19 100644 --- a/Makefile +++ b/Makefile @@ -33,10 +33,10 @@ install-back-with-prod: pip install --upgrade -r requirements-dev.txt -r requirements-prod.txt run-back: zmd-check ## Run the backend server - python manage.py runserver --nostatic + python manage.py runserver --nostatic 0.0.0.0:8000 run-back-fast: zmd-check ## Run the backend server in fast mode (no debug toolbar & full browser cache) - python manage.py runserver --settings zds.settings.dev_fast + python manage.py runserver --settings zds.settings.dev_fast 0.0.0.0:8000 lint-back: ## Lint Python code black . --check diff --git a/doc/source/install/install-docker.rst b/doc/source/install/install-docker.rst new file mode 100644 index 0000000000..db01c5b0f6 --- /dev/null +++ b/doc/source/install/install-docker.rst @@ -0,0 +1,51 @@ +======================== +Installation dans Docker +======================== + + +.. note:: + + Par manque de développeurs utilisant Docker au sein de l'équipe de + développement de ZdS, cette section n'est guère fournie. Les instructions + données ici ne le sont qu'à titre indicatif. N'hésitez pas à signaler tout + problème ou proposer des améliorations ! + +L'installation de l'environnement de développement dans Docker se base sur `l'installation sous Linux `_. + +Lancez un shell interactif dans un conteneur basé sur Debian : + +.. sourcecode:: bash + + docker run -it -p 8000:8000 debian:buster + + +Une fois dans le conteneur, saisissez les commandes suivantes : + +.. sourcecode:: bash + + # On se place dans le $HOME + cd + + # Permet d'utiliser correctement apt + DEBIAN_FRONTEND=noninteractive + + # Installez les paquets minimaux requis + apt update + apt install sudo make vim git + + # Clonez le dépôt de ZdS + git clone https://github.com//zds-site.git + cd zds-site/ + + # Installez ZdS + make install-linux + + # Nécessaire pour avoir nvm dans le PATH + source ../.bashrc + + # À partir de maintenant, les commandes ne sont plus spécifiques à l'utilisation de Docker. + + # Lancement de ZdS + source zdsenv/bin/activate + make zmd-start + make run-back diff --git a/doc/source/install/install-linux.rst b/doc/source/install/install-linux.rst index 8efbf3367c..eb750ac80a 100644 --- a/doc/source/install/install-linux.rst +++ b/doc/source/install/install-linux.rst @@ -15,7 +15,10 @@ Pour installer une version locale de ZdS sur GNU/Linux, veuillez suivre les inst - Si malgré tout vous ne parvenez pas à installer ZdS, n'hésitez pas à ouvrir `un sujet sur le forum `_ -Après avoir cloné, installer ZdS sous Linux est relativement simple. En effet, il suffit de lancer la commande suivante (qui se chargera d'installer ce qui est nécessaire, plus d'infos ci-dessous): +Pour installer ZdS, vous aurez besoin d'abord des programmes ``make`` et ``sudo``. S'ils ne sont pas déjà installés sur votre système, ils sont généralement disponibles dans les gestionnaires de paquets sous le même nom. + + +Après avoir cloné le dépôt du code source, installer ZdS sous Linux est relativement simple. En effet, il suffit de lancer la commande suivante (qui se chargera d'installer ce qui est nécessaire, plus d'infos ci-dessous): .. sourcecode:: bash diff --git a/scripts/install_zds.sh b/scripts/install_zds.sh index a0f7d199c7..18c2f1a73f 100755 --- a/scripts/install_zds.sh +++ b/scripts/install_zds.sh @@ -222,7 +222,16 @@ if ! $(_in "--force-skip-activating" $@) && [[ ( $VIRTUAL_ENV == "" || $(realpat echo " - If you don't have other choice, use \`--force-skip-activating\`." exit 1 fi -else + + # Some dependencies (like rust ones) require a recent pip: + print_info "* upgrading pip" + pip install --upgrade pip; exVal=$? + + if [[ $exVal != 0 ]]; then + print_error "!! Failed to upgrade pip" + exit 1 + fi + print_info "!! Add \`$(realpath $ZDS_VENV)\` in your PATH." if [ ! -d $ZDS_VENV ]; then diff --git a/zds/settings/abstract_base/django.py b/zds/settings/abstract_base/django.py index ebbfd9e8bc..ba1f7eb4f9 100644 --- a/zds/settings/abstract_base/django.py +++ b/zds/settings/abstract_base/django.py @@ -7,8 +7,11 @@ from .config import config from .base_dir import BASE_DIR - -INTERNAL_IPS = ("127.0.0.1",) # debug toolbar +# especially for debug toolbar: +INTERNAL_IPS = ( + "127.0.0.1", + "172.17.0.1", # to enable debug toolbar when executed in a Docker container +) DATABASES = { "default": { diff --git a/zds/settings/dev.py b/zds/settings/dev.py index ddc63a6e1d..1f2c2e2239 100644 --- a/zds/settings/dev.py +++ b/zds/settings/dev.py @@ -4,8 +4,7 @@ DEBUG = True -# NOTE: Can be removed once Django 3 is used -ALLOWED_HOSTS = [".localhost", "127.0.0.1", "[::1]"] +ALLOWED_HOSTS = ["*"] # allow everything in case we are in a Docker container INSTALLED_APPS += ( "debug_toolbar", From e8c31db7e3314eb68bdbecc62c5afd1e875e2123 Mon Sep 17 00:00:00 2001 From: Arnaud-D <35631001+Arnaud-D@users.noreply.github.com> Date: Tue, 21 Dec 2021 11:40:43 +0100 Subject: [PATCH 19/23] =?UTF-8?q?Ajoute=20des=20tests=20=C3=A0=20RemoveCon?= =?UTF-8?q?tributor=20(#6193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests_views/tests_removecontributor.py | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 zds/tutorialv2/tests/tests_views/tests_removecontributor.py diff --git a/zds/tutorialv2/tests/tests_views/tests_removecontributor.py b/zds/tutorialv2/tests/tests_views/tests_removecontributor.py new file mode 100644 index 0000000000..27daec1dad --- /dev/null +++ b/zds/tutorialv2/tests/tests_views/tests_removecontributor.py @@ -0,0 +1,137 @@ +from django.test import TestCase +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django.utils.html import escape + +from zds.member.factories import ProfileFactory, StaffProfileFactory +from zds.tutorialv2.factories import PublishableContentFactory +from zds.tutorialv2.models.database import ContentContribution, ContentContributionRole +from zds.tutorialv2.tests import TutorialTestMixin, override_for_contents + + +def create_contribution(role, contributor, content): + contribution = ContentContribution(contribution_role=role, user=contributor, content=content) + contribution.save() + return contribution + + +def create_role(title): + role = ContentContributionRole(title=title) + role.save() + return role + + +@override_for_contents() +class RemoveContributorPermissionTests(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 + self.contributor = ProfileFactory().user + + # Create a contribution role + self.role = create_role("Validateur") + + # Create content + self.content = PublishableContentFactory(author_list=[self.author]) + self.contribution = create_contribution(self.role, self.contributor, self.content) + + # Get information to be reused in tests + self.form_url = reverse("content:remove-contributor", kwargs={"pk": self.content.pk}) + self.login_url = reverse("member-login") + "?next=" + self.form_url + self.content_url = reverse("content:view", kwargs={"pk": self.content.pk, "slug": self.content.slug}) + self.form_data = {"pk_contribution": self.contribution.pk} + + def test_not_authenticated(self): + self.client.logout() + response = self.client.post(self.form_url, self.form_data) + self.assertRedirects(response, self.login_url) + + def test_authenticated_outsider(self): + self.client.force_login(self.outsider) + self.content.type = "TUTORIAL" + self.content.save() + response = self.client.post(self.form_url, self.form_data) + self.assertEqual(response.status_code, 403) + + def test_authenticated_author(self): + self.client.force_login(self.author) + self.content.type = "TUTORIAL" + self.content.save() + response = self.client.post(self.form_url, self.form_data) + self.assertRedirects(response, self.content_url) + + def test_authenticated_staff_tutorial(self): + self.client.force_login(self.staff) + self.content.type = "TUTORIAL" + self.content.save() + response = self.client.post(self.form_url, self.form_data) + self.assertRedirects(response, self.content_url) + + def test_authenticated_staff_article(self): + self.client.force_login(self.staff) + self.content.type = "ARTICLE" + self.content.save() + response = self.client.post(self.form_url, self.form_data) + self.assertRedirects(response, self.content_url) + + def test_authenticated_staff_opinion(self): + self.client.force_login(self.staff) + self.content.type = "OPINION" + self.content.save() + response = self.client.post(self.form_url, self.form_data) + self.assertEqual(response.status_code, 403) + + +class RemoveContributorWorkflowTests(TutorialTestMixin, TestCase): + """Test the workflow of the form, such as validity errors and success messages.""" + + def setUp(self): + # Create entities for the test + self.author = ProfileFactory().user + self.contributor = ProfileFactory().user + self.role = create_role("Validateur") + self.content = PublishableContentFactory(author_list=[self.author]) + self.contribution = create_contribution(self.role, self.contributor, self.content) + + # Get information to be reused in tests + self.form_url = reverse("content:remove-contributor", kwargs={"pk": self.content.pk}) + self.success_message_fragment = _("Vous avez enlevé ") + self.error_message_fragment = _("Les contributeurs sélectionnés n'existent pas.") + + # Log in with an authorized user to perform the tests + self.client.force_login(self.author) + + def test_existing(self): + 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()), []) + + def test_empty(self): + 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]) + + def test_invalid(self): + 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]) + + def test_not_integer(self): + with self.assertRaises(ValueError): + self.client.post(self.form_url, {"pk_contribution": "abcd"}, follow=True) + self.assertEqual(list(ContentContribution.objects.all()), [self.contribution]) + + def test_no_argument(self): + 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]) + + def test_wrong_contribution(self): + 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]) From 85e6b60825da2b68072ce3792f875a47927763e1 Mon Sep 17 00:00:00 2001 From: Arnaud-D <35631001+Arnaud-D@users.noreply.github.com> Date: Tue, 21 Dec 2021 12:01:26 +0100 Subject: [PATCH 20/23] =?UTF-8?q?Ajoute=20des=20tests=20=C3=A0=20AddContri?= =?UTF-8?q?butor=20(#6194)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/tests_views/tests_addcontributor.py | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 zds/tutorialv2/tests/tests_views/tests_addcontributor.py diff --git a/zds/tutorialv2/tests/tests_views/tests_addcontributor.py b/zds/tutorialv2/tests/tests_views/tests_addcontributor.py new file mode 100644 index 0000000000..f2790d1ffc --- /dev/null +++ b/zds/tutorialv2/tests/tests_views/tests_addcontributor.py @@ -0,0 +1,192 @@ +from unittest.mock import patch + +from django.conf import settings +from django.test import TestCase +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django.utils.html import escape + + +from zds.member.factories import ProfileFactory, StaffProfileFactory +from zds.tutorialv2.factories import PublishableContentFactory +from zds.tutorialv2.forms import ContributionForm +from zds.tutorialv2.models.database import ContentContribution, ContentContributionRole +from zds.tutorialv2.tests import TutorialTestMixin, override_for_contents + + +def create_contribution(role, contributor, content): + contribution = ContentContribution(contribution_role=role, user=contributor, content=content) + contribution.save() + return contribution + + +def create_role(title): + role = ContentContributionRole(title=title) + role.save() + return role + + +@override_for_contents() +class AddContributorPermissionTests(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 + self.contributor = ProfileFactory().user + settings.ZDS_APP["member"]["bot_account"] = ProfileFactory().user.username + + # Create content + self.content = PublishableContentFactory(author_list=[self.author]) + self.role = create_role("Contributeur espiègle") + + # Get information to be reused in tests + self.form_url = reverse("content:add-contributor", kwargs={"pk": self.content.pk}) + self.login_url = reverse("member-login") + "?next=" + self.form_url + self.content_url = reverse("content:view", kwargs={"pk": self.content.pk, "slug": self.content.slug}) + self.form_data = {"username": self.contributor, "contribution_role": self.role.pk} + + def test_not_authenticated(self): + self.client.logout() + response = self.client.post(self.form_url, self.form_data) + self.assertRedirects(response, self.login_url) + + def test_authenticated_outsider(self): + self.client.force_login(self.outsider) + self.content.type = "TUTORIAL" + self.content.save() + response = self.client.post(self.form_url, self.form_data) + self.assertEqual(response.status_code, 403) + + def test_authenticated_author(self): + self.client.force_login(self.author) + self.content.type = "TUTORIAL" + self.content.save() + response = self.client.post(self.form_url, self.form_data) + self.assertRedirects(response, self.content_url) + + def test_authenticated_staff_tutorial(self): + self.client.force_login(self.staff) + self.content.type = "TUTORIAL" + self.content.save() + response = self.client.post(self.form_url, self.form_data) + self.assertRedirects(response, self.content_url) + + def test_authenticated_staff_article(self): + self.client.force_login(self.staff) + self.content.type = "ARTICLE" + self.content.save() + response = self.client.post(self.form_url, self.form_data) + self.assertRedirects(response, self.content_url) + + def test_authenticated_staff_opinion(self): + self.client.force_login(self.staff) + self.content.type = "OPINION" + self.content.save() + response = self.client.post(self.form_url, self.form_data) + self.assertEqual(response.status_code, 403) + + +class AddContributorWorkflowTests(TutorialTestMixin, TestCase): + """Test the workflow of the form, such as validity errors and success messages.""" + + def setUp(self): + # Create entities for the test + self.author = ProfileFactory().user + self.contributor = ProfileFactory().user + self.role = create_role("Validateur") + self.content = PublishableContentFactory(author_list=[self.author]) + settings.ZDS_APP["member"]["bot_account"] = ProfileFactory().user.username + + # Get information to be reused in tests + 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!" + + # Log in with an authorized user to perform the tests + self.client.force_login(self.author) + + def test_correct(self): + form_data = { + "username": self.contributor, + "contribution_role": self.role.pk, + "comment": self.comment, + } + self.client.post(self.form_url, form_data, follow=True) + contribution = ContentContribution.objects.filter( + content=self.content.pk, + user=self.contributor, + contribution_role=self.role, + comment=self.comment, + ).first() + self.assertEqual(list(ContentContribution.objects.all()), [contribution]) + + def test_empty_user(self): + form_data = { + "username": "", + "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()), []) + + def test_no_user(self): + 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()), []) + + def test_invalid_user(self): + form_data = { + "username": "this pseudo does not exist", + "contribution_role": self.role.pk, + } + 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()), []) + + def test_author_contributor(self): + form_data = { + "username": self.author, + "contribution_role": self.role.pk, + } + 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()), []) + + def test_empty_role(self): + form_data = { + "username": self.contributor, + "contribution_role": "", + } + response = self.client.post(self.form_url, form_data, follow=True) + self.assertContains( + response, escape(ContributionForm.declared_fields["contribution_role"].error_messages["required"]) + ) + self.assertEqual(list(ContentContribution.objects.all()), []) + + def test_no_role(self): + form_data = { + "username": self.contributor, + } + response = self.client.post(self.form_url, form_data, follow=True) + self.assertContains( + response, escape(ContributionForm.declared_fields["contribution_role"].error_messages["required"]) + ) + self.assertEqual(list(ContentContribution.objects.all()), []) + + def test_invalid_role(self): + form_data = { + "username": self.contributor, + "contribution_role": 3150, # must be an invalid pk, integer or not + } + response = self.client.post(self.form_url, form_data, follow=True) + self.assertContains( + response, escape(ContributionForm.declared_fields["contribution_role"].error_messages["invalid_choice"]) + ) + self.assertEqual(list(ContentContribution.objects.all()), []) From 480a9e19da8ab3f84c36e8ad29f8df3ec4fe0d98 Mon Sep 17 00:00:00 2001 From: Philippe MILINK Date: Sat, 25 Dec 2021 17:40:14 +0100 Subject: [PATCH 21/23] Corrige une typo dans la documentation --- doc/source/install.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/install.rst b/doc/source/install.rst index 77af624dc4..76762a90ca 100644 --- a/doc/source/install.rst +++ b/doc/source/install.rst @@ -27,7 +27,7 @@ Si vous souhaitez terminer d'installer puis démarrer une instance locale de ZdS install/install-* -Les détails concernant la contribution au code du site peuvent être trouvé `ici <./contributing.html>`_. +Les détails concernant la contribution au code du site peuvent être trouvés `ici <./contributing.html>`_. Quelques informations supplémentaires: From 5ea1809cf85dd96d228c73cf03e0ec977b2e8aa9 Mon Sep 17 00:00:00 2001 From: Situphen Date: Mon, 27 Dec 2021 12:20:39 +0100 Subject: [PATCH 22/23] =?UTF-8?q?Mise=20=C3=A0=20jour=20triviale=20des=20d?= =?UTF-8?q?=C3=A9pendances=20Python?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 +- .pre-commit-config.yaml | 2 +- requirements-dev.txt | 10 +++++----- requirements-prod.txt | 6 +++--- requirements.txt | 10 +++++----- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index def1f01fe2..136757b7ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ env: PYTHON_VERSION: "3.7" MARIADB_VERSION: "10.4.10" COVERALLS_VERSION: "2.2.0" - BLACK_VERSION: "21.10b0" # needs to be also updated in requirements-dev.txt and .pre-commit-config.yaml + BLACK_VERSION: "21.12b0" # needs to be also updated in requirements-dev.txt and .pre-commit-config.yaml # As GitHub Action does not allow environment variables # to be used in services definitions, these are only for diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c8adfd10c0..5f8a27b7b5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ repos: - id: pyupgrade args: [--py36-plus] - repo: https://github.com/psf/black - rev: 21.10b0 # needs to be also updated in requirements-dev.txt and .github/workflows/ci.yml + rev: 21.12b0 # needs to be also updated in requirements-dev.txt and .github/workflows/ci.yml hooks: - id: black language_version: python3 diff --git a/requirements-dev.txt b/requirements-dev.txt index 1724d81f90..94e66db865 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,12 +1,12 @@ -r requirements.txt -black==21.10b0 # needs to be also updated in .github/workflows/ci.yml and .pre-commit-config.yaml +black==21.12b0 # needs to be also updated in .github/workflows/ci.yml and .pre-commit-config.yaml colorlog==6.6.0 -django-debug-toolbar==3.2.2 +django-debug-toolbar==3.2.4 django-extensions==3.1.5 -Faker==9.8.1 -pre-commit==2.15.0 +Faker==10.0.0 +pre-commit==2.16.0 PyYAML==6.0 selenium==3.141.0 -Sphinx==4.3.0 +Sphinx==4.3.2 sphinx_rtd_theme==1.0.0 diff --git a/requirements-prod.txt b/requirements-prod.txt index 58a8ff8dbd..c0766fd28b 100644 --- a/requirements-prod.txt +++ b/requirements-prod.txt @@ -1,6 +1,6 @@ -r requirements.txt gunicorn==20.1.0 -mysqlclient==2.0.3 -sentry-sdk==1.4.3 -ujson==4.2.0 +mysqlclient==2.1.0 +sentry-sdk==1.5.1 +ujson==5.1.0 diff --git a/requirements.txt b/requirements.txt index f680f43b2b..a9e7cb0699 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,24 +10,24 @@ django-crispy-forms==1.13.0 django-model-utils==4.2.0 django-munin==0.2.1 django-recaptcha==2.0.6 -Django==2.2.24 +Django==2.2.25 easy-thumbnails==2.8.0 factory-boy==3.2.1 -geoip2==4.4.0 +geoip2==4.5.0 GitPython==3.1.24 homoglyphs==2.0.4 -lxml==4.6.4 +lxml==4.7.1 Pillow==8.4.0 python-memcached==1.59 requests==2.26.0 toml==0.10.2 # Api dependencies -django-cors-headers==3.10.0 +django-cors-headers==3.10.1 django-filter==21.1 django-oauth-toolkit==1.5.0 djangorestframework-xml==2.0.0 -djangorestframework==3.12.4 +djangorestframework==3.13.1 drf-extensions==0.7.1 dry-rest-permissions==0.1.10 drf_yasg==1.20.0 From 44a96f42a841f300c38152e51e9847d7d5668ffb Mon Sep 17 00:00:00 2001 From: Arnaud-D <35631001+Arnaud-D@users.noreply.github.com> Date: Mon, 27 Dec 2021 22:10:09 +0100 Subject: [PATCH 23/23] Importe l'information 'ready_to_publish' depuis les archives (#6203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Prend en compte le champ "ready_to_publish" à l'import * Ajoute un test pour l'import de 'ready_to_publish' * Corrige une coquille dans un test des tutos --- .../tests/tests_views/tests_content.py | 55 ++++++++++++++++++- zds/tutorialv2/views/archives.py | 3 + 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/zds/tutorialv2/tests/tests_views/tests_content.py b/zds/tutorialv2/tests/tests_views/tests_content.py index 1aa28ac807..0b5decb0a8 100644 --- a/zds/tutorialv2/tests/tests_views/tests_content.py +++ b/zds/tutorialv2/tests/tests_views/tests_content.py @@ -1634,7 +1634,7 @@ def test_import_in_existing_content(self): # ensure the content self.assertEqual(versioned.get_introduction(), some_text) - self.assertEqual(versioned.get_introduction(), some_text) + self.assertEqual(versioned.get_conclusion(), some_text) self.assertEqual(len(versioned.children), 1) new_chapter = versioned.children[-1] @@ -1774,6 +1774,59 @@ def test_import_image_with_archive(self): os.remove(draft_zip_path) os.remove(image_zip_path) + def test_import_ready_to_publish(self): + """Test whether the 'ready_to_publish' info from the archive is correctly imported.""" + + # General principle of this test: + # * create an archive by creating a content and exporting it + # * change the 'ready_to_publish' toggles on the content + # * import the archive and check whether we get back to the initial state (i.e. correct import) + + self.client.force_login(self.user_author) + + # Create a content with parts + content = PublishableContentFactory(author_list=[self.user_author]) + versioned = content.load_version() + part1 = ContainerFactory(db_object=content, parent=versioned) + chapter1 = ContainerFactory(db_object=content, parent=part1) + chapter1.ready_to_publish = False + ContainerFactory(db_object=content, parent=part1) # chapter 2 + part2 = ContainerFactory(db_object=content, parent=versioned) + part2.ready_to_publish = False + sha = versioned.repo_update(content.title, content.slug, "introduction", "conclusion") + content.sha_draft = sha + content.save() + + # Download archive of initial state for content + result = self.client.get(reverse("content:download-zip", args=[content.pk, content.slug]), follow=False) + self.assertEqual(result.status_code, 200) + draft_zip_path = os.path.join(tempfile.gettempdir(), "__draft1.zip") + with open(draft_zip_path, "wb") as f: + f.write(result.content) + + # Update readiness of part 2 and part1/chapter1 + # Failure to import this information defaults also to True, this is to make sure. + versioned.children[0].children[0].ready_to_publish = True + versioned.children[1].ready_to_publish = True + sha = versioned.repo_update_top_container(content.title, content.slug, "introduction", "conclusion") + content.sha_draft = sha + content.save() + + # Import archive + result = self.client.post( + reverse("content:import", args=[content.pk, content.slug]), + {"archive": open(draft_zip_path, "rb")}, + ) + self.assertEqual(result.status_code, 302) + + # Check override of previous modifications through the import + content = PublishableContent.objects.get(pk=content.pk) # reload from database + versioned = content.load_version() + self.assertTrue(versioned.children[0].ready_to_publish) + self.assertTrue(versioned.children[0].children[1].ready_to_publish) + self.assertFalse(versioned.children[0].children[0].ready_to_publish) + self.assertFalse(versioned.children[1].ready_to_publish) + def test_display_history(self): """Test DisplayHistory view""" diff --git a/zds/tutorialv2/views/archives.py b/zds/tutorialv2/views/archives.py index 6f5c7600fc..91ded70442 100644 --- a/zds/tutorialv2/views/archives.py +++ b/zds/tutorialv2/views/archives.py @@ -198,6 +198,8 @@ def update_from_new_version_in_zip(copy_to, copy_from, zip_file): raise BadArchiveError(_(f"Le fichier « {child.conclusion} » n'est pas encodé en UTF-8")) copy_to.repo_add_container(child.title, introduction, conclusion, do_commit=False, slug=child.slug) + copy_to.children[-1].ready_to_publish = child.ready_to_publish + copy_to.repo_update(copy_to.title, introduction, conclusion, do_commit=False) UpdateContentWithArchive.update_from_new_version_in_zip(copy_to.children[-1], child, zip_file) elif isinstance(child, Extract): @@ -381,6 +383,7 @@ def form_valid(self, form): if new_version.conclusion: conclusion = str(zfile.read(new_version.conclusion), "utf-8") + versioned.ready_to_publish = new_version.ready_to_publish versioned.repo_update_top_container( new_version.title, new_version.slug, introduction, conclusion, do_commit=False )