Skip to content
Snippets Groups Projects
Select Git revision
  • 422437b9ece0c86840e51321585882c0c47efc0c
  • test default protected
  • master protected
  • feat/custom-css
  • feat/redesign-improvements-10
  • feat/redesign-improvements-8
  • feat/redesign-fixes-3
  • feat/pirstan-changes
  • feat/separate-import-thread
  • feat/dary-improvements
  • features/add-pdf-page
  • features/add-typed-table
  • features/fix-broken-calendar-categories
  • features/add-embed-to-articles
  • features/create-mastodon-feed-block
  • features/add-custom-numbering-for-candidates
  • features/add-timeline
  • features/create-wordcloud-from-article-page
  • features/create-collapsible-extra-legal-info
  • features/extend-hero-banner
  • features/add-link-to-images
21 results

0194_add_people_block.py

Blame
  • models.py 56.66 KiB
    import json
    import random
    from functools import cached_property
    
    from django.core.cache import cache
    from django.core.exceptions import ValidationError
    from django.db import models
    from django.http import HttpResponseNotFound, HttpResponseRedirect
    from django.shortcuts import render
    from django.utils.safestring import mark_safe
    from django.utils.translation import gettext_lazy
    from modelcluster.contrib.taggit import ClusterTaggableManager
    from modelcluster.fields import ParentalKey
    from taggit.models import Tag, TaggedItemBase
    from wagtail.admin.panels import (
        FieldPanel,
        HelpPanel,
        InlinePanel,
        MultiFieldPanel,
        ObjectList,
        PageChooserPanel,
        TabbedInterface,
    )
    from wagtail.contrib.routable_page.models import RoutablePageMixin, route
    from wagtail.fields import RichTextField, StreamField
    from wagtail.models import Orderable, Page
    from wagtail.search import index
    from wagtailmetadata.models import MetadataPageMixin
    
    from calendar_utils.models import CalendarMixin
    from maps_utils.blocks import MapPointBlock
    from maps_utils.const import (
        DEFAULT_MAP_STYLE,
        MAP_STYLES,
        SUPPORTED_FEATURE_TYPES,
        TILE_SERVER_CONFIG,
    )
    from maps_utils.validation import validators as maps_validators
    from shared.blocks import (
        DEFAULT_CONTENT_BLOCKS,
        ButtonGroupBlock,
        ChartBlock,
        FigureBlock,
        FullSizeHeaderBlock,
        HeadlineBlock,
        NewsletterSubscriptionBlock,
        YouTubeVideoBlock,
    )
    from shared.const import RICH_TEXT_DEFAULT_FEATURES
    from shared.models import (
        ArticleMixin,
        ArticlesMixin,
        ArticlesPageMixin,
        ExtendedMetadataHomePageMixin,
        ExtendedMetadataPageMixin,
        FooterMixin,
        MenuMixin,
        PdfPageMixin,
        SharedTaggedDistrictArticle,
        SubpageMixin,
    )
    from shared.utils import make_promote_panels, strip_all_html_tags, trim_to_length
    from tuning import admin_help
    
    from . import blocks
    from .forms import JekyllImportForm
    
    CONTENT_BLOCKS = DEFAULT_CONTENT_BLOCKS + [
        ("chart", ChartBlock(template="district/blocks/chart.html")),
        ("related", blocks.ArticlesBlock()),
        ("related_links", blocks.ArticleLinksBlock()),
    ]
    
    
    class DistrictHomePage(
        RoutablePageMixin,
        MenuMixin,
        ExtendedMetadataHomePageMixin,
        MetadataPageMixin,
        CalendarMixin,
        FooterMixin,
        ArticlesMixin,
        Page,
    ):
        ### FIELDS
    
        calendar_page = models.ForeignKey(
            "DistrictCalendarPage",
            verbose_name="Stránka s kalendářem",
            on_delete=models.PROTECT,
            null=True,
            blank=True,
        )
    
        subheader = StreamField(
            [
                ("header_full_size", FullSizeHeaderBlock()),
                ("header_simple", blocks.HomepageSimpleHeaderBlock()),
                ("header", blocks.HomepageHeaderBlock()),
                ("hero_banner", blocks.HeroBannerBlock()),
            ],
            verbose_name="Blok pod headerem",
            blank=True,
            use_json_field=True,
        )
    
        content = StreamField(
            [
                (
                    "text",
                    blocks.RichTextBlock(
                        label="Textový editor", features=RICH_TEXT_DEFAULT_FEATURES
                    ),
                ),
                ("headline", HeadlineBlock()),
            ],
            verbose_name="Obsah stránky",
            blank=True,
            use_json_field=True,
        )
    
        articles_title = models.CharField("Nadpis článků", max_length=256)
        election_countdown_datetime = models.DateTimeField(
            "Datum a čas pro odpočet do voleb",
            null=True,
            blank=True,
            help_text="Pro skrytí nechte nevyplněné",
        )
        show_calendar_on_hp = models.BooleanField(
            "Zobrazit kalendář dole na homepage", default=True
        )
    
        region_map_button_text = models.CharField(
            "Text tlačítka mapy", max_length=256, default="Piráti v krajích"
        )
        calendar_button_text = models.CharField(
            "Text tlačítka kalendáře", max_length=256, default="Kalendář"
        )
    
        custom_logo = models.ForeignKey(
            "wagtailimages.Image", blank=True, null=True, on_delete=models.SET_NULL
        )
    
        show_pirati_cz_link = models.BooleanField(
            "Zobrazit v záhlaví odkaz 'pirati.cz'", default=True
        )
        show_eshop_link = models.BooleanField(
            "Zobrazit v záhlaví odkaz na pirátský eshop", default=True
        )
        show_magazine_link = models.BooleanField(
            "Zobrazit v záhlaví odkaz na pirátské listy", default=True
        )
    
        facebook = models.URLField(
            "Facebook URL",
            blank=True,
            null=True,
            default="https://www.facebook.com/ceska.piratska.strana",
        )
        twitter = models.URLField(
            "Twitter URL",
            blank=True,
            null=True,
            default="https://www.twitter.com/PiratskaStrana",
        )
        youtube = models.URLField(
            "YouTube URL",
            blank=True,
            null=True,
            default="https://www.youtube.com/channel/UC_zxYLGrkmrYazYt0MzyVlA",
        )
        instagram = models.URLField(
            "Instagram URL",
            blank=True,
            null=True,
            default="https://www.instagram.com/pirati.cz/",
        )
        flickr = models.URLField(
            "Flickr URL",
            blank=True,
            null=True,
            default="https://www.flickr.com/photos/pirati/",
        )
        forum = models.URLField(
            "Fórum URL", blank=True, null=True, default="https://forum.pirati.cz/"
        )
    
        contact_email = models.EmailField("kontaktni email", max_length=250, blank=True)
        contact_phone = models.TextField("kontaktni telefon", max_length=250, blank=True)
        contact_newcomers = models.URLField(
            "URL pro zájemce o členství",
            blank=True,
            null=True,
            default="https://nalodeni.pirati.cz",
        )
    
        donation_page = models.URLField(
            "URL pro příjem darů (tlačítko Darovat)",
            blank=True,
            null=True,
            default="https://dary.pirati.cz",
        )
    
        newsletter_list_id = models.CharField(
            "ID newsletteru",
            max_length=20,
            blank=True,
            null=True,
            help_text="ID newsletteru z Mailtrainu. Po vyplnění se formulář pro odběr newsletteru zobrazí na úvodní stránce a na stránce s kontakty.",
        )
        newsletter_description = models.CharField(
            "Popis newsletteru",
            max_length=250,
            default="Fake news tam nenajdeš, ale dozvíš se, co chystáme doopravdy!",
        )
    
        # Lide uvedeni v paticce
        footer_person_list = StreamField(
            [
                ("footer_person_list", blocks.PersonCustomPositionBlock()),
            ],
            verbose_name="Osoby v zápatí webu",
            blank=True,
            max_num=6,
            use_json_field=True,
        )
    
        # Extra komentar v paticce
        footer_extra_content = RichTextField(
            verbose_name="Extra obsah pod šedou patičkou",
            blank=True,
            features=RICH_TEXT_DEFAULT_FEATURES,
        )
    
        # settings
        matomo_id = models.IntegerField(
            "Matomo ID pro sledování návštěvnosti", blank=True, null=True
        )
        fallback_image = models.ForeignKey(
            "wagtailimages.Image",
            on_delete=models.PROTECT,
            null=True,
            related_name="+",
        )
    
        ### PANELS
    
        content_panels = Page.content_panels + [
            FieldPanel("subheader"),
            FieldPanel("content"),
            FieldPanel("articles_title"),
            FieldPanel("election_countdown_datetime"),
            FieldPanel("show_calendar_on_hp"),
        ]
    
        promote_panels = make_promote_panels(admin_help.build(admin_help.IMPORTANT_TITLE))
    
        settings_panels = [
            FieldPanel("custom_logo"),
            FieldPanel("matomo_id"),
            FieldPanel("title_suffix"),
            MultiFieldPanel(
                [
                    FieldPanel("show_pirati_cz_link"),
                    FieldPanel("show_eshop_link"),
                    FieldPanel("show_magazine_link"),
                    FieldPanel("donation_page"),
                    FieldPanel("contact_newcomers"),
                    FieldPanel("facebook"),
                    FieldPanel("twitter"),
                    FieldPanel("youtube"),
                    FieldPanel("instagram"),
                    FieldPanel("flickr"),
                    FieldPanel("forum"),
                ],
                gettext_lazy("Odkazy na webu"),
            ),
            MultiFieldPanel(
                [
                    FieldPanel("contact_email"),
                    FieldPanel("contact_phone"),
                ],
                gettext_lazy("Kontakty"),
            ),
            MultiFieldPanel(
                [
                    FieldPanel("footer_person_list"),
                ],
                gettext_lazy("Lidé v zápatí stránky"),
            ),
            MultiFieldPanel(
                [
                    FieldPanel("footer_extra_content"),
                ],
                gettext_lazy("Extra obsah v patičce"),
            ),
            MultiFieldPanel(
                [
                    FieldPanel("region_map_button_text"),
                    FieldPanel("calendar_button_text"),
                    MultiFieldPanel(
                        [
                            FieldPanel("calendar_url"),
                            PageChooserPanel("calendar_page"),
                        ],
                        "Kalendář",
                    ),
                ],
                gettext_lazy("Nastavení lišty s kalendářem a mapou"),
            ),
            MultiFieldPanel(
                [
                    FieldPanel("newsletter_list_id"),
                    FieldPanel("newsletter_description"),
                ],
                gettext_lazy("Formulář pro odběr newsletteru"),
            ),
            FieldPanel("footer_links"),
            FieldPanel("fallback_image"),
        ]
    
        ### EDIT HANDLERS
    
        edit_handler = TabbedInterface(
            [
                ObjectList(content_panels, heading="Obsah"),
                ObjectList(promote_panels, heading="Propagovat"),
                ObjectList(settings_panels, heading="Nastavení"),
                ObjectList(MenuMixin.menu_panels, heading="Menu"),
            ]
        )
    
        ### RELATIONS
    
        subpage_types = [
            "district.DistrictArticlesPage",
            "district.DistrictCenterPage",
            "district.DistrictContactPage",
            "district.DistrictCrossroadPage",
            "district.DistrictCustomPage",
            "district.DistrictElectionRootPage",
            "district.DistrictPeoplePage",
            "district.DistrictProgramPage",
            "district.DistrictInteractiveProgramPage",
            "district.DistrictGeoFeatureCollectionPage",
            "district.DistrictCalendarPage",
            "district.DistrictPdfPage",
        ]
    
        ### OTHERS
    
        class Meta:
            verbose_name = "Oblastní sdružení"
    
        @route(r"^sdilene/$", name="shared")
        def shared(self, request):
            return self.setup_article_page_context(request)
    
        def _first_subpage_of_type(self, page_type) -> Page or None:
            try:
                return self.get_descendants().type(page_type).live().specific()[0]
            except IndexError:
                return None
    
        @property
        def articles(self):
            return self.materialize_shared_articles_query(
                self.append_all_shared_articles_query(
                    DistrictArticlePage.objects.descendant_of(self)
                )[:6]
            )
    
        @property
        def articles_page(self):
            return self._first_subpage_of_type(DistrictArticlesPage)
    
        @property
        def center_page(self):
            return self._first_subpage_of_type(DistrictCenterPage)
    
        @property
        def contact_page(self):
            return self._first_subpage_of_type(DistrictContactPage)
    
        @property
        def election_page(self):
            return self._first_subpage_of_type(DistrictElectionRootPage)
    
        @staticmethod
        def get_404_response(request):
            return render(request, "district/404.html", status=404)
    
        @property
        def people_page(self):
            return self._first_subpage_of_type(DistrictPeoplePage)
    
        @property
        def program_page(self):
            return self._first_subpage_of_type(DistrictProgramPage)
    
        @property
        def interactive_program_page(self):
            return self._first_subpage_of_type(DistrictInteractiveProgramPage)
    
        @property
        def root_page(self):
            return self
    
        @property
        def has_calendar(self):
            return self.calendar_id is not None
    
        @property
        def newsletter_info(self):
            if not self.newsletter_list_id:
                return None
            return {
                "list_id": self.newsletter_list_id,
                "description": self.newsletter_description,
            }
    
    
    class DistrictArticleTag(TaggedItemBase):
        content_object = ParentalKey(
            "district.DistrictArticlePage",
            on_delete=models.CASCADE,
            related_name="tagged_items",
        )
    
    
    class DistrictArticlePage(
        ArticleMixin, ExtendedMetadataPageMixin, SubpageMixin, MetadataPageMixin, Page
    ):
        ### FIELDS
    
        author_page = models.ForeignKey(
            "district.DistrictPersonPage", on_delete=models.SET_NULL, null=True, blank=True
        )
        is_black = models.BooleanField("Má tmavé pozadí?", default=False)
        tags = ClusterTaggableManager(through=DistrictArticleTag, blank=True)
        shared_tags = ClusterTaggableManager(
            verbose_name="Tagy pro sdílení mezi weby",
            through=SharedTaggedDistrictArticle,
            blank=True,
        )
        thumb_image = models.ForeignKey(
            "wagtailimages.Image",
            on_delete=models.PROTECT,
            blank=True,
            null=True,
            verbose_name="náhledový obrázek",
            related_name="thumb_image",
        )
    
        search_fields = ArticleMixin.search_fields + [
            index.SearchField("author_page"),
            index.FilterField("slug"),
        ]
    
        ### PANELS
    
        content_panels = ArticleMixin.content_panels + [
            FieldPanel("author_page"),
            FieldPanel("is_black"),
            FieldPanel("tags"),
            FieldPanel("shared_tags"),
            FieldPanel("thumb_image"),
        ]
    
        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 = ["district.DistrictArticlesPage"]
        subpage_types = ["district.DistrictPdfPage"]
    
        ### OTHERS
    
        class Meta:
            verbose_name = "Aktualita"
    
        def clean(self):
            cleaned_data = super().clean()
    
            if not self.image and not self.thumb_image:
                raise ValidationError("Musí být nahraný buď obrázek nebo náhledový obrázek")
    
            return cleaned_data
    
        def get_context(self, request):
            context = super().get_context(request)
            context["related_articles"] = (
                (
                    self.get_siblings(inclusive=False)
                    .live()  # TODO? filtrovat na stejné tagy? nebo sdílené články?
                    .specific()
                    .order_by("-districtarticlepage__timestamp")[:3]
                )
                if self.shared_from is None
                else []
            )
            return context
    
    
    class DistrictArticlesPage(
        RoutablePageMixin,
        ExtendedMetadataPageMixin,
        SubpageMixin,
        MetadataPageMixin,
        ArticlesPageMixin,
        Page,
    ):
        ### FIELDS
    
        last_import_log = models.TextField(
            "Výstup z posledního importu", null=True, blank=True
        )
        max_items = models.IntegerField("Počet článků na stránce", default=12)
    
        ### PANELS
    
        content_panels = ArticlesPageMixin.content_panels + [
            FieldPanel("max_items"),
        ]
    
        promote_panels = make_promote_panels()
    
        import_panels = [
            MultiFieldPanel(
                [
                    FieldPanel("do_import"),
                    FieldPanel("collection"),
                    FieldPanel("dry_run"),
                    FieldPanel("use_git"),
                    FieldPanel("jekyll_repo_url"),
                    FieldPanel("readonly_log"),
                    HelpPanel(
                        mark_safe(
                            "Import provádějte vždy až po vytvoření stránky aktualit. "
                            "Pro uložení logu je nutné volit možnost <strong>Publikovat</strong>, nikoliv "
                            "pouze <strong>Uložit koncept</strong>. "
                            "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",
            ),
        ]
    
        ### EDIT HANDLERS
    
        edit_handler = TabbedInterface(
            [
                ObjectList(content_panels, heading="Obsah"),
                ObjectList(promote_panels, heading="Propagovat"),
                ObjectList(import_panels, heading="Import"),
            ]
        )
    
        ### RELATIONS
    
        parent_page_types = ["district.DistrictHomePage"]
        subpage_types = ["district.DistrictArticlePage"]
    
        ### OTHERS
    
        base_form_class = JekyllImportForm
    
        class Meta:
            verbose_name = "Aktuality"
    
        def get_context(self, request):
            context = super().get_context(request)
            context["articles"] = self.get_page_with_shared_articles(
                self.append_all_shared_articles_query(
                    DistrictArticlePage.objects.child_of(self)
                ),
                self.max_items,
                request.GET.get("page", 1),
            )
            return context
    
        @route(r"^tagy/$", name="tags")
        def tags(self, request):
            return render(
                request,
                "district/district_tags_page.html",
                context=self.get_tags_page_context(request=request),
            )
    
        @route(r"^sdilene/$", name="shared")
        def shared(self, request):
            return self.setup_article_page_context(request)
    
        def get_tags_page_context(self, request) -> dict:
            # Potřebujeme IDčka článků pro správnou root_page pro filtrování zobrazených
            # tagů i samotných stránek, protože se filtrují přes specifický field
            # (tags__slug)
            context = super().get_context(request)
    
            articles = self.materialize_articles_as_id_only(
                self.append_all_shared_articles_query(
                    DistrictArticlePage.objects.child_of(self)
                )
            )
    
            page_ids = list(map(lambda article: article.page_ptr.id, articles))
    
            # Naplním "tag" a "article_page_list" parametry
            context.update(**self.get_tag_and_articles(request, page_ids))
            context["tag_list"] = self.get_tag_qs(articles)
    
            # Pro obecnou paginaci posílám "extra_query", abych si podržel tag pro další GET
            context["extra_query"] = "&tag={}".format(request.GET.get("tag", ""))
            return context
    
        def get_tag_and_articles(self, request, page_ids: list) -> dict:
            """
            Vrátí vyfiltrované články podle tagu a page query pro z daného "výběru"
            pro danou stránku (site_article_ids). Lepší by bylo články a tag řešit
            separátně, ale pak by se musel rozpadnout ten try/except na více bloků.
            """
    
            article_page_qs = None
            tag = None
    
            try:
                tag = Tag.objects.filter(slug=request.GET["tag"])[0]
                article_page_qs = self.append_all_shared_articles_query(
                    DistrictArticlePage.objects.filter(
                        page_ptr_id__in=page_ids, tags__slug=tag.slug
                    ),
                    custom_article_query=lambda shared: shared.filter(
                        page_ptr_id__in=page_ids, tags__slug=tag.slug
                    ),
                )
            except (KeyError, IndexError):
                tag = None
                article_page_qs = self.append_all_shared_articles_query(
                    DistrictArticlePage.objects.filter(page_ptr_id__in=page_ids),
                    custom_article_query=lambda shared: shared.filter(
                        page_ptr_id__in=page_ids
                    ),
                )
    
            return {
                "article_page_list": self.get_page_with_shared_articles(
                    article_page_qs, self.max_items, request.GET.get("page", 1)
                ),
                "tag": tag,
            }
    
        def get_tag_qs(self, articles: list) -> models.QuerySet:
            """
            Getuje Tagy pouze pro DistrictArticlePage omezeno IDčky getnutých přes
            root_page. Počítá, kolik článků je s daným tagem.
            """
            return self.search_tags_with_count(articles)
    
    
    class DistrictContactPage(
        ExtendedMetadataPageMixin, SubpageMixin, MetadataPageMixin, Page
    ):
        ### FIELDS
    
        contact_people = StreamField(
            [("item", blocks.PersonCustomPositionBlock())],
            verbose_name="Kontakty",
            blank=True,
            use_json_field=True,
        )
        text = RichTextField("Text", blank=True, features=RICH_TEXT_DEFAULT_FEATURES)
    
        ### PANELS
    
        content_panels = Page.content_panels + [
            FieldPanel("contact_people"),
            FieldPanel("text"),
        ]
    
        promote_panels = make_promote_panels()
    
        settings_panels = []
    
        ### RELATIONS
    
        parent_page_types = ["district.DistrictHomePage"]
        subpage_types = []
    
        ### OTHERS
    
        class Meta:
            verbose_name = "Kontakty"
    
    
    class DistrictPersonTag(TaggedItemBase):
        content_object = ParentalKey(
            "district.DistrictPersonPage",
            on_delete=models.CASCADE,
            related_name="tagged_items",
        )
    
    
    class DistrictPersonPage(
        ExtendedMetadataPageMixin,
        SubpageMixin,
        MetadataPageMixin,
        CalendarMixin,
        Page,
    ):
        ### FIELDS
        job = models.CharField(
            "Povolání",
            max_length=128,
            blank=True,
            null=True,
            help_text="Např. 'Informatik'",
        )
        job_function = models.CharField(
            "Funkce", max_length=128, blank=True, null=True, help_text="Např. 'Předseda'"
        )
        background_photo = models.ForeignKey(
            "wagtailimages.Image",
            on_delete=models.PROTECT,
            blank=True,
            null=True,
            related_name="+",
            verbose_name="obrázek do záhlaví",
        )
        profile_photo = models.ForeignKey(
            "wagtailimages.Image",
            on_delete=models.PROTECT,
            blank=True,
            null=True,
            related_name="+",
            verbose_name="profilová fotka",
        )
        text = RichTextField("text", blank=True, features=RICH_TEXT_DEFAULT_FEATURES)
    
        email = models.EmailField("Email", null=True, blank=True)
        show_email = models.BooleanField("Zobrazovat email na stránce?", default=True)
        phone = models.CharField("Telefon", max_length=16, blank=True, null=True)
        city = models.CharField("Město/obec", max_length=64, blank=True, null=True)
        age = models.IntegerField("Věk", blank=True, null=True)
        is_pirate = models.BooleanField("Je členem Pirátské strany?", default=True)
        other_party = models.CharField(
            "Strana",
            max_length=64,
            blank=True,
            null=True,
            help_text="Vyplňte pokud osoba není Pirát",
        )
        other_party_logo = models.ForeignKey(
            "wagtailimages.Image",
            on_delete=models.PROTECT,
            blank=True,
            null=True,
            related_name="+",
            verbose_name="Logo strany",
            help_text="Vyplňte pokud osoba není Pirát",
        )
    
        facebook_url = models.URLField("Odkaz na Facebook", blank=True, null=True)
        instagram_url = models.URLField("Odkaz na Instagram", blank=True, null=True)
        twitter_url = models.URLField("Odkaz na Twitter", blank=True, null=True)
        youtube_url = models.URLField("Odkaz na Youtube kanál", blank=True, null=True)
        flickr_url = models.URLField("Odkaz na Flickr", blank=True, null=True)
        custom_web_url = models.URLField("Odkaz na vlastní web", blank=True, null=True)
        other_urls = StreamField(
            [("other_url", blocks.PersonUrlBlock())],
            verbose_name="Další odkaz",
            blank=True,
            use_json_field=True,
        )
    
        ### PANELS
    
        content_panels = Page.content_panels + [
            MultiFieldPanel(
                [
                    FieldPanel("job"),
                    FieldPanel("job_function"),
                ],
                "Základní údaje",
            ),
            MultiFieldPanel(
                [
                    FieldPanel("profile_photo"),
                    FieldPanel("background_photo"),
                ],
                "Fotky",
            ),
            FieldPanel("text"),
            MultiFieldPanel(
                [
                    FieldPanel("email"),
                    FieldPanel("show_email"),
                    FieldPanel("phone"),
                    FieldPanel("city"),
                    FieldPanel("age"),
                    FieldPanel("is_pirate"),
                    FieldPanel("other_party"),
                    FieldPanel("other_party_logo"),
                ],
                "Kontaktní informace",
            ),
            FieldPanel("calendar_url"),
            MultiFieldPanel(
                [
                    FieldPanel("facebook_url"),
                    FieldPanel("instagram_url"),
                    FieldPanel("twitter_url"),
                    FieldPanel("youtube_url"),
                    FieldPanel("flickr_url"),
                    FieldPanel("custom_web_url"),
                    FieldPanel("other_urls"),
                ],
                "Sociální sítě",
            ),
        ]
    
        settings_panels = []
    
        ### RELATIONS
    
        parent_page_types = ["district.DistrictPeoplePage"]
        subpage_types = []
    
        ### OTHERS
    
        class Meta:
            verbose_name = "Detail osoby"
            ordering = ("title",)
    
        def get_background_photo(self):
            """
            Vrací background_photo pro pozadí na stránce, pokud není nastaveno,
            vezme falbback z homepage
            """
            return (
                self.background_photo
                if self.background_photo
                else self.root_page.fallback_image
            )
    
        def get_context(self, request):
            context = super().get_context(request)
            # Na strance detailu cloveka se vpravo zobrazuji 3 dalsi nahodne profily
            context["random_people"] = list(
                self.get_siblings(inclusive=False).live().specific()
            )
            random.shuffle(context["random_people"])
            context["random_people"] = context["random_people"][:3]
            return context
    
        def get_meta_image(self):
            return self.search_image or self.profile_photo
    
        def get_meta_description(self):
            if self.search_description:
                return self.search_description
    
            if self.text:
                return trim_to_length(strip_all_html_tags(self.text))
    
            return None
    
    
    class DistrictPeoplePage(
        ExtendedMetadataPageMixin, SubpageMixin, MetadataPageMixin, Page
    ):
        ### FIELDS
    
        content = StreamField(
            [
                (
                    "text",
                    blocks.RichTextBlock(
                        label="Textový editor", features=RICH_TEXT_DEFAULT_FEATURES
                    ),
                ),
                ("people_group", blocks.PeopleGroupListBlock()),
            ],
            verbose_name="Obsah stránky",
            blank=True,
            use_json_field=True,
        )
    
        ### PANELS
    
        content_panels = Page.content_panels + [FieldPanel("content")]
    
        promote_panels = make_promote_panels()
    
        settings_panels = []
    
        ### RELATIONS
    
        parent_page_types = ["district.DistrictHomePage"]
        subpage_types = ["district.DistrictPersonPage"]
    
        ### OTHERS
    
        class Meta:
            verbose_name = "Lidé"
    
    
    class DistrictElectionBasePage(
        ExtendedMetadataPageMixin, SubpageMixin, MetadataPageMixin, Page
    ):
        ### FIELDS
    
        content = StreamField(
            CONTENT_BLOCKS
            + [
                ("badge", blocks.PersonBadgeBlock()),
            ],
            verbose_name="Obsah",
            blank=True,
            use_json_field=True,
        )
    
        campaign_funding_info = models.URLField(
            "URL pro zjištění informací o financování kampaně",
            blank=True,
            null=True,
            help_text="Pokud ponecháte prázdné, použije se buď odkaz z nadřazené stránky, nebo https://wiki.pirati.cz/ft/start.",
        )
    
        ### PANELS
    
        content_panels = Page.content_panels + [
            FieldPanel("content"),
            FieldPanel("campaign_funding_info"),
        ]
    
        promote_panels = make_promote_panels()
    
        settings_panels = []
    
        class Meta:
            abstract = True
    
        @cached_property
        def root_election_page(self):
            if isinstance(self, DistrictElectionRootPage):
                return self
    
            return (
                self.get_ancestors()
                .type(DistrictElectionRootPage)
                .live()
                .specific()
                .first()
            )
    
    
    class DistrictElectionSubCampaignPageMixin:
        @cached_property
        def campaign_page(self):
            return (
                self.get_ancestors()
                .type(DistrictElectionCampaignPage)
                .live()
                .specific()
                .first()
            )
    
    
    class DistrictPostElectionStrategyPage(
        DistrictElectionSubCampaignPageMixin, DistrictElectionBasePage
    ):
        ### FIELDS
    
        perex = models.TextField("Perex", help_text="Pro přehled volebního programu")
    
        content_panels = DistrictElectionBasePage.content_panels + [
            FieldPanel("perex"),
        ]
    
        ### RELATIONS
    
        parent_page_types = ["district.DistrictElectionCampaignPage"]
        subpage_types = []
    
        class Meta:
            verbose_name = "Povolební strategie"
    
        def get_meta_description(self):
            if self.search_description:
                return self.search_description
            return self.perex
    
    
    class DistrictElectionProgramPage(
        DistrictElectionSubCampaignPageMixin, DistrictElectionBasePage
    ):
        ### FIELDS
    
        guarantor = models.ForeignKey(
            "district.DistrictPersonPage",
            verbose_name="Garant",
            on_delete=models.PROTECT,
            blank=True,
            null=True,
        )
        image = models.ForeignKey(
            "wagtailimages.Image",
            verbose_name="Ilustrační obrázek",
            on_delete=models.PROTECT,
            related_name="+",
        )
        perex = models.TextField("Perex", help_text="Pro přehled volebního programu")
    
        ### PANELS
    
        content_panels = DistrictElectionBasePage.content_panels + [
            PageChooserPanel("guarantor"),
            FieldPanel("image"),
            FieldPanel("perex"),
        ]
    
        ### RELATIONS
    
        parent_page_types = ["district.DistrictElectionCampaignPage"]
        subpage_types = []
    
        class Meta:
            verbose_name = "Bod programu voleb"
    
        def get_meta_image(self):
            return self.search_image or self.image or self.root_page.get_meta_image()
    
        def get_meta_description(self):
            if self.search_description:
                return self.search_description
            return self.perex
    
    
    class DistrictElectionCampaignPage(DistrictElectionBasePage):
        ### FIELDS
        number = models.CharField(
            "Zvolené číslo kandidátní listiny", blank=True, null=True, max_length=4
        )
        candidates = StreamField(
            [
                ("candidates", blocks.CandidateListBlock()),
            ],
            verbose_name="Kandidátní listina",
            blank=True,
            use_json_field=True,
        )
        candidate_list_title = models.CharField(
            "Titulek kandidátní listiny",
            blank=True,
            null=True,
            max_length=128,
            help_text="Např. Kandidátní listina pro magistrát.",
        )
        program_point_list_title = models.CharField(
            "Titulek programové sekce",
            blank=True,
            null=True,
            max_length=128,
            help_text="Např. Program pro magistrát.",
        )
        show_program_points_inline = models.BooleanField(
            "Zobrazit obsah programu na jedné stránce",
            default=False,
            help_text="Hodí se v případě spousty krátkých bodů programu, z nichž si většina nezaslouží vlastní stránku.",
        )
        hero_headline = models.CharField(
            "Banner headline",
            max_length=128,
            blank=True,
            null=True,
            help_text="Použije se v hlavním banneru. Pokud je toto hlavní kampaň voleb, nebo ve vaší obci je jen jedna jediná kandidátní listina, můžete ponechat prázdné a pro titulek se pak použije titulek root volební stránky.",
        )
        hero_motto = models.CharField(
            "Motto/claim pro banner",
            max_length=128,
            blank=True,
            null=True,
            help_text="Použije se v hlavním banneru.",
        )
        hero_cta_buttons = StreamField(
            [
                ("button_group", ButtonGroupBlock()),
            ],
            verbose_name="CTAs pro banner",
            blank=True,
            null=True,
            help_text="Použije se v hlavním banneru.",
            use_json_field=True,
        )
        hero_image = models.ForeignKey(
            "wagtailimages.Image",
            on_delete=models.PROTECT,
            related_name="+",
            verbose_name="Ilustrační obrázek pro banner",
            help_text="Pokud ponecháte prázdné, použije se výchozí obrázek stránek.",
            null=True,
            blank=True,
        )
        hero_candidates_image = models.ForeignKey(
            "wagtailimages.Image",
            on_delete=models.PROTECT,
            related_name="+",
            verbose_name="Obrázek s kandidáty pro banner",
            help_text="Použije se jako overlay v hlavním banneru a proto by fotka měla mít průhlednost aby to nevypadalo divně.",
            null=True,
            blank=True,
        )
        order = models.SmallIntegerField(
            "Pořadí",
            default=0,
            help_text="Čím nižší pořadí, tím důležitější kampaň je. Bude použito při řazených výpisech a nejdůležitější kampaň bude automaticky routovaná pokud někdo přistoupí na volební rozcestník.",
        )
    
        ### PANELS
    
        content_panels = Page.content_panels + [
            HelpPanel(
                "<strong>TIP: </strong>Body programu a stránku povolební strategie přidávejte jako podstránky této stránky"
            ),
            FieldPanel("candidates"),
            MultiFieldPanel(
                [
                    FieldPanel("number"),
                    FieldPanel("candidate_list_title"),
                    FieldPanel("program_point_list_title"),
                    FieldPanel("show_program_points_inline"),
                    FieldPanel("content"),
                ],
                "Personalizace",
            ),
            MultiFieldPanel(
                [
                    FieldPanel("hero_headline"),
                    FieldPanel("hero_motto"),
                    FieldPanel("hero_image"),
                    FieldPanel("hero_candidates_image"),
                    FieldPanel("hero_cta_buttons"),
                ],
                "Hero banner",
            ),
            FieldPanel("order"),
            FieldPanel("campaign_funding_info"),
        ]
    
        ### RELATIONS
    
        parent_page_types = ["district.DistrictElectionRootPage"]
        subpage_types = [
            "district.DistrictElectionProgramPage",
            "district.DistrictPostElectionStrategyPage",
        ]
    
        class Meta:
            verbose_name = "Kandidatura"
    
        @cached_property
        def post_election_strategy(self):
            return (
                self.get_children()
                .type(DistrictPostElectionStrategyPage)
                .live()
                .specific()
                .first()
            )
    
        @cached_property
        def program_points(self):
            return self.get_children().type(DistrictElectionProgramPage).live().specific()
    
        def get_meta_image(self):
            return (
                self.search_image
                or self.hero_candidates_image
                or self.hero_image
                or self.root_page.get_meta_image()
            )
    
        def get_meta_description(self):
            if self.search_description:
                return self.search_description
    
            return self.hero_motto
    
    
    class DistrictElectionRootPage(RoutablePageMixin, Page):
        """The election root page only serves as a wrapper for sharing stuff among campaign pages.
    
        It is never rendered on its own. When accessed, it will automatically redirect to the first campaign page.
        """
    
        ### PANELS
    
        ### RELATIONS
    
        parent_page_types = [
            "district.DistrictHomePage",
            "district.DistrictCustomPage",
            "district.DistrictCrossroadPage",
        ]
        subpage_types = [
            "district.DistrictElectionCampaignPage",
            "district.DistrictGeoFeatureCollectionPage",
        ]
    
        ### OTHERS
    
        class Meta:
            verbose_name = "Volební rozcestník"
    
        @cached_property
        def campaigns(self):
            return (
                self.get_children()
                .type(DistrictElectionCampaignPage)
                .live()
                .order_by("districtelectioncampaignpage__order")
            )
    
        @cached_property
        def primary_campaign(self):
            return self.campaigns.first()
    
        @route(r"^$")
        def index(self, request):
            """When accessed, redirect to first campaign page available or trigger 404."""
            if not self.primary_campaign:
                return HttpResponseNotFound()
    
            return HttpResponseRedirect(self.primary_campaign.get_url())
    
    
    class DistrictInteractiveProgramPage(
        ExtendedMetadataPageMixin, SubpageMixin, MetadataPageMixin, Page
    ):
        ### FIELDS
    
        perex = models.TextField("Perex", blank=True)
        content = StreamField(
            [("interactive_program_block", blocks.InteractiveProgramBlock())],
            verbose_name="Části programu",
            blank=False,
            use_json_field=True,
        )
    
        ### PANELS
    
        content_panels = Page.content_panels + [
            FieldPanel("perex"),
            FieldPanel("content"),
        ]
    
        promote_panels = make_promote_panels()
    
        settings_panels = []
    
        ### RELATIONS
    
        parent_page_types = ["district.DistrictHomePage"]
        subpage_types = []
    
        ### OTHERS
    
        class Meta:
            verbose_name = "Interaktivní program"
    
        def save(self, **kwargs):
            from redmine_utils.functions import fill_data_from_redmine_for_page
    
            fill_data_from_redmine_for_page(self)
            return super().save(**kwargs)
    
        def get_meta_description(self):
            if self.search_description:
                return self.search_description
            return self.perex
    
    
    class DistrictProgramPage(
        ExtendedMetadataPageMixin, SubpageMixin, MetadataPageMixin, Page
    ):
        ### FIELDS
    
        perex = models.TextField("Perex", blank=True)
        content = StreamField(
            [
                ("static_program_block", blocks.StaticProgramBlock()),
                ("redmine_program_block", blocks.RedmineProgramBlock()),
            ],
            verbose_name="obsah stránky",
            blank=True,
            use_json_field=True,
        )
    
        ### PANELS
    
        content_panels = Page.content_panels + [
            FieldPanel("perex"),
            FieldPanel("content"),
        ]
    
        promote_panels = make_promote_panels()
    
        settings_panels = []
    
        ### RELATIONS
    
        parent_page_types = ["district.DistrictHomePage"]
        subpage_types = []
    
        ### OTHERS
    
        class Meta:
            verbose_name = "Plnění programu"
    
        def save(self, **kwargs):
            from redmine_utils.functions import fill_data_from_redmine_for_page
    
            fill_data_from_redmine_for_page(self)
            return super().save(**kwargs)
    
        def get_meta_description(self):
            if self.search_description:
                return self.search_description
            return self.perex
    
    
    class DistrictCenterPage(
        CalendarMixin, ExtendedMetadataPageMixin, SubpageMixin, MetadataPageMixin, Page
    ):
        ### FIELDS
    
        calendar_page = models.ForeignKey(
            "DistrictCalendarPage",
            verbose_name="Stránka s kalendářem",
            on_delete=models.PROTECT,
            null=True,
            blank=True,
        )
    
        perex = models.TextField("Perex", blank=True, null=True)
        background_photo = models.ForeignKey(
            "wagtailimages.Image",
            on_delete=models.PROTECT,
            blank=True,
            null=True,
            related_name="+",
        )
        content = StreamField(
            CONTENT_BLOCKS
            + [
                ("badge", blocks.PersonBadgeBlock()),
            ],
            verbose_name="Obsah",
            blank=True,
            use_json_field=True,
        )
        text = RichTextField("Text", blank=True, null=True)
        sidebar_content = StreamField(
            [
                ("map", MapPointBlock()),
                ("figure", FigureBlock()),
                ("youtube", YouTubeVideoBlock(label="YouTube video")),
                ("address", blocks.AddressBlock()),
                ("contact", blocks.CenterContactBlock()),
                ("badge", blocks.PersonBadgeBlock()),
            ],
            verbose_name="Obsah bočního panelu",
            blank=True,
            use_json_field=True,
        )
    
        ### PANELS
    
        content_panels = Page.content_panels + [
            FieldPanel("perex"),
            FieldPanel("background_photo"),
            FieldPanel("text"),
            FieldPanel("content"),
            MultiFieldPanel(
                [
                    FieldPanel("calendar_url"),
                    PageChooserPanel("calendar_page"),
                ],
                "Kalendář",
            ),
            PageChooserPanel("calendar_page"),
            FieldPanel("sidebar_content"),
        ]
    
        promote_panels = make_promote_panels()
    
        settings_panels = []
    
        ### RELATIONS
    
        parent_page_types = ["district.DistrictHomePage"]
        subpage_types = ["district.DistrictCalendarPage"]
    
        ### OTHERS
    
        class Meta:
            verbose_name = "Stránka pirátského centra"
    
        def get_background_photo(self):
            """
            Vrací background_photo pro pozadí na stránce, pokud není nastaveno,
            vezme falbback z homepage
            """
            return (
                self.background_photo
                if self.background_photo
                else self.root_page.fallback_image
            )
    
        @property
        def has_calendar(self):
            return self.calendar_id is not None
    
        def get_meta_image(self):
            return (
                self.search_image
                or self.background_photo
                or self.root_page.get_meta_image()
            )
    
        def get_meta_description(self):
            if self.search_description:
                return self.search_description
    
            desc = None
    
            if self.perex:
                desc = self.perex
            elif self.text:
                desc = trim_to_length(strip_all_html_tags(self.text))
    
            return desc
    
    
    class DistrictCrossroadPage(
        ExtendedMetadataPageMixin, SubpageMixin, MetadataPageMixin, Page
    ):
        ### FIELDS
    
        cards_content = StreamField(
            [("cards", blocks.CardLinkWithHeadlineBlock())],
            verbose_name="Karty rozcestníku",
            blank=True,
            use_json_field=True,
        )
    
        content = StreamField(
            CONTENT_BLOCKS
            + [
                ("badge", blocks.PersonBadgeBlock()),
                ("people_group", blocks.PeopleGroupListBlock()),
            ],
            verbose_name="Obsah stránky",
            blank=True,
            use_json_field=True,
        )
    
        ### PANELS
    
        content_panels = Page.content_panels + [
            FieldPanel("cards_content"),
            FieldPanel("content"),
        ]
    
        promote_panels = make_promote_panels()
    
        settings_panels = []
    
        ### RELATIONS
    
        parent_page_types = ["district.DistrictHomePage"]
        subpage_types = [
            "district.DistrictArticlePage",
            "district.DistrictArticlesPage",
            "district.DistrictCenterPage",
            "district.DistrictContactPage",
            "district.DistrictCrossroadPage",
            "district.DistrictCustomPage",
            "district.DistrictElectionRootPage",
            "district.DistrictGeoFeatureCollectionPage",
            "district.DistrictPeoplePage",
            "district.DistrictPersonPage",
            "district.DistrictProgramPage",
            "district.DistrictInteractiveProgramPage",
        ]
        ### OTHERS
    
        class Meta:
            verbose_name = "Rozcestník s kartami"
    
    
    class DistrictCustomPage(
        ExtendedMetadataPageMixin,
        SubpageMixin,
        MetadataPageMixin,
        RoutablePageMixin,
        Page,
    ):
        ### FIELDS
    
        content = StreamField(
            CONTENT_BLOCKS
            + [
                ("badge", blocks.PersonBadgeBlock()),
                ("people_group", blocks.PeopleGroupListBlock()),
                ("newsletter", NewsletterSubscriptionBlock()),
            ],
            verbose_name="Obsah",
            blank=True,
            use_json_field=True,
        )
    
        ### PANELS
    
        content_panels = Page.content_panels + [
            FieldPanel("content"),
        ]
    
        promote_panels = make_promote_panels()
    
        settings_panels = []
    
        ### RELATIONS
    
        parent_page_types = ["district.DistrictHomePage", "district.DistrictCrossroadPage"]
        subpage_types = ["district.DistrictElectionRootPage"]
    
        ### OTHERS
    
        class Meta:
            verbose_name = "Libovolná vlastní stránka"
    
    
    class DistrictGeoFeatureCollectionPage(
        ExtendedMetadataPageMixin, SubpageMixin, MetadataPageMixin, Page
    ):
        ### FIELDS
        perex = models.TextField("Perex", null=True)
        hero_cta_buttons = StreamField(
            [
                ("button_group", ButtonGroupBlock()),
            ],
            verbose_name="CTAs pro banner",
            blank=True,
            null=True,
            help_text="Použije se v hlavním banneru.",
            use_json_field=True,
        )
        content = StreamField(
            CONTENT_BLOCKS
            + [
                ("badge", blocks.PersonBadgeBlock()),
                ("people_group", blocks.PeopleGroupListBlock()),
            ],
            verbose_name="Obsah úvodní",
            blank=True,
            use_json_field=True,
        )
        content_after = StreamField(
            CONTENT_BLOCKS
            + [
                ("badge", blocks.PersonBadgeBlock()),
                ("people_group", blocks.PeopleGroupListBlock()),
            ],
            verbose_name="Obsah za mapou",
            blank=True,
            use_json_field=True,
        )
        content_footer = StreamField(
            CONTENT_BLOCKS
            + [
                ("badge", blocks.PersonBadgeBlock()),
                ("people_group", blocks.PeopleGroupListBlock()),
            ],
            verbose_name="Obsah v patičkové části",
            blank=True,
            use_json_field=True,
        )
        image = models.ForeignKey(
            "wagtailimages.Image",
            on_delete=models.PROTECT,
            blank=True,
            null=True,
            verbose_name="Obrázek na pozadí",
            related_name="+",
        )
        logo_image = models.ForeignKey(
            "wagtailimages.Image",
            on_delete=models.PROTECT,
            blank=True,
            null=True,
            verbose_name="Logo",
            related_name="+",
        )
        style = models.CharField(
            "Styl mapy", choices=MAP_STYLES, max_length=50, default=DEFAULT_MAP_STYLE
        )
        map_title = models.TextField("Titulek mapy", blank=True, null=True)
        category_list_title = models.TextField(
            "Titulek přehledu dle kategorie", blank=True, null=True
        )
        promoted_block_title = models.TextField(
            "Titulek bloku propagovaných položek", blank=True, null=True
        )
    
        ### PANELS
    
        content_panels = Page.content_panels + [
            MultiFieldPanel(
                [
                    FieldPanel("perex"),
                    FieldPanel("hero_cta_buttons"),
                    FieldPanel("content"),
                    FieldPanel("content_after"),
                    FieldPanel("content_footer"),
                    FieldPanel("promoted_block_title"),
                    FieldPanel("logo_image"),
                    FieldPanel("image"),
                ],
                "Obsah hlavní stránky kolekce",
            ),
            MultiFieldPanel(
                [
                    InlinePanel("categories"),
                    FieldPanel("category_list_title"),
                ],
                "Kategorie",
            ),
            MultiFieldPanel(
                [
                    FieldPanel("map_title"),
                    FieldPanel("style"),
                ],
                "Nastavení mapy",
            ),
        ]
    
        settings_panels = []
    
        ### RELATIONS
    
        parent_page_types = [
            "district.DistrictHomePage",
            "district.DistrictElectionRootPage",
        ]
        subpage_types = ["district.DistrictGeoFeatureDetailPage"]
    
        class Meta:
            verbose_name = "Stránka s mapovou kolekcí"
    
        def get_features_by_category(self):
            features = (
                self.get_children()
                .live()
                .specific()
                .prefetch_related("category")
                .order_by("districtgeofeaturedetailpage__sort_order")
            )
            categories = sorted(set(f.category for f in features), key=lambda c: c.name)
    
            return [
                (category, [f for f in features if f.category == category])
                for category in categories
            ]
    
        def get_promoted_features(self):
            return (
                self.get_children()
                .live()
                .specific()
                .filter(districtgeofeaturedetailpage__promoted=True)
                .order_by("districtgeofeaturedetailpage__sort_order")
            )
    
        def get_context(self, request):
            context = super().get_context(request)
            features_by_category = self.get_features_by_category()
    
            context["features_by_category"] = features_by_category
            context["promoted_features"] = self.get_promoted_features()
            context["js_map"] = {
                "tile_server_config": json.dumps(TILE_SERVER_CONFIG),
                "style": self.style,
                "categories": json.dumps(
                    [
                        # Gather all categories used in collection
                        {"name": c.name, "color": c.hex_color}
                        for c, f in features_by_category
                    ]
                ),
                "geojson": json.dumps(
                    [
                        f.as_geojson_object(request)
                        for c, features in features_by_category
                        for f in features
                    ]
                ),
            }
    
            return context
    
        def get_meta_image(self):
            return (
                self.search_image
                or self.logo_image
                or self.image
                or self.root_page.get_meta_image()
            )
    
        def get_meta_description(self):
            if self.search_description:
                return self.search_description
            return self.perex
    
    
    class DistrictGeoFeatureCollectionCategory(Orderable):
        name = models.CharField("Název", max_length=100)
        hex_color = models.CharField(
            "Barva (HEX)",
            max_length=6,
            help_text="Zadejte barvu pomocí HEX notace (bez # na začátku).",
        )
        page = ParentalKey(
            DistrictGeoFeatureCollectionPage,
            on_delete=models.CASCADE,
            related_name="categories",
        )
    
        ### PANELS
    
        panels = [
            FieldPanel("name"),
            FieldPanel("hex_color"),
        ]
    
        class Meta:
            verbose_name = "Kategorie mapové kolekce"
    
        def __str__(self) -> str:
            return f"{self.page} / {self.name}"
    
        @property
        def rgb(self):
            return tuple(int(self.hex_color[i : i + 2], 16) for i in (0, 2, 4))
    
    
    def make_feature_index_cache_key(feature: "DistrictGeoFeatureDetailPage"):
        return f"DistrictGeoFeatureDetailPage::{feature.id}::index"
    
    
    class DistrictCalendarPage(SubpageMixin, MetadataPageMixin, CalendarMixin, Page):
        """
        Page for displaying full calendar
        """
    
        ### PANELS
    
        content_panels = Page.content_panels + [FieldPanel("calendar_url")]
    
        ### RELATIONS
    
        parent_page_types = [
            "district.DistrictCenterPage",
            "district.DistrictHomePage",
        ]
        subpage_types = []
    
        ### OTHERS
    
        class Meta:
            verbose_name = "Stránka s kalendářem"
    
    
    class DistrictGeoFeatureDetailPage(
        ExtendedMetadataPageMixin, MetadataPageMixin, SubpageMixin, Page, Orderable
    ):
        perex = models.TextField("Perex", null=False)
        geojson = models.TextField(
            "Geodata",
            help_text="Vložte surový GeoJSON objekt typu 'FeatureCollection'. Vyrobit jej můžete např. pomocí online služby geojson.io. Pokud u Feature objektů v kolekci poskytnete properties 'title' a 'description', zobrazí se jak na mapě, tak i v detailu.",
            null=False,
        )
        category = models.ForeignKey(
            "district.DistrictGeoFeatureCollectionCategory",
            verbose_name="Kategorie",
            blank=False,
            null=False,
            on_delete=models.PROTECT,
        )
        guarantor = models.ForeignKey(
            "district.DistrictPersonPage",
            verbose_name="Garant",
            on_delete=models.PROTECT,
            null=True,
            blank=True,
        )
        image = models.ForeignKey(
            "wagtailimages.Image",
            on_delete=models.PROTECT,
            blank=True,
            null=True,
            verbose_name="obrázek",
        )
        content = StreamField(
            CONTENT_BLOCKS
            + [
                ("badge", blocks.PersonBadgeBlock()),
                ("people_group", blocks.PeopleGroupListBlock()),
            ],
            verbose_name="Obsah",
            blank=True,
            use_json_field=True,
        )
        parts_section_title = models.CharField(
            "Titulek přehledu součástí",
            help_text="Pokud ponecháte prázdné, nebude se titulek zobrazovat. Sekce se vůbec nezobrazí pokud GeoJSON FeatureCollection obsahuje jen jeden Feature.",
            max_length=100,
            null=True,
            blank=True,
        )
        initial_zoom = models.IntegerField(
            "Výchozí zoom",
            default=15,
            null=False,
            blank=False,
        )
        promoted = models.BooleanField(
            "Propagovat",
            default=False,
        )
        sort_order = models.IntegerField(
            "Index řazení",
            null=True,
            blank=True,
            help_text="Čím větší hodnotu zadáte, tím později ve výpise položek bude. Můžete tím tedy ovlivňovat očíslování "
            "položky ve výpisu.",
            default=0,
        )
        sort_order_field = "sort_order"
    
        ### PANELS
    
        content_panels = [
            MultiFieldPanel(
                [
                    FieldPanel("title"),
                    FieldPanel("perex"),
                    FieldPanel("content"),
                    FieldPanel("parts_section_title"),
                    FieldPanel("image"),
                    FieldPanel("category"),
                    FieldPanel("promoted"),
                ],
                "Základní informace",
            ),
            MultiFieldPanel(
                [
                    FieldPanel("geojson"),
                    FieldPanel("initial_zoom"),
                ],
                "Mapka",
            ),
            PageChooserPanel("guarantor"),
            FieldPanel("sort_order"),
        ]
    
        settings_panels = []
    
        ### RELATIONS
    
        parent_page_types = ["district.DistrictGeoFeatureCollectionPage"]
        subpage_types = []
    
        class Meta:
            verbose_name = "Položka mapové kolekce"
            ordering = ["sort_order"]
    
        def save(self, *args, **kwargs):
            # delete all sibling index cache keys to force recompute
            keys = [
                make_feature_index_cache_key(feature)
                for feature in self.get_siblings(inclusive=True).live()
            ]
            cache.delete_many(keys)
            return super().save(*args, **kwargs)
    
        @property
        def index(self):
            key = make_feature_index_cache_key(self)
            cached_index = cache.get(key)
    
            if cached_index is None:
                cached_index = (
                    list(
                        self.get_siblings(inclusive=True)
                        .live()
                        .order_by("districtgeofeaturedetailpage__sort_order")
                        .values_list("pk", flat=True)
                    ).index(self.pk)
                    + 1
                )
                cache.set(key, cached_index)
    
            return cached_index
    
        def get_meta_image(self):
            return self.search_image or self.image
    
        def get_meta_description(self):
            if self.search_description:
                return self.search_description
            return self.perex
    
        def as_geojson_object(self, request):
            collection = json.loads(self.geojson)
            image = (
                self.image.get_rendition("fill-1200x675-c75|jpegquality-80").url
                if self.image
                else None
            )
            url = self.get_url(request) if self.content else None
    
            for idx, feature in enumerate(collection["features"]):
                feature["properties"].update(
                    {
                        "id": self.pk,
                        "index": self.index,
                        "slug": f"{idx}-{self.pk}-{self.slug}",
                        "collectionTitle": self.title,
                        "collectionDescription": self.perex,
                        "image": image,
                        "link": url,
                        "category": self.category.name,
                    }
                )
    
                if not "description" in feature["properties"]:
                    feature["properties"]["description"] = None
    
            # This extends GeoJSON spec to pass down more context to the map.
            collection["properties"] = {}
            collection["properties"]["slug"] = f"{self.pk}-{self.slug}"
            collection["properties"]["collectionTitle"] = self.title
            collection["properties"]["collectionDescription"] = self.perex
            collection["properties"]["category"] = self.category.name
            collection["properties"]["index"] = self.index
            collection["properties"]["image"] = image
            collection["properties"]["link"] = url
    
            return collection
    
        def get_context(self, request):
            context = super().get_context(request)
            context[
                "features_by_category"
            ] = self.get_parent().specific.get_features_by_category()
            feature_collection = self.as_geojson_object(request)
    
            context["js_map"] = {
                "tile_server_config": json.dumps(TILE_SERVER_CONFIG),
                "style": self.get_parent().specific.style,
                "categories": json.dumps(
                    [{"name": self.category.name, "color": self.category.hex_color}]
                ),
                "geojson": json.dumps([feature_collection]),
            }
            context["parts"] = [f["properties"] for f in feature_collection["features"]]
            return context
    
        def clean(self):
            try:
                self.geojson = maps_validators.normalize_geojson_feature_collection(
                    self.geojson, allowed_types=SUPPORTED_FEATURE_TYPES
                )
            except ValueError as exc:
                raise ValidationError({"geojson": str(exc)}) from exc
    
    
    class DistrictPdfPage(PdfPageMixin, MetadataPageMixin, SubpageMixin, Page):
        """
        Single pdf page display
        """
    
        ### RELATIONS
    
        parent_page_types = [
            "district.DistrictHomePage",
            "district.DistrictArticlePage",
        ]
        subpage_types = []
    
        ### PANELS
    
        content_panels = Page.content_panels + PdfPageMixin.content_panels
    
        ### OTHER
    
        class Meta:
            verbose_name = "PDF stránka"