From 46ece396b6bbe8f0b0e30d37dcdf82e4f1642056 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Valenta?= <git@imaniti.org>
Date: Wed, 3 Jan 2024 20:26:49 +0100
Subject: [PATCH] move further models to mixins

---
 main/blocks.py                                |  21 +-
 ...5_alter_mainhomepage_footer_other_links.py |   5 +-
 main/models.py                                | 316 +---------------
 shared/blocks/main.py                         |  29 +-
 shared/const.py                               |  15 +
 shared/forms.py                               |  79 ++++
 shared/models/main.py                         | 346 +++++++++++++++++-
 7 files changed, 462 insertions(+), 349 deletions(-)

diff --git a/main/blocks.py b/main/blocks.py
index faac594d..f257d8f1 100644
--- a/main/blocks.py
+++ b/main/blocks.py
@@ -9,7 +9,6 @@ from wagtail.blocks import (
     TextBlock,
     URLBlock,
 )
-from wagtail.documents.blocks import DocumentChooserBlock
 from wagtail.images.blocks import ImageChooserBlock
 
 from shared.blocks import CardLinkBlockMixin, CardLinkWithHeadlineBlockMixin, CTAMixin
@@ -250,16 +249,6 @@ class PersonContactBoxBlock(StructBlock):
 
 
 # ARTICLE BLOCKS
-class ArticleQuoteBlock(StructBlock):
-    quote = CharBlock(label="Citace")
-    autor_name = CharBlock(label="Jméno autora")
-
-    class Meta:
-        icon = "user"
-        label = "Blok citace"
-        template = "main/includes/legacy/article_quote_block.html"
-
-
 class ArticleImageMixin(StructBlock):
     image = ImageChooserBlock(label="Obrázek")
     image_source = CharBlock(
@@ -285,15 +274,6 @@ class ArticleRightImageBlock(ArticleImageMixin):
         template = "main/includes/molecules/articles/article_richtext_content_with_right_image.html"
 
 
-class ArticleDownloadBlock(StructBlock):
-    file = DocumentChooserBlock(label="Stáhnutelný soubor")
-
-    class Meta:
-        icon = "user"
-        label = "Blok stáhnutelného dokumentu"
-        template = "main/includes/molecules/blocks/article_download_block.html"
-
-
 class TwoTextColumnBlock(StructBlock):
     text_column_1 = RichTextBlock(label="První sloupec textu")
     text_column_2 = RichTextBlock(label="Druhý sloupec textu")
@@ -352,5 +332,6 @@ class TeamBlock(StructBlock):
 
 # --- TODO: Remove legacy blocks used in migrations only
 
+
 class LinkBlock(StructBlock):
     pass
diff --git a/main/migrations/0065_alter_mainhomepage_footer_other_links.py b/main/migrations/0065_alter_mainhomepage_footer_other_links.py
index 787c9193..2d06c8fb 100644
--- a/main/migrations/0065_alter_mainhomepage_footer_other_links.py
+++ b/main/migrations/0065_alter_mainhomepage_footer_other_links.py
@@ -1,9 +1,10 @@
 # Generated by Django 4.1.10 on 2024-01-03 19:04
 
-from django.db import migrations
-import shared.blocks.main
 import wagtail.blocks
 import wagtail.fields
+from django.db import migrations
+
+import shared.blocks.main
 
 
 class Migration(migrations.Migration):
diff --git a/main/models.py b/main/models.py
index 65cb63fa..305ad9e1 100644
--- a/main/models.py
+++ b/main/models.py
@@ -29,6 +29,7 @@ from wagtail.search import index
 from wagtailmetadata.models import MetadataPageMixin
 
 from calendar_utils.models import CalendarMixin
+from shared import blocks as shared_blocks
 from shared.forms import SubscribeForm
 from shared.models import (  # MenuMixin,
     ArticleMixin,
@@ -36,22 +37,20 @@ from shared.models import (  # MenuMixin,
     ArticlesPageMixin,
     ExtendedMetadataHomePageMixin,
     ExtendedMetadataPageMixin,
+    MainArticlePageMixin,
+    MainArticlesPageMixin,
+    MainHomePageMixin,
     SharedTaggedMainArticle,
     SubpageMixin,
 )
 from shared.utils import make_promote_panels, subscribe_to_newsletter
-from shared import blocks as shared_blocks
 from tuning import admin_help
 
 from . import blocks
-from .constants import MONTH_NAMES
 from .forms import JekyllImportForm
 from .menu import MenuMixin, PageInMenuMixin
 
 
-from shared.models import MainHomePageMixin
-
-
 class MainHomePage(MainHomePageMixin):
     # content
     content = StreamField(
@@ -128,251 +127,10 @@ class MainHomePage(MainHomePageMixin):
         return self.setup_article_page_context(request)
 
 
-class MainArticlesPage(
-    RoutablePageMixin,
-    ExtendedMetadataPageMixin,
-    SubpageMixin,
-    MetadataPageMixin,
-    ArticlesPageMixin,
-    PageInMenuMixin,
-    Page,
-):
-    last_import_log = models.TextField(
-        "Výstup z posledního importu", null=True, blank=True
-    )
-    perex = models.TextField()
-
-    import_panels = [
-        MultiFieldPanel(
-            [
-                FieldPanel("do_import"),
-                FieldPanel("collection"),
-                FieldPanel("dry_run"),
-                FieldPanel("jekyll_repo_url"),
-                FieldPanel("readonly_log"),
-                HelpPanel(
-                    "Import provádějte vždy až po vytvoření stránky aktualit. "
-                    'Pro uložení logu je nutné volit možnost "Publikovat", nikoliv'
-                    'pouze "Uložit koncept". '
-                    "Import proběhne na pozadí a může trvat až několik minut. "
-                    "Dejte si po spuštění importu kávu a potom obnovte stránku pro "
-                    "zobrazení výsledku importu."
-                ),
-            ],
-            "import z Jekyll repozitáře",
-        ),
-    ]
-
-    ### RELATIONS
-
+class MainArticlesPage(MainArticlesPageMixin):
     parent_page_types = ["main.MainHomePage"]
     subpage_types = ["main.MainArticlePage"]
 
-    ### PANELS
-    content_panels = Page.content_panels + [
-        FieldPanel("perex"),
-        FieldPanel("shared_tags"),
-    ]
-    promote_panels = make_promote_panels()
-
-    ### EDIT HANDLERS
-
-    edit_handler = TabbedInterface(
-        [
-            ObjectList(content_panels, heading="Obsah"),
-            ObjectList(promote_panels, heading="Propagovat"),
-            ObjectList(import_panels, heading="Import"),
-        ]
-    )
-
-    ### OTHERS
-
-    base_form_class = JekyllImportForm
-
-    class Meta:
-        verbose_name = "Rozcestník článků"
-
-    def get_base_shared_articles_query(self, filter: models.Q):
-        return self.materialize_shared_articles_query(
-            self.append_all_shared_articles_query(
-                MainArticlePage.objects.filter(filter)
-            )
-            .live()
-            .order_by("-union_date")
-        )
-
-    def get_article_data_list(
-        self,
-        months_back: int = 1,
-        search_filter: models.Q | None = None,
-    ):
-        if search_filter is None:
-            search_filter = models.Q()
-
-        target_date_list = (
-            self.append_all_shared_articles_query(
-                MainArticlePage.objects.filter(search_filter)
-            )
-            .order_by("-union_date")
-            .live()
-            .values_list("union_date", flat=True)
-        )
-
-        if not target_date_list:
-            return []
-
-        target_date = target_date_list[0] - relativedelta(months=months_back)
-        first_day_of_target_month = target_date.replace(day=1)
-
-        filter = models.Q(date__gte=first_day_of_target_month) & search_filter
-
-        sorted_article_qs = self.get_base_shared_articles_query(filter)
-
-        article_data_list = []
-
-        current_month_data = self.get_empty_month_data(timezone.now().date())
-
-        for article in sorted_article_qs:
-            if article.date.month != current_month_data["month_number"]:
-                if len(current_month_data["articles"]) != 0:
-                    # append completed month if it contains any articles
-                    article_data_list.append(current_month_data)
-
-                current_month_data = self.get_empty_month_data(article.date)
-
-            current_month_data["articles"].append(article)
-
-        article_data_list.append(current_month_data)  # last iteration
-
-        return article_data_list
-
-    def get_search_filters(self, request):
-        filter = models.Q()
-
-        if "tag_id" in request.GET:
-            tag = self.get_filtered_tag(request)
-
-            if tag is not None:
-                filter = filter & models.Q(tags__id=tag.id)
-
-        if "q" in request.GET:
-            filter = filter & models.Q(title__icontains=self.get_search_query(request))
-
-        return filter
-
-    def get_filtered_tag(self, request) -> Tag | None:
-        if "tag_id" in request.GET:
-            try:
-                return Tag.objects.filter(id=int(request.GET["tag_id"])).first()
-            except Exception:
-                pass
-
-        return None
-
-    def get_search_query(self, request) -> str | None:
-        if "q" in request.GET:
-            return request.GET["q"]
-
-    def get_context(self, request, get_articles: bool = True, *args, **kwargs):
-        ctx = super().get_context(request, args, kwargs)
-
-        if get_articles:
-            filtered_tag = self.get_filtered_tag(request)
-
-            if filtered_tag is not None:
-                ctx["filtered_tag"] = filtered_tag
-
-            search_query = self.get_search_query(request)
-
-            if search_query is not None:
-                ctx["search_query"] = search_query
-
-            search_filter = self.get_search_filters(request)
-
-            article_timeline_list = self.get_article_data_list(1, search_filter)
-
-            ctx["article_timeline_list"] = article_timeline_list
-            ctx["show_next_timeline_articles"] = len(
-                self.get_base_shared_articles_query(search_filter)
-            ) != 0 and (
-                self.materialize_shared_articles_query(
-                    self.append_all_shared_articles_query(
-                        MainArticlePage.objects.filter(search_filter)
-                    )
-                    .live()
-                    .order_by("union_date")[:2]  # LIMIT 2
-                )[0]
-                not in article_timeline_list[-1]["articles"]
-            )
-
-            tags = []
-
-            for article in MainArticlePage.objects.all()[:50]:
-                for tag in article.tags.all():
-                    if tag in tags:
-                        continue
-
-                    tags.append(tag)
-
-            ctx["tags"] = tags
-
-            # meow
-
-        return ctx
-
-    def get_timeline_articles_response(self, request):
-        try:
-            months = int(request.GET.get("months", None))
-        except ValueError:
-            months = 1
-
-        search_filter = self.get_search_filters(request)
-        article_timeline_list = self.get_article_data_list(months, search_filter)
-
-        context = {"article_timeline_list": article_timeline_list}
-
-        data = {
-            "html": render(
-                request,
-                "main/includes/organisms/articles/articles_timeline_list.html",
-                context,
-            ).content.decode("utf-8"),
-            "has_next": (
-                len(self.get_base_shared_articles_query(search_filter)) != 0
-                and (
-                    self.materialize_shared_articles_query(
-                        self.append_all_shared_articles_query(
-                            MainArticlePage.objects.filter(search_filter)
-                        )
-                        .live()
-                        .order_by("union_date")[:2]  # LIMIT 2
-                    )[0]
-                    not in article_timeline_list[-1]["articles"]
-                )
-            ),
-        }
-
-        return JsonResponse(data=data, safe=False)
-
-    @route(r"^sdilene/$", name="shared")
-    def shared(self, request):
-        return self.setup_article_page_context(request)
-
-    def serve(self, request, *args, **kwargs):
-        if request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest":
-            if "months" in request.GET:
-                return self.get_timeline_articles_response(request)
-
-        return super().serve(request, *args, **kwargs)
-
-    @staticmethod
-    def get_empty_month_data(date_obj):
-        return {
-            "month_number": date_obj.month,
-            "month_text": MONTH_NAMES[date_obj.month - 1],
-            "articles": [],
-        }
-
 
 class MainArticleTag(TaggedItemBase):
     content_object = ParentalKey(
@@ -382,29 +140,7 @@ class MainArticleTag(TaggedItemBase):
     )
 
 
-class MainArticlePage(
-    ArticleMixin,
-    ExtendedMetadataPageMixin,
-    SubpageMixin,
-    MetadataPageMixin,
-    PageInMenuMixin,
-    Page,
-):
-    ### FIELDS
-    content = StreamField(
-        [
-            (
-                "text",
-                RichTextBlock(template="main/includes/atoms/text/prose_richtext.html"),
-            ),
-            ("quote", blocks.ArticleQuoteBlock()),
-            ("download", blocks.ArticleDownloadBlock()),
-        ],
-        verbose_name="Článek",
-        blank=True,
-        use_json_field=True,
-    )
-
+class MainArticlePage(MainArticlePageMixin):
     author_page = models.ForeignKey(
         "main.MainPersonPage",
         on_delete=models.SET_NULL,
@@ -415,50 +151,10 @@ class MainArticlePage(
     tags = ClusterTaggableManager(
         through=MainArticleTag, related_name="tagged_articles", blank=True
     )
-    shared_tags = ClusterTaggableManager(
-        verbose_name="Tagy pro sdílení mezi weby",
-        through=SharedTaggedMainArticle,
-        blank=True,
-    )
-
-    search_fields = ArticleMixin.search_fields + [
-        index.SearchField("author_page"),
-        index.FilterField("slug"),
-    ]
-
-    ### PANELS
-
-    content_panels = ArticleMixin.content_panels + [
-        FieldPanel("author_page"),
-        FieldPanel("tags"),
-        FieldPanel("shared_tags"),
-    ]
-
-    promote_panels = make_promote_panels(
-        admin_help.build(admin_help.NO_SEO_TITLE, admin_help.NO_DESCRIPTION_USE_PEREX),
-        search_image=False,
-    )
-
-    ### RELATIONS
 
     parent_page_types = ["main.MainArticlesPage"]
     subpage_types = []
 
-    ### OTHERS
-
-    class Meta:
-        verbose_name = "Aktualita"
-
-    # def get_context(self, request): chceme/nechceme?
-    #     context = super().get_context(request)
-    #     context["related_articles"] = (
-    #         self.get_siblings(inclusive=False)
-    #         .live()
-    #         .specific()
-    #         .order_by("-mainarticlepage__date")[:3]
-    #     )
-    #     return context
-
 
 class MainProgramPage(
     ExtendedMetadataPageMixin, SubpageMixin, MetadataPageMixin, PageInMenuMixin, Page
diff --git a/shared/blocks/main.py b/shared/blocks/main.py
index 9cce950f..43eefe90 100644
--- a/shared/blocks/main.py
+++ b/shared/blocks/main.py
@@ -1,5 +1,4 @@
 from wagtail import blocks
-
 from wagtail.blocks import (
     CharBlock,
     ListBlock,
@@ -8,12 +7,13 @@ from wagtail.blocks import (
     StructBlock,
     URLBlock,
 )
+from wagtail.documents.blocks import DocumentChooserBlock
 
 from .base import MenuItemBlock as MenuItemBlockBase
 
-
 # Mixins (or used as such)
 
+
 class CTAMixin(StructBlock):
     button_link = URLBlock(label="Odkaz tlačítka")
     button_text = CharBlock(label="Text tlačítka")
@@ -34,6 +34,7 @@ class LinkBlock(StructBlock):
 
 # Navbar
 
+
 class MainMenuItemBlock(MenuItemBlockBase):
     title = blocks.CharBlock(
         label="Titulek",
@@ -63,8 +64,31 @@ class SocialLinkBlock(LinkBlock):
         label = "Odkaz"
 
 
+# Articles
+
+
+class ArticleQuoteBlock(StructBlock):
+    quote = CharBlock(label="Citace")
+    autor_name = CharBlock(label="Jméno autora")
+
+    class Meta:
+        icon = "user"
+        label = "Blok citace"
+        template = "main/includes/legacy/article_quote_block.html"
+
+
+class ArticleDownloadBlock(StructBlock):
+    file = DocumentChooserBlock(label="Stáhnutelný soubor")
+
+    class Meta:
+        icon = "user"
+        label = "Blok stáhnutelného dokumentu"
+        template = "main/includes/molecules/blocks/article_download_block.html"
+
+
 # People
 
+
 class PersonContactBlock(StructBlock):
     position = CharBlock(label="Název pozice", required=False)
     # email, phone?
@@ -80,6 +104,7 @@ class PersonContactBlock(StructBlock):
 
 # Footer
 
+
 class OtherLinksBlock(StructBlock):
     title = CharBlock(label="Titulek")
     list = ListBlock(LinkBlock, label="Seznam odkazů s titulkem")
diff --git a/shared/const.py b/shared/const.py
index d2f6a034..af05b79c 100644
--- a/shared/const.py
+++ b/shared/const.py
@@ -17,3 +17,18 @@ RICH_TEXT_DEFAULT_FEATURES = [
     "blockquote",
     "embed",
 ]
+
+MONTH_NAMES = [
+    "Leden",
+    "Únor",
+    "Březen",
+    "Duben",
+    "Květen",
+    "Červen",
+    "Červenec",
+    "Srpen",
+    "Září",
+    "Říjen",
+    "Listopad",
+    "Prosinec",
+]
diff --git a/shared/forms.py b/shared/forms.py
index 98a3256a..f93c09f7 100644
--- a/shared/forms.py
+++ b/shared/forms.py
@@ -1,7 +1,86 @@
 from django import forms
+from wagtail.admin.forms import WagtailAdminPageForm
+from wagtail.models.collections import Collection
+
+from shared.jekyll_import import JekyllArticleImporter
 
 
 class SubscribeForm(forms.Form):
     email = forms.EmailField()
     confirmed = forms.BooleanField()
     return_page_id = forms.IntegerField()
+
+
+class JekyllImportForm(WagtailAdminPageForm):
+    do_import = forms.BooleanField(
+        initial=False, required=False, label="Provést import z Jekyllu"
+    )
+    collection = forms.ModelChoiceField(
+        queryset=Collection.objects.all(), required=False, label="Kolekce obrázků"
+    )
+    dry_run = forms.BooleanField(
+        initial=True,
+        required=False,
+        label="Jenom na zkoušku",
+        help_text="Žádné články se neuloží, vypíše případné problémy či "
+        "již existující články - 'ostrému' importu existující "
+        "články nevadí, přeskočí je",
+    )
+    jekyll_repo_url = forms.URLField(
+        max_length=512,
+        required=False,
+        help_text="např. https://github.com/pirati-web/pirati.cz",
+    )
+    readonly_log = forms.CharField(
+        disabled=True,
+        label="Log z posledního importu",
+        required=False,
+        widget=forms.Textarea,
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.fields["readonly_log"].initial = self.instance.last_import_log
+
+    def clean(self):
+        cleaned_data = super().clean()
+
+        if not cleaned_data.get("do_import"):
+            return cleaned_data
+
+        if cleaned_data.get("do_import") and not self.instance.id:
+            self.add_error(
+                "do_import", "Import proveďte prosím až po vytvoření stránky"
+            )
+
+        if not cleaned_data.get("collection"):
+            self.add_error("collection", "Pro import je toto pole povinné")
+        if not cleaned_data.get("jekyll_repo_url"):
+            self.add_error("jekyll_repo_url", "Pro import je toto pole povinné")
+
+        if cleaned_data.get("jekyll_repo_url", "").endswith(".zip"):
+            self.add_error(
+                "jekyll_repo_url", "Vložte odkaz pouze na repozitář, ne na zip"
+            )
+
+        return cleaned_data
+
+    def handle_import(self, model):
+        # TODO: Portable function
+
+        from .models import MainArticlePage
+
+        JekyllArticleImporter(
+            article_parent_page=self.instance,
+            collection_id=self.cleaned_data["collection"].id,
+            url=self.cleaned_data["jekyll_repo_url"],
+            dry_run=self.cleaned_data["dry_run"],
+            use_git=True,
+            page_model=MainArticlePage,
+        ).perform_import()
+
+    def save(self, commit=True):
+        if self.cleaned_data.get("do_import"):
+            self.handle_import()
+
+        return super().save(commit=commit)
diff --git a/shared/models/main.py b/shared/models/main.py
index fd3d0c52..29823a91 100644
--- a/shared/models/main.py
+++ b/shared/models/main.py
@@ -29,28 +29,29 @@ from wagtail.search import index
 from wagtailmetadata.models import MetadataPageMixin
 
 from calendar_utils.models import CalendarMixin
-from shared.forms import SubscribeForm
+from shared.blocks import (
+    ArticleDownloadBlock,
+    ArticleQuoteBlock,
+    MainMenuItemBlock,
+    NavbarMenuItemBlock,
+    OtherLinksBlock,
+    PersonContactBlock,
+    SocialLinkBlock,
+)
+from shared.const import MONTH_NAMES
+from shared.forms import JekyllImportForm, SubscribeForm
+from shared.utils import make_promote_panels, subscribe_to_newsletter
+from tuning import admin_help
+
 from .base import (  # MenuMixin,
     ArticleMixin,
     ArticlesMixin,
     ArticlesPageMixin,
     ExtendedMetadataHomePageMixin,
     ExtendedMetadataPageMixin,
-    SharedTaggedMainArticle,
-    SubpageMixin,
 )
-from shared.utils import make_promote_panels, subscribe_to_newsletter
-from tuning import admin_help
-
-from wagtail.models import Page
-
-from django.db import models
-from wagtail.admin.panels import FieldPanel, MultiFieldPanel
-from wagtail.fields import StreamField
-from wagtail.models import Page
-
 from .base import MenuMixin as MenuMixinBase
-from shared.blocks import MainMenuItemBlock, NavbarMenuItemBlock, OtherLinksBlock, PersonContactBlock, SocialLinkBlock
+from .base import SharedTaggedMainArticle, SubpageMixin  # MenuMixin,
 
 
 class MainMenuMixin(MenuMixinBase):
@@ -261,7 +262,9 @@ class MainHomePageMixin(
         context = super().get_context(request, args, kwargs)
 
         context["article_data_list"] = self.materialize_shared_articles_query(
-            self.append_all_shared_articles_query(self.article_page_model.objects.live().all())
+            self.append_all_shared_articles_query(
+                self.article_page_model.objects.live().all()
+            )
             .live()
             .order_by("-union_date")[:3]
         )
@@ -373,3 +376,316 @@ class MainHomePageMixin(
     @route(r"^sdilene/$", name="shared")
     def shared(self, request):
         return self.setup_article_page_context(request)
+
+
+class MainArticlesPageMixin(
+    RoutablePageMixin,
+    ExtendedMetadataPageMixin,
+    SubpageMixin,
+    MetadataPageMixin,
+    ArticlesPageMixin,
+    PageInMenuMixin,
+    Page,
+):
+    last_import_log = models.TextField(
+        "Výstup z posledního importu", null=True, blank=True
+    )
+    perex = models.TextField()
+
+    import_panels = [
+        MultiFieldPanel(
+            [
+                FieldPanel("do_import"),
+                FieldPanel("collection"),
+                FieldPanel("dry_run"),
+                FieldPanel("jekyll_repo_url"),
+                FieldPanel("readonly_log"),
+                HelpPanel(
+                    "Import provádějte vždy až po vytvoření stránky aktualit. "
+                    'Pro uložení logu je nutné volit možnost "Publikovat", nikoliv'
+                    'pouze "Uložit koncept". '
+                    "Import proběhne na pozadí a může trvat až několik minut. "
+                    "Dejte si po spuštění importu kávu a potom obnovte stránku pro "
+                    "zobrazení výsledku importu."
+                ),
+            ],
+            "import z Jekyll repozitáře",
+        ),
+    ]
+
+    ### RELATIONS
+
+    parent_page_types = []  # NOTE: Must be implemented
+    subpage_types = []  # NOTE: Must be implemented
+
+    ### PANELS
+    content_panels = Page.content_panels + [
+        FieldPanel("perex"),
+        FieldPanel("shared_tags"),
+    ]
+    promote_panels = make_promote_panels()
+
+    ### EDIT HANDLERS
+
+    edit_handler = TabbedInterface(
+        [
+            ObjectList(content_panels, heading="Obsah"),
+            ObjectList(promote_panels, heading="Propagovat"),
+            ObjectList(import_panels, heading="Import"),
+        ]
+    )
+
+    ### OTHERS
+
+    base_form_class = JekyllImportForm
+
+    class Meta:
+        verbose_name = "Rozcestník článků"
+        abstract = True
+
+    def get_base_shared_articles_query(self, filter: models.Q):
+        return self.materialize_shared_articles_query(
+            self.append_all_shared_articles_query(
+                self.root_page.article_page_model.objects.filter(filter)
+            )
+            .live()
+            .order_by("-union_date")
+        )
+
+    def get_article_data_list(
+        self,
+        months_back: int = 1,
+        search_filter: models.Q | None = None,
+    ):
+        if search_filter is None:
+            search_filter = models.Q()
+
+        target_date_list = (
+            self.append_all_shared_articles_query(
+                self.root_page.article_page_model.objects.filter(search_filter)
+            )
+            .order_by("-union_date")
+            .live()
+            .values_list("union_date", flat=True)
+        )
+
+        if not target_date_list:
+            return []
+
+        target_date = target_date_list[0] - relativedelta(months=months_back)
+        first_day_of_target_month = target_date.replace(day=1)
+
+        filter = models.Q(date__gte=first_day_of_target_month) & search_filter
+
+        sorted_article_qs = self.get_base_shared_articles_query(filter)
+
+        article_data_list = []
+
+        current_month_data = self.get_empty_month_data(timezone.now().date())
+
+        for article in sorted_article_qs:
+            if article.date.month != current_month_data["month_number"]:
+                if len(current_month_data["articles"]) != 0:
+                    # append completed month if it contains any articles
+                    article_data_list.append(current_month_data)
+
+                current_month_data = self.get_empty_month_data(article.date)
+
+            current_month_data["articles"].append(article)
+
+        article_data_list.append(current_month_data)  # last iteration
+
+        return article_data_list
+
+    def get_search_filters(self, request):
+        filter = models.Q()
+
+        if "tag_id" in request.GET:
+            tag = self.get_filtered_tag(request)
+
+            if tag is not None:
+                filter = filter & models.Q(tags__id=tag.id)
+
+        if "q" in request.GET:
+            filter = filter & models.Q(title__icontains=self.get_search_query(request))
+
+        return filter
+
+    def get_filtered_tag(self, request) -> Tag | None:
+        if "tag_id" in request.GET:
+            try:
+                return Tag.objects.filter(id=int(request.GET["tag_id"])).first()
+            except Exception:
+                pass
+
+        return None
+
+    def get_search_query(self, request) -> str | None:
+        if "q" in request.GET:
+            return request.GET["q"]
+
+    def get_context(self, request, get_articles: bool = True, *args, **kwargs):
+        ctx = super().get_context(request, args, kwargs)
+
+        if get_articles:
+            filtered_tag = self.get_filtered_tag(request)
+
+            if filtered_tag is not None:
+                ctx["filtered_tag"] = filtered_tag
+
+            search_query = self.get_search_query(request)
+
+            if search_query is not None:
+                ctx["search_query"] = search_query
+
+            search_filter = self.get_search_filters(request)
+
+            article_timeline_list = self.get_article_data_list(1, search_filter)
+
+            ctx["article_timeline_list"] = article_timeline_list
+            ctx["show_next_timeline_articles"] = len(
+                self.get_base_shared_articles_query(search_filter)
+            ) != 0 and (
+                self.materialize_shared_articles_query(
+                    self.append_all_shared_articles_query(
+                        self.root_page.article_page_model.objects.filter(search_filter)
+                    )
+                    .live()
+                    .order_by("union_date")[:2]  # LIMIT 2
+                )[0]
+                not in article_timeline_list[-1]["articles"]
+            )
+
+            tags = []
+
+            for article in self.root_page.article_page_model.objects.all()[:50]:
+                for tag in article.tags.all():
+                    if tag in tags:
+                        continue
+
+                    tags.append(tag)
+
+            ctx["tags"] = tags
+
+            # meow
+
+        return ctx
+
+    def get_timeline_articles_response(self, request):
+        try:
+            months = int(request.GET.get("months", None))
+        except ValueError:
+            months = 1
+
+        search_filter = self.get_search_filters(request)
+        article_timeline_list = self.get_article_data_list(months, search_filter)
+
+        context = {"article_timeline_list": article_timeline_list}
+
+        data = {
+            "html": render(
+                request,
+                "main/includes/organisms/articles/articles_timeline_list.html",
+                context,
+            ).content.decode("utf-8"),
+            "has_next": (
+                len(self.get_base_shared_articles_query(search_filter)) != 0
+                and (
+                    self.materialize_shared_articles_query(
+                        self.append_all_shared_articles_query(
+                            self.root_page.article_page_model.objects.filter(
+                                search_filter
+                            )
+                        )
+                        .live()
+                        .order_by("union_date")[:2]  # LIMIT 2
+                    )[0]
+                    not in article_timeline_list[-1]["articles"]
+                )
+            ),
+        }
+
+        return JsonResponse(data=data, safe=False)
+
+    @route(r"^sdilene/$", name="shared")
+    def shared(self, request):
+        return self.setup_article_page_context(request)
+
+    def serve(self, request, *args, **kwargs):
+        if request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest":
+            if "months" in request.GET:
+                return self.get_timeline_articles_response(request)
+
+        return super().serve(request, *args, **kwargs)
+
+    @staticmethod
+    def get_empty_month_data(date_obj):
+        return {
+            "month_number": date_obj.month,
+            "month_text": MONTH_NAMES[date_obj.month - 1],
+            "articles": [],
+        }
+
+
+class MainArticlePageMixin(
+    ArticleMixin,
+    ExtendedMetadataPageMixin,
+    SubpageMixin,
+    MetadataPageMixin,
+    PageInMenuMixin,
+    Page,
+):
+    ### FIELDS
+    content = StreamField(
+        [
+            (
+                "text",
+                RichTextBlock(template="main/includes/atoms/text/prose_richtext.html"),
+            ),
+            ("quote", ArticleQuoteBlock()),
+            ("download", ArticleDownloadBlock()),
+        ],
+        verbose_name="Článek",
+        blank=True,
+        use_json_field=True,
+    )
+
+    @property
+    def tags(self):
+        # NOTE: Must be implemented
+        raise NotImplementedError
+
+    shared_tags = ClusterTaggableManager(
+        verbose_name="Tagy pro sdílení mezi weby",
+        through=SharedTaggedMainArticle,
+        blank=True,
+    )
+
+    search_fields = ArticleMixin.search_fields + [
+        index.SearchField("author_page"),
+        index.FilterField("slug"),
+    ]
+
+    ### PANELS
+
+    content_panels = ArticleMixin.content_panels + [
+        FieldPanel("author_page"),
+        FieldPanel("tags"),
+        FieldPanel("shared_tags"),
+    ]
+
+    promote_panels = make_promote_panels(
+        admin_help.build(admin_help.NO_SEO_TITLE, admin_help.NO_DESCRIPTION_USE_PEREX),
+        search_image=False,
+    )
+
+    ### RELATIONS
+
+    parent_page_types = []  # NOTE: Must be implemented
+    subpage_types = []  # NOTE: Must be implemented
+
+    ### OTHERS
+
+    class Meta:
+        verbose_name = "Aktualita"
+        abstract = True
-- 
GitLab