import datetime import json import logging import re import urllib import requests_cache 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(): session = requests_cache.CachedSession( "redmine_cache", expire_after=datetime.timedelta(hours=1), ) projects = session.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 = blocks.CharBlock( label="Filtr pro datum vytvoření", max_length=128, help_text="Např. >=2022-01-01, pro rozsah dat ><2022-01-01|2022-12-31. Více informací na pi2.cz/redmine-api", required=False, ) 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, ) def _get_issues_url(self, value): url = "https://redmine.pirati.cz/issues.json" params = [ ("sort", "created_on"), ("limit", "100"), ] if "is_open" in value and "is_closed" in value: params.append(("status_id", "*")) elif "is_open" in value: params.append(("status_id", "open")) elif "is_closed" in value: params.append(("status_id", "closed")) for string_filter in ("created_on", "updated_on"): if value.get(string_filter, "") != "": params.append((string_filter, value[string_filter])) 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 return url def _get_parsed_issues(self, value, issues_url) -> tuple: session = requests_cache.CachedSession( "redmine_cache", expire_after=datetime.timedelta(days=14), ) issues_response = session.get(issues_url) issues_response.raise_for_status() issues_response = issues_response.json() collected_issues = issues_response["issues"] offset = 0 while issues_response["total_count"] - offset > len(issues_response["issues"]): offset += 100 issues_response = session.get(f"{issues_url}&offset={offset}") issues_response.raise_for_status() issues_response = issues_response.json() collected_issues += issues_response["issues"] labels = [] for issue in collected_issues: created_on_date = issue["created_on"].split("T")[0] if created_on_date in labels: continue labels.append(created_on_date) data = [0] * len(labels) current_issue_count = 0 current_label = labels[0] ending_position = len(collected_issues) - 1 for position, issue in enumerate( collected_issues ): # Assume correct sorting order created_on_date = 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 return labels, data def get_context(self, value) -> list: context = super().get_context(value) issues_url = self._get_issues_url(value) context["parsed_issues"] = self._get_parsed_issues(value, issues_url) return context class Meta: label = "Zdroj dat z Redmine (úkoly)" 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", ) 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)", ) 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"]: dataset = dict(dataset) 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"]: single_label_set, data = ChartRedmineIssueDataset().get_context( dataset_wrapper )["parsed_issues"] labels += single_label_set datasets = [{"label": dataset_wrapper["issue_label"], "data": data}] 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()), ]