326 lines
11 KiB
Python
326 lines
11 KiB
Python
|
import collections
|
||
|
import math
|
||
|
import operator
|
||
|
|
||
|
from django.conf import settings
|
||
|
from django.db import models
|
||
|
from django.urls import reverse
|
||
|
from django.utils.html import strip_tags
|
||
|
|
||
|
from imagekit.models import ImageSpecField, ProcessedImageField
|
||
|
from imagekit.processors import ResizeToFill
|
||
|
from martor.models import MartorField
|
||
|
from martor.utils import markdownify
|
||
|
|
||
|
|
||
|
class Category(models.Model):
|
||
|
name = models.CharField(max_length=20)
|
||
|
slug = models.SlugField(max_length=20)
|
||
|
description = models.TextField()
|
||
|
content = MartorField()
|
||
|
formatted_content = models.TextField(editable=False)
|
||
|
tag_name = models.CharField(
|
||
|
max_length=12,
|
||
|
help_text="Singular word for what the tags refer to (e.g., theme)"
|
||
|
)
|
||
|
about_page = models.ForeignKey('cms.Page', on_delete=models.PROTECT, blank=True, null=True)
|
||
|
order_on_homepage = models.PositiveIntegerField(default=0)
|
||
|
icon = models.CharField(max_length=10,
|
||
|
help_text="Semantic UI icon used for the label on the article page")
|
||
|
archive_link_text = models.CharField(
|
||
|
max_length=20,
|
||
|
help_text="The text shown on the homepage for linking to this archive"
|
||
|
)
|
||
|
|
||
|
class Meta:
|
||
|
verbose_name_plural = 'categories'
|
||
|
ordering = ['order_on_homepage']
|
||
|
|
||
|
def get_latest_article(self):
|
||
|
return self.articles.filter(published=True).latest()
|
||
|
|
||
|
def get_articles(self):
|
||
|
return self.articles.filter(published=True).order_by('featured__order_on_homepage')
|
||
|
|
||
|
def get_absolute_url(self):
|
||
|
return reverse('category', args=[self.slug])
|
||
|
|
||
|
def save(self, *args, **kwargs):
|
||
|
# Parse markdown and cache it.
|
||
|
self.formatted_content = markdownify(self.content)
|
||
|
super().save(*args, **kwargs)
|
||
|
|
||
|
def __str__(self):
|
||
|
return self.name
|
||
|
|
||
|
|
||
|
class Tag(models.Model):
|
||
|
name = models.CharField(max_length=50)
|
||
|
slug = models.SlugField(max_length=50)
|
||
|
description = models.TextField()
|
||
|
image = ProcessedImageField(
|
||
|
upload_to='tags',
|
||
|
processors=[ResizeToFill(540, 360)],
|
||
|
options={'quality': 100},
|
||
|
blank=True,
|
||
|
null=True,
|
||
|
help_text="Resized to 540x360",
|
||
|
)
|
||
|
category = models.ForeignKey(
|
||
|
Category,
|
||
|
on_delete=models.CASCADE,
|
||
|
related_name='tags', null=True)
|
||
|
|
||
|
class Meta:
|
||
|
ordering = ['name']
|
||
|
|
||
|
def __str__(self):
|
||
|
return self.name
|
||
|
|
||
|
def get_articles(self):
|
||
|
# only show articles whose category matches the tag category
|
||
|
return self.articles.filter(
|
||
|
category=self.category,
|
||
|
published=True
|
||
|
).order_by('-date')
|
||
|
|
||
|
def get_latest_article(self, existing_articles=None):
|
||
|
articles = self.articles.filter(published=True)
|
||
|
if existing_articles:
|
||
|
articles = articles.exclude(pk__in=existing_articles)
|
||
|
|
||
|
if articles.exists():
|
||
|
return articles.latest()
|
||
|
|
||
|
def get_date(self):
|
||
|
latest = self.get_latest_article()
|
||
|
if latest:
|
||
|
return latest.date
|
||
|
|
||
|
def get_absolute_url(self):
|
||
|
return reverse('tag', args=[self.slug])
|
||
|
|
||
|
|
||
|
class Author(models.Model):
|
||
|
name = models.CharField(max_length=100)
|
||
|
bio = MartorField()
|
||
|
formatted_bio = models.TextField(editable=False)
|
||
|
slug = models.SlugField(unique=True)
|
||
|
is_editor = models.BooleanField(default=False)
|
||
|
twitter = models.CharField(max_length=15, blank=True, help_text='Username without the @')
|
||
|
|
||
|
def __str__(self):
|
||
|
return self.name
|
||
|
|
||
|
def get_articles(self):
|
||
|
return self.articles.filter(published=True).order_by('-date')
|
||
|
|
||
|
def get_absolute_url(self):
|
||
|
return reverse('author', args=[self.slug])
|
||
|
|
||
|
def save(self, *args, **kwargs):
|
||
|
# Parse markdown and cache it.
|
||
|
self.formatted_bio = markdownify(self.bio)
|
||
|
super().save(*args, **kwargs)
|
||
|
|
||
|
|
||
|
class Issue(models.Model):
|
||
|
number = models.PositiveSmallIntegerField(unique=True)
|
||
|
title = models.CharField(max_length=50)
|
||
|
date = models.DateField(help_text='Day ignored')
|
||
|
slug = models.SlugField()
|
||
|
image = ProcessedImageField(
|
||
|
upload_to='issues',
|
||
|
processors=[ResizeToFill(540, 360)],
|
||
|
options={'quality': 100},
|
||
|
help_text='Cropped to 540x360'
|
||
|
)
|
||
|
content = MartorField()
|
||
|
formatted_content = models.TextField(editable=False)
|
||
|
published = models.BooleanField(default=False)
|
||
|
|
||
|
class Meta:
|
||
|
get_latest_by = 'date'
|
||
|
ordering = ['-date']
|
||
|
|
||
|
def save(self, *args, **kwargs):
|
||
|
self.formatted_content = markdownify(self.content)
|
||
|
super().save(*args, **kwargs)
|
||
|
|
||
|
def get_articles(self):
|
||
|
# If this issue isn't published, just return all the articles.
|
||
|
if self.published:
|
||
|
return self.articles.filter(published=True)
|
||
|
else:
|
||
|
return self.articles.all()
|
||
|
|
||
|
# Use h2 or h3 in footer depending on the length of the title.
|
||
|
def get_title_header(self):
|
||
|
if len(self.title) > 30:
|
||
|
return 'h3'
|
||
|
else:
|
||
|
return 'h2'
|
||
|
|
||
|
def get_absolute_url(self):
|
||
|
return reverse('issue', args=[self.slug])
|
||
|
|
||
|
def __str__(self):
|
||
|
return self.title
|
||
|
|
||
|
|
||
|
class Article(models.Model):
|
||
|
category = models.ForeignKey(Category, on_delete=models.CASCADE,
|
||
|
related_name='articles')
|
||
|
title = models.CharField(max_length=255)
|
||
|
slug = models.SlugField(unique=True)
|
||
|
authors = models.ManyToManyField(Author, related_name='articles',
|
||
|
blank=True)
|
||
|
tags = models.ManyToManyField(Tag, related_name='articles', blank=True)
|
||
|
subtitle = models.TextField(blank=True)
|
||
|
content = MartorField()
|
||
|
formatted_content = models.TextField(editable=False)
|
||
|
# Store the formatted_content field with all tags removed (for related)
|
||
|
unformatted_content = models.TextField(editable=False)
|
||
|
date = models.DateField()
|
||
|
issue = models.ForeignKey(Issue, related_name='articles', null=True,
|
||
|
blank=True, on_delete=models.CASCADE)
|
||
|
order_in_issue = models.PositiveIntegerField(default=0)
|
||
|
image = ProcessedImageField(
|
||
|
upload_to='articles',
|
||
|
processors=[ResizeToFill(540, 360)],
|
||
|
format='JPEG',
|
||
|
options={'quality': 100},
|
||
|
help_text="Resized to 540x360.",
|
||
|
blank=True
|
||
|
)
|
||
|
image_credit = models.URLField(blank=True)
|
||
|
related_1 = models.ForeignKey("self", related_name='related_1_articles',
|
||
|
on_delete=models.CASCADE, blank=True, null=True)
|
||
|
related_2 = models.ForeignKey("self", related_name='related_2_articles',
|
||
|
on_delete=models.CASCADE, blank=True, null=True)
|
||
|
last_modified = models.DateField(auto_now=True)
|
||
|
published = models.BooleanField(default=False)
|
||
|
|
||
|
class Meta:
|
||
|
ordering = ['-date', 'order_in_issue']
|
||
|
get_latest_by = 'date'
|
||
|
|
||
|
def __str__(self):
|
||
|
return self.title
|
||
|
|
||
|
@property
|
||
|
def language(self):
|
||
|
return 'en'
|
||
|
|
||
|
def get_language_display(self):
|
||
|
return 'English'
|
||
|
|
||
|
def get_absolute_url(self):
|
||
|
return reverse('article', args=[self.slug])
|
||
|
|
||
|
def get_word_count(self):
|
||
|
return len(self.unformatted_content.split())
|
||
|
|
||
|
def get_ordered_authors(self):
|
||
|
return sorted(self.authors.all(), key=lambda s: s.name.split()[1] if len(s.name.split()) > 1 else s.name)
|
||
|
|
||
|
def save(self, *args, **kwargs):
|
||
|
# Parse markdown and cache it.
|
||
|
self.formatted_content = markdownify(self.content)
|
||
|
self.unformatted_content = strip_tags(self.formatted_content)
|
||
|
words = self.unformatted_content.split()
|
||
|
|
||
|
# Find the two most similar articles based on cosine similarity. Only
|
||
|
# do this if they're missing!
|
||
|
if not self.related_1 or not self.related_2:
|
||
|
this_counter = collections.Counter(words)
|
||
|
articles = []
|
||
|
for article in Article.objects.exclude(slug=self.slug):
|
||
|
other_counter = collections.Counter(article.unformatted_content.split())
|
||
|
intersection = set(this_counter.keys()) & set(other_counter.keys())
|
||
|
numerator = sum([this_counter[x] * other_counter[x] for x in intersection])
|
||
|
|
||
|
this_sum = sum([v**2 for v in this_counter.values()])
|
||
|
other_sum = sum([v**2 for v in this_counter.values()])
|
||
|
denominator = math.sqrt(this_sum) * math.sqrt(other_sum)
|
||
|
|
||
|
cosine = numerator / denominator if denominator else 0.0
|
||
|
articles.append((cosine, article))
|
||
|
articles.sort(key=operator.itemgetter(0), reverse=True)
|
||
|
|
||
|
if len(articles) > 1:
|
||
|
self.related_1 = articles[0][1]
|
||
|
if len(articles) > 1:
|
||
|
self.related_2 = articles[1][1]
|
||
|
|
||
|
super().save(*args, **kwargs)
|
||
|
|
||
|
# Use h3 or h4 in article thumbnail depending on the length of the title.
|
||
|
def get_title_header(self):
|
||
|
if len(self.title) > 50:
|
||
|
return 'h4'
|
||
|
else:
|
||
|
return 'h3'
|
||
|
|
||
|
def get_related(self):
|
||
|
# Limited to 2. Currently just gets the latest articles.
|
||
|
related = []
|
||
|
if self.related_1:
|
||
|
related.append(self.related_1)
|
||
|
if self.related_2:
|
||
|
related.append(self.related_2)
|
||
|
return related
|
||
|
|
||
|
|
||
|
class FeaturedArticle(models.Model):
|
||
|
"""For featured articles on the homepage. Can be full width or thumbnail."""
|
||
|
article = models.OneToOneField(
|
||
|
Article,
|
||
|
related_name="featured",
|
||
|
on_delete=models.CASCADE,
|
||
|
primary_key=True
|
||
|
)
|
||
|
is_thumb = models.BooleanField(
|
||
|
help_text="Check this if you want the box to be small, rather than taking up the whole container"
|
||
|
)
|
||
|
order_on_homepage = models.PositiveIntegerField(
|
||
|
unique=True,
|
||
|
help_text="For determining the order of articles on the homepage. 1, 2, 3, etc. Note that large (non-thumb) articles will always be shown first."
|
||
|
)
|
||
|
|
||
|
def __str__(self):
|
||
|
return self.article.title
|
||
|
|
||
|
class Meta:
|
||
|
ordering = ['order_on_homepage']
|
||
|
|
||
|
|
||
|
class ArticleTranslation(models.Model):
|
||
|
article = models.ForeignKey(Article, on_delete=models.CASCADE,
|
||
|
related_name='translations')
|
||
|
language = models.CharField(max_length=2, choices=settings.LANGUAGES)
|
||
|
title = models.CharField(max_length=255)
|
||
|
subtitle = models.TextField()
|
||
|
content = MartorField()
|
||
|
formatted_content = models.TextField(editable=False)
|
||
|
# Store the formatted_content field with all tags removed (for description)
|
||
|
unformatted_content = models.TextField(editable=False)
|
||
|
# The slug should really have uniqueness checks but, too hard tbh
|
||
|
slug = models.SlugField(max_length=50)
|
||
|
last_modified = models.DateField(auto_now=True)
|
||
|
|
||
|
class Meta:
|
||
|
unique_together = ('article', 'language')
|
||
|
|
||
|
def __str__(self):
|
||
|
return "{}—{}".format(self.article.title, self.get_language_display())
|
||
|
|
||
|
def save(self, *args, **kwargs):
|
||
|
# Parse markdown and cache it.
|
||
|
self.formatted_content = markdownify(self.content)
|
||
|
self.unformatted_content = strip_tags(self.formatted_content)
|
||
|
super().save(*args, **kwargs)
|
||
|
|
||
|
def get_absolute_url(self):
|
||
|
return reverse('article', args=[self.slug])
|