Source code for eucrim.core.blocks

# SPDX-FileCopyrightText: 2024 Thomas Breitner <t.breitner@csl.mpg.de>
#
# SPDX-License-Identifier: EUPL-1.2

from django.core.exceptions import ValidationError
from django import forms
from django.forms.utils import ErrorList
from django.utils.safestring import mark_safe

from wagtail.images.blocks import ImageChooserBlock
from wagtail.embeds.blocks import EmbedBlock
from wagtail.blocks.field_block import MultipleChoiceBlock
from wagtail.blocks import (
    CharBlock,
    ChoiceBlock,
    RichTextBlock,
    StreamBlock,
    StructBlock,
    TextBlock,
    StaticBlock,
    PageChooserBlock,
    URLBlock,
    ListBlock,
    EmailBlock,
)

from eucrim.users.models import CustomUser
from .utils import Languages


# class LinkBlock(StructBlock):
#     text = CharBlock(required=False)
#     internal_link = PageChooserBlock(required=False)
#     external_link = URLBlock(label="Url", required=False)
#
#     class Meta:
#         template = 'core/blocks/link_block.html'
#
#     def clean(self, value):
#         if value.get('internal_link') and value.get('external_link'):
#             errors = {
#                 'internal_link': ErrorList([
#                     ValidationError('Either a page or url must be defined'),
#                 ]),
#             }
#             raise ValidationError('There is a problem with this link', params=errors)
#
#         return super().clean(value)
#
#     def get_context(self, value, parent_context=None):
#         ctx = super().get_context(value, parent_context=parent_context)
#         if value.get('internal_link'):
#             url = value['internal_link'].url
#         elif value.get('external_link'):
#             url = value.get('external_link')
#         else:
#             url = None
#         ctx['url'] = url
#         return ctx


[docs] class ImageBlock(StructBlock): """ Custom `StructBlock` for utilizing images with associated caption and attribution data """ image = ImageChooserBlock(required=True) caption = CharBlock(required=False) attribution = CharBlock(required=False) class Meta: icon = "image" template = "core/blocks/image_block.html"
[docs] class HeadingBlock(StructBlock): """ Custom `StructBlock` that allows the user to select h2 - h4 sizes for headers """ heading_text = CharBlock(classname="title", required=True) size = ChoiceBlock( choices=[ ("", "Select a header size"), ("h2", "H2"), ("h3", "H3"), ("h4", "H4"), ], blank=True, required=False, ) class Meta: icon = "title" template = "core/blocks/heading_block.html"
[docs] class BlockQuote(StructBlock): """ Custom `StructBlock` that allows the user to attribute a quote to the author """ text = TextBlock() attribute_name = CharBlock(blank=True, required=False, label="e.g. Mary Berry") class Meta: icon = "openquote" template = "core/blocks/blockquote.html"
[docs] class CitationDefinitionBlock(StructBlock): intro = RichTextBlock( required=False, ) citation_definition = RichTextBlock( required=False, features=["bold", "italic", "link"], ) citation_example = RichTextBlock( required=False, features=["bold", "italic", "link"], ) class Meta: icon = "openquote" template = "core/blocks/citation_definition.html"
[docs] class LatestAuthorsStaticBlock(StaticBlock): class Meta: icon = "user" label = "Latest authors" admin_text = "{label}: configured elsewhere".format(label=label) template = "core/blocks/latest_authors.html"
[docs] def get_context(self, value, parent_context=None): from eucrim.profile.models import ProfilePage, ProfileIndexPage context = super().get_context(value, parent_context=parent_context) context["author_index_page"] = ProfileIndexPage.objects.live().first() context["latest_authors"] = ProfilePage.author_objects.all().order_by( "-first_published_at" )[:3] return context
[docs] class TeamBlock(StructBlock): headline = CharBlock( required=False, ) style = ChoiceBlock( choices=[ ("flipcard", "flipcard"), ("compact", "compact"), ], required=False, ) team_roles = MultipleChoiceBlock( choices=CustomUser.TEAM_ROLE_CHOICES, default=CustomUser.NOROLE, widget=forms.CheckboxSelectMultiple, help_text="Multiple team roles are allowed. Make sure 'no role' checkbox is unset.", )
[docs] def get_context(self, value, parent_context=None): from eucrim.profile.models import ProfilePage, ProfileIndexPage context = super().get_context(value, parent_context=parent_context) context.update( { "author_index_page": ProfileIndexPage.objects.live().first(), "profiles": ProfilePage.team_objects.filter( team_role__in=value["team_roles"] ).order_by("team_role", "last_name"), "role_choices": CustomUser.TEAM_ROLE_CHOICES, } ) return context
class Meta: icon = "group" label = "Team members" admin_text = f"{label}: Inserts a gallery of team members." template = "core/blocks/team.html"
[docs] class CalltoactionBlock(StructBlock): headline = CharBlock() text = RichTextBlock() link_text = CharBlock( required=False, help_text="If using one of the link options, be sure to also set " "a link text here - the generated link button will " "otherwise be empty.", ) internal_link = PageChooserBlock(label="Internal page", required=False) external_link = URLBlock(label="External URL", required=False) email_link = EmailBlock(label="Email address", required=False)
[docs] def clean(self, value): if ( value.get("internal_link") and value.get("external_link") and value.get("email_link") ): errors = { "internal_link": ErrorList( [ ValidationError( "Either a page or url or an email link must be defined" ), ] ), } raise ValidationError("There is a problem with this link", params=errors) return super().clean(value)
[docs] def get_context(self, value, parent_context=None): context = super().get_context(value, parent_context=parent_context) if value.get("internal_link"): url = value["internal_link"].url elif value.get("external_link"): url = value.get("external_link") elif value.get("email_link"): url = "mailto:{}".format(value.get("email_link")) else: url = None context["url"] = url return context
class Meta: icon = "pick" template = "core/blocks/calltoaction.html"
[docs] class TabBlock(StructBlock): title = CharBlock( label="Tab Title", required=False, max_length=255, help_text="The tab title should be as short as possible.", ) language = ChoiceBlock( choices=Languages, required=False, ) icon = CharBlock( max_length=100, required=False, help_text=mark_safe( 'Complete CSS class name for the required <a style="text-decoration: underline;" target="_blank" href="https://fontawesome.com/icons?d=gallery&m=free">font awesome 5 free icon set</a>. Example: "fas fa-ambulance"' ), ) content = RichTextBlock( label="Tab content", )
[docs] class FigureBlock(StructBlock): number_source = ChoiceBlock( choices=[ ("manual", "Manual — enter number below"), ("issues", "Auto: Issue count"), ("articles", "Auto: Article count"), ("news", "Auto: News count"), ("authors", "Auto: Author count"), ], default="manual", label="Number source", help_text="Choose 'Manual' to enter a fixed number, or select an auto-calculated source.", ) number = CharBlock( required=False, label="Number (manual only)", help_text="Used only when 'Number source' is set to Manual.", ) label = CharBlock( required=True, label="Label", help_text="Description text (e.g., Issues, Articles, etc.)", ) class Meta: label = "Figure"
[docs] class CountUpFiguresBlock(StructBlock): figures = ListBlock( FigureBlock(), label="Figures", help_text="Add one or more figures with their labels", ) class Meta: template = "core/blocks/countup_figures_block.html" icon = "form" label = "Count-up Figures"
[docs] def get_context(self, value, parent_context=None): from eucrim.issue.models import IssuePage from eucrim.article.models import ArticlePage from eucrim.news.models import NewsPage from eucrim.profile.models import ProfilePage context = super().get_context(value, parent_context=parent_context) resolved = [] for figure in value.get("figures", []): source = figure.get("number_source", "manual") # Use if/elif for lazy evaluation: only execute the database query # for the selected source, avoiding unnecessary count() calls. if source == "issues": number = IssuePage.objects.live().count() elif source == "articles": number = ArticlePage.objects.live().count() elif source == "news": number = NewsPage.objects.live().count() elif source == "authors": number = ProfilePage.objects.live().filter(is_author=True).count() else: number = figure.get("number", "") resolved.append({"number": number, "label": figure.get("label", "")}) context["resolved_figures"] = resolved return context
[docs] def build_publication_timeline(show_articles=True, show_news=True, article_boost=1): """ Build an interactive publication timeline chart showing ArticlePage and/or NewsPage publication counts grouped by month. Args: show_articles: Include articles trace if True. show_news: Include news trace if True. article_boost: Multiplier for article counts (only applied when both are shown). """ from eucrim.article.models import ArticlePage from eucrim.news.models import NewsPage import plotly.graph_objects as go import plotly.io as pio from django.db.models.functions import TruncMonth from django.db.models import Count from datetime import date from dateutil.relativedelta import relativedelta def get_monthly_counts(model): """Query a model for publication counts grouped by month.""" return { row["month"]: row["count"] for row in model.objects.live() .exclude(publication_date__isnull=True) .annotate(month=TruncMonth("publication_date")) .values("month") .annotate(count=Count("id")) .order_by("month") } article_counts = get_monthly_counts(ArticlePage) if show_articles else {} news_counts = get_monthly_counts(NewsPage) if show_news else {} # Unified date range: all months from first to last observed all_months = sorted(set(article_counts) | set(news_counts)) if len(all_months) < 2: return "" start, end = all_months[0], all_months[-1] months = [] cur = date(start.year, start.month, 1) while cur <= date(end.year, end.month, 1): months.append(cur) cur += relativedelta(months=1) x_labels = [f"{m:%Y %B}" for m in months] y_articles = [article_counts.get(m, 0) * article_boost for m in months] # Negate news counts to display below the x-axis y_news = [-news_counts.get(m, 0) for m in months] color_news = "rgb(219, 227, 235)" color_articles = "rgb(16,16,122)" fig = go.Figure() # Add news first (so articles appear in front) if show_news: fig.add_trace( go.Scatter( x=x_labels, y=y_news, name="News", mode="lines", line_shape="spline", line=dict(color=color_news, width=2), fill="tozeroy", fillcolor=color_news, hovertemplate="%{x}<br>News: %{customdata}<extra></extra>", customdata=[-y for y in y_news], ) ) if show_articles: fig.add_trace( go.Scatter( x=x_labels, y=y_articles, name="Articles", mode="lines", line_shape="spline", line=dict(color=color_articles, width=2), fill="tozeroy", fillcolor=color_articles, hovertemplate="%{x}<br>Articles: %{y}<extra></extra>", ) ) fig.update_layout( showlegend=show_articles and show_news, legend=dict(orientation="h", yanchor="bottom", y=1, xanchor="right", x=1) if show_articles and show_news else {}, plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", margin=dict(l=0, r=0, t=30, b=10), xaxis=dict(visible=False, showline=False), yaxis=dict(visible=False), height=120, ) return pio.to_html(fig, full_html=False, config={"displayModeBar": False})
[docs] class PublicationTimelineBlock(StructBlock): """Renders an interactive publication timeline chart (articles + news per month).""" display_mode = ChoiceBlock( choices=[ ("both", "Articles and News"), ("articles", "Articles only"), ("news", "News only"), ], default="both", required=True, ) article_boost = CharBlock( required=False, default="1", help_text="Multiplier for article counts when displaying both timelines (e.g., 2 to double articles). Only applied when showing both articles and news.", )
[docs] def get_context(self, value, parent_context=None): context = super().get_context(value, parent_context) display_mode = value.get("display_mode", "both") show_articles = display_mode in ("both", "articles") show_news = display_mode in ("both", "news") try: article_boost = float(value.get("article_boost", "1")) except (ValueError, TypeError): article_boost = 1.0 context["chart_html"] = build_publication_timeline( show_articles=show_articles, show_news=show_news, article_boost=article_boost if (show_articles and show_news) else 1, ) return context
class Meta: template = "core/blocks/publication_timeline_block.html" icon = "date" label = "Publication Timeline"
[docs] class CommonContentBlock(StreamBlock): heading_block = HeadingBlock() paragraph_block = RichTextBlock( icon="pilcrow", template="core/blocks/paragraph_block.html" ) image_block = ImageBlock() block_quote = BlockQuote() embed_block = EmbedBlock( help_text="Insert an embed URL e.g https://www.youtube.com/embed/SGJFWirQ3ks", icon="media", template="core/blocks/embed_block.html", ) authors_block = LatestAuthorsStaticBlock() team_block = TeamBlock() calltoaction_block = CalltoactionBlock() figures = CountUpFiguresBlock() publication_timeline = PublicationTimelineBlock()
[docs] class AccordionContentBlock(CommonContentBlock): citation_definitions = ListBlock( CitationDefinitionBlock(), icon="folder", template="core/blocks/citation_definitions.html", )
[docs] class AccordionItemBlock(StructBlock): accordion_title = CharBlock( required=False, ) accordion_body = AccordionContentBlock() # CommonContentBlock() class Meta: template = "core/blocks/accordion_item_block.html" icon = "tasks"
# StreamBlocks
[docs] class BaseStreamBlock(CommonContentBlock): tabs = ListBlock( TabBlock(), icon="folder", template="core/blocks/tabs_block.html", ) accordion = AccordionItemBlock()