magazine/models.py
import datetime
from datetime import timedelta
from django.db import models
from django.db.models import QuerySet
from django.http import HttpRequest
from django_flatpickr.widgets import DatePickerInput
from modelcluster.contrib.taggit import ClusterTaggableManager # type: ignore
from modelcluster.fields import ParentalKey # type: ignore
from modelcluster.models import ClusterableModel # type: ignore
from taggit.models import TaggedItemBase # type: ignore
from wagtail.admin.panels import (
FieldPanel,
FieldRowPanel,
HelpPanel,
InlinePanel,
MultiFieldPanel,
PageChooserPanel,
)
from wagtail.fields import RichTextField, StreamField
from wagtail.models import Orderable, Page
from wagtail.search import index
from common.models import DrupalFields
from core.constants import COMMON_STREAMFIELD_BLOCKS
from pagination.helpers import get_paginated_items
from .panels import NestedInlinePanel
MAGAZINE_ARCHIVE_THRESHOLD_DAYS = 180
ARCHIVE_THRESHOLD_DATE = datetime.date.today() - timedelta(
days=MAGAZINE_ARCHIVE_THRESHOLD_DAYS,
)
class MagazineIndexPage(Page):
intro = RichTextField(blank=True)
deep_archive_intro = RichTextField(blank=True)
deep_archive_page = models.ForeignKey(
"magazine.DeepArchiveIndexPage",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
featured_deep_archive_issue = models.ForeignKey(
"magazine.ArchiveIssue",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
content_panels = Page.content_panels + [
FieldPanel("intro"),
FieldPanel("deep_archive_intro"),
PageChooserPanel(
"deep_archive_page",
page_type="magazine.DeepArchiveIndexPage",
),
PageChooserPanel(
"featured_deep_archive_issue",
page_type="magazine.ArchiveIssue",
),
]
subpage_types: list[str] = [
"MagazineDepartmentIndexPage",
"MagazineIssue",
"MagazineTagIndexPage",
"DeepArchiveIndexPage",
]
max_count = 1
def get_context(
self,
request: HttpRequest,
*args: tuple,
**kwargs: dict,
) -> dict:
context = super().get_context(request)
published_issues = MagazineIssue.objects.live().order_by("-publication_date")
# recent issues are published after the archive threshold
context["recent_issues"] = published_issues.filter(
publication_date__gte=ARCHIVE_THRESHOLD_DATE,
)
archive_issues = published_issues.filter(
publication_date__lt=ARCHIVE_THRESHOLD_DATE,
)
# Get the unique years of the archive issues as a list of integers (years)
archive_issues_years = archive_issues.dates(
"publication_date",
"year",
order="ASC",
)
archive_issues_years = [
archive_issue.year for archive_issue in archive_issues_years
]
# Filter archive issues by year, if a year is provided in the query string
archive_year = request.GET.get("year")
if archive_year:
archive_issues = archive_issues.filter(
publication_date__year=archive_year,
)
page_number = request.GET.get("page", "1")
items_per_page = 8
context["archive_issues"] = get_paginated_items(
items=archive_issues,
items_per_page=items_per_page,
page_number=page_number,
)
context["archive_issues_fragment_identifier"] = "#archive-issues"
context["archive_issues_years"] = archive_issues_years
return context
class MagazineIssue(DrupalFields, Page): # type: ignore
cover_image = models.ForeignKey(
"wagtailimages.Image",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="+",
)
publication_date = models.DateField(
help_text="Please select the first day of the publication month",
default=datetime.date.today,
)
issue_number = models.PositiveIntegerField(null=True, blank=True)
drupal_node_id = models.PositiveIntegerField(null=True, blank=True, db_index=True)
@property
def featured_articles(self) -> QuerySet["MagazineArticle"]:
# Return a cursor of related articles that are featured
return (
MagazineArticle.objects.child_of(self)
.live()
.filter(is_featured=True)
.prefetch_related("authors__author")
)
@property
def articles_by_department(self) -> QuerySet["MagazineArticle"]:
# Return a cursor of child articles ordered by department
return (
MagazineArticle.objects.child_of(self)
.live()
.order_by("department__title")
.prefetch_related("authors__author", "department")
)
@property
def publication_end_date(self) -> datetime.date | None:
"""Return the first day of the month after the publication date.
NOTE: we can return any day in the following month,
since we only use the year and month components
"""
# We add 31 days here since we can't add a month directly
# 31 days is a safe upper bound for adding a month
# since the publication date will be at least 28 days prior
max_days_in_month = 31
return self.publication_date + timedelta(days=+max_days_in_month)
@property
def is_public_access(self) -> bool:
"""Check whether issue should be accessible to all readers or only
subscribers based on publication date and archive threshold."""
# check whether publication date is before public access date
return self.publication_date < ARCHIVE_THRESHOLD_DATE
search_template = "search/magazine_issue.html"
content_panels = Page.content_panels + [
FieldPanel("publication_date", widget=DatePickerInput()),
FieldPanel("cover_image"),
]
parent_page_types = ["MagazineIndexPage"]
subpage_types: list[str] = ["MagazineArticle"]
class Meta:
indexes = [
models.Index(fields=["drupal_node_id"]),
]
def get_sitemap_urls(self, changefreq=None, priority=None) -> list[dict]:
return [
{
"location": self.full_url,
"lastmod": self.latest_revision_created_at,
"changefreq": changefreq,
},
]
class MagazineArticleTag(TaggedItemBase):
content_object = ParentalKey(
to="MagazineArticle",
related_name="tagged_items",
on_delete=models.CASCADE,
)
class MagazineTagIndexPage(Page):
max_count = 1
def get_context(
self,
request: HttpRequest,
*args: tuple,
**kwargs: dict,
) -> dict:
tag = request.GET.get("tag")
context = super().get_context(request)
# prefetch related authors and issue
articles = (
MagazineArticle.objects.filter(
tagged_items__tag__name=tag,
)
.live()
.prefetch_related(
"authors__author",
)
)
context["articles"] = articles
return context
class MagazineDepartmentIndexPage(Page):
intro = RichTextField(blank=True)
content_panels = Page.content_panels + [FieldPanel("intro")]
parent_page_types = ["MagazineIndexPage"]
subpage_types: list[str] = ["MagazineDepartment"]
max_count = 1
def get_context(
self,
request: HttpRequest,
*args: tuple,
**kwargs: dict,
) -> dict:
departments = MagazineDepartment.objects.all()
context = super().get_context(request)
context["departments"] = departments
return context
class MagazineDepartment(Page):
parent_page_types = ["MagazineDepartmentIndexPage"]
subpage_types: list[str] = []
# TODO: Determine whether we still use the autocomplete widget
# Remove the following code if not using autocomplete
autocomplete_search_field = "title"
# TODO: remove if not using autocomplete
def autocomplete_label(self) -> str:
return self.title
# TODO: remove if not using autocomplete
def __str__(self) -> str:
return self.title
def get_context(
self,
request: HttpRequest,
*args: tuple,
**kwargs: dict,
) -> dict:
context = super().get_context(request)
context["articles"] = (
MagazineArticle.objects.filter(
department__title=self.title,
)
.live()
.prefetch_related(
"authors__author",
"issue",
)
)
articles = (
MagazineArticle.objects.filter(
department__title=self.title,
)
.live()
.prefetch_related(
"authors__author",
"issue",
)
)
context["articles"] = articles
return context
class MagazineArticle(DrupalFields, Page): # type: ignore
teaser = RichTextField( # type: ignore
blank=True,
help_text="Try to keep teaser to a couple dozen words.",
features=[
"bold",
"italic",
"link",
"strikethrough",
],
)
body = StreamField(
COMMON_STREAMFIELD_BLOCKS,
use_json_field=True,
)
is_featured = models.BooleanField(
default=False,
help_text="Feature this article in the related issue and allow full access without a subscription?", # noqa: E501
)
body_migrated = models.TextField(
help_text="Used only for content from old Drupal website.",
null=True,
blank=True,
)
department = models.ForeignKey(
MagazineDepartment,
on_delete=models.PROTECT,
related_name="articles",
)
tags = ClusterTaggableManager(through=MagazineArticleTag, blank=True)
drupal_node_id = models.PositiveIntegerField(null=True, blank=True, db_index=True)
search_template = "search/magazine_article.html"
@classmethod
def get_queryset(cls):
"""Prefetch authors and tags for performance."""
related_fields = ["authors__author", "tags__tag", "department"]
return super().get_queryset().prefetch_related(*related_fields)
class Meta:
verbose_name = "Page"
verbose_name_plural = "Pages"
search_fields = Page.search_fields + [
index.SearchField(
"body",
),
]
content_panels = Page.content_panels + [
FieldPanel("teaser", classname="full"),
FieldPanel("body"),
InlinePanel(
"authors",
heading="Authors",
help_text="Select one or more authors, who contributed to this article. Note: you must first add contacts in order to select them as authors.", # noqa: E501
min_num=1,
),
MultiFieldPanel(
[
PageChooserPanel("department", "magazine.MagazineDepartment"),
FieldPanel("tags"),
FieldPanel("is_featured"),
],
heading="Article information",
),
]
parent_page_types = ["MagazineIssue"]
subpage_types: list[str] = []
def get_sitemap_urls(self, changefreq=None, priority=None) -> list[dict]:
return [
{
"location": self.full_url,
"lastmod": self.latest_revision_created_at,
"changefreq": changefreq,
"priority": 1,
},
]
@property
def is_public_access(self) -> bool:
"""Check whether article should be accessible to all readers or only
subscribers based on whether the issue is public access."""
parent_issue = self.get_parent()
return parent_issue.specific.is_public_access # type: ignore
def get_context(
self,
request: HttpRequest,
*args: tuple,
**kwargs: dict,
) -> dict:
context = super().get_context(request)
user_is_authenticated = False
user_is_subscriber = False
user_is_superuser = False
# If user object is present in the request,
# check for their authentication
# and authorization status (subscriber or superuser authorization)
if request.user is not None:
user_is_authenticated = request.user.is_authenticated
# Only check for subscriber and superuser status if user is authenticated,
# preventing attribute errors on unauthenticated users
if user_is_authenticated:
user_is_subscriber = request.user.is_subscriber # type: ignore
user_is_superuser = request.user.is_superuser # type: ignore
# A user can view full article if
# - they are a subscriber or a superuser,
# - or if the article is marked as public access or featured
context["user_can_view_full_article"] = (
user_is_subscriber
or user_is_superuser
or self.is_public_access
or self.is_featured
)
return context
class MagazineArticleAuthor(Orderable):
article = ParentalKey(
"magazine.MagazineArticle",
on_delete=models.CASCADE,
related_name="authors",
)
author = models.ForeignKey(
"wagtailcore.Page",
on_delete=models.CASCADE,
related_name="articles_authored",
)
panels = [
PageChooserPanel(
"author",
["contact.Person", "contact.Meeting", "contact.Organization"],
),
]
class Meta:
verbose_name = "Page"
verbose_name_plural = "Pages"
class ArchiveArticleAuthor(Orderable):
article = ParentalKey(
"magazine.ArchiveArticle",
null=True,
on_delete=models.CASCADE,
related_name="archive_authors",
)
author = models.ForeignKey(
"wagtailcore.Page",
null=True,
on_delete=models.CASCADE,
related_name="archive_articles_authored",
)
panels = [
PageChooserPanel(
"author",
["contact.Person", "contact.Meeting", "contact.Organization"],
),
]
class Meta:
unique_together = ("article", "author")
class ArchiveArticle(ClusterableModel):
title = models.CharField(max_length=255)
issue = ParentalKey(
"magazine.ArchiveIssue",
null=True,
on_delete=models.CASCADE,
related_name="archive_articles",
)
# We record two page numbers
# since the original documents used various page numbering schemes over time
# and the PDF page number may differ from the original document
toc_page_number = models.PositiveIntegerField(
help_text="Page number as it appears the original document",
default=1,
)
pdf_page_number = models.PositiveIntegerField(
help_text="Page in the actual PDF file",
default=1,
)
drupal_node_id = models.PositiveIntegerField(null=True, blank=True)
panels = [
FieldPanel("title", classname="full"),
FieldRowPanel(
[
FieldPanel("toc_page_number"),
FieldPanel("pdf_page_number"),
],
heading="Page numbers",
),
HelpPanel(
content="Add article authors by clicking the '+ Add' button below, if known.", # noqa: E501
),
NestedInlinePanel(
"archive_authors",
heading="Authors",
help_text="Select one or more authors who contributed to this article",
),
]
class Meta:
indexes = [
models.Index(fields=["drupal_node_id"]),
]
class ArchiveIssue(DrupalFields, Page): # type: ignore
publication_date = models.DateField(
null=True,
help_text="Please select the first day of the publication month",
)
internet_archive_identifier = models.CharField(
max_length=255,
db_index=True,
help_text="Identifier for Internet Archive item.",
unique=True,
)
western_friend_volume = models.CharField(
max_length=255,
help_text="Related Western Friend volume.",
null=True,
blank=True,
)
content_panels = Page.content_panels + [
FieldPanel("publication_date", widget=DatePickerInput()),
FieldPanel("internet_archive_identifier"),
FieldPanel("western_friend_volume"),
InlinePanel(
"archive_articles",
heading="Table of contents",
help_text="Add articles to the table of contents by clicking the '+ Add' button below", # noqa: E501
),
]
parent_page_types = ["DeepArchiveIndexPage"]
subpage_types: list[str] = []
class Meta:
indexes = [
models.Index(fields=["internet_archive_identifier"]),
]
class DeepArchiveIndexPage(Page):
intro = RichTextField(blank=True)
content_panels = Page.content_panels + [FieldPanel("intro")]
max_count = 1
parent_page_types = ["MagazineIndexPage"]
subpage_types: list[str] = ["ArchiveIssue"]
def get_publication_years(self) -> list[int]:
publication_dates = ArchiveIssue.objects.dates("publication_date", "year")
return [publication_date.year for publication_date in publication_dates]
def get_filtered_archive_issues(
self,
query: dict[str, str],
) -> QuerySet[ArchiveIssue]:
# Filter out any facet that isn't a model field
allowed_keys = [
"publication_date__year",
]
facets = {
f"{key}__icontains": query[key] for key in query if key in allowed_keys
}
return ArchiveIssue.objects.all().filter(**facets)
def get_context(
self,
request: HttpRequest,
*args: tuple,
**kwargs: dict,
) -> dict:
context = super().get_context(request, *args, **kwargs)
query = request.GET.dict()
archive_issues = self.get_filtered_archive_issues(
query=query, # type: ignore[arg-type]
)
page = request.GET.get("page", "1")
items_per_page = 12
context["archive_issues"] = get_paginated_items(
items=archive_issues,
items_per_page=items_per_page,
page_number=page,
)
# Add publication years to context, for select menu
context["publication_years"] = self.get_publication_years()
return context