- {% 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 %}
+
+
+
+
+ {% for member in network_members %}
+
{% include "misc/member_item.part.html" with member=member info=member.last_visit|format_date:True avatar=True %}
+ {% endfor %}
+
+
+
+
+ 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 %}
-
+ {# 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 %}
+
+ {# 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 %}
+
+ {# 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 %}
+