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/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/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/models.py b/ietf/blog/models.py index c84764ec..5cd210cf 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): @@ -161,13 +168,6 @@ 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 @@ -416,7 +416,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 +428,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..e4bfdaf0 100644 --- a/ietf/blog/tests.py +++ b/ietf/blog/tests.py @@ -1,168 +1,240 @@ 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 .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) + ) # type: ignore - now = timezone.now() + self.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.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.alice = Person.objects.create(name="Alice", slug="alice") - self.bob = Person.objects.create(name="Bob", slug="bob") + 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 - 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) + 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) 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("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 "blog body
"}]', - ) - 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 ' 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/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/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/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/context_processors.py b/ietf/utils/context_processors.py index d74a2596..e47fcc9e 100644 --- a/ietf/utils/context_processors.py +++ b/ietf/utils/context_processors.py @@ -1,3 +1,4 @@ +from collections.abc import Iterable from operator import attrgetter from ietf.utils.models import MainMenuItem @@ -7,7 +8,7 @@ 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): 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/models.py b/ietf/utils/models.py index 1c5be375..3d94bb89 100644 --- a/ietf/utils/models.py +++ b/ietf/utils/models.py @@ -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, } @@ -319,7 +319,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/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 ' + 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'', + }, + ] + 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_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/requirements/base.txt b/requirements/base.txt index e8487015..b4c969af 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -122,4 +122,6 @@ webencodings==0.5.1 # bleach # html5lib willow[heif]==1.6.3 - # via wagtail + # via + # wagtail + # willow 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