diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a81131be..1ce3fbe6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,11 +2,11 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.9 + rev: v0.8.0 hooks: - id: ruff args: [ --fix, --exit-non-zero-on-fix ] - repo: https://github.com/psf/black - rev: 23.11.0 + rev: 24.10.0 hooks: - id: black diff --git a/blog/locale/fr/LC_MESSAGES/django.mo b/blog/locale/fr/LC_MESSAGES/django.mo index 9efe6853..c7000ea4 100644 Binary files a/blog/locale/fr/LC_MESSAGES/django.mo and b/blog/locale/fr/LC_MESSAGES/django.mo differ diff --git a/blog/locale/fr/LC_MESSAGES/django.po b/blog/locale/fr/LC_MESSAGES/django.po index 18255ea5..13d7b4ee 100644 --- a/blog/locale/fr/LC_MESSAGES/django.po +++ b/blog/locale/fr/LC_MESSAGES/django.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-09-17 12:04+0200\n" -"PO-Revision-Date: 2024-09-17 12:04+0200\n" +"POT-Creation-Date: 2024-11-27 14:45+0100\n" +"PO-Revision-Date: 2024-11-27 14:47+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: fr\n" @@ -18,19 +18,19 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "X-Generator: Poedit 3.4.2\n" -#: blog/blocks.py:9 blog/models.py:37 blog/models.py:65 +#: blog/blocks.py:9 blog/models.py:39 blog/models.py:67 msgid "Name" msgstr "Nom" -#: blog/blocks.py:10 blog/models.py:66 +#: blog/blocks.py:10 blog/models.py:68 msgid "Role" msgstr "Fonction" -#: blog/blocks.py:11 blog/models.py:54 +#: blog/blocks.py:11 blog/models.py:56 msgid "Organization" msgstr "Organisation" -#: blog/blocks.py:12 blog/models.py:68 +#: blog/blocks.py:12 blog/models.py:70 msgid "Contact info" msgstr "Informations de contact" @@ -42,120 +42,124 @@ msgstr "Texte riche" msgid "Contact card" msgstr "Carte contact" -#: blog/models.py:93 +#: blog/models.py:95 msgid "Person" msgstr "Personne" -#: blog/models.py:105 +#: blog/models.py:107 msgid "Category name" msgstr "Nom de la catégorie" -#: blog/models.py:112 +#: blog/models.py:114 msgid "Parent category" msgstr "Catégorie parente" -#: blog/models.py:119 +#: blog/models.py:121 msgid "Description" msgstr "Description" -#: blog/models.py:120 +#: blog/models.py:122 msgid "Displayed on the top of the category page" msgstr "Affiché en haut de la page de la catégorie" -#: blog/models.py:126 +#: blog/models.py:128 msgid "Text displayed at the end of every page in the category" msgstr "Texte affiché à la fin de chaque page de la catégorie" -#: blog/models.py:153 +#: blog/models.py:155 msgid "Parent category cannot be self." msgstr "La catégorie ne peut être sa propre parente." -#: blog/models.py:155 +#: blog/models.py:157 msgid "Cannot have circular Parents." msgstr "Il est impossible d’avoir des parents circulaires." -#: blog/models.py:164 blog/models.py:183 +#: blog/models.py:166 blog/models.py:185 msgid "Category" msgstr "Catégorie" -#: blog/models.py:165 blog/models.py:273 blog/models.py:390 -#: blog/templates/blog/categories_list_page.html:20 blog/views.py:117 +#: blog/models.py:167 blog/models.py:279 blog/models.py:460 blog/models.py:467 +#: blog/models.py:518 msgid "Categories" msgstr "Catégories" -#: blog/models.py:199 +#: blog/models.py:201 msgid "Posts per page" msgstr "Articles par page" -#: blog/models.py:203 blog/templates/blog/blog_index_page.html:77 +#: blog/models.py:207 +msgid "Post limit in the RSS/Atom feeds" +msgstr "Nombre d’articles dans les flux RSS/Atom" + +#: blog/models.py:211 blog/templates/blog/blog_index_page.html:85 msgid "Filter by category" msgstr "Filtrer par catégorie" -#: blog/models.py:204 blog/templates/blog/blog_index_page.html:94 +#: blog/models.py:212 blog/templates/blog/blog_index_page.html:102 msgid "Filter by tag" msgstr "Filtrer par étiquette" -#: blog/models.py:205 blog/templates/blog/blog_index_page.html:111 +#: blog/models.py:213 blog/templates/blog/blog_index_page.html:119 msgid "Filter by author" msgstr "Filtrer par auteur" -#: blog/models.py:207 blog/templates/blog/blog_index_page.html:126 +#: blog/models.py:215 blog/templates/blog/blog_index_page.html:134 msgid "Filter by source" msgstr "Filtrer par source" -#: blog/models.py:207 +#: blog/models.py:215 msgid "The source is the organization of the post author" msgstr "La source est l’organisation à laquelle appartient l’auteur de l’article" -#: blog/models.py:219 +#: blog/models.py:228 msgid "Show filters" msgstr "Afficher les filtres" -#: blog/models.py:226 +#: blog/models.py:235 msgid "Blog index" msgstr "Index de blog" -#: blog/models.py:255 blog/templates/blog/tags_list_page.html:20 -#: blog/views.py:149 +#: blog/models.py:262 blog/models.py:483 blog/models.py:497 +#: blog/templates/blog/tags_list_page.html:20 msgid "Tags" msgstr "Étiquettes" -#: blog/models.py:260 +#: blog/models.py:267 #, python-format msgid "Posts tagged with %(tag)s" msgstr "Articles avec l’étiquette %(tag)s" -#: blog/models.py:278 +#: blog/models.py:284 #, python-format msgid "Posts in category %(category)s" msgstr "Articles dans la catégorie %(category)s" -#: blog/models.py:289 blog/models.py:291 blog/models.py:302 blog/models.py:305 +#: blog/models.py:294 blog/models.py:296 blog/models.py:306 blog/models.py:309 msgid "Posts written by" msgstr "Articles écrits par" -#: blog/models.py:309 +#: blog/models.py:314 #, python-format msgid "Posts published in %(year)s" msgstr "Articles publiés en %(year)s" -#: blog/models.py:392 +#: blog/models.py:520 msgid "Post date" msgstr "Date de publication" -#: blog/models.py:394 +#: blog/models.py:522 msgid "Author entries can be created in Snippets > Persons" msgstr "Les auteurs peuvent être créés via Fragments > Personnes" -#: blog/models.py:413 +#: blog/models.py:541 msgid "Scheduled publishing" msgstr "Publication planifiée" -#: blog/models.py:421 +#: blog/models.py:549 msgid "Tags and Categories" msgstr "Étiquettes et Catégories" -#: blog/models.py:437 +#: blog/models.py:565 msgid "Blog page" msgstr "Page de blog" @@ -163,12 +167,28 @@ msgstr "Page de blog" msgid "No article found." msgstr "Aucun article trouvé." -#: blog/templates/blog/blog_entry_page.html:74 +#: blog/templates/blog/blocks/feeds.html:10 +msgid "Atom feed for the category" +msgstr "Flux Atom pour la catégorie" + +#: blog/templates/blog/blocks/feeds.html:12 +msgid "Atom feed" +msgstr "Flux Atom" + +#: blog/templates/blog/blocks/feeds.html:20 +msgid "RSS feed for the category" +msgstr "Flux RSS pour la catégorie" + +#: blog/templates/blog/blocks/feeds.html:22 +msgid "RSS feed" +msgstr "Flux RSS" + +#: blog/templates/blog/blog_entry_page.html:79 msgid "Posted by:" msgstr "Écrit par :" -#: blog/templates/blog/blog_index_page.html:72 -#: blog/templates/blog/blog_index_page.html:74 +#: blog/templates/blog/blog_index_page.html:80 +#: blog/templates/blog/blog_index_page.html:82 msgid "Filters" msgstr "Filtres" diff --git a/blog/migrations/0038_blogindexpage_feed_posts_limit.py b/blog/migrations/0038_blogindexpage_feed_posts_limit.py new file mode 100644 index 00000000..caf285e5 --- /dev/null +++ b/blog/migrations/0038_blogindexpage_feed_posts_limit.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.3 on 2024-11-27 10:47 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("blog", "0037_alter_blogentrypage_body_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="blogindexpage", + name="feed_posts_limit", + field=models.PositiveSmallIntegerField( + default=20, + validators=[ + django.core.validators.MaxValueValidator(100), + django.core.validators.MinValueValidator(1), + ], + verbose_name="Post limit in the RSS/Atom feeds", + ), + ), + ] diff --git a/blog/models.py b/blog/models.py index 96b85a2f..3cca040f 100644 --- a/blog/models.py +++ b/blog/models.py @@ -5,21 +5,23 @@ from django.db import models from django.db.models import BooleanField, Count, QuerySet from django.db.models.expressions import F +from django.http import HttpRequest, HttpResponse from django.shortcuts import get_object_or_404 from django.template.defaultfilters import slugify -from django.urls import reverse -from django.utils import timezone -from django.utils.translation import get_language, gettext_lazy as _ +from django.utils import feedgenerator, timezone +from django.utils.translation import gettext_lazy as _ from modelcluster.fields import ParentalKey, ParentalManyToManyField from modelcluster.tags import ClusterTaggableManager from rest_framework import serializers from taggit.models import TaggedItemBase +from unidecode import unidecode from wagtail.admin.panels import FieldPanel, FieldRowPanel, MultiFieldPanel, TitleFieldPanel from wagtail.admin.widgets.slug import SlugInput from wagtail.api import APIField +from wagtail.contrib.routable_page.models import RoutablePageMixin, path from wagtail.fields import RichTextField, StreamField from wagtail.models import Orderable -from wagtail.models.i18n import Locale, TranslatableMixin +from wagtail.models.i18n import TranslatableMixin from wagtail.search import index from wagtail.snippets.models import register_snippet @@ -192,13 +194,19 @@ class TagEntryPage(TaggedItemBase): content_object = ParentalKey("BlogEntryPage", related_name="entry_tags") -class BlogIndexPage(SitesFacilesBasePage): +class BlogIndexPage(RoutablePageMixin, SitesFacilesBasePage): posts_per_page = models.PositiveSmallIntegerField( default=10, validators=[MaxValueValidator(100), MinValueValidator(1)], verbose_name=_("Posts per page"), ) + feed_posts_limit = models.PositiveSmallIntegerField( + default=20, + validators=[MaxValueValidator(100), MinValueValidator(1)], + verbose_name=_("Post limit in the RSS/Atom feeds"), + ) + # Filters filter_by_category = models.BooleanField(_("Filter by category"), default=True) filter_by_tag = models.BooleanField(_("Filter by tag"), default=True) @@ -209,6 +217,7 @@ class BlogIndexPage(SitesFacilesBasePage): settings_panels = SitesFacilesBasePage.settings_panels + [ FieldPanel("posts_per_page"), + FieldPanel("feed_posts_limit"), MultiFieldPanel( [ FieldPanel("filter_by_category"), @@ -234,16 +243,14 @@ def posts(self): ) return posts - def get_context(self, request, tag=None, category=None, author=None, source=None, year=None, *args, **kwargs): + def get_context(self, request, *args, **kwargs): context = super(BlogIndexPage, self).get_context(request, *args, **kwargs) posts = self.posts - locale = Locale.objects.get(language_code=get_language()) extra_breadcrumbs = None extra_title = "" - if tag is None: - tag = request.GET.get("tag") + tag = request.GET.get("tag") if tag: tag = get_object_or_404(Tag, slug=tag) posts = posts.filter(tags=tag) @@ -251,7 +258,7 @@ def get_context(self, request, tag=None, category=None, author=None, source=None "links": [ {"url": self.get_url(), "title": self.title}, { - "url": reverse("blog:tags_list", kwargs={"blog_slug": self.slug}), + "url": f"{self.get_url()}{self.reverse_subpage('tags_list')}", "title": _("Tags"), }, ], @@ -259,17 +266,16 @@ def get_context(self, request, tag=None, category=None, author=None, source=None } extra_title = _("Posts tagged with %(tag)s") % {"tag": tag} - if category is None: - category = request.GET.get("category") + category = request.GET.get("category") if category: - category = get_object_or_404(Category, slug=category, locale=locale) + category = get_object_or_404(Category, slug=category, locale=self.locale) posts = posts.filter(blog_categories=category) extra_breadcrumbs = { "links": [ {"url": self.get_url(), "title": self.title}, { - "url": reverse("blog:categories_list", kwargs={"blog_slug": self.slug}), + "url": f"{self.get_url()}{self.reverse_subpage('categories_list')}", "title": _("Categories"), }, ], @@ -277,8 +283,7 @@ def get_context(self, request, tag=None, category=None, author=None, source=None } extra_title = _("Posts in category %(category)s") % {"category": category.name} - if source is None: - source = request.GET.get("source") + source = request.GET.get("source") if source: source = get_object_or_404(Organization, slug=source) posts = posts.filter(authors__organization=source) @@ -290,8 +295,7 @@ def get_context(self, request, tag=None, category=None, author=None, source=None } extra_title = _("Posts written by") + f" {source.name}" - if author is None: - author = request.GET.get("author") + author = request.GET.get("author") if author: author = get_object_or_404(Person, id=author) @@ -304,6 +308,7 @@ def get_context(self, request, tag=None, category=None, author=None, source=None posts = posts.filter(authors=author) extra_title = _("Posts written by") + f" {author.name}" + year = request.GET.get("year") if year: posts = posts.filter(date__year=year) extra_title = _("Posts published in %(year)s") % {"year": year} @@ -380,6 +385,129 @@ def list_tags(self, min_count: int = 1) -> list: def show_filters(self) -> bool | BooleanField: return self.filter_by_category or self.filter_by_tag or self.filter_by_author or self.filter_by_source + def feed_posts(self, feed, request): + """ + Returns the posts for a RSS or ATOM feed relative to the parameters + """ + posts = self.posts + + category = request.GET.get("category") + if category: + category = get_object_or_404(Category, slug=category, locale=self.locale) + posts = posts.filter(blog_categories=category) + + limit = int(request.GET.get("limit", self.feed_posts_limit)) + posts = posts[:limit] + + for post in posts: + feed.add_item( + post.title, + post.full_url, + pubdate=post.date, + description=post.search_description, + ) + + return feed + + @path("rss/", name="rss_feed") + def rss_view(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """ + Return the current blog as a RSS feed + """ + + if self.seo_title: + title = self.seo_title + else: + title = self.title + + feed = feedgenerator.Rss201rev2Feed( + title=title, + link=self.full_url, + description=self.search_description, + language=self.locale.language_code, + feed_url=f"{self.full_url}{self.reverse_subpage('rss_feed')}", + ) + feed = self.feed_posts(feed, request) + + response = HttpResponse(feed.writeString("UTF-8"), content_type="application/xml") + return response + + @path("atom/", name="atom_feed") + def atom_view(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """ + Return the current blog as an Atom feed + """ + + if self.seo_title: + title = self.seo_title + else: + title = self.title + + feed = feedgenerator.Atom1Feed( + title=title, + link=self.full_url, + description=self.search_description, + language=self.locale.language_code, + feed_url=f"{self.full_url}{self.reverse_subpage('atom_feed')}", + ) + feed = self.feed_posts(feed, request) + + response = HttpResponse(feed.writeString("UTF-8"), content_type="application/xml") + return response + + @path("categories/", name="categories_list") + def categories_list(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + extra_title = _("Categories") + categories = self.list_categories() + + extra_breadcrumbs = { + "links": [ + {"url": self.get_url(), "title": self.title}, + ], + "current": _("Categories"), + } + + return self.render( + request, + context_overrides={ + "categories": categories, + "page": self, + "extra_title": extra_title, + "extra_breadcrumbs": extra_breadcrumbs, + }, + template="blog/categories_list_page.html", + ) + + @path("tags/", name="tags_list") + def tags_list(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + extra_title = _("Tags") + tags = self.list_tags() + + tags_by_first_letter = {} + for tag in tags: + first_letter = unidecode(tag["tag_slug"][0].upper()) + if first_letter not in tags_by_first_letter: + tags_by_first_letter[first_letter] = [] + tags_by_first_letter[first_letter].append(tag) + + extra_breadcrumbs = { + "links": [ + {"url": self.get_url(), "title": self.title}, + ], + "current": _("Tags"), + } + + return self.render( + request, + context_overrides={ + "sorted_tags": tags_by_first_letter, + "page": self, + "extra_title": extra_title, + "extra_breadcrumbs": extra_breadcrumbs, + }, + template="blog/tags_list_page.html", + ) + class BlogEntryPage(SitesFacilesBasePage): tags = ClusterTaggableManager(through="TagEntryPage", blank=True) diff --git a/blog/templates/blog/blocks/feeds.html b/blog/templates/blog/blocks/feeds.html new file mode 100644 index 00000000..b3d95651 --- /dev/null +++ b/blog/templates/blog/blocks/feeds.html @@ -0,0 +1,27 @@ + +{% load wagtailroutablepage_tags i18n %} + +
diff --git a/blog/templates/blog/blog_index_page.html b/blog/templates/blog/blog_index_page.html index ec187e8f..6170c9fe 100644 --- a/blog/templates/blog/blog_index_page.html +++ b/blog/templates/blog/blog_index_page.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% load static dsfr_tags wagtailcore_tags wagtailimages_tags wagtail_dsfr_tags i18n %} +{% load static dsfr_tags wagtailcore_tags wagtailimages_tags wagtailroutablepage_tags wagtail_dsfr_tags i18n %} {% block title %}