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 expire_in: 2 hours
pip: pip:
image: python:3.11-slim image: python:3.12-slim
stage: build stage: build
variables: variables:
PIP_CACHE_DIR: $CI_PROJECT_DIR/.pip-cache PIP_CACHE_DIR: $CI_PROJECT_DIR/.pip-cache
@ -45,7 +45,7 @@ pip:
expire_in: 2 hours expire_in: 2 hours
.python_test_template: .python_test_template:
image: python:3.11-slim image: python:3.12-slim
stage: test stage: test
dependencies: dependencies:
- pip - 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 RUN npm run build
# The actual container # The actual container
FROM python:3.11-slim as production FROM python:3.12-slim as production
ENV VIRTUAL_ENV=/venv 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 RUN useradd website --create-home -u 1000 && mkdir /app $VIRTUAL_ENV && chown -R website /app $VIRTUAL_ENV
WORKDIR /app 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/* && 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 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 \ ENV PATH=$VIRTUAL_ENV/bin:$PATH \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1
EXPOSE 8000 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 USER website
@ -56,18 +62,17 @@ RUN cat ./etc/bashrc.sh >> ~/.bashrc
RUN SECRET_KEY=none python manage.py collectstatic --noinput --clear 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 # Just dev stuff
FROM production as dev 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 # Swap user, so the following tasks can be run as root
USER 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 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 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 ./ COPY --chown=website dev-requirements.txt ./
RUN pip install --no-cache -r dev-requirements.txt RUN pip install --no-cache -r dev-requirements.txt
ENTRYPOINT []
CMD sleep infinity CMD sleep infinity

View File

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

View File

@ -4,7 +4,6 @@ services:
build: build:
context: ../../ context: ../../
target: dev target: dev
init: true
environment: environment:
- QUEUE_STORE_URL=redis://redis/0 - QUEUE_STORE_URL=redis://redis/0
- DEBUG=true - DEBUG=true
@ -12,6 +11,8 @@ services:
- DATABASE_URL=postgres://website:website@db/website - DATABASE_URL=postgres://website:website@db/website
volumes: volumes:
- ../../:/app - ../../:/app
tmpfs:
- /tmp
depends_on: depends_on:
- redis - redis
- db - 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" wsgi_app = "website.wsgi:application"
accesslog = "-"
disable_redirect_access_to_syslog = True disable_redirect_access_to_syslog = True
preload_app = True preload_app = True
bind = "0.0.0.0:8080" bind = "127.0.0.1:8080"
max_requests = 1200 max_requests = 1200
max_requests_jitter = 50 max_requests_jitter = 50
forwarded_allow_ips = "*" forwarded_allow_ips = "*"
# Run an additional thread so the GIL isn't sitting completely idle # Run additional threads so the GIL isn't sitting completely idle
threads = 2 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 { server {
listen 8000; listen 8000;
access_log /dev/stdout; access_log /dev/stdout;
error_log /dev/stderr;
gzip_static on; gzip_static on;
@ -12,30 +21,35 @@ server {
more_set_headers "Server: Wouldn't you like to know"; more_set_headers "Server: Wouldn't you like to know";
server_tokens off; 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 / { location / {
proxy_buffers 32 4k; proxy_pass http://localhost:8080;
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;
} }
location /static { location /static {
add_header Cache-Control "public, immutable, max-age=31536000"; proxy_cache nginxcache;
alias /app/collected-static; add_header X-Cache-Status $upstream_cache_status;
proxy_pass http://localhost:8080;
} }
location /media { location /media {
add_header Cache-Control "public, immutable, max-age=3600"; proxy_cache nginxcache;
alias /app/media; 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 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 set -e
cd /app
exec python manage.py rqworker --with-scheduler 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: build:
docker-compose -f {{ DEV_COMPOSE }} pull docker-compose -f {{ DEV_COMPOSE }} pull
docker-compose -f {{ DEV_COMPOSE }} build 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: @compose +ARGS:
docker-compose -f {{ DEV_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.debounce": "4.0.8",
"lodash.throttle": "4.1.1", "lodash.throttle": "4.1.1",
"npm-run-all": "4.1.5", "npm-run-all": "4.1.5",
"sass": "1.67.0", "sass": "1.70.0",
"shareon": "2.4.0" "shareon": "2.4.0"
}, },
"devDependencies": { "devDependencies": {
@ -3303,9 +3303,9 @@
} }
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.67.0", "version": "1.70.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.67.0.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.70.0.tgz",
"integrity": "sha512-SVrO9ZeX/QQyEGtuZYCVxoeAL5vGlYjJ9p4i4HFuekWl8y/LtJ7tJc10Z+ck1c8xOuoBm2MYzcLfTAffD0pl/A==", "integrity": "sha512-uUxNQ3zAHeAx5nRFskBnrWzDUJrrvpCPD5FNAoRvTi0WwremlheES3tg+56PaVtCs5QDRX5CBLxxKMDJMEa1WQ==",
"dependencies": { "dependencies": {
"chokidar": ">=3.0.0 <4.0.0", "chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0", "immutable": "^4.0.0",
@ -6262,9 +6262,9 @@
} }
}, },
"sass": { "sass": {
"version": "1.67.0", "version": "1.70.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.67.0.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.70.0.tgz",
"integrity": "sha512-SVrO9ZeX/QQyEGtuZYCVxoeAL5vGlYjJ9p4i4HFuekWl8y/LtJ7tJc10Z+ck1c8xOuoBm2MYzcLfTAffD0pl/A==", "integrity": "sha512-uUxNQ3zAHeAx5nRFskBnrWzDUJrrvpCPD5FNAoRvTi0WwremlheES3tg+56PaVtCs5QDRX5CBLxxKMDJMEa1WQ==",
"requires": { "requires": {
"chokidar": ">=3.0.0 <4.0.0", "chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0", "immutable": "^4.0.0",

View File

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

View File

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

View File

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

View File

@ -1,17 +1,27 @@
body.page-homepage { body.page-homepage {
height: 100vh; min-height: 100vh;
main { main {
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
height: 100%; height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
color: $white; color: $white;
margin-bottom: 0; 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 { h1 {
@ -23,7 +33,7 @@ body.page-homepage {
min-width: 45%; min-width: 45%;
} }
.latest { .box {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
margin-top: 2rem; margin-top: 2rem;
background-color: color.adjust($dark, $alpha: -0.2); background-color: color.adjust($dark, $alpha: -0.2);
@ -59,4 +69,60 @@ body.page-homepage {
#to-top { #to-top {
display: none; 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 { .date-header {
font-size: $size-2; font-size: $size-2;
font-weight: $weight-bold; font-weight: $weight-bold;

View File

@ -14,10 +14,6 @@ body.page-searchpage {
max-width: 80%; max-width: 80%;
} }
.htmx-request i {
animation: search-loading 1.5s linear infinite;
}
#search-results > p { #search-results > p {
font-size: 1.5rem; font-size: 1.5rem;
text-align: center; text-align: center;
@ -47,9 +43,19 @@ body.page-searchpage {
#search-page-indicator { #search-page-indicator {
text-align: center; text-align: center;
margin-top: 1.5rem; margin-top: 1.5rem;
font-size: $size-3;
&:not(.htmx-request) { &:not(.htmx-request) {
display: none; 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; $support-pill-position: 1.5rem;
.tag.support-pill { .tag.support-pill {
@ -13,6 +13,11 @@ $support-pill-position: 1.5rem;
height: $support-pill-size; height: $support-pill-size;
font-size: 100%; font-size: 100%;
z-index: $dropdown-content-z; z-index: $dropdown-content-z;
padding: none;
&:hover {
background-color: $primary-dark;
}
@include mobile { @include mobile {
display: none; display: none;

View File

@ -2,5 +2,6 @@ from rest_framework.pagination import PageNumberPagination
class CustomPageNumberPagination(PageNumberPagination): class CustomPageNumberPagination(PageNumberPagination):
page_size = 15 page_size = 10
max_page_size = 40 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 self.context["request"].build_absolute_uri(image_url)
return 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: def test_schema(self) -> None:
response = self.client.get(reverse("api:schema")) response = self.client.get(reverse("api:schema"))
self.assertEqual(response.status_code, 200) 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("ping/", views.PingAPIView.as_view(), name="ping"),
path("page-links/", views.PageLinksAPIView.as_view(), name="page-links"), path("page-links/", views.PageLinksAPIView.as_view(), name="page-links"),
path("lmotfy/", views.LMOTFYAPIView.as_view(), name="lmotfy"), path("lmotfy/", views.LMOTFYAPIView.as_view(), name="lmotfy"),
path("latest-posts/", views.LatestPostsAPIView.as_view(), name="latest-posts"),
] ]
schema_view = get_schema_view( schema_view = get_schema_view(

View File

@ -60,3 +60,20 @@ class SwaggerRedirectView(RedirectView):
return HttpResponseRedirect( return HttpResponseRedirect(
self.SWAGGER_EDITOR_URL + request.build_absolute_uri(reverse("api:schema")) 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 django.utils.functional import cached_property
from modelcluster.fields import ParentalManyToManyField from modelcluster.fields import ParentalManyToManyField
from wagtail.admin.panels import FieldPanel from wagtail.admin.panels import FieldPanel
from wagtail.models import PageQuerySet
from wagtail.search import index from wagtail.search import index
from wagtailautocomplete.edit_handlers import AutocompletePanel from wagtailautocomplete.edit_handlers import AutocompletePanel
@ -61,6 +62,19 @@ class BlogPostPage(BaseContentPage):
def tag_list_page_url(self) -> Optional[str]: def tag_list_page_url(self) -> Optional[str]:
return SingletonPageCache.get_url(BlogPostTagListPage) 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 @cached_property
def blog_post_list_page_url(self) -> Optional[str]: def blog_post_list_page_url(self) -> Optional[str]:
return SingletonPageCache.get_url(BlogPostListPage) return SingletonPageCache.get_url(BlogPostListPage)
@ -79,7 +93,7 @@ class BlogPostPage(BaseContentPage):
else models.Value(1), 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( similar_posts = similar_posts.alias(
# If this page has no tags, ignore it as part of similarity # If this page has no tags, ignore it as part of similarity
# NB: Cast to a float, because `COUNT` returns a `bigint`. # NB: Cast to a float, because `COUNT` returns a `bigint`.

View File

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

View File

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

View File

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

View File

@ -18,7 +18,7 @@ class BlogPostPageTestCase(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_queries(self) -> None: def test_queries(self) -> None:
with self.assertNumQueries(48): with self.assertNumQueries(43):
self.client.get(self.page.url) self.client.get(self.page.url)
@ -76,7 +76,7 @@ class BlogPostListPageTestCase(TestCase):
self.assertEqual(len(response.context["listing_pages"]), 2) self.assertEqual(len(response.context["listing_pages"]), 2)
def test_queries(self) -> None: def test_queries(self) -> None:
with self.assertNumQueries(44): with self.assertNumQueries(39):
self.client.get(self.page.url) self.client.get(self.page.url)
def test_feed_accessible(self) -> None: 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.shortcuts import redirect
from django.template.defaultfilters import pluralize from django.template.defaultfilters import pluralize
from django.utils.functional import cached_property, classproperty 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.admin.panels import FieldPanel, MultiFieldPanel
from wagtail.contrib.routable_page.models import RoutablePageMixin, route from wagtail.contrib.routable_page.models import RoutablePageMixin, route
from wagtail.contrib.settings.models import BaseGenericSetting, register_setting 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 .streamfield import add_heading_anchors, get_blocks, get_content_html
from .utils import ( from .utils import (
TocEntry, TocEntry,
count_words,
extract_text, extract_text,
get_site_title, get_site_title,
get_table_of_contents, get_table_of_contents,
get_url_mime_type, get_url_mime_type,
truncate_string,
) )
@ -141,16 +139,11 @@ class BaseContentPage(BasePage, MetadataMixin):
@cached_property @cached_property
def word_count(self) -> int: def word_count(self) -> int:
return count_words(self.plain_text) return len(self.plain_text.split())
@cached_property @cached_property
def summary(self) -> str: def summary(self) -> str:
summary = truncate_string(self.plain_text, 50) return Truncator(self.plain_text).words(50)
if summary and summary != self.plain_text and not summary.endswith("."):
summary += ""
return summary
@cached_property @cached_property
def body_html(self) -> str: def body_html(self) -> str:
@ -195,6 +188,13 @@ class BaseContentPage(BasePage, MetadataMixin):
def list_image_url(self) -> Optional[str]: def list_image_url(self) -> Optional[str]:
return self.hero_url("small") 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: def get_meta_url(self) -> str:
return self.full_url return self.full_url
@ -256,7 +256,12 @@ class BaseListingPage(RoutablePageMixin, BaseContentPage):
def get_context(self, request: HttpRequest) -> dict: def get_context(self, request: HttpRequest) -> dict:
context = super().get_context(request) 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 return context
@cached_property @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> <!DOCTYPE html>
<html lang="en-GB"> <html lang="en-GB">
@ -14,6 +14,8 @@
{% block extra_head %}{% endblock %} {% 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="alternate" type="application/rss+xml" href="{% url 'feed' %}" />
<link rel="me" href="https://{{ ACTIVITYPUB_HOST }}/@jake" /> <link rel="me" href="https://{{ ACTIVITYPUB_HOST }}/@jake" />
@ -30,31 +32,31 @@
<body class="{% block body_class %}{% endblock %}"> <body class="{% block body_class %}{% endblock %}">
{% wagtailuserbar %} {% wagtailuserbar %}
{% cache 1800 "navbar" request.is_preview %} {% wagtailcache 1800 "navbar" %}
{% navbar %} {% navbar %}
{% endcache %} {% endwagtailcache %}
{% block main %} {% block main %}
<main> <main>
{% block main_content %}{% endblock %} {% block main_content %}{% endblock %}
</main> </main>
{% endblock %} {% endblock %}
{% cache 1800 "footer" request.is_preview %} {% wagtailcache 1800 "footer" %}
{% footer %} {% footer %}
{% endcache %} {% endwagtailcache %}
{# Not async to avoid bright flashes #} {# Not async to avoid bright flashes #}
{% sri_static "js/dark-mode.js" %} {% 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 %} {% block plausible %}
{% if not request.user.is_authenticated or not request.is_preview %} {% if not request.user.is_authenticated or not request.is_preview %}
{% plausible %} {% plausible %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
</body> </body>
</html> </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 %} {% wagtailpagecache FRAGMENT_CACHE_TTL "content-details" %}
<div class="content-details field is-grouped"> <div class="content-details field is-grouped">
{% if page.date %} {% if page.date %}
<span class="icon-text"> <span class="icon-text">
<span class="icon"> <span class="icon">
<i class="far fa-lg fa-calendar-alt"></i> <i class="far fa-lg fa-calendar-alt"></i>
</span>
<span>{{ page.date|date:"Y-m-d" }}</span>
</span> </span>
{% endif %} <span>{{ page.date|date:"Y-m-d" }}</span>
</span>
{% endif %}
{% if page.show_reading_time %} {% if page.show_reading_time %}
<div class="icon-text" {% if page.word_count %}title="{{ page.word_count }} words"{% endif %}> <div class="icon-text" {% if page.word_count %}title="{{ page.word_count }} words"{% endif %}>
<span class="icon"> <span class="icon">
<i class="far fa-lg fa-clock"></i> <i class="far fa-lg fa-clock"></i>
</span> </span>
<span>{{ page.reading_time_display }}</span> <span>{{ page.reading_time_display }}</span>
</div> </div>
{% endif %} {% endif %}
{% if page.tags.all %} {% if page.tags_list %}
<div class="icon-text is-family-code"> <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"> <span class="icon">
<a href="{{ page.tag_list_page_url }}" title="View all tags"> <i class="fas fa-lg fa-images"></i>
<i class="fas fa-lg fa-tags"></i>
</a>
</span> </span>
{% for tag in page.tags.all|dictsort:"slug" %} <span>Slides</span>
<span><a title="{{ tag.name }}" href="{% pageurl tag %}">#{{ tag.slug }}</a></span> </a>
{% endfor %} </span>
</div> {% endif %}
{% endif %}
</div> {% if page.video_url %}
{% endcache %} <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 %} {% wagtailpagecache FRAGMENT_CACHE_TTL "listing-item" breadcrumbs show_listing_images %}
<article class="media listing-item"> <article class="media listing-item">
<div class="columns"> <div class="columns">
<figure class="media-left column is-3 image-column"> <figure class="media-left column is-{{ show_listing_images|yesno:'3,1' }} image-column">
{% if page.list_image_url %} {% if page.list_image_url %}
<a href="{% pageurl page %}" class="image" title="{{ page.title }}"> <a href="{% pageurl page %}" class="image" title="{{ page.title }}">
<img src="{{ page.list_image_url }}" alt="" loading="lazy" decoding="async" /> <img src="{{ page.list_image_url }}" alt="{{ page.hero_image_alt }}" loading="lazy" decoding="async" referrerpolicy="no-referrer" />
</a> </a>
{% endif %}
</figure>
<div class="media-content column">
<div>
{% if breadcrumbs %}
{% include "common/breadcrumbs.html" with parents=page.get_parent_pages %}
{% endif %} {% endif %}
</figure> <h2 class="title is-3">
<div class="media-content column"> <a href="{% pageurl page %}">{{ page.title }}</a>
<div> </h2>
{% if breadcrumbs %} {% include "common/content-details.html" %}
{% include "common/breadcrumbs.html" with parents=page.get_parent_pages %} <p>{{ page.summary }}</p>
{% endif %}
<h2 class="title is-3">
<a href="{% pageurl page %}">{{ page.title }}</a>
</h2>
{% include "common/content-details.html" %}
<p>{{ page.summary }}</p>
</div>
</div> </div>
</div> </div>
</article> </div>
{% endcache %} </article>
{% endwagtailpagecache %}

View File

@ -1,14 +1,14 @@
{% load wagtailadmin_tags %} {% 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 %} {% 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 %} {% else %}
<span class="pagination-previous is-disabled"><i class="fas fa-arrow-left" aria-hidden="true"></i></span> <span class="pagination-previous is-disabled"><i class="fas fa-arrow-left" aria-hidden="true"></i></span>
{% endif %} {% endif %}
{% if page.has_next %} {% 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 %} {% else %}
<span class="pagination-next is-disabled"><i class="fas fa-arrow-right" aria-hidden="true"></i></span> <span class="pagination-next is-disabled"><i class="fas fa-arrow-right" aria-hidden="true"></i></span>
{% endif %} {% endif %}
@ -16,7 +16,7 @@
<ul class="pagination-list"> <ul class="pagination-list">
{% if page.has_previous and page.previous_page_number != 1 %} {% if page.has_previous and page.previous_page_number != 1 %}
<li> <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>
<li> <li>
<span class="pagination-ellipsis">&hellip;</span> <span class="pagination-ellipsis">&hellip;</span>
@ -25,17 +25,17 @@
{% if page.has_previous %} {% if page.has_previous %}
<li> <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> </li>
{% endif %} {% endif %}
<li> <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> </li>
{% if page.has_next %} {% if page.has_next %}
<li> <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> </li>
{% endif %} {% endif %}
@ -44,7 +44,7 @@
<span class="pagination-ellipsis">&hellip;</span> <span class="pagination-ellipsis">&hellip;</span>
</li> </li>
<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> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -1,8 +1,8 @@
{% load util_tags %} {% load wagtailcore_tags %}
<section class="container has-text-centered shareon-container" id="shareon"> <section class="container has-text-centered shareon-container" id="shareon">
<p>Share this page</p> <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="facebook" title="Share on Facebook"></a>
<a class="linkedin" title="Share on LinkedIn"></a> <a class="linkedin" title="Share on LinkedIn"></a>
<a class="mastodon" title="Share on Mastodon"></a> <a class="mastodon" title="Share on Mastodon"></a>

View File

@ -1,7 +1,7 @@
{% load wagtailcore_tags %} {% load wagtailcore_tags %}
{% if support_page and page.id != support_page.id %} {% 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> <i class="fas fa-praying-hands"></i>
</a> </a>
{% endif %} {% 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> <picture>
{% for width, image_url in page.hero_image_urls.items reversed %}<source srcset="{{ image_url }}" media="(max-width: {{ width }}px)" />{% endfor %} {% 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> </picture>
{% endif %} {% endif %}

View File

@ -1,8 +1,5 @@
import random
from django.template import Library from django.template import Library
from django.utils.encoding import force_str from django.utils.encoding import force_str
from wagtail.models import Page
from wagtail.rich_text import RichText from wagtail.rich_text import RichText
from website.common import utils from website.common import utils
@ -15,16 +12,6 @@ def do_range(stop: int) -> range:
return range(stop) 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() @register.filter()
def extract_text(html: str | RichText) -> str: def extract_text(html: str | RichText) -> str:
return utils.extract_text(force_str(html)) return utils.extract_text(force_str(html))

View File

@ -36,7 +36,7 @@ class ContentPageTestCase(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_queries(self) -> None: def test_queries(self) -> None:
with self.assertNumQueries(39): with self.assertNumQueries(32):
self.client.get(self.page.url) self.client.get(self.page.url)
@ -53,7 +53,7 @@ class ListingPageTestCase(TestCase):
ContentPageFactory(parent=cls.page) ContentPageFactory(parent=cls.page)
def test_accessible(self) -> None: def test_accessible(self) -> None:
with self.assertNumQueries(42): with self.assertNumQueries(35):
response = self.client.get(self.page.url) response = self.client.get(self.page.url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context["listing_pages"]), 2) 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 wagtail.rich_text import features as richtext_feature_registry
from website.common.utils import ( from website.common.utils import (
count_words,
extract_text, extract_text,
get_table_of_contents, get_table_of_contents,
heading_id, heading_id,
@ -97,13 +96,6 @@ class ExtractTextTestCase(SimpleTestCase):
self.assertEqual(extract_text("Hello there!"), "Hello there!") 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): class RichTextFeaturesTestCase(SimpleTestCase):
def test_features_exist(self) -> None: def test_features_exist(self) -> None:
for editor, editor_config in settings.WAGTAILADMIN_RICH_TEXT_EDITORS.items(): 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: def test_queries(self) -> None:
with self.assertNumQueries(22): with self.assertNumQueries(16):
self.client.get(self.url) self.client.get(self.url)

View File

@ -1,12 +1,13 @@
from dataclasses import dataclass from dataclasses import dataclass
from itertools import islice, pairwise from itertools import pairwise
from typing import Iterable, Optional, Type from typing import Optional, Type
import requests import requests
from bs4 import BeautifulSoup, SoupStrainer from bs4 import BeautifulSoup, SoupStrainer
from django.conf import settings from django.conf import settings
from django.db import models
from django.http.request import HttpRequest 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 django_cache_decorator import django_cache_decorator
from wagtail.models import Page, Site from wagtail.models import Page, Site
from wagtail.models import get_page_models as get_wagtail_page_models 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 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: def extract_text(html: str) -> str:
""" """
Get the plain text of some HTML. 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: def heading_id(heading: str) -> str:
""" """
Convert a heading into an identifier which is valid for a HTML id attribute 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") return requests_session.head(url).headers.get("Content-Type")
except requests.exceptions.RequestException: except requests.exceptions.RequestException:
return None 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.models import FaviconSettings
from wagtail_favicon.utils import get_rendition_url from wagtail_favicon.utils import get_rendition_url
from website.blog.models import BlogPostPage
from website.common.utils import get_site_title from website.common.utils import get_site_title
from website.contrib.singleton_page.utils import SingletonPageCache from website.contrib.singleton_page.utils import SingletonPageCache
from website.home.models import HomePage from website.home.models import HomePage
@ -63,6 +64,7 @@ class KeybaseView(TemplateView):
class AllPagesFeed(Feed): class AllPagesFeed(Feed):
feed_type = CustomFeed feed_type = CustomFeed
link = "/" link = "/"
description_template = "feed-description.html"
def __init__(self) -> None: def __init__(self) -> None:
self.style_tag = f'<?xml-stylesheet href="{static("contrib/pretty-feed-v3.xsl")}" type="text/xsl"?>'.encode() 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: def item_updateddate(self, item: BasePage) -> datetime:
return item.last_published_at 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]]: def item_categories(self, item: BasePage) -> Optional[list[str]]:
if tags := getattr(item, "tags", None): if isinstance(item, BlogPostPage):
return tags.order_by("slug").values_list("slug", flat=True) return item.tags_list.values_list("slug", flat=True)
return None return None
def item_enclosure_url(self, item: BasePage) -> Optional[str]: def item_enclosure_url(self, item: BasePage) -> Optional[str]:

View File

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

View File

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

View File

@ -14,6 +14,6 @@ class UnsplashPhotoChooser(AdminChooser):
def get_title(self, instance: UnsplashPhoto) -> str: def get_title(self, instance: UnsplashPhoto) -> str:
return format_html( 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(), instance.get_thumbnail_url(),
) )

View File

@ -1,8 +1,5 @@
from typing import Optional, Tuple
from django.db import models from django.db import models
from django.http.request import HttpRequest from django.http.request import HttpRequest
from django_cache_decorator import django_cache_decorator
from wagtail.admin.panels import FieldPanel from wagtail.admin.panels import FieldPanel
from wagtail.images import get_image_model_string from wagtail.images import get_image_model_string
from wagtail.images.models import Image from wagtail.images.models import Image
@ -12,20 +9,6 @@ from website.common.models import BasePage
from website.contrib.singleton_page.utils import SingletonPageCache 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): class HomePage(BasePage, WagtailImageMetadataMixin):
max_count = 1 max_count = 1
@ -55,9 +38,22 @@ class HomePage(BasePage, WagtailImageMetadataMixin):
return self.html_title return self.html_title
def get_context(self, request: HttpRequest) -> dict: def get_context(self, request: HttpRequest) -> dict:
from website.blog.models import BlogPostListPage, BlogPostPage
from website.search.models import SearchPage from website.search.models import SearchPage
context = super().get_context(request) 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["search_page_url"] = SingletonPageCache.get_url(SearchPage, request)
context["blog_post_list_url"] = SingletonPageCache.get_url(
BlogPostListPage, request
)
return context return context

View File

@ -4,21 +4,38 @@
{% block main %} {% block main %}
<main {% if page.image %}style="background-image: url({% image_url page.image 'width-1200' %})"{% endif %}> <main {% if page.image %}style="background-image: url({% image_url page.image 'width-1200' %})"{% endif %}>
<div class="heading-wrapper"> <div class="top-section">
<h1>{{ page.heading }}</h1> <div class="heading-wrapper">
{% if search_page_url %} <h1>{{ page.heading }}</h1>
<form action="{{ search_page_url }}"> {% if search_page_url %}
<input id="search-input" class="input" type="text" placeholder="Search" name="q" /> <form action="{{ search_page_url }}">
</form> <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 %} {% endif %}
</div> </div>
{% if latest_blog_post %} <section class="container content recent-posts">
<div class="box latest"> <h2 class="has-text-centered has-text-white is-size-3">Recent Posts</h2>
<strong>Latest Post</strong>: <div class="columns content-list is-multiline">
<a href="{{ latest_blog_post.1 }}">{{ latest_blog_post.0 }}</a> {% for page in recent_posts %}
&rarr; {% include "home/home_page_card.html" %}
{% endfor %}
</div> </div>
{% endif %}
{% if blog_post_list_url %}
<div class="box">
<a href="{{ blog_post_list_url }}">View more &rarr;</a>
</div>
{% endif %}
</section>
</main> </main>
{% endblock %} {% 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/<slug:slug>/", views.TagView.as_view()),
path("tags/", views.TagsView.as_view()), path("tags/", views.TagsView.as_view()),
path("categories/", 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 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") @method_decorator(cache_control(max_age=60 * 60), name="dispatch")
class TagView(RedirectView): class TagView(RedirectView):
permanent = True permanent = True
def get_redirect_url(self, slug: str) -> str: 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) return tag.get_url(request=self.request)

View File

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

View File

@ -5,5 +5,9 @@ from website.common.serializers import PaginationSerializer
MIN_SEARCH_LENGTH = 3 MIN_SEARCH_LENGTH = 3
class SearchParamsSerializer(PaginationSerializer): class SearchParamSerializer(serializers.Serializer):
q = serializers.CharField(min_length=MIN_SEARCH_LENGTH) 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"> <section class="container search-controls">
<div class="field"> <div class="field">
<p class="control has-icons-left has-icons-right"> <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" /> <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"> <span class="icon is-small is-left htmx-indicator search-indicator" id="search-icon">
<i class="fas fa-search"></i> <i class="fas fa-search"></i>
</span> </span>
<span class="icon is-small is-right htmx-indicator" id="search-indicator"> <span class="icon is-small is-left htmx-indicator search-indicator">
<i class="fas fa-circle-notch"></i> <i class="fas fa-spinner fa-pulse"></i>
</span> </span>
</p> </p>
</div> </div>
@ -27,7 +27,7 @@
</div> </div>
<div class="htmx-indicator" id="search-page-indicator"> <div class="htmx-indicator" id="search-page-indicator">
<i class="fas fa-circle-notch"></i> <i class="fas fa-spinner fa-pulse"></i>
</div> </div>
</section> </section>
{% endblock %} {% endblock %}

View File

@ -1,5 +1,6 @@
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.test import TestCase from django.test import TestCase
from django.urls import reverse
from website.common.factories import ContentPageFactory from website.common.factories import ContentPageFactory
from website.home.models import HomePage from website.home.models import HomePage
@ -40,7 +41,7 @@ class SearchPageTestCase(TestCase):
self.assertEqual(search_input.attrs["value"], "") 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-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): class SearchPageResultsTestCase(TestCase):
@ -55,7 +56,7 @@ class SearchPageResultsTestCase(TestCase):
cls.url = cls.page.url + cls.page.reverse_subpage("results") cls.url = cls.page.url + cls.page.reverse_subpage("results")
def test_returns_results(self) -> None: 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") response = self.client.get(self.url, {"q": "post"}, HTTP_HX_REQUEST="true")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -89,7 +90,7 @@ class SearchPageResultsTestCase(TestCase):
) )
def test_too_high_page(self) -> None: def test_too_high_page(self) -> None:
with self.assertNumQueries(49): with self.assertNumQueries(42):
response = self.client.get( response = self.client.get(
self.url, {"q": "post", "page": 30}, HTTP_HX_REQUEST="true" self.url, {"q": "post", "page": 30}, HTTP_HX_REQUEST="true"
) )
@ -110,20 +111,114 @@ class SearchPageResultsTestCase(TestCase):
self.assertContains(response, "No results found") self.assertContains(response, "No results found")
def test_no_query(self) -> None: def test_no_query(self) -> None:
with self.assertNumQueries(7): with self.assertNumQueries(6):
response = self.client.get(self.url, HTTP_HX_REQUEST="true") response = self.client.get(self.url, HTTP_HX_REQUEST="true")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "search/enter-search-term.html") self.assertTemplateUsed(response, "search/enter-search-term.html")
def test_empty_query(self) -> None: 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") response = self.client.get(self.url, {"q": ""}, HTTP_HX_REQUEST="true")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "search/enter-search-term.html") self.assertTemplateUsed(response, "search/enter-search-term.html")
def test_not_htmx(self) -> None: def test_not_htmx(self) -> None:
with self.assertNumQueries(7): with self.assertNumQueries(6):
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertEqual(response.status_code, 400) 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.utils",
"website.well_known", "website.well_known",
"website.legacy", "website.legacy",
"website.talks",
"website.contrib.code_block", "website.contrib.code_block",
"website.contrib.mermaid_block", "website.contrib.mermaid_block",
"website.contrib.unsplash", "website.contrib.unsplash",
@ -398,9 +399,6 @@ SESSION_COOKIE_AGE = 2419200 # About a month
CSRF_COOKIE_SECURE = not DEBUG CSRF_COOKIE_SECURE = not DEBUG
SESSION_COOKIE_HTTPONLY = True 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") SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
PERMISSIONS_POLICY: dict[str, list] = { 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("feed/", AllPagesFeed(), name="feed"),
path(".health/", include("health_check.urls")), path(".health/", include("health_check.urls")),
path("", include("website.legacy.urls")), path("", include("website.legacy.urls")),
path("", include("website.search.urls")),
path("api/", include("website.api.urls", namespace="api")), path("api/", include("website.api.urls", namespace="api")),
path( path(
"@jake", "@jake",
@ -57,6 +58,11 @@ urlpatterns = [
), ),
path("favicon.ico", FaviconView.as_view()), path("favicon.ico", FaviconView.as_view()),
path("", include(favicon_urls)), 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 # Add django-debug-toolbar
urlpatterns.append(path("__debug__/", include("debug_toolbar.urls"))) 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: if settings.DEBUG or settings.TEST:
urlpatterns.extend( urlpatterns.extend(

View File

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

View File

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