Compare commits

...

45 commits

Author SHA1 Message Date
ea73189b9c
Update matrix domain 2024-09-01 16:45:01 +01:00
0bd43a4bee
Update Django 2024-08-06 14:49:29 +01:00
9788b8e4dd
Replace npm-run-all with npm-run-all2 2024-07-28 21:47:36 +01:00
7d4a9647d4
Use native dark mode for comentario 2024-07-28 21:37:04 +01:00
4cfb0a896e
Update fira-code 2024-07-28 21:12:54 +01:00
09dbbc407b
Update HTMX 2024-07-28 21:09:55 +01:00
040647b506
Update lite-youtube-embed 2024-07-28 21:00:41 +01:00
4134426823
Remove deprecated version key 2024-07-28 20:53:40 +01:00
451c2d4cf9
Update django-tasks 2024-07-28 20:45:12 +01:00
5e34485fec
Update Wagtail to 5.2.6 2024-07-28 20:41:08 +01:00
91de1fcf19
Unpin lxml, and use bs4 extra 2024-07-28 20:38:59 +01:00
009b4926a4
Order talks by date 2024-07-11 11:35:55 +01:00
1822f1c4d5
Use LTS version of Django
This is supported for much longer, and I don't seem to be using any of the 5.0 features
2024-07-10 18:40:42 +01:00
a8fe244ab4
Update Django 2024-07-10 17:36:15 +01:00
df84b28114 Update dependency Pygments to v2.18.0 2024-07-05 21:02:53 +01:00
a25e83a0df
Require approval before opening renovate PRs 2024-07-01 23:11:05 +01:00
46669c9b17
Add replacements and workarounds to renovate 2024-07-01 23:07:16 +01:00
41a04af8dc
Fix pickle errors for metadata 2024-07-01 22:34:58 +01:00
d242f94024
Add comments and similar content to TOC 2024-06-28 18:08:10 +01:00
1421d19fdc
Reduce spacing around similar content 2024-06-28 17:57:06 +01:00
ef7dd5f860
Add comments anchor 2024-06-28 17:56:36 +01:00
872fd4fc82
Minify pygments styles 2024-06-23 21:35:13 +01:00
fffd41dc82
Change comments to comentario 2024-06-23 18:55:01 +01:00
df3dff0708
Remove old container dependency 2024-06-23 16:52:48 +01:00
af379243ad
Bump django-tasks 2024-06-21 21:14:31 +01:00
f18801abba
Update django-tasks to a working version 2024-06-08 16:34:06 +01:00
2a6c3146f1
Use version of django-tasks from PyPI 2024-06-08 16:16:50 +01:00
5d31c16a3c
Replace RQ with django-tasks 2024-06-08 12:52:52 +01:00
a7ec3a8f8e
Include external posts in similarity 2024-05-30 19:20:29 +01:00
26d9e256ea
Don't enable DDT during tests 2024-05-30 19:13:32 +01:00
f427fc7bb5
Only include blog posts in similar posts 2024-05-30 13:54:52 +01:00
d199c3a681
Don't use page if there isn't one 2024-05-30 13:47:13 +01:00
36211e88f2
Support linking out to external posts 2024-05-29 23:30:17 +01:00
2639d6eb1c
Block AI bots 2024-05-26 17:52:48 +01:00
43503921db
Ensure random redirects aren't indexed
Still allow them to be followed
2024-05-26 16:15:23 +01:00
54aaca3087
Remove explicit URLs from robots.txt
Rely instead on the `robots` metatag on the respective pages
2024-05-26 15:45:58 +01:00
8d724277b0
Add random button to listing pages 2024-05-09 19:42:33 +01:00
7fcbbad885
Move gzipping to nginx
This means less time is spent in gunicorn, letting the workers process other requests sooner.
2024-05-04 23:19:43 +01:00
514e609973
Change minify-html details to be more spec compliant 2024-05-04 23:07:35 +01:00
b89b9d0797
Update Django to 5.0.4 2024-05-04 22:05:10 +01:00
413b3165e0
Update Wagtail to 5.2.5 2024-05-04 20:59:57 +01:00
2779a3e97f
Fix arguments on after_move_page hook 2024-05-04 20:56:23 +01:00
ac2ba1ef17
Return when a colours error is found 2024-05-04 20:53:45 +01:00
0f328aff0a
Minify HTML 2024-05-04 20:07:59 +01:00
7cbdda9397
Fix listing item images 2024-04-18 17:53:47 +01:00
47 changed files with 768 additions and 1247 deletions

View file

@ -2,4 +2,4 @@ web: ./manage.py runserver 0.0.0.0:8080
watch-js: npm run build:js -- --watch
watch-css: npm run build:css -- --watch
watch-contrib: ./scripts/copy-npm-contrib.sh; while inotifywait -e modify ./scripts/copy-npm-contrib.sh; do ./scripts/copy-npm-contrib.sh; done
rqworker: ./manage.py rqworker --with-scheduler
worker: ./manage.py db_worker --interval 5

View file

@ -1,11 +1,9 @@
version: '3.7'
services:
web:
build:
context: ../../
target: dev
environment:
- QUEUE_STORE_URL=redis://redis/0
- DEBUG=true
- SECRET_KEY=super-secret-key
- DATABASE_URL=postgres://website:website@db/website
@ -14,15 +12,11 @@ services:
tmpfs:
- /tmp
depends_on:
- redis
- db
ports:
- 127.0.0.1:8000:8000
- 127.0.0.1:8080:8080
redis:
image: redis:6-alpine
db:
image: postgres:14-alpine
environment:

View file

@ -14,6 +14,9 @@ server {
gzip_static on;
gzip on;
gzip_vary on;
set_real_ip_from 0.0.0.0/0;
real_ip_header X-Forwarded-For;
@ -39,6 +42,7 @@ server {
location / {
proxy_pass http://localhost:8080;
gzip_types *;
}
location /static {

View file

@ -4,4 +4,4 @@ set -e
cd /app
exec python manage.py rqworker --with-scheduler
exec python manage.py db_worker -v3 --interval 10

1349
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -28,19 +28,19 @@
"stylelint-config-standard-scss": "6.1.0"
},
"dependencies": {
"@fontsource/fira-code": "5.0.2",
"@fontsource/fira-code": "5.0.18",
"@fortawesome/fontawesome-free": "6.5.2",
"@ledge/is-ie-11": "7.0.0",
"bulma": "0.9.4",
"elevator.js": "1.0.1",
"esbuild": "0.20.2",
"glightbox": "3.3.0",
"htmx.org": "1.9.2",
"lite-youtube-embed": "0.3.0",
"htmx.org": "2.0.1",
"lite-youtube-embed": "0.3.2",
"lodash.clamp": "4.0.3",
"lodash.debounce": "4.0.8",
"lodash.throttle": "4.1.1",
"npm-run-all": "4.1.5",
"npm-run-all2": "5.0.0",
"sass": "1.75.0",
"shareon": "2.4.0"
}

View file

@ -1,16 +1,11 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base"
"config:base",
"replacements:all",
"workarounds:all"
],
"prConcurrentLimit": 0,
"packageRules": [
{
"matchUpdateTypes": ["patch"],
"schedule": ["every weekend"],
"enabled": false
}
],
"regexManagers": [
{
"fileMatch": ["^Dockerfile$"],
@ -18,5 +13,6 @@
"depNameTemplate": "just-containers/s6-overlay",
"datasourceTemplate": "github-releases"
}
]
],
"dependencyDashboardApproval": true
}

View file

@ -1,13 +1,11 @@
Django==5.0.1
wagtail==5.2.2
Django==4.2.15
wagtail==5.2.6
django-environ==0.11.2
whitenoise[brotli]==6.6.0
Pygments==2.17.2
beautifulsoup4
lxml==5.2.1
Pygments==2.18.0
beautifulsoup4[lxml]
requests
wagtail-generic-chooser==0.6
django-rq==2.10.1
django-redis==5.4.0
gunicorn==22.0.0
psycopg==3.1.18
@ -28,6 +26,10 @@ django-permissions-policy==4.18.0
django-enforce-host==1.1.0
django-proxy==1.2.2
wagtail-lite-youtube-embed==0.1.0
django-minify-html==1.7.1
metadata-parser==0.12.1
django-tasks==0.3.0
lightningcss==0.2.0
# DRF OpenAPI dependencies
uritemplate

View file

@ -3,6 +3,7 @@ const STORAGE_KEY = "dark-mode";
const htmlTag = document.getElementsByTagName("html")[0];
const darkModeToggle = document.getElementById("dark-mode-toggle");
const comentarioComments = document.getElementsByTagName("comentario-comments");
const matchesDarkMode = window.matchMedia("(prefers-color-scheme: dark)");
@ -14,6 +15,10 @@ function handleDarkMode(darkMode) {
} else {
htmlTag.classList.remove(DARK_MODE_CLASS);
}
for (const commentElement of comentarioComments) {
commentElement.setAttribute("theme", darkMode ? "dark" : "light");
}
}
if (window.localStorage.getItem(STORAGE_KEY) === null) {

View file

@ -1,35 +0,0 @@
#commento {
.commento-profile-button {
@include dark-mode {
fill: $dark-mode-text;
}
}
.commento-name,
#commento-mod-tools-lock-button,
.commento-login-text,
.commento-anonymous-checkbox-container label {
@include dark-mode {
color: $dark-mode-text;
}
}
#commento-textarea-root,
#commento-guest-details-input-root,
.commento-textarea-container textarea {
@include dark-mode {
background-color: $black;
color: $dark-mode-text;
}
}
.commento-card {
@include dark-mode {
border-top: 1px solid color.adjust($black, $alpha: -0.4);
p {
color: $dark-mode-text;
}
}
}
}

View file

@ -67,5 +67,6 @@ section.content {
}
#comments {
margin-top: 2rem;
scroll-margin-top: var(--hero-height); // hero height (ish)
}

View file

@ -18,6 +18,17 @@
.title {
margin-bottom: 0;
a {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
i {
font-size: 50%;
}
}
}
.content-details {

View file

@ -15,8 +15,8 @@ section#similar-content {
}
.media {
@include desktop {
transform: scale(85%);
}
transform: scale(85%);
margin-top: 0;
padding-top: 0;
}
}

View file

@ -19,7 +19,6 @@
@import "spotify";
@import "404";
@import "password_required";
@import "commento";
@import "similar_content";
@import "support_pill";

View file

@ -1,3 +1,5 @@
import factory
from website.common.factories import BaseContentFactory, BaseListingFactory
from . import models
@ -11,3 +13,11 @@ class BlogPostListPageFactory(BaseListingFactory):
class BlogPostPageFactory(BaseContentFactory):
class Meta:
model = models.BlogPostPage
class ExternalBlogPostPageFactory(BaseContentFactory):
external_url = factory.Faker("url")
class Meta:
model = models.ExternalBlogPostPage
exclude = ["subtitle"]

View file

@ -0,0 +1,45 @@
# Generated by Django 5.0.4 on 2024-05-29 21:10
import django.db.models.deletion
import django.utils.timezone
import modelcluster.fields
import wagtailmetadata.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("blog", "0005_auto_20230602_1236"),
("wagtailcore", "0089_log_entry_data_json_null_to_object"),
]
operations = [
migrations.CreateModel(
name="ExternalBlogPostPage",
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",
),
),
("external_url", models.URLField()),
("date", models.DateField(default=django.utils.timezone.now)),
(
"tags",
modelcluster.fields.ParentalManyToManyField(
blank=True, to="blog.blogposttagpage"
),
),
],
options={
"abstract": False,
},
bases=("wagtailcore.page", wagtailmetadata.models.MetadataMixin),
),
]

View file

@ -1,18 +1,26 @@
from typing import Any, Optional
from urllib.parse import urlsplit
from django.contrib.postgres.search import TrigramSimilarity
from django.db import models
from django.db.models.functions import Cast
from django.db.models.functions import Cast, Coalesce
from django.http import HttpRequest, HttpResponse, HttpResponsePermanentRedirect
from django.utils import timezone
from django.utils.functional import cached_property
from metadata_parser import ParsedResult
from modelcluster.fields import ParentalManyToManyField
from wagtail.admin.panels import FieldPanel
from wagtail.models import PageQuerySet
from wagtail.models import Page, PageQuerySet, Site
from wagtail.search import index
from wagtailautocomplete.edit_handlers import AutocompletePanel
from website.common.models import BaseContentPage, BaseListingPage
from website.common.utils import TocEntry
from website.common.models import BaseContentPage, BaseListingPage, BasePage
from website.common.utils import (
TocEntry,
extend_query_params,
get_page_metadata,
get_url_mime_type,
)
from website.contrib.singleton_page.utils import SingletonPageCache
@ -23,6 +31,8 @@ class BlogPostListPage(BaseListingPage):
"blog.BlogPostTagListPage",
"blog.BlogPostCollectionListPage",
"blog.BlogPostCollectionPage",
"blog.BlogPostCollectionPage",
"blog.ExternalBlogPostPage",
]
@cached_property
@ -31,9 +41,12 @@ class BlogPostListPage(BaseListingPage):
def get_listing_pages(self) -> models.QuerySet:
return (
BlogPostPage.objects.descendant_of(self)
.live()
Page.objects.live()
.public()
.annotate(date=Coalesce("blogpostpage__date", "externalblogpostpage__date"))
.descendant_of(self)
.type(BlogPostPage, ExternalBlogPostPage)
.specific()
.order_by("-date", "title")
)
@ -80,36 +93,40 @@ class BlogPostPage(BaseContentPage):
return SingletonPageCache.get_url(BlogPostListPage)
def get_similar_posts(self) -> models.QuerySet:
try:
listing_pages = BlogPostListPage.objects.get().get_listing_pages()
except BlogPostListPage.DoesNotExist:
return BlogPostPage.objects.none()
listing_pages = BlogPostListPage.objects.get().get_listing_pages()
similar_posts = listing_pages.exclude(id=self.id).alias(
title_similarity=TrigramSimilarity("title", self.title),
# If this page has no subtitle, ignore it as part of similarity
subtitle_similarity=TrigramSimilarity("subtitle", self.subtitle)
if self.subtitle
else models.Value(1),
)
page_tags = list(self.tags.public().live().values_list("id", flat=True))
# If this page has no tags, ignore it as part of similarity
divisor = len(page_tags) if page_tags else models.Value(1)
similar_posts = similar_posts.alias(
# If this page has no tags, ignore it as part of similarity
# NB: Cast to a float, because `COUNT` returns a `bigint`.
tag_similarity=Cast(
models.Count("tags", filter=models.Q(tags__in=page_tags)),
_blog_tag_similarity=Cast(
models.Count(
"blogpostpage__tags",
filter=models.Q(blogpostpage__tags__in=page_tags),
),
output_field=models.FloatField(),
)
/ len(page_tags)
if page_tags
else models.Value(1)
/ divisor,
_external_tag_similarity=Cast(
models.Count(
"externalblogpostpage__tags",
filter=models.Q(externalblogpostpage__tags__in=page_tags),
),
output_field=models.FloatField(),
)
/ divisor,
tag_similarity=models.F("_blog_tag_similarity")
+ models.F("_external_tag_similarity"),
)
similar_posts = similar_posts.annotate(
similarity=(models.F("tag_similarity") * 2)
+ (models.F("title_similarity") * 10)
+ (models.F("subtitle_similarity"))
).order_by("-similarity")[:3]
return similar_posts
@ -137,7 +154,12 @@ class BlogPostTagPage(BaseListingPage):
def get_listing_pages(self) -> models.QuerySet:
blog_list_page = BlogPostListPage.objects.get()
return blog_list_page.get_listing_pages().filter(tags=self)
listing_pages = blog_list_page.get_listing_pages()
return listing_pages.filter(
models.Q(blogpostpage__tags=self)
| models.Q(externalblogpostpage__tags=self)
).distinct()
class BlogPostCollectionListPage(BaseListingPage):
@ -167,3 +189,101 @@ class BlogPostCollectionPage(BaseListingPage):
.public()
.order_by("-date", "title")
)
class ExternalBlogPostPage(BaseContentPage):
subpage_types: list[Any] = []
parent_page_types = [BlogPostListPage]
preview_modes: list[Any] = []
is_external = True
# Some `BaseContentPage` fields aren't relevant
body = None
subtitle = None
hero_image = None
hero_unsplash_photo = None
external_url = models.URLField()
tags = ParentalManyToManyField("blog.BlogPostTagPage", blank=True)
date = models.DateField(default=timezone.now)
content_panels = BasePage.content_panels + [FieldPanel("external_url")]
promote_panels = BaseContentPage.promote_panels + [
FieldPanel("date"),
AutocompletePanel("tags"),
]
search_fields = BaseContentPage.search_fields + [
index.RelatedFields("tags", [index.SearchField("title", boost=1)]),
index.SearchField("external_url"),
]
@cached_property
def tag_list_page_url(self) -> Optional[str]:
return SingletonPageCache.get_url(BlogPostTagListPage)
@cached_property
def tags_list(self) -> models.QuerySet:
"""
Use this to get a page's tags.
"""
tags = self.tags.order_by("slug")
# In drafts, `django-modelcluster` doesn't support these filters
if isinstance(tags, PageQuerySet):
return tags.public().live()
return tags
@cached_property
def metadata(self) -> ParsedResult:
return get_page_metadata(self.external_url)
@cached_property
def _body_html(self) -> str:
try:
return self.metadata.get_metadatas("description")[0]
except (KeyError, IndexError, TypeError):
return ""
@cached_property
def plain_text(self) -> str:
# The metadata is already just text
return self._body_html
def hero_url(
self, image_size: str, wagtail_image_spec_extra: Optional[str] = None
) -> Optional[str]:
try:
return self.metadata.get_metadatas("image")[0]
except (KeyError, IndexError, TypeError):
return None
@cached_property
def hero_image_url(self) -> str:
return ""
@cached_property
def hero_image_alt(self) -> str:
return ""
def get_meta_image_mime(self) -> Optional[str]:
return get_url_mime_type(self.hero_url(""))
def get_url(
self, request: HttpRequest | None = None, current_site: Site | None = None
) -> str:
return self.get_full_url(request)
def get_full_url(self, request: HttpRequest | None = None) -> str:
full_url = urlsplit(super().get_full_url(request))
return extend_query_params(self.external_url, {"utm_source": full_url.netloc})
def serve(self, request: HttpRequest, *args: tuple, **kwargs: dict) -> HttpResponse:
"""
Send the user directly to the external page
"""
return HttpResponsePermanentRedirect(self.get_full_url(request))

View file

@ -1,7 +1,5 @@
{% extends "common/listing_page.html" %}
{% load wagtailroutablepage_tags %}
{% block hero_buttons %}
<a class="button is-radiusless" href="{{ page.tag_list_page_url }}" title="View tags"><i class="fas fa-tags" aria-hidden="true"></i></a>
{{ block.super }}

View file

@ -2,6 +2,16 @@
{% load wagtail_cache navbar_tags %}
{% block post_toc %}
<hr class="dropdown-divider" />
<li>
<a href="#similar-content">Similar content</a>
</li>
<li>
<a href="#comments">Comments</a>
</li>
{% endblock %}
{% block post_content %}
{% if not request.is_preview %}
{% include "common/shareon.html" %}
@ -16,7 +26,7 @@
{% for page in page.get_similar_posts %}
{% block listing_item %}
{% include "common/listing-item.html" %}
{% include "common/listing-item.html" with show_listing_images=True %}
{% endblock %}
{% endfor %}

View file

@ -0,0 +1,16 @@
{% comment %}
This template is never used, but exists just in case.
{% endcomment %}
<!DOCTYPE html>
<html lang="en-GB">
<head>
<title>Redirecting...</title>
<link rel="canonical" href="{{ page.external_url }}" />
<meta charset="utf-8" />
<meta http-equiv="refresh" content="0; url={{ page.external_url }}" />
</head>
<body>
<p>Redirecting...</p>
</body>
</html>

View file

@ -1,9 +1,15 @@
import pickle
from django.test import TestCase
from django.urls import reverse
from website.home.models import HomePage
from .factories import BlogPostListPageFactory, BlogPostPageFactory
from .factories import (
BlogPostListPageFactory,
BlogPostPageFactory,
ExternalBlogPostPageFactory,
)
class BlogPostPageTestCase(TestCase):
@ -69,14 +75,15 @@ class BlogPostListPageTestCase(TestCase):
BlogPostPageFactory(parent=cls.page)
BlogPostPageFactory(parent=cls.page)
ExternalBlogPostPageFactory(parent=cls.page, external_url="https://example.com")
def test_accessible(self) -> None:
response = self.client.get(self.page.url)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context["listing_pages"]), 2)
self.assertEqual(len(response.context["listing_pages"]), 3)
def test_queries(self) -> None:
with self.assertNumQueries(39):
with self.assertNumQueries(43):
self.client.get(self.page.url)
def test_feed_accessible(self) -> None:
@ -84,3 +91,31 @@ class BlogPostListPageTestCase(TestCase):
self.assertRedirects(
response, reverse("feed"), status_code=301, fetch_redirect_response=True
)
class ExternalBlogPostPageTestCase(TestCase):
@classmethod
def setUpTestData(cls) -> None:
cls.home_page = HomePage.objects.get()
cls.blog_post_list_page = BlogPostListPageFactory(parent=cls.home_page)
cls.page = ExternalBlogPostPageFactory(
parent=cls.blog_post_list_page, external_url="https://example.com"
)
def test_redirects(self) -> None:
with self.assertNumQueries(10):
response = self.client.get(self.page.url)
self.assertRedirects(
response,
self.page.external_url + "?utm_source=localhost",
status_code=301,
fetch_redirect_response=False,
)
def test_metadata(self) -> None:
metadata = self.page.metadata
self.assertIsNone(metadata.soup)
# Confirm it can pickle
pickle.dumps(metadata)

View file

@ -0,0 +1,9 @@
from django_minify_html.middleware import MinifyHtmlMiddleware
class CustomMinifyHtmlMiddleware(MinifyHtmlMiddleware):
minify_args = {
"do_not_minify_doctype": True,
"ensure_spec_compliant_unquoted_attribute_values": True,
"keep_spaces_between_attributes": True,
}

View file

@ -1,7 +1,6 @@
from datetime import timedelta
from math import ceil
from typing import Any, Optional
from urllib.parse import urlencode
from django.core.paginator import EmptyPage, Paginator
from django.core.paginator import Page as PaginatorPage
@ -31,6 +30,7 @@ from .serializers import PaginationSerializer
from .streamfield import add_heading_anchors, get_blocks, get_content_html
from .utils import (
TocEntry,
extend_query_params,
extract_text,
get_site_title,
get_table_of_contents,
@ -181,6 +181,7 @@ class BaseContentPage(BasePage, MetadataMixin):
for size, width in UNSPLASH_SIZES.items()
}
@cached_property
def hero_image_url(self) -> Optional[str]:
return self.hero_url("regular")
@ -286,15 +287,22 @@ class BaseListingPage(RoutablePageMixin, BaseContentPage):
url = super().get_meta_url()
if not query_data:
return url
return url + "?" + urlencode(query_data)
return extend_query_params(url, query_data)
@route(r"^feed/$")
def feed(self, request: HttpRequest) -> HttpResponse:
return redirect("feed", permanent=True)
@route(r"^random/$")
def random(self, request: HttpRequest) -> HttpResponse:
page = self.get_listing_pages().order_by("?").first()
if page is None:
response = redirect(self.get_url(request=request), permanent=False)
else:
response = redirect(page.get_url(request=request), permanent=False)
response.headers["X-Robots-Tag"] = "noindex"
return response
class ListingPage(BaseListingPage):
pass

View file

@ -1,5 +1,5 @@
<section class="container" id="comments">
<div id="commento"></div>
<comentario-comments></comentario-comments>
</section>
<script async defer src="https://commento.theorangeone.net/js/commento.js"></script>
<script async defer src="https://comentario.theorangeone.net/comentario.js"></script>

View file

@ -10,6 +10,13 @@
{% endif %}
{% endblock %}
{% block post_toc %}
<hr class="dropdown-divider" />
<li>
<a href="#comments">Comments</a>
</li>
{% endblock %}
{% block post_content %}
{% if not request.is_preview %}
{% include "common/shareon.html" %}

View file

@ -16,7 +16,10 @@
{% include "common/breadcrumbs.html" with parents=page.get_parent_pages %}
{% endif %}
<h2 class="title is-3">
<a href="{% pageurl page %}">{{ page.title }}</a>
<a href="{% pageurl page %}">
{{ page.title }}
{% if page.is_external %}<i class="fa-solid fa-arrow-up-right-from-square" title="This page is from a external source"></i>{% endif %}
</a>
</h2>
{% include "common/content-details.html" %}
<p>{{ page.summary }}</p>

View file

@ -1,8 +1,10 @@
{% extends "common/content_page.html" %}
{% load wagtailadmin_tags %}
{% load wagtailadmin_tags wagtailroutablepage_tags %}
{% block hero_buttons %}
<a class="button is-radiusless" href="{% routablepageurl page 'random' %}" title="View random"><i class="fas fa-dice" aria-hidden="true"></i></a>
{% if listing_pages.has_previous %}
<a class="button is-radiusless" href="{% querystring page=listing_pages.previous_page_number %}" title="Previous page"><i class="fas fa-arrow-left" aria-hidden="true"></i></a>
{% endif %}

View file

@ -1,11 +1,7 @@
{% if SEO_INDEX %}
User-agent: *
Allow: /
{% else %}
User-agent: *
Disallow: /
{% endif %}
{% if SEO_INDEX %}Allow: /{% else %}Disallow: /{% endif %}
# https://github.com/ai-robots-txt/ai.robots.txt
{{ ai_robots_txt }}
Disallow: {% url "wagtailadmin_home" %}
Disallow: {% url "api:index" %}
Sitemap: {{ sitemap }}

View file

@ -56,6 +56,7 @@
{% for toc_item in page.table_of_contents %}
{% include "common/toc-item.html" %}
{% endfor %}
{% block post_toc %}{% endblock %}
</ul>
</div>
</div>

View file

@ -77,3 +77,12 @@ class ListingPageTestCase(TestCase):
self.assertEqual(
response.context["page"].get_meta_url(), self.page.full_url + "?page=2"
)
def test_random(self) -> None:
url = self.page.url + self.page.reverse_subpage("random")
with self.assertNumQueries(10):
response = self.client.get(url)
self.assertEqual(response.status_code, 302)
self.assertIn(
response.url, [page.get_url() for page in self.page.get_listing_pages()]
)

View file

@ -3,6 +3,7 @@ from django.test import SimpleTestCase
from wagtail.rich_text import features as richtext_feature_registry
from website.common.utils import (
extend_query_params,
extract_text,
get_table_of_contents,
heading_id,
@ -111,3 +112,25 @@ class HeadingIDTestCase(SimpleTestCase):
self.assertEqual(heading_id("123"), "ref-123")
self.assertEqual(heading_id("test"), "test")
self.assertEqual(heading_id("Look, a title!"), "look-a-title")
class ExtendQueryParamsTestCase(SimpleTestCase):
def test_params(self) -> None:
self.assertEqual(
extend_query_params("https://example.com", {"foo": "bar"}),
"https://example.com?foo=bar",
)
self.assertEqual(
extend_query_params("https://example.com?foo=bar", {"bar": "foo"}),
"https://example.com?foo=bar&bar=foo",
)
self.assertEqual(
extend_query_params("https://example.com?foo=baz", {"foo": "baz"}),
"https://example.com?foo=baz",
)
def test_removes_param(self) -> None:
self.assertEqual(
extend_query_params("https://example.com?foo=bar", {"foo": None}),
"https://example.com",
)

View file

@ -1,14 +1,17 @@
from dataclasses import dataclass
from itertools import pairwise
from typing import Optional, Type
from typing import Any, Optional, Type
from urllib.parse import urlsplit, urlunsplit
import requests
from bs4 import BeautifulSoup, SoupStrainer
from django.conf import settings
from django.db import models
from django.http import QueryDict
from django.http.request import HttpRequest
from django.utils.text import slugify
from django_cache_decorator import django_cache_decorator
from metadata_parser import MetadataParser, ParsedResult
from wagtail.models import Page, Site
from wagtail.models import get_page_models as get_wagtail_page_models
@ -112,3 +115,36 @@ def get_or_none(queryset: models.QuerySet) -> models.Model:
return queryset.get()
except (queryset.model.DoesNotExist, queryset.model.MultipleObjectsReturned):
return None
@django_cache_decorator(time=21600)
def get_ai_robots_txt() -> str:
"""
https://github.com/ai-robots-txt/ai.robots.txt
"""
return requests_session.get(
"https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/main/robots.txt"
).content.decode()
@django_cache_decorator(time=21600)
def get_page_metadata(url: str) -> ParsedResult:
metadata = MetadataParser(url=url, search_head_only=True).parsed_result
# HACK: BeautifulSoup doesn't pickle nicely, and so can't be cached
metadata.soup = None
return metadata
def extend_query_params(url: str, params: dict[str, Any]) -> str:
scheme, netloc, path, query, fragment = urlsplit(url)
query_dict = QueryDict(query, mutable=True)
for k, v in params.items():
if v is None:
del query_dict[k]
else:
query_dict[k] = v
return urlunsplit((scheme, netloc, path, query_dict.urlencode(), fragment))

View file

@ -23,6 +23,7 @@ from website.search.models import SearchPage
from .feed_generators import CustomFeed
from .models import BaseListingPage, BasePage
from .utils import extend_query_params, get_ai_robots_txt
class Error404View(TemplateView):
@ -52,6 +53,7 @@ class RobotsView(TemplateView):
def get_context_data(self, **kwargs: dict) -> dict:
context = super().get_context_data(**kwargs)
context["sitemap"] = self.request.build_absolute_uri(reverse("sitemap"))
context["ai_robots_txt"] = get_ai_robots_txt()
return context
@ -114,7 +116,9 @@ class AllPagesFeed(Feed):
return item.title
def item_link(self, item: BasePage) -> str:
return item.get_full_url(request=self.request) + "?utm_medium=rss"
return extend_query_params(
item.get_full_url(request=self.request), {"utm_medium": "rss"}
)
def item_pubdate(self, item: BasePage) -> datetime:
if item_date := getattr(item, "date", None):

View file

@ -9,6 +9,7 @@ class GitHubLinguistHealthCheckBackend(BaseHealthCheckBackend):
colours = _get_linguist_colours()
except Exception as e:
self.add_error(str(e))
return
if colours is None:
self.add_error("No colours provided")

View file

@ -1,21 +1,16 @@
import lightningcss
from django.http import HttpRequest, HttpResponse
from django.utils.datastructures import OrderedSet
from django.views.decorators.cache import cache_control
from pygments.formatters.html import HtmlFormatter
@cache_control(max_age=3600)
def pygments_styles(request: HttpRequest) -> HttpResponse:
default_styles = (
HtmlFormatter(style="default")
.get_style_defs("html:not(.dark-mode) .highlight")
.split("\n")
default_styles = HtmlFormatter(style="default").get_style_defs(
"html:not(.dark-mode) .highlight"
)
dark_styles = (
HtmlFormatter(style="monokai")
.get_style_defs("html.dark-mode .highlight")
.split("\n")
)
return HttpResponse(
"".join(OrderedSet(default_styles + dark_styles)), content_type="text/css"
dark_styles = HtmlFormatter(style="monokai").get_style_defs(
"html.dark-mode .highlight"
)
compressed = lightningcss.process_stylesheet(default_styles + dark_styles)
return HttpResponse(compressed, content_type="text/css")

View file

@ -1,5 +1,6 @@
from django.core.cache import cache
from wagtail import hooks
from wagtail.models import Page
from website.common.utils import get_page_models
@ -7,7 +8,7 @@ from .utils import SingletonPageCache
@hooks.register("after_move_page")
def clear_singleton_url_cache(**kwargs: dict) -> None:
def clear_singleton_url_cache(page_to_move: Page) -> None:
"""
Clear all page caches, in case a parent has moved
"""

View file

@ -3,13 +3,15 @@ from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from django_tasks import task
from website.contrib.unsplash.models import UnsplashPhoto
from website.contrib.unsplash.utils import get_unsplash_photo
from website.utils.queue import enqueue_or_sync
def update_photo(photo: UnsplashPhoto) -> None:
@task()
def update_photo(photo_id: int) -> None:
photo = UnsplashPhoto.objects.get(id=photo_id)
photo.data = get_unsplash_photo(photo.unsplash_id)
photo.data_last_updated = timezone.now()
photo.save()
@ -25,6 +27,6 @@ class Command(BaseCommand):
photos = UnsplashPhoto.objects.filter(data_last_updated__lte=max_age)
self.stdout.write(f"Found {photos.count()} photos to update.")
for photo in photos:
self.stdout.write(f"Updating {photo.unsplash_id}")
enqueue_or_sync(update_photo, args=[photo])
for photo_id, unsplash_id in photos.values_list("id", "unsplash_id"):
self.stdout.write(f"Updating {unsplash_id}")
update_photo.enqueue(photo_id)

View file

@ -1,7 +1,7 @@
{% load wagtailadmin_tags %}
{% for page in results %}
{% include "common/listing-item.html" with breadcrumbs=True %}
{% include "common/listing-item.html" with breadcrumbs=True show_listing_images=True %}
{% endfor %}
{% if not results and page_num == 1 %}

View file

@ -38,7 +38,7 @@ class SearchPageTestCase(TestCase):
self.assertEqual(search_input.attrs["name"], "q")
self.assertEqual(search_input.attrs["hx-get"], "results/")
self.assertEqual(search_input.attrs["value"], "")
self.assertNotIn("value", search_input.attrs) # Because of minify-html
self.assertEqual(len(soup.select(search_input.attrs["hx-target"])), 1)
self.assertEqual(len(soup.select(search_input.attrs["hx-indicator"])), 2)

View file

@ -7,7 +7,7 @@ from wagtail.search.utils import parse_query_string
from wagtail_favicon.models import FaviconSettings
from wagtail_favicon.utils import get_rendition_url
from website.common.utils import get_or_none, get_site_title
from website.common.utils import extend_query_params, get_or_none, get_site_title
from website.contrib.singleton_page.utils import SingletonPageCache
from .models import SearchPage
@ -87,4 +87,4 @@ class GoView(RedirectView):
if slug_match := get_or_none(pages.filter(slug__iexact=query)):
return slug_match.get_url(request=self.request)
return f"{search_page_url}?{self.request.GET.urlencode()}"
return extend_query_params(search_page_url, self.request.GET)

View file

@ -69,7 +69,6 @@ INSTALLED_APPS = [
"generic_chooser",
"wagtail_draftail_snippet",
"wagtailautocomplete",
"django_rq",
"rest_framework",
"corsheaders",
"wagtail_favicon",
@ -79,6 +78,8 @@ INSTALLED_APPS = [
"wagtail_2fa",
"django_otp",
"django_otp.plugins.otp_totp",
"django_minify_html",
"django_tasks.backends.database",
"health_check",
"health_check.db",
"health_check.cache",
@ -93,12 +94,12 @@ INSTALLED_APPS = [
]
MIDDLEWARE = [
"django.middleware.gzip.GZipMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"corsheaders.middleware.CorsMiddleware",
"enforce_host.EnforceHostMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"website.common.middleware.CustomMinifyHtmlMiddleware",
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
@ -145,16 +146,10 @@ CACHES = {
# https://docs.wagtail.io/en/v2.13/reference/settings.html#redirects
WAGTAIL_REDIRECTS_FILE_STORAGE = "cache"
RQ_QUEUES = {}
USE_REDIS_QUEUE = False
if queue_store := env.cache(
"QUEUE_STORE_URL", default=None, backend="django_redis.cache.RedisCache"
):
CACHES["rq"] = queue_store
USE_REDIS_QUEUE = True
RQ_QUEUES["default"] = {"USE_REDIS_CACHE": "rq"}
if TEST:
TASKS = {"default": {"BACKEND": "django_tasks.backends.immediate.ImmediateBackend"}}
else:
TASKS = {"default": {"BACKEND": "django_tasks.backends.database.DatabaseBackend"}}
# Internationalization
# https://docs.djangoproject.com/en/4.0/topics/i18n/
@ -319,14 +314,15 @@ if DEBUG:
INSTALLED_APPS.append("django_browser_reload")
MIDDLEWARE.append("django_browser_reload.middleware.BrowserReloadMiddleware")
# Add django-debug-toolbar
INSTALLED_APPS.append("debug_toolbar")
MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
DEBUG_TOOLBAR_CONFIG = {
"SHOW_TOOLBAR_CALLBACK": "website.common.utils.show_toolbar_callback",
"RESULTS_CACHE_SIZE": 5,
"SHOW_COLLAPSED": True,
}
if not TEST:
# Add django-debug-toolbar
INSTALLED_APPS.append("debug_toolbar")
MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
DEBUG_TOOLBAR_CONFIG = {
"SHOW_TOOLBAR_CALLBACK": "website.common.utils.show_toolbar_callback",
"RESULTS_CACHE_SIZE": 5,
"SHOW_COLLAPSED": True,
}
# Add Wagtail styleguide
INSTALLED_APPS.append("wagtail.contrib.styleguide")
@ -390,6 +386,11 @@ LOGGING = {
"level": "WARNING",
"propagate": False,
},
"metadata_parser": {
"handlers": ["console"],
"level": "CRITICAL",
"propagate": False,
},
},
}
@ -441,12 +442,11 @@ if sentry_dsn := env("SENTRY_DSN"):
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from sentry_sdk.integrations.rq import RqIntegration
from sentry_sdk.utils import get_default_release
sentry_kwargs = {
"dsn": sentry_dsn,
"integrations": [DjangoIntegration(), RqIntegration(), RedisIntegration()],
"integrations": [DjangoIntegration(), RedisIntegration()],
"release": get_default_release(),
}

View file

@ -1,10 +1,11 @@
from django.core.cache import cache
from django.core.management.base import BaseCommand
from django_tasks import task
from website.spotify.models import SpotifyPlaylistPage
from website.utils.queue import enqueue_or_sync
@task()
def refresh_cache(page_id: int) -> None:
page = SpotifyPlaylistPage.objects.get(id=page_id)
cache.delete(page.playlist_cache_key)
@ -15,5 +16,7 @@ def refresh_cache(page_id: int) -> None:
class Command(BaseCommand):
def handle(self, *args: list, **options: dict) -> None:
for page in SpotifyPlaylistPage.objects.all().defer_streamfields().iterator():
enqueue_or_sync(refresh_cache, args=[page.id])
for page_id in (
SpotifyPlaylistPage.objects.all().values_list("id", flat=True).iterator()
):
refresh_cache.enqueue(page_id)

View file

@ -12,6 +12,14 @@ class TalksListPage(BaseListingPage):
max_count = 1
subpage_types = ["talks.TalkPage"]
def get_listing_pages(self) -> models.QuerySet:
return (
TalkPage.objects.live()
.public()
.descendant_of(self)
.order_by("-date", "title")
)
class TalkPage(BaseContentPage):
subpage_types: list[Any] = []

View file

@ -37,7 +37,7 @@ class TalksListPageTestCase(TestCase):
self.assertEqual(len(response.context["listing_pages"]), 2)
def test_queries(self) -> None:
with self.assertNumQueries(35):
with self.assertNumQueries(34):
self.client.get(self.page.url)
def test_feed_accessible(self) -> None:

View file

@ -1,21 +0,0 @@
from typing import Callable
from django.conf import settings
from django_rq import get_queue
def enqueue_or_sync(
job_func: Callable, args: list | None = None, kwargs: dict | None = None
) -> None:
"""
Run a task now, or put in RQ
"""
if args is None:
args = []
if kwargs is None:
kwargs = {}
if settings.USE_REDIS_QUEUE:
get_queue().enqueue(job_func, args=args, kwargs=kwargs)
else:
job_func(*args, **kwargs)

View file

@ -1,5 +1,5 @@
{
"m.homeserver": {
"base_url": "https://matrix.jakehoward.tech"
"base_url": "https://matrix.theorangeone.net"
}
}

View file

@ -1 +1 @@
{"m.server": "matrix.jakehoward.tech:443"}
{"m.server": "matrix.theorangeone.net:443"}