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 = """\
+
+
+
+
+ http://www.ietf.org/blog/
+
+
+ en-gb
+ Tue, 05 Mar 2024 14:46:00 +0000
+ -
+ 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": "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/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 @@