Compare commits

..

47 Commits

Author SHA1 Message Date
Renovate 54d42f13b0 Update dependency stylelint-config-standard-scss to v13
renovate/artifacts Artifact file update failure
2024-04-02 22:00:46 +01:00
Jake Howard 9b27baf1ba
Add more text to RSS feed 2024-04-01 19:43:26 +01:00
Jake Howard 3a8e6182ad
Simplify summary and word count implementations 2024-04-01 19:42:59 +01:00
Jake Howard fe43b9c683
install inflection for OpenAPI support
Required by DRF
2024-04-01 12:15:56 +01:00
Jake Howard 6cbac34f2d
Add continue reading button to feed items 2024-03-31 23:59:56 +01:00
Jake Howard e5de558958
Correct argument name when proxy view fails 2024-03-10 15:25:48 +00:00
Jake Howard 1a9d981c7d
Add location information 2024-03-01 17:52:14 +00:00
Jake Howard b0f1191d8f
Only show listing images if there are some
This saves a bit more space for page listings which don't have images
2024-03-01 17:34:26 +00:00
Jake Howard bd4c1a193a
Add page type for talks
Content coming soon, probably
2024-03-01 17:09:56 +00:00
Jake Howard 1934b36ec1
Use `urljoin` to safely join activitypub URL 2024-02-17 21:21:37 +00:00
Jake Howard 23ce49ca8f
Improve readability of recent posts 2024-02-17 20:44:43 +00:00
Jake Howard 926e62518c
Improve sizing of recent posts 2024-02-17 20:43:48 +00:00
Jake Howard ec609ae562
Add redirect view for old pages listing 2024-02-17 19:44:45 +00:00
Jake Howard a19964199f
Put similar content above comments 2024-02-10 15:06:01 +00:00
Jake Howard c69d8d8329
Add recent post cards to homepage 2024-02-10 14:56:26 +00:00
Jake Howard 0424c2dba2
Fix issues with public tags in templates 2024-02-06 18:35:40 +00:00
Jake Howard 4c600651b6
Actually allow customizing the page size 2024-02-05 19:40:39 +00:00
Jake Howard 7d3605f5e1
Add API for latest posts 2024-01-28 18:06:17 +00:00
Jake Howard ae4ea780b7
Only show published tags 2024-01-27 19:59:59 +00:00
Renovate 552639ec40 Update dependency sass to v1.70.0 2024-01-21 22:24:24 +00:00
Renovate 316ab7b628 Update dependency lxml to v5.1.0 2024-01-21 22:19:00 +00:00
Jake Howard 5ff5ad113b
Load potentially external images without referrer 2024-01-17 19:38:42 +00:00
Jake Howard 8c72558ca6
Add alt text for unsplash image URLs 2024-01-17 19:38:42 +00:00
Jake Howard 750fceee02
Remove cache lock 2024-01-14 16:39:05 +00:00
Jake Howard 87cac3fecb
Ensure cache files remain active for a while 2024-01-14 16:14:43 +00:00
Jake Howard 9ba8a505fc
Fix query count for go view
This might be non-deterministic
2024-01-14 13:09:28 +00:00
Jake Howard bbf7411f50
Run everything as non-root 2024-01-14 12:59:31 +00:00
Jake Howard f5a18fdca0
Use cache to accelerate static file serving with nginx
This lets whitenoise handle the headers, and nginx serves them quickly from the cache
2024-01-13 23:01:15 +00:00
Jake Howard 8ce25dcf2d
Use s6 to run everything in a single container 2024-01-13 21:43:10 +00:00
Jake Howard 6f1b823dfa
Replace nvm with nodesource scripts
They're back!
2024-01-13 17:49:51 +00:00
Jake Howard 59912f6ddb
Use short "Go" view for search shortcut 2024-01-12 15:16:31 +00:00
Jake Howard e9f74ec0c1
Upgrade to Python 3.12
This required installing `setuptools`, as Honcho implicitly depends on it
2024-01-11 09:26:39 +00:00
Jake Howard 4e450f6144
Fix `wagtail-draftail-snippet` for Wagtail 5.2 2024-01-06 19:30:42 +00:00
Jake Howard 042cd9f452
Create image migration to modify image file field 2024-01-05 17:31:25 +00:00
Jake Howard 8e0f948f66
Move search icon to left 2024-01-05 16:52:37 +00:00
Jake Howard c36f24b212
Use heading for simple list items 2024-01-05 16:24:25 +00:00
Jake Howard 1ff31828f5
Add labels to all pagination buttons 2024-01-05 16:21:42 +00:00
Jake Howard c8885d19d3
Use correct heading tag for date subtitles 2024-01-05 16:17:12 +00:00
Jake Howard 166441b3e3
Set CSRF cookie as httpOnly 2024-01-05 15:59:23 +00:00
Jake Howard 307cd7fe26
Update dependencies 2024-01-05 15:56:39 +00:00
Jake Howard 8fe3cdbbc3
Replace custom `pagefullurl` tag with built-in 2024-01-05 15:46:38 +00:00
Jake Howard 518461a88f
Use wagtail's new built-in cache tags
They're good, because I wrote them
2024-01-05 15:44:45 +00:00
Jake Howard 48e36bc5b9
Update to Wagtail 5.2 (and others) 2024-01-05 15:30:31 +00:00
Jake Howard 53479eeea9
Update support pill title 2024-01-05 15:05:21 +00:00
Jake Howard e0ffa6a14d
Account for content pages without content or a scroll indicator 2024-01-05 14:18:50 +00:00
Jake Howard 3984660e2b
Make support pill larger and have hover effect 2024-01-05 11:57:55 +00:00
Jake Howard 5d50907ed2
Add opensearch description file
Not _that_ opensearch
2024-01-04 22:21:32 +00:00
90 changed files with 1363 additions and 350 deletions

View File

@ -23,7 +23,7 @@ static:
expire_in: 2 hours
pip:
image: python:3.11-slim
image: python:3.12-slim
stage: build
variables:
PIP_CACHE_DIR: $CI_PROJECT_DIR/.pip-cache
@ -45,7 +45,7 @@ pip:
expire_in: 2 hours
.python_test_template:
image: python:3.11-slim
image: python:3.12-slim
stage: test
dependencies:
- pip

1
.nvmrc
View File

@ -1 +0,0 @@
20

View File

@ -1,3 +1,6 @@
{
"extends": ["stylelint-config-standard-scss", "stylelint-config-prettier-scss"]
"extends": ["stylelint-config-standard-scss", "stylelint-config-prettier-scss"],
"rules": {
"scss/at-extend-no-missing-placeholder": null
}
}

View File

@ -11,10 +11,13 @@ COPY ./static/src ./static/src
RUN npm run build
# The actual container
FROM python:3.11-slim as production
FROM python:3.12-slim as production
ENV VIRTUAL_ENV=/venv
# renovate: datasource=github-tags depName=gchq/cyberchef
ENV S6_OVERLAY_VERSION=3.1.6.2
RUN useradd website --create-home -u 1000 && mkdir /app $VIRTUAL_ENV && chown -R website /app $VIRTUAL_ENV
WORKDIR /app
@ -31,13 +34,16 @@ RUN apt-get update --yes --quiet && apt-get install --yes --quiet --no-install-r
&& apt-get autoremove && rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://github.com/aptible/supercronic/releases/download/v0.2.1/supercronic-linux-amd64 -o /usr/local/bin/supercronic && chmod +x /usr/local/bin/supercronic
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-x86_64.tar.xz /tmp
RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz && tar -C / -Jxpf /tmp/s6-overlay-x86_64.tar.xz
ENV PATH=$VIRTUAL_ENV/bin:$PATH \
PYTHONUNBUFFERED=1
EXPOSE 8000
RUN ln -fs /app/etc/nginx.conf /etc/nginx/sites-available/default
RUN ln -fs /app/etc/nginx.conf /etc/nginx/sites-available/default && chown -R website /var/log/nginx
USER website
@ -56,18 +62,17 @@ RUN cat ./etc/bashrc.sh >> ~/.bashrc
RUN SECRET_KEY=none python manage.py collectstatic --noinput --clear
CMD ["/app/etc/entrypoints/web"]
COPY ./etc/s6-rc.d /etc/s6-overlay/s6-rc.d
ENTRYPOINT [ "/init" ]
# Just dev stuff
FROM production as dev
COPY --chown=website .nvmrc ./
RUN curl https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash \
&& bash --login -c "nvm install --no-progress && nvm alias default $(nvm run --silent --version)"
# Swap user, so the following tasks can be run as root
USER root
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs
RUN apt-get update --yes --quiet && apt-get install -y postgresql-client inotify-tools
RUN curl -sSf https://just.systems/install.sh | bash -s -- --to /usr/bin
@ -77,4 +82,5 @@ USER website
COPY --chown=website dev-requirements.txt ./
RUN pip install --no-cache -r dev-requirements.txt
ENTRYPOINT []
CMD sleep infinity

View File

@ -1,13 +1,14 @@
-r requirements.txt
honcho==1.1.0
black==23.7.0
django-browser-reload==1.11.0
black==23.12.1
django-browser-reload==1.12.1
django-debug-toolbar
types-requests==2.31.0.1
mypy==1.5.1
wagtail-factories==4.0.0
coverage==7.3.0
djlint==1.31.0
types-pyyaml==6.0.12.9
ruff==0.0.278
types-requests
mypy==1.8.0
wagtail-factories==4.1.0
coverage==7.4.0
djlint==1.34.1
types-pyyaml
ruff==0.1.11
setuptools # required for Honcho to work on Python 3.12+

View File

@ -4,7 +4,6 @@ services:
build:
context: ../../
target: dev
init: true
environment:
- QUEUE_STORE_URL=redis://redis/0
- DEBUG=true
@ -12,6 +11,8 @@ services:
- DATABASE_URL=postgres://website:website@db/website
volumes:
- ../../:/app
tmpfs:
- /tmp
depends_on:
- redis
- db

View File

@ -1,7 +0,0 @@
#!/usr/bin/env bash
set -e
python manage.py migrate --noinput
exec gunicorn -c etc/gunicorn.conf.py

View File

@ -1,11 +1,10 @@
wsgi_app = "website.wsgi:application"
accesslog = "-"
disable_redirect_access_to_syslog = True
preload_app = True
bind = "0.0.0.0:8080"
bind = "127.0.0.1:8080"
max_requests = 1200
max_requests_jitter = 50
forwarded_allow_ips = "*"
# Run an additional thread so the GIL isn't sitting completely idle
threads = 2
# Run additional threads so the GIL isn't sitting completely idle
threads = 4

View File

@ -1,7 +1,16 @@
proxy_cache_path /tmp/nginx_cache levels=1:2 keys_zone=nginxcache:10m max_size=150m inactive=24h;
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp_path;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
server {
listen 8000;
access_log /dev/stdout;
error_log /dev/stderr;
gzip_static on;
@ -12,30 +21,35 @@ server {
more_set_headers "Server: Wouldn't you like to know";
server_tokens off;
proxy_buffers 32 4k;
proxy_connect_timeout 240;
proxy_headers_hash_bucket_size 128;
proxy_headers_hash_max_size 1024;
proxy_http_version 1.1;
proxy_read_timeout 240;
proxy_send_timeout 240;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Proxy "";
proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
proxy_cache_valid 404 1m;
location / {
proxy_buffers 32 4k;
proxy_connect_timeout 240;
proxy_headers_hash_bucket_size 128;
proxy_headers_hash_max_size 1024;
proxy_http_version 1.1;
proxy_read_timeout 240;
proxy_send_timeout 240;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Proxy "";
proxy_pass http://django:8080;
proxy_pass http://localhost:8080;
}
location /static {
add_header Cache-Control "public, immutable, max-age=31536000";
alias /app/collected-static;
proxy_cache nginxcache;
add_header X-Cache-Status $upstream_cache_status;
proxy_pass http://localhost:8080;
}
location /media {
add_header Cache-Control "public, immutable, max-age=3600";
alias /app/media;
proxy_cache nginxcache;
add_header X-Cache-Status $upstream_cache_status;
proxy_pass http://localhost:8080;
}
}

View File

7
etc/s6-rc.d/cron/run Normal file
View File

@ -0,0 +1,7 @@
#!/command/with-contenv bash
set -e
cd /app
exec supercronic etc/crontab

1
etc/s6-rc.d/cron/type Normal file
View File

@ -0,0 +1 @@
longrun

7
etc/s6-rc.d/django/run Normal file
View File

@ -0,0 +1,7 @@
#!/command/with-contenv bash
set -e
cd /app
exec gunicorn -c etc/gunicorn.conf.py

1
etc/s6-rc.d/django/type Normal file
View File

@ -0,0 +1 @@
longrun

1
etc/s6-rc.d/migrate/type Normal file
View File

@ -0,0 +1 @@
oneshot

1
etc/s6-rc.d/migrate/up Normal file
View File

@ -0,0 +1 @@
with-contenv bash -c "cd /app && python manage.py migrate --noinput"

View File

2
etc/entrypoints/nginx → etc/s6-rc.d/nginx/run Executable file → Normal file
View File

@ -1,4 +1,4 @@
#!/usr/bin/env bash
#!/command/with-contenv bash
set -e

1
etc/s6-rc.d/nginx/type Normal file
View File

@ -0,0 +1 @@
longrun

View File

4
etc/entrypoints/worker → etc/s6-rc.d/rq/run Executable file → Normal file
View File

@ -1,5 +1,7 @@
#!/usr/bin/env bash
#!/command/with-contenv bash
set -e
cd /app
exec python manage.py rqworker --with-scheduler

1
etc/s6-rc.d/rq/type Normal file
View File

@ -0,0 +1 @@
longrun

View File

View File

View File

View File

View File

@ -9,7 +9,7 @@ DEV_COMPOSE := justfile_directory() + "/docker/dev/docker-compose.yml"
build:
docker-compose -f {{ DEV_COMPOSE }} pull
docker-compose -f {{ DEV_COMPOSE }} build
docker-compose -f {{ DEV_COMPOSE }} run --rm --no-deps web bash -lc "npm ci"
docker-compose -f {{ DEV_COMPOSE }} run --entrypoint=bash --rm --no-deps web -c "npm ci"
@compose +ARGS:
docker-compose -f {{ DEV_COMPOSE }} {{ ARGS }}

14
package-lock.json generated
View File

@ -21,7 +21,7 @@
"lodash.debounce": "4.0.8",
"lodash.throttle": "4.1.1",
"npm-run-all": "4.1.5",
"sass": "1.67.0",
"sass": "1.70.0",
"shareon": "2.4.0"
},
"devDependencies": {
@ -3303,9 +3303,9 @@
}
},
"node_modules/sass": {
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.67.0.tgz",
"integrity": "sha512-SVrO9ZeX/QQyEGtuZYCVxoeAL5vGlYjJ9p4i4HFuekWl8y/LtJ7tJc10Z+ck1c8xOuoBm2MYzcLfTAffD0pl/A==",
"version": "1.70.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.70.0.tgz",
"integrity": "sha512-uUxNQ3zAHeAx5nRFskBnrWzDUJrrvpCPD5FNAoRvTi0WwremlheES3tg+56PaVtCs5QDRX5CBLxxKMDJMEa1WQ==",
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
@ -6262,9 +6262,9 @@
}
},
"sass": {
"version": "1.67.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.67.0.tgz",
"integrity": "sha512-SVrO9ZeX/QQyEGtuZYCVxoeAL5vGlYjJ9p4i4HFuekWl8y/LtJ7tJc10Z+ck1c8xOuoBm2MYzcLfTAffD0pl/A==",
"version": "1.70.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.70.0.tgz",
"integrity": "sha512-uUxNQ3zAHeAx5nRFskBnrWzDUJrrvpCPD5FNAoRvTi0WwremlheES3tg+56PaVtCs5QDRX5CBLxxKMDJMEa1WQ==",
"requires": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",

View File

@ -25,7 +25,7 @@
"prettier": "2.7.1",
"stylelint": "14.16.1",
"stylelint-config-prettier-scss": "0.0.1",
"stylelint-config-standard-scss": "13.0.0"
"stylelint-config-standard-scss": "13.1.0"
},
"dependencies": {
"@fontsource/fira-code": "5.0.2",
@ -41,7 +41,7 @@
"lodash.debounce": "4.0.8",
"lodash.throttle": "4.1.1",
"npm-run-all": "4.1.5",
"sass": "1.67.0",
"sass": "1.70.0",
"shareon": "2.4.0"
}
}

View File

@ -10,5 +10,13 @@
"schedule": ["every weekend"],
"enabled": false
}
],
"regexManagers": [
{
"fileMatch": ["^Dockerfile$"],
"matchStrings": ["ENV S6_OVERLAY_VERSION=(?<currentValue>.*?)\\n"],
"depNameTemplate": "just-containers/s6-overlay",
"datasourceTemplate": "github-releases"
}
]
}

View File

@ -1,30 +1,30 @@
Django==3.2.22
wagtail==4.1.8
Django==5.0.1
wagtail==5.2.2
django-environ==0.11.2
whitenoise[brotli]==6.5.0
Pygments==2.16.1
beautifulsoup4==4.11.2
lxml==4.9.1
requests==2.31.0
wagtail-generic-chooser==0.5.1
django-rq==2.8.0
django-redis==5.3.0
whitenoise[brotli]==6.6.0
Pygments==2.17.2
beautifulsoup4
lxml==5.1.0
requests
wagtail-generic-chooser==0.6
django-rq==2.10.1
django-redis==5.4.0
gunicorn==21.2.0
psycopg2==2.9.6
psycopg2==2.9.9
djangorestframework
django-htmx==1.16.0
wagtail-metadata==4.0.3
django-htmx==1.17.2
wagtail-metadata==5.0.0
django-plausible==0.5.0
sentry-sdk==1.29.2
sentry-sdk
django-sri==0.7.0
wagtail-2fa==1.6.5
wagtail-2fa==1.6.9
django-health-check==3.17.0
wagtail-autocomplete==0.10.0
Wand==0.6.11
wagtail-autocomplete==0.11.0
Wand==0.6.13
django3-cache-decorator==0.5.2
django-cors-headers==4.2.0
django-cors-headers==4.3.1
django-csp==3.7
django-permissions-policy==4.17.0
django-permissions-policy==4.18.0
django-enforce-host==1.1.0
django-proxy==1.2.2
wagtail-lite-youtube-embed==0.1.0
@ -32,9 +32,10 @@ wagtail-lite-youtube-embed==0.1.0
# DRF OpenAPI dependencies
uritemplate
PyYAML
inflection
# Use custom `wagtail-favicon` with performance improvements
git+https://github.com/RealOrangeOne/wagtail-favicon@4586efaac746085338fc7d61713006d9adc62d2e
git+https://github.com/RealOrangeOne/wagtail-favicon@b892165e047b35c46d7244109b9ad9226d32a213
# Use custom `wagtail-draftail-snippet` with support for Wagtail 4.1
git+https://github.com/RealOrangeOne/wagtail-draftail-snippet@0924ab12b1ca205b94ccd9a34ecc446d7ac422e5
# Use custom `wagtail-draftail-snippet` with support for Wagtail 5.x
git+https://github.com/RealOrangeOne/wagtail-draftail-snippet@74ed858bd958a066d5aee295c9848257107b1546

View File

@ -23,11 +23,13 @@ function handleScrollIndicator() {
}
window.addEventListener("load", () => {
window.addEventListener("resize", handleScrollIndicator);
window.addEventListener("scroll", handleScrollIndicator);
if (CONTENT && SCROLL_INDICATOR) {
window.addEventListener("resize", handleScrollIndicator);
window.addEventListener("scroll", handleScrollIndicator);
// Initialize the indicator
handleScrollIndicator();
}
GLightbox({});
// Initialize the indicator
handleScrollIndicator();
});

View File

@ -1,17 +1,27 @@
body.page-homepage {
height: 100vh;
min-height: 100vh;
main {
background-repeat: no-repeat;
background-size: cover;
background-position: center;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
color: $white;
margin-bottom: 0;
padding-bottom: 2rem;
display: flex;
flex-direction: column;
justify-content: space-evenly;
}
.top-section {
min-width: 90%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 2rem;
margin-bottom: 1rem;
}
h1 {
@ -23,7 +33,7 @@ body.page-homepage {
min-width: 45%;
}
.latest {
.box {
padding: 0.25rem 0.5rem;
margin-top: 2rem;
background-color: color.adjust($dark, $alpha: -0.2);
@ -59,4 +69,60 @@ body.page-homepage {
#to-top {
display: none;
}
.content-list {
width: 90%;
.card-image {
overflow: hidden;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
transition: filter 0.25s;
filter: brightness(0.85);
}
p {
position: absolute;
bottom: 0;
color: $white;
padding: 0.5rem;
transition: background-color 0.25s;
width: 100%;
background-color: rgb(0 0 0 / 55%);
}
figure {
margin: 0;
text-align: initial;
}
a:hover {
img {
filter: unset;
}
p {
background-color: rgb(0 0 0 / 75%);
}
}
}
.recent-posts {
display: flex;
align-items: center;
flex-direction: column;
width: 100%;
flex-grow: unset;
margin-top: 2rem;
.box {
margin: 0 auto;
display: inline-block;
}
}
}

View File

@ -48,7 +48,7 @@
}
}
.page-blogpostlistpage {
.container.listing {
.date-header {
font-size: $size-2;
font-weight: $weight-bold;

View File

@ -14,10 +14,6 @@ body.page-searchpage {
max-width: 80%;
}
.htmx-request i {
animation: search-loading 1.5s linear infinite;
}
#search-results > p {
font-size: 1.5rem;
text-align: center;
@ -47,9 +43,19 @@ body.page-searchpage {
#search-page-indicator {
text-align: center;
margin-top: 1.5rem;
font-size: $size-3;
&:not(.htmx-request) {
display: none;
}
}
// The search icon is hidden during requests
#search-icon {
opacity: 1 !important;
&.htmx-request {
opacity: 0 !important;
}
}
}

View File

@ -1,4 +1,4 @@
$support-pill-size: 50px;
$support-pill-size: 60px;
$support-pill-position: 1.5rem;
.tag.support-pill {
@ -13,6 +13,11 @@ $support-pill-position: 1.5rem;
height: $support-pill-size;
font-size: 100%;
z-index: $dropdown-content-z;
padding: none;
&:hover {
background-color: $primary-dark;
}
@include mobile {
display: none;

View File

@ -2,5 +2,6 @@ from rest_framework.pagination import PageNumberPagination
class CustomPageNumberPagination(PageNumberPagination):
page_size = 15
max_page_size = 40
page_size = 10
page_size_query_param = "page_size"
max_page_size = 25

View File

@ -36,3 +36,14 @@ class LMOTFYSerializer(serializers.ModelSerializer):
return self.context["request"].build_absolute_uri(image_url)
return image_url
class LatestPostSerializer(serializers.ModelSerializer):
full_url = serializers.SerializerMethodField()
class Meta:
model = BlogPostPage
fields = read_only_fields = ["full_url", "title", "date"]
def get_full_url(self, page: Page) -> str:
return page.get_full_url(request=self.context["request"])

View File

@ -86,3 +86,20 @@ class SchemaTestCase(APISimpleTestCase):
def test_schema(self) -> None:
response = self.client.get(reverse("api:schema"))
self.assertEqual(response.status_code, 200)
class LatestPostsAPIViewTestCase(APITestCase):
url = reverse("api:latest-posts")
@classmethod
def setUpTestData(cls) -> None:
cls.home_page = HomePage.objects.get()
for i in range(4):
BlogPostPageFactory(parent=cls.home_page, title=f"Post {i}")
def test_accessible(self) -> None:
with self.assertNumQueries(5):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["count"], 4)

View File

@ -10,6 +10,7 @@ api_urlpatterns = [
path("ping/", views.PingAPIView.as_view(), name="ping"),
path("page-links/", views.PageLinksAPIView.as_view(), name="page-links"),
path("lmotfy/", views.LMOTFYAPIView.as_view(), name="lmotfy"),
path("latest-posts/", views.LatestPostsAPIView.as_view(), name="latest-posts"),
]
schema_view = get_schema_view(

View File

@ -60,3 +60,20 @@ class SwaggerRedirectView(RedirectView):
return HttpResponseRedirect(
self.SWAGGER_EDITOR_URL + request.build_absolute_uri(reverse("api:schema"))
)
class LatestPostsAPIView(ListAPIView):
"""
List my latest blog posts
"""
serializer_class = serializers.LatestPostSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self) -> PageQuerySet:
return (
BlogPostPage.objects.live()
.public()
.only("id", "url_path", "title", "date")
.order_by("-date")
)

View File

@ -7,6 +7,7 @@ from django.utils import timezone
from django.utils.functional import cached_property
from modelcluster.fields import ParentalManyToManyField
from wagtail.admin.panels import FieldPanel
from wagtail.models import PageQuerySet
from wagtail.search import index
from wagtailautocomplete.edit_handlers import AutocompletePanel
@ -61,6 +62,19 @@ class BlogPostPage(BaseContentPage):
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 blog_post_list_page_url(self) -> Optional[str]:
return SingletonPageCache.get_url(BlogPostListPage)
@ -79,7 +93,7 @@ class BlogPostPage(BaseContentPage):
else models.Value(1),
)
page_tags = list(self.tags.values_list("id", flat=True))
page_tags = list(self.tags.public().live().values_list("id", flat=True))
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`.

View File

@ -8,14 +8,14 @@
{% endblock %}
{% block post_content %}
<section class="container">
<section class="container listing">
{% for page in listing_pages %}
{% ifchanged %}
<h3 id="date-{{ page.date|date:'Y-m' }}" class="date-header">
<h2 id="date-{{ page.date|date:'Y-m' }}" class="date-header">
<time datetime="{{ page.date|date:'Y-m' }}" title="{{ page.date|date:'F Y' }}">
{{ page.date|date:"Y-m" }}
</time>
</h3>
</h2>
{% endifchanged %}
{% include "common/listing-item.html" %}

View File

@ -1,26 +1,32 @@
{% extends "common/content_page.html" %}
{% load cache util_tags %}
{% load wagtail_cache navbar_tags %}
{% block post_content %}
{{ block.super }}
{% if not request.is_preview %}
{% cache FRAGMENT_CACHE_TTL|jitter:FRAGMENT_CACHE_TTL_JITTER "similar-content" page.id request.is_preview %}
<section class="container similar-content" id="similar-content">
<h2 class="subtitle is-size-2">Similar content</h2>
{% include "common/shareon.html" %}
<p class="view-all">
<a href="{{ page.blog_post_list_page_url }}">View all &rarr;</a>
</p>
{% wagtailpagecache FRAGMENT_CACHE_TTL "similar-content" %}
<section class="container similar-content" id="similar-content">
<h2 class="subtitle is-size-2">Similar content</h2>
{% for page in page.get_similar_posts %}
{% block listing_item %}
{% include "common/listing-item.html" %}
{% endblock %}
{% endfor %}
<p class="view-all">
<a href="{{ page.blog_post_list_page_url }}">View all &rarr;</a>
</p>
</section>
{% endcache %}
{% for page in page.get_similar_posts %}
{% block listing_item %}
{% include "common/listing-item.html" %}
{% endblock %}
{% endfor %}
</section>
{% endwagtailpagecache %}
{% include "common/comments.html" %}
{% if not request.user.is_authenticated %}
{% support_pill %}
{% endif %}
{% endif %}
{% endblock %}

View File

@ -7,9 +7,9 @@
<div class="card-content">
<div class="media">
<div class="media-content">
<p class="title is-4">
<h2 class="title is-4">
<a href="{% pageurl page %}">{{ page.title }}</a>
</p>
</h2>
<p class="subtitle is-6">{{ page.summary }}</p>
</div>
<div class="media-right">

View File

@ -18,7 +18,7 @@ class BlogPostPageTestCase(TestCase):
self.assertEqual(response.status_code, 200)
def test_queries(self) -> None:
with self.assertNumQueries(48):
with self.assertNumQueries(43):
self.client.get(self.page.url)
@ -76,7 +76,7 @@ class BlogPostListPageTestCase(TestCase):
self.assertEqual(len(response.context["listing_pages"]), 2)
def test_queries(self) -> None:
with self.assertNumQueries(44):
with self.assertNumQueries(39):
self.client.get(self.page.url)
def test_feed_accessible(self) -> None:

View File

@ -11,7 +11,7 @@ from django.http.response import Http404, HttpResponse, HttpResponseBadRequest
from django.shortcuts import redirect
from django.template.defaultfilters import pluralize
from django.utils.functional import cached_property, classproperty
from django.utils.text import slugify
from django.utils.text import Truncator, slugify
from wagtail.admin.panels import FieldPanel, MultiFieldPanel
from wagtail.contrib.routable_page.models import RoutablePageMixin, route
from wagtail.contrib.settings.models import BaseGenericSetting, register_setting
@ -31,12 +31,10 @@ from .serializers import PaginationSerializer
from .streamfield import add_heading_anchors, get_blocks, get_content_html
from .utils import (
TocEntry,
count_words,
extract_text,
get_site_title,
get_table_of_contents,
get_url_mime_type,
truncate_string,
)
@ -141,16 +139,11 @@ class BaseContentPage(BasePage, MetadataMixin):
@cached_property
def word_count(self) -> int:
return count_words(self.plain_text)
return len(self.plain_text.split())
@cached_property
def summary(self) -> str:
summary = truncate_string(self.plain_text, 50)
if summary and summary != self.plain_text and not summary.endswith("."):
summary += ""
return summary
return Truncator(self.plain_text).words(50)
@cached_property
def body_html(self) -> str:
@ -195,6 +188,13 @@ class BaseContentPage(BasePage, MetadataMixin):
def list_image_url(self) -> Optional[str]:
return self.hero_url("small")
@cached_property
def hero_image_alt(self) -> str:
if self.hero_unsplash_photo_id is None:
return ""
return self.hero_unsplash_photo.data.get("description", "")
def get_meta_url(self) -> str:
return self.full_url
@ -256,7 +256,12 @@ class BaseListingPage(RoutablePageMixin, BaseContentPage):
def get_context(self, request: HttpRequest) -> dict:
context = super().get_context(request)
context["listing_pages"] = self.get_paginator_page()
listing_pages = self.get_paginator_page()
context["listing_pages"] = listing_pages
# Show listing images if at least 1 page has an image
context["show_listing_images"] = any(p.list_image_url for p in listing_pages)
return context
@cached_property

View File

@ -1,4 +1,4 @@
{% load static wagtailcore_tags wagtailuserbar navbar_tags footer_tags plausible_wagtail favicon_tags sri cache %}
{% load static wagtailcore_tags wagtailuserbar navbar_tags footer_tags plausible_wagtail favicon_tags sri wagtail_cache %}
<!DOCTYPE html>
<html lang="en-GB">
@ -14,6 +14,8 @@
{% block extra_head %}{% endblock %}
<link rel="search" type="application/opensearchdescription+xml" href="{% url 'opensearch' %}" title="Orange search" />
<link rel="alternate" type="application/rss+xml" href="{% url 'feed' %}" />
<link rel="me" href="https://{{ ACTIVITYPUB_HOST }}/@jake" />
@ -30,31 +32,31 @@
<body class="{% block body_class %}{% endblock %}">
{% wagtailuserbar %}
{% cache 1800 "navbar" request.is_preview %}
{% navbar %}
{% endcache %}
{% wagtailcache 1800 "navbar" %}
{% navbar %}
{% endwagtailcache %}
{% block main %}
<main>
{% block main_content %}{% endblock %}
</main>
{% endblock %}
{% block main %}
<main>
{% block main_content %}{% endblock %}
</main>
{% endblock %}
{% cache 1800 "footer" request.is_preview %}
{% footer %}
{% endcache %}
{% wagtailcache 1800 "footer" %}
{% footer %}
{% endwagtailcache %}
{# Not async to avoid bright flashes #}
{% sri_static "js/dark-mode.js" %}
{# Not async to avoid bright flashes #}
{% sri_static "js/dark-mode.js" %}
<script async defer type="text/javascript" src="{% static 'js/base.js' %}" integrity="{% sri_integrity_static 'js/base.js' %}"></script>
<script async defer type="text/javascript" src="{% static 'js/base.js' %}" integrity="{% sri_integrity_static 'js/base.js' %}"></script>
{% block extra_js %}{% endblock %}
{% block extra_js %}{% endblock %}
{% block plausible %}
{% if not request.user.is_authenticated or not request.is_preview %}
{% plausible %}
{% endif %}
{% endblock %}
</body>
{% block plausible %}
{% if not request.user.is_authenticated or not request.is_preview %}
{% plausible %}
{% endif %}
{% endblock %}
</body>
</html>

View File

@ -1,36 +1,69 @@
{% load wagtailcore_tags cache util_tags %}
{% load wagtailcore_tags wagtail_cache %}
{% cache FRAGMENT_CACHE_TTL|jitter:FRAGMENT_CACHE_TTL_JITTER "content-details" page.id request.is_preview %}
<div class="content-details field is-grouped">
{% if page.date %}
<span class="icon-text">
<span class="icon">
<i class="far fa-lg fa-calendar-alt"></i>
</span>
<span>{{ page.date|date:"Y-m-d" }}</span>
{% wagtailpagecache FRAGMENT_CACHE_TTL "content-details" %}
<div class="content-details field is-grouped">
{% if page.date %}
<span class="icon-text">
<span class="icon">
<i class="far fa-lg fa-calendar-alt"></i>
</span>
{% endif %}
<span>{{ page.date|date:"Y-m-d" }}</span>
</span>
{% endif %}
{% if page.show_reading_time %}
<div class="icon-text" {% if page.word_count %}title="{{ page.word_count }} words"{% endif %}>
<span class="icon">
<i class="far fa-lg fa-clock"></i>
</span>
<span>{{ page.reading_time_display }}</span>
</div>
{% endif %}
{% if page.show_reading_time %}
<div class="icon-text" {% if page.word_count %}title="{{ page.word_count }} words"{% endif %}>
<span class="icon">
<i class="far fa-lg fa-clock"></i>
</span>
<span>{{ page.reading_time_display }}</span>
</div>
{% endif %}
{% if page.tags.all %}
<div class="icon-text is-family-code">
{% if page.tags_list %}
<div class="icon-text is-family-code">
<span class="icon">
<a href="{{ page.tag_list_page_url }}" title="View all tags">
<i class="fas fa-lg fa-tags"></i>
</a>
</span>
{% for tag in page.tags_list %}
<span><a title="{{ tag.name }}" href="{% pageurl tag %}">#{{ tag.slug }}</a></span>
{% endfor %}
</div>
{% endif %}
{% if page.slides_url %}
<span class="icon-text">
<a href="{{ page.slides_url }}">
<span class="icon">
<a href="{{ page.tag_list_page_url }}" title="View all tags">
<i class="fas fa-lg fa-tags"></i>
</a>
<i class="fas fa-lg fa-images"></i>
</span>
{% for tag in page.tags.all|dictsort:"slug" %}
<span><a title="{{ tag.name }}" href="{% pageurl tag %}">#{{ tag.slug }}</a></span>
{% endfor %}
</div>
{% endif %}
</div>
{% endcache %}
<span>Slides</span>
</a>
</span>
{% endif %}
{% if page.video_url %}
<span class="icon-text">
<a href="{{ page.video_url }}">
<span class="icon">
<i class="fas fa-lg fa-film"></i>
</span>
<span>Video</span>
</a>
</span>
{% endif %}
{% if page.location_name and page.location_url %}
<span class="icon-text">
<a href="{{ page.location_url }}">
<span class="icon">
<i class="fas fa-lg fa-map-marker-alt"></i>
</span>
<span>{{ page.location_name }}</span>
</a>
</span>
{% endif %}
</div>
{% endwagtailpagecache %}

View File

@ -1,27 +1,27 @@
{% load wagtailcore_tags cache util_tags %}
{% load wagtailcore_tags wagtail_cache util_tags %}
{% cache FRAGMENT_CACHE_TTL|jitter:FRAGMENT_CACHE_TTL_JITTER "listing-item" page.id request.is_preview breadcrumbs %}
<article class="media listing-item">
<div class="columns">
<figure class="media-left column is-3 image-column">
{% if page.list_image_url %}
<a href="{% pageurl page %}" class="image" title="{{ page.title }}">
<img src="{{ page.list_image_url }}" alt="" loading="lazy" decoding="async" />
</a>
{% wagtailpagecache FRAGMENT_CACHE_TTL "listing-item" breadcrumbs show_listing_images %}
<article class="media listing-item">
<div class="columns">
<figure class="media-left column is-{{ show_listing_images|yesno:'3,1' }} image-column">
{% if page.list_image_url %}
<a href="{% pageurl page %}" class="image" title="{{ page.title }}">
<img src="{{ page.list_image_url }}" alt="{{ page.hero_image_alt }}" loading="lazy" decoding="async" referrerpolicy="no-referrer" />
</a>
{% endif %}
</figure>
<div class="media-content column">
<div>
{% if breadcrumbs %}
{% include "common/breadcrumbs.html" with parents=page.get_parent_pages %}
{% endif %}
</figure>
<div class="media-content column">
<div>
{% if breadcrumbs %}
{% include "common/breadcrumbs.html" with parents=page.get_parent_pages %}
{% endif %}
<h2 class="title is-3">
<a href="{% pageurl page %}">{{ page.title }}</a>
</h2>
{% include "common/content-details.html" %}
<p>{{ page.summary }}</p>
</div>
<h2 class="title is-3">
<a href="{% pageurl page %}">{{ page.title }}</a>
</h2>
{% include "common/content-details.html" %}
<p>{{ page.summary }}</p>
</div>
</div>
</article>
{% endcache %}
</div>
</article>
{% endwagtailpagecache %}

View File

@ -1,14 +1,14 @@
{% load wagtailadmin_tags %}
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
<nav class="pagination is-centered" role="navigation" title="pagination">
{% if page.has_previous %}
<a class="pagination-previous" href="{% querystring page=page.previous_page_number %}"><i class="fas fa-arrow-left" aria-hidden="true"></i></a>
<a class="pagination-previous" title="Go to page {{ page.previous_page_number }}" href="{% querystring page=page.previous_page_number %}"><i class="fas fa-arrow-left" aria-hidden="true"></i></a>
{% else %}
<span class="pagination-previous is-disabled"><i class="fas fa-arrow-left" aria-hidden="true"></i></span>
{% endif %}
{% if page.has_next %}
<a class="pagination-next" href="{% querystring page=page.next_page_number %}"><i class="fas fa-arrow-right" aria-hidden="true"></i></a>
<a class="pagination-next" title="Go to page {{ page.next_page_number }}" href="{% querystring page=page.next_page_number %}"><i class="fas fa-arrow-right" aria-hidden="true"></i></a>
{% else %}
<span class="pagination-next is-disabled"><i class="fas fa-arrow-right" aria-hidden="true"></i></span>
{% endif %}
@ -16,7 +16,7 @@
<ul class="pagination-list">
{% if page.has_previous and page.previous_page_number != 1 %}
<li>
<a class="pagination-link" aria-label="Goto page 1" href="{% querystring page=1 %}">1</a>
<a class="pagination-link" aria-label="Go to page 1" href="{% querystring page=1 %}">1</a>
</li>
<li>
<span class="pagination-ellipsis">&hellip;</span>
@ -25,17 +25,17 @@
{% if page.has_previous %}
<li>
<a class="pagination-link" aria-label="Goto page {{ page.previous_page_number }}" href="{% querystring page=page.previous_page_number %}">{{ page.previous_page_number }}</a>
<a class="pagination-link" title="Go to page {{ page.previous_page_number }}" href="{% querystring page=page.previous_page_number %}">{{ page.previous_page_number }}</a>
</li>
{% endif %}
<li>
<a class="pagination-link is-current" aria-label="Page {{ page.number }}" aria-current="page" href="{% querystring page=page.number %}">{{ page.number }}</a>
<a class="pagination-link is-current" title="Page {{ page.number }}" aria-current="page" href="{% querystring page=page.number %}">{{ page.number }}</a>
</li>
{% if page.has_next %}
<li>
<a class="pagination-link" aria-label="Goto page {{ page.next_page_number }}" href="{% querystring page=page.next_page_number %}">{{ page.next_page_number }}</a>
<a class="pagination-link" title="Go to page {{ page.next_page_number }}" href="{% querystring page=page.next_page_number %}">{{ page.next_page_number }}</a>
</li>
{% endif %}
@ -44,7 +44,7 @@
<span class="pagination-ellipsis">&hellip;</span>
</li>
<li>
<a class="pagination-link" aria-label="Goto page {{ page.paginator.num_pages }}" href="{% querystring page=page.paginator.num_pages %}">{{ page.paginator.num_pages }}</a>
<a class="pagination-link" title="Go to page {{ page.paginator.num_pages }}" href="{% querystring page=page.paginator.num_pages %}">{{ page.paginator.num_pages }}</a>
</li>
{% endif %}
</ul>

View File

@ -1,8 +1,8 @@
{% load util_tags %}
{% load wagtailcore_tags %}
<section class="container has-text-centered shareon-container" id="shareon">
<p>Share this page</p>
<div class="shareon" data-title="{{ page.title }}" data-url="{% pagefullurl page %}">
<div class="shareon" data-title="{{ page.title }}" data-url="{% fullpageurl page %}">
<a class="facebook" title="Share on Facebook"></a>
<a class="linkedin" title="Share on LinkedIn"></a>
<a class="mastodon" title="Share on Mastodon"></a>

View File

@ -1,7 +1,7 @@
{% load wagtailcore_tags %}
{% if support_page and page.id != support_page.id %}
<a href="{% pageurl support_page %}" class="tag is-primary support-pill" title="Support me">
<a href="{% pageurl support_page %}" class="tag is-primary support-pill" title="If you like what I do, please consider supporting my work!">
<i class="fas fa-praying-hands"></i>
</a>
{% endif %}

View File

@ -0,0 +1,9 @@
{% load wagtailcore_tags %}
{% spaceless %}
{{ obj.content_html | truncatewords_html:100 | safe }}
<p>
<a href="{% fullpageurl obj %}">Continue Reading&hellip;</a>
</p>
{% endspaceless %}

View File

@ -15,7 +15,7 @@
<picture>
{% for width, image_url in page.hero_image_urls.items reversed %}<source srcset="{{ image_url }}" media="(max-width: {{ width }}px)" />{% endfor %}
<img class="hero" src="{{ page.hero_image_url }}" decoding="async" alt="" />
<img class="hero" src="{{ page.hero_image_url }}" referrerpolicy="no-referrer" decoding="async" alt="{{ page.hero_image_alt }}" />
</picture>
{% endif %}

View File

@ -1,8 +1,5 @@
import random
from django.template import Library
from django.utils.encoding import force_str
from wagtail.models import Page
from wagtail.rich_text import RichText
from website.common import utils
@ -15,16 +12,6 @@ def do_range(stop: int) -> range:
return range(stop)
@register.simple_tag(takes_context=True)
def pagefullurl(context: dict, page: Page) -> str:
return page.get_full_url(context["request"])
@register.filter()
def jitter(original: float, jitter: float) -> float:
return random.uniform(original + jitter, original - jitter)
@register.filter()
def extract_text(html: str | RichText) -> str:
return utils.extract_text(force_str(html))

View File

@ -36,7 +36,7 @@ class ContentPageTestCase(TestCase):
self.assertEqual(response.status_code, 200)
def test_queries(self) -> None:
with self.assertNumQueries(39):
with self.assertNumQueries(32):
self.client.get(self.page.url)
@ -53,7 +53,7 @@ class ListingPageTestCase(TestCase):
ContentPageFactory(parent=cls.page)
def test_accessible(self) -> None:
with self.assertNumQueries(42):
with self.assertNumQueries(35):
response = self.client.get(self.page.url)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context["listing_pages"]), 2)

View File

@ -3,7 +3,6 @@ from django.test import SimpleTestCase
from wagtail.rich_text import features as richtext_feature_registry
from website.common.utils import (
count_words,
extract_text,
get_table_of_contents,
heading_id,
@ -97,13 +96,6 @@ class ExtractTextTestCase(SimpleTestCase):
self.assertEqual(extract_text("Hello there!"), "Hello there!")
class CountWordsTestCase(SimpleTestCase):
def test_counts_words(self) -> None:
self.assertEqual(count_words("a b c"), 3)
self.assertEqual(count_words("Correct Horse Battery Staple"), 4)
self.assertEqual(count_words("Hello there! How are you?"), 5)
class RichTextFeaturesTestCase(SimpleTestCase):
def test_features_exist(self) -> None:
for editor, editor_config in settings.WAGTAILADMIN_RICH_TEXT_EDITORS.items():

View File

@ -22,7 +22,7 @@ class Error404PageTestCase(TestCase):
)
def test_queries(self) -> None:
with self.assertNumQueries(22):
with self.assertNumQueries(16):
self.client.get(self.url)

View File

@ -1,12 +1,13 @@
from dataclasses import dataclass
from itertools import islice, pairwise
from typing import Iterable, Optional, Type
from itertools import pairwise
from typing import Optional, Type
import requests
from bs4 import BeautifulSoup, SoupStrainer
from django.conf import settings
from django.db import models
from django.http.request import HttpRequest
from django.utils.text import re_words, slugify
from django.utils.text import slugify
from django_cache_decorator import django_cache_decorator
from wagtail.models import Page, Site
from wagtail.models import get_page_models as get_wagtail_page_models
@ -68,19 +69,6 @@ def show_toolbar_callback(request: HttpRequest) -> bool:
return settings.DEBUG
def split_words(text: str) -> Iterable[str]:
for word in re_words.split(text):
if word and word.strip():
yield word.strip()
def count_words(text: str) -> int:
"""
Count the number of words in the text, without duplicating the item in memory
"""
return len(list(split_words(text)))
def extract_text(html: str) -> str:
"""
Get the plain text of some HTML.
@ -90,10 +78,6 @@ def extract_text(html: str) -> str:
)
def truncate_string(text: str, words: int) -> str:
return " ".join(islice(split_words(text), words))
def heading_id(heading: str) -> str:
"""
Convert a heading into an identifier which is valid for a HTML id attribute
@ -118,3 +102,13 @@ def get_url_mime_type(url: str) -> Optional[str]:
return requests_session.head(url).headers.get("Content-Type")
except requests.exceptions.RequestException:
return None
def get_or_none(queryset: models.QuerySet) -> models.Model:
"""
Helper method to get a single instance, or None if there is not exactly 1 matches
"""
try:
return queryset.get()
except (queryset.model.DoesNotExist, queryset.model.MultipleObjectsReturned):
return None

View File

@ -15,6 +15,7 @@ from wagtail.query import PageQuerySet
from wagtail_favicon.models import FaviconSettings
from wagtail_favicon.utils import get_rendition_url
from website.blog.models import BlogPostPage
from website.common.utils import get_site_title
from website.contrib.singleton_page.utils import SingletonPageCache
from website.home.models import HomePage
@ -63,6 +64,7 @@ class KeybaseView(TemplateView):
class AllPagesFeed(Feed):
feed_type = CustomFeed
link = "/"
description_template = "feed-description.html"
def __init__(self) -> None:
self.style_tag = f'<?xml-stylesheet href="{static("contrib/pretty-feed-v3.xsl")}" type="text/xsl"?>'.encode()
@ -122,12 +124,9 @@ class AllPagesFeed(Feed):
def item_updateddate(self, item: BasePage) -> datetime:
return item.last_published_at
def item_description(self, item: BasePage) -> str:
return getattr(item, "summary", None) or item.title
def item_categories(self, item: BasePage) -> Optional[list[str]]:
if tags := getattr(item, "tags", None):
return tags.order_by("slug").values_list("slug", flat=True)
if isinstance(item, BlogPostPage):
return item.tags_list.values_list("slug", flat=True)
return None
def item_enclosure_url(self, item: BasePage) -> Optional[str]:

View File

@ -7,12 +7,12 @@
<div class="card-content">
<div class="media">
<div class="media-content">
<p class="title is-4">
<h3 class="title is-4">
<a href="{{ account.url }}">
{% if account.icon %}<i class="{{ account.icon }}"></i>{% endif %}
{{ account.name }}
</a>
</p>
</h3>
<p class="subtitle is-6">{{ account.username }}</p>
</div>
</div>

View File

@ -3,11 +3,11 @@ from typing import Type
from django.core.exceptions import ValidationError
from django.http.response import Http404
from django.utils.html import format_html
from wagtail import hooks
from wagtail.admin.forms.models import WagtailAdminModelForm
from wagtail.contrib.modeladmin.helpers import WagtailBackendSearchHandler
from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register
from wagtail.contrib.modeladmin.views import CreateView, EditView, IndexView
from wagtail.core import hooks
from .models import UnsplashPhoto
from .utils import get_unsplash_photo

View File

@ -14,6 +14,6 @@ class UnsplashPhotoChooser(AdminChooser):
def get_title(self, instance: UnsplashPhoto) -> str:
return format_html(
"<img src='{}' width=165 loading='lazy' decoding='async'>",
"<img src='{}' width=165 loading='lazy' decoding='async' referrerpolicy='no-referrer'>",
instance.get_thumbnail_url(),
)

View File

@ -1,8 +1,5 @@
from typing import Optional, Tuple
from django.db import models
from django.http.request import HttpRequest
from django_cache_decorator import django_cache_decorator
from wagtail.admin.panels import FieldPanel
from wagtail.images import get_image_model_string
from wagtail.images.models import Image
@ -12,20 +9,6 @@ from website.common.models import BasePage
from website.contrib.singleton_page.utils import SingletonPageCache
@django_cache_decorator(time=600)
def get_latest_blog_post() -> Optional[Tuple[str, str]]:
from website.blog.models import BlogPostPage
try:
latest_blog_post = (
BlogPostPage.objects.live().public().defer_streamfields().latest("date")
)
except BlogPostPage.DoesNotExist:
return None
return latest_blog_post.title, latest_blog_post.get_url()
class HomePage(BasePage, WagtailImageMetadataMixin):
max_count = 1
@ -55,9 +38,22 @@ class HomePage(BasePage, WagtailImageMetadataMixin):
return self.html_title
def get_context(self, request: HttpRequest) -> dict:
from website.blog.models import BlogPostListPage, BlogPostPage
from website.search.models import SearchPage
context = super().get_context(request)
context["latest_blog_post"] = get_latest_blog_post()
context["recent_posts"] = list(
BlogPostPage.objects.live()
.public()
.defer_streamfields()
.order_by("-date")[:7]
)
context["latest_blog_post"] = (
context["recent_posts"].pop(0) if context["recent_posts"] else None
)
context["search_page_url"] = SingletonPageCache.get_url(SearchPage, request)
context["blog_post_list_url"] = SingletonPageCache.get_url(
BlogPostListPage, request
)
return context

View File

@ -4,21 +4,38 @@
{% block main %}
<main {% if page.image %}style="background-image: url({% image_url page.image 'width-1200' %})"{% endif %}>
<div class="heading-wrapper">
<h1>{{ page.heading }}</h1>
{% if search_page_url %}
<form action="{{ search_page_url }}">
<input id="search-input" class="input" type="text" placeholder="Search" name="q" />
</form>
<div class="top-section">
<div class="heading-wrapper">
<h1>{{ page.heading }}</h1>
{% if search_page_url %}
<form action="{{ search_page_url }}">
<input id="search-input" class="input" type="text" placeholder="Search" name="q" />
</form>
{% endif %}
</div>
{% if latest_blog_post %}
<div class="box latest is-size-5">
<strong>Latest Post</strong>:
<a href="{% pageurl latest_blog_post %}">{{ latest_blog_post.title }}</a>
&rarr;
</div>
{% endif %}
</div>
{% if latest_blog_post %}
<div class="box latest">
<strong>Latest Post</strong>:
<a href="{{ latest_blog_post.1 }}">{{ latest_blog_post.0 }}</a>
&rarr;
<section class="container content recent-posts">
<h2 class="has-text-centered has-text-white is-size-3">Recent Posts</h2>
<div class="columns content-list is-multiline">
{% for page in recent_posts %}
{% include "home/home_page_card.html" %}
{% endfor %}
</div>
{% endif %}
{% if blog_post_list_url %}
<div class="box">
<a href="{{ blog_post_list_url }}">View more &rarr;</a>
</div>
{% endif %}
</section>
</main>
{% endblock %}

View File

@ -0,0 +1,16 @@
{% load wagtail_cache wagtailcore_tags %}
{% wagtailpagecache FRAGMENT_CACHE_TTL "homepage-card" %}
<div class="column is-one-third-widescreen is-half">
<div class="card">
<div class="card-image">
<a href="{% pageurl page %}">
<figure class="image is-16by9">
<img src="{{ page.list_image_url }}" alt="{{ page.hero_image_alt }}" loading="lazy" decoding="async" referrerpolicy="no-referrer" />
<p>{{ page.title }}</p>
</figure>
</a>
</div>
</div>
</div>
{% endwagtailpagecache %}

View File

@ -0,0 +1,33 @@
# Generated by Django 5.0.1 on 2024-01-05 17:28
import wagtail.images.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("images", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="customimage",
name="file",
field=wagtail.images.models.WagtailImageField(
height_field="height",
upload_to=wagtail.images.models.get_upload_to,
verbose_name="file",
width_field="width",
),
),
migrations.AlterField(
model_name="customrendition",
name="file",
field=wagtail.images.models.WagtailImageField(
height_field="height",
storage=wagtail.images.models.get_rendition_storage,
upload_to=wagtail.images.models.get_rendition_upload_to,
width_field="width",
),
),
]

View File

@ -10,4 +10,5 @@ urlpatterns = [
path("tags/<slug:slug>/", views.TagView.as_view()),
path("tags/", views.TagsView.as_view()),
path("categories/", views.TagsView.as_view()),
path("index.json", views.PageLinksView.as_view()),
]

View File

@ -12,12 +12,18 @@ class AllPagesFeedView(RedirectView):
permanent = True
@method_decorator(cache_control(max_age=60 * 60), name="dispatch")
class PageLinksView(RedirectView):
pattern_name = "api:page-links"
permanent = True
@method_decorator(cache_control(max_age=60 * 60), name="dispatch")
class TagView(RedirectView):
permanent = True
def get_redirect_url(self, slug: str) -> str:
tag = get_object_or_404(BlogPostTagPage, slug=slug)
tag = get_object_or_404(BlogPostTagPage.objects.public().live(), slug=slug)
return tag.get_url(request=self.request)

View File

@ -1,4 +1,5 @@
from django.core.paginator import EmptyPage, Paginator
from django.db import models
from django.http.request import HttpRequest
from django.http.response import Http404, HttpResponse, HttpResponseBadRequest
from django.template.response import TemplateResponse
@ -12,7 +13,7 @@ from wagtail.search.utils import parse_query_string
from website.common.models import BaseContentPage, BaseListingPage
from website.common.utils import get_page_models
from .serializers import MIN_SEARCH_LENGTH, SearchParamsSerializer
from .serializers import MIN_SEARCH_LENGTH, SearchPageParamsSerializer
class SearchPage(RoutablePageMixin, BaseContentPage):
@ -43,13 +44,21 @@ class SearchPage(RoutablePageMixin, BaseContentPage):
context["SEO_INDEX"] = False
return context
@classmethod
def get_listing_pages(cls) -> models.QuerySet:
return (
Page.objects.live()
.public()
.not_type(cls.__class__, *cls.EXCLUDED_PAGE_TYPES)
)
@route(r"^results/$")
@method_decorator(require_GET)
def results(self, request: HttpRequest) -> HttpResponse:
if not request.htmx:
return HttpResponseBadRequest()
serializer = SearchParamsSerializer(data=request.GET)
serializer = SearchPageParamsSerializer(data=request.GET)
if not serializer.is_valid():
return TemplateResponse(
@ -68,12 +77,7 @@ class SearchPage(RoutablePageMixin, BaseContentPage):
}
filters, query = parse_query_string(search_query)
pages = (
Page.objects.live()
.public()
.not_type(self.__class__, *self.EXCLUDED_PAGE_TYPES)
.search(query, order_by_relevance=True)
)
pages = self.get_listing_pages().search(query, order_by_relevance=True)
paginator = Paginator(pages, self.PAGE_SIZE)
context["paginator"] = paginator

View File

@ -5,5 +5,9 @@ from website.common.serializers import PaginationSerializer
MIN_SEARCH_LENGTH = 3
class SearchParamsSerializer(PaginationSerializer):
class SearchParamSerializer(serializers.Serializer):
q = serializers.CharField(min_length=MIN_SEARCH_LENGTH)
class SearchPageParamsSerializer(SearchParamSerializer, PaginationSerializer):
pass

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<ShortName>{{ site_title }}</ShortName>
<Description>{{ site_title }}</Description>
<InputEncoding>UTF-8</InputEncoding>
{% if favicon_url %}
<Image type="image/png">{{ favicon_url }}</Image>
{% endif %}
<Url type="text/html" template="{{ search_page_url }}?q={searchTerms}"/>
<Url type="application/x-suggestions+json" template="{{ search_suggestions_url }}?q={searchTerms}"/>
</OpenSearchDescription>

View File

@ -6,12 +6,12 @@
<section class="container search-controls">
<div class="field">
<p class="control has-icons-left has-icons-right">
<input type="search" class="input" name="q" placeholder="Search" hx-get="{{ search_url }}" hx-trigger="keyup changed delay:200ms, search{% if search_query %}, load{% endif %}" hx-target="#search-results" autocomplete="off" value="{{ search_query }}" hx-indicator="#search-indicator" />
<span class="icon is-small is-left">
<input type="search" class="input" name="q" placeholder="Search" hx-get="{{ search_url }}" hx-trigger="keyup changed delay:200ms, search{% if search_query %}, load{% endif %}" hx-target="#search-results" autocomplete="off" value="{{ search_query }}" hx-indicator=".search-indicator" />
<span class="icon is-small is-left htmx-indicator search-indicator" id="search-icon">
<i class="fas fa-search"></i>
</span>
<span class="icon is-small is-right htmx-indicator" id="search-indicator">
<i class="fas fa-circle-notch"></i>
<span class="icon is-small is-left htmx-indicator search-indicator">
<i class="fas fa-spinner fa-pulse"></i>
</span>
</p>
</div>
@ -27,7 +27,7 @@
</div>
<div class="htmx-indicator" id="search-page-indicator">
<i class="fas fa-circle-notch"></i>
<i class="fas fa-spinner fa-pulse"></i>
</div>
</section>
{% endblock %}

View File

@ -1,5 +1,6 @@
from bs4 import BeautifulSoup
from django.test import TestCase
from django.urls import reverse
from website.common.factories import ContentPageFactory
from website.home.models import HomePage
@ -40,7 +41,7 @@ class SearchPageTestCase(TestCase):
self.assertEqual(search_input.attrs["value"], "")
self.assertEqual(len(soup.select(search_input.attrs["hx-target"])), 1)
self.assertEqual(len(soup.select(search_input.attrs["hx-indicator"])), 1)
self.assertEqual(len(soup.select(search_input.attrs["hx-indicator"])), 2)
class SearchPageResultsTestCase(TestCase):
@ -55,7 +56,7 @@ class SearchPageResultsTestCase(TestCase):
cls.url = cls.page.url + cls.page.reverse_subpage("results")
def test_returns_results(self) -> None:
with self.assertNumQueries(24):
with self.assertNumQueries(23):
response = self.client.get(self.url, {"q": "post"}, HTTP_HX_REQUEST="true")
self.assertEqual(response.status_code, 200)
@ -89,7 +90,7 @@ class SearchPageResultsTestCase(TestCase):
)
def test_too_high_page(self) -> None:
with self.assertNumQueries(49):
with self.assertNumQueries(42):
response = self.client.get(
self.url, {"q": "post", "page": 30}, HTTP_HX_REQUEST="true"
)
@ -110,20 +111,114 @@ class SearchPageResultsTestCase(TestCase):
self.assertContains(response, "No results found")
def test_no_query(self) -> None:
with self.assertNumQueries(7):
with self.assertNumQueries(6):
response = self.client.get(self.url, HTTP_HX_REQUEST="true")
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "search/enter-search-term.html")
def test_empty_query(self) -> None:
with self.assertNumQueries(7):
with self.assertNumQueries(6):
response = self.client.get(self.url, {"q": ""}, HTTP_HX_REQUEST="true")
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "search/enter-search-term.html")
def test_not_htmx(self) -> None:
with self.assertNumQueries(7):
with self.assertNumQueries(6):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 400)
class OpenSearchTestCase(TestCase):
@classmethod
def setUpTestData(cls) -> None:
cls.home_page = HomePage.objects.get()
cls.page = SearchPageFactory(parent=cls.home_page)
for i in range(6):
ContentPageFactory(parent=cls.home_page, title=f"Post {i}")
def test_opensearch_description(self) -> None:
with self.assertNumQueries(6):
response = self.client.get(reverse("opensearch"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, reverse("go"))
self.assertContains(response, reverse("opensearch-suggestions"))
def test_opensearch_suggestions(self) -> None:
with self.assertNumQueries(3):
response = self.client.get(reverse("opensearch-suggestions"), {"q": "post"})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data[0], "post")
self.assertEqual(data[1], [f"Post {i}" for i in range(5)])
class GoViewTestCase(TestCase):
@classmethod
def setUpTestData(cls) -> None:
cls.home_page = HomePage.objects.get()
cls.search_page = SearchPageFactory(parent=cls.home_page)
cls.post_1 = ContentPageFactory(
parent=cls.home_page, title="Post Title 1", slug="post-slug-1"
)
cls.post_2 = ContentPageFactory(
parent=cls.home_page, title="Post Title 2", slug="post-slug-2"
)
def test_by_title(self) -> None:
with self.assertNumQueries(5):
response = self.client.get(reverse("go"), {"q": self.post_1.title})
self.assertRedirects(
response, self.post_1.get_url(), fetch_redirect_response=True
)
def test_by_slug(self) -> None:
with self.assertNumQueries(6):
response = self.client.get(reverse("go"), {"q": self.post_2.slug})
self.assertRedirects(
response, self.post_2.get_url(), fetch_redirect_response=True
)
def test_no_match(self) -> None:
with self.assertNumQueries(6):
response = self.client.get(reverse("go"), {"q": "Something else"})
self.assertRedirects(
response,
self.search_page.get_url() + "?q=Something+else",
fetch_redirect_response=True,
)
def test_no_query(self) -> None:
with self.assertNumQueries(3):
response = self.client.get(reverse("go"))
self.assertRedirects(
response, self.search_page.get_url(), fetch_redirect_response=True
)
def test_multiple_matches(self) -> None:
ContentPageFactory(parent=self.home_page, title=self.post_1.title)
with self.assertNumQueries(6):
response = self.client.get(reverse("go"), {"q": self.post_1.title})
self.assertRedirects(
response,
self.search_page.get_url() + f"?q={self.post_1.title}",
fetch_redirect_response=True,
)
def test_no_search_page(self) -> None:
self.search_page.delete()
response = self.client.get(reverse("go"))
self.assertEqual(response.status_code, 404)

13
website/search/urls.py Normal file
View File

@ -0,0 +1,13 @@
from django.urls import path
from . import views
urlpatterns = [
path("opensearch.xml", views.OpenSearchView.as_view(), name="opensearch"),
path(
"opensearch-suggestions/",
views.OpenSearchSuggestionsView.as_view(),
name="opensearch-suggestions",
),
path("go/", views.GoView.as_view(), name="go"),
]

90
website/search/views.py Normal file
View File

@ -0,0 +1,90 @@
from django.http import Http404, HttpRequest, JsonResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control, cache_page
from django.views.generic import RedirectView, TemplateView, View
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.contrib.singleton_page.utils import SingletonPageCache
from .models import SearchPage
from .serializers import SearchParamSerializer
@method_decorator(cache_control(max_age=60 * 60), name="dispatch")
class OpenSearchView(TemplateView):
template_name = "search/opensearch.xml"
content_type = "application/xml"
def get_context_data(self, **kwargs: dict) -> dict:
context = super().get_context_data(**kwargs)
favicon_settings = FaviconSettings.for_request(self.request)
if favicon_settings.base_favicon_image_id:
context["favicon_url"] = self.request.build_absolute_uri(
get_rendition_url(
favicon_settings.base_favicon_image, "fill-100|format-png"
)
)
context["search_page_url"] = self.request.build_absolute_uri(reverse("go"))
context["search_suggestions_url"] = self.request.build_absolute_uri(
reverse("opensearch-suggestions")
)
context["site_title"] = get_site_title()
return context
@method_decorator(cache_control(max_age=60 * 60), name="dispatch")
class OpenSearchSuggestionsView(View):
def get(self, request: HttpRequest) -> JsonResponse:
serializer = SearchParamSerializer(data=request.GET)
if not serializer.is_valid():
return JsonResponse(serializer.errors, status=400)
filters, query = parse_query_string(serializer.validated_data["q"])
results = (
SearchPage.get_listing_pages()
.search(query, order_by_relevance=True)[:5]
.get_queryset()
)
return JsonResponse(
[
serializer.validated_data["q"],
list(results.values_list("title", flat=True)),
],
safe=False,
)
@method_decorator(cache_page(60 * 60), name="dispatch")
class GoView(RedirectView):
def get_redirect_url(self) -> str:
serializer = SearchParamSerializer(data=self.request.GET)
search_page_url = SingletonPageCache.get_url(SearchPage, self.request)
if search_page_url is None:
raise Http404
if not serializer.is_valid():
return search_page_url
query = serializer.validated_data["q"]
pages = SearchPage.get_listing_pages()
if title_match := get_or_none(pages.filter(title__iexact=query)):
return title_match.get_url(request=self.request)
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()}"

View File

@ -42,6 +42,7 @@ INSTALLED_APPS = [
"website.utils",
"website.well_known",
"website.legacy",
"website.talks",
"website.contrib.code_block",
"website.contrib.mermaid_block",
"website.contrib.unsplash",
@ -398,9 +399,6 @@ SESSION_COOKIE_AGE = 2419200 # About a month
CSRF_COOKIE_SECURE = not DEBUG
SESSION_COOKIE_HTTPONLY = True
# https://github.com/wagtail/wagtail-autocomplete/issues/149
CSRF_COOKIE_HTTPONLY = False
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
PERMISSIONS_POLICY: dict[str, list] = {

View File

View File

@ -0,0 +1,17 @@
from datetime import timedelta
from website.common.factories import BaseContentFactory, BaseListingFactory
from . import models
class TalksListPageFactory(BaseListingFactory):
class Meta:
model = models.TalksListPage
class TalkPageFactory(BaseContentFactory):
duration = timedelta(minutes=30)
class Meta:
model = models.TalkPage

View File

@ -0,0 +1,354 @@
# Generated by Django 5.0.1 on 2024-03-01 17:44
import django.db.models.deletion
import django.utils.timezone
import wagtail.blocks
import wagtail.contrib.routable_page.models
import wagtail.contrib.typed_table_block.blocks
import wagtail.embeds.blocks
import wagtail.fields
import wagtail.images.blocks
import wagtailmetadata.models
from django.db import migrations, models
import website.contrib.code_block.blocks
class Migration(migrations.Migration):
initial = True
dependencies = [
("images", "0002_alter_customimage_file_alter_customrendition_file"),
("unsplash", "0001_initial"),
("wagtailcore", "0089_log_entry_data_json_null_to_object"),
]
operations = [
migrations.CreateModel(
name="TalkPage",
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",
),
),
("subtitle", wagtail.fields.RichTextField(blank=True)),
(
"body",
wagtail.fields.StreamField(
[
("embed", wagtail.embeds.blocks.EmbedBlock()),
("rich_text", wagtail.blocks.RichTextBlock()),
(
"lorem",
wagtail.blocks.StructBlock(
[
(
"paragraphs",
wagtail.blocks.IntegerBlock(min_value=1),
)
]
),
),
("html", wagtail.blocks.RawHTMLBlock()),
(
"image",
wagtail.blocks.StructBlock(
[
(
"image",
wagtail.images.blocks.ImageChooserBlock(),
),
(
"caption",
wagtail.blocks.RichTextBlock(
editor="plain", required=False
),
),
]
),
),
(
"code",
wagtail.blocks.StructBlock(
[
(
"filename",
wagtail.blocks.CharBlock(
max_length=128, required=False
),
),
(
"language",
wagtail.blocks.ChoiceBlock(
choices=website.contrib.code_block.blocks.get_language_choices
),
),
("source", wagtail.blocks.TextBlock()),
]
),
),
(
"tangent",
wagtail.blocks.StructBlock(
[
(
"name",
wagtail.blocks.CharBlock(max_length=64),
),
(
"content",
wagtail.blocks.RichTextBlock(
editor="simple"
),
),
]
),
),
(
"mermaid",
wagtail.blocks.StructBlock(
[
("source", wagtail.blocks.TextBlock()),
(
"caption",
wagtail.blocks.RichTextBlock(
editor="plain", required=False
),
),
]
),
),
(
"table",
wagtail.contrib.typed_table_block.blocks.TypedTableBlock(
[
(
"rich_text",
wagtail.blocks.RichTextBlock(
editor="plain"
),
),
("numeric", wagtail.blocks.FloatBlock()),
("text", wagtail.blocks.CharBlock()),
]
),
),
(
"iframe",
wagtail.blocks.StructBlock(
[
("url", wagtail.blocks.URLBlock()),
(
"caption",
wagtail.blocks.RichTextBlock(
editor="plain", required=False
),
),
]
),
),
],
blank=True,
use_json_field=True,
),
),
("date", models.DateField(default=django.utils.timezone.now)),
("duration", models.DurationField()),
("slides_url", models.URLField(blank=True)),
("video_url", models.URLField(blank=True)),
("location_name", models.CharField(blank=True, max_length=64)),
("location_url", models.URLField(blank=True)),
(
"hero_image",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="images.customimage",
),
),
(
"hero_unsplash_photo",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="unsplash.unsplashphoto",
),
),
],
options={
"abstract": False,
},
bases=("wagtailcore.page", wagtailmetadata.models.MetadataMixin),
),
migrations.CreateModel(
name="TalksListPage",
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",
),
),
(
"body",
wagtail.fields.StreamField(
[
("embed", wagtail.embeds.blocks.EmbedBlock()),
("rich_text", wagtail.blocks.RichTextBlock()),
(
"lorem",
wagtail.blocks.StructBlock(
[
(
"paragraphs",
wagtail.blocks.IntegerBlock(min_value=1),
)
]
),
),
("html", wagtail.blocks.RawHTMLBlock()),
(
"image",
wagtail.blocks.StructBlock(
[
(
"image",
wagtail.images.blocks.ImageChooserBlock(),
),
(
"caption",
wagtail.blocks.RichTextBlock(
editor="plain", required=False
),
),
]
),
),
(
"code",
wagtail.blocks.StructBlock(
[
(
"filename",
wagtail.blocks.CharBlock(
max_length=128, required=False
),
),
(
"language",
wagtail.blocks.ChoiceBlock(
choices=website.contrib.code_block.blocks.get_language_choices
),
),
("source", wagtail.blocks.TextBlock()),
]
),
),
(
"tangent",
wagtail.blocks.StructBlock(
[
(
"name",
wagtail.blocks.CharBlock(max_length=64),
),
(
"content",
wagtail.blocks.RichTextBlock(
editor="simple"
),
),
]
),
),
(
"mermaid",
wagtail.blocks.StructBlock(
[
("source", wagtail.blocks.TextBlock()),
(
"caption",
wagtail.blocks.RichTextBlock(
editor="plain", required=False
),
),
]
),
),
(
"table",
wagtail.contrib.typed_table_block.blocks.TypedTableBlock(
[
(
"rich_text",
wagtail.blocks.RichTextBlock(
editor="plain"
),
),
("numeric", wagtail.blocks.FloatBlock()),
("text", wagtail.blocks.CharBlock()),
]
),
),
(
"iframe",
wagtail.blocks.StructBlock(
[
("url", wagtail.blocks.URLBlock()),
(
"caption",
wagtail.blocks.RichTextBlock(
editor="plain", required=False
),
),
]
),
),
],
blank=True,
use_json_field=True,
),
),
(
"hero_image",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="images.customimage",
),
),
(
"hero_unsplash_photo",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="unsplash.unsplashphoto",
),
),
],
options={
"abstract": False,
},
bases=(
wagtail.contrib.routable_page.models.RoutablePageMixin,
"wagtailcore.page",
wagtailmetadata.models.MetadataMixin,
),
),
]

View File

62
website/talks/models.py Normal file
View File

@ -0,0 +1,62 @@
from datetime import timedelta
from typing import Any
from django.db import models
from django.utils import timezone
from wagtail.admin.panels import FieldPanel, MultiFieldPanel
from website.common.models import BaseContentPage, BaseListingPage
class TalksListPage(BaseListingPage):
max_count = 1
subpage_types = ["talks.TalkPage"]
class TalkPage(BaseContentPage):
subpage_types: list[Any] = []
parent_page_types = [TalksListPage]
date = models.DateField(default=timezone.now)
duration = models.DurationField()
slides_url = models.URLField(blank=True)
video_url = models.URLField(blank=True)
location_name = models.CharField(max_length=64, blank=True)
location_url = models.URLField(blank=True)
content_panels = BaseContentPage.content_panels + [
MultiFieldPanel(
[
FieldPanel("slides_url"),
FieldPanel("video_url"),
],
heading="Media",
),
MultiFieldPanel(
[
FieldPanel("location_name"),
FieldPanel("location_url"),
],
heading="Location",
),
FieldPanel("duration"),
]
promote_panels = BaseContentPage.promote_panels + [
FieldPanel("date"),
]
@property
def show_table_of_contents(self) -> bool:
return False
@property
def reading_time(self) -> timedelta:
return self.duration
@property
def word_count(self) -> int:
return 0

View File

@ -0,0 +1,11 @@
{% extends "common/content_page.html" %}
{% load wagtailembeds_tags %}
{% block pre_content %}
{% if page.video_url %}
<section class="container mb-5 content">
<div class="block-embed">{% embed page.video_url %}</div>
</section>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,26 @@
{% extends "common/listing_page.html" %}
{% load wagtailroutablepage_tags %}
{% block post_content %}
<section class="container listing">
{% for page in listing_pages %}
{% ifchanged %}
<h2 id="date-{{ page.date.year }}" class="date-header">
<time datetime="{{ page.date.year }}" title="{{ page.date.year }}">
{{ page.date.year }}
</time>
</h2>
{% endifchanged %}
{% include "common/listing-item.html" %}
{% endfor %}
</section>
{% if listing_pages.has_other_pages %}
<section class="container">
<hr class="my-5" />
{% include "common/pagination.html" with page=listing_pages %}
</section>
{% endif %}
{% endblock %}

47
website/talks/tests.py Normal file
View File

@ -0,0 +1,47 @@
from django.test import TestCase
from django.urls import reverse
from website.home.models import HomePage
from .factories import TalkPageFactory, TalksListPageFactory
class TalkPageTestCase(TestCase):
@classmethod
def setUpTestData(cls) -> None:
cls.home_page = HomePage.objects.get()
cls.list_page = TalksListPageFactory(parent=cls.home_page)
cls.page = TalkPageFactory(parent=cls.list_page)
def test_accessible(self) -> None:
response = self.client.get(self.page.url)
self.assertEqual(response.status_code, 200)
def test_queries(self) -> None:
with self.assertNumQueries(34):
self.client.get(self.page.url)
class TalksListPageTestCase(TestCase):
@classmethod
def setUpTestData(cls) -> None:
cls.home_page = HomePage.objects.get()
cls.page = TalksListPageFactory(parent=cls.home_page)
TalkPageFactory(parent=cls.page)
TalkPageFactory(parent=cls.page)
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)
def test_queries(self) -> None:
with self.assertNumQueries(35):
self.client.get(self.page.url)
def test_feed_accessible(self) -> None:
response = self.client.get(self.page.url + self.page.reverse_subpage("feed"))
self.assertRedirects(
response, reverse("feed"), status_code=301, fetch_redirect_response=True
)

View File

@ -50,6 +50,7 @@ urlpatterns = [
path("feed/", AllPagesFeed(), name="feed"),
path(".health/", include("health_check.urls")),
path("", include("website.legacy.urls")),
path("", include("website.search.urls")),
path("api/", include("website.api.urls", namespace="api")),
path(
"@jake",
@ -57,6 +58,11 @@ urlpatterns = [
),
path("favicon.ico", FaviconView.as_view()),
path("", include(favicon_urls)),
re_path(
r"^%s(?P<path>.*)$" % re.escape(settings.MEDIA_URL.lstrip("/")),
cache_control(max_age=60 * 60)(serve),
{"document_root": settings.MEDIA_ROOT},
),
]
@ -71,15 +77,6 @@ if settings.DEBUG:
# Add django-debug-toolbar
urlpatterns.append(path("__debug__/", include("debug_toolbar.urls")))
urlpatterns.append(
# Media is served by nginx in production
re_path(
r"^%s(?P<path>.*)$" % re.escape(settings.MEDIA_URL.lstrip("/")),
cache_control(max_age=60 * 60)(serve),
{"document_root": settings.MEDIA_ROOT},
)
)
if settings.DEBUG or settings.TEST:
urlpatterns.extend(

View File

@ -3,12 +3,9 @@ from django.http.request import HttpRequest
def global_vars(request: HttpRequest) -> dict:
# noop caching in preview
fragment_cache_ttl = 0 if getattr(request, "is_preview", False) else 3600
return {
"SEO_INDEX": settings.SEO_INDEX,
"DEBUG": settings.DEBUG,
"FRAGMENT_CACHE_TTL": fragment_cache_ttl,
"FRAGMENT_CACHE_TTL_JITTER": fragment_cache_ttl * 0.1,
"FRAGMENT_CACHE_TTL": 3600,
"ACTIVITYPUB_HOST": settings.ACTIVITYPUB_HOST,
}

View File

@ -1,4 +1,5 @@
from datetime import timedelta
from urllib.parse import urljoin
from django.conf import settings
from django.http.request import HttpRequest
@ -56,10 +57,13 @@ def activitypub_proxy(request: HttpRequest) -> HttpResponse:
if not settings.ACTIVITYPUB_HOST:
raise Http404
activitypub_url = urljoin(
"https://" + settings.ACTIVITYPUB_HOST,
request.path,
allow_fragments=True,
)
try:
return proxy_view(
request,
f"https://{settings.ACTIVITYPUB_HOST}{request.path}",
)
return proxy_view(request, activitypub_url)
except RequestException:
return HttpResponse(status_code=502)
return HttpResponse(status=502)