diff --git a/.github/workflows/ci-run-tests.yml b/.github/workflows/ci-run-tests.yml index 34778102..2fab7562 100644 --- a/.github/workflows/ci-run-tests.yml +++ b/.github/workflows/ci-run-tests.yml @@ -3,6 +3,7 @@ on: push: branches: - main + - feat/modern-footer pull_request: branches: - '*' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index e956f584..37661ea3 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -2,7 +2,9 @@ name: "CodeQL" on: push: - branches: [main, deploy/preview] + branches: + - 'main' + - 'feat/modern-footer' pull_request: # The branches below must be a subset of the branches above branches: [main, deploy/preview] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d621073..9d2afb2a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,13 @@ See the [installation instructions](README.md#install) ### Testing -Wagtail is based on Django, and there are many Django-style tests typically named `tests.py` to test templates. These verify that the templates can be compiled (that they don't have syntax errors) and that they are inserting variables. +Wagtail is based on Django, and there are many tests, in each app, typically named `tests.py` or `tests/test_*.py`, to test templates and business logic. These verify that the pages render without error and that they contain the expected values. + +The testsuite uses [pytest](https://docs.pytest.org/) and [pytest-django](https://pytest-django.readthedocs.io/). You can run the testsuite locally: + +```bash +pytest +``` ## Frontend Development diff --git a/dev/deploy-to-container/cli.js b/dev/deploy-to-container/cli.js index dc463f4d..173feb37 100644 --- a/dev/deploy-to-container/cli.js +++ b/dev/deploy-to-container/cli.js @@ -154,7 +154,7 @@ async function main () { `VIRTUAL_PORT=80`, `DJANGO_SETTINGS_MODULE=ietf.settings.production`, `PGHOST=ws-db-${branch}`, - `PGDATABASE=torchbox`, + `PGDATABASE=torchbox_temp`, `PGUSER=postgres`, `PGPASSWORD=password`, `SECRET_KEY=${nanoid(36)}`, diff --git a/ietf/announcements/factories.py b/ietf/announcements/factories.py new file mode 100644 index 00000000..2e98791c --- /dev/null +++ b/ietf/announcements/factories.py @@ -0,0 +1,23 @@ +import factory +import wagtail_factories + +from ietf.utils.factories import StandardBlockFactory +from .models import IABAnnouncementIndexPage, IABAnnouncementPage + + +class IABAnnouncementPageFactory(wagtail_factories.PageFactory): + title = factory.Faker("name") + date = factory.Faker("date") + introduction = factory.Faker("paragraph") + body = wagtail_factories.StreamFieldFactory(StandardBlockFactory) + + class Meta: # type: ignore + model = IABAnnouncementPage + + +class IABAnnouncementIndexPageFactory(wagtail_factories.PageFactory): + title = factory.Faker("name") + introduction = factory.Faker("paragraph") + + class Meta: # type: ignore + model = IABAnnouncementIndexPage diff --git a/ietf/announcements/migrations/0004_alter_iabannouncementpage_body.py b/ietf/announcements/migrations/0004_alter_iabannouncementpage_body.py new file mode 100644 index 00000000..a7b31302 --- /dev/null +++ b/ietf/announcements/migrations/0004_alter_iabannouncementpage_body.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.7 on 2024-04-03 13:40 + +from django.db import migrations +import wagtail.blocks +import wagtail.contrib.table_block.blocks +import wagtail.contrib.typed_table_block.blocks +import wagtail.embeds.blocks +import wagtail.fields +import wagtail.images.blocks +import wagtailmarkdown.blocks + + +class Migration(migrations.Migration): + + dependencies = [ + ('announcements', '0003_alter_iabannouncementpage_body'), + ] + + operations = [ + migrations.AlterField( + model_name='iabannouncementpage', + name='body', + field=wagtail.fields.StreamField([('heading', wagtail.blocks.CharBlock(icon='title')), ('paragraph', wagtail.blocks.RichTextBlock(icon='pilcrow')), ('image', wagtail.images.blocks.ImageChooserBlock(icon='image', template='includes/imageblock.html')), ('markdown', wagtailmarkdown.blocks.MarkdownBlock(icon='code')), ('embed', wagtail.embeds.blocks.EmbedBlock(icon='code')), ('raw_html', wagtail.blocks.RawHTMLBlock(icon='placeholder')), ('table', wagtail.contrib.table_block.blocks.TableBlock(table_options={'renderer': 'html'}, template='includes/tableblock.html')), ('typed_table', wagtail.contrib.typed_table_block.blocks.TypedTableBlock([('text', wagtail.blocks.CharBlock(required=False)), ('numeric', wagtail.blocks.FloatBlock(required=False, template='blocks/float_block.html')), ('rich_text', wagtail.blocks.RichTextBlock(required=False)), ('image', wagtail.images.blocks.ImageChooserBlock(required=False))])), ('note_well', wagtail.blocks.StructBlock([], icon='placeholder', label='Note Well Text'))], use_json_field=True), + ), + ] diff --git a/ietf/announcements/tests.py b/ietf/announcements/tests.py new file mode 100644 index 00000000..66102d46 --- /dev/null +++ b/ietf/announcements/tests.py @@ -0,0 +1,79 @@ +from datetime import timedelta +from bs4 import BeautifulSoup +from django.test import Client +from django.utils import timezone + +import pytest + +from ietf.home.models import IABHomePage +from .factories import IABAnnouncementIndexPageFactory, IABAnnouncementPageFactory +from .models import IABAnnouncementIndexPage, IABAnnouncementPage + +pytestmark = pytest.mark.django_db + + +class TestIABAnnouncement: + @pytest.fixture(autouse=True) + def set_up(self, iab_home: IABHomePage, client: Client): + self.home = iab_home + self.client = client + + self.index: IABAnnouncementIndexPage = IABAnnouncementIndexPageFactory( + parent=self.home, + ) # type: ignore + + now = timezone.now() + + self.announcement_1: IABAnnouncementPage = IABAnnouncementPageFactory( + parent=self.index, + date=now - timedelta(days=10), + ) # type: ignore + + self.announcement_2: IABAnnouncementPage = IABAnnouncementPageFactory( + parent=self.index, + date=now - timedelta(days=8), + ) # type: ignore + + self.announcement_3: IABAnnouncementPage = IABAnnouncementPageFactory( + parent=self.index, + date=now - timedelta(days=4), + body__0__heading="Heading in body Streamfield", + ) # type: ignore + + self.announcement_4: IABAnnouncementPage = IABAnnouncementPageFactory( + parent=self.index, + date=now, + ) # type: ignore + + def test_announcement_page(self): + response = self.client.get(self.announcement_3.url) + assert response.status_code == 200 + html = response.content.decode() + + assert self.announcement_3.title in html + assert self.announcement_3.body[0].value in html + assert self.announcement_3.introduction in html + + def test_homepage(self): + """ The two most recent announcements are shown on the homepage """ + response = self.client.get(self.home.url) + assert response.status_code == 200 + html = response.content.decode() + + assert f'href="{self.announcement_3.url}"' in html + assert self.announcement_3.title in html + assert f'href="{self.announcement_4.url}"' in html + assert self.announcement_4.title in html + + def test_index_page(self): + response = self.client.get(self.index.url) + assert response.status_code == 200 + html = response.content.decode() + soup = BeautifulSoup(html, "html.parser") + links = [a.get_text().strip() for a in soup.select("#content .container h2 a")] + assert links == [ + self.announcement_4.title, + self.announcement_3.title, + self.announcement_2.title, + self.announcement_1.title, + ] diff --git a/ietf/bibliography/models.py b/ietf/bibliography/models.py index 96305861..01c82950 100644 --- a/ietf/bibliography/models.py +++ b/ietf/bibliography/models.py @@ -8,8 +8,6 @@ from django.template.loader import get_template from wagtail.models import Page -from ietf.utils import OrderedSet - class BibliographyItem(models.Model): """ @@ -101,7 +99,7 @@ def render(self, request=None): else: return str(object) - def __str__(self): + def __str__(self): # pragma: no cover return "Bibliography Item #{}: {}".format(self.ordering, self.content_object) @@ -159,9 +157,11 @@ def save(self, *args, **kwargs): ) for content_field, prepared_content_field in self.CONTENT_FIELD_MAP.items() } - tags = OrderedSet(all_soup.find_all("a", attrs={"data-app": True})) - for tag in tags: + # Look for nodes that are tagged with bibliographic markup, + # create BibliographyItem records, and turn the nodes into + # footnote links. + for index, tag in enumerate(all_soup.find_all("a", attrs={"data-app": True})): app = tag["data-app"] model = tag["data-linktype"] obj_id = tag["data-id"] @@ -187,7 +187,7 @@ def save(self, *args, **kwargs): } item = BibliographyItem.objects.create( page=self, - ordering=list(tags).index(tag) + 1, + ordering=index + 1, content_key=model, content_identifier=obj_id, **object_details diff --git a/ietf/bibliography/tests.py b/ietf/bibliography/tests.py new file mode 100644 index 00000000..fc062ac9 --- /dev/null +++ b/ietf/bibliography/tests.py @@ -0,0 +1,137 @@ +import pytest +from django.contrib.contenttypes.models import ContentType +from django.test import Client +from django.urls import reverse + +from ietf.bibliography.models import BibliographyItem +from ietf.home.models import HomePage +from ietf.snippets.models import RFC +from ietf.standard.factories import StandardIndexPageFactory, StandardPageFactory +from ietf.standard.models import StandardIndexPage, StandardPage + +pytestmark = pytest.mark.django_db + + +class TestBibliography: + @pytest.fixture(autouse=True) + def set_up(self, home: HomePage, client: Client): + self.home = home + self.client = client + + self.rfc_2026 = RFC.objects.create( + name="draft-ietf-poised95-std-proc-3", + title="The Internet Standards Process -- Revision 3", + rfc="2026", + ) + + self.standard_index: StandardIndexPage = StandardIndexPageFactory( + parent=self.home, + ) # type: ignore + + self.standard_page: StandardPage = StandardPageFactory( + parent=self.standard_index, + ) # type: ignore + self.standard_page.in_depth = [ + { + "type": "raw_html", + "value": ( + f'The Standards RFC' + ), + } + ] + self.standard_page.save() + + def test_bibliography_item_created(self): + """ + Make sure that a BibliographyItem record was created when + `self.standard_page` was created in `set_up()`. + """ + assert BibliographyItem.objects.count() == 1 + item = BibliographyItem.objects.get() + assert item.content_object == self.rfc_2026 + + def test_referenced_types(self, admin_client): + """ + Admin view that shows which object types might be referenced in content + pages. + """ + rfc_content_type = ContentType.objects.get_for_model(RFC) + response = admin_client.get(reverse("referenced_types")) + assert response.status_code == 200 + html = response.content.decode() + assert reverse("referenced_objects", args=[rfc_content_type.pk]) in html + assert "snippets | RFC" in html + + def test_referenced_objects(self, admin_client): + """ + Admin view that shows which objects are being referenced as + bibliography items in content pages. + """ + rfc_content_type = ContentType.objects.get_for_model(RFC) + response = admin_client.get( + reverse("referenced_objects", args=[rfc_content_type.pk]) + ) + assert response.status_code == 200 + html = response.content.decode() + assert reverse( + "referencing_pages", args=[rfc_content_type.pk, self.rfc_2026.pk] + ) in html + assert "RFC 2026" in html + + def test_referencing_pages(self, admin_client): + """ + Admin view that shows which pages are referencing a given object. + """ + rfc_content_type = ContentType.objects.get_for_model(RFC) + response = admin_client.get( + reverse("referencing_pages", args=[rfc_content_type.pk, self.rfc_2026.pk]) + ) + assert response.status_code == 200 + html = response.content.decode() + assert self.standard_page.title in html + + def test_render_page(self, client): + """ + The title of the referenced object should be displayed in the page. + """ + response = client.get(self.standard_page.url) + assert response.status_code == 200 + html = response.content.decode() + assert "RFC 2026" in html + + def test_render_page_reference_removed(self, client): + """ + The target of a BibliographyItem was deleted. It should be displayed as + such. + """ + self.rfc_2026.delete() + self.standard_page.save() + response = client.get(self.standard_page.url) + assert response.status_code == 200 + html = response.content.decode() + assert "RFC 2026" not in html + assert "(removed)" in html + + def test_update_fields_partial_raises_exception(self): + """ + Updating the `key_info` and `in_depth` fields, without also updating + the corresponding `prepared_*` fields, is not allowed. The prepared + fields contain properly formatted footnotes and are meant to be + displayed to the visitor. + """ + with pytest.raises(ValueError) as error: + self.standard_page.save(update_fields=["key_info", "in_depth"]) + + assert error.match("Either all prepared content fields must be updated or none") + + def test_update_fields_with_all_prepared_fields_succeeds(self): + """ + Updating the `key_info` and `in_depth` fields, while also updating + the corresponding `prepared_*` fields, should work fine. + """ + self.standard_page.save( + update_fields=[ + "key_info", "in_depth", "prepared_key_info", "prepared_in_depth" + ] + ) diff --git a/ietf/blog/factories.py b/ietf/blog/factories.py new file mode 100644 index 00000000..8a3261af --- /dev/null +++ b/ietf/blog/factories.py @@ -0,0 +1,22 @@ +import factory +import wagtail_factories + +from ietf.utils.factories import StandardBlockFactory + +from .models import BlogIndexPage, BlogPage + + +class BlogPageFactory(wagtail_factories.PageFactory): + title = factory.Faker("name") + introduction = factory.Faker("paragraph") + body = wagtail_factories.StreamFieldFactory(StandardBlockFactory) + + class Meta: # type: ignore + model = BlogPage + + +class BlogIndexPageFactory(wagtail_factories.PageFactory): + title = factory.Faker("name") + + class Meta: # type: ignore + model = BlogIndexPage diff --git a/ietf/blog/feeds.py b/ietf/blog/feeds.py index a80c9468..1275744e 100644 --- a/ietf/blog/feeds.py +++ b/ietf/blog/feeds.py @@ -1,5 +1,6 @@ from django.contrib.syndication.views import Feed from django.db.models.functions import Coalesce +from django.utils.functional import cached_property from wagtail.models import Site from ..blog.models import BlogPage @@ -9,10 +10,13 @@ class BlogFeed(Feed): link = "/blog/" + def get_title(self): + return self.feed_settings.blog_feed_title + def __call__(self, request, *args, **kwargs): - settings = FeedSettings.for_site(Site.find_for_request(request)) - self.title = settings.blog_feed_title - self.description = settings.blog_feed_description + self.feed_settings = FeedSettings.for_site(Site.find_for_request(request)) + self.title = self.get_title() + self.description = self.feed_settings.blog_feed_description return super().__call__(request, *args, **kwargs) def items(self): @@ -38,9 +42,15 @@ def item_pubdate(self, item): return item.date class TopicBlogFeed(BlogFeed): - def __call__(self, request, *args, **kwargs): - self.topic = kwargs.get('topic') - return super().__call__(request, *args, **kwargs) + def __init__(self, topic): + self.topic = topic + return super().__init__() + + def get_title(self): + title = super().get_title() + if title: + title = f"{title} – {self.topic}" + return title def items(self): return ( @@ -56,5 +66,11 @@ def __init__(self, person, queryset): self.queryset = queryset return super().__init__() + def get_title(self): + title = super().get_title() + if title: + title = f"{title} – {self.person.name}" + return title + def items(self): return self.queryset diff --git a/ietf/blog/migrations/0009_alter_blogpage_body.py b/ietf/blog/migrations/0009_alter_blogpage_body.py new file mode 100644 index 00000000..cb5c356d --- /dev/null +++ b/ietf/blog/migrations/0009_alter_blogpage_body.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.7 on 2024-04-03 13:40 + +from django.db import migrations +import wagtail.blocks +import wagtail.contrib.table_block.blocks +import wagtail.contrib.typed_table_block.blocks +import wagtail.embeds.blocks +import wagtail.fields +import wagtail.images.blocks +import wagtailmarkdown.blocks + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0008_alter_blogpageauthor_author'), + ] + + operations = [ + migrations.AlterField( + model_name='blogpage', + name='body', + field=wagtail.fields.StreamField([('heading', wagtail.blocks.CharBlock(icon='title')), ('paragraph', wagtail.blocks.RichTextBlock(icon='pilcrow')), ('image', wagtail.images.blocks.ImageChooserBlock(icon='image', template='includes/imageblock.html')), ('markdown', wagtailmarkdown.blocks.MarkdownBlock(icon='code')), ('embed', wagtail.embeds.blocks.EmbedBlock(icon='code')), ('raw_html', wagtail.blocks.RawHTMLBlock(icon='placeholder')), ('table', wagtail.contrib.table_block.blocks.TableBlock(table_options={'renderer': 'html'}, template='includes/tableblock.html')), ('typed_table', wagtail.contrib.typed_table_block.blocks.TypedTableBlock([('text', wagtail.blocks.CharBlock(required=False)), ('numeric', wagtail.blocks.FloatBlock(required=False, template='blocks/float_block.html')), ('rich_text', wagtail.blocks.RichTextBlock(required=False)), ('image', wagtail.images.blocks.ImageChooserBlock(required=False))])), ('note_well', wagtail.blocks.StructBlock([], icon='placeholder', label='Note Well Text'))], use_json_field=True), + ), + ] diff --git a/ietf/blog/models.py b/ietf/blog/models.py index c84764ec..e60c8a75 100644 --- a/ietf/blog/models.py +++ b/ietf/blog/models.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, time from functools import partial from django.core.exceptions import ObjectDoesNotExist @@ -9,6 +9,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.utils import functional from django.utils.safestring import mark_safe +from django.utils.timezone import make_aware from modelcluster.fields import ParentalKey from wagtail.admin.panels import FieldPanel, InlinePanel from wagtail.contrib.routable_page.models import RoutablePageMixin, route @@ -21,6 +22,12 @@ from ..utils.blocks import StandardBlock from ..utils.models import FeedSettings, PromoteMixin +IESG_STATEMENT_TOPIC_ID = "7" + + +def make_date_aware(value): + return make_aware(datetime.combine(value, time())) + def ordered_live_annotated_blogs(sibling=None): blogs = BlogPage.objects.live().prefetch_related("authors") @@ -33,11 +40,11 @@ def ordered_live_annotated_blogs(sibling=None): def filter_pages_by_date_from(pages, date_from): - return pages.filter(d__gte=date_from) + return pages.filter(d__gte=make_date_aware(date_from)) def filter_pages_by_date_to(pages, date_to): - return pages.filter(d__lte=date_to) + return pages.filter(d__lte=make_date_aware(date_to)) def parse_date_search_input(date): @@ -157,17 +164,15 @@ class BlogPage(Page, BibliographyMixin, PromoteMixin): ) CONTENT_FIELD_MAP = {"body": "prepared_body"} + parent_page_types = [ + "blog.BlogIndexPage", + ] + subpage_types = [] + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.filter_topic = None - @property - def first_author(self): - try: - return self.authors.first().author - except AttributeError: - return self.authors.none() - @property def date(self): return self.date_published or self.first_published_at @@ -403,7 +408,7 @@ def feed_by_author(self, request, slug): def feed_with_topic(self, request, topic): from .feeds import TopicBlogFeed - return TopicBlogFeed()(request, topic=topic) + return TopicBlogFeed(topic=topic)(request) @route(r"^([-\w]+)/all/$") def filtered_entries(self, request, slug, *args, **kwargs): @@ -416,7 +421,7 @@ def redirect_first(self, request, slug=None, *args, **kwargs): # IESG statements were moved under the IESG about/groups page. Queries to the # base /blog/ page that used a query string to filter for IESG statements can't # be redirected through ordinary redirection, so we're doing it here. - if request.GET.get("primary_topic") == "7": + if request.GET.get("primary_topic") == IESG_STATEMENT_TOPIC_ID: query_string = "" topic = request.GET.get("secondary_topic") if topic: @@ -428,7 +433,7 @@ def redirect_first(self, request, slug=None, *args, **kwargs): date_to = request.GET.get("date_to") if date_to: separator = "&" if query_string else "" - query_string = query_string + separator + "date_to" + date_to + query_string = query_string + separator + "date_to=" + date_to target_url = "/about/groups/iesg/statements" if query_string: target_url = target_url + "?" + query_string diff --git a/ietf/blog/tests.py b/ietf/blog/tests.py index 23d0dc58..0e5beb7d 100644 --- a/ietf/blog/tests.py +++ b/ietf/blog/tests.py @@ -1,168 +1,250 @@ from datetime import timedelta +from bs4 import BeautifulSoup +from django.test import Client from django.utils import timezone -from django.test import TestCase -from wagtail.models import Page, Site +import pytest -from ..home.models import HomePage -from ..snippets.models import Person, Topic -from .models import BlogIndexPage, BlogPage, BlogPageAuthor, BlogPageTopic +from ietf.snippets.factories import PersonFactory, TopicFactory +from ietf.home.models import HomePage +from ietf.snippets.models import Topic +from ietf.utils.models import FeedSettings +from .factories import BlogIndexPageFactory, BlogPageFactory +from .models import ( + IESG_STATEMENT_TOPIC_ID, + BlogIndexPage, + BlogPage, + BlogPageAuthor, + BlogPageTopic, +) +pytestmark = pytest.mark.django_db -class BlogTests(TestCase): - def setUp(self): - root = Page.get_first_root_node() - home = HomePage( - slug="homepageslug", - title="home page title", - heading="home page heading", - introduction="home page introduction", - ) - - root.add_child(instance=home) +def datefmt(value): + return value.strftime("%d/%m/%Y") - Site.objects.all().delete() - Site.objects.create( - hostname="localhost", - root_page=home, - is_default_site=True, - site_name="testingsitename", - ) +class TestBlog: + @pytest.fixture(autouse=True) + def set_up(self, home: HomePage, client: Client): + self.home = home + self.client = client - self.blog_index = BlogIndexPage( + self.blog_index: BlogIndexPage = BlogIndexPageFactory( + parent=self.home, slug="blog", - title="blog index title", - ) - home.add_child(instance=self.blog_index) - - now = timezone.now() - - self.otherblog = BlogPage( - slug="otherpost", - title="other title", - introduction="other introduction", - body='[{"id": "1", "type": "rich_text", "value": "

other body

"}]', - date_published=(now - timedelta(minutes=10)), - ) - self.blog_index.add_child(instance=self.otherblog) - self.otherblog.save - - self.prevblog = BlogPage( - slug="prevpost", - title="prev title", - introduction="prev introduction", - body='[{"id": "2", "type": "rich_text", "value": "

prev body

"}]', - date_published=(now - timedelta(minutes=5)), - ) - self.blog_index.add_child(instance=self.prevblog) - self.prevblog.save() - - self.blog = BlogPage( - slug="blogpost", - title="blog title", - introduction="blog introduction", - body='[{"id": "3", "type": "rich_text", "value": "

blog body

"}]', - first_published_at=(now + timedelta(minutes=1)), - ) - self.blog_index.add_child(instance=self.blog) - self.blog.save() - - self.nextblog = BlogPage( - slug="nextpost", - title="next title", - introduction="next introduction", - body='[{"id": "4", "type": "rich_text", "value": "

next body

"}]', - first_published_at=(now + timedelta(minutes=5)), - ) - self.blog_index.add_child(instance=self.nextblog) - self.nextblog.save() - - self.alice = Person.objects.create(name="Alice", slug="alice") - self.bob = Person.objects.create(name="Bob", slug="bob") - - BlogPageAuthor.objects.create(page=self.otherblog, author=self.alice) - BlogPageAuthor.objects.create(page=self.prevblog, author=self.alice) - BlogPageAuthor.objects.create(page=self.prevblog, author=self.bob) - BlogPageAuthor.objects.create(page=self.nextblog, author=self.bob) + ) # type: ignore + + self.now = timezone.now() + + self.iab_topic: Topic = TopicFactory(title="iab") # type: ignore + self.iesg_topic: Topic = TopicFactory(title="iesg") # type: ignore + + self.other_blog_page: BlogPage = BlogPageFactory( + parent=self.blog_index, + date_published=self.now - timedelta(days=10), + topics=[BlogPageTopic(topic=self.iab_topic)], + ) # type: ignore + + self.prev_blog_page: BlogPage = BlogPageFactory( + parent=self.blog_index, + date_published=self.now - timedelta(days=5), + topics=[BlogPageTopic(topic=self.iab_topic)], + ) # type: ignore + + self.blog_page: BlogPage = BlogPageFactory( + parent=self.blog_index, + first_published_at=self.now + timedelta(days=1), + body__0__heading="Heading in body Streamfield", + ) # type: ignore + + self.next_blog_page: BlogPage = BlogPageFactory( + parent=self.blog_index, + first_published_at=self.now + timedelta(days=5), + topics=[BlogPageTopic(topic=self.iesg_topic)], + ) # type: ignore + + self.alice = PersonFactory(name="Alice") + self.bob = PersonFactory(name="Bob") + + BlogPageAuthor.objects.create(page=self.other_blog_page, author=self.alice) + BlogPageAuthor.objects.create(page=self.prev_blog_page, author=self.alice) + BlogPageAuthor.objects.create(page=self.prev_blog_page, author=self.bob) + BlogPageAuthor.objects.create(page=self.next_blog_page, author=self.bob) + + self.feed_settings = FeedSettings.for_site(self.home.get_site()) + self.feed_settings.blog_feed_title = "Blog Feed Title" + self.feed_settings.blog_feed_description = "Blog Feed Description" + self.feed_settings.save() def test_blog(self): - r = self.client.get(path=self.blog_index.url) - self.assertEqual(r.status_code, 200) + index_response = self.client.get(path=self.blog_index.url) + assert index_response.status_code == 200 - r = self.client.get(path=self.blog.url) - self.assertEqual(r.status_code, 200) + response = self.client.get(path=self.blog_page.url) + assert response.status_code == 200 + html = response.content.decode() - self.assertIn(self.blog.title.encode(), r.content) - self.assertIn(self.blog.introduction.encode(), r.content) - # self.assertIn(blog.body.raw_text.encode(), r.content) - self.assertIn(('href="%s"' % self.nextblog.url).encode(), r.content) - self.assertIn(('href="%s"' % self.prevblog.url).encode(), r.content) - self.assertIn(('href="%s"' % self.otherblog.url).encode(), r.content) + assert self.blog_page.title in html + assert self.blog_page.body[0].value in html + assert self.blog_page.introduction in html + assert ('href="%s"' % self.next_blog_page.url) in html + assert ('href="%s"' % self.prev_blog_page.url) in html + assert ('href="%s"' % self.other_blog_page.url) in html def test_previous_next_links_correct(self): - self.assertTrue(self.prevblog.date < self.blog.date) - self.assertTrue(self.nextblog.date > self.blog.date) - blog = BlogPage.objects.get(pk=self.blog.pk) - self.assertEqual(self.prevblog, blog.previous) - self.assertEqual(self.nextblog, blog.next) + assert self.prev_blog_page.date < self.blog_page.date + assert self.next_blog_page.date > self.blog_page.date + blog = BlogPage.objects.get(pk=self.blog_page.pk) + assert self.prev_blog_page == blog.previous + assert self.next_blog_page == blog.next def test_author_index(self): alice_url = self.blog_index.reverse_subpage( "index_by_author", kwargs={"slug": self.alice.slug} ) alice_resp = self.client.get(self.blog_index.url + alice_url) - self.assertEqual(alice_resp.status_code, 200) + assert alice_resp.status_code == 200 html = alice_resp.content.decode("utf8") - self.assertIn("IETF | Articles by Alice", html) - self.assertIn("

Articles by Alice

", html) - self.assertIn(self.otherblog.url, html) - self.assertIn(self.prevblog.url, html) - self.assertNotIn(self.nextblog.url, html) - self.assertNotIn(self.blog.url, html) + assert "IETF | Articles by Alice" in html + assert "

Articles by Alice

" in html + assert self.other_blog_page.url in html + assert self.prev_blog_page.url in html + assert self.next_blog_page.url not in html + assert self.blog_page.url not in html def test_blog_feed(self): - r = self.client.get(path='/blog/feed/') - self.assertEqual(r.status_code, 200) - self.assertIn(self.blog.url.encode(), r.content) - self.assertIn(self.otherblog.url.encode(), r.content) + response = self.client.get(path="/blog/feed/") + assert response.status_code == 200 + feed = response.content.decode() + + assert f"{self.feed_settings.blog_feed_title}" in feed + assert self.blog_page.url in feed + assert self.other_blog_page.url in feed def test_topic_feed(self): - iab_topic = Topic(title="iab", slug="iab") - iab_topic.save() - iab_bptopic = BlogPageTopic(topic=iab_topic, page=self.otherblog) - iab_bptopic.save() - self.otherblog.topics = [iab_bptopic, ] - self.otherblog.save() - iesg_topic = Topic(title="iesg", slug="iesg") - iesg_topic.save() - iesg_bptopic = BlogPageTopic(topic=iesg_topic, page=self.otherblog) - iesg_bptopic.save() - self.nextblog.topics = [iesg_bptopic, ] - self.nextblog.save() - - r = self.client.get(path='/blog/iab/feed/') - self.assertEqual(r.status_code, 200) - self.assertIn(self.otherblog.url.encode(), r.content) - self.assertNotIn(self.blog.url.encode(), r.content) - self.assertNotIn(self.nextblog.url.encode(), r.content) - - r = self.client.get(path='/blog/iesg/feed/') - self.assertEqual(r.status_code, 200) - self.assertIn(self.nextblog.url.encode(), r.content) - self.assertNotIn(self.blog.url.encode(), r.content) - self.assertNotIn(self.otherblog.url.encode(), r.content) + iab_response = self.client.get(path="/blog/iab/feed/") + assert iab_response.status_code == 200 + iab_feed = iab_response.content.decode() + + assert f"{self.feed_settings.blog_feed_title} – iab" in iab_feed + assert self.other_blog_page.url in iab_feed + assert self.blog_page.url not in iab_feed + assert self.next_blog_page.url not in iab_feed + + iesg_response = self.client.get(path="/blog/iesg/feed/") + assert iesg_response.status_code == 200 + iesg_feed = iesg_response.content.decode() + + assert f"{self.feed_settings.blog_feed_title} – iesg" in iesg_feed + assert self.next_blog_page.url in iesg_feed + assert self.blog_page.url not in iesg_feed + assert self.other_blog_page.url not in iesg_feed def test_author_feed(self): alice_url = self.blog_index.reverse_subpage( "feed_by_author", kwargs={"slug": self.alice.slug} ) - self.assertIn("/feed/", alice_url) + assert "/feed/" in alice_url alice_resp = self.client.get(self.blog_index.url + alice_url) - self.assertEqual(alice_resp.status_code, 200) + assert alice_resp.status_code == 200 feed = alice_resp.content.decode("utf8") - self.assertIn(self.otherblog.url, feed) - self.assertIn(self.prevblog.url, feed) - self.assertNotIn(self.nextblog.url, feed) - self.assertNotIn(self.blog.url, feed) + assert f"{self.feed_settings.blog_feed_title} – Alice" in feed + assert self.other_blog_page.url in feed + assert self.prev_blog_page.url in feed + assert self.next_blog_page.url not in feed + assert self.blog_page.url not in feed + + def test_homepage(self): + """ The two most recent blog posts are shown on the homepage. """ + response = self.client.get(path=self.home.url) + assert response.status_code == 200 + html = response.content.decode() + + assert f'href="{self.blog_page.url}"' in html + assert self.blog_page.title in html + + def test_all_page(self): + """ The /blog/all/ page shows all the published blog posts. """ + response = self.client.get(f"{self.blog_index.url}all/") + assert response.status_code == 200 + html = response.content.decode() + soup = BeautifulSoup(html, "html.parser") + links = [a.get_text().strip() for a in soup.select("main table a")] + assert links == [ + self.next_blog_page.title, + self.blog_page.title, + self.prev_blog_page.title, + self.other_blog_page.title, + ] + + def test_filtering(self): + """ + Test the filtering of the blogs page. + + The blog page shows the most recent (filtered) post, along with a list + of other posts that match the filter, in descending order of + publication date. + """ + + def get_filtered(days_before=0, days_after=0, topic=None): + date_from = self.now + timedelta(days=days_before) + date_to = self.now + timedelta(days=days_after) + params = f"date_from={datefmt(date_from)}&date_to={datefmt(date_to)}" + if topic: + params += f"&topic={topic.pk}" + response = self.client.get(f"{self.blog_index.url}?{params}", follow=True) + assert response.status_code == 200 + html = response.content.decode() + soup = BeautifulSoup(html, "html.parser") + featured = soup.select("h1")[0].get_text().strip() + others = [ + a.get_text().strip() + for a in soup.select('aside[aria-label="Blog listing"] h2 a') + ] + return (featured, others) + + assert get_filtered(-10, 10) == ( + self.next_blog_page.title, + [ + self.blog_page.title, + self.prev_blog_page.title, + self.other_blog_page.title, + ], + ) + + assert get_filtered(0, 10) == ( + self.next_blog_page.title, + [self.blog_page.title], + ) + + assert get_filtered(-10, 0) == ( + self.prev_blog_page.title, + [self.other_blog_page.title], + ) + + assert get_filtered(-10, 10, self.iab_topic) == ( + self.prev_blog_page.title, + [self.other_blog_page.title], + ) + + def test_iesg_statements_redirect(self): + params = "&".join( + [ + f"primary_topic={IESG_STATEMENT_TOPIC_ID}", + f"date_from={datefmt(self.now + timedelta(days=-10))}", + f"date_to={datefmt(self.now + timedelta(days=10))}", + f"secondary_topic={self.iab_topic.pk}", + ] + ) + response = self.client.get(f"{self.blog_index.url}?{params}") + new_params = "&".join( + [ + f"topic={self.iab_topic.pk}", + f"date_from={datefmt(self.now + timedelta(days=-10))}", + f"date_to={datefmt(self.now + timedelta(days=10))}", + ] + ) + assert response.status_code == 302 + assert response.url == f"/about/groups/iesg/statements?{new_params}" diff --git a/ietf/conftest.py b/ietf/conftest.py new file mode 100644 index 00000000..6194af43 --- /dev/null +++ b/ietf/conftest.py @@ -0,0 +1,48 @@ +from unittest.mock import Mock +import pytest +from wagtail.models import Page, Site + +from ietf.home.factories import HomePageFactory, IABHomePageFactory +from ietf.utils.models import IAB_BASE, LayoutSettings + + +@pytest.fixture(autouse=True) +def disable_caches(settings): + """ + Tests run with the "dev" settings, which use memcached. We override them + with the dummy cache so we don't pollute our local development cache. + """ + + settings.CACHES = { + "default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}, + "sessions": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}, + "dummy": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}, + } + + +@pytest.fixture +def home(): + site = Site.objects.get() + site.root_page = HomePageFactory(parent=Page.get_first_root_node()) + site.save(update_fields=["root_page"]) + return site.root_page + + +@pytest.fixture +def iab_home(): + site = Site.objects.get() + site.root_page = IABHomePageFactory(parent=Page.get_first_root_node()) + site.hostname = "iab.org" + site.save(update_fields=["root_page", "hostname"]) + layout_settings = LayoutSettings.for_site(site) + layout_settings.base_template = IAB_BASE + layout_settings.save(update_fields=["base_template"]) + return site.root_page + + +@pytest.fixture(autouse=True) +def iab_blog_feed(monkeypatch: pytest.MonkeyPatch): + mock_get = Mock() + mock_get.return_value.text = "" + monkeypatch.setattr("ietf.home.models.get_request", mock_get) + return mock_get \ No newline at end of file diff --git a/ietf/context_processors.py b/ietf/context_processors.py index 18cbdf9d..3486bf25 100644 --- a/ietf/context_processors.py +++ b/ietf/context_processors.py @@ -4,7 +4,7 @@ from ietf.home.models import HomePage, IABHomePage from ietf.utils.models import SecondaryMenuItem, SocialMediaSettings -from ietf.utils.context_processors import get_main_menu +from ietf.utils.context_processors import get_footer, get_main_menu def home_page(site): @@ -46,4 +46,5 @@ def global_pages(request): "MENU": lambda: get_main_menu(site), "SECONDARY_MENU": lambda: secondary_menu(site), "SOCIAL_MENU": lambda: social_menu(site), + "FOOTER": lambda: get_footer(), } diff --git a/ietf/events/factories.py b/ietf/events/factories.py new file mode 100644 index 00000000..d780a44f --- /dev/null +++ b/ietf/events/factories.py @@ -0,0 +1,21 @@ +import factory +import wagtail_factories + +from ietf.utils.factories import StandardBlockFactory +from .models import EventListingPage, EventPage + + +class EventPageFactory(wagtail_factories.PageFactory): + title = factory.Faker("name") + introduction = factory.Faker("paragraph") + body = wagtail_factories.StreamFieldFactory(StandardBlockFactory) + + class Meta: # type: ignore + model = EventPage + + +class EventListingPageFactory(wagtail_factories.PageFactory): + title = factory.Faker("name") + + class Meta: # type: ignore + model = EventListingPage diff --git a/ietf/events/migrations/0009_alter_eventpage_body.py b/ietf/events/migrations/0009_alter_eventpage_body.py new file mode 100644 index 00000000..cab4aa2d --- /dev/null +++ b/ietf/events/migrations/0009_alter_eventpage_body.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.7 on 2024-04-03 13:40 + +from django.db import migrations +import wagtail.blocks +import wagtail.contrib.table_block.blocks +import wagtail.contrib.typed_table_block.blocks +import wagtail.embeds.blocks +import wagtail.fields +import wagtail.images.blocks +import wagtailmarkdown.blocks + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0008_alter_eventpage_body'), + ] + + operations = [ + migrations.AlterField( + model_name='eventpage', + name='body', + field=wagtail.fields.StreamField([('heading', wagtail.blocks.CharBlock(icon='title')), ('paragraph', wagtail.blocks.RichTextBlock(icon='pilcrow')), ('image', wagtail.images.blocks.ImageChooserBlock(icon='image', template='includes/imageblock.html')), ('markdown', wagtailmarkdown.blocks.MarkdownBlock(icon='code')), ('embed', wagtail.embeds.blocks.EmbedBlock(icon='code')), ('raw_html', wagtail.blocks.RawHTMLBlock(icon='placeholder')), ('table', wagtail.contrib.table_block.blocks.TableBlock(table_options={'renderer': 'html'}, template='includes/tableblock.html')), ('typed_table', wagtail.contrib.typed_table_block.blocks.TypedTableBlock([('text', wagtail.blocks.CharBlock(required=False)), ('numeric', wagtail.blocks.FloatBlock(required=False, template='blocks/float_block.html')), ('rich_text', wagtail.blocks.RichTextBlock(required=False)), ('image', wagtail.images.blocks.ImageChooserBlock(required=False))])), ('note_well', wagtail.blocks.StructBlock([], icon='placeholder', label='Note Well Text'))], use_json_field=True), + ), + ] diff --git a/ietf/events/tests.py b/ietf/events/tests.py index 8083c917..03504e5e 100644 --- a/ietf/events/tests.py +++ b/ietf/events/tests.py @@ -1,53 +1,54 @@ -from django.test import TestCase -from wagtail.models import Page, Site +from datetime import timedelta -from ..home.models import HomePage +import pytest +from django.test import Client +from django.utils import timezone + +from ietf.home.models import HomePage +from .factories import EventListingPageFactory, EventPageFactory from .models import EventListingPage, EventPage +pytestmark = pytest.mark.django_db -class EventPageTests(TestCase): - def test_event_page(self): - root = Page.get_first_root_node() - - home = HomePage( - slug="homepageslug", - title="home page title", - heading="home page heading", - introduction="home page introduction", - ) - - root.add_child(instance=home) - - Site.objects.all().delete() - - Site.objects.create( - hostname="localhost", - root_page=home, - is_default_site=True, - site_name="testingsitename", - ) - - eventlisting = EventListingPage( - slug="eventlisting", - title="event listing page title", - introduction="event listing page introduction", - ) - home.add_child(instance=eventlisting) - - eventpage = EventPage( - slug="event", - title="event title", - introduction="event introduction", - ) - eventlisting.add_child(instance=eventpage) - - rindex = self.client.get(path=eventlisting.url) - self.assertEqual(rindex.status_code, 200) - - r = self.client.get(path=eventpage.url) - self.assertEqual(r.status_code, 200) - - self.assertIn(eventpage.title.encode(), r.content) - self.assertIn(eventpage.introduction.encode(), r.content) - self.assertIn(('href="%s"' % eventlisting.url).encode(), r.content) +class TestEventPage: + @pytest.fixture(autouse=True) + def set_up(self, home: HomePage, client: Client): + self.home = home + self.client = client + + self.event_listing: EventListingPage = EventListingPageFactory( + parent=self.home, + ) # type: ignore + self.event_page: EventPage = EventPageFactory( + parent=self.event_listing, + end_date=timezone.now() + timedelta(days=1), + body__0__heading="Heading in body Streamfield", + ) # type: ignore + + def test_event_listing(self): + response = self.client.get(path=self.event_listing.url) + assert response.status_code == 200 + html = response.content.decode() + + assert self.event_page.title in html + assert f'href="{self.event_page.url}"' in html + + def test_event_page(self): + response = self.client.get(path=self.event_page.url) + assert response.status_code == 200 + html = response.content.decode() + + assert self.event_page.title in html + assert self.event_page.body[0].value in html + assert self.event_page.introduction in html + assert f'href="{self.event_listing.url}"' in html + + def test_home_page(self): + """ The first two upcoming events are shown on the homepage. """ + response = self.client.get(path=self.home.url) + assert response.status_code == 200 + html = response.content.decode() + + assert f'href="{self.event_page.url}"' in html + assert self.event_page.title in html diff --git a/ietf/forms/factories.py b/ietf/forms/factories.py new file mode 100644 index 00000000..7e202a5d --- /dev/null +++ b/ietf/forms/factories.py @@ -0,0 +1,13 @@ +import factory +import wagtail_factories + +from .models import FormPage + + +class FormPageFactory(wagtail_factories.PageFactory): + title = factory.Faker("name") + intro = factory.Faker("paragraph") + thank_you_text = factory.Faker("paragraph") + + class Meta: # type: ignore + model = FormPage diff --git a/ietf/forms/templatetags/form_tags.py b/ietf/forms/templatetags/form_tags.py index cbcb027a..7781d86f 100644 --- a/ietf/forms/templatetags/form_tags.py +++ b/ietf/forms/templatetags/form_tags.py @@ -4,11 +4,6 @@ register = template.Library() -@register.filter -def fieldtype(bound_field): - return camelcase_to_underscore(bound_field.field.__class__.__name__) - - @register.filter def widgettype(bound_field): return camelcase_to_underscore(bound_field.field.widget.__class__.__name__) diff --git a/ietf/forms/tests.py b/ietf/forms/tests.py index d3856463..37674c02 100644 --- a/ietf/forms/tests.py +++ b/ietf/forms/tests.py @@ -1,42 +1,38 @@ -from django.test import TestCase -from wagtail.models import Page, Site +import pytest +from django.core import mail +from django.test import Client -from ..home.models import HomePage +from ietf.home.models import HomePage +from .factories import FormPageFactory from .models import FormPage +pytestmark = pytest.mark.django_db -class FormPageTests(TestCase): - def test_form_page(self): - - root = Page.get_first_root_node() - - home = HomePage( - slug="homepageslug", - title="home page title", - heading="home page heading", - introduction="home page introduction", - ) - root.add_child(instance=home) +class TestFormPage: + FORM_ADDRESS = "forms@example.com" - Site.objects.all().delete() + @pytest.fixture(autouse=True) + def set_up(self, home: HomePage, client: Client): + self.home = home + self.client = client - Site.objects.create( - hostname="localhost", - root_page=home, - is_default_site=True, - site_name="testingsitename", - ) + self.form_page: FormPage = FormPageFactory( + parent=self.home, + to_address=self.FORM_ADDRESS, + ) # type: ignore - form = FormPage( - slug="form", - title="form title", - intro="form introduction", - ) - home.add_child(instance=form) - - r = self.client.get(path=form.url) - self.assertEqual(r.status_code, 200) - - self.assertIn(form.title.encode(), r.content) - self.assertIn(form.intro.encode(), r.content) + def test_form_page(self): + response = self.client.get(path=self.form_page.url) + assert response.status_code == 200 + html = response.content.decode() + + assert self.form_page.title in html + assert self.form_page.intro in html + + def test_submit(self): + response = self.client.post(self.form_page.url, {}) + assert response.status_code == 200 + assert len(mail.outbox) == 1 + message = mail.outbox[0] + assert message.to == [self.FORM_ADDRESS] diff --git a/ietf/glossary/factories.py b/ietf/glossary/factories.py new file mode 100644 index 00000000..ff81c9d6 --- /dev/null +++ b/ietf/glossary/factories.py @@ -0,0 +1,12 @@ +import factory +import wagtail_factories + +from .models import GlossaryPage + + +class GlossaryPageFactory(wagtail_factories.PageFactory): + title = factory.Faker("name") + introduction = factory.Faker("paragraph") + + class Meta: # type: ignore + model = GlossaryPage diff --git a/ietf/glossary/tests.py b/ietf/glossary/tests.py index 6367b1ed..75669463 100644 --- a/ietf/glossary/tests.py +++ b/ietf/glossary/tests.py @@ -1,42 +1,26 @@ -from django.test import TestCase -from wagtail.models import Page, Site +import pytest +from django.test import Client -from ..home.models import HomePage +from ietf.home.models import HomePage +from .factories import GlossaryPageFactory from .models import GlossaryPage +pytestmark = pytest.mark.django_db -class GlossaryPageTests(TestCase): - def test_glossary_page(self): - - root = Page.get_first_root_node() - - home = HomePage( - slug="homepageslug", - title="home page title", - heading="home page heading", - introduction="home page introduction", - ) - - root.add_child(instance=home) - Site.objects.all().delete() +class TestGlossaryPage: + @pytest.fixture(autouse=True) + def set_up(self, home: HomePage, client: Client): + self.home = home + self.client = client + self.glossary_page: GlossaryPage = GlossaryPageFactory( + parent=self.home, + ) # type: ignore - Site.objects.create( - hostname="localhost", - root_page=home, - is_default_site=True, - site_name="testingsitename", - ) - - glossary = GlossaryPage( - slug="glossary", - title="glossary title", - introduction="glossary introduction", - ) - home.add_child(instance=glossary) - - r = self.client.get(path=glossary.url) - self.assertEqual(r.status_code, 200) + def test_glossary_page(self): + response = self.client.get(path=self.glossary_page.url) + assert response.status_code == 200 + html = response.content.decode() - self.assertIn(glossary.title.encode(), r.content) - self.assertIn(glossary.introduction.encode(), r.content) + assert self.glossary_page.title in html + assert self.glossary_page.introduction in html diff --git a/ietf/home/factories.py b/ietf/home/factories.py new file mode 100644 index 00000000..0f01b199 --- /dev/null +++ b/ietf/home/factories.py @@ -0,0 +1,21 @@ +import factory +import wagtail_factories + +from .models import HomePage, IABHomePage + + +class HomePageFactory(wagtail_factories.PageFactory): + title = factory.Faker("name") + heading = factory.Faker("name") + introduction = factory.Faker("name") + + class Meta: # type: ignore + model = HomePage + + +class IABHomePageFactory(wagtail_factories.PageFactory): + title = factory.Faker("name") + heading = factory.Faker("name") + + class Meta: # type: ignore + model = IABHomePage diff --git a/ietf/home/models.py b/ietf/home/models.py index 63c59f97..9994fcba 100644 --- a/ietf/home/models.py +++ b/ietf/home/models.py @@ -17,36 +17,7 @@ from ..events.models import EventListingPage, EventPage -class HomePageBase: - def upcoming_events(self): - return ( - EventPage.objects.filter(end_date__gte=datetime.today()) - .live() - .descendant_of(self) - .order_by("start_date")[:2] - ) - - def event_index(self): - return EventListingPage.objects.live().descendant_of(self).first() - - def blog_index(self): - return BlogIndexPage.objects.live().first() - - def blogs(self, bp_kwargs={}): - return ( - BlogPage.objects.live() - .filter(**bp_kwargs) - .annotate( - date_sql=RawSQL( - "CASE WHEN (date_published IS NOT NULL) THEN date_published ELSE first_published_at END", - (), - ) - ) - .order_by("-date_sql")[:2] - ) - - -class HomePage(Page, HomePageBase): +class HomePage(Page): heading = models.CharField(max_length=255) introduction = models.CharField(max_length=255) main_image = models.ForeignKey( @@ -158,9 +129,6 @@ def announcements(self): def announcement_index(self): return IABAnnouncementIndexPage.objects.live().first() - def blog_index(self): - return BlogIndexPage.objects.live().first() - def blogs(self, bp_kwargs={}): entries = [] try: diff --git a/ietf/home/tests.py b/ietf/home/tests.py index b372d8f9..5558c2bf 100644 --- a/ietf/home/tests.py +++ b/ietf/home/tests.py @@ -1,74 +1,121 @@ -from django.test import TestCase -from wagtail.models import Page, Site +from django.test import Client +import pytest -from ..blog.models import BlogIndexPage, BlogPage -from .models import HomePage +from ietf.standard.factories import IABStandardPageFactory, StandardPageFactory +from ietf.standard.models import StandardPage +from .models import HomePage, IABHomePage + +pytestmark = pytest.mark.django_db + + +class TestHome: + @pytest.fixture(autouse=True) + def set_up(self, home: HomePage, client: Client): + self.home = home + self.client = client -class HomeTests(TestCase): def test_homepage(self): + response = self.client.get(path=self.home.url) + assert response.status_code == 200 + html = response.content.decode() + + assert "IETF" in html + assert self.home.title in html + assert self.home.heading in html + assert self.home.introduction in html + + def test_button(self): + page: StandardPage = StandardPageFactory( + parent=self.home, + ) # type: ignore + self.home.button_text = "Homepage button text" + self.home.button_link = page + self.home.save(update_fields=["button_text", "button_link"]) + + response = self.client.get(path=self.home.url) + assert response.status_code == 200 + html = response.content.decode() + + assert self.home.button_text in html + assert f'href="{page.url}"' in html + + +IAB_FEED_XML = """\ +<?xml version="1.0" encoding="utf-8"?> +<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"> + <channel> + <title/> + <link>http://www.ietf.org/blog/</link> + <description/> + <atom:link href="http://www.ietf.org/blog/iab/feed/" rel="self"/> + <language>en-gb</language> + <lastBuildDate>Tue, 05 Mar 2024 14:46:00 +0000</lastBuildDate> + <item> + <title>IAB Workshop on Barriers to Internet Access of Services (BIAS) + http://www.ietf.org/blog/iab-bias-workshop/ + The Internet Architecture Board (IAB) organizes workshops about topics of interest to the community that bring diverse experts together, raise awareness, and possibly identify the next steps that can be explored by the community. The IAB held its “Barriers for Internet Access of Services (Bias)” fully online workshop during the week of January 15, 2024. + Dhruv Dhody + Tue, 05 Mar 2024 14:46:00 +0000 + http://www.ietf.org/blog/iab-bias-workshop/ + + + Stepping towards a Sustainable Internet + http://www.ietf.org/blog/eimpact-program-workshop/ + The IAB’s new Environmental Impacts of Internet Technology (E-Impact) program will hold its first virtual interim meeting over two slots on February 15th and 16th 2024. These interim meetings are open to participation, and we invite all interested community members to join, participate, and contribute. + Jari Arkko, Suresh Krishnan + Wed, 07 Feb 2024 09:56:00 +0000 + http://www.ietf.org/blog/eimpact-program-workshop/ + + + +""" + + +class TestIABHome: + @pytest.fixture(autouse=True) + def set_up(self, iab_home: IABHomePage, client: Client): + self.home = iab_home + self.client = client - root = Page.get_first_root_node() - - home = HomePage( - slug="homepageslug", - title="home page title", - heading="home page heading", - introduction="home page introduction", - ) - - root.add_child(instance=home) - - self.assertEqual(HomePage.objects.count(), 1) - - Site.objects.all().delete() - - Site.objects.create( - hostname="localhost", - root_page=home, - is_default_site=True, - site_name="testingsitename", - ) - - blogindex = BlogIndexPage( - slug="blog", - title="blog index title", - ) - home.add_child(instance=blogindex) - - blog = BlogPage( - slug="blogpost", - title="blog title", - introduction="blog introduction", - body='[{"id": "1", "type": "rich_text", "value": "

blog body

"}]', - ) - blogindex.add_child(instance=blog) - - home.button_text = "blog button text" - home.button_link = blog - home.save() - - r = self.client.get(path=home.url) - self.assertEqual(r.status_code, 200) - self.assertIn(home.title.encode(), r.content) - self.assertIn(home.heading.encode(), r.content) - self.assertIn(home.introduction.encode(), r.content) - self.assertIn(home.button_text.encode(), r.content) - self.assertIn(('href="%s"' % blog.url).encode(), r.content) - - # other_page = BlogPage.objects.create( - # introduction = 'blog introduction', - # title='blog title', - # slug='blog-slug', - # ) - - # home = HomePage.objects.create( - # heading = 'homepage heading', - # introduction = 'homepage introduction', - # #main_image = TODO, - # button_text = 'homepage button text', - # button_link_id = other_page, - # ) - - # r = self.client.get(url=home.url_path) - # self.assertEqual(r.status_code, 200) + def test_homepage(self): + response = self.client.get(path=self.home.url) + assert response.status_code == 200 + html = response.content.decode() + + assert "IAB" in html + assert self.home.title in html + assert self.home.heading in html + + def test_button(self): + page: StandardPage = IABStandardPageFactory( + parent=self.home, + ) # type: ignore + self.home.button_text = "Homepage button text" + self.home.button_link = page + self.home.save(update_fields=["button_text", "button_link"]) + + response = self.client.get(path=self.home.url) + assert response.status_code == 200 + html = response.content.decode() + + assert self.home.button_text in html + assert f'href="{page.url}"' in html + + def test_blog_feed(self, iab_blog_feed): + iab_blog_feed.return_value.text = IAB_FEED_XML + response = self.client.get(path=self.home.url) + assert response.status_code == 200 + html = response.content.decode() + + assert "IAB Workshop on Barriers to Internet Access" in html + assert "http://www.ietf.org/blog/iab-bias-workshop/" in html + + def test_blog_feed_error_does_not_crash_homepage(self, iab_blog_feed): + iab_blog_feed.side_effect = RuntimeError + response = self.client.get(path=self.home.url) + assert response.status_code == 200 + html = response.content.decode() + + assert "IAB Workshop on Barriers to Internet Access" not in html + assert "http://www.ietf.org/blog/iab-bias-workshop/" not in html diff --git a/ietf/iesg_statement/factories.py b/ietf/iesg_statement/factories.py new file mode 100644 index 00000000..7e8a01a6 --- /dev/null +++ b/ietf/iesg_statement/factories.py @@ -0,0 +1,21 @@ +import factory +import wagtail_factories + +from ietf.utils.factories import StandardBlockFactory +from .models import IESGStatementIndexPage, IESGStatementPage + + +class IESGStatementPageFactory(wagtail_factories.PageFactory): + title = factory.Faker("name") + introduction = factory.Faker("paragraph") + body = wagtail_factories.StreamFieldFactory(StandardBlockFactory) + + class Meta: # type: ignore + model = IESGStatementPage + + +class IESGStatementIndexPageFactory(wagtail_factories.PageFactory): + title = factory.Faker("name") + + class Meta: # type: ignore + model = IESGStatementIndexPage diff --git a/ietf/iesg_statement/migrations/0008_alter_iesgstatementpage_body.py b/ietf/iesg_statement/migrations/0008_alter_iesgstatementpage_body.py new file mode 100644 index 00000000..c6190cee --- /dev/null +++ b/ietf/iesg_statement/migrations/0008_alter_iesgstatementpage_body.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.7 on 2024-04-03 13:40 + +from django.db import migrations +import wagtail.blocks +import wagtail.contrib.table_block.blocks +import wagtail.contrib.typed_table_block.blocks +import wagtail.embeds.blocks +import wagtail.fields +import wagtail.images.blocks +import wagtailmarkdown.blocks + + +class Migration(migrations.Migration): + + dependencies = [ + ('iesg_statement', '0007_alter_iesgstatementpage_body'), + ] + + operations = [ + migrations.AlterField( + model_name='iesgstatementpage', + name='body', + field=wagtail.fields.StreamField([('heading', wagtail.blocks.CharBlock(icon='title')), ('paragraph', wagtail.blocks.RichTextBlock(icon='pilcrow')), ('image', wagtail.images.blocks.ImageChooserBlock(icon='image', template='includes/imageblock.html')), ('markdown', wagtailmarkdown.blocks.MarkdownBlock(icon='code')), ('embed', wagtail.embeds.blocks.EmbedBlock(icon='code')), ('raw_html', wagtail.blocks.RawHTMLBlock(icon='placeholder')), ('table', wagtail.contrib.table_block.blocks.TableBlock(table_options={'renderer': 'html'}, template='includes/tableblock.html')), ('typed_table', wagtail.contrib.typed_table_block.blocks.TypedTableBlock([('text', wagtail.blocks.CharBlock(required=False)), ('numeric', wagtail.blocks.FloatBlock(required=False, template='blocks/float_block.html')), ('rich_text', wagtail.blocks.RichTextBlock(required=False)), ('image', wagtail.images.blocks.ImageChooserBlock(required=False))])), ('note_well', wagtail.blocks.StructBlock([], icon='placeholder', label='Note Well Text'))], use_json_field=True), + ), + ] diff --git a/ietf/iesg_statement/models.py b/ietf/iesg_statement/models.py index f205680b..831e3bc8 100644 --- a/ietf/iesg_statement/models.py +++ b/ietf/iesg_statement/models.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, time from functools import partial from django.core.exceptions import ObjectDoesNotExist @@ -7,6 +7,7 @@ from django.shortcuts import redirect from django.utils import functional from django.utils.safestring import mark_safe +from django.utils.timezone import make_aware from modelcluster.fields import ParentalKey from wagtail.admin.panels import FieldPanel, InlinePanel from wagtail.contrib.routable_page.models import RoutablePageMixin, route @@ -18,10 +19,13 @@ from ..snippets.models import Topic from ..utils.blocks import StandardBlock -# from ..utils.models import FeedSettings, PromoteMixin from ..utils.models import PromoteMixin +def make_date_aware(value): + return make_aware(datetime.combine(value, time())) + + def filter_pages_by_topic(pages, topic): return pages.filter(topics__topic=topic) @@ -31,11 +35,11 @@ def get_topic_by_id(id): def filter_pages_by_date_from(pages, date_from): - return pages.filter(d__gte=date_from) + return pages.filter(d__gte=make_date_aware(date_from)) def filter_pages_by_date_to(pages, date_to): - return pages.filter(d__lte=date_to) + return pages.filter(d__lte=make_date_aware(date_to)) def parse_date_search_input(date): diff --git a/ietf/iesg_statement/tests.py b/ietf/iesg_statement/tests.py index 3d50ae8a..9ca417db 100644 --- a/ietf/iesg_statement/tests.py +++ b/ietf/iesg_statement/tests.py @@ -1,52 +1,95 @@ -from django.test import TestCase -from wagtail.models import Page, Site +from datetime import timedelta -from ..home.models import HomePage +import factory +import pytest +from bs4 import BeautifulSoup +from django.test import Client +from django.utils import timezone + +from ietf.home.models import HomePage +from .factories import IESGStatementIndexPageFactory, IESGStatementPageFactory from .models import IESGStatementIndexPage, IESGStatementPage +pytestmark = pytest.mark.django_db -class IESGStatementPageTests(TestCase): - def test_iesg_statement_page(self): - root = Page.get_first_root_node() +def datefmt(value): + return value.strftime("%d/%m/%Y") - home = HomePage( - slug="homepageslug", - title="home page title", - heading="home page heading", - introduction="home page introduction", - ) - root.add_child(instance=home) +class TestIESGStatementPage: + @pytest.fixture(autouse=True) + def set_up(self, home: HomePage, client: Client): + self.home = home + self.client = client + self.now = timezone.now() - Site.objects.all().delete() + self.index: IESGStatementIndexPage = IESGStatementIndexPageFactory( + parent=self.home, + ) # type: ignore - Site.objects.create( - hostname="localhost", - root_page=home, - is_default_site=True, - site_name="testingsitename", - ) + self.statement: IESGStatementPage = IESGStatementPageFactory( + parent=self.index, + date_published=self.now, + body__0__heading="Heading in body Streamfield", + ) # type: ignore - iesg_statement_index = IESGStatementIndexPage( - slug="iesg_statement_index", - title="iesg statement index page title", - ) - home.add_child(instance=iesg_statement_index) + def test_index_page(self): + response = self.client.get(path=self.index.url) + assert response.status_code == 200 + html = response.content.decode() + + assert self.statement.title in html + assert f'href="{self.statement.url}"' in html + + def test_statement_page(self): + response = self.client.get(path=self.statement.url) + assert response.status_code == 200 + html = response.content.decode() + + assert self.statement.title in html + assert self.statement.introduction in html + assert self.statement.body[0].value in html + assert f'href="{self.index.url}"' in html - iesg_statement_page = IESGStatementPage( - slug="iesgstatement", - title="iesg statement title", - introduction="iesg statement introduction", + def test_filtering(self): + """ + Test the filtering on the individual IESG statement page. + + The page shows the current statement, and a list of other recent + statements, that fall within the filtered date interval. + """ + + old1 = IESGStatementPageFactory( + parent=self.index, date_published=self.now - timedelta(days=10) + ) + old2 = IESGStatementPageFactory( + parent=self.index, date_published=self.now - timedelta(days=5) ) - iesg_statement_index.add_child(instance=iesg_statement_page) + new1 = IESGStatementPageFactory( + parent=self.index, date_published=self.now + timedelta(days=5) + ) + + def get_filtered(days_before=0, days_after=0): + date_from = self.now + timedelta(days=days_before) + date_to = self.now + timedelta(days=days_after) + params = f"date_from={datefmt(date_from)}&date_to={datefmt(date_to)}" + response = self.client.get(f"{self.index.url}?{params}", follow=True) + assert response.status_code == 200 + html = response.content.decode() + soup = BeautifulSoup(html, "html.parser") + featured = soup.select("h1")[0].get_text().strip() + others = [ + a.get_text().strip() + for a in soup.select('aside[aria-label="Statement listing"] h2 a') + ] + return (featured, others) - rindex = self.client.get(path=iesg_statement_index.url) - self.assertEqual(rindex.status_code, 200) + assert get_filtered(-10, 10) == ( + new1.title, + [self.statement.title, old2.title, old1.title], + ) - # r = self.client.get(path=iesg_statement_page.url) - # self.assertEqual(r.status_code, 200) + assert get_filtered(0, 10) == (new1.title, [self.statement.title]) - # self.assertIn(iesg_statement_page.title.encode(), r.content) - # self.assertIn(iesg_statement_page.introduction.encode(), r.content) - # self.assertIn(('href="%s"' % iesg_statement_index.url).encode(), r.content) + assert get_filtered(-10, 0) == (old2.title, [old1.title]) diff --git a/ietf/search/tests.py b/ietf/search/tests.py index f5cdd2b7..592ef6d2 100644 --- a/ietf/search/tests.py +++ b/ietf/search/tests.py @@ -1,56 +1,49 @@ -from django.test import TestCase +import pytest +from django.test import Client from django.urls import reverse -from wagtail.models import Page, Site +from wagtail.models import Page -from ..blog.models import BlogIndexPage, BlogPage -from ..home.models import HomePage +from ietf.home.models import HomePage +from ietf.standard.factories import StandardPageFactory +from ietf.standard.models import StandardPage +pytestmark = pytest.mark.django_db -class SearchTests(TestCase): - def test_search(self): - root = Page.get_first_root_node() - - home = HomePage( - slug="homepageslug", - title="home page title", - heading="home page heading", - introduction="home page introduction", - ) - - root.add_child(instance=home) - - Site.objects.all().delete() - - Site.objects.create( - hostname="localhost", - root_page=home, - is_default_site=True, - site_name="testingsitename", - ) - - blogindex = BlogIndexPage( - slug="blog", - title="blog index title", - ) - home.add_child(instance=blogindex) - - blog = BlogPage( - slug="blogpost", - title="blog title", - introduction="blog introduction", - body='[{"id": "1", "type": "rich_text", "value": "<p>blog body</p>"}]', - ) - blogindex.add_child(instance=blog) - - home.button_text = "blog button text" - home.button_link = blog - home.save() - - resp = self.client.get(f"{reverse('search')}?query=introduction") - - self.assertEqual(resp.context["search_query"], "introduction") - self.assertEqual( - list(resp.context["search_results"]), - [Page.objects.get(pk=blog.pk)], - ) +class TestSearch: + @pytest.fixture(autouse=True) + def set_up(self, home: HomePage, client: Client): + self.home = home + self.client = client + + self.standard_page: StandardPage = StandardPageFactory( + parent=self.home, + introduction="Some random introduction text", + ) # type: ignore + + def test_search(self): + query = "random" + resp = self.client.get(f"{reverse('search')}?query={query}") + assert resp.status_code == 200 + + assert resp.context["search_query"] == query + assert list(resp.context["search_results"]) == \ + [Page.objects.get(pk=self.standard_page.pk)] + + def test_empty_query(self): + resp = self.client.get(f"{reverse('search')}?query=") + assert resp.status_code == 200 + + def test_empty_page(self): + query = "random" + resp = self.client.get(f"{reverse('search')}?query={query}&page=100") + assert resp.status_code == 200 + assert list(resp.context["search_results"]) == \ + [Page.objects.get(pk=self.standard_page.pk)] + + def test_non_integer_page(self): + query = "random" + resp = self.client.get(f"{reverse('search')}?query={query}&page=foo") + assert resp.status_code == 200 + assert list(resp.context["search_results"]) == \ + [Page.objects.get(pk=self.standard_page.pk)] diff --git a/ietf/settings/base.py b/ietf/settings/base.py index 94f77b3a..9115f39a 100644 --- a/ietf/settings/base.py +++ b/ietf/settings/base.py @@ -245,7 +245,7 @@ _cf_purge_bearer_token = os.environ.get("CLOUDFLARE_CACHE_PURGE_BEARER_TOKEN") _cf_purge_zone_id = os.environ.get("CLOUDFLARE_CACHE_PURGE_ZONE_ID") -if _cf_purge_bearer_token and _cf_purge_zone_id: +if _cf_purge_bearer_token and _cf_purge_zone_id: # pragma: no cover INSTALLED_APPS += ( "wagtail.contrib.frontend_cache", ) WAGTAILFRONTENDCACHE = { "cloudflare": { diff --git a/ietf/settings/dev.py b/ietf/settings/dev.py index cf98a09d..848a5481 100644 --- a/ietf/settings/dev.py +++ b/ietf/settings/dev.py @@ -10,7 +10,13 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' -try: + +# Process all tasks synchronously. +# Helpful for local development and running tests +CELERY_EAGER_PROPAGATES_EXCEPTIONS = True +CELERY_ALWAYS_EAGER = True + +try: # pragma: no cover from .local import * -except ImportError: +except ImportError: # pragma: no cover pass diff --git a/ietf/snippets/factories.py b/ietf/snippets/factories.py new file mode 100644 index 00000000..045bb39d --- /dev/null +++ b/ietf/snippets/factories.py @@ -0,0 +1,47 @@ +import factory +from django.utils.text import slugify +from factory.django import DjangoModelFactory + +from .models import Charter, MailingListSignup, Person, Topic, WorkingGroup + + +class PersonFactory(DjangoModelFactory): + name = factory.Faker("name") + slug = factory.LazyAttribute(lambda obj: slugify(obj.name)) + link = factory.Faker("url") + + class Meta: # type: ignore + model = Person + + +class TopicFactory(DjangoModelFactory): + title = factory.Faker("name") + slug = factory.LazyAttribute(lambda obj: slugify(obj.title)) + + class Meta: # type: ignore + model = Topic + + +class CharterFactory(DjangoModelFactory): + name = factory.Faker("name") + + class Meta: # type: ignore + model = Charter + + +class WorkingGroupFactory(DjangoModelFactory): + name = factory.Faker("name") + list_subscribe = factory.Faker("url") + + class Meta: # type: ignore + model = WorkingGroup + + +class MailingListSignupFactory(DjangoModelFactory): + title = factory.Faker("name") + blurb = factory.Faker("paragraph") + button_text = factory.Faker("name") + sign_up = factory.Faker("url") + + class Meta: # type: ignore + model = MailingListSignup diff --git a/ietf/snippets/models.py b/ietf/snippets/models.py index 789057ba..8114bfe8 100644 --- a/ietf/snippets/models.py +++ b/ietf/snippets/models.py @@ -38,7 +38,7 @@ class Charter(models.Model, index.Indexed): index.AutocompleteField("abstract"), ] - def __str__(self): + def __str__(self): # pragma: no cover return self.title @property @@ -80,7 +80,7 @@ def url(self): def charter_url(self): return self.url + "/charter/" - def __str__(self): + def __str__(self): # pragma: no cover return self.name class Meta: @@ -118,7 +118,7 @@ class RFC(models.Model, index.Indexed): index.AutocompleteField("abstract"), ] - def __str__(self): + def __str__(self): # pragma: no cover return "RFC {}".format(self.rfc) @property @@ -149,7 +149,7 @@ class Person(models.Model, Indexed): FieldPanel("slug", widget=SlugInput), ] - def __str__(self): + def __str__(self): # pragma: no cover return self.name class Meta: @@ -167,7 +167,7 @@ class Role(models.Model, Indexed): panels = [FieldPanel("name")] - def __str__(self): + def __str__(self): # pragma: no cover return self.name class Meta: @@ -221,7 +221,7 @@ class Group(models.Model, Indexed, RenderableSnippetMixin): FieldPanel("image"), ] - def __str__(self): + def __str__(self): # pragma: no cover return self.name TEMPLATE_NAME = "snippets/group.html" @@ -258,7 +258,7 @@ class CallToAction(Indexed, RelatedLink, RenderableSnippetMixin): FieldPanel("button_text"), ] - def __str__(self): + def __str__(self): # pragma: no cover return self.title TEMPLATE_NAME = "snippets/call_to_action.html" @@ -335,7 +335,7 @@ def link(self): TEMPLATE_NAME = "snippets/mailing_list_signup.html" - def __str__(self): + def __str__(self): # pragma: no cover return self.title class Meta: @@ -363,7 +363,7 @@ class Topic(models.Model, Indexed): FieldPanel("slug", widget=SlugInput), ] - def __str__(self): + def __str__(self): # pragma: no cover return self.title class Meta: @@ -392,7 +392,7 @@ class Sponsor(models.Model, Indexed): panels = [FieldPanel("title"), FieldPanel("logo"), FieldPanel("link")] - def __str__(self): + def __str__(self): # pragma: no cover return self.title class Meta: @@ -423,7 +423,7 @@ class GlossaryItem(models.Model, Indexed): FieldPanel("link"), ] - def __str__(self): + def __str__(self): # pragma: no cover return self.title @property diff --git a/ietf/snippets/tests/test_charter.py b/ietf/snippets/tests/test_charter.py new file mode 100644 index 00000000..1a73e277 --- /dev/null +++ b/ietf/snippets/tests/test_charter.py @@ -0,0 +1,10 @@ +import pytest +from ietf.snippets.factories import CharterFactory, WorkingGroupFactory + +pytestmark = pytest.mark.django_db + + +def test_link_working_group(): + working_group = WorkingGroupFactory() + snippet = CharterFactory(working_group=working_group) + assert snippet.url == working_group.charter_url diff --git a/ietf/snippets/tests/test_mailing_list_signup.py b/ietf/snippets/tests/test_mailing_list_signup.py new file mode 100644 index 00000000..27cf1f56 --- /dev/null +++ b/ietf/snippets/tests/test_mailing_list_signup.py @@ -0,0 +1,49 @@ +from bs4 import BeautifulSoup +from django.urls import reverse +import pytest +from django.test import Client + +from ietf.home.models import HomePage +from ietf.standard.factories import StandardPageFactory +from ietf.snippets.factories import MailingListSignupFactory, WorkingGroupFactory + +pytestmark = pytest.mark.django_db + + +def test_disclaimer(client: Client, home: HomePage): + """ + The "note well" disclaimer is a page that is shown when a user clicks on a + mailing list link. It displays an informative text, and the "next" button + is a link to the actual mailing list. + """ + snippet = MailingListSignupFactory() + page = StandardPageFactory(parent=home, mailing_list_signup=snippet) + + page_response = client.get(page.url) + assert page_response.status_code == 200 + page_html = page_response.content.decode() + page_soup = BeautifulSoup(page_html, "html.parser") + [link] = page_soup.select(".mailing_list_signup__container a") + disclaimer_url = reverse("disclaimer", args=[snippet.pk]) + assert link.attrs["href"] == disclaimer_url + + disclaimer_response = client.get(disclaimer_url) + assert disclaimer_response.status_code == 200 + disclaimer_html = disclaimer_response.content.decode() + disclaimer_soup = BeautifulSoup(disclaimer_html, "html.parser") + + assert 'See <a href="https://github.com/ietf/note-well/">' in disclaimer_html + link = disclaimer_soup.select(".body .container a")[-1] + assert "I understand" in link.get_text() + assert link.attrs["href"] == snippet.sign_up + + +def test_link_mailto(): + snippet = MailingListSignupFactory(sign_up="foo@example.com") + assert snippet.link == "mailto:foo@example.com" + + +def test_link_working_group(): + working_group = WorkingGroupFactory() + snippet = MailingListSignupFactory(sign_up="", working_group=working_group) + assert snippet.link == working_group.list_subscribe diff --git a/ietf/standard/factories.py b/ietf/standard/factories.py new file mode 100644 index 00000000..204d44f8 --- /dev/null +++ b/ietf/standard/factories.py @@ -0,0 +1,27 @@ +import factory +import wagtail_factories + +from .models import IABStandardPage, StandardIndexPage, StandardPage + + +class StandardPageFactory(wagtail_factories.PageFactory): + title = factory.Faker("name") + introduction = factory.Faker("paragraph") + + class Meta: # type: ignore + model = StandardPage + + +class StandardIndexPageFactory(wagtail_factories.PageFactory): + title = factory.Faker("name") + + class Meta: # type: ignore + model = StandardIndexPage + + +class IABStandardPageFactory(wagtail_factories.PageFactory): + title = factory.Faker("name") + introduction = factory.Faker("paragraph") + + class Meta: # type: ignore + model = IABStandardPage diff --git a/ietf/standard/migrations/0009_alter_iabstandardpage_in_depth_and_more.py b/ietf/standard/migrations/0009_alter_iabstandardpage_in_depth_and_more.py new file mode 100644 index 00000000..d1f32eac --- /dev/null +++ b/ietf/standard/migrations/0009_alter_iabstandardpage_in_depth_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.7 on 2024-04-03 13:40 + +from django.db import migrations +import wagtail.blocks +import wagtail.contrib.table_block.blocks +import wagtail.contrib.typed_table_block.blocks +import wagtail.embeds.blocks +import wagtail.fields +import wagtail.images.blocks +import wagtailmarkdown.blocks + + +class Migration(migrations.Migration): + + dependencies = [ + ('standard', '0008_auto_20230414_0340'), + ] + + operations = [ + migrations.AlterField( + model_name='iabstandardpage', + name='in_depth', + field=wagtail.fields.StreamField([('heading', wagtail.blocks.CharBlock(icon='title')), ('paragraph', wagtail.blocks.RichTextBlock(icon='pilcrow')), ('image', wagtail.images.blocks.ImageChooserBlock(icon='image', template='includes/imageblock.html')), ('markdown', wagtailmarkdown.blocks.MarkdownBlock(icon='code')), ('embed', wagtail.embeds.blocks.EmbedBlock(icon='code')), ('raw_html', wagtail.blocks.RawHTMLBlock(icon='placeholder')), ('table', wagtail.contrib.table_block.blocks.TableBlock(table_options={'renderer': 'html'}, template='includes/tableblock.html')), ('typed_table', wagtail.contrib.typed_table_block.blocks.TypedTableBlock([('text', wagtail.blocks.CharBlock(required=False)), ('numeric', wagtail.blocks.FloatBlock(required=False, template='blocks/float_block.html')), ('rich_text', wagtail.blocks.RichTextBlock(required=False)), ('image', wagtail.images.blocks.ImageChooserBlock(required=False))])), ('note_well', wagtail.blocks.StructBlock([], icon='placeholder', label='Note Well Text'))], blank=True, use_json_field=True), + ), + migrations.AlterField( + model_name='iabstandardpage', + name='key_info', + field=wagtail.fields.StreamField([('heading', wagtail.blocks.CharBlock(icon='title')), ('paragraph', wagtail.blocks.RichTextBlock(icon='pilcrow')), ('image', wagtail.images.blocks.ImageChooserBlock(icon='image', template='includes/imageblock.html')), ('markdown', wagtailmarkdown.blocks.MarkdownBlock(icon='code')), ('embed', wagtail.embeds.blocks.EmbedBlock(icon='code')), ('raw_html', wagtail.blocks.RawHTMLBlock(icon='placeholder')), ('table', wagtail.contrib.table_block.blocks.TableBlock(table_options={'renderer': 'html'}, template='includes/tableblock.html')), ('typed_table', wagtail.contrib.typed_table_block.blocks.TypedTableBlock([('text', wagtail.blocks.CharBlock(required=False)), ('numeric', wagtail.blocks.FloatBlock(required=False, template='blocks/float_block.html')), ('rich_text', wagtail.blocks.RichTextBlock(required=False)), ('image', wagtail.images.blocks.ImageChooserBlock(required=False))])), ('note_well', wagtail.blocks.StructBlock([], icon='placeholder', label='Note Well Text'))], blank=True, use_json_field=True), + ), + migrations.AlterField( + model_name='standardindexpage', + name='in_depth', + field=wagtail.fields.StreamField([('heading', wagtail.blocks.CharBlock(icon='title')), ('paragraph', wagtail.blocks.RichTextBlock(icon='pilcrow')), ('image', wagtail.images.blocks.ImageChooserBlock(icon='image', template='includes/imageblock.html')), ('markdown', wagtailmarkdown.blocks.MarkdownBlock(icon='code')), ('embed', wagtail.embeds.blocks.EmbedBlock(icon='code')), ('raw_html', wagtail.blocks.RawHTMLBlock(icon='placeholder')), ('table', wagtail.contrib.table_block.blocks.TableBlock(table_options={'renderer': 'html'}, template='includes/tableblock.html')), ('typed_table', wagtail.contrib.typed_table_block.blocks.TypedTableBlock([('text', wagtail.blocks.CharBlock(required=False)), ('numeric', wagtail.blocks.FloatBlock(required=False, template='blocks/float_block.html')), ('rich_text', wagtail.blocks.RichTextBlock(required=False)), ('image', wagtail.images.blocks.ImageChooserBlock(required=False))])), ('note_well', wagtail.blocks.StructBlock([], icon='placeholder', label='Note Well Text'))], blank=True, use_json_field=True), + ), + migrations.AlterField( + model_name='standardindexpage', + name='key_info', + field=wagtail.fields.StreamField([('heading', wagtail.blocks.CharBlock(icon='title')), ('paragraph', wagtail.blocks.RichTextBlock(icon='pilcrow')), ('image', wagtail.images.blocks.ImageChooserBlock(icon='image', template='includes/imageblock.html')), ('markdown', wagtailmarkdown.blocks.MarkdownBlock(icon='code')), ('embed', wagtail.embeds.blocks.EmbedBlock(icon='code')), ('raw_html', wagtail.blocks.RawHTMLBlock(icon='placeholder')), ('table', wagtail.contrib.table_block.blocks.TableBlock(table_options={'renderer': 'html'}, template='includes/tableblock.html')), ('typed_table', wagtail.contrib.typed_table_block.blocks.TypedTableBlock([('text', wagtail.blocks.CharBlock(required=False)), ('numeric', wagtail.blocks.FloatBlock(required=False, template='blocks/float_block.html')), ('rich_text', wagtail.blocks.RichTextBlock(required=False)), ('image', wagtail.images.blocks.ImageChooserBlock(required=False))])), ('note_well', wagtail.blocks.StructBlock([], icon='placeholder', label='Note Well Text'))], blank=True, use_json_field=True), + ), + migrations.AlterField( + model_name='standardpage', + name='in_depth', + field=wagtail.fields.StreamField([('heading', wagtail.blocks.CharBlock(icon='title')), ('paragraph', wagtail.blocks.RichTextBlock(icon='pilcrow')), ('image', wagtail.images.blocks.ImageChooserBlock(icon='image', template='includes/imageblock.html')), ('markdown', wagtailmarkdown.blocks.MarkdownBlock(icon='code')), ('embed', wagtail.embeds.blocks.EmbedBlock(icon='code')), ('raw_html', wagtail.blocks.RawHTMLBlock(icon='placeholder')), ('table', wagtail.contrib.table_block.blocks.TableBlock(table_options={'renderer': 'html'}, template='includes/tableblock.html')), ('typed_table', wagtail.contrib.typed_table_block.blocks.TypedTableBlock([('text', wagtail.blocks.CharBlock(required=False)), ('numeric', wagtail.blocks.FloatBlock(required=False, template='blocks/float_block.html')), ('rich_text', wagtail.blocks.RichTextBlock(required=False)), ('image', wagtail.images.blocks.ImageChooserBlock(required=False))])), ('note_well', wagtail.blocks.StructBlock([], icon='placeholder', label='Note Well Text'))], blank=True, use_json_field=True), + ), + migrations.AlterField( + model_name='standardpage', + name='key_info', + field=wagtail.fields.StreamField([('heading', wagtail.blocks.CharBlock(icon='title')), ('paragraph', wagtail.blocks.RichTextBlock(icon='pilcrow')), ('image', wagtail.images.blocks.ImageChooserBlock(icon='image', template='includes/imageblock.html')), ('markdown', wagtailmarkdown.blocks.MarkdownBlock(icon='code')), ('embed', wagtail.embeds.blocks.EmbedBlock(icon='code')), ('raw_html', wagtail.blocks.RawHTMLBlock(icon='placeholder')), ('table', wagtail.contrib.table_block.blocks.TableBlock(table_options={'renderer': 'html'}, template='includes/tableblock.html')), ('typed_table', wagtail.contrib.typed_table_block.blocks.TypedTableBlock([('text', wagtail.blocks.CharBlock(required=False)), ('numeric', wagtail.blocks.FloatBlock(required=False, template='blocks/float_block.html')), ('rich_text', wagtail.blocks.RichTextBlock(required=False)), ('image', wagtail.images.blocks.ImageChooserBlock(required=False))])), ('note_well', wagtail.blocks.StructBlock([], icon='placeholder', label='Note Well Text'))], blank=True, use_json_field=True), + ), + ] diff --git a/ietf/standard/tests.py b/ietf/standard/tests.py index ded2f588..8152fe2b 100644 --- a/ietf/standard/tests.py +++ b/ietf/standard/tests.py @@ -1,53 +1,60 @@ -from django.test import TestCase -from wagtail.models import Page, Site +from django.test import Client +import pytest -from ..home.models import HomePage -from .models import StandardIndexPage, StandardPage +from ietf.home.models import HomePage, IABHomePage +from .factories import IABStandardPageFactory, StandardIndexPageFactory, StandardPageFactory +from .models import IABStandardPage, StandardIndexPage, StandardPage +pytestmark = pytest.mark.django_db + + +class TestStandardPage: + @pytest.fixture(autouse=True) + def set_up(self, home: HomePage, client: Client): + self.home = home + self.client = client + + self.standard_index: StandardIndexPage = StandardIndexPageFactory( + parent=self.home, + ) # type: ignore + + self.standard_page: StandardPage = StandardPageFactory( + parent=self.standard_index, + ) # type: ignore + + def test_index_page(self): + response = self.client.get(path=self.standard_index.url) + assert response.status_code == 200 + html = response.content.decode() + + assert self.standard_page.title in html + assert f'href="{self.standard_page.url}"' in html + + def test_standard_page(self): + response = self.client.get(path=self.standard_page.url) + assert response.status_code == 200 + html = response.content.decode() + + assert self.standard_page.title in html + assert self.standard_page.introduction in html + assert f'href="{self.standard_index.url}"' in html + + +class TestIABStandardPage: + @pytest.fixture(autouse=True) + def set_up(self, iab_home: IABHomePage, client: Client): + self.home = iab_home + self.client = client + + self.standard_page: IABStandardPage = IABStandardPageFactory( + parent=self.home, + ) # type: ignore -class StandardPageTests(TestCase): def test_standard_page(self): + response = self.client.get(path=self.standard_page.url) + assert response.status_code == 200 + html = response.content.decode() - root = Page.get_first_root_node() - - home = HomePage( - slug="homepageslug", - title="home page title", - heading="home page heading", - introduction="home page introduction", - ) - - root.add_child(instance=home) - - Site.objects.all().delete() - - Site.objects.create( - hostname="localhost", - root_page=home, - is_default_site=True, - site_name="testingsitename", - ) - - standardindex = StandardIndexPage( - slug="standardindex", - title="standard index page title", - introduction="standard index page introduction", - ) - home.add_child(instance=standardindex) - - standardpage = StandardPage( - slug="standard", - title="standard title", - introduction="standard introduction", - ) - standardindex.add_child(instance=standardpage) - - rindex = self.client.get(path=standardindex.url) - self.assertEqual(rindex.status_code, 200) - - r = self.client.get(path=standardpage.url) - self.assertEqual(r.status_code, 200) - - self.assertIn(standardpage.title.encode(), r.content) - self.assertIn(standardpage.introduction.encode(), r.content) - self.assertIn(('href="%s"' % standardindex.url).encode(), r.content) + assert self.standard_page.title in html + assert self.standard_page.introduction in html + assert f'href="{self.home.url}"' in html diff --git a/ietf/static_src/css/custom-spacers.scss b/ietf/static_src/css/bs-configure.scss similarity index 85% rename from ietf/static_src/css/custom-spacers.scss rename to ietf/static_src/css/bs-configure.scss index 068f3d5a..6de4a683 100644 --- a/ietf/static_src/css/custom-spacers.scss +++ b/ietf/static_src/css/bs-configure.scss @@ -11,3 +11,5 @@ $custom-spacers: ( ); $spacers: map-merge($spacers, $custom-spacers); + +$enable-negative-margins: true; diff --git a/ietf/static_src/css/main.scss b/ietf/static_src/css/main.scss index d4700877..ba9ac636 100644 --- a/ietf/static_src/css/main.scss +++ b/ietf/static_src/css/main.scss @@ -2,7 +2,7 @@ @import 'bootstrap/scss/functions'; @import 'bootstrap/scss/variables'; -@import './custom-spacers.scss'; +@import './bs-configure.scss'; @import '@ietf-tools/common-bootstrap-theme/scss/ietf-theme.scss'; @import 'bootstrap/scss/bootstrap'; @@ -15,6 +15,7 @@ @import './images'; // Styles for images that can't be managed with bootstrap classes alone @import './utilities'; // Utility classes that don't exist in Bootstrap yet @import './streamfield'; // Styles for streamfield blocks +@import './typography'; // Styles for text @import './datepicker'; // Styles for jquery-ui datepicker @import './no-js'; // Styles for when javascript is disabled @import './focus'; diff --git a/ietf/static_src/css/typography.scss b/ietf/static_src/css/typography.scss new file mode 100644 index 00000000..cc4c5f7e --- /dev/null +++ b/ietf/static_src/css/typography.scss @@ -0,0 +1,10 @@ +blockquote { + color: #666666; + border-left: 5px solid #bebebe; + padding-left: 1rem; + font-weight: 500; + + p:last-child { + padding-bottom: 0; + } +} diff --git a/ietf/static_src/css/utilities.scss b/ietf/static_src/css/utilities.scss index f10be72e..70e5dbfd 100644 --- a/ietf/static_src/css/utilities.scss +++ b/ietf/static_src/css/utilities.scss @@ -21,3 +21,9 @@ .fw-semibold { font-weight: 600 !important; } + +.u-border-lg-bottom-0 { + @include media-breakpoint-up(lg) { + border-bottom: 0 !important; + } +} diff --git a/ietf/templates/blocks/float_block.html b/ietf/templates/blocks/float_block.html new file mode 100644 index 00000000..5bdd8821 --- /dev/null +++ b/ietf/templates/blocks/float_block.html @@ -0,0 +1 @@ +{% if value is not None %}{{ value }}{% endif %} diff --git a/ietf/templates/includes/footer.html b/ietf/templates/includes/footer.html index afd08de3..3d9eb096 100644 --- a/ietf/templates/includes/footer.html +++ b/ietf/templates/includes/footer.html @@ -1,9 +1,64 @@ <footer class="bg-dark text-light py-1"> - <div class="container"> - <ul class="nav row"> - {% for item in settings.utils.FooterLinks.footer_link_items.all %} - <li class="nav-item col-6 col-lg-auto"><a class="nav-link text-light" href="{{ item.link }}">{{ item.title }}</a></li> + <div class="container my-5"> + <div class="row"> + {% for column in FOOTER %} + <section class="col-lg"> + <div class="border-bottom u-border-lg-bottom-0 border-light border-opacity-50"> + <h4 class="my-0 py-4 fs-6" role="button" aria-expanded="false"> + {{ column.title }} + <i class="bi bi-chevron-down"></i> + </h4> + <ul class="list-unstyled opacity-75 d-grid gap-2"> + {% for link in column.links %} + {% if link.value.url and link.value.text %} + <li class="nav-item"> + <a + href="{{ link.value.url }}" + class="link-underline-opacity-0 link-light fw-semibold lh-base" + > + {{ link.value.text }} + </a> + </li> + {% endif %} + {% endfor %} + </ul> + </div> + </section> {% endfor %} - </ul> + </div> + </div> + <div class="container my-5"> + <div class="d-lg-flex justify-content-between align-items-start lh-1"> + <div class="d-flex fs-4 my-5 my-lg-0 ms-n2 my-5 me-3"> + {% for item in SOCIAL_MENU %} + <a class="d-block text-light px-2" href="{{ item.url }}" rel="me"> + <i class="bi bi-{{ item.icon }}"></i> + </a> + {% endfor %} + </div> + <ul class=" + row gx-0 column-gap-5 row-gap-3 justify-content-lg-end + my-5 my-lg-0 + nav opacity-75 + "> + {% for item in settings.utils.FooterLinks.footer_link_items.all %} + <li class="nav-item col-auto py-0"> + <a href="{{ item.link }}" class="nav-link text-light fs-10 p-0"> + {{ item.title }} + </a> + </li> + {% endfor %} + </ul> + </div> </div> </footer> + +<script> +[... document.querySelectorAll("footer section")].forEach((section) => { + const heading = section.querySelector("h4"); + heading.addEventListener("click", () => { + const expanded = section.classList.toggle("expanded"); + heading.setAttribute("aria-expanded", expanded); + }); +}); +</script> diff --git a/ietf/templates/includes/header.html b/ietf/templates/includes/header.html index 6fedf4f4..552cff8f 100644 --- a/ietf/templates/includes/header.html +++ b/ietf/templates/includes/header.html @@ -103,25 +103,6 @@ </a> </li> </ul> - <div class="d-flex justify-content-center ms-lg-auto me-lg-4"> - {% for item in SOCIAL_MENU %} - <a class="d-block text-dark px-2" href="{{ item.url }}" rel="me"> - <i class="bi bi-{{ item.icon }}"></i> - </a> - {% endfor %} - </div> - <div class="d-lg-none d-grid gap-2 mt-2"> - <a - href="/endowment/donate-ietf-endowment/" - class="btn btn-primary" - >Donate</a> - </div> - </div> - <div class="bg-primary rounded nav-item"> - <a - href="/endowment/donate-ietf-endowment/" - class="px-3 py-3 d-none d-lg-block text-white" - >Donate</a> </div> </div> </nav> diff --git a/ietf/templates/includes/megamenu.html b/ietf/templates/includes/megamenu.html index e4203207..2a952df3 100644 --- a/ietf/templates/includes/megamenu.html +++ b/ietf/templates/includes/megamenu.html @@ -68,7 +68,7 @@ <h6 class="mt-3 mb-1 pb-1 border-bottom">{{ section.title }}</h6> {% for link in section.links %} <li> <a class="dropdown-item" href="{{ link.url }}"> - {{ link.title }} + {{ link.text }} </a> </li> {% endfor %} diff --git a/ietf/templates/includes/styles/footer.scss b/ietf/templates/includes/styles/footer.scss new file mode 100644 index 00000000..d60874d4 --- /dev/null +++ b/ietf/templates/includes/styles/footer.scss @@ -0,0 +1,30 @@ +footer section { + h4[role=button] { + @include media-breakpoint-up(lg) { + cursor: text; + } + } + + h4 .bi-chevron-down { + display: block; + float: right; + @include media-breakpoint-up(lg) { + display: none; + } + } + + &.expanded h4 .bi-chevron-down { + transform: rotate(180deg); + } + + ul { + display: none; + @include media-breakpoint-up(lg) { + display: block; + } + } + + &.expanded ul { + display: block; + } +} diff --git a/ietf/templates/includes/styles/index.scss b/ietf/templates/includes/styles/index.scss index 90c7e145..65f0fed5 100644 --- a/ietf/templates/includes/styles/index.scss +++ b/ietf/templates/includes/styles/index.scss @@ -1,4 +1,5 @@ @import 'header.scss'; +@import 'footer.scss'; .block-paragraph { .h2, h2 { diff --git a/ietf/topics/factories.py b/ietf/topics/factories.py new file mode 100644 index 00000000..bf9ffded --- /dev/null +++ b/ietf/topics/factories.py @@ -0,0 +1,20 @@ +import factory +import wagtail_factories + +from .models import TopicIndexPage, PrimaryTopicPage + + +class PrimaryTopicPageFactory(wagtail_factories.PageFactory): + title = factory.Faker("name") + introduction = factory.Faker("paragraph") + + class Meta: # type: ignore + model = PrimaryTopicPage + + +class TopicIndexPageFactory(wagtail_factories.PageFactory): + title = factory.Faker("name") + introduction = factory.Faker("paragraph") + + class Meta: # type: ignore + model = TopicIndexPage diff --git a/ietf/topics/migrations/0008_alter_primarytopicpage_in_depth_and_more.py b/ietf/topics/migrations/0008_alter_primarytopicpage_in_depth_and_more.py new file mode 100644 index 00000000..a9c724e8 --- /dev/null +++ b/ietf/topics/migrations/0008_alter_primarytopicpage_in_depth_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.7 on 2024-04-03 13:40 + +from django.db import migrations +import wagtail.blocks +import wagtail.contrib.table_block.blocks +import wagtail.contrib.typed_table_block.blocks +import wagtail.embeds.blocks +import wagtail.fields +import wagtail.images.blocks +import wagtailmarkdown.blocks + + +class Migration(migrations.Migration): + + dependencies = [ + ('topics', '0007_auto_20230414_0340'), + ] + + operations = [ + migrations.AlterField( + model_name='primarytopicpage', + name='in_depth', + field=wagtail.fields.StreamField([('heading', wagtail.blocks.CharBlock(icon='title')), ('paragraph', wagtail.blocks.RichTextBlock(icon='pilcrow')), ('image', wagtail.images.blocks.ImageChooserBlock(icon='image', template='includes/imageblock.html')), ('markdown', wagtailmarkdown.blocks.MarkdownBlock(icon='code')), ('embed', wagtail.embeds.blocks.EmbedBlock(icon='code')), ('raw_html', wagtail.blocks.RawHTMLBlock(icon='placeholder')), ('table', wagtail.contrib.table_block.blocks.TableBlock(table_options={'renderer': 'html'}, template='includes/tableblock.html')), ('typed_table', wagtail.contrib.typed_table_block.blocks.TypedTableBlock([('text', wagtail.blocks.CharBlock(required=False)), ('numeric', wagtail.blocks.FloatBlock(required=False, template='blocks/float_block.html')), ('rich_text', wagtail.blocks.RichTextBlock(required=False)), ('image', wagtail.images.blocks.ImageChooserBlock(required=False))])), ('note_well', wagtail.blocks.StructBlock([], icon='placeholder', label='Note Well Text'))], blank=True, use_json_field=True), + ), + migrations.AlterField( + model_name='primarytopicpage', + name='key_info', + field=wagtail.fields.StreamField([('heading', wagtail.blocks.CharBlock(icon='title')), ('paragraph', wagtail.blocks.RichTextBlock(icon='pilcrow')), ('image', wagtail.images.blocks.ImageChooserBlock(icon='image', template='includes/imageblock.html')), ('markdown', wagtailmarkdown.blocks.MarkdownBlock(icon='code')), ('embed', wagtail.embeds.blocks.EmbedBlock(icon='code')), ('raw_html', wagtail.blocks.RawHTMLBlock(icon='placeholder')), ('table', wagtail.contrib.table_block.blocks.TableBlock(table_options={'renderer': 'html'}, template='includes/tableblock.html')), ('typed_table', wagtail.contrib.typed_table_block.blocks.TypedTableBlock([('text', wagtail.blocks.CharBlock(required=False)), ('numeric', wagtail.blocks.FloatBlock(required=False, template='blocks/float_block.html')), ('rich_text', wagtail.blocks.RichTextBlock(required=False)), ('image', wagtail.images.blocks.ImageChooserBlock(required=False))])), ('note_well', wagtail.blocks.StructBlock([], icon='placeholder', label='Note Well Text'))], blank=True, use_json_field=True), + ), + ] diff --git a/ietf/topics/test.py b/ietf/topics/test.py index bedcee8c..d8ccea12 100644 --- a/ietf/topics/test.py +++ b/ietf/topics/test.py @@ -1,53 +1,40 @@ -from django.test import TestCase -from wagtail.models import Page, Site +import pytest +from django.test import Client -from ..home.models import HomePage +from ietf.home.models import HomePage +from .factories import PrimaryTopicPageFactory, TopicIndexPageFactory from .models import PrimaryTopicPage, TopicIndexPage +pytestmark = pytest.mark.django_db -class StandardPageTests(TestCase): - def test_standard_page(self): - root = Page.get_first_root_node() +class TestTopicPage: + @pytest.fixture(autouse=True) + def set_up(self, home: HomePage, client: Client): + self.home = home + self.client = client - home = HomePage( - slug="homepageslug", - title="home page title", - heading="home page heading", - introduction="home page introduction", - ) + self.topic_index: TopicIndexPage = TopicIndexPageFactory( + parent=self.home, + ) # type: ignore - root.add_child(instance=home) + self.topic_page: PrimaryTopicPage = PrimaryTopicPageFactory( + parent=self.topic_index, + ) # type: ignore - Site.objects.all().delete() + def test_index_page(self): + response = self.client.get(path=self.topic_index.url) + assert response.status_code == 200 + html = response.content.decode() - Site.objects.create( - hostname="localhost", - root_page=home, - is_default_site=True, - site_name="testingsitename", - ) + assert self.topic_page.title in html + assert f'href="{self.topic_page.url}"' in html - topicindex = TopicIndexPage( - slug="topicindex", - title="topic index page title", - introduction="topic index page introduction", - ) - home.add_child(instance=topicindex) + def test_topic_page(self): + response = self.client.get(path=self.topic_page.url) + assert response.status_code == 200 + html = response.content.decode() - topicpage = PrimaryTopicPage( - slug="topic", - title="topic title", - introduction="topic introduction", - ) - topicindex.add_child(instance=topicpage) - - rindex = self.client.get(path=topicindex.url) - self.assertEqual(rindex.status_code, 200) - - r = self.client.get(path=topicpage.url) - self.assertEqual(r.status_code, 200) - - self.assertIn(topicpage.title.encode(), r.content) - self.assertIn(topicpage.introduction.encode(), r.content) - self.assertIn(('href="%s"' % topicindex.url).encode(), r.content) + assert self.topic_page.title in html + assert self.topic_page.introduction in html + assert f'href="{self.topic_index.url}"' in html diff --git a/ietf/urls.py b/ietf/urls.py index f166f69c..5d645ef2 100644 --- a/ietf/urls.py +++ b/ietf/urls.py @@ -29,7 +29,7 @@ ] -if settings.DEBUG: +if settings.DEBUG: # pragma: no cover from django.conf.urls.static import static from django.contrib.staticfiles.urls import staticfiles_urlpatterns from django.views.generic import TemplateView diff --git a/ietf/utils/__init__.py b/ietf/utils/__init__.py index 04f2dab9..e69de29b 100644 --- a/ietf/utils/__init__.py +++ b/ietf/utils/__init__.py @@ -1,63 +0,0 @@ -from collections import abc as collections - - -class OrderedSet(collections.MutableSet): - """ - Liberated from https://code.activestate.com/recipes/576694/ - """ - - def __init__(self, iterable=None): - self.end = end = [] - end += [None, end, end] # sentinel node for doubly linked list - self.map = {} # key --> [key, prev, next] - if iterable is not None: - self |= iterable - - def __len__(self): - return len(self.map) - - def __contains__(self, key): - return key in self.map - - def add(self, key): - if key not in self.map: - end = self.end - curr = end[1] - curr[2] = end[1] = self.map[key] = [key, curr, end] - - def discard(self, key): - if key in self.map: - key, prev, next = self.map.pop(key) - prev[2] = next - next[1] = prev - - def __iter__(self): - end = self.end - curr = end[2] - while curr is not end: - yield curr[0] - curr = curr[2] - - def __reversed__(self): - end = self.end - curr = end[1] - while curr is not end: - yield curr[0] - curr = curr[1] - - def pop(self, last=True): - if not self: - raise KeyError('set is empty') - key = self.end[1][0] if last else self.end[2][0] - self.discard(key) - return key - - def __repr__(self): - if not self: - return '%s()' % (self.__class__.__name__,) - return '%s(%r)' % (self.__class__.__name__, list(self)) - - def __eq__(self, other): - if isinstance(other, OrderedSet): - return len(self) == len(other) and list(self) == list(other) - return set(self) == set(other) diff --git a/ietf/utils/apps.py b/ietf/utils/apps.py index 47e91092..b01340df 100644 --- a/ietf/utils/apps.py +++ b/ietf/utils/apps.py @@ -1,11 +1,11 @@ from django.apps import AppConfig -from .signal_handlers import register_signal_handlers - class UtilsAppConfig(AppConfig): name = 'ietf.utils' verbose_name = "IETF Website Utils" def ready(self): + from .signal_handlers import register_signal_handlers + register_signal_handlers() diff --git a/ietf/utils/blocks.py b/ietf/utils/blocks.py index ce8bae73..2c12ac43 100644 --- a/ietf/utils/blocks.py +++ b/ietf/utils/blocks.py @@ -1,3 +1,4 @@ +from django.utils.functional import cached_property from wagtail.blocks import ( CharBlock, FloatBlock, @@ -7,6 +8,7 @@ RichTextBlock, StreamBlock, StructBlock, + StructValue, URLBlock, ) from wagtail.contrib.table_block.blocks import TableBlock @@ -28,11 +30,36 @@ class Meta: template = "blocks/note_well_block.html" +class LinkStructValue(StructValue): + @cached_property + def url(self): + if external_url := self.get("external_url"): + return external_url + + if page := self.get("page"): + return page.url + + return "" + + @cached_property + def text(self): + if title := self.get("title"): + return title + + if page := self.get("page"): + return page.title + + return self.get("external_url") + + class LinkBlock(StructBlock): page = PageChooserBlock(label="Page", required=False) title = CharBlock(label="Link text", required=False) external_url = URLBlock(label="External URL", required=False) + class Meta: # type: ignore + value_class = LinkStructValue + class MainMenuSection(StructBlock): title = CharBlock(label="Section title", required=True) @@ -51,10 +78,10 @@ class StandardBlock(StreamBlock): ) typed_table = TypedTableBlock( [ - ("text", CharBlock()), - ("numeric", FloatBlock()), - ("rich_text", RichTextBlock()), - ("image", ImageChooserBlock()), + ("text", CharBlock(required=False)), + ("numeric", FloatBlock(required=False, template="blocks/float_block.html")), + ("rich_text", RichTextBlock(required=False)), + ("image", ImageChooserBlock(required=False)), ] ) note_well = NoteWellBlock(icon="placeholder", label="Note Well Text") diff --git a/ietf/utils/context_processors.py b/ietf/utils/context_processors.py index d74a2596..3b200206 100644 --- a/ietf/utils/context_processors.py +++ b/ietf/utils/context_processors.py @@ -1,13 +1,14 @@ +from collections.abc import Iterable from operator import attrgetter -from ietf.utils.models import MainMenuItem +from ietf.utils.models import FooterColumn, MainMenuItem class MainMenu: def __init__(self, site): self.site = site - def get_items(self): + def get_items(self) -> Iterable[MainMenuItem]: return MainMenuItem.objects.all().select_related("page") def get_introduction(self, page): @@ -16,33 +17,6 @@ def get_introduction(self, page): return "" - def get_link_url(self, link): - if external_url := link.get("external_url"): - return external_url - - if page := link.get("page"): - return page.get_url(current_site=self.site) - - return "" - - def get_link_title(self, link): - if title := link.get("title"): - return title - - if page := link.get("page"): - return page.title - - return link.get("external_url") - - def get_section_links(self, section): - for link in section.value.get("links"): - item = { - "title": self.get_link_title(link), - "url": self.get_link_url(link), - } - if item["title"] and item["url"]: - yield item - def get_menu_item(self, item): main_section_links = [ { @@ -54,7 +28,11 @@ def get_menu_item(self, item): secondary_sections = [ { "title": section.value.get("title"), - "links": list(self.get_section_links(section)), + "links": [ + link + for link in section.value.get("links") + if link.text and link.url + ], } for section in item.secondary_sections ] @@ -70,10 +48,7 @@ def get_menu_item(self, item): } def get_menu(self): - return [ - self.get_menu_item(item) - for item in self.get_items() - ] + return [self.get_menu_item(item) for item in self.get_items()] class PreviewMainMenu(MainMenu): @@ -107,3 +82,17 @@ def get_main_menu(site): return get_iab_main_menu(site) return MainMenu(site).get_menu() + + +def get_footer(): + return FooterColumn.objects.all() + + +def get_preview_footer(current): + items = [ + current if item == current else item + for item in FooterColumn.objects.all() + ] + if not current.pk: + items.append(current) + return sorted(items, key=attrgetter("sort_order")) diff --git a/ietf/utils/factories.py b/ietf/utils/factories.py new file mode 100644 index 00000000..c72a820c --- /dev/null +++ b/ietf/utils/factories.py @@ -0,0 +1,11 @@ +import factory +import wagtail_factories + +from . import blocks + + +class StandardBlockFactory(wagtail_factories.StreamBlockFactory): + heading = factory.SubFactory(wagtail_factories.CharBlockFactory) + + class Meta: + model = blocks.StandardBlock diff --git a/ietf/utils/migrations/0010_footercolumn.py b/ietf/utils/migrations/0010_footercolumn.py new file mode 100644 index 00000000..35d73676 --- /dev/null +++ b/ietf/utils/migrations/0010_footercolumn.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.7 on 2024-03-29 14:06 + +from django.db import migrations, models +import wagtail.blocks +import wagtail.fields +import wagtail.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('utils', '0009_megamenu'), + ] + + operations = [ + migrations.CreateModel( + name='FooterColumn', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('links', wagtail.fields.StreamField([('link', wagtail.blocks.StructBlock([('page', wagtail.blocks.PageChooserBlock(label='Page', required=False)), ('title', wagtail.blocks.CharBlock(label='Link text', required=False)), ('external_url', wagtail.blocks.URLBlock(label='External URL', required=False))]))], blank=True, use_json_field=True)), + ('sort_order', models.PositiveSmallIntegerField()), + ], + options={ + 'ordering': ['sort_order'], + }, + bases=(wagtail.models.PreviewableMixin, models.Model), + ), + ] diff --git a/ietf/utils/models.py b/ietf/utils/models.py index 1c5be375..7e2ddb60 100644 --- a/ietf/utils/models.py +++ b/ietf/utils/models.py @@ -9,7 +9,7 @@ from wagtail.models import Orderable, PreviewableMixin, Site from wagtailorderable.models import Orderable as WagtailOrderable -from ietf.utils.blocks import MainMenuSection +from ietf.utils.blocks import LinkBlock, MainMenuSection class LinkFields(models.Model): @@ -126,19 +126,19 @@ class MainMenuItem(PreviewableMixin, models.Model): class Meta: ordering = ["sort_order"] - def __str__(self): + def __str__(self): # pragma: no cover return self.page.title - def get_preview_template(self, request, model_name): + def get_preview_template(self, request, mode_name): return "previews/main_menu_item.html" - def get_preview_context(self, request, model_name): + def get_preview_context(self, request, mode_name): from .context_processors import PreviewMainMenu site = Site.find_for_request(request) return { - **super().get_preview_context(request, model_name), + **super().get_preview_context(request, mode_name), "MENU": PreviewMainMenu(site, self).get_menu(), "MENU_PREVIEW": self, } @@ -197,6 +197,35 @@ class Meta: verbose_name_plural = "Secondary Menu" +class FooterColumn(PreviewableMixin, models.Model): + title = models.CharField(max_length=255) + links = StreamField( + [ + ("link", LinkBlock()), + ], + blank=True, + use_json_field=True, + ) + sort_order = models.PositiveSmallIntegerField() + + class Meta: + ordering = ["sort_order"] + + def __str__(self): # pragma: no cover + return self.title + + def get_preview_template(self, request, mode_name): + return "previews/footer_column.html" + + def get_preview_context(self, request, mode_name): + from .context_processors import get_preview_footer + + return { + **super().get_preview_context(request, mode_name), + "FOOTER": get_preview_footer(current=self), + } + + @register_setting class SocialMediaSettings(BaseSiteSetting): twitter_handle = models.CharField( @@ -319,7 +348,7 @@ class LayoutSettings(BaseSiteSetting): max_length=255, blank=True, choices=BASE_TEMPLATE_CHOICES, - default="base.html", + default=DEFAULT_BASE, ) diff --git a/ietf/utils/signal_handlers.py b/ietf/utils/signal_handlers.py index fd8a749c..d607bf46 100644 --- a/ietf/utils/signal_handlers.py +++ b/ietf/utils/signal_handlers.py @@ -1,16 +1,17 @@ -def register_signal_handlers(): - from django.db.models.signals import post_delete, post_save - from wagtail.contrib.frontend_cache.utils import purge_pages_from_cache - from wagtail.models import Page, ReferenceIndex - from wagtail.signals import page_published, page_unpublished +from django.db.models.signals import post_delete, post_save +from wagtail.contrib.frontend_cache.utils import purge_pages_from_cache +from wagtail.models import Page, ReferenceIndex +from wagtail.signals import page_published, page_unpublished + +from ietf.utils.models import MainMenuItem - from ietf.utils.models import MainMenuItem +def register_signal_handlers(): def page_published_or_unpublished_handler(instance, **kwargs): home_page = instance.get_site().root_page purge_pages = set() - if not instance == home_page: + if not instance.pk == home_page.pk: parent = instance.get_parent() purge_pages.add(parent) @@ -22,11 +23,11 @@ def page_published_or_unpublished_handler(instance, **kwargs): if isinstance(obj, MainMenuItem): purge_pages.add(home_page) - purge_pages_from_cache(list(purge_pages)) + purge_pages_from_cache(purge_pages) def main_menu_item_saved_or_deleted_handler(instance, **kwargs): home_page = instance.page.get_site().root_page - purge_pages_from_cache([home_page]) + purge_pages_from_cache({home_page}) page_published.connect(page_published_or_unpublished_handler) page_unpublished.connect(page_published_or_unpublished_handler) diff --git a/ietf/utils/templates/previews/footer_column.html b/ietf/utils/templates/previews/footer_column.html new file mode 100644 index 00000000..b4473501 --- /dev/null +++ b/ietf/utils/templates/previews/footer_column.html @@ -0,0 +1 @@ +{% extends settings.utils.LayoutSettings.base_template %} diff --git a/ietf/utils/tests.py b/ietf/utils/tests.py deleted file mode 100644 index 6bf0869f..00000000 --- a/ietf/utils/tests.py +++ /dev/null @@ -1,79 +0,0 @@ -from django.test import TestCase -from wagtail.models import Page, Site -from wagtail.test.utils import WagtailTestUtils - -from ietf.events.models import EventListingPage, EventPage -from ietf.utils.models import SecondaryMenuItem - -from ..home.models import HomePage - - -class MenuTests(TestCase, WagtailTestUtils): - def setUp(self): - super().setUp() - self._setup_pages() - self.login() - - def _setup_pages(self): - root = Page.get_first_root_node() - - home = HomePage( - slug="homepageslug", - title="home page title", - heading="home page heading", - introduction="home page introduction", - ) - - root.add_child(instance=home) - - Site.objects.all().delete() - - Site.objects.create( - hostname="localhost", - root_page=home, - is_default_site=True, - site_name="testingsitename", - ) - - self.eventlisting = EventListingPage( - slug="eventlisting", - title="event listing page title", - introduction="event listing page introduction", - ) - home.add_child(instance=self.eventlisting) - - self.eventpage = EventPage( - slug="event", - title="event title", - introduction="event introduction", - ) - self.eventlisting.add_child(instance=self.eventpage) - - def _build_menu(self): - SecondaryMenuItem.objects.create(page=self.eventlisting, text="Menu One", sort_order=0) - SecondaryMenuItem.objects.create(page=self.eventpage, text="Menu Two", sort_order=1) - - def test_admin_menu_item_index(self): - response = self.client.get("/admin/utils/secondarymenuitem/") - self.assertEqual(response.status_code, 200) - - def test_menu_context_loads(self): - self._build_menu() - menu_items = SecondaryMenuItem.objects.order_by("sort_order").all() - response = self.client.get("/") - self.assertEqual(response.status_code, 200) - secondary_menu = response.context["SECONDARY_MENU"]() - self.assertEqual(len(secondary_menu), 2) - self.assertEqual(menu_items[0], secondary_menu[0]) - self.assertEqual(menu_items[1], secondary_menu[1]) - - def test_menu_in_template(self): - self._build_menu() - menu_items = SecondaryMenuItem.objects.order_by("sort_order").all() - response = self.client.get("/") - self.assertContains( - response, "Menu Two".format(menu_items[1].page.url), count=1 - ) - self.assertContains( - response, "Menu One".format(menu_items[0].page.url), count=1 - ) diff --git a/ietf/utils/tests/test_500_page.py b/ietf/utils/tests/test_500_page.py new file mode 100644 index 00000000..4f4029c6 --- /dev/null +++ b/ietf/utils/tests/test_500_page.py @@ -0,0 +1,17 @@ +from unittest.mock import Mock +from django.test import Client +import pytest + +pytestmark = pytest.mark.django_db + + +def test_500_page(client: Client, monkeypatch: pytest.MonkeyPatch, settings, home): + settings.DEBUG = False + monkeypatch.setattr( + "ietf.home.models.HomePage.serve", Mock(side_effect=RuntimeError) + ) + client.raise_request_exception = False + response = client.get("/") + assert response.status_code == 500 + expect = 'If the matter is urgent, please email <a href="mailto:support@ietf.org">' + assert expect in response.content.decode() diff --git a/ietf/utils/tests/test_cache_purging.py b/ietf/utils/tests/test_cache_purging.py new file mode 100644 index 00000000..571ec7fc --- /dev/null +++ b/ietf/utils/tests/test_cache_purging.py @@ -0,0 +1,77 @@ +from unittest.mock import Mock, call + +import pytest +from django.test import Client +from wagtail.models import Page + +from ietf.home.models import HomePage +from ietf.standard.factories import StandardIndexPageFactory, StandardPageFactory +from ietf.standard.models import StandardIndexPage, StandardPage +from ietf.utils.models import MainMenuItem + +pytestmark = pytest.mark.django_db + + +class TestPagePurging: + @pytest.fixture(autouse=True) + def set_up(self, home: HomePage, client: Client, monkeypatch: pytest.MonkeyPatch): + self.home = home + self.client = client + + self.standard_index: StandardIndexPage = StandardIndexPageFactory( + parent=self.home, + ) # type: ignore + + self.standard_page: StandardPage = StandardPageFactory( + parent=self.standard_index, + ) # type: ignore + + self.mock_purge = Mock() + monkeypatch.setattr( + "ietf.utils.signal_handlers.purge_pages_from_cache", self.mock_purge + ) + + def test_purge_parent(self): + self.standard_page.save_revision().publish() + + # Wagtail already purges the page itself + assert self.mock_purge.call_args_list == [ + call({Page.objects.get(pk=self.standard_index.pk)}), + ] + + def test_purge_referencing_page(self): + self.standard_page.key_info = [ + { + "type": "paragraph", + "value": f'<p><a id="{self.home.pk}" linktype="page">Home</a></p>', + }, + ] + self.standard_page.save() + self.home.save_revision().publish() + + # Wagtail already purges the page itself + assert self.mock_purge.call_args_list == [ + call({Page.objects.get(pk=self.standard_page.pk)}), + ] + + def test_main_menu_item_updates_homepage(self): + MainMenuItem.objects.create(page=self.standard_page, sort_order=1) + + assert self.mock_purge.call_args_list == [ + call({Page.objects.get(pk=self.home.pk)}), + ] + + def test_main_menu_reference_updates_homepage(self): + MainMenuItem.objects.create(page=self.standard_page, sort_order=1) + self.mock_purge.reset_mock() + self.standard_page.save_revision().publish() + + assert self.mock_purge.call_args_list == [ + call( + { + Page.objects.get(pk=self.home.pk), + # parent page gets purged anyway + Page.objects.get(pk=self.standard_index.pk), + } + ), + ] diff --git a/ietf/utils/tests/test_footer.py b/ietf/utils/tests/test_footer.py new file mode 100644 index 00000000..fe405cda --- /dev/null +++ b/ietf/utils/tests/test_footer.py @@ -0,0 +1,99 @@ +from bs4 import BeautifulSoup +import pytest +from django.test import Client, RequestFactory + +from ietf.home.models import HomePage +from ietf.standard.factories import StandardIndexPageFactory, StandardPageFactory +from ietf.standard.models import StandardIndexPage, StandardPage +from ietf.utils.models import FooterColumn + +pytestmark = pytest.mark.django_db + + +class TestFooterColumns: + @pytest.fixture(autouse=True) + def set_up(self, home: HomePage, client: Client): + self.home = home + self.client = client + + self.standard_index: StandardIndexPage = StandardIndexPageFactory( + parent=self.home, + ) # type: ignore + + self.standard_page: StandardPage = StandardPageFactory( + parent=self.standard_index, + show_in_menus=True, + ) # type: ignore + + def test_links(self): + FooterColumn.objects.create( + title="Column Title", + links=[ + { + "type": "link", + "value": { + "page": self.standard_index.pk, + }, + }, + { + "type": "link", + "value": { + "page": self.standard_page.pk, + "title": "My Page Title", + }, + }, + { + "type": "link", + "value": { + "external_url": "http://example.com", + }, + }, + { + "type": "link", + "value": { + "external_url": "http://example.com", + "title": "My External Link Title", + }, + }, + ], + sort_order=1, + ) + + response = self.client.get("/") + assert response.status_code == 200 + html = response.content.decode() + soup = BeautifulSoup(html, "html.parser") + + # Select the single footer column. + [section] = soup.select("footer section") + + # Select the column's heading. + [h4] = section.select("h4") + assert h4.get_text().strip() == "Column Title" + + # Select the links. They should match what we specified in the `links` + # field. + [link1, link2, link3, link4] = section.select("ul li a") + assert link1.get_text().strip() == self.standard_index.title + assert link1.attrs["href"] == self.standard_index.url + assert link2.get_text().strip() == "My Page Title" + assert link2.attrs["href"] == self.standard_page.url + assert link3.get_text().strip() == "http://example.com" + assert link3.attrs["href"] == "http://example.com" + assert link4.get_text().strip() == "My External Link Title" + assert link4.attrs["href"] == "http://example.com" + + def test_order_in_preview(self): + item1 = FooterColumn.objects.create(title="One", sort_order=10) + item2 = FooterColumn.objects.create(title="Two", sort_order=20) + + item1.sort_order = 30 + context = item1.get_preview_context(RequestFactory().get("/"), "") + assert [i.title for i in context["FOOTER"]] == ["Two", "One"] + + def test_order_in_preview_new_object(self): + item1 = FooterColumn.objects.create(title="One", sort_order=10) + item2 = FooterColumn(title="Two", sort_order=5) + + context = item2.get_preview_context(RequestFactory().get("/"), "") + assert [i.title for i in context["FOOTER"]] == ["Two", "One"] diff --git a/ietf/utils/tests/test_iab_main_menu.py b/ietf/utils/tests/test_iab_main_menu.py new file mode 100644 index 00000000..56bec58c --- /dev/null +++ b/ietf/utils/tests/test_iab_main_menu.py @@ -0,0 +1,44 @@ +from bs4 import BeautifulSoup +from django.test import Client +import pytest + +from ietf.home.models import IABHomePage +from ietf.standard.factories import IABStandardPageFactory + +pytestmark = pytest.mark.django_db + + +class TestIABHome: + @pytest.fixture(autouse=True) + def set_up(self, iab_home: IABHomePage, client: Client): + self.home = iab_home + self.client = client + + def test_pages_in_menu(self): + page1 = IABStandardPageFactory(parent=self.home, show_in_menus=True) + page1a = IABStandardPageFactory(parent=page1, show_in_menus=True) + page1b = IABStandardPageFactory(parent=page1, show_in_menus=True) + page2 = IABStandardPageFactory(parent=self.home, show_in_menus=True) + page2a = IABStandardPageFactory(parent=page2, show_in_menus=True) + page2b = IABStandardPageFactory(parent=page2, show_in_menus=True) + + response = self.client.get(path=self.home.url) + assert response.status_code == 200 + html = response.content.decode() + soup = BeautifulSoup(html, "html.parser") + + def get_nav_item(item): + """ Get the menu item link, and the links within the menu. """ + [main_link] = item.select("a.nav-link") + child_links = item.select("ul.dropdown-menu > li > a") + return ( + main_link.attrs["href"], + [link.attrs["href"] for link in child_links], + ) + + menu = [get_nav_item(item) for item in soup.select(".navbar-nav > li")] + assert menu == [ + (page1.url, [page1a.url, page1b.url]), + (page2.url, [page2a.url, page2b.url]), + ('/search', []), + ] diff --git a/ietf/utils/tests/test_mega_menu.py b/ietf/utils/tests/test_mega_menu.py new file mode 100644 index 00000000..7558c2ad --- /dev/null +++ b/ietf/utils/tests/test_mega_menu.py @@ -0,0 +1,120 @@ +from bs4 import BeautifulSoup +import pytest +from django.test import Client, RequestFactory + +from ietf.home.models import HomePage +from ietf.standard.factories import StandardIndexPageFactory, StandardPageFactory +from ietf.standard.models import StandardIndexPage, StandardPage +from ietf.utils.models import MainMenuItem + +pytestmark = pytest.mark.django_db + + +class TestMegaMenu: + @pytest.fixture(autouse=True) + def set_up(self, home: HomePage, client: Client): + self.home = home + self.client = client + + self.standard_index: StandardIndexPage = StandardIndexPageFactory( + parent=self.home, + ) # type: ignore + + self.standard_page: StandardPage = StandardPageFactory( + parent=self.standard_index, + show_in_menus=True, + ) # type: ignore + + def test_primary_section(self): + MainMenuItem.objects.create(page=self.standard_index, sort_order=1) + + response = self.client.get("/") + assert response.status_code == 200 + html = response.content.decode() + soup = BeautifulSoup(html, "html.parser") + + # Select the button that expands the megamenu for our MainMenuItem. + [button] = soup.select(".megamenu__toggle") + assert button.get_text().strip() == self.standard_index.title + + # Select the menu content box. + [menu] = soup.select(".megamenu__menu") + + # Select the primary heading, which links to the MainMenuItem's page, + # defined in its `page` field. + [primary_heading] = menu.select("h5") + assert primary_heading.get_text().strip() == self.standard_index.title + assert primary_heading.select("a")[0].attrs["href"] == self.standard_index.url + + # Select the links just below the primary heading. They should be child + # pages of the MainMenuItem's page. + primary_linklist = menu.select("ul.megamenu__linklist")[0] + [page_link] = primary_linklist.select("li a") + assert page_link.get_text().strip() == self.standard_page.title + assert page_link.attrs["href"] == self.standard_page.url + + def test_secondary_section(self): + MainMenuItem.objects.create( + page=self.standard_index, + sort_order=1, + secondary_sections=[ + { + "type": "section", + "value": { + "title": "Secondary Links", + "links": [ + {"page": self.standard_index.pk}, + {"page": self.standard_page.pk, "title": "Alternate Title"}, + {"external_url": "http://example.com"}, + {"external_url": "http://example.com", "title": "External"}, + ], + }, + }, + ], + ) + + response = self.client.get("/") + assert response.status_code == 200 + html = response.content.decode() + soup = BeautifulSoup(html, "html.parser") + + # Select the menu content box. + [menu] = soup.select(".megamenu__menu") + + # Select the (single) secondary heading, defined in the `secondary_links` field. + [secondary_heading] = menu.select("h6") + assert secondary_heading.get_text() == "Secondary Links" + + # Select the links just below the secondary heading. They should match + # what we specified in the `secondary_links` field. + secondary_linklist = menu.select("ul.megamenu__linklist")[1] + [link1, link2, link3, link4] = secondary_linklist.select("li a") + assert link1.get_text().strip() == self.standard_index.title + assert link1.attrs["href"] == self.standard_index.url + assert link2.get_text().strip() == "Alternate Title" + assert link2.attrs["href"] == self.standard_page.url + assert link3.get_text().strip() == "http://example.com" + assert link3.attrs["href"] == "http://example.com" + assert link4.get_text().strip() == "External" + assert link4.attrs["href"] == "http://example.com" + + def test_order_in_preview(self): + item1 = MainMenuItem.objects.create(page=self.standard_index, sort_order=10) + item2 = MainMenuItem.objects.create(page=self.standard_page, sort_order=20) + + item1.sort_order = 30 + context = item1.get_preview_context(RequestFactory().get("/"), "") + assert [i["url"] for i in context["MENU"]] == [ + self.standard_page.url, + self.standard_index.url, + ] + + def test_order_in_preview_new_object(self): + item1 = MainMenuItem.objects.create(page=self.standard_index, sort_order=10) + item2 = MainMenuItem(page=self.standard_page, sort_order=5) + + context = item2.get_preview_context(RequestFactory().get("/"), "") + assert [i["url"] for i in context["MENU"]] == [ + self.standard_page.url, + self.standard_index.url, + ] diff --git a/ietf/utils/tests/test_secondary_menu.py b/ietf/utils/tests/test_secondary_menu.py new file mode 100644 index 00000000..83746c09 --- /dev/null +++ b/ietf/utils/tests/test_secondary_menu.py @@ -0,0 +1,46 @@ +from django.test import Client +import pytest +from wagtail.test.utils import WagtailTestUtils + +from ietf.events.factories import EventListingPageFactory, EventPageFactory +from ietf.home.models import HomePage +from ietf.utils.models import SecondaryMenuItem + +pytestmark = pytest.mark.django_db + + +class TestMenu(WagtailTestUtils): + @pytest.fixture(autouse=True) + def set_up(self, home: HomePage): + self.home = home + self.eventlisting = EventListingPageFactory( + parent=home, + ) + self.eventpage = EventPageFactory( + parent=self.eventlisting, + ) + + def _build_menu(self): + SecondaryMenuItem.objects.create(page=self.eventlisting, text="Menu One", sort_order=0) + SecondaryMenuItem.objects.create(page=self.eventpage, text="Menu Two", sort_order=1) + + def test_admin_menu_item_index(self, admin_client): + response = admin_client.get("/admin/utils/secondarymenuitem/") + assert response.status_code == 200 + + def test_menu_context_loads(self, client: Client): + self._build_menu() + menu_items = SecondaryMenuItem.objects.order_by("sort_order").all() + response = client.get("/") + assert response.status_code == 200 + secondary_menu = response.context["SECONDARY_MENU"]() + assert len(secondary_menu) == 2 + assert menu_items[0] == secondary_menu[0] + assert menu_items[1] == secondary_menu[1] + + def test_menu_in_template(self, client: Client): + self._build_menu() + response = client.get("/") + html = response.content.decode() + assert "Menu Two" in html + assert "Menu One" in html diff --git a/ietf/utils/wagtail_hooks.py b/ietf/utils/wagtail_hooks.py index 046d5072..d75dca5c 100644 --- a/ietf/utils/wagtail_hooks.py +++ b/ietf/utils/wagtail_hooks.py @@ -6,10 +6,10 @@ from wagtail_modeladmin.options import ModelAdmin, modeladmin_register from wagtailorderable.modeladmin.mixins import OrderableMixin -from ietf.utils.models import MainMenuItem, SecondaryMenuItem +from .models import FooterColumn, MainMenuItem, SecondaryMenuItem -@hooks.register("insert_global_admin_css") +@hooks.register("insert_global_admin_css") # type: ignore def editor_css(): return format_html( '<link rel="stylesheet" href="' @@ -22,13 +22,13 @@ class MainMenuViewSet(SnippetViewSet): list_display = [ "__str__", "sort_order", - ] + ] # type: ignore register_snippet(MainMenuItem, viewset=MainMenuViewSet) -class MenuItemAdmin(OrderableMixin, ModelAdmin): +class MenuItemAdmin(OrderableMixin, ModelAdmin): # type: ignore model = SecondaryMenuItem menu_order = 900 menu_label = "Secondary Menu" @@ -40,3 +40,13 @@ class MenuItemAdmin(OrderableMixin, ModelAdmin): modeladmin_register(MenuItemAdmin) + + +class FooterColumnViewSet(SnippetViewSet): + list_display = [ + "__str__", + "sort_order", + ] # type: ignore + + +register_snippet(FooterColumn, viewset=MainMenuViewSet) diff --git a/requirements/dev.in b/requirements/dev.in index 03bac4d6..78f9dcca 100644 --- a/requirements/dev.in +++ b/requirements/dev.in @@ -3,3 +3,4 @@ pip-tools pytest-cov pytest-django +wagtail-factories diff --git a/requirements/dev.txt b/requirements/dev.txt index ef30d09b..eca6a1f5 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -4,19 +4,120 @@ # # pip-compile dev.in # +anyascii==0.3.2 + # via + # -c base.txt + # wagtail +asgiref==3.7.2 + # via + # -c base.txt + # django +beautifulsoup4==4.11.2 + # via + # -c base.txt + # wagtail build==1.0.3 # via pip-tools +certifi==2023.11.17 + # via + # -c base.txt + # requests +charset-normalizer==3.3.2 + # via + # -c base.txt + # requests click==8.1.7 # via pip-tools coverage[toml]==7.3.2 - # via pytest-cov + # via + # coverage + # pytest-cov +defusedxml==0.7.1 + # via + # -c base.txt + # willow +django==4.2.7 + # via + # -c base.txt + # django-filter + # django-modelcluster + # django-permissionedforms + # django-taggit + # django-treebeard + # djangorestframework + # wagtail +django-filter==23.4 + # via + # -c base.txt + # wagtail +django-modelcluster==6.1 + # via + # -c base.txt + # wagtail +django-permissionedforms==0.1 + # via + # -c base.txt + # wagtail +django-taggit==4.0.0 + # via + # -c base.txt + # wagtail +django-treebeard==4.7 + # via + # -c base.txt + # wagtail +djangorestframework==3.14.0 + # via + # -c base.txt + # wagtail +draftjs-exporter==2.1.7 + # via + # -c base.txt + # wagtail +et-xmlfile==1.1.0 + # via + # -c base.txt + # openpyxl +factory-boy==3.3.0 + # via wagtail-factories +faker==24.4.0 + # via factory-boy +filetype==1.2.0 + # via + # -c base.txt + # willow +html5lib==1.1 + # via + # -c base.txt + # wagtail +idna==3.6 + # via + # -c base.txt + # requests iniconfig==2.0.0 # via pytest +l18n==2021.3 + # via + # -c base.txt + # wagtail +openpyxl==3.1.2 + # via + # -c base.txt + # wagtail packaging==23.2 # via # -c base.txt # build # pytest +pillow==10.1.0 + # via + # -c base.txt + # pillow-heif + # wagtail +pillow-heif==0.13.1 + # via + # -c base.txt + # willow pip-tools==7.3.0 # via -r dev.in pluggy==1.3.0 @@ -31,8 +132,57 @@ pytest-cov==4.1.0 # via -r dev.in pytest-django==4.7.0 # via -r dev.in +python-dateutil==2.9.0.post0 + # via faker +pytz==2023.3.post1 + # via + # -c base.txt + # django-modelcluster + # djangorestframework + # l18n +requests==2.31.0 + # via + # -c base.txt + # wagtail +six==1.16.0 + # via + # -c base.txt + # html5lib + # l18n + # python-dateutil +soupsieve==2.5 + # via + # -c base.txt + # beautifulsoup4 +sqlparse==0.4.4 + # via + # -c base.txt + # django +telepath==0.3.1 + # via + # -c base.txt + # wagtail +urllib3==2.1.0 + # via + # -c base.txt + # requests +wagtail==5.2.1 + # via + # -c base.txt + # wagtail-factories +wagtail-factories==4.1.0 + # via -r dev.in +webencodings==0.5.1 + # via + # -c base.txt + # html5lib wheel==0.42.0 # via pip-tools +willow[heif]==1.6.3 + # via + # -c base.txt + # wagtail + # willow # The following packages are considered to be unsafe in a requirements file: # pip