diff --git a/core/migrations/0005_mappage.py b/core/migrations/0005_mappage.py new file mode 100644 index 00000000..ab645871 --- /dev/null +++ b/core/migrations/0005_mappage.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.1 on 2019-10-08 18:47 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailcore', '0041_group_collection_permissions_verbose_name_plural'), + ('core', '0004_archivespage_body'), + ] + + operations = [ + migrations.CreateModel( + name='MapPage', + fields=[ + ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), + ], + options={ + 'abstract': False, + }, + bases=('wagtailcore.page',), + ), + ] diff --git a/core/migrations/0006_mappage_places.py b/core/migrations/0006_mappage_places.py new file mode 100644 index 00000000..03220213 --- /dev/null +++ b/core/migrations/0006_mappage_places.py @@ -0,0 +1,21 @@ +# Generated by Django 2.2.1 on 2019-10-22 21:01 + +from django.db import migrations +import wagtail.core.blocks +import wagtail.core.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0005_mappage'), + ] + + operations = [ + migrations.AddField( + model_name='mappage', + name='places', + field=wagtail.core.fields.StreamField([('Locations', wagtail.core.blocks.StructBlock([('name', wagtail.core.blocks.RichTextBlock(default='', required=True)), ('address', wagtail.core.blocks.RichTextBlock(default='', required=True)), ('description', wagtail.core.blocks.RichTextBlock(default='', required=True))]))], default=''), + preserve_default=False, + ), + ] diff --git a/core/models.py b/core/models.py index 6e266be7..3cf238b4 100644 --- a/core/models.py +++ b/core/models.py @@ -1,635 +1,649 @@ -import datetime -import logging -import operator - -from bs4 import BeautifulSoup -from django.apps import apps -from django.core.paginator import Paginator -from django.db import models -from django.db.models import F, Max -from django.db.models.signals import pre_delete, pre_save -from django.dispatch import receiver -from django.http import Http404 -from django.shortcuts import render -from django.utils import timezone -from django.utils.functional import cached_property -from django.utils.html import format_html, mark_safe -from django.utils.text import slugify -from wagtail.contrib.routable_page.models import RoutablePageMixin, route -from wagtail.core.blocks import ( - RichTextBlock, - ListBlock, - StructBlock, - URLBlock, - StructValue, - ChoiceBlock, -) -from wagtail.core.fields import RichTextField, StreamField -from wagtail.core.models import Page, Orderable -from wagtail.admin.edit_handlers import ( - FieldPanel, - StreamFieldPanel, - MultiFieldPanel, - InlinePanel, - PageChooserPanel, -) -from wagtail.embeds.embeds import get_embed -from wagtail.embeds.blocks import EmbedBlock -from wagtail.search import index -from wagtail.snippets.blocks import SnippetChooserBlock -from wagtail.snippets.edit_handlers import SnippetChooserPanel -from wagtail.snippets.models import register_snippet -from wagtail.images.blocks import ImageChooserBlock -from wagtail.images.edit_handlers import ImageChooserPanel -from wagtail.images.models import Image, AbstractImage, AbstractRendition -from wagtailautocomplete.edit_handlers import AutocompletePanel -from modelcluster.fields import ParentalKey, ParentalManyToManyField - - -logger = logging.getLogger("pipeline") - - -class StaticPage(Page): - body = RichTextField(blank=True, null=True) - - content_panels = Page.content_panels + [FieldPanel("body")] - - -@register_snippet -class Contributor(index.Indexed, models.Model): - name = models.CharField(max_length=255) - rich_name = RichTextField( - features=["italic"], max_length=255, null=True, blank=True - ) - - search_fields = [index.SearchField("name", partial_match=True)] - - autocomplete_search_field = "name" - - def autocomplete_label(self): - return self.name - - @classmethod - def autocomplete_create(kls: type, value: str): - return kls.objects.create(name=value) - - def get_articles(self): - return ( - ArticlePage.objects.live() - .filter(authors__author=self) - .order_by("-first_published_at") - .all() - ) - - def __str__(self): - return self.name - - -class StaffPage(Page): - first_name = models.CharField(max_length=100) - last_name = models.CharField(max_length=100) - biography = RichTextField(null=True, blank=True) - photo = models.ForeignKey( - "CustomImage", null=True, blank=True, on_delete=models.PROTECT - ) - email_address = models.EmailField(null=True, blank=True) - contributor = models.OneToOneField( - Contributor, - on_delete=models.PROTECT, - null=True, - blank=True, - related_name="staff_page", - ) - - content_panels = [ - MultiFieldPanel( - [FieldPanel("first_name"), FieldPanel("last_name")], heading="Name" - ), - FieldPanel("email_address"), - FieldPanel("biography"), - ImageChooserPanel("photo"), - InlinePanel("terms", label="Term", heading="Terms", min_num=0), - SnippetChooserPanel("contributor"), - ] - - search_fields = [index.SearchField("first_name"), index.SearchField("last_name")] - - parent_page_types = ["StaffIndexPage"] - subpage_types = [] - - @property - def name(self): - return self.title - - @cached_property - def is_active(self): - return self.terms.filter( - date_ended__isnull=True, position__isnull=False - ).exists() - - def clean(self): - super().clean() - self.title = f"{self.first_name} {self.last_name}" - - def get_articles(self): - return ( - ArticlePage.objects.live() - .filter(authors__author=self.contributor) - .order_by("-first_published_at") - .all() - ) - - def get_positions_html(self): - terms = self.current_terms - builder = "" - for i, term in enumerate(terms): - if i == len(terms) - 1 and len(terms) > 1: - builder += " and " - - position_prefix = "" - if term.acting: - if i == 0: - position_prefix += "Acting " - else: - position_prefix += "acting " - elif term.de_facto: - if i == 0: - position_prefix += "De facto " - else: - position_prefix += "de facto " - - builder += mark_safe(position_prefix) + term.position.title - if i < len(terms) - 1 and len(terms) > 2: - builder += ", " - - return mark_safe(builder) - - @cached_property - def current_terms(self): - return [term for term in self.terms.filter(date_ended__isnull=True)] - - @cached_property - def get_active_positions(self): - return [term.position for term in self.terms.all() if term.date_ended is None] - - @cached_property - def get_previous_terms(self): - return [term for term in self.terms.filter(date_ended__isnull=False)] - - -class StaffIndexPage(Page): - subpage_types = ["StaffPage"] - - def get_active_staff(self): - return ( - StaffPage.objects.live() - .descendant_of(self) - .filter(terms__position__isnull=False, terms__date_ended__isnull=True) - .select_related("photo") - .prefetch_related("terms__position") - .distinct() - ) - - def get_previous_staff(self): - return ( - StaffPage.objects.live() - .descendant_of(self) - .exclude(terms__date_ended__isnull=True, terms__position__isnull=False) - .annotate(latest_term_ended=Max("terms__date_ended")) - .order_by(F("latest_term_ended").desc(nulls_last=True)) - ) - - -@register_snippet -class Position(models.Model): - title = models.CharField(max_length=100) - - def __str__(self): - return self.title - - -@register_snippet -class Term(Orderable, models.Model): - position = models.ForeignKey( - Position, on_delete=models.PROTECT, related_name="terms" - ) - person = ParentalKey(StaffPage, on_delete=models.PROTECT, related_name="terms") - date_started = models.DateField(blank=True, null=True) - date_ended = models.DateField(blank=True, null=True) - acting = models.BooleanField(default=False) - de_facto = models.BooleanField(default=False) - - def __str__(self): - return f'{self.position.title} ({self.date_started}—{self.date_ended or "now"})' - - -# https://docs.wagtail.io/en/v2.2.2/advanced_topics/images/custom_image_model.html -class CustomImage(AbstractImage): - photographer = models.ForeignKey( - Contributor, - on_delete=models.CASCADE, - related_name="images", - blank=True, - null=True, - ) - - admin_form_fields = Image.admin_form_fields + ("photographer",) - - def get_attribution_html(self): - if self.photographer is None: - return "" - - if hasattr(self.photographer, "staff_page"): - sp = self.photographer.staff_page - return format_html( - '{}/The Polytechnic', sp.url, sp.name - ) - - return self.photographer.name - - -# Delete the source image file when an image is deleted -@receiver(pre_delete, sender=CustomImage) -def image_delete(sender, instance, **kwargs): - instance.file.delete(False) - - -# Do feature detection when a user saves an image without a focal point -@receiver(pre_save, sender=CustomImage) -def image_feature_detection(sender, instance, **kwargs): - # Make sure the image doesn't already have a focal point - if not instance.has_focal_point(): - # Set the focal point - instance.set_focal_point(instance.get_suggested_focal_point()) - - -class CustomRendition(AbstractRendition): - image = models.ForeignKey( - CustomImage, on_delete=models.CASCADE, related_name="renditions" - ) - - class Meta: - unique_together = ("image", "filter_spec", "focal_point_key") - - -# Delete the rendition image file when a rendition is deleted -# TODO: test if this actually works -@receiver(pre_delete, sender=CustomRendition) -def rendition_delete(sender, instance, **kwargs): - instance.file.delete(False) - - -@register_snippet -class Kicker(models.Model): - title = models.CharField(max_length=255) - - @classmethod - def autocomplete_create(kls: type, value: str): - return kls.objects.create(title=value) - - def __str__(self): - return self.title - - -class EmbeddedMediaValue(StructValue): - def type(self): - embed_url = self.get("embed").url - embed = get_embed(embed_url) - return embed.type - - -class EmbeddedMediaBlock(StructBlock): - embed = EmbedBlock(help_text="URL to the content to embed.") - - class Meta: - value_class = EmbeddedMediaValue - icon = "media" - - -class PhotoBlock(StructBlock): - image = ImageChooserBlock() - caption = RichTextBlock(features=["italic"], required=False) - size = ChoiceBlock( - choices=[("small", "Small"), ("medium", "Medium"), ("large", "Large")], - default="medium", - help_text="Width of image in article.", - ) - - class Meta: - icon = "image" - - -class AdBlock(StructBlock): - image = ImageChooserBlock(help_text="Image should be 22:7") - link = URLBlock(label="target", required=False) - - class Meta: - icon = "image" - - -class MarqueeBlock(StructBlock): - body = RichTextBlock(required=True) - banner_type = ChoiceBlock( - choices=[("moves", "Rotating")], # can add stationary later - default="moves", - help_text="Determines whether the marquee banner is stationary or rotating. Only rotating works right now.", - required=True, - ) - - -class GalleryPhotoBlock(StructBlock): - image = ImageChooserBlock() - caption = RichTextBlock(features=["italic"], required=False) - - -class ArticlePage(RoutablePageMixin, Page): - headline = RichTextField(features=["italic"]) - subdeck = RichTextField(features=["italic"], null=True, blank=True) - kicker = models.ForeignKey(Kicker, null=True, blank=True, on_delete=models.PROTECT) - body = StreamField( - [ - ("paragraph", RichTextBlock()), - ("photo", PhotoBlock()), - ("photo_gallery", ListBlock(GalleryPhotoBlock(), icon="image")), - ("embed", EmbeddedMediaBlock()), - ], - blank=True, - ) - summary = RichTextField( - features=["italic"], - null=True, - blank=True, - help_text="Displayed on the home page or other places to provide a taste of what the article is about.", - ) - featured_image = models.ForeignKey( - CustomImage, - null=True, - blank=True, - on_delete=models.PROTECT, - help_text="Shown at the top of the article and on the home page.", - ) - featured_caption = RichTextField(features=["italic"], blank=True, null=True) - - content_panels = [ - MultiFieldPanel( - [FieldPanel("headline", classname="title"), FieldPanel("subdeck")] - ), - MultiFieldPanel( - [ - InlinePanel( - "authors", - panels=[ - AutocompletePanel("author", target_model="core.Contributor") - ], - label="Author", - ), - AutocompletePanel("kicker", target_model="core.Kicker"), - ImageChooserPanel("featured_image"), - FieldPanel("featured_caption"), - ], - heading="Metadata", - classname="collapsible", - ), - FieldPanel("summary"), - StreamFieldPanel("body"), - ] - - search_fields = Page.search_fields + [ - index.SearchField("headline"), - index.SearchField("subdeck"), - index.SearchField("body"), - index.SearchField("summary"), - index.RelatedFields("kicker", [index.SearchField("title")]), - index.SearchField("get_author_names", partial_match=True), - ] - - subpage_types = [] - - def clean(self): - super().clean() - - soup = BeautifulSoup(self.headline, "html.parser") - self.title = soup.text - - @route(r"^$") - def post_404(self, request): - """Return an HTTP 404 whenever the page is accessed directly. - - This is because it should instead by accessed by its date-based path, - i.e. `///`.""" - raise Http404 - - def set_url_path(self, parent): - """Make sure the page knows its own path. The published date might not be set, - so we have to take that into account and ignore it if so.""" - date = self.get_published_date() or timezone.now() - self.url_path = f"{parent.url_path}{date.year}/{date.month:02d}/{self.slug}/" - return self.url_path - - def serve_preview(self, request, mode_name): - request.is_preview = True - return self.serve(request) - - def get_context(self, request): - context = super().get_context(request) - context["authors"] = self.get_authors() - return context - - def get_authors(self): - return [r.author for r in self.authors.select_related("author")] - - def get_author_names(self): - return [a.name for a in self.get_authors()] - - def get_published_date(self): - return ( - self.go_live_at - or self.first_published_at - or getattr(self.get_latest_revision(), "created_at", None) - ) - - def get_text_html(self): - """Get the HTML that represents paragraphs within the article as a string.""" - builder = "" - for block in self.body: - if block.block_type == "paragraph": - builder += str(block.value) - return builder - - def get_plain_text(self): - builder = "" - soup = BeautifulSoup(self.get_text_html(), "html.parser") - for para in soup.findAll("p"): - builder += para.text - builder += " " - return builder[:-1] - - def get_related_articles(self): - found_articles = [] - related_articles = [] - current_article_text = self.get_plain_text() - if current_article_text is not None: - current_article_words = set(current_article_text.split(" ")) - authors = self.get_authors() - for author in authors: - articles = author.get_articles() - for article in articles: - if article.headline != self.headline: - text_to_match = article.get_plain_text() - article_words = set(text_to_match.split(" ")) - found_articles.append( - ( - article, - len( - list( - current_article_words.intersection( - article_words - ) - ) - ), - ) - ) - found_articles.sort(key=operator.itemgetter(1), reverse=True) - for i in range(min(4, len(found_articles))): - related_articles.append(found_articles[i][0]) - return related_articles - - def get_first_chars(self, n=100): - """Convert the body to HTML, extract the text, and then build - a string out of it until we have at least n characters. - If this isn't possible, then return None.""" - - text = self.get_plain_text() - if len(text) < n: - return None - - punctuation = {".", "!"} - for i in range(n, len(text)): - if text[i] in punctuation: - if i + 1 == len(text): - return text - elif text[i + 1] == " ": - return text[: i + 1] - - return None - - def get_meta_tags(self): - tags = {} - tags["og:type"] = "article" - tags["og:title"] = self.title - tags["og:url"] = self.full_url - tags["og:site_name"] = self.get_site().site_name - - # description: either the article's summary or first paragraph - if self.summary is not None: - soup = BeautifulSoup(self.summary, "html.parser") - tags["og:description"] = soup.get_text() - tags["twitter:description"] = soup.get_text() - else: - first_chars = self.get_first_chars() - if first_chars is not None: - tags["og:description"] = first_chars - tags["twitter:description"] = first_chars - - # image - if self.featured_image is not None: - # pylint: disable=E1101 - rendition = self.featured_image.get_rendition("min-1200x1200") - rendition_url = self.get_site().root_url + rendition.url - tags["og:image"] = rendition_url - tags["twitter:image"] = rendition_url - else: - tags["og:image"] = ( - self.get_site().root_url + "/static/images/minimal_logo_tag_padding.png" - ) - tags["twitter:image"] = ( - self.get_site().root_url + "static/images/minimal_logo_tag_padding.png" - ) - - tags["twitter:site"] = "@rpipoly" - tags["twitter:title"] = self.title - if "twitter:description" in tags and "twitter:image" in tags: - tags["twitter:card"] = "summary_large_image" - else: - tags["twitter:card"] = "summary" - - return tags - - -class ArticlesIndexPage(RoutablePageMixin, Page): - subpage_types = ["ArticlePage"] - - @route(r"^(\d{4})\/(\d{2})\/(.*)\/$") - def post_by_date(self, request, year, month, slug, *args, **kwargs): - try: - page = ArticlePage.objects.live().get( - slug=slug, - first_published_at__year=year, - first_published_at__month=month, - ) - except ArticlePage.DoesNotExist: - raise Http404 - return page.serve(request, *args, **kwargs) - - def get_articles(self): - return ( - ArticlePage.objects.live() - .descendant_of(self) - .order_by("-first_published_at") - .select_related("kicker", "featured_image") - ) - - def get_context(self, request): - context = super().get_context(request) - paginator = Paginator(self.get_articles(), 24) - page = request.GET.get("page") - context["articles"] = paginator.get_page(page) - return context - - -class ArticleAuthorRelationship(models.Model): - article = ParentalKey(ArticlePage, related_name="authors", on_delete=models.CASCADE) - author = models.ForeignKey( - Contributor, related_name="articles", on_delete=models.PROTECT - ) - - panels = [SnippetChooserPanel("author")] - - class Meta: - unique_together = [("article", "author")] - - -class ArchivesPage(RoutablePageMixin, Page): - body = RichTextField(blank=True, null=True) - content_panels = Page.content_panels + [FieldPanel("body")] - subpage_types = [] - - @route(r"(\d{4})/(\d{2})/$") - def by_year_month(self, request, year, month, *args, **kwargs): - articles = ( - ArticlePage.objects.filter( - first_published_at__year=year, first_published_at__month=month - ) - .order_by("-first_published_at") - .select_related("kicker", "featured_image") - ) - - if len(articles) == 0: - raise Http404 - - date = datetime.datetime(int(year), int(month), 1) - context = super().get_context(request) - context["articles"] = articles - context["date"] = date - return render(request, "core/archives_page_list.html", context) - - def get_months(self): - return ArticlePage.objects.live().dates("first_published_at", "month") - - -class MigrationInformation(models.Model): - """Contains information about articles migrated from WordPress posts at poly.rpi.edu.""" - - article = models.OneToOneField(ArticlePage, on_delete=models.CASCADE) - link = models.URLField(db_index=True) - guid = models.CharField(max_length=255, primary_key=True) +import datetime +import logging +import operator + +from bs4 import BeautifulSoup +from django.apps import apps +from django.core.paginator import Paginator +from django.db import models +from django.db.models import F, Max +from django.db.models.signals import pre_delete, pre_save +from django.dispatch import receiver +from django.http import Http404 +from django.shortcuts import render +from django.utils import timezone +from django.utils.functional import cached_property +from django.utils.html import format_html, mark_safe +from django.utils.text import slugify +from wagtail.contrib.routable_page.models import RoutablePageMixin, route +from wagtail.core.blocks import ( + RichTextBlock, + ListBlock, + StructBlock, + URLBlock, + StructValue, + ChoiceBlock, +) +from wagtail.core.fields import RichTextField, StreamField +from wagtail.core.models import Page, Orderable +from wagtail.admin.edit_handlers import ( + FieldPanel, + StreamFieldPanel, + MultiFieldPanel, + InlinePanel, + PageChooserPanel, +) +from wagtail.embeds.embeds import get_embed +from wagtail.embeds.blocks import EmbedBlock +from wagtail.search import index +from wagtail.snippets.blocks import SnippetChooserBlock +from wagtail.snippets.edit_handlers import SnippetChooserPanel +from wagtail.snippets.models import register_snippet +from wagtail.images.blocks import ImageChooserBlock +from wagtail.images.edit_handlers import ImageChooserPanel +from wagtail.images.models import Image, AbstractImage, AbstractRendition +from wagtailautocomplete.edit_handlers import AutocompletePanel +from modelcluster.fields import ParentalKey, ParentalManyToManyField + + +logger = logging.getLogger("pipeline") + + +class StaticPage(Page): + body = RichTextField(blank=True, null=True) + + content_panels = Page.content_panels + [FieldPanel("body")] + + +@register_snippet +class Contributor(index.Indexed, models.Model): + name = models.CharField(max_length=255) + rich_name = RichTextField( + features=["italic"], max_length=255, null=True, blank=True + ) + + search_fields = [index.SearchField("name", partial_match=True)] + + autocomplete_search_field = "name" + + def autocomplete_label(self): + return self.name + + @classmethod + def autocomplete_create(kls: type, value: str): + return kls.objects.create(name=value) + + def get_articles(self): + return ( + ArticlePage.objects.live() + .filter(authors__author=self) + .order_by("-first_published_at") + .all() + ) + + def __str__(self): + return self.name + + +class StaffPage(Page): + first_name = models.CharField(max_length=100) + last_name = models.CharField(max_length=100) + biography = RichTextField(null=True, blank=True) + photo = models.ForeignKey( + "CustomImage", null=True, blank=True, on_delete=models.PROTECT + ) + email_address = models.EmailField(null=True, blank=True) + contributor = models.OneToOneField( + Contributor, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="staff_page", + ) + + content_panels = [ + MultiFieldPanel( + [FieldPanel("first_name"), FieldPanel("last_name")], heading="Name" + ), + FieldPanel("email_address"), + FieldPanel("biography"), + ImageChooserPanel("photo"), + InlinePanel("terms", label="Term", heading="Terms", min_num=0), + SnippetChooserPanel("contributor"), + ] + + search_fields = [index.SearchField("first_name"), index.SearchField("last_name")] + + parent_page_types = ["StaffIndexPage"] + subpage_types = [] + + @property + def name(self): + return self.title + + @cached_property + def is_active(self): + return self.terms.filter( + date_ended__isnull=True, position__isnull=False + ).exists() + + def clean(self): + super().clean() + self.title = f"{self.first_name} {self.last_name}" + + def get_articles(self): + return ( + ArticlePage.objects.live() + .filter(authors__author=self.contributor) + .order_by("-first_published_at") + .all() + ) + + def get_positions_html(self): + terms = self.current_terms + builder = "" + for i, term in enumerate(terms): + if i == len(terms) - 1 and len(terms) > 1: + builder += " and " + + position_prefix = "" + if term.acting: + if i == 0: + position_prefix += "Acting " + else: + position_prefix += "acting " + elif term.de_facto: + if i == 0: + position_prefix += "De facto " + else: + position_prefix += "de facto " + + builder += mark_safe(position_prefix) + term.position.title + if i < len(terms) - 1 and len(terms) > 2: + builder += ", " + + return mark_safe(builder) + + @cached_property + def current_terms(self): + return [term for term in self.terms.filter(date_ended__isnull=True)] + + @cached_property + def get_active_positions(self): + return [term.position for term in self.terms.all() if term.date_ended is None] + + @cached_property + def get_previous_terms(self): + return [term for term in self.terms.filter(date_ended__isnull=False)] + +class Location(StructBlock): + latitude = RichTextBlock(required = True, default ="") + longitude = RichTextBlock(required = True, default ="") + description = RichTextBlock(required = True, default ="") + + +class MapPage(Page): + places = StreamField([("Locations", Location())]) + content_panels = Page.content_panels + [StreamFieldPanel("places")] + def get_context(self, request): + context = super().get_context(request) + return context + + + +class StaffIndexPage(Page): + subpage_types = ["StaffPage"] + + def get_active_staff(self): + return ( + StaffPage.objects.live() + .descendant_of(self) + .filter(terms__position__isnull=False, terms__date_ended__isnull=True) + .select_related("photo") + .prefetch_related("terms__position") + .distinct() + ) + + def get_previous_staff(self): + return ( + StaffPage.objects.live() + .descendant_of(self) + .exclude(terms__date_ended__isnull=True, terms__position__isnull=False) + .annotate(latest_term_ended=Max("terms__date_ended")) + .order_by(F("latest_term_ended").desc(nulls_last=True)) + ) + + +@register_snippet +class Position(models.Model): + title = models.CharField(max_length=100) + + def __str__(self): + return self.title + + +@register_snippet +class Term(Orderable, models.Model): + position = models.ForeignKey( + Position, on_delete=models.PROTECT, related_name="terms" + ) + person = ParentalKey(StaffPage, on_delete=models.PROTECT, related_name="terms") + date_started = models.DateField(blank=True, null=True) + date_ended = models.DateField(blank=True, null=True) + acting = models.BooleanField(default=False) + de_facto = models.BooleanField(default=False) + + def __str__(self): + return f'{self.position.title} ({self.date_started}—{self.date_ended or "now"})' + + +# https://docs.wagtail.io/en/v2.2.2/advanced_topics/images/custom_image_model.html +class CustomImage(AbstractImage): + photographer = models.ForeignKey( + Contributor, + on_delete=models.CASCADE, + related_name="images", + blank=True, + null=True, + ) + + admin_form_fields = Image.admin_form_fields + ("photographer",) + + def get_attribution_html(self): + if self.photographer is None: + return "" + + if hasattr(self.photographer, "staff_page"): + sp = self.photographer.staff_page + return format_html( + '{}/The Polytechnic', sp.url, sp.name + ) + + return self.photographer.name + + +# Delete the source image file when an image is deleted +@receiver(pre_delete, sender=CustomImage) +def image_delete(sender, instance, **kwargs): + instance.file.delete(False) + + +# Do feature detection when a user saves an image without a focal point +@receiver(pre_save, sender=CustomImage) +def image_feature_detection(sender, instance, **kwargs): + # Make sure the image doesn't already have a focal point + if not instance.has_focal_point(): + # Set the focal point + instance.set_focal_point(instance.get_suggested_focal_point()) + + +class CustomRendition(AbstractRendition): + image = models.ForeignKey( + CustomImage, on_delete=models.CASCADE, related_name="renditions" + ) + + class Meta: + unique_together = ("image", "filter_spec", "focal_point_key") + + +# Delete the rendition image file when a rendition is deleted +# TODO: test if this actually works +@receiver(pre_delete, sender=CustomRendition) +def rendition_delete(sender, instance, **kwargs): + instance.file.delete(False) + + +@register_snippet +class Kicker(models.Model): + title = models.CharField(max_length=255) + + @classmethod + def autocomplete_create(kls: type, value: str): + return kls.objects.create(title=value) + + def __str__(self): + return self.title + + +class EmbeddedMediaValue(StructValue): + def type(self): + embed_url = self.get("embed").url + embed = get_embed(embed_url) + return embed.type + + +class EmbeddedMediaBlock(StructBlock): + embed = EmbedBlock(help_text="URL to the content to embed.") + + class Meta: + value_class = EmbeddedMediaValue + icon = "media" + + +class PhotoBlock(StructBlock): + image = ImageChooserBlock() + caption = RichTextBlock(features=["italic"], required=False) + size = ChoiceBlock( + choices=[("small", "Small"), ("medium", "Medium"), ("large", "Large")], + default="medium", + help_text="Width of image in article.", + ) + + class Meta: + icon = "image" + + +class AdBlock(StructBlock): + image = ImageChooserBlock(help_text="Image should be 22:7") + link = URLBlock(label="target", required=False) + + class Meta: + icon = "image" + + +class MarqueeBlock(StructBlock): + body = RichTextBlock(required=True) + banner_type = ChoiceBlock( + choices=[("moves", "Rotating")], # can add stationary later + default="moves", + help_text="Determines whether the marquee banner is stationary or rotating. Only rotating works right now.", + required=True, + ) + + +class GalleryPhotoBlock(StructBlock): + image = ImageChooserBlock() + caption = RichTextBlock(features=["italic"], required=False) + + +class ArticlePage(RoutablePageMixin, Page): + headline = RichTextField(features=["italic"]) + subdeck = RichTextField(features=["italic"], null=True, blank=True) + kicker = models.ForeignKey(Kicker, null=True, blank=True, on_delete=models.PROTECT) + body = StreamField( + [ + ("paragraph", RichTextBlock()), + ("photo", PhotoBlock()), + ("photo_gallery", ListBlock(GalleryPhotoBlock(), icon="image")), + ("embed", EmbeddedMediaBlock()), + ], + blank=True, + ) + summary = RichTextField( + features=["italic"], + null=True, + blank=True, + help_text="Displayed on the home page or other places to provide a taste of what the article is about.", + ) + featured_image = models.ForeignKey( + CustomImage, + null=True, + blank=True, + on_delete=models.PROTECT, + help_text="Shown at the top of the article and on the home page.", + ) + featured_caption = RichTextField(features=["italic"], blank=True, null=True) + + content_panels = [ + MultiFieldPanel( + [FieldPanel("headline", classname="title"), FieldPanel("subdeck")] + ), + MultiFieldPanel( + [ + InlinePanel( + "authors", + panels=[ + AutocompletePanel("author", target_model="core.Contributor") + ], + label="Author", + ), + AutocompletePanel("kicker", target_model="core.Kicker"), + ImageChooserPanel("featured_image"), + FieldPanel("featured_caption"), + ], + heading="Metadata", + classname="collapsible", + ), + FieldPanel("summary"), + StreamFieldPanel("body"), + ] + + search_fields = Page.search_fields + [ + index.SearchField("headline"), + index.SearchField("subdeck"), + index.SearchField("body"), + index.SearchField("summary"), + index.RelatedFields("kicker", [index.SearchField("title")]), + index.SearchField("get_author_names", partial_match=True), + ] + + subpage_types = [] + + def clean(self): + super().clean() + + soup = BeautifulSoup(self.headline, "html.parser") + self.title = soup.text + + @route(r"^$") + def post_404(self, request): + """Return an HTTP 404 whenever the page is accessed directly. + + This is because it should instead by accessed by its date-based path, + i.e. `///`.""" + raise Http404 + + def set_url_path(self, parent): + """Make sure the page knows its own path. The published date might not be set, + so we have to take that into account and ignore it if so.""" + date = self.get_published_date() or timezone.now() + self.url_path = f"{parent.url_path}{date.year}/{date.month:02d}/{self.slug}/" + return self.url_path + + def serve_preview(self, request, mode_name): + request.is_preview = True + return self.serve(request) + + def get_context(self, request): + context = super().get_context(request) + context["authors"] = self.get_authors() + return context + + def get_authors(self): + return [r.author for r in self.authors.select_related("author")] + + def get_author_names(self): + return [a.name for a in self.get_authors()] + + def get_published_date(self): + return ( + self.go_live_at + or self.first_published_at + or getattr(self.get_latest_revision(), "created_at", None) + ) + + def get_text_html(self): + """Get the HTML that represents paragraphs within the article as a string.""" + builder = "" + for block in self.body: + if block.block_type == "paragraph": + builder += str(block.value) + return builder + + def get_plain_text(self): + builder = "" + soup = BeautifulSoup(self.get_text_html(), "html.parser") + for para in soup.findAll("p"): + builder += para.text + builder += " " + return builder[:-1] + + def get_related_articles(self): + found_articles = [] + related_articles = [] + current_article_text = self.get_plain_text() + if current_article_text is not None: + current_article_words = set(current_article_text.split(" ")) + authors = self.get_authors() + for author in authors: + articles = author.get_articles() + for article in articles: + if article.headline != self.headline: + text_to_match = article.get_plain_text() + article_words = set(text_to_match.split(" ")) + found_articles.append( + ( + article, + len( + list( + current_article_words.intersection( + article_words + ) + ) + ), + ) + ) + found_articles.sort(key=operator.itemgetter(1), reverse=True) + for i in range(min(4, len(found_articles))): + related_articles.append(found_articles[i][0]) + return related_articles + + def get_first_chars(self, n=100): + """Convert the body to HTML, extract the text, and then build + a string out of it until we have at least n characters. + If this isn't possible, then return None.""" + + text = self.get_plain_text() + if len(text) < n: + return None + + punctuation = {".", "!"} + for i in range(n, len(text)): + if text[i] in punctuation: + if i + 1 == len(text): + return text + elif text[i + 1] == " ": + return text[: i + 1] + + return None + + def get_meta_tags(self): + tags = {} + tags["og:type"] = "article" + tags["og:title"] = self.title + tags["og:url"] = self.full_url + tags["og:site_name"] = self.get_site().site_name + + # description: either the article's summary or first paragraph + if self.summary is not None: + soup = BeautifulSoup(self.summary, "html.parser") + tags["og:description"] = soup.get_text() + tags["twitter:description"] = soup.get_text() + else: + first_chars = self.get_first_chars() + if first_chars is not None: + tags["og:description"] = first_chars + tags["twitter:description"] = first_chars + + # image + if self.featured_image is not None: + # pylint: disable=E1101 + rendition = self.featured_image.get_rendition("min-1200x1200") + rendition_url = self.get_site().root_url + rendition.url + tags["og:image"] = rendition_url + tags["twitter:image"] = rendition_url + else: + tags["og:image"] = ( + self.get_site().root_url + "/static/images/minimal_logo_tag_padding.png" + ) + tags["twitter:image"] = ( + self.get_site().root_url + "static/images/minimal_logo_tag_padding.png" + ) + + tags["twitter:site"] = "@rpipoly" + tags["twitter:title"] = self.title + if "twitter:description" in tags and "twitter:image" in tags: + tags["twitter:card"] = "summary_large_image" + else: + tags["twitter:card"] = "summary" + + return tags + + +class ArticlesIndexPage(RoutablePageMixin, Page): + subpage_types = ["ArticlePage"] + + @route(r"^(\d{4})\/(\d{2})\/(.*)\/$") + def post_by_date(self, request, year, month, slug, *args, **kwargs): + try: + page = ArticlePage.objects.live().get( + slug=slug, + first_published_at__year=year, + first_published_at__month=month, + ) + except ArticlePage.DoesNotExist: + raise Http404 + return page.serve(request, *args, **kwargs) + + def get_articles(self): + return ( + ArticlePage.objects.live() + .descendant_of(self) + .order_by("-first_published_at") + .select_related("kicker", "featured_image") + ) + + def get_context(self, request): + context = super().get_context(request) + paginator = Paginator(self.get_articles(), 24) + page = request.GET.get("page") + context["articles"] = paginator.get_page(page) + return context + + +class ArticleAuthorRelationship(models.Model): + article = ParentalKey(ArticlePage, related_name="authors", on_delete=models.CASCADE) + author = models.ForeignKey( + Contributor, related_name="articles", on_delete=models.PROTECT + ) + + panels = [SnippetChooserPanel("author")] + + class Meta: + unique_together = [("article", "author")] + + +class ArchivesPage(RoutablePageMixin, Page): + body = RichTextField(blank=True, null=True) + content_panels = Page.content_panels + [FieldPanel("body")] + subpage_types = [] + + @route(r"(\d{4})/(\d{2})/$") + def by_year_month(self, request, year, month, *args, **kwargs): + articles = ( + ArticlePage.objects.filter( + first_published_at__year=year, first_published_at__month=month + ) + .order_by("-first_published_at") + .select_related("kicker", "featured_image") + ) + + if len(articles) == 0: + raise Http404 + + date = datetime.datetime(int(year), int(month), 1) + context = super().get_context(request) + context["articles"] = articles + context["date"] = date + return render(request, "core/archives_page_list.html", context) + + def get_months(self): + return ArticlePage.objects.live().dates("first_published_at", "month") + + +class MigrationInformation(models.Model): + """Contains information about articles migrated from WordPress posts at poly.rpi.edu.""" + + article = models.OneToOneField(ArticlePage, on_delete=models.CASCADE) + link = models.URLField(db_index=True) + guid = models.CharField(max_length=255, primary_key=True) diff --git a/core/templates/core/Map_information.json b/core/templates/core/Map_information.json new file mode 100644 index 00000000..b8af4806 --- /dev/null +++ b/core/templates/core/Map_information.json @@ -0,0 +1,5 @@ +{ + name: "Place1" + address: "Address1" + information: "information1" +} \ No newline at end of file diff --git a/core/templates/core/map_page.html b/core/templates/core/map_page.html new file mode 100644 index 00000000..3d87a56e --- /dev/null +++ b/core/templates/core/map_page.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} + +{% load wagtailcore_tags wagtailimages_tags %} + +{% block body_class %}template-mappage{% endblock %} + +{% block content %} +
+
+ + + + + + + + +
+ + +
+{% endblock %} diff --git a/pipeline.new.backup b/pipeline.new.backup new file mode 100644 index 00000000..6fde36c5 Binary files /dev/null and b/pipeline.new.backup differ diff --git a/pipeline/settings/dev.py b/pipeline/settings/dev.py index a1e2d112..105891e8 100644 --- a/pipeline/settings/dev.py +++ b/pipeline/settings/dev.py @@ -1,53 +1,53 @@ -import logging - -from .base import * - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "9v281(zz#j15vjq5yvnp(zvn=^=e)h@dfe-uj58#9p9+w&o*tm" - -# SECURITY WARNING: define the correct hosts in production! -ALLOWED_HOSTS = ["*"] - -EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" - -MIDDLEWARE = [ - "debug_toolbar.middleware.DebugToolbarMiddleware", - "nplusone.ext.django.NPlusOneMiddleware", -] + MIDDLEWARE - -INSTALLED_APPS = INSTALLED_APPS + ["debug_toolbar", "nplusone.ext.django"] - -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "handlers": {"console": {"class": "logging.StreamHandler", "formatter": "verbose"}}, - "formatters": { - "verbose": {"format": "[{asctime}] {levelname} {name} {message}", "style": "{"} - }, - "loggers": { - "nplusone": {"handlers": ["console"], "level": "WARN"}, - "pipeline": {"handlers": ["console"], "level": "DEBUG"}, - }, -} - - -DATABASES = { - "default": dj_database_url.config( - default="postgresql://postgres:postgres@127.0.0.1:5432/pipeline" - ) -} - - -NPLUSONE_LOGGER = logging.getLogger("nplusone") -NPLUSONE_LOG_LEVEL = logging.WARN - -# Caching. Use dummy cache in development -CACHES = {"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}} - -try: - from .local import * -except ImportError: - pass +import logging + +from .base import * + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "9v281(zz#j15vjq5yvnp(zvn=^=e)h@dfe-uj58#9p9+w&o*tm" + +# SECURITY WARNING: define the correct hosts in production! +ALLOWED_HOSTS = ["*"] + +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + +MIDDLEWARE = [ + "debug_toolbar.middleware.DebugToolbarMiddleware", + "nplusone.ext.django.NPlusOneMiddleware", +] + MIDDLEWARE + +INSTALLED_APPS = INSTALLED_APPS + ["debug_toolbar", "nplusone.ext.django"] + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": {"console": {"class": "logging.StreamHandler", "formatter": "verbose"}}, + "formatters": { + "verbose": {"format": "[{asctime}] {levelname} {name} {message}", "style": "{"} + }, + "loggers": { + "nplusone": {"handlers": ["console"], "level": "WARN"}, + "pipeline": {"handlers": ["console"], "level": "DEBUG"}, + }, +} + + +DATABASES = { + "default": dj_database_url.config( + default="postgresql://postgres:gav228@127.0.0.1:5432/pipeline" + ) +} + + +NPLUSONE_LOGGER = logging.getLogger("nplusone") +NPLUSONE_LOG_LEVEL = logging.WARN + +# Caching. Use dummy cache in development +CACHES = {"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}} + +try: + from .local import * +except ImportError: + pass