From 29b75e0b0db136dcb08c72c350c53111e428fcbf Mon Sep 17 00:00:00 2001 From: Arnaud-D <35631001+Arnaud-D@users.noreply.github.com> Date: Mon, 9 Sep 2024 19:54:00 +0200 Subject: [PATCH] =?UTF-8?q?Permet=20d'=C3=A9crire=20des=20billets=20ou=20a?= =?UTF-8?q?rticles=20avec=20une=20structure=20de=20tutoriel=20(#6550)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Permet d'écrire des billets ou articles avec une structure de tutoriel * Refactorise les patterns d'URL pour les tutoriels * Fix can_add_container * Fix get_list_of_chapters * Ajoute les tests de route basiques pour les tutoriels * Mise à jour documentation * Modifie la génération des fixtures --- doc/source/back-end/contents.rst | 39 ++++------ zds/tutorialv2/models/__init__.py | 7 -- zds/tutorialv2/models/versioned.py | 39 ++++------ zds/tutorialv2/tests/tests_opinion_views.py | 8 -- zds/tutorialv2/tests/tests_routes.py | 78 +++++++++++++++++++ zds/tutorialv2/urls/urls_articles.py | 25 ++++-- zds/tutorialv2/urls/urls_opinions.py | 25 ++++-- zds/tutorialv2/urls/urls_tutorials.py | 34 ++++---- .../management/commands/load_fixtures.py | 69 ++++++++-------- 9 files changed, 200 insertions(+), 124 deletions(-) create mode 100644 zds/tutorialv2/tests/tests_routes.py diff --git a/doc/source/back-end/contents.rst b/doc/source/back-end/contents.rst index 0c1d11956e..dd98c3422e 100644 --- a/doc/source/back-end/contents.rst +++ b/doc/source/back-end/contents.rst @@ -1,6 +1,6 @@ -========================= -Les tutoriels et articles -========================= +================================ +Les tutoriels, articles, billets +================================ Vocabulaire et définitions ========================== @@ -50,38 +50,36 @@ Un contenu Un **contenu** est un agencement particulier de conteneurs et d'extraits. Il est décrit par des métadonnées (*metadata*), détaillées -`ici <./contents_manifest.html>`__. Une de ces métadonnées est le type : article -ou tutoriel. Leur visée pédagogique diffère, mais aussi leur structure : un -article ne peut comporter de conteneurs, seulement des extraits, ce qui n'est -pas le cas d'un tutoriel. +`ici <./contents_manifest.html>`__. Une de ces métadonnées est le type : article, tutoriel ou billet. -Les exemples suivants devraient éclairer ces notions. +Techniquement, le type n'a pas d'influence sur la structure du contenu. On rencontre cependant +des structures typiques selon la longueur du contenu. -Communément appelé « mini-tutoriel » : +Structure typique pour un contenu court : .. sourcecode:: none - + Tutoriel + + Contenu + Section + Section + Section -Communément appelé « moyen-tutoriel » : +Structure typique pour un contenu de taille moyenne : .. sourcecode:: none - + Tutoriel + + Contenu + Partie + Section + Partie + Section + Section -Communément appelé « big-tutoriel » : +Structure typique pour un contenu long : .. sourcecode:: none - + Tutoriel + + Contenu + Partie + Chapitre + Section @@ -93,7 +91,7 @@ Communément appelé « big-tutoriel » : + Section + Section -On peut aussi faire un mélange des conteneurs : +Des structures plus complexes sont possibles, avec des niveaux de conteneurs différents selon les parties : .. sourcecode:: none @@ -107,7 +105,7 @@ On peut aussi faire un mélange des conteneurs : + Chapitre + Section -Mais pas de conteneurs et d'extraits adjacents : +Il n'est pas possible d'avoir à la fois des conteneurs et des extraits au même niveau : .. sourcecode:: none @@ -122,15 +120,6 @@ Mais pas de conteneurs et d'extraits adjacents : + Section + Section /!\ Impossible ! -Pour finir, un article. Même structure qu'un mini-tutoriel, mais vocation -pédagogique différente : - -.. sourcecode:: none - - + Article - + Section - + Section - D'autre part, tout contenu se voit attribuer un identifiant unique sous la forme d'un entier naturel (en anglais : *pk*, pour *primary key*). Cet identifiant apparaît dans les URL, qui sont de la forme diff --git a/zds/tutorialv2/models/__init__.py b/zds/tutorialv2/models/__init__.py index b636226aa8..e96c506d1f 100644 --- a/zds/tutorialv2/models/__init__.py +++ b/zds/tutorialv2/models/__init__.py @@ -8,7 +8,6 @@ # verbose_name_plural User-friendly pluralized type name # category_name User-friendly category name which contains this content # requires_validation Boolean; whether this content has to be validated before publication - # single_container Boolean; True if the content is a single container # beta Boolean; True if the content can be in beta { "name": "TUTORIAL", @@ -16,7 +15,6 @@ "verbose_name_plural": "tutoriels", "category_name": "tutoriel", "requires_validation": True, - "single_container": False, "beta": True, }, { @@ -25,7 +23,6 @@ "verbose_name_plural": "articles", "category_name": "article", "requires_validation": True, - "single_container": True, "beta": True, }, { @@ -34,7 +31,6 @@ "verbose_name_plural": "billets", "category_name": "tribune", "requires_validation": False, - "single_container": True, "beta": False, }, ) @@ -49,9 +45,6 @@ # a list of contents which have to be validated before publication CONTENT_TYPES_REQUIRING_VALIDATION = [content["name"] for content in CONTENT_TYPES if content["requires_validation"]] -# a list of contents which have one big container containing at least one small container -SINGLE_CONTAINER_CONTENT_TYPES = [content["name"] for content in CONTENT_TYPES if content["single_container"]] - # a list of contents which can be in beta CONTENT_TYPES_BETA = [content["name"] for content in CONTENT_TYPES if content["beta"]] diff --git a/zds/tutorialv2/models/versioned.py b/zds/tutorialv2/models/versioned.py index b43753a1f7..1e062eeef2 100644 --- a/zds/tutorialv2/models/versioned.py +++ b/zds/tutorialv2/models/versioned.py @@ -15,7 +15,7 @@ from django.template.loader import render_to_string from zds.tutorialv2.models.mixins import TemplatableContentModelMixin -from zds.tutorialv2.models import SINGLE_CONTAINER_CONTENT_TYPES, CONTENT_TYPES_REQUIRING_VALIDATION +from zds.tutorialv2.models import CONTENT_TYPES_REQUIRING_VALIDATION from zds.tutorialv2.utils import default_slug_pool, export_content, get_commit_author, InvalidOperationError from zds.tutorialv2.utils import get_blob from zds.utils.validators import InvalidSlugError, check_slug @@ -258,16 +258,14 @@ def long_slug(self): long_slug = self.parent.long_slug() + "__" return long_slug + self.slug - def can_add_container(self): + def can_add_container(self) -> bool: """ - :return: ``True`` if this container accepts child containers, ``False`` otherwise - :rtype: bool + Return `True` if adding child containers is allowed. + Adding subcontainers is forbidden: + * if the container already has extracts as children, + * or if the limit of nested containers has been reached. """ - if not self.has_extracts(): - if self.get_tree_depth() < settings.ZDS_APP["content"]["max_tree_depth"] - 1: - if not self.top_container().type in SINGLE_CONTAINER_CONTENT_TYPES: - return True - return False + return not self.has_extracts() and self.get_tree_depth() < settings.ZDS_APP["content"]["max_tree_depth"] - 1 def can_add_extract(self): """Return ``True`` if this container can contain extracts, i.e doesn't @@ -1331,21 +1329,16 @@ def get_prod_path(self, relative=False, file_ext="html"): return path - def get_list_of_chapters(self): - """ - :return: a list of chapters (Container which contains Extracts) in the reading order - :rtype: list[Container] - """ + def get_list_of_chapters(self) -> list[Container]: continuous_list = [] - if self.type not in SINGLE_CONTAINER_CONTENT_TYPES: # cannot be paginated - if len(self.children) != 0 and isinstance(self.children[0], Container): # children must be Containers! - for child in self.children: - if len(child.children) != 0: - if isinstance(child.children[0], Extract): - continuous_list.append(child) # it contains Extract, this is a chapter, so paginated - else: # Container is a part - for sub_child in child.children: - continuous_list.append(sub_child) # even if empty `sub_child.childreen`, it's chapter + if len(self.children) != 0 and isinstance(self.children[0], Container): # children must be Containers! + for child in self.children: + if len(child.children) != 0: + if isinstance(child.children[0], Extract): + continuous_list.append(child) # it contains Extract, this is a chapter, so paginated + else: # Container is a part + for sub_child in child.children: + continuous_list.append(sub_child) # even if `sub_child.children` is empty, it's a chapter return continuous_list def get_json(self): diff --git a/zds/tutorialv2/tests/tests_opinion_views.py b/zds/tutorialv2/tests/tests_opinion_views.py index 418ca9ada4..56b2a6194d 100644 --- a/zds/tutorialv2/tests/tests_opinion_views.py +++ b/zds/tutorialv2/tests/tests_opinion_views.py @@ -177,14 +177,6 @@ def test_accessible_ui_for_author(self): self.assertNotContains(resp, "{}?subcategory=".format(reverse("publication:list"))) self.assertContains(resp, "{}?category=".format(reverse("opinion:list"))) - def test_no_help_for_tribune(self): - self.client.force_login(self.user_author) - - def test_help_for_article(self): - self.client.force_login(self.user_author) - resp = self.client.get(reverse("content:create-content", kwargs={"created_content_type": "ARTICLE"})) - self.assertEqual(200, resp.status_code) - def test_opinion_publication_staff(self): """ Test the publication of PublishableContent where type is OPINION (with staff). diff --git a/zds/tutorialv2/tests/tests_routes.py b/zds/tutorialv2/tests/tests_routes.py new file mode 100644 index 0000000000..1418a764ce --- /dev/null +++ b/zds/tutorialv2/tests/tests_routes.py @@ -0,0 +1,78 @@ +from django.test import TestCase +from django.urls import reverse + +from zds.tutorialv2.models.database import PublishableContent +from zds.tutorialv2.publication_utils import publish_content +from zds.tutorialv2.tests import override_for_contents, TutorialTestMixin +from zds.tutorialv2.tests.factories import PublishableContentFactory, ContainerFactory + + +def mock_publication_process(content: PublishableContent): + published = publish_content(content, content.load_version()) + content.sha_public = content.sha_draft + content.public_version = published + content.save() + + +class BasicRouteTestsMixin(TutorialTestMixin): + def setUp(self): + self.build_content(self.content_type) + mock_publication_process(self.content) + + def build_content(self, type): + self.content = PublishableContentFactory() + self.content.type = type + self.content.save() + content_versioned = self.content.load_version() + self.container = ContainerFactory(parent=content_versioned, db_object=self.content) + self.subcontainer = ContainerFactory(parent=self.container, db_object=self.content) + + def test_view(self): + route_args = { + "pk": self.content.pk, + "slug": self.content.slug, + } + self.assert_can_be_reached(self.view_name, route_args) + + def test_view_container_one_level_deep(self): + route_args = { + "pk": self.content.pk, + "slug": self.content.slug, + "container_slug": self.container.slug, + } + self.assert_can_be_reached(self.container_view_name, route_args) + + def test_view_container_two_level_deep(self): + route_args = { + "pk": self.content.pk, + "slug": self.content.slug, + "parent_container_slug": self.container.slug, + "container_slug": self.subcontainer.slug, + } + self.assert_can_be_reached(self.container_view_name, route_args) + + def assert_can_be_reached(self, route, route_args): + url = reverse(route, kwargs=route_args) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + +@override_for_contents() +class OpinionDisplayRoutesTests(BasicRouteTestsMixin, TestCase): + content_type = "OPINION" + view_name = "opinion:view" + container_view_name = "opinion:view-container" + + +@override_for_contents() +class ArticlesDisplayRoutesTests(BasicRouteTestsMixin, TestCase): + content_type = "ARTICLE" + view_name = "article:view" + container_view_name = "article:view-container" + + +@override_for_contents() +class TutorialsDisplayRoutesTests(BasicRouteTestsMixin, TestCase): + content_type = "TUTORIAL" + view_name = "tutorial:view" + container_view_name = "tutorial:view-container" diff --git a/zds/tutorialv2/urls/urls_articles.py b/zds/tutorialv2/urls/urls_articles.py index 98afa89715..83d1426539 100644 --- a/zds/tutorialv2/urls/urls_articles.py +++ b/zds/tutorialv2/urls/urls_articles.py @@ -4,22 +4,33 @@ from zds.tutorialv2.views.contributors import ContentOfContributors from zds.tutorialv2.views.lists import TagsListView, ContentOfAuthor from zds.tutorialv2.views.download_online import DownloadOnlineArticle -from zds.tutorialv2.views.display import ArticleOnlineView +from zds.tutorialv2.views.display import ArticleOnlineView, ContainerOnlineView from zds.tutorialv2.feeds import LastArticlesFeedRSS, LastArticlesFeedATOM -urlpatterns = [ - # Flux +feed_patterns = [ path("flux/rss/", LastArticlesFeedRSS(), name="feed-rss"), path("flux/atom/", LastArticlesFeedATOM(), name="feed-atom"), - # View +] + +display_patterns = [ path("//", ArticleOnlineView.as_view(), name="view"), - # Downloads + path( + "////", + ContainerOnlineView.as_view(), + name="view-container", + ), + path("///", ContainerOnlineView.as_view(), name="view-container"), +] + +download_patterns = [ path("md//.md", DownloadOnlineArticle.as_view(requested_file="md"), name="download-md"), path("pdf//.pdf", DownloadOnlineArticle.as_view(requested_file="pdf"), name="download-pdf"), path("tex//.tex", DownloadOnlineArticle.as_view(requested_file="tex"), name="download-tex"), path("epub//.epub", DownloadOnlineArticle.as_view(requested_file="epub"), name="download-epub"), path("zip//.zip", DownloadOnlineArticle.as_view(requested_file="zip"), name="download-zip"), - # Listing +] + +listing_patterns = [ path("", RedirectView.as_view(pattern_name="publication:list", permanent=True)), re_path(r"tags/*", TagsListView.as_view(displayed_types=["ARTICLE"]), name="tags"), path( @@ -33,3 +44,5 @@ name="find-contributions-article", ), ] + +urlpatterns = feed_patterns + display_patterns + download_patterns + listing_patterns diff --git a/zds/tutorialv2/urls/urls_opinions.py b/zds/tutorialv2/urls/urls_opinions.py index f5d80d52a9..9c625b473b 100644 --- a/zds/tutorialv2/urls/urls_opinions.py +++ b/zds/tutorialv2/urls/urls_opinions.py @@ -3,21 +3,32 @@ from zds.tutorialv2.feeds import LastOpinionsFeedRSS, LastOpinionsFeedATOM from zds.tutorialv2.views.lists import ListOpinions, ContentOfAuthor from zds.tutorialv2.views.download_online import DownloadOnlineOpinion -from zds.tutorialv2.views.display import OpinionOnlineView +from zds.tutorialv2.views.display import OpinionOnlineView, ContainerOnlineView -urlpatterns = [ - # Flux +feed_patterns = [ path("flux/rss/", LastOpinionsFeedRSS(), name="feed-rss"), path("flux/atom/", LastOpinionsFeedATOM(), name="feed-atom"), - # View +] + +display_patterns = [ path("//", OpinionOnlineView.as_view(), name="view"), - # downloads: + path( + "////", + ContainerOnlineView.as_view(), + name="view-container", + ), + path("///", ContainerOnlineView.as_view(), name="view-container"), +] + +download_patterns = [ path("md//.md", DownloadOnlineOpinion.as_view(requested_file="md"), name="download-md"), path("pdf//.pdf", DownloadOnlineOpinion.as_view(requested_file="pdf"), name="download-pdf"), path("epub//.epub", DownloadOnlineOpinion.as_view(requested_file="epub"), name="download-epub"), path("zip//.zip", DownloadOnlineOpinion.as_view(requested_file="zip"), name="download-zip"), path("tex//.tex", DownloadOnlineOpinion.as_view(requested_file="tex"), name="download-tex"), - # Listing +] + +listing_patterns = [ path("", ListOpinions.as_view(), name="list"), path( "voir//", @@ -25,3 +36,5 @@ name="find-opinion", ), ] + +urlpatterns = feed_patterns + display_patterns + download_patterns + listing_patterns diff --git a/zds/tutorialv2/urls/urls_tutorials.py b/zds/tutorialv2/urls/urls_tutorials.py index 77ffa78dc8..76f9fc46a1 100644 --- a/zds/tutorialv2/urls/urls_tutorials.py +++ b/zds/tutorialv2/urls/urls_tutorials.py @@ -8,17 +8,12 @@ from zds.tutorialv2.views.redirect import RedirectContentSEO, RedirectOldBetaTuto from zds.tutorialv2.feeds import LastTutorialsFeedRSS, LastTutorialsFeedATOM - -urlpatterns = [ - # flux +feed_patterns = [ path("flux/rss/", LastTutorialsFeedRSS(), name="feed-rss"), path("flux/atom/", LastTutorialsFeedATOM(), name="feed-atom"), - # view - path( - "//////", - RedirectContentSEO.as_view(), - name="redirect_old_tuto", - ), +] + +display_patterns = [ path( "////", ContainerOnlineView.as_view(), @@ -26,15 +21,17 @@ ), path("///", ContainerOnlineView.as_view(), name="view-container"), path("//", TutorialOnlineView.as_view(), name="view"), - # downloads: +] + +download_patterns = [ path("md//.md", DownloadOnlineTutorial.as_view(requested_file="md"), name="download-md"), path("pdf//.pdf", DownloadOnlineTutorial.as_view(requested_file="pdf"), name="download-pdf"), path("epub//.epub", DownloadOnlineTutorial.as_view(requested_file="epub"), name="download-epub"), path("zip//.zip", DownloadOnlineTutorial.as_view(requested_file="zip"), name="download-zip"), path("tex//.tex", DownloadOnlineTutorial.as_view(requested_file="tex"), name="download-tex"), - # Old beta url compatibility - path("beta///", RedirectOldBetaTuto.as_view(), name="old-beta-url"), - # Listing +] + +listing_patterns = [ path("", RedirectView.as_view(pattern_name="publication:list", permanent=True)), path("tags/", TagsListView.as_view(displayed_types=["TUTORIAL"]), name="tags"), path( @@ -48,3 +45,14 @@ name="find-contributions-tutorial", ), ] + +redirect_patterns = [ + path( + "//////", + RedirectContentSEO.as_view(), + name="redirect_old_tuto", + ), + path("beta///", RedirectOldBetaTuto.as_view(), name="old-beta-url"), +] + +urlpatterns = feed_patterns + display_patterns + download_patterns + listing_patterns + redirect_patterns diff --git a/zds/utils/management/commands/load_fixtures.py b/zds/utils/management/commands/load_fixtures.py index dec45e3df1..bf4c06bfc7 100644 --- a/zds/utils/management/commands/load_fixtures.py +++ b/zds/utils/management/commands/load_fixtures.py @@ -398,7 +398,6 @@ def load_contents(cli, size, fake, _type, *_, **__): nb_avg_containers_in_content = size nb_avg_extracts_in_content = size - is_articles = _type == "ARTICLE" is_tutorials = _type == "TUTORIAL" is_opinion = _type == "OPINION" @@ -411,14 +410,11 @@ def load_contents(cli, size, fake, _type, *_, **__): # small introduction cli.stdout.write(f"À créer: {nb_contents:d} {textual_type}s", ending="") - if is_tutorials: - cli.stdout.write( - " ({:g} petits, {:g} moyens et {:g} grands)".format( - nb_contents * percent_mini, nb_contents * percent_medium, nb_contents * percent_big - ) + cli.stdout.write( + " ({:g} petits, {:g} moyens et {:g} grands)".format( + nb_contents * percent_mini, nb_contents * percent_medium, nb_contents * percent_big ) - else: - cli.stdout.write("") + ) cli.stdout.write( " - {:g} en brouillon".format( @@ -523,15 +519,9 @@ def load_contents(cli, size, fake, _type, *_, **__): versioned = content.load_version() generate_text_for_content( - current_size, - fake, - is_articles, - is_opinion, - nb_avg_containers_in_content, - nb_avg_extracts_in_content, - versioned, + current_size, fake, nb_avg_containers_in_content, nb_avg_extracts_in_content, versioned ) - # add some informations: + # add some information author = users[random.randint(0, nb_users - 1)].user content.authors.add(author) UserGalleryFactory(gallery=content.gallery, mode="W", user=author) @@ -573,18 +563,19 @@ def validate_edited_content(content, fake, nb_staffs, staffs, to_do, versioned): content.save() -def generate_text_for_content( - current_size, fake, is_articles, is_opinion, nb_avg_containers_in_content, nb_avg_extracts_in_content, versioned -): - if current_size == 0 or is_articles or is_opinion: - for _ in range(random.randint(1, nb_avg_extracts_in_content * 2)): - ExtractFactory(container=versioned, title=fake.text(max_nb_chars=60), light=False) +def generate_text_for_content(content_size, fake, nb_avg_containers_in_content, nb_avg_extracts_in_content, versioned): + if content_size == 0: + nb_extracts = random.randint(1, nb_avg_extracts_in_content * 2) + for _ in range(nb_extracts): + extract_title = fake.text(max_nb_chars=60) + ExtractFactory(container=versioned, title=extract_title, light=False) else: - for _ in range(random.randint(1, nb_avg_containers_in_content * 2)): - container = ContainerFactory(parent=versioned, title=fake.text(max_nb_chars=60)) - + nb_containers = random.randint(1, nb_avg_containers_in_content * 2) + for _ in range(nb_containers): + container_title = fake.text(max_nb_chars=60) + container = ContainerFactory(parent=versioned, title=container_title) handle_content_with_chapter_and_parts( - container, current_size, fake, nb_avg_containers_in_content, nb_avg_extracts_in_content + container, content_size, fake, nb_avg_containers_in_content, nb_avg_extracts_in_content ) @@ -599,17 +590,23 @@ def publish_opinion(content, action_flag, versioned): def handle_content_with_chapter_and_parts( - container, current_size, fake, nb_avg_containers_in_content, nb_avg_extracts_in_content + container, size, fake, nb_avg_containers_in_content, nb_avg_extracts_in_content ): - if current_size == 1: # medium size tutorial - for k in range(random.randint(1, nb_avg_extracts_in_content * 2)): - ExtractFactory(container=container, title=fake.text(max_nb_chars=60), light=False) - else: # big-size tutorial - for k in range(random.randint(1, nb_avg_containers_in_content * 2)): - subcontainer = ContainerFactory(parent=container, title=fake.text(max_nb_chars=60)) - - for m in range(random.randint(1, nb_avg_extracts_in_content * 2)): - ExtractFactory(container=subcontainer, title=fake.text(max_nb_chars=60), light=False) + if size == 1: # medium size content + nb_of_extracts = random.randint(1, nb_avg_extracts_in_content * 2) + for k in range(nb_of_extracts): + extract_title = fake.text(max_nb_chars=60) + ExtractFactory(container=container, title=extract_title, light=False) + else: # big-size content + nb_of_containers = random.randint(1, nb_avg_containers_in_content * 2) + for _ in range(nb_of_containers): + subcontainer_title = fake.text(max_nb_chars=60) + subcontainer = ContainerFactory(parent=container, title=subcontainer_title) + + nb_of_extracts = random.randint(1, nb_avg_extracts_in_content * 2) + for _ in range(nb_of_extracts): + extract_title = fake.text(max_nb_chars=60) + ExtractFactory(container=subcontainer, title=extract_title, light=False) ZDSResource = collections.namedtuple("zdsresource", ["name", "description", "callback", "extra_args"])