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
|
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,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
|
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
|
||||||
|
|
|
@ -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+
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
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
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
set -e
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
longrun
|
|
@ -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
|
|
@ -0,0 +1 @@
|
||||||
|
longrun
|
2
justfile
2
justfile
|
@ -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 }}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"])
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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")
|
||||||
|
)
|
||||||
|
|
|
@ -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`.
|
||||||
|
|
|
@ -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" %}
|
||||||
|
|
|
@ -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 →</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 →</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 %}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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">…</span>
|
<span class="pagination-ellipsis">…</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">…</span>
|
<span class="pagination-ellipsis">…</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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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>
|
<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 %}
|
||||||
|
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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():
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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]:
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
→
|
||||||
|
</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 %}
|
||||||
→
|
{% 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 →</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% 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/<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()),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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">
|
<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 %}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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.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] = {
|
||||||
|
|
|
@ -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("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(
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue