Skip to content
Snippets Groups Projects
Select Git revision
  • 955855d72e919b7c0b5cbc80749a89c017acbe27
  • test default protected
  • master protected
  • niki_edit_branch
  • 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
21 results

blocks.py

Blame
  • user avatar
    Tomáš Valenta authored
    955855d7
    History
    blocks.py 34.77 KiB
    import datetime
    import json
    import logging
    import re
    import typing
    import urllib
    
    import requests
    from django.core.cache import cache
    from django.core.exceptions import ValidationError
    from django.core.files.images import ImageFile
    from django.forms.utils import ErrorList
    from wagtail import blocks
    from wagtail.blocks.struct_block import StructBlockValidationError
    from wagtail.contrib.table_block.blocks import TableBlock
    from wagtail.images.blocks import ImageChooserBlock
    from wagtail.images.models import Image
    from wagtail.models import Collection
    
    from maps_utils.blocks import MapFeatureCollectionBlock, MapPointBlock
    from shared.const import RICH_TEXT_DEFAULT_FEATURES
    
    logger = logging.getLogger(__name__)
    
    
    class GalleryBlock(blocks.StructBlock):
        gallery_items = blocks.ListBlock(
            ImageChooserBlock(label="obrázek", required=True),
            label="Galerie",
            icon="image",
            group="ostatní",
        )
    
        class Meta:
            label = "Galerie"
            icon = "image"
            template = "styleguide/2.3.x/blocks/gallery_block.html"
    
    
    class FigureBlock(blocks.StructBlock):
        img = ImageChooserBlock(label="Obrázek", required=True)
        caption = blocks.TextBlock(label="Popisek", required=False)
    
        class Meta:
            label = "Obrázek"
            icon = "image"
            template = "styleguide/2.3.x/blocks/figure_block.html"
    
    
    class MenuItemBlock(blocks.StructBlock):
        title = blocks.CharBlock(label="Titulek", required=True)
        page = blocks.PageChooserBlock(label="Stránka", required=False)
        link = blocks.URLBlock(label="Odkaz", required=False)
    
        class Meta:
            label = "Položka v menu"
            template = "styleguide/2.3.x/menu_item.html"
    
        def clean(self, value):
            errors = {}
    
            if value["page"] and value["link"]:
                errors["page"] = ErrorList(
                    ["Stránka nemůže být vybrána současně s odkazem."]
                )
                errors["link"] = ErrorList(
                    ["Odkaz nemůže být vybrán současně se stránkou."]
                )
            if errors:
                raise StructBlockValidationError(errors)
            return super().clean(value)
    
    
    class MenuParentBlock(blocks.StructBlock):
        title = blocks.CharBlock(label="Titulek", required=True)
        menu_items = blocks.ListBlock(
            MenuItemBlock(),
        )
    
        class Meta:
            label = "Podmenu"
            template = "styleguide/2.3.x/menu_parent.html"
    
    
    class ProgramItemBlock(blocks.StructBlock):
        title = blocks.CharBlock(label="Název", required=True)
        completion_percentage = blocks.IntegerBlock(
            label="Procento dokončení", required=True
        )
        issue_link = blocks.URLBlock(label="Odkaz na Redmine issue", required=False)
    
    
    class YouTubeVideoBlock(blocks.StructBlock):
        poster_image = ImageChooserBlock(
            label="Náhled videa (automatické pole)",
            required=False,
            help_text="Není třeba vyplňovat, náhled bude " "dohledán automaticky.",
        )
        video_url = blocks.URLBlock(
            label="Odkaz na video",
            required=False,
            help_text="Odkaz na YouTube video bude automaticky "
            "zkonvertován na ID videa a NEBUDE uložen.",
        )
        video_id = blocks.CharBlock(
            label="ID videa (automatické pole)",
            required=False,
            help_text="Není třeba vyplňovat, bude automaticky " "načteno z odkazu.",
        )
    
        class Meta:
            label = "YouTube video"
            icon = "media"
            template = "styleguide/2.3.x/blocks/video_block.html"
    
        def clean(self, value):
            errors = {}
    
            if not value["video_url"] and not value["video_id"]:
                errors["video_url"] = ErrorList(["Zadejte prosím odkaz na YouTube video."])
    
            if value["video_url"]:
                if not value["video_url"].startswith("https://youtu.be") and not value[
                    "video_url"
                ].startswith("https://www.youtube.com"):
                    errors["video_url"] = ErrorList(
                        [
                            'Odkaz na video musí začínat "https://www.youtube.com" '
                            'nebo "https://youtu.be"'
                        ]
                    )
    
            if value["video_id"]:
                if not re.match("^[A-Za-z0-9_-]{11}$", value["video_id"]):
                    errors["video_url"] = ErrorList(
                        ["Formát ID YouTube videa není validní"]
                    )
    
            if errors:
                raise StructBlockValidationError(errors)
            return super().clean(value)
    
        def get_prep_value(self, value):
            value = super().get_prep_value(value)
    
            if value["video_url"]:
                value["video_id"] = self.convert_youtube_link_to_video_id(
                    value["video_url"]
                )
                value["poster_image"] = self.get_wagtail_image_id_for_youtube_poster(
                    value["video_id"]
                )
    
            value["video_url"] = ""
    
            return value
    
        @staticmethod
        def convert_youtube_link_to_video_id(url):
            reg_str = (
                "((?<=(v|V)/)|(?<=youtu\.be/)|(?<=youtube\.com/watch(\?|\&)v=)"
                "|(?<=embed/))([\w-]+)"
            )
            search_result = re.search(reg_str, url)
    
            if search_result:
                return search_result.group(0)
    
            logger.warning(
                "Nepodařilo se získat video ID z YouTube URL", extra={"url": url}
            )
            return ""
    
        @classmethod
        def get_wagtail_image_id_for_youtube_poster(cls, video_id) -> int or None:
            image_url = "https://img.youtube.com/vi/{}/hqdefault.jpg".format(video_id)
    
            img_path = "/tmp/{}.jpg".format(video_id)
            urllib.request.urlretrieve(image_url, img_path)
            file = ImageFile(open(img_path, "rb"), name=img_path)
    
            return cls.get_image_id(file, "YT_poster_v_{}".format(video_id))
    
        @classmethod
        def get_image_id(cls, file: ImageFile, image_title: str) -> int:
            try:
                image = Image.objects.get(title=image_title)
            except Image.DoesNotExist:
                image = Image(
                    title=image_title, file=file, collection=cls.get_posters_collection()
                )
                image.save()
            return image.id
    
        @staticmethod
        def get_posters_collection() -> Collection:
            collection_name = "YouTube nahledy"
    
            try:
                collection = Collection.objects.get(name=collection_name)
            except Collection.DoesNotExist:
                root_collection = Collection.get_first_root_node()
                collection = root_collection.add_child(name=collection_name)
    
            return collection
    
    
    class CardBlock(blocks.StructBlock):
        img = ImageChooserBlock(label="Obrázek", required=False)
        elevation = blocks.IntegerBlock(
            label="Velikost stínu",
            min_value=0,
            max_value=21,
            help_text="0 = žádný stín, 21 = maximální stín",
            default=2,
        )
        headline = blocks.TextBlock(label="Titulek", required=False)
        hoveractive = blocks.BooleanBlock(
            label="Zvýraznit stín na hover",
            default=False,
            help_text="Pokud je zapnuto, stín se zvýrazní, když na kartu uživatel najede myší.",
            required=False,
        )
        content = blocks.StreamBlock(
            label="Obsah",
            local_blocks=[
                (
                    "text",
                    blocks.RichTextBlock(
                        label="Textový editor", features=RICH_TEXT_DEFAULT_FEATURES
                    ),
                ),
                ("table", TableBlock(template="shared/blocks/table_block.html")),
                ("figure", FigureBlock()),
                ("youtube", YouTubeVideoBlock()),
                ("map_point", MapPointBlock(label="Špendlík na mapě")),
                ("map_collection", MapFeatureCollectionBlock(label="Mapová kolekce")),
            ],
            required=False,
        )
        page = blocks.PageChooserBlock(label="Stránka", required=False)
        link = blocks.URLBlock(label="Odkaz", required=False)
    
        class Meta:
            label = "Karta"
            icon = "form"
            template = "styleguide/2.3.x/blocks/card_block.html"
    
        def clean(self, value):
            errors = {}
    
            if value["page"] and value["link"]:
                errors["page"] = ErrorList(
                    ["Stránka nemůže být vybrána současně s odkazem."]
                )
                errors["link"] = ErrorList(
                    ["Odkaz nemůže být vybrán současně se stránkou."]
                )
            if errors:
                raise StructBlockValidationError(errors)
    
            return super().clean(value)
    
    
    class ButtonBlock(blocks.StructBlock):
        title = blocks.CharBlock(label="Titulek", max_length=128, required=True)
        icon = blocks.CharBlock(
            label="Ikonka",
            max_length=128,
            required=False,
            help_text="Identifikátor ikonky ze styleguide (https://styleguide.pirati.cz/latest/?p=viewall-atoms-icons), např. ico--key.",
        )
        size = blocks.ChoiceBlock(
            choices=(("sm", "Malá"), ("base", "Střední"), ("lg", "Velká")),
            label="Velikost",
            default="base",
        )
        color = blocks.ChoiceBlock(
            choices=(
                ("black", "Černá"),
                ("white", "Bílá"),
                ("grey-125", "Světle šedá"),
                ("blue-300", "Modrá"),
                ("cyan-200", "Tyrkysová"),
                ("green-400", "Zelené"),
                ("violet-400", "Vínová"),
                ("red-600", "Červená"),
            ),
            label="Barva",
            default="base",
        )
        hoveractive = blocks.BooleanBlock(
            label="Animovat na hover",
            default=True,
            help_text="Pokud je zapnuto, tlačítko mění barvu, když na něj uživatel najede myší.",
            required=False,
        )
        mobile_fullwidth = blocks.BooleanBlock(
            label="Plná šířka na mobilních zařízeních",
            default=True,
            help_text="Pokud je zapnuto, tlačítko se na mobilních zařízeních roztáhne na plnou šířku.",
            required=False,
        )
        page = blocks.PageChooserBlock(label="Stránka", required=False)
        link = blocks.URLBlock(label="Odkaz", required=False)
        align = blocks.ChoiceBlock(
            choices=(
                ("auto", "Automaticky"),
                ("center", "Na střed"),
            ),
            label="Zarovnání",
            default="auto",
        )
    
        class Meta:
            label = "Tlačítko"
            icon = "code"
            template = "styleguide/2.3.x/blocks/button_block.html"
    
        def clean(self, value):
            errors = {}
    
            if value["page"] and value["link"]:
                errors["page"] = ErrorList(
                    ["Stránka nemůže být vybrána současně s odkazem."]
                )
                errors["link"] = ErrorList(
                    ["Odkaz nemůže být vybrán současně se stránkou."]
                )
    
            if not value["page"] and not value["link"]:
                errors["page"] = ErrorList(["Stránka nebo odkaz musí být vyplněna."])
                errors["link"] = ErrorList(["Stránka nebo odkaz musí být vyplněna."])
    
            if errors:
                raise StructBlockValidationError(errors)
    
            return super().clean(value)
    
        def get_context(self, value, parent_context=None):
            context = super().get_context(value, parent_context)
    
            if value["icon"]:
                context["icon_class"] = (
                    value["icon"]
                    if value["icon"].startswith("ico--")
                    else f"ico--{value['icon']}"
                )
            else:
                context["icon_class"] = None
    
            return context
    
    
    class ButtonGroupBlock(blocks.StructBlock):
        buttons = blocks.ListBlock(ButtonBlock(), label="Tlačítka")
    
        class Meta:
            label = "Skupina tlačítek"
            icon = "list-ul"
            template = "styleguide/2.3.x/blocks/button_group_block.html"
    
    
    class FullSizeHeaderBlock(blocks.StructBlock):
        title = blocks.CharBlock(label="Titulek", required=True)
        image_background = ImageChooserBlock(label="Obrázek v pozadí", required=True)
        image_foreground = ImageChooserBlock(label="Obrázek v popředí", required=False)
        button_group = blocks.ListBlock(ButtonBlock(), label="Tlačítka")
    
        class Meta:
            template = "styleguide/2.3.x/blocks/full_size_header_block.html"
            icon = "placeholder"
            label = "Nadpis s obrázkem a tlačítky přes celou stránku"
    
    
    class HeadlineBlock(blocks.StructBlock):
        headline = blocks.CharBlock(label="Headline", max_length=300, required=True)
        style = blocks.ChoiceBlock(
            choices=(
                ("head-alt-xl", "Bebas XL"),
                ("head-alt-lg", "Bebas L"),
                ("head-alt-md", "Bebas M"),
                ("head-alt-base", "Bebas base"),
                ("head-alt-sm", "Bebas SM"),
                ("head-alt-xs", "Bebas XS"),
                ("head-alt-2xs", "Bebas 2XS"),
                ("head-heavy-base", "Roboto base"),
                ("head-heavy-sm", "Roboto SM"),
                ("head-heavy-xs", "Roboto XS"),
                ("head-heavy-2xs", "Roboto 2XS"),
                ("head-allcaps-2xs", "Allcaps 2XS"),
                ("head-allcaps-3xs", "Allcaps 3XS"),
                ("head-allcaps-4xs", "Allcaps 4XS"),
                ("head-heavy-allcaps-2xs", "Allcaps heavy 2XS"),
                ("head-heavy-allcaps-3xs", "Allcaps heavy 3XS"),
                ("head-heavy-allcaps-4xs", "Allcaps heavy 4XS"),
            ),
            label="Styl",
            help_text="Náhled si prohlédněte na https://styleguide.pir-test.eu/latest/?p=viewall-atoms-text.",
            default="head-alt-md",
            required=True,
        )
        tag = blocks.ChoiceBlock(
            choices=(
                ("h1", "H1"),
                ("h2", "H2"),
                ("h3", "H3"),
                ("h4", "H4"),
                ("h5", "H5"),
                ("h6", "H6"),
            ),
            label="Úroveň nadpisu",
            help_text="Čím nižší číslo, tím vyšší úroveň.",
            required=True,
            default="h1",
        )
        align = blocks.ChoiceBlock(
            choices=(
                ("auto", "Automaticky"),
                ("center", "Na střed"),
            ),
            label="Zarovnání",
            default="auto",
            required=True,
        )
    
        def get_context(self, value, parent_context=None):
            context = super().get_context(value, parent_context)
            context["responsive_style"] = {
                "head-alt-xl": "head-alt-lg md:head-alt-xl",
                "head-alt-lg": "head-alt-md md:head-alt-lg",
                "head-alt-md": "head-alt-md",
                "head-alt-base": "head-alt-base",
                "head-alt-sm": "head-alt-sm",
                "head-alt-xs": "head-alt-xs",
                "head-alt-2xs": "head-alt-2xs",
                "head-heavy-base": "head-heavy-base",
                "head-heavy-sm": "head-heavy-sm",
                "head-heavy-xs": "head-heavy-xs",
                "head-heavy-2xs": "head-heavy-2xs",
                "head-allcaps-2xs": "head-allcaps-2xs",
                "head-allcaps-3xs": "head-allcaps-3xs",
                "head-allcaps-4xs": "head-allcaps-4xs",
                "head-heavy-allcaps-2xs": "head-heavy-allcaps-2xs",
                "head-heavy-allcaps-3xs": "head-heavy-allcaps-3xs",
                "head-heavy-allcaps-4xs": "head-heavy-allcaps-4xs",
            }[value["style"]]
            return context
    
        class Meta:
            label = "Headline"
            icon = "bold"
            template = "styleguide/2.3.x/blocks/headline_block.html"
    
    
    class TwoColumnBlock(blocks.StructBlock):
        left_column_content = blocks.StreamBlock(
            label="Obsah levého sloupce",
            local_blocks=[
                (
                    "text",
                    blocks.RichTextBlock(
                        label="Textový editor", features=RICH_TEXT_DEFAULT_FEATURES
                    ),
                ),
                ("table", TableBlock(template="shared/blocks/table_block.html")),
                ("card", CardBlock()),
                ("figure", FigureBlock()),
                ("youtube", YouTubeVideoBlock()),
                ("map_point", MapPointBlock(label="Špendlík na mapě")),
                ("map_collection", MapFeatureCollectionBlock(label="Mapová kolekce")),
                ("button", ButtonBlock()),
                ("button_group", ButtonGroupBlock()),
            ],
            required=True,
        )
        right_column_content = blocks.StreamBlock(
            label="Obsah pravého sloupce",
            local_blocks=[
                (
                    "text",
                    blocks.RichTextBlock(
                        label="Textový editor", features=RICH_TEXT_DEFAULT_FEATURES
                    ),
                ),
                ("table", TableBlock(template="shared/blocks/table_block.html")),
                ("card", CardBlock()),
                ("figure", FigureBlock()),
                ("youtube", YouTubeVideoBlock()),
                ("map_point", MapPointBlock(label="Špendlík na mapě")),
                ("map_collection", MapFeatureCollectionBlock(label="Mapová kolekce")),
                ("button", ButtonBlock()),
                ("button_group", ButtonGroupBlock()),
            ],
            required=True,
        )
    
        class Meta:
            label = "Dva sloupce"
            icon = "grip"
            template = "styleguide/2.3.x/blocks/two_column_block.html"
    
    
    class ThreeColumnBlock(blocks.StructBlock):
        left_column_content = blocks.StreamBlock(
            label="Obsah levého sloupce",
            local_blocks=[
                (
                    "text",
                    blocks.RichTextBlock(
                        label="Textový editor", features=RICH_TEXT_DEFAULT_FEATURES
                    ),
                ),
                ("table", TableBlock(template="shared/blocks/table_block.html")),
                ("card", CardBlock()),
                ("figure", FigureBlock()),
                ("youtube", YouTubeVideoBlock()),
                ("map_point", MapPointBlock(label="Špendlík na mapě")),
                ("map_collection", MapFeatureCollectionBlock(label="Mapová kolekce")),
                ("button", ButtonBlock()),
                ("button_group", ButtonGroupBlock()),
            ],
            required=True,
        )
        middle_column_content = blocks.StreamBlock(
            label="Obsah prostředního sloupce",
            local_blocks=[
                (
                    "text",
                    blocks.RichTextBlock(
                        label="Textový editor", features=RICH_TEXT_DEFAULT_FEATURES
                    ),
                ),
                ("table", TableBlock(template="shared/blocks/table_block.html")),
                ("card", CardBlock()),
                ("figure", FigureBlock()),
                ("youtube", YouTubeVideoBlock()),
                ("map_point", MapPointBlock(label="Špendlík na mapě")),
                ("map_collection", MapFeatureCollectionBlock(label="Mapová kolekce")),
                ("button", ButtonBlock()),
                ("button_group", ButtonGroupBlock()),
            ],
            required=True,
        )
        right_column_content = blocks.StreamBlock(
            label="Obsah pravého sloupce",
            local_blocks=[
                (
                    "text",
                    blocks.RichTextBlock(
                        label="Textový editor", features=RICH_TEXT_DEFAULT_FEATURES
                    ),
                ),
                ("table", TableBlock(template="shared/blocks/table_block.html")),
                ("card", CardBlock()),
                ("figure", FigureBlock()),
                ("youtube", YouTubeVideoBlock()),
                ("map_point", MapPointBlock(label="Špendlík na mapě")),
                ("map_collection", MapFeatureCollectionBlock(label="Mapová kolekce")),
                ("button", ButtonBlock()),
                ("button_group", ButtonGroupBlock()),
            ],
            required=True,
        )
    
        class Meta:
            label = "Tři sloupce"
            icon = "grip"
            template = "styleguide/2.3.x/blocks/three_column_block.html"
    
    
    class ImageBannerBlock(blocks.StructBlock):
        image = ImageChooserBlock(
            label="Obrázek",
            required=True,
        )
        headline = blocks.CharBlock(label="Headline", max_length=128, required=True)
        content = blocks.StreamBlock(
            label="Obsah pravého sloupce",
            local_blocks=[
                (
                    "text",
                    blocks.RichTextBlock(
                        label="Textový editor",
                        features=(
                            "h2",
                            "h3",
                            "h4",
                            "h5",
                            "bold",
                            "italic",
                            "ol",
                            "ul",
                            "hr",
                            "link",
                            "document-link",
                            "superscript",
                            "subscript",
                            "strikethrough",
                            "blockquote",
                        ),
                    ),
                ),
                ("button", ButtonBlock()),
                ("button_group", ButtonGroupBlock()),
            ],
            required=False,
        )
    
        class Meta:
            label = "Obrázkový banner"
            icon = "image"
            template = "styleguide/2.3.x/blocks/image_banner_block.html"
    
    
    class CardLinkBlockMixin(blocks.StructBlock):
        """
        Shared mixin for cards, which vary in templates and the types of pages
        they're allowed to link to.
        """
    
        image = ImageChooserBlock(label="Obrázek")
        title = blocks.CharBlock(label="Titulek", required=True)
        text = blocks.RichTextBlock(label="Krátký text pod nadpisem", required=False)
    
        page = blocks.PageChooserBlock(
            label="Stránka",
            page_type=[],
            required=False,
        )
        link = blocks.URLBlock(label="Odkaz", required=False)
    
        class Meta:
            # template = ""
            icon = "link"
            label = "Karta s odkazem"
    
        def clean(self, value):
            errors = {}
    
            if value["page"] and value["link"]:
                errors["page"] = ErrorList(
                    ["Stránka nemůže být vybrána současně s odkazem."]
                )
                errors["link"] = ErrorList(
                    ["Odkaz nemůže být vybrán současně se stránkou."]
                )
            elif not value["page"] and not value["link"]:
                errors["page"] = ErrorList(["Zvolte stránku nebo vyplňte odkaz."])
                errors["link"] = ErrorList(["Vyplňte odkaz nebo zvolte stránku."])
            if errors:
                raise StructBlockValidationError(errors)
            return super().clean(value)
    
    
    class CardLinkWithHeadlineBlockMixin(blocks.StructBlock):
        headline = blocks.CharBlock(label="Titulek bloku", required=False)
        card_items = blocks.ListBlock(CardLinkBlockMixin(), label="Karty s odkazy")
    
        class Meta:
            # template = ""
            icon = "link"
            label = "Karty odkazů s nadpisem"
    
    
    class ChartDataset(blocks.StructBlock):
        label = blocks.CharBlock(
            label="Označení zdroje dat",
            max_length=120,
        )
    
        data = blocks.ListBlock(
            blocks.IntegerBlock(),
            label="Data",
            default=[0],
        )
    
        class Meta:
            label = "Zdroj dat"
    
    
    def get_redmine_projects():
        projects = requests.get("https://redmine.pirati.cz/projects.json?limit=10000")
        projects.raise_for_status()
        projects = projects.json()["projects"]
    
        return [(project["id"], project["name"]) for project in projects]
    
    
    class ChartRedmineIssueDataset(blocks.StructBlock):
        projects = blocks.MultipleChoiceBlock(
            label="Projekty", choices=get_redmine_projects
        )
    
        is_open = blocks.BooleanBlock(
            label="Jen otevřené",
            required=False,
        )
        is_closed = blocks.BooleanBlock(
            label="Jen uzavřené",
            required=False,
        )
    
        created_on_min_date = blocks.DateBlock(label="Min. datum vytvoření", required=True)
        created_on_max_date = blocks.DateBlock(label="Max. datum vytvoření", required=True)
        updated_on = blocks.CharBlock(
            label="Filtr pro datum aktualizace",
            max_length=128,
            help_text="Např. <=2023-01-01. Více informací na pi2.cz/redmine-api",
            required=False,
        )
    
        issue_label = blocks.CharBlock(
            label="Označení úkolů uvnitř grafu",
            max_length=128,
            required=True,
        )
    
        split_per_project = blocks.BooleanBlock(
            label="Rozdělit podle projektu",
            required=False,
        )
    
        only_grow = blocks.BooleanBlock(
            label="Pouze růst nahoru",
            required=False,
        )
    
        def _get_issues_url(self, value, project_id: typing.Union[None, str, list[str]] = None):
            url = "https://redmine.pirati.cz/issues.json"
            params = [
                ("sort", "created_on"),
                ("limit", "100"),
                (
                    "created_on",
                    f"><{value['created_on_min_date']}|{value['created_on_max_date']}",
                ),
            ]
    
            if isinstance(project_id, str):
                params.append(("project_id", project_id))
            elif isinstance(project_id, list):
                params.append(("project_id", ",".join(project_id)))
    
            is_open = value.get("is_open", False)
            is_closed = value.get("is_closed", False)
    
            if is_open and is_closed:
                params.append(("status_id", "*"))
            elif is_open:
                params.append(("status_id", "open"))
            elif is_closed:
                params.append(("status_id", "closed"))
    
            if value.get("updated_on", "") != "":
                params.append(("updated_on", value["updated_on"]))
    
            is_first = True
    
            for param_set in params:
                param, param_value = param_set
    
                url += "?" if is_first else "&"
                url += f"{param}={urllib.parse.quote(param_value)}"
    
                is_first = False
    
            print(url)
    
            return url
    
        def _get_parsed_issues(self, value, labels, issues_url) -> tuple:
            issues_response = cache.get(f"redmine_{issues_url}")
    
            if issues_response is None:
                issues_response = requests.get(issues_url)
                issues_response.raise_for_status()
                issues_response = issues_response.json()
    
                cache.set(
                    f"redmine_{issues_url}",
                    issues_response,
                    timeout=604800,  # 1 week
                )
    
            only_grow = value.get("only_grow", False)
    
            collected_issues = issues_response["issues"]
            offset = 0
    
            while issues_response["total_count"] - offset > len(issues_response["issues"]):
                offset += 100
                url_with_offset = f"{issues_url}&offset={offset}"
    
                issues_response = cache.get(f"redmine_{url_with_offset}")
    
                if issues_response is None:
                    issues_response = requests.get(url_with_offset)
                    issues_response.raise_for_status()
                    issues_response = issues_response.json()
    
                    cache.set(
                        f"redmine_{url_with_offset}",
                        issues_response,
                        timeout=604800,  # 1 week
                    )
    
                collected_issues += issues_response["issues"]
    
            ending_position = len(collected_issues) - 1
    
            data = None
    
            current_issue_count = 0
            current_label = datetime.date.fromisoformat(
                collected_issues[0]["created_on"].split("T")[0]
            )
    
            if not only_grow:
                data = [0] * len(labels)
    
                for position, issue in enumerate(
                    collected_issues
                ):  # Assume correct sorting order
                    created_on_date = datetime.date.fromisoformat(
                        issue["created_on"].split("T")[0]
                    )
    
                    if current_label != created_on_date or position == ending_position:
                        data[
                            labels.index(current_label)
                        ] = current_issue_count  # Assume labels are unique
                        current_label = created_on_date
    
                        if position != ending_position:
                            current_issue_count = 0
                        else:
                            data[labels.index(current_label)] = 1
                            break
    
                    current_issue_count += 1
            else:
                data = []
                issue_count_by_date = {}
    
                for position, issue in enumerate(
                    collected_issues
                ):  # Assume correct sorting order
                    created_on_date = datetime.date.fromisoformat(
                        issue["created_on"].split("T")[0]
                    )
    
                    if current_label not in issue_count_by_date:
                        issue_count_by_date[current_label] = 0
    
                    if current_label != created_on_date or position == ending_position:
                        issue_count_by_date[
                            current_label
                        ] = current_issue_count  # Assume labels are unique
                        current_label = created_on_date
    
                        if position == ending_position:
                            issue_count_by_date[current_label] = current_issue_count + 1
                            break
    
                    current_issue_count += 1
    
                previous_date = None
    
                for date in labels:
                    if date not in issue_count_by_date:
                        if previous_date is None:
                            data.append(0)
                            continue
    
                        data.append(issue_count_by_date[previous_date])
                        continue
    
                    data.append(issue_count_by_date[date])
                    previous_date = date
    
            return data
    
        def get_context(self, value) -> list:
            context = super().get_context(value)
    
            labels = []
            datasets = []
    
            for day_count in range(
                (value["created_on_max_date"] - value["created_on_min_date"]).days + 1
            ):
                day = value["created_on_min_date"] + datetime.timedelta(days=day_count)
                labels.append(day)
    
            if value.get("split_per_project", False):
                project_choices_lookup = dict(get_redmine_projects())
    
                for project_id in value["projects"]:
                    issues_url = self._get_issues_url(value, project_id)
    
                    datasets.append(
                        {
                            "label": f"{value['issue_label']} - {project_choices_lookup[int(project_id)]}",
                            "data": self._get_parsed_issues(value, labels, issues_url),
                        }
                    )
            else:
                issues_url = self._get_issues_url(value, value["projects"])
    
                datasets.append(
                    {
                        "label": value["issue_label"],
                        "data": self._get_parsed_issues(value, labels, issues_url),
                    }
                )
    
            labels = [date.strftime("%d. %m. %Y") for date in labels]
    
            context["parsed_issue_labels"] = labels
            context["parsed_issues"] = datasets
    
            return context
    
        class Meta:
            label = "Zdroj dat z Redmine (úkoly vytvořené za den)"
            help_text = (
                "Po prvním otevření se bude stránka otevírat delší dobu, "
                "zatímco se na pozadí načítají data do grafu. Poté bude "
                "fungovat běžně."
            )
    
    
    class ChartBlock(blocks.StructBlock):
        title = blocks.CharBlock(
            label="Název",
            max_length=120,
        )
        chart_type = blocks.ChoiceBlock(
            label="Typ",
            choices=[
                ("bar", "Graf se sloupci"),
                ("horizontalBar", "Graf s vodorovnými sloupci"),
                ("pie", "Koláčový graf"),
                ("doughnut", "Donutový graf"),
                ("polarArea", "Graf polární oblasti"),
                ("radar", "Radarový graf"),
                ("line", "Graf s liniemi"),
            ],
            default="bar",
        )
    
        hide_points = blocks.BooleanBlock(
            label="Schovat body",
            required=False,
            help_text="Mění vzhled pouze u linových grafů.",
        )
    
        local_labels = blocks.ListBlock(
            blocks.CharBlock(
                max_length=40,
                label="Skupina",
            ),
            default=[],
            blank=True,
            required=False,
            collapsed=True,
            label="Místně definované skupiny",
        )
        local_datasets = blocks.ListBlock(
            ChartDataset(),
            default=[],
            blank=True,
            required=False,
            collapsed=True,
            label="Místní zdroje dat",
        )
    
        redmine_issue_datasets = blocks.ListBlock(
            ChartRedmineIssueDataset(label="Redmine úkoly"),
            default=[],
            blank=True,
            required=False,
            label="Zdroje dat z Redmine (úkoly)",
            help_text=(
                "Úkoly, podle doby vytvoření. Pokud definuješ "
                "více zdrojů, datumy v nich musí být stejné."
            ),
        )
    
        def clean(self, value):
            result = super().clean(value)
    
            redmine_issues_exist = len(value.get("redmine_issue_datasets", [])) != 0
    
            if len(value.get("local_datasets", [])) != 0 and redmine_issues_exist:
                raise ValidationError(
                    "Definuj pouze jeden typ zdroje dat - místní, nebo z Redmine."
                )
    
            if redmine_issues_exist:
                min_date = value["redmine_issue_datasets"][0]["created_on_min_date"]
                max_date = value["redmine_issue_datasets"][0]["created_on_max_date"]
    
                if len(value["redmine_issue_datasets"]) > 1:
                    for dataset in value["redmine_issue_datasets"]:
                        if (
                            dataset["created_on_min_date"] != min_date
                            or dataset["created_on_max_date"] != max_date
                        ):
                            raise ValidationError(
                                "Maximální a minimální data všech zdrojů z Redmine musí být stejné"
                            )
    
            return result
    
        def get_context(self, value, parent_context=None):
            context = super().get_context(value, parent_context=parent_context)
    
            datasets = []
            labels = []
    
            if len(value["local_datasets"]) != 0:
                labels = value["local_labels"]
    
                for dataset in value["local_datasets"]:
                    datasets.append(
                        {
                            "label": dataset["label"],
                            "data": [item for item in dataset["data"]],
                        }
                    )
            elif len(value["redmine_issue_datasets"]) != 0:
                for dataset_wrapper in value["redmine_issue_datasets"]:
                    redmine_context = ChartRedmineIssueDataset().get_context(
                        dataset_wrapper
                    )
    
                    labels = redmine_context["parsed_issue_labels"]
                    datasets += redmine_context["parsed_issues"]
    
            value["datasets"] = json.dumps(datasets)
            value["labels"] = json.dumps([label for label in labels])
    
            return context
    
        class Meta:
            # template = ""
            label = "Graf"
            icon = "form"
            help_text = """Všechny položky zdrojů dat se chovají jako sloupce.
    Zobrazí se tolik definovaných sloupců, kolik existuje skupin."""
    
    
    DEFAULT_CONTENT_BLOCKS = [
        (
            "text",
            blocks.RichTextBlock(
                label="Textový editor", features=RICH_TEXT_DEFAULT_FEATURES
            ),
        ),
        ("headline", HeadlineBlock()),
        ("table", TableBlock(template="shared/blocks/table_block.html")),
        ("gallery", GalleryBlock(label="Galerie")),
        ("figure", FigureBlock()),
        ("card", CardBlock()),
        ("two_columns", TwoColumnBlock()),
        ("three_columns", ThreeColumnBlock()),
        ("youtube", YouTubeVideoBlock(label="YouTube video")),
        ("map_point", MapPointBlock(label="Špendlík na mapě")),
        ("map_collection", MapFeatureCollectionBlock(label="Mapová kolekce")),
        ("button", ButtonBlock()),
        ("button_group", ButtonGroupBlock()),
        ("image_banner", ImageBannerBlock()),
    ]