Compare commits
47 Commits
4005161237
...
54d42f13b0
Author | SHA1 | Date |
---|---|---|
Renovate | 54d42f13b0 | |
Jake Howard | 9b27baf1ba | |
Jake Howard | 3a8e6182ad | |
Jake Howard | fe43b9c683 | |
Jake Howard | 6cbac34f2d | |
Jake Howard | e5de558958 | |
Jake Howard | 1a9d981c7d | |
Jake Howard | b0f1191d8f | |
Jake Howard | bd4c1a193a | |
Jake Howard | 1934b36ec1 | |
Jake Howard | 23ce49ca8f | |
Jake Howard | 926e62518c | |
Jake Howard | ec609ae562 | |
Jake Howard | a19964199f | |
Jake Howard | c69d8d8329 | |
Jake Howard | 0424c2dba2 | |
Jake Howard | 4c600651b6 | |
Jake Howard | 7d3605f5e1 | |
Jake Howard | ae4ea780b7 | |
Renovate | 552639ec40 | |
Renovate | 316ab7b628 | |
Jake Howard | 5ff5ad113b | |
Jake Howard | 8c72558ca6 | |
Jake Howard | 750fceee02 | |
Jake Howard | 87cac3fecb | |
Jake Howard | 9ba8a505fc | |
Jake Howard | bbf7411f50 | |
Jake Howard | f5a18fdca0 | |
Jake Howard | 8ce25dcf2d | |
Jake Howard | 6f1b823dfa | |
Jake Howard | 59912f6ddb | |
Jake Howard | e9f74ec0c1 | |
Jake Howard | 4e450f6144 | |
Jake Howard | 042cd9f452 | |
Jake Howard | 8e0f948f66 | |
Jake Howard | c36f24b212 | |
Jake Howard | 1ff31828f5 | |
Jake Howard | c8885d19d3 | |
Jake Howard | 166441b3e3 | |
Jake Howard | 307cd7fe26 | |
Jake Howard | 8fe3cdbbc3 | |
Jake Howard | 518461a88f | |
Jake Howard | 48e36bc5b9 | |
Jake Howard | 53479eeea9 | |
Jake Howard | e0ffa6a14d | |
Jake Howard | 3984660e2b | |
Jake Howard | 5d50907ed2 |
|
@ -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,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
|
||||
}
|
||||
}
|
||||
|
|
20
Dockerfile
20
Dockerfile
|
@ -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
|
||||
|
|
|
@ -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+
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
python manage.py migrate --noinput
|
||||
|
||||
exec gunicorn -c etc/gunicorn.conf.py
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
#!/command/with-contenv bash
|
||||
|
||||
set -e
|
||||
|
||||
cd /app
|
||||
|
||||
exec supercronic etc/crontab
|
|
@ -0,0 +1 @@
|
|||
longrun
|
|
@ -0,0 +1,7 @@
|
|||
#!/command/with-contenv bash
|
||||
|
||||
set -e
|
||||
|
||||
cd /app
|
||||
|
||||
exec gunicorn -c etc/gunicorn.conf.py
|
|
@ -0,0 +1 @@
|
|||
longrun
|
|
@ -0,0 +1 @@
|
|||
oneshot
|
|
@ -0,0 +1 @@
|
|||
with-contenv bash -c "cd /app && python manage.py migrate --noinput"
|
|
@ -1,4 +1,4 @@
|
|||
#!/usr/bin/env bash
|
||||
#!/command/with-contenv bash
|
||||
|
||||
set -e
|
||||
|
|
@ -0,0 +1 @@
|
|||
longrun
|
|
@ -1,5 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
#!/command/with-contenv bash
|
||||
|
||||
set -e
|
||||
|
||||
cd /app
|
||||
|
||||
exec python manage.py rqworker --with-scheduler
|
|
@ -0,0 +1 @@
|
|||
longrun
|
2
justfile
2
justfile
|
@ -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 }}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.page-blogpostlistpage {
|
||||
.container.listing {
|
||||
.date-header {
|
||||
font-size: $size-2;
|
||||
font-weight: $weight-bold;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"])
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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")
|
||||
)
|
||||
|
|
|
@ -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`.
|
||||
|
|
|
@ -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" %}
|
||||
|
|
|
@ -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 →</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 →</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 %}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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">…</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">…</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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
{% load wagtailcore_tags %}
|
||||
|
||||
{% spaceless %}
|
||||
{{ obj.content_html | truncatewords_html:100 | safe }}
|
||||
|
||||
<p>
|
||||
<a href="{% fullpageurl obj %}">Continue Reading…</a>
|
||||
</p>
|
||||
{% endspaceless %}
|
|
@ -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 %}
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]:
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
→
|
||||
</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>
|
||||
→
|
||||
<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 →</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
|
@ -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 %}
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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()),
|
||||
]
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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 %}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"),
|
||||
]
|
|
@ -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()}"
|
|
@ -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] = {
|
||||
|
|
|
@ -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
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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
|
||||
)
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue