# 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 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]
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()