From 338b6b01173677ec566a6127315247dbb87ba19b Mon Sep 17 00:00:00 2001 From: James Biggs <62654785+jamesbiggs@users.noreply.github.com> Date: Thu, 12 Oct 2023 13:09:07 +0100 Subject: [PATCH] CHORE: Author Pages (#1245) --- config/settings/base.py | 1 + etna/articles/factories.py | 1 - .../0098_alter_focusedarticlepage_author.py | 35 ++++ etna/articles/models.py | 15 +- etna/authors/blocks.py | 10 - ...page_authorpage_authortag_delete_author.py | 195 ++++++++++++++++++ etna/authors/models.py | 108 +++++++--- etna/authors/tests/__init__.py | 0 etna/authors/tests/test_models.py | 67 ++++++ etna/core/models/basepage.py | 11 +- sass/etna.scss | 2 + sass/includes/_author-info.scss | 37 ++++ sass/includes/_author-intro.scss | 108 ++++++++++ sass/includes/_generic-intro.scss | 4 + templates/articles/focused_article_page.html | 23 ++- templates/authors/author_index_page.html | 61 ++++++ templates/authors/author_page.html | 57 +++++ 17 files changed, 694 insertions(+), 41 deletions(-) create mode 100644 etna/articles/migrations/0098_alter_focusedarticlepage_author.py delete mode 100644 etna/authors/blocks.py create mode 100644 etna/authors/migrations/0002_authorindexpage_authorpage_authortag_delete_author.py create mode 100644 etna/authors/tests/__init__.py create mode 100644 etna/authors/tests/test_models.py create mode 100644 sass/includes/_author-info.scss create mode 100644 sass/includes/_author-intro.scss create mode 100644 templates/authors/author_index_page.html create mode 100644 templates/authors/author_page.html diff --git a/config/settings/base.py b/config/settings/base.py index 181b5adcd..4758a9491 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -36,6 +36,7 @@ "etna.alerts", "etna.analytics", "etna.articles", + "etna.authors", "etna.categories", "etna.ciim", "etna.collections", diff --git a/etna/articles/factories.py b/etna/articles/factories.py index 485a08694..10ba09c73 100755 --- a/etna/articles/factories.py +++ b/etna/articles/factories.py @@ -33,7 +33,6 @@ class Meta: class FocusedArticlePageFactory(BasePageFactory): hero_image = factory.SubFactory(ImageFactory) hero_image_caption = "
Hero image caption
" - author = "John Doe" class Meta: model = app_models.FocusedArticlePage diff --git a/etna/articles/migrations/0098_alter_focusedarticlepage_author.py b/etna/articles/migrations/0098_alter_focusedarticlepage_author.py new file mode 100644 index 000000000..0f49f5cbd --- /dev/null +++ b/etna/articles/migrations/0098_alter_focusedarticlepage_author.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.5 on 2023-09-25 10:55 + +from django.db import migrations, models +import django.db.models.deletion + + +def convert_to_foreign_key(apps, schema_editor): + FocusedArticlePage = apps.get_model("articles", "FocusedArticlePage") + + for page in FocusedArticlePage.objects.all(): + if page.author: + page.author = None + page.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("authors", "0002_authorindexpage_authorpage_authortag_delete_author"), + ("articles", "0097_alter_articlepage_mark_new_on_next_publish_and_more"), + ] + + operations = [ + migrations.RunPython(convert_to_foreign_key), + migrations.AlterField( + model_name="focusedarticlepage", + name="author", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="focused_articles", + to="authors.authorpage", + ), + ), + ] diff --git a/etna/articles/models.py b/etna/articles/models.py index 95f475a22..2e3f5201c 100755 --- a/etna/articles/models.py +++ b/etna/articles/models.py @@ -23,6 +23,7 @@ from taggit.models import ItemBase, TagBase +from etna.authors.models import AuthorPageMixin from etna.collections.models import TopicalPageMixin from etna.core.models import ( BasePageWithIntro, @@ -310,6 +311,7 @@ def latest_items( class FocusedArticlePage( TopicalPageMixin, + AuthorPageMixin, HeroImageMixin, ContentWarningMixin, NewLabelMixin, @@ -321,8 +323,12 @@ class FocusedArticlePage( The FocusedArticlePage model. """ - author = models.CharField( - max_length=100, blank=True, null=True, help_text="The author of this article." + author = models.ForeignKey( + "authors.AuthorPage", + blank=True, + null=True, + related_name="focused_articles", + on_delete=models.SET_NULL, ) body = StreamField( @@ -343,7 +349,6 @@ class Meta: BasePageWithIntro.content_panels + HeroImageMixin.content_panels + [ - FieldPanel("author"), MultiFieldPanel( [ FieldPanel("display_content_warning"), @@ -361,6 +366,9 @@ class Meta: + BasePageWithIntro.promote_panels + ArticleTagMixin.promote_panels + [ + FieldPanel( + "author", heading="Author", help_text="Add the author of this page" + ), TopicalPageMixin.get_topics_inlinepanel(), TopicalPageMixin.get_time_periods_inlinepanel(), ] @@ -376,6 +384,7 @@ class Meta: index.SearchField("body"), index.SearchField("topic_names", boost=1), index.SearchField("time_period_names", boost=1), + index.SearchField("author_name", boost=1), ] ) diff --git a/etna/authors/blocks.py b/etna/authors/blocks.py deleted file mode 100644 index b1cab453a..000000000 --- a/etna/authors/blocks.py +++ /dev/null @@ -1,10 +0,0 @@ -from wagtail import blocks -from wagtail.snippets.blocks import SnippetChooserBlock - - -class AuthorBlock(blocks.StructBlock): - author = SnippetChooserBlock("authors.Author") - - class Meta: - icon = "user-circle " - template = "authors/blocks/author.html" diff --git a/etna/authors/migrations/0002_authorindexpage_authorpage_authortag_delete_author.py b/etna/authors/migrations/0002_authorindexpage_authorpage_authortag_delete_author.py new file mode 100644 index 000000000..9874caa42 --- /dev/null +++ b/etna/authors/migrations/0002_authorindexpage_authorpage_authortag_delete_author.py @@ -0,0 +1,195 @@ +# Generated by Django 4.2.5 on 2023-09-25 10:55 + +from django.db import migrations, models +import django.db.models.deletion +import etna.analytics.mixins +import modelcluster.fields +import uuid +import wagtail.fields +import wagtailmetadata.models + + +class Migration(migrations.Migration): + dependencies = [ + ("images", "0008_alter_customimagerendition_file"), + ("wagtailcore", "0089_log_entry_data_json_null_to_object"), + ("authors", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="AuthorIndexPage", + fields=[ + ( + "page_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="wagtailcore.page", + ), + ), + ( + "teaser_text", + models.TextField( + help_text="A short, enticing description of this page. This will appear in promos and under thumbnails around the site.", + max_length=160, + verbose_name="teaser text", + ), + ), + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + unique=True, + verbose_name="UUID", + ), + ), + ( + "search_image", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="images.customimage", + verbose_name="Search image", + ), + ), + ( + "teaser_image", + models.ForeignKey( + blank=True, + help_text="Image that will appear on thumbnails and promos around the site.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="images.customimage", + ), + ), + ], + options={ + "abstract": False, + }, + bases=( + wagtailmetadata.models.WagtailImageMetadataMixin, + etna.analytics.mixins.DataLayerMixin, + "wagtailcore.page", + models.Model, + ), + ), + migrations.CreateModel( + name="AuthorPage", + fields=[ + ( + "page_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="wagtailcore.page", + ), + ), + ( + "teaser_text", + models.TextField( + help_text="A short, enticing description of this page. This will appear in promos and under thumbnails around the site.", + max_length=160, + verbose_name="teaser text", + ), + ), + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + unique=True, + verbose_name="UUID", + ), + ), + ("role", models.CharField(blank=True, max_length=100, null=True)), + ("summary", wagtail.fields.RichTextField(blank=True, null=True)), + ( + "image", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="images.customimage", + ), + ), + ( + "search_image", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="images.customimage", + verbose_name="Search image", + ), + ), + ( + "teaser_image", + models.ForeignKey( + blank=True, + help_text="Image that will appear on thumbnails and promos around the site.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="images.customimage", + ), + ), + ], + options={ + "verbose_name": "Author page", + "verbose_name_plural": "Author pages", + }, + bases=( + wagtailmetadata.models.WagtailImageMetadataMixin, + etna.analytics.mixins.DataLayerMixin, + "wagtailcore.page", + models.Model, + ), + ), + migrations.CreateModel( + name="AuthorTag", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="author_pages", + to="authors.authorpage", + verbose_name="author", + ), + ), + ( + "page", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="author_tags", + to="wagtailcore.page", + ), + ), + ], + ), + migrations.DeleteModel( + name="Author", + ), + ] diff --git a/etna/authors/models.py b/etna/authors/models.py index 6a5d5d98e..ee1f6096e 100644 --- a/etna/authors/models.py +++ b/etna/authors/models.py @@ -1,21 +1,42 @@ from django.conf import settings from django.db import models +from django.utils.functional import cached_property +from modelcluster.fields import ParentalKey from wagtail.admin.panels import FieldPanel from wagtail.fields import RichTextField from wagtail.images import get_image_model_string -from wagtail.snippets.models import register_snippet +from wagtail.models import Page +from etna.core.models import BasePage -@register_snippet -class Author(models.Model): - """Author snippet - Model to store author details. Including image and a link to - an external biography page. +class AuthorIndexPage(BasePage): + """Author index page + + This is the parent page for all authors. It is used to + display a list of authors, and to link to individual + author pages from the list. + """ + + subpage_types = ["authors.AuthorPage"] + + parent_page_types = ["home.HomePage"] + + @cached_property + def author_pages(self): + """Return a sample of child pages for rendering in teaser.""" + return self.get_children().type(AuthorPage).order_by("title").live().specific() + + +class AuthorPage(BasePage): + """Author page + + This page is to be used for an author profile page, where + we can put info about the author, an image, and then use it + to link pages to an author. """ - name = models.CharField(blank=False, null=False, max_length=100) role = models.CharField(blank=True, null=True, max_length=100) summary = RichTextField( blank=True, null=True, features=settings.INLINE_RICH_TEXT_FEATURES @@ -28,24 +49,65 @@ class Author(models.Model): related_name="+", ) - bio_link = models.URLField( - blank=False, null=False, help_text="Link to external bio page" - ) - bio_link_label = models.CharField( - blank=False, null=False, help_text="Button text for bio link", max_length=50 - ) - - panels = [ - FieldPanel("name"), + content_panels = BasePage.content_panels + [ + FieldPanel("image"), FieldPanel("role"), FieldPanel("summary"), - FieldPanel("image"), - FieldPanel("bio_link"), - FieldPanel("bio_link_label"), ] - def __str__(self): - return self.name - class Meta: - verbose_name_plural = "Authors" + verbose_name = "Author page" + verbose_name_plural = "Author pages" + + parent_page_types = ["authors.AuthorIndexPage"] + subpage_types = [] + + @cached_property + def authored_focused_articles(self): + return ( + self.focused_articles.live() + .public() + .order_by("-first_published_at") + .select_related("teaser_image") + ) + + +class AuthorTag(models.Model): + """ + This model allows any page type to be associated with an author page. + + Add a ForeignKey with a fitting related_name (e.g. `focused_articles` + for `FocusedArticlePage`) to the page's model to use this. + """ + + page = ParentalKey(Page, on_delete=models.CASCADE, related_name="author_tags") + author = models.ForeignKey( + AuthorPage, + verbose_name="author", + related_name="author_pages", + on_delete=models.CASCADE, + ) + + +class AuthorPageMixin: + """ + A mixin for pages that uses the ``AuthorTag`` model + in order to be associated with an author. + """ + + @cached_property + def author(self): + if author_item := self.author_tags.select_related("author").filter( + author__live=True + ): + return author_item[0].author + return None + + @property + def author_name(self): + """ + Returns the title of the author to be used for indexing + """ + if self.author: + return self.author.title + return None diff --git a/etna/authors/tests/__init__.py b/etna/authors/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/etna/authors/tests/test_models.py b/etna/authors/tests/test_models.py new file mode 100644 index 000000000..fc1888ab7 --- /dev/null +++ b/etna/authors/tests/test_models.py @@ -0,0 +1,67 @@ +from django.test import TestCase + +from wagtail.models import Site + +from ...articles.models import FocusedArticlePage +from ...images.models import CustomImage +from ..models import AuthorIndexPage, AuthorPage, AuthorTag + + +class TestAuthorPages(TestCase): + def setUp(self): + root = Site.objects.get().root_page + + self.author_index_page = AuthorIndexPage( + title="Authors", teaser_text="Test teaser text" + ) + root.add_child(instance=self.author_index_page) + + self.image = CustomImage.objects.create(width=0, height=0) + + self.author_page = AuthorPage( + title="John Doe", + role="Author on Test Site", + summary="Test summary", + image=self.image, + teaser_text="Test teaser text", + ) + self.author_index_page.add_child(instance=self.author_page) + + self.focused_articles = {} + self.author_tags = {} + for i in range(4): + self.focused_articles[f"focused_article{i}"] = FocusedArticlePage( + title=f"Test Article{i}", + intro="Test intro", + teaser_text="Test teaser text", + ) + root.add_child(instance=self.focused_articles[f"focused_article{i}"]) + + self.author_tags[f"author_tag{i}"] = AuthorTag( + page=self.focused_articles[f"focused_article{i}"], + author=self.author_page, + ) + root.add_child(instance=self.author_tags[f"author_tag{i}"]) + + def test_author_index_page(self): + self.assertEqual(self.author_index_page.title, "Authors") + self.assertEqual(self.author_index_page.get_children().count(), 1) + self.assertEqual( + self.author_index_page.get_children().first().title, "John Doe" + ) + + def test_author_page(self): + self.assertEqual(self.author_page.title, "John Doe") + self.assertEqual(self.author_page.role, "Author on Test Site") + self.assertEqual(self.author_page.summary, "Test summary") + self.assertEqual(self.author_page.image, self.image) + + def test_focused_article_author(self): + for i in self.focused_articles: + self.assertEqual( + self.focused_articles[i].author_tags.first().author.title, "John Doe" + ) + + def test_authored_focused_articles(self): + for item in self.author_page.authored_focused_articles.all(): + self.assertEqual(item.title in [f"Test Article{i}" for i in range(4)], True) diff --git a/etna/core/models/basepage.py b/etna/core/models/basepage.py index 0ed7eb755..6c2e93ea7 100644 --- a/etna/core/models/basepage.py +++ b/etna/core/models/basepage.py @@ -10,6 +10,7 @@ from django.utils.translation import gettext_lazy as _ from wagtail.admin.panels import FieldPanel, MultiFieldPanel +from wagtail.admin.widgets.slug import SlugInput from wagtail.fields import RichTextField from wagtail.images import get_image_model_string from wagtail.models import Page @@ -76,9 +77,9 @@ class BasePage(MetadataPageMixin, DataLayerMixin, Page): help_text=_( "The name of the page as it will appear at the end of the URL e.g. http://nationalarchives.org.uk/[slug]" ), + widget=SlugInput, ), FieldPanel("seo_title"), - FieldPanel("show_in_menus"), FieldPanel( "search_description", help_text=_( @@ -92,7 +93,13 @@ class BasePage(MetadataPageMixin, DataLayerMixin, Page): ), ), ], - _("Common page configuration"), + _("For search engines"), + ), + MultiFieldPanel( + [ + FieldPanel("show_in_menus"), + ], + _("For site menus"), ), FieldPanel("teaser_image"), FieldPanel("teaser_text"), diff --git a/sass/etna.scss b/sass/etna.scss index c807937f1..77e0c4665 100644 --- a/sass/etna.scss +++ b/sass/etna.scss @@ -132,6 +132,8 @@ These are Etna specific components, created using BEM and following our guidelin @import "includes/featured-record-article"; @import "includes/record-crosslink-panel"; @import "includes/image-block"; +@import "includes/author-intro"; +@import "includes/author-info"; @import "includes/password-page"; @import "includes/record-creator-description"; @import "includes/record-creator-collections"; diff --git a/sass/includes/_author-info.scss b/sass/includes/_author-info.scss new file mode 100644 index 000000000..d72eaae4f --- /dev/null +++ b/sass/includes/_author-info.scss @@ -0,0 +1,37 @@ +.author-info { + margin-left: 1rem; + margin-top: 2rem; + + &__label { + text-transform: uppercase; + font-family: $font__roboto; + margin-bottom: 1rem; + } + + &__image { + width: 65%; + border-radius: 50%; + } + + &__text { + margin-top: 1rem; + } + + &__title { + margin-top: 0; + margin-bottom: 0; + font-family: $font__supria-sans; + + @media (max-width: $screen__md) { + font-size: 2.5rem; + } + } + + &__title a { + color: $color__black; + } + + &__paragraph { + color: $color__grey-600; + } +} diff --git a/sass/includes/_author-intro.scss b/sass/includes/_author-intro.scss new file mode 100644 index 000000000..7763805e3 --- /dev/null +++ b/sass/includes/_author-intro.scss @@ -0,0 +1,108 @@ +.author-intro { + padding-top: 1rem; + background-color: $color__white; + color: $color__black; + + a { + color: $color__black; + } + + &__title { + margin-top: 0; + margin-bottom: 0; + font-size: 4rem; + font-family: $font__supria-sans; + //background: green; + + @media (max-width: $screen__md) { + font-size: 2.5rem; + } + + &-prefix { + display: block; + @media (max-width: $screen__md) { + font-size: 1.25rem; + line-height: 1.3rem; + } + font-size: 2rem; + line-height: 0.3rem; + margin-top: 1rem; + } + } + + &__meta { + background-color: $color__grey-700; + color: $color__white; + padding-bottom: 1.875rem; + + &-list { + margin-bottom: 0; + } + + &-item { + color: $color__grey-200; + display: inline-block; + + &:not(:last-of-type) { + margin-right: 1.25rem; + } + } + } + + &__paragraph { + font-size: 1.125rem; // one off font size (18px) + margin-bottom: 0.5rem; + + @media only screen and (min-width: $screen__lg) { + font-size: 1.313rem; // one off font size (21px) + } + + .template-focused-article & { + margin-top: 2.875rem; + font-size: $font-size-base; + + @media (min-width: $screen__md) { + margin-top: 4.625rem; + font-size: 1.313rem; + } + } + } + + // Styles for Focused Article templates + .template-focused-article & { + background-color: $color__grey-700; + + &__title { + color: $color__white; + line-height: 122%; + margin-bottom: 1rem; + @extend .col-md-9; + @extend .row; + } + } + + &__meta-item a { + color: $color__white; + } + + .info { + padding-top: 5%; + } + + &__image { + border-radius: 50%; + } + + &--dark .generic-intro__paragraph { + margin-top: 0; + background-color: $color__grey-700; + color: $color__white; + margin-bottom: 0; + @extend .col-md-9; + @extend .row; + + p:last-of-type { + margin-bottom: 1.5rem; + } + } +} diff --git a/sass/includes/_generic-intro.scss b/sass/includes/_generic-intro.scss index 5b083c4bc..cb47bbf71 100644 --- a/sass/includes/_generic-intro.scss +++ b/sass/includes/_generic-intro.scss @@ -81,6 +81,10 @@ } } + &__meta-item a { + color: $color__white; + } + &--dark .generic-intro__paragraph { margin-top: 0; background-color: $color__grey-700; diff --git a/templates/articles/focused_article_page.html b/templates/articles/focused_article_page.html index 7f2db1d0d..48dddb782 100644 --- a/templates/articles/focused_article_page.html +++ b/templates/articles/focused_article_page.html @@ -2,6 +2,7 @@ {% load static wagtailcore_tags sections_tags wagtailmetadata_tags %} +{% load wagtailimages_tags %} {% block meta_tag %} {% meta_tags %} {% endblock %} @@ -14,19 +15,20 @@ {% else %} {% include 'includes/generic-intro.html' with breadcrumb=True title=page.title %} {% endif %} - {% if page.author or page.first_published_at %} + {% if page.author_name %} {% endif %} + {% include 'includes/hero-img.html' with hero_image=page.hero_image caption=page.hero_image_caption %} {% if page.intro and page.hero_image %} @@ -50,6 +52,23 @@ + {% if page.author_name %} + + {% endif %}