Compare commits
93 Commits
fix/csp-go
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 92c1ee425d | |||
| ff587d9e1b | |||
| 0fab9ac0bf | |||
| 607d8eaf85 | |||
| 0dc997d2cf | |||
| 0e35fb0ad3 | |||
| 6ab6c3c0bf | |||
| e75dda84ef | |||
|
|
b0e009d606 | ||
| 3848cb6d23 | |||
| d0a90ce8ff | |||
| 9d7821b94d | |||
| 8e43409895 | |||
| 9b3992f250 | |||
| fbc9a1ff0a | |||
| 1a0617fbd0 | |||
| 15ef35e249 | |||
|
|
a450e7409f
|
||
|
|
10e39b8331
|
||
| 59cc1c41a9 | |||
|
|
2c2cb5446f
|
||
|
|
521075cf04
|
||
| 93d3e4703b | |||
| e09e6a21f0 | |||
|
|
4ea1e66cdf
|
||
| c2ad0e67c3 | |||
|
|
96a3971781 | ||
| 989d0fc20d | |||
|
|
2f9babe18e | ||
| d39fff2be0 | |||
|
|
badd61b0aa | ||
|
|
a001ac1de6 | ||
| 9bee1b9a12 | |||
|
|
4796a08acc | ||
| 17484fa815 | |||
|
|
96b49bb064 | ||
| 3ccb872cc3 | |||
|
|
b2ea693d9d | ||
|
|
48f395866b | ||
|
|
c8e01f5201 | ||
|
|
380dcb22c3 | ||
| ed878bbdae | |||
|
|
0eddb9696a | ||
|
|
c01fc14258 | ||
|
|
88ce59aecc | ||
|
|
a118df487d | ||
|
|
d0a550fee6 | ||
| cc25d2ad2e | |||
|
|
99b06d1f3b | ||
|
|
906206d4cd | ||
| eebd5c9978 | |||
|
|
2acb194d40 | ||
|
|
b897447296 | ||
|
|
d387bf4f03 | ||
|
|
be8d6d4a12 | ||
|
|
2b1e7ff4eb | ||
|
|
2c94040221 | ||
| 2d93555c60 | |||
|
|
73b023dca2 | ||
| 6555fdc41e | |||
|
|
e8b835e6fc | ||
|
|
04a55844fd | ||
|
|
f7ca4bc44b | ||
|
|
7669a5049c | ||
|
|
e2f71a801c | ||
| 49baf6a37d | |||
|
|
d65a802afb | ||
| 6342133851 | |||
|
|
d3687779a2 | ||
|
|
1c5ba6cf90 | ||
| 22d596d666 | |||
|
|
987f308e06 | ||
| bcc9305a00 | |||
|
|
62ff7f5792 | ||
| ad271aa817 | |||
|
|
8a97b6e2a0 | ||
| 43e7068110 | |||
|
|
6bae864c1e | ||
| 17d30a4073 | |||
|
|
0818f71566 | ||
| 3799d76bed | |||
|
|
fbe8546b37 | ||
| a59d21cfcb | |||
|
|
43594777e0 | ||
| f7c89be05c | |||
|
|
2e7949ac23 | ||
| f5c2f87820 | |||
|
|
abbc3c3d1d
|
||
| c028a83bef | |||
|
|
155c8f7569
|
||
| d83f7db57c | |||
|
|
221c8c19c2
|
||
| c0cd4e5037 |
@@ -75,12 +75,22 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
CI_IMAGE: nohype-ci-e2e:${{ github.run_id }}
|
CI_IMAGE: nohype-ci-e2e:${{ github.run_id }}
|
||||||
|
PLAYWRIGHT_CACHE_VOLUME: nohype-playwright-browsers
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: docker build -t "$CI_IMAGE" .
|
run: docker build -t "$CI_IMAGE" .
|
||||||
|
|
||||||
|
- name: Ensure Playwright Chromium cache
|
||||||
|
run: |
|
||||||
|
docker volume create "$PLAYWRIGHT_CACHE_VOLUME" >/dev/null
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PLAYWRIGHT_CACHE_VOLUME:/ms-playwright" \
|
||||||
|
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
||||||
|
"$CI_IMAGE" \
|
||||||
|
python -m playwright install chromium
|
||||||
|
|
||||||
- name: Start PostgreSQL
|
- name: Start PostgreSQL
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name pr-e2e-postgres \
|
docker run -d --name pr-e2e-postgres \
|
||||||
@@ -100,14 +110,15 @@ jobs:
|
|||||||
- name: Start app with seeded content
|
- name: Start app with seeded content
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name pr-e2e-app --network container:pr-e2e-postgres \
|
docker run -d --name pr-e2e-app --network container:pr-e2e-postgres \
|
||||||
-v /opt/playwright-tools/browsers:/opt/playwright-tools/browsers:ro \
|
-v "$PLAYWRIGHT_CACHE_VOLUME:/ms-playwright:ro" \
|
||||||
-e SECRET_KEY=ci-secret-key \
|
-e SECRET_KEY=ci-secret-key \
|
||||||
-e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
|
-e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
|
||||||
-e CONSENT_POLICY_VERSION=1 \
|
-e CONSENT_POLICY_VERSION=1 \
|
||||||
-e EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend \
|
-e EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend \
|
||||||
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
|
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
|
||||||
-e NEWSLETTER_PROVIDER=buttondown \
|
-e NEWSLETTER_PROVIDER=buttondown \
|
||||||
-e PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-tools/browsers \
|
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
||||||
|
-e E2E_MODE=1 \
|
||||||
"$CI_IMAGE" \
|
"$CI_IMAGE" \
|
||||||
sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000"
|
sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000"
|
||||||
for i in $(seq 1 40); do
|
for i in $(seq 1 40); do
|
||||||
@@ -139,10 +150,19 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
CI_IMAGE: nohype-ci-nightly:${{ github.run_id }}
|
CI_IMAGE: nohype-ci-nightly:${{ github.run_id }}
|
||||||
|
PLAYWRIGHT_CACHE_VOLUME: nohype-playwright-browsers
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Build
|
- name: Build
|
||||||
run: docker build -t "$CI_IMAGE" .
|
run: docker build -t "$CI_IMAGE" .
|
||||||
|
- name: Ensure Playwright Chromium cache
|
||||||
|
run: |
|
||||||
|
docker volume create "$PLAYWRIGHT_CACHE_VOLUME" >/dev/null
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PLAYWRIGHT_CACHE_VOLUME:/ms-playwright" \
|
||||||
|
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
||||||
|
"$CI_IMAGE" \
|
||||||
|
python -m playwright install chromium
|
||||||
- name: Start PostgreSQL
|
- name: Start PostgreSQL
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name nightly-postgres \
|
docker run -d --name nightly-postgres \
|
||||||
@@ -161,14 +181,15 @@ jobs:
|
|||||||
- name: Start dev server with seeded content
|
- name: Start dev server with seeded content
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name nightly-e2e --network container:nightly-postgres \
|
docker run -d --name nightly-e2e --network container:nightly-postgres \
|
||||||
-v /opt/playwright-tools/browsers:/opt/playwright-tools/browsers:ro \
|
-v "$PLAYWRIGHT_CACHE_VOLUME:/ms-playwright:ro" \
|
||||||
-e SECRET_KEY=ci-secret-key \
|
-e SECRET_KEY=ci-secret-key \
|
||||||
-e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
|
-e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
|
||||||
-e CONSENT_POLICY_VERSION=1 \
|
-e CONSENT_POLICY_VERSION=1 \
|
||||||
-e EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend \
|
-e EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend \
|
||||||
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
|
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
|
||||||
-e NEWSLETTER_PROVIDER=buttondown \
|
-e NEWSLETTER_PROVIDER=buttondown \
|
||||||
-e PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-tools/browsers \
|
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
||||||
|
-e E2E_MODE=1 \
|
||||||
"$CI_IMAGE" \
|
"$CI_IMAGE" \
|
||||||
sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000"
|
sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000"
|
||||||
for i in $(seq 1 40); do
|
for i in $(seq 1 40); do
|
||||||
@@ -194,12 +215,34 @@ jobs:
|
|||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-latest
|
runs-on:
|
||||||
|
- ubuntu-latest
|
||||||
|
- agent-workspace
|
||||||
|
env:
|
||||||
|
BAO_TOKEN_FILE: /run/openbao-agent-ci_runner/token
|
||||||
steps:
|
steps:
|
||||||
|
- name: Configure SSH via OpenBao CA
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
: "${OPENBAO_ADDR:?OPENBAO_ADDR must be set by the runner environment}"
|
||||||
|
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||||
|
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -q
|
||||||
|
BAO_TOKEN="$(<"$BAO_TOKEN_FILE")"
|
||||||
|
SIGNED_KEY=$(curl -fsS \
|
||||||
|
-H "X-Vault-Token: $BAO_TOKEN" \
|
||||||
|
-H "X-Vault-Request: true" \
|
||||||
|
-X POST \
|
||||||
|
-d "{\"public_key\": \"$(cat ~/.ssh/id_ed25519.pub)\", \"valid_principals\": \"${{ vars.DEPLOY_USER }}\"}" \
|
||||||
|
"${OPENBAO_ADDR}/v1/ssh/sign/${{ vars.DEPLOY_SSH_ROLE }}" \
|
||||||
|
| jq -r '.data.signed_key')
|
||||||
|
[ -n "$SIGNED_KEY" ] && [ "$SIGNED_KEY" != "null" ] \
|
||||||
|
|| { echo "ERROR: failed to sign SSH key via OpenBao CA" >&2; exit 1; }
|
||||||
|
printf '%s\n' "$SIGNED_KEY" > ~/.ssh/id_ed25519-cert.pub
|
||||||
|
unset BAO_TOKEN SIGNED_KEY
|
||||||
|
|
||||||
|
- name: Add deploy host to known_hosts
|
||||||
|
run: ssh-keyscan -H "${{ vars.DEPLOY_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
- name: Deploy to lintel-prod-01
|
- name: Deploy to lintel-prod-01
|
||||||
uses: appleboy/ssh-action@v1
|
run: ssh "${{ vars.DEPLOY_USER }}@${{ vars.DEPLOY_HOST }}" "bash /srv/sum/nohype/app/deploy/deploy.sh"
|
||||||
with:
|
|
||||||
host: ${{ secrets.PROD_SSH_HOST }}
|
|
||||||
username: deploy
|
|
||||||
key: ${{ secrets.PROD_SSH_KEY }}
|
|
||||||
script: bash /srv/sum/nohype/app/deploy/deploy.sh
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ RUN set -eux; \
|
|||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
|
libavif-dev \
|
||||||
curl \
|
curl \
|
||||||
nodejs \
|
nodejs \
|
||||||
npm \
|
npm \
|
||||||
@@ -49,4 +50,9 @@ RUN pip install --upgrade pip && pip install -r requirements/base.txt
|
|||||||
|
|
||||||
COPY . /app
|
COPY . /app
|
||||||
|
|
||||||
|
ARG GIT_SHA=unknown
|
||||||
|
ARG BUILD_ID=unknown
|
||||||
|
ENV GIT_SHA=${GIT_SHA} \
|
||||||
|
BUILD_ID=${BUILD_ID}
|
||||||
|
|
||||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
|
|||||||
123
Makefile
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
DC = docker compose -f /srv/sum/nohype/docker-compose.prod.yml
|
||||||
|
WEB = $(DC) exec web
|
||||||
|
MANAGE = $(WEB) python manage.py
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
# ── Help ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help:
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
||||||
|
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-28s\033[0m %s\n", $$1, $$2}' \
|
||||||
|
| sort
|
||||||
|
|
||||||
|
# ── Docker ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: ## Build / rebuild images
|
||||||
|
$(DC) build
|
||||||
|
|
||||||
|
.PHONY: up
|
||||||
|
up: ## Start services (detached)
|
||||||
|
$(DC) up -d
|
||||||
|
|
||||||
|
.PHONY: run
|
||||||
|
run: ## Start services in foreground (with logs)
|
||||||
|
$(DC) up
|
||||||
|
|
||||||
|
.PHONY: down
|
||||||
|
down: ## Stop and remove containers
|
||||||
|
$(DC) down
|
||||||
|
|
||||||
|
.PHONY: restart
|
||||||
|
restart: ## Restart all services
|
||||||
|
$(DC) restart
|
||||||
|
|
||||||
|
.PHONY: logs
|
||||||
|
logs: ## Tail logs for all services (Ctrl-C to stop)
|
||||||
|
$(DC) logs -f
|
||||||
|
|
||||||
|
.PHONY: logs-web
|
||||||
|
logs-web: ## Tail web service logs
|
||||||
|
$(DC) logs -f web
|
||||||
|
|
||||||
|
.PHONY: ps
|
||||||
|
ps: ## Show running containers
|
||||||
|
$(DC) ps
|
||||||
|
|
||||||
|
# ── Django ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.PHONY: migrate
|
||||||
|
migrate: ## Apply database migrations
|
||||||
|
$(MANAGE) migrate --noinput
|
||||||
|
|
||||||
|
.PHONY: makemigrations
|
||||||
|
makemigrations: ## Create new migrations (pass app= to target an app)
|
||||||
|
$(MANAGE) makemigrations $(app)
|
||||||
|
|
||||||
|
.PHONY: showmigrations
|
||||||
|
showmigrations: ## List all migrations and their status
|
||||||
|
$(MANAGE) showmigrations
|
||||||
|
|
||||||
|
.PHONY: createsuperuser
|
||||||
|
createsuperuser: ## Create a Django superuser interactively
|
||||||
|
$(MANAGE) createsuperuser
|
||||||
|
|
||||||
|
.PHONY: collectstatic
|
||||||
|
collectstatic: ## Collect static files
|
||||||
|
$(MANAGE) collectstatic --noinput
|
||||||
|
|
||||||
|
.PHONY: shell
|
||||||
|
shell: ## Open a Django shell (inside the web container)
|
||||||
|
$(MANAGE) shell
|
||||||
|
|
||||||
|
.PHONY: dbshell
|
||||||
|
dbshell: ## Open a Django database shell
|
||||||
|
$(MANAGE) dbshell
|
||||||
|
|
||||||
|
.PHONY: bash
|
||||||
|
bash: ## Open a bash shell inside the web container
|
||||||
|
$(WEB) bash
|
||||||
|
|
||||||
|
.PHONY: psql
|
||||||
|
psql: ## Open a psql shell in the db container
|
||||||
|
$(DC) exec db psql -U nohype -d nohype
|
||||||
|
|
||||||
|
# ── Tailwind ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.PHONY: tailwind-install
|
||||||
|
tailwind-install: ## Install Tailwind npm dependencies
|
||||||
|
$(MANAGE) tailwind install --no-input
|
||||||
|
|
||||||
|
.PHONY: tailwind-build
|
||||||
|
tailwind-build: ## Build Tailwind CSS
|
||||||
|
$(MANAGE) tailwind build
|
||||||
|
|
||||||
|
.PHONY: tailwind-watch
|
||||||
|
tailwind-watch: ## Watch and rebuild Tailwind CSS on changes
|
||||||
|
$(MANAGE) tailwind start
|
||||||
|
|
||||||
|
# ── Testing ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test: ## Run unit/integration tests with pytest
|
||||||
|
$(DC) exec web pytest $(args)
|
||||||
|
|
||||||
|
.PHONY: test-e2e
|
||||||
|
test-e2e: ## Run Playwright E2E tests
|
||||||
|
$(DC) exec web pytest e2e/ $(args)
|
||||||
|
|
||||||
|
# ── Custom management commands ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.PHONY: seed
|
||||||
|
seed: ## Seed deterministic E2E content
|
||||||
|
$(MANAGE) seed_e2e_content
|
||||||
|
|
||||||
|
.PHONY: check-content
|
||||||
|
check-content: ## Validate live content integrity
|
||||||
|
$(MANAGE) check_content_integrity
|
||||||
|
|
||||||
|
.PHONY: purge-comments
|
||||||
|
purge-comments: ## Purge old comment personal data (pass months=N to override default 24)
|
||||||
|
$(MANAGE) purge_old_comment_data $(if $(months),--months $(months),)
|
||||||
@@ -3,7 +3,7 @@ from django.contrib.syndication.views import Feed
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from taggit.models import Tag
|
from taggit.models import Tag
|
||||||
|
|
||||||
from apps.blog.models import ArticlePage
|
from apps.blog.models import ArticlePage, Category
|
||||||
|
|
||||||
|
|
||||||
class AllArticlesFeed(Feed):
|
class AllArticlesFeed(Feed):
|
||||||
@@ -16,7 +16,7 @@ class AllArticlesFeed(Feed):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def items(self):
|
def items(self):
|
||||||
return ArticlePage.objects.live().order_by("-first_published_at")[:20]
|
return ArticlePage.objects.live().order_by("-published_date")[:20]
|
||||||
|
|
||||||
def item_title(self, item: ArticlePage):
|
def item_title(self, item: ArticlePage):
|
||||||
return item.title
|
return item.title
|
||||||
@@ -25,7 +25,7 @@ class AllArticlesFeed(Feed):
|
|||||||
return item.summary
|
return item.summary
|
||||||
|
|
||||||
def item_pubdate(self, item: ArticlePage):
|
def item_pubdate(self, item: ArticlePage):
|
||||||
return item.first_published_at
|
return item.published_date or item.first_published_at
|
||||||
|
|
||||||
def item_author_name(self, item: ArticlePage):
|
def item_author_name(self, item: ArticlePage):
|
||||||
return item.author.name
|
return item.author.name
|
||||||
@@ -47,4 +47,16 @@ class TagArticlesFeed(AllArticlesFeed):
|
|||||||
return f"No Hype AI — {obj.name}"
|
return f"No Hype AI — {obj.name}"
|
||||||
|
|
||||||
def items(self, obj):
|
def items(self, obj):
|
||||||
return ArticlePage.objects.live().filter(tags=obj).order_by("-first_published_at")[:20]
|
return ArticlePage.objects.live().filter(tags=obj).order_by("-published_date")[:20]
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryArticlesFeed(AllArticlesFeed):
|
||||||
|
def get_object(self, request, category_slug: str):
|
||||||
|
self.request = request
|
||||||
|
return get_object_or_404(Category, slug=category_slug)
|
||||||
|
|
||||||
|
def title(self, obj):
|
||||||
|
return f"No Hype AI — {obj.name}"
|
||||||
|
|
||||||
|
def items(self, obj):
|
||||||
|
return ArticlePage.objects.live().filter(category=obj).order_by("-published_date")[:20]
|
||||||
|
|||||||
86
apps/blog/migrations/0002_category_articlepage_category.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-03-03
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_category(apps, schema_editor):
|
||||||
|
Category = apps.get_model("blog", "Category")
|
||||||
|
Category.objects.get_or_create(
|
||||||
|
slug="general",
|
||||||
|
defaults={
|
||||||
|
"name": "General",
|
||||||
|
"description": "General articles",
|
||||||
|
"colour": "neutral",
|
||||||
|
"sort_order": 0,
|
||||||
|
"show_in_nav": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assign_default_category_to_articles(apps, schema_editor):
|
||||||
|
Category = apps.get_model("blog", "Category")
|
||||||
|
ArticlePage = apps.get_model("blog", "ArticlePage")
|
||||||
|
default_category = Category.objects.get(slug="general")
|
||||||
|
ArticlePage.objects.filter(category__isnull=True).update(category=default_category)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("blog", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Category",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("name", models.CharField(max_length=100, unique=True)),
|
||||||
|
("slug", models.SlugField(unique=True)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"hero_image",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
to="wagtailimages.image",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"colour",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")],
|
||||||
|
default="neutral",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("sort_order", models.IntegerField(default=0)),
|
||||||
|
("show_in_nav", models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
options={"ordering": ["sort_order", "name"]},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="articlepage",
|
||||||
|
name="category",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="+",
|
||||||
|
to="blog.category",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(create_default_category, migrations.RunPython.noop),
|
||||||
|
migrations.RunPython(assign_default_category_to_articles, migrations.RunPython.noop),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="articlepage",
|
||||||
|
name="category",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="+",
|
||||||
|
to="blog.category",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
apps/blog/migrations/0003_add_published_date.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-03-03 13:59
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('blog', '0002_category_articlepage_category'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='articlepage',
|
||||||
|
name='published_date',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Display date for this article. Auto-set on first publish if left blank.', null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
24
apps/blog/migrations/0004_backfill_published_date.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-03-03 13:59
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_published_date(apps, schema_editor):
|
||||||
|
schema_editor.execute(
|
||||||
|
"UPDATE blog_articlepage SET published_date = p.first_published_at "
|
||||||
|
"FROM wagtailcore_page p "
|
||||||
|
"WHERE blog_articlepage.page_ptr_id = p.id "
|
||||||
|
"AND blog_articlepage.published_date IS NULL "
|
||||||
|
"AND p.first_published_at IS NOT NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('blog', '0003_add_published_date'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(backfill_published_date, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-03-19 00:10
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('blog', '0004_backfill_published_date'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='category',
|
||||||
|
options={'ordering': ['sort_order', 'name'], 'verbose_name_plural': 'categories'},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import re
|
import re
|
||||||
from math import ceil
|
from math import ceil
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -7,17 +8,43 @@ from typing import Any
|
|||||||
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import CASCADE, PROTECT, SET_NULL, Prefetch
|
from django.db.models import CASCADE, PROTECT, SET_NULL, Prefetch
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
from django.utils.text import slugify
|
||||||
from modelcluster.contrib.taggit import ClusterTaggableManager
|
from modelcluster.contrib.taggit import ClusterTaggableManager
|
||||||
from modelcluster.fields import ParentalKey
|
from modelcluster.fields import ParentalKey
|
||||||
from taggit.models import Tag, TaggedItemBase
|
from taggit.models import Tag, TaggedItemBase
|
||||||
from wagtail.admin.panels import FieldPanel, PageChooserPanel
|
from wagtail.admin.forms.pages import WagtailAdminPageForm
|
||||||
|
from wagtail.admin.panels import FieldPanel, ObjectList, PageChooserPanel, TabbedInterface
|
||||||
|
from wagtail.contrib.routable_page.models import RoutablePageMixin, route
|
||||||
from wagtail.fields import RichTextField, StreamField
|
from wagtail.fields import RichTextField, StreamField
|
||||||
from wagtail.models import Page
|
from wagtail.models import Page
|
||||||
|
from wagtail.search import index
|
||||||
from wagtailseo.models import SeoMixin
|
from wagtailseo.models import SeoMixin
|
||||||
|
|
||||||
|
from apps.authors.models import Author
|
||||||
from apps.blog.blocks import ARTICLE_BODY_BLOCKS
|
from apps.blog.blocks import ARTICLE_BODY_BLOCKS
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_summary_from_stream(body: Any, *, max_chars: int = 220) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
if body is None:
|
||||||
|
return ""
|
||||||
|
for block in body:
|
||||||
|
if getattr(block, "block_type", None) == "code":
|
||||||
|
continue
|
||||||
|
value = getattr(block, "value", block)
|
||||||
|
text = value.source if hasattr(value, "source") else str(value)
|
||||||
|
clean_text = strip_tags(text)
|
||||||
|
if clean_text:
|
||||||
|
parts.append(clean_text)
|
||||||
|
summary = re.sub(r"\s+", " ", " ".join(parts)).strip()
|
||||||
|
if len(summary) <= max_chars:
|
||||||
|
return summary
|
||||||
|
truncated = summary[:max_chars].rsplit(" ", 1)[0].strip()
|
||||||
|
return truncated or summary[:max_chars].strip()
|
||||||
|
|
||||||
|
|
||||||
class HomePage(Page):
|
class HomePage(Page):
|
||||||
featured_article = models.ForeignKey(
|
featured_article = models.ForeignKey(
|
||||||
"blog.ArticlePage", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
"blog.ArticlePage", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
||||||
@@ -34,18 +61,24 @@ class HomePage(Page):
|
|||||||
articles_qs = (
|
articles_qs = (
|
||||||
ArticlePage.objects.live()
|
ArticlePage.objects.live()
|
||||||
.public()
|
.public()
|
||||||
.select_related("author")
|
.select_related("author", "category")
|
||||||
.prefetch_related("tags__metadata")
|
.prefetch_related("tags__metadata")
|
||||||
.order_by("-first_published_at")
|
.order_by("-published_date")
|
||||||
)
|
)
|
||||||
articles = list(articles_qs[:5])
|
articles = list(articles_qs[:5])
|
||||||
ctx["featured_article"] = self.featured_article
|
ctx["featured_article"] = self.featured_article
|
||||||
ctx["latest_articles"] = articles
|
ctx["latest_articles"] = articles
|
||||||
ctx["more_articles"] = articles[:3]
|
ctx["more_articles"] = articles[:3]
|
||||||
|
ctx["available_tags"] = (
|
||||||
|
Tag.objects.filter(
|
||||||
|
id__in=ArticlePage.objects.live().public().values_list("tags__id", flat=True)
|
||||||
|
).distinct().order_by("name")
|
||||||
|
)
|
||||||
|
ctx["available_categories"] = Category.objects.filter(show_in_nav=True).order_by("sort_order", "name")
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class ArticleIndexPage(Page):
|
class ArticleIndexPage(RoutablePageMixin, Page):
|
||||||
parent_page_types = ["blog.HomePage"]
|
parent_page_types = ["blog.HomePage"]
|
||||||
subpage_types = ["blog.ArticlePage"]
|
subpage_types = ["blog.ArticlePage"]
|
||||||
ARTICLES_PER_PAGE = 12
|
ARTICLES_PER_PAGE = 12
|
||||||
@@ -54,15 +87,24 @@ class ArticleIndexPage(Page):
|
|||||||
return (
|
return (
|
||||||
ArticlePage.objects.child_of(self)
|
ArticlePage.objects.child_of(self)
|
||||||
.live()
|
.live()
|
||||||
.select_related("author")
|
.select_related("author", "category")
|
||||||
.prefetch_related("tags__metadata")
|
.prefetch_related("tags__metadata")
|
||||||
.order_by("-first_published_at")
|
.order_by("-published_date")
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_context(self, request, *args, **kwargs):
|
def get_category_url(self, category):
|
||||||
ctx = super().get_context(request, *args, **kwargs)
|
return f"{self.url}category/{category.slug}/"
|
||||||
|
|
||||||
|
def get_listing_context(self, request, active_category=None):
|
||||||
tag_slug = request.GET.get("tag")
|
tag_slug = request.GET.get("tag")
|
||||||
articles = self.get_articles()
|
articles = self.get_articles()
|
||||||
|
available_categories = Category.objects.order_by("sort_order", "name")
|
||||||
|
category_links = [
|
||||||
|
{"category": category, "url": self.get_category_url(category)}
|
||||||
|
for category in available_categories
|
||||||
|
]
|
||||||
|
if active_category:
|
||||||
|
articles = articles.filter(category=active_category)
|
||||||
available_tags = (
|
available_tags = (
|
||||||
Tag.objects.filter(id__in=articles.values_list("tags__id", flat=True)).distinct().order_by("name")
|
Tag.objects.filter(id__in=articles.values_list("tags__id", flat=True)).distinct().order_by("name")
|
||||||
)
|
)
|
||||||
@@ -76,10 +118,25 @@ class ArticleIndexPage(Page):
|
|||||||
page_obj = paginator.page(1)
|
page_obj = paginator.page(1)
|
||||||
except EmptyPage:
|
except EmptyPage:
|
||||||
page_obj = paginator.page(paginator.num_pages)
|
page_obj = paginator.page(paginator.num_pages)
|
||||||
ctx["articles"] = page_obj
|
return {
|
||||||
ctx["paginator"] = paginator
|
"articles": page_obj,
|
||||||
ctx["active_tag"] = tag_slug
|
"paginator": paginator,
|
||||||
ctx["available_tags"] = available_tags
|
"active_tag": tag_slug,
|
||||||
|
"available_tags": available_tags,
|
||||||
|
"available_categories": available_categories,
|
||||||
|
"category_links": category_links,
|
||||||
|
"active_category": active_category,
|
||||||
|
"active_category_url": self.get_category_url(active_category) if active_category else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
@route(r"^category/(?P<category_slug>[-\w]+)/$")
|
||||||
|
def category_listing(self, request, category_slug):
|
||||||
|
category = get_object_or_404(Category, slug=category_slug)
|
||||||
|
return self.render(request, context_overrides=self.get_listing_context(request, active_category=category))
|
||||||
|
|
||||||
|
def get_context(self, request, *args, **kwargs):
|
||||||
|
ctx = super().get_context(request, *args, **kwargs)
|
||||||
|
ctx.update(self.get_listing_context(request))
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
@@ -87,26 +144,242 @@ class ArticleTag(TaggedItemBase):
|
|||||||
content_object = ParentalKey("blog.ArticlePage", related_name="tagged_items", on_delete=CASCADE)
|
content_object = ParentalKey("blog.ArticlePage", related_name="tagged_items", on_delete=CASCADE)
|
||||||
|
|
||||||
|
|
||||||
|
class Category(models.Model):
|
||||||
|
COLOUR_CHOICES = [("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")]
|
||||||
|
|
||||||
|
name = models.CharField(max_length=100, unique=True)
|
||||||
|
slug = models.SlugField(unique=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
hero_image = models.ForeignKey(
|
||||||
|
"wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
||||||
|
)
|
||||||
|
colour = models.CharField(max_length=20, choices=COLOUR_CHOICES, default="neutral")
|
||||||
|
sort_order = models.IntegerField(default=0)
|
||||||
|
show_in_nav = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
FieldPanel("name"),
|
||||||
|
FieldPanel("slug"),
|
||||||
|
FieldPanel("description"),
|
||||||
|
FieldPanel("hero_image"),
|
||||||
|
FieldPanel("colour"),
|
||||||
|
FieldPanel("sort_order"),
|
||||||
|
FieldPanel("show_in_nav"),
|
||||||
|
]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["sort_order", "name"]
|
||||||
|
verbose_name_plural = "categories"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tag colour palette ────────────────────────────────────────────────────────
|
||||||
|
# Deterministic hash-based colour assignment for tags. Each entry is a dict
|
||||||
|
# with Tailwind CSS class strings for bg, text, and border.
|
||||||
|
|
||||||
|
TAG_COLOUR_PALETTE: list[dict[str, str]] = [
|
||||||
|
{
|
||||||
|
"bg": "bg-brand-cyan/10",
|
||||||
|
"text": "text-brand-cyan",
|
||||||
|
"border": "border-brand-cyan/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "bg-brand-pink/10",
|
||||||
|
"text": "text-brand-pink",
|
||||||
|
"border": "border-brand-pink/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "bg-amber-500/10",
|
||||||
|
"text": "text-amber-400",
|
||||||
|
"border": "border-amber-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "bg-emerald-500/10",
|
||||||
|
"text": "text-emerald-400",
|
||||||
|
"border": "border-emerald-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "bg-violet-500/10",
|
||||||
|
"text": "text-violet-400",
|
||||||
|
"border": "border-violet-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "bg-rose-500/10",
|
||||||
|
"text": "text-rose-400",
|
||||||
|
"border": "border-rose-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "bg-sky-500/10",
|
||||||
|
"text": "text-sky-400",
|
||||||
|
"border": "border-sky-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "bg-lime-500/10",
|
||||||
|
"text": "text-lime-400",
|
||||||
|
"border": "border-lime-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "bg-orange-500/10",
|
||||||
|
"text": "text-orange-400",
|
||||||
|
"border": "border-orange-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "bg-fuchsia-500/10",
|
||||||
|
"text": "text-fuchsia-400",
|
||||||
|
"border": "border-fuchsia-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "bg-teal-500/10",
|
||||||
|
"text": "text-teal-400",
|
||||||
|
"border": "border-teal-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg": "bg-indigo-500/10",
|
||||||
|
"text": "text-indigo-400",
|
||||||
|
"border": "border-indigo-500/20",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_auto_tag_colour_css(tag_name: str) -> dict[str, str]:
|
||||||
|
"""Deterministically assign a colour from the palette based on tag name."""
|
||||||
|
digest = hashlib.md5(tag_name.lower().encode(), usedforsecurity=False).hexdigest() # noqa: S324
|
||||||
|
index = int(digest, 16) % len(TAG_COLOUR_PALETTE)
|
||||||
|
return TAG_COLOUR_PALETTE[index]
|
||||||
|
|
||||||
|
|
||||||
class TagMetadata(models.Model):
|
class TagMetadata(models.Model):
|
||||||
COLOUR_CHOICES = [("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")]
|
COLOUR_CHOICES = [("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")]
|
||||||
|
|
||||||
tag = models.OneToOneField("taggit.Tag", on_delete=CASCADE, related_name="metadata")
|
tag = models.OneToOneField("taggit.Tag", on_delete=CASCADE, related_name="metadata")
|
||||||
colour = models.CharField(max_length=20, choices=COLOUR_CHOICES, default="neutral")
|
colour = models.CharField(max_length=20, choices=COLOUR_CHOICES, default="neutral")
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_fallback_css(cls) -> dict[str, str]:
|
|
||||||
return {"bg": "bg-zinc-100", "text": "text-zinc-800"}
|
|
||||||
|
|
||||||
def get_css_classes(self) -> dict[str, str]:
|
def get_css_classes(self) -> dict[str, str]:
|
||||||
mapping = {
|
mapping = {
|
||||||
"cyan": {"bg": "bg-cyan-100", "text": "text-cyan-900"},
|
"cyan": {
|
||||||
"pink": {"bg": "bg-pink-100", "text": "text-pink-900"},
|
"bg": "bg-brand-cyan/10",
|
||||||
"neutral": self.get_fallback_css(),
|
"text": "text-brand-cyan",
|
||||||
|
"border": "border-brand-cyan/20",
|
||||||
|
},
|
||||||
|
"pink": {
|
||||||
|
"bg": "bg-brand-pink/10",
|
||||||
|
"text": "text-brand-pink",
|
||||||
|
"border": "border-brand-pink/20",
|
||||||
|
},
|
||||||
|
"neutral": {
|
||||||
|
"bg": "bg-zinc-800 dark:bg-zinc-100",
|
||||||
|
"text": "text-white dark:text-black",
|
||||||
|
"border": "border-zinc-600/20 dark:border-zinc-400/20",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return mapping.get(self.colour, self.get_fallback_css())
|
css = mapping.get(self.colour)
|
||||||
|
if css is not None:
|
||||||
|
return css
|
||||||
|
return get_auto_tag_colour_css(self.tag.name)
|
||||||
|
|
||||||
|
|
||||||
|
class ArticlePageAdminForm(WagtailAdminPageForm):
|
||||||
|
SUMMARY_MAX_CHARS = 220
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for name in ("slug", "author", "category", "summary"):
|
||||||
|
if name in self.fields:
|
||||||
|
self.fields[name].required = False
|
||||||
|
|
||||||
|
default_author = self._get_default_author(create=False)
|
||||||
|
if default_author and not self.initial.get("author"):
|
||||||
|
self.initial["author"] = default_author.pk
|
||||||
|
|
||||||
|
default_category = self._get_default_category(create=False)
|
||||||
|
if default_category and not self.initial.get("category"):
|
||||||
|
self.initial["category"] = default_category.pk
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = getattr(self, "cleaned_data", {})
|
||||||
|
self._apply_defaults(cleaned_data)
|
||||||
|
self.cleaned_data = cleaned_data
|
||||||
|
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
self._apply_defaults(cleaned_data)
|
||||||
|
|
||||||
|
if not cleaned_data.get("slug"):
|
||||||
|
self.add_error("slug", "Slug is required.")
|
||||||
|
if not cleaned_data.get("author"):
|
||||||
|
self.add_error("author", "Author is required.")
|
||||||
|
if not cleaned_data.get("category"):
|
||||||
|
self.add_error("category", "Category is required.")
|
||||||
|
if not cleaned_data.get("summary"):
|
||||||
|
self.add_error("summary", "Summary is required.")
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
def _apply_defaults(self, cleaned_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
title = (cleaned_data.get("title") or "").strip()
|
||||||
|
|
||||||
|
if not cleaned_data.get("slug") and title:
|
||||||
|
cleaned_data["slug"] = self._build_unique_page_slug(title)
|
||||||
|
if not cleaned_data.get("author"):
|
||||||
|
cleaned_data["author"] = self._get_default_author(create=True)
|
||||||
|
if not cleaned_data.get("category"):
|
||||||
|
cleaned_data["category"] = self._get_default_category(create=True)
|
||||||
|
if not cleaned_data.get("summary"):
|
||||||
|
cleaned_data["summary"] = _generate_summary_from_stream(
|
||||||
|
cleaned_data.get("body"),
|
||||||
|
max_chars=self.SUMMARY_MAX_CHARS,
|
||||||
|
) or title
|
||||||
|
if not cleaned_data.get("search_description") and cleaned_data.get("summary"):
|
||||||
|
cleaned_data["search_description"] = cleaned_data["summary"]
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
def _get_default_author(self, *, create: bool) -> Author | None:
|
||||||
|
user = self.for_user
|
||||||
|
if not user or not user.is_authenticated:
|
||||||
|
return None
|
||||||
|
existing = Author.objects.filter(user=user).first()
|
||||||
|
if existing or not create:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
base_name = (user.get_full_name() or user.get_username() or f"user-{user.pk}").strip()
|
||||||
|
base_slug = slugify(base_name) or f"user-{user.pk}"
|
||||||
|
slug = base_slug
|
||||||
|
suffix = 2
|
||||||
|
while Author.objects.filter(slug=slug).exists():
|
||||||
|
slug = f"{base_slug}-{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
return Author.objects.create(user=user, name=base_name, slug=slug)
|
||||||
|
|
||||||
|
def _get_default_category(self, *, create: bool):
|
||||||
|
existing = Category.objects.filter(slug="general").first()
|
||||||
|
if existing or not create:
|
||||||
|
return existing
|
||||||
|
category, _ = Category.objects.get_or_create(
|
||||||
|
slug="general",
|
||||||
|
defaults={"name": "General", "description": "General articles", "colour": "neutral"},
|
||||||
|
)
|
||||||
|
return category
|
||||||
|
|
||||||
|
def _build_unique_page_slug(self, title: str) -> str:
|
||||||
|
base_slug = slugify(title) or "article"
|
||||||
|
parent_page = self.parent_page
|
||||||
|
if parent_page is None and self.instance.pk:
|
||||||
|
parent_page = self.instance.get_parent()
|
||||||
|
if parent_page is None:
|
||||||
|
return base_slug
|
||||||
|
|
||||||
|
sibling_pages = parent_page.get_children().exclude(pk=self.instance.pk)
|
||||||
|
slug = base_slug
|
||||||
|
suffix = 2
|
||||||
|
while sibling_pages.filter(slug=slug).exists():
|
||||||
|
slug = f"{base_slug}-{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
return slug
|
||||||
|
|
||||||
|
|
||||||
class ArticlePage(SeoMixin, Page):
|
class ArticlePage(SeoMixin, Page):
|
||||||
|
category = models.ForeignKey("blog.Category", on_delete=PROTECT, related_name="+")
|
||||||
author = models.ForeignKey("authors.Author", on_delete=PROTECT)
|
author = models.ForeignKey("authors.Author", on_delete=PROTECT)
|
||||||
hero_image = models.ForeignKey(
|
hero_image = models.ForeignKey(
|
||||||
"wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
"wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
||||||
@@ -116,27 +389,109 @@ class ArticlePage(SeoMixin, Page):
|
|||||||
tags = ClusterTaggableManager(through="blog.ArticleTag", blank=True)
|
tags = ClusterTaggableManager(through="blog.ArticleTag", blank=True)
|
||||||
read_time_mins = models.PositiveIntegerField(editable=False, default=1)
|
read_time_mins = models.PositiveIntegerField(editable=False, default=1)
|
||||||
comments_enabled = models.BooleanField(default=True)
|
comments_enabled = models.BooleanField(default=True)
|
||||||
|
published_date = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Display date for this article. Auto-set on first publish if left blank.",
|
||||||
|
)
|
||||||
|
|
||||||
parent_page_types = ["blog.ArticleIndexPage"]
|
parent_page_types = ["blog.ArticleIndexPage"]
|
||||||
subpage_types: list[str] = []
|
subpage_types: list[str] = []
|
||||||
|
base_form_class = ArticlePageAdminForm
|
||||||
|
|
||||||
content_panels = Page.content_panels + [
|
content_panels = [
|
||||||
FieldPanel("author"),
|
FieldPanel("title"),
|
||||||
FieldPanel("hero_image"),
|
|
||||||
FieldPanel("summary"),
|
FieldPanel("summary"),
|
||||||
FieldPanel("body"),
|
FieldPanel("body"),
|
||||||
|
]
|
||||||
|
|
||||||
|
metadata_panels = [
|
||||||
|
FieldPanel("category"),
|
||||||
|
FieldPanel("author"),
|
||||||
FieldPanel("tags"),
|
FieldPanel("tags"),
|
||||||
|
FieldPanel("hero_image"),
|
||||||
FieldPanel("comments_enabled"),
|
FieldPanel("comments_enabled"),
|
||||||
]
|
]
|
||||||
|
|
||||||
promote_panels = Page.promote_panels + SeoMixin.seo_panels
|
publishing_panels = [
|
||||||
|
FieldPanel("published_date"),
|
||||||
|
FieldPanel("go_live_at"),
|
||||||
|
FieldPanel("expire_at"),
|
||||||
|
]
|
||||||
|
|
||||||
search_fields = Page.search_fields
|
edit_handler = TabbedInterface(
|
||||||
|
[
|
||||||
|
ObjectList(content_panels, heading="Content"),
|
||||||
|
ObjectList(metadata_panels, heading="Metadata"),
|
||||||
|
ObjectList(publishing_panels, heading="Publishing"),
|
||||||
|
ObjectList(SeoMixin.seo_panels, heading="SEO"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
search_fields = Page.search_fields + [
|
||||||
|
index.SearchField("summary"),
|
||||||
|
index.SearchField("body_text", es_extra={"analyzer": "english"}),
|
||||||
|
index.AutocompleteField("title"),
|
||||||
|
index.RelatedFields("tags", [
|
||||||
|
index.SearchField("name"),
|
||||||
|
]),
|
||||||
|
index.FilterField("category"),
|
||||||
|
index.FilterField("published_date"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def body_text(self) -> str:
|
||||||
|
"""Extract prose text from body StreamField, excluding code blocks."""
|
||||||
|
parts: list[str] = []
|
||||||
|
for block in self.body:
|
||||||
|
if block.block_type == "code":
|
||||||
|
continue
|
||||||
|
value = block.value
|
||||||
|
text = value.source if hasattr(value, "source") else str(value)
|
||||||
|
parts.append(text)
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
def save(self, *args: Any, **kwargs: Any) -> None:
|
def save(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
if not getattr(self, "slug", "") and self.title:
|
||||||
|
self.slug = self._auto_slug_from_title()
|
||||||
|
if not self.category_id:
|
||||||
|
self.category, _ = Category.objects.get_or_create(
|
||||||
|
slug="general",
|
||||||
|
defaults={"name": "General", "description": "General articles", "colour": "neutral"},
|
||||||
|
)
|
||||||
|
if not (self.summary or "").strip():
|
||||||
|
self.summary = _generate_summary_from_stream(self.body) or self.title
|
||||||
|
if not getattr(self, "search_description", "") and self.summary:
|
||||||
|
self.search_description = self.summary
|
||||||
|
if not self.published_date and self.first_published_at:
|
||||||
|
self.published_date = self.first_published_at
|
||||||
|
if self._should_refresh_read_time():
|
||||||
self.read_time_mins = self._compute_read_time()
|
self.read_time_mins = self._compute_read_time()
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def _auto_slug_from_title(self) -> str:
|
||||||
|
base_slug = slugify(self.title) or "article"
|
||||||
|
parent = self.get_parent() if self.pk else None
|
||||||
|
if parent is None:
|
||||||
|
return base_slug
|
||||||
|
sibling_pages = parent.get_children().exclude(pk=self.pk)
|
||||||
|
slug = base_slug
|
||||||
|
suffix = 2
|
||||||
|
while sibling_pages.filter(slug=slug).exists():
|
||||||
|
slug = f"{base_slug}-{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
return slug
|
||||||
|
|
||||||
|
def _should_refresh_read_time(self) -> bool:
|
||||||
|
if not self.pk:
|
||||||
|
return True
|
||||||
|
|
||||||
|
previous = type(self).objects.only("body").filter(pk=self.pk).first()
|
||||||
|
if previous is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return previous.body_text != self.body_text
|
||||||
|
|
||||||
def _compute_read_time(self) -> int:
|
def _compute_read_time(self) -> int:
|
||||||
words = []
|
words = []
|
||||||
for block in self.body:
|
for block in self.body:
|
||||||
@@ -158,14 +513,14 @@ class ArticlePage(SeoMixin, Page):
|
|||||||
.filter(tags__in=tag_ids)
|
.filter(tags__in=tag_ids)
|
||||||
.exclude(pk=self.pk)
|
.exclude(pk=self.pk)
|
||||||
.distinct()
|
.distinct()
|
||||||
.order_by("-first_published_at")[:count]
|
.order_by("-published_date")[:count]
|
||||||
)
|
)
|
||||||
if len(related) < count:
|
if len(related) < count:
|
||||||
exclude_ids = [a.pk for a in related] + [self.pk]
|
exclude_ids = [a.pk for a in related] + [self.pk]
|
||||||
fallback = list(
|
fallback = list(
|
||||||
ArticlePage.objects.live()
|
ArticlePage.objects.live()
|
||||||
.exclude(pk__in=exclude_ids)
|
.exclude(pk__in=exclude_ids)
|
||||||
.order_by("-first_published_at")[: count - len(related)]
|
.order_by("-published_date")[: count - len(related)]
|
||||||
)
|
)
|
||||||
return related + fallback
|
return related + fallback
|
||||||
return related
|
return related
|
||||||
@@ -173,12 +528,20 @@ class ArticlePage(SeoMixin, Page):
|
|||||||
def get_context(self, request, *args, **kwargs):
|
def get_context(self, request, *args, **kwargs):
|
||||||
ctx = super().get_context(request, *args, **kwargs)
|
ctx = super().get_context(request, *args, **kwargs)
|
||||||
ctx["related_articles"] = self.get_related_articles()
|
ctx["related_articles"] = self.get_related_articles()
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from apps.comments.models import Comment
|
from apps.comments.models import Comment
|
||||||
|
from apps.comments.views import _annotate_reaction_counts, _get_session_key
|
||||||
|
|
||||||
approved_replies = Comment.objects.filter(is_approved=True).select_related("parent")
|
approved_replies = Comment.objects.filter(is_approved=True).select_related("parent")
|
||||||
ctx["approved_comments"] = self.comments.filter(is_approved=True, parent__isnull=True).prefetch_related(
|
comments = list(
|
||||||
|
self.comments.filter(is_approved=True, parent__isnull=True).prefetch_related(
|
||||||
Prefetch("replies", queryset=approved_replies)
|
Prefetch("replies", queryset=approved_replies)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
_annotate_reaction_counts(comments, _get_session_key(request))
|
||||||
|
ctx["approved_comments"] = comments
|
||||||
|
ctx["turnstile_site_key"] = getattr(settings, "TURNSTILE_SITE_KEY", "")
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class ArticlePageFactory(wagtail_factories.PageFactory):
|
|||||||
summary = "Summary"
|
summary = "Summary"
|
||||||
body = [("rich_text", "<p>Hello world</p>")]
|
body = [("rich_text", "<p>Hello world</p>")]
|
||||||
first_published_at = factory.LazyFunction(timezone.now)
|
first_published_at = factory.LazyFunction(timezone.now)
|
||||||
|
published_date = factory.LazyFunction(timezone.now)
|
||||||
|
|
||||||
|
|
||||||
class LegalIndexPageFactory(wagtail_factories.PageFactory):
|
class LegalIndexPageFactory(wagtail_factories.PageFactory):
|
||||||
|
|||||||
495
apps/blog/tests/test_admin_experience.py
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.messages.storage.fallback import FallbackStorage
|
||||||
|
from django.contrib.sessions.middleware import SessionMiddleware
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.blog.models import ArticleIndexPage, ArticlePage, ArticlePageAdminForm, Category
|
||||||
|
from apps.blog.tests.factories import AuthorFactory
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_published_date_auto_set_on_first_publish(home_page):
|
||||||
|
"""published_date should be auto-populated from first_published_at on first publish."""
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="Auto Date",
|
||||||
|
slug="auto-date",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
)
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save_revision().publish()
|
||||||
|
article.refresh_from_db()
|
||||||
|
assert article.published_date is not None
|
||||||
|
assert article.published_date == article.first_published_at
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_published_date_preserved_when_explicitly_set(home_page):
|
||||||
|
"""An explicitly set published_date should not be overwritten on save."""
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
custom_date = timezone.now() - timedelta(days=30)
|
||||||
|
article = ArticlePage(
|
||||||
|
title="Custom Date",
|
||||||
|
slug="custom-date",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
published_date=custom_date,
|
||||||
|
)
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save_revision().publish()
|
||||||
|
article.refresh_from_db()
|
||||||
|
assert article.published_date == custom_date
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_homepage_orders_articles_by_published_date(home_page):
|
||||||
|
"""HomePage context should list articles ordered by -published_date."""
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
|
||||||
|
older = ArticlePage(
|
||||||
|
title="Older",
|
||||||
|
slug="older",
|
||||||
|
author=author,
|
||||||
|
summary="s",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
published_date=timezone.now() - timedelta(days=10),
|
||||||
|
)
|
||||||
|
index.add_child(instance=older)
|
||||||
|
older.save_revision().publish()
|
||||||
|
|
||||||
|
newer = ArticlePage(
|
||||||
|
title="Newer",
|
||||||
|
slug="newer",
|
||||||
|
author=author,
|
||||||
|
summary="s",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
published_date=timezone.now(),
|
||||||
|
)
|
||||||
|
index.add_child(instance=newer)
|
||||||
|
newer.save_revision().publish()
|
||||||
|
|
||||||
|
ctx = home_page.get_context(type("Req", (), {"GET": {}})())
|
||||||
|
titles = [a.title for a in ctx["latest_articles"]]
|
||||||
|
assert titles.index("Newer") < titles.index("Older")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_index_orders_by_published_date(home_page, rf):
|
||||||
|
"""ArticleIndexPage.get_articles should order by -published_date."""
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
|
||||||
|
old = ArticlePage(
|
||||||
|
title="Old",
|
||||||
|
slug="old",
|
||||||
|
author=author,
|
||||||
|
summary="s",
|
||||||
|
body=[("rich_text", "<p>b</p>")],
|
||||||
|
published_date=timezone.now() - timedelta(days=5),
|
||||||
|
)
|
||||||
|
index.add_child(instance=old)
|
||||||
|
old.save_revision().publish()
|
||||||
|
|
||||||
|
new = ArticlePage(
|
||||||
|
title="New",
|
||||||
|
slug="new",
|
||||||
|
author=author,
|
||||||
|
summary="s",
|
||||||
|
body=[("rich_text", "<p>b</p>")],
|
||||||
|
published_date=timezone.now(),
|
||||||
|
)
|
||||||
|
index.add_child(instance=new)
|
||||||
|
new.save_revision().publish()
|
||||||
|
|
||||||
|
articles = list(index.get_articles())
|
||||||
|
assert articles[0].title == "New"
|
||||||
|
assert articles[1].title == "Old"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_feed_uses_published_date(article_page):
|
||||||
|
"""RSS feed item_pubdate should use published_date."""
|
||||||
|
from apps.blog.feeds import AllArticlesFeed
|
||||||
|
|
||||||
|
feed = AllArticlesFeed()
|
||||||
|
assert feed.item_pubdate(article_page) == article_page.published_date
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
||||||
|
def test_articles_listing_viewset_loads(client, django_user_model, home_page):
|
||||||
|
"""The Articles PageListingViewSet index page should load."""
|
||||||
|
admin = django_user_model.objects.create_superuser(
|
||||||
|
username="admin", email="admin@example.com", password="admin-pass"
|
||||||
|
)
|
||||||
|
client.force_login(admin)
|
||||||
|
response = client.get("/cms/articles/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
||||||
|
def test_articles_listing_shows_articles(client, django_user_model, home_page):
|
||||||
|
"""The Articles listing should show existing articles."""
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="Listed Article",
|
||||||
|
slug="listed-article",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
)
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save_revision().publish()
|
||||||
|
|
||||||
|
admin = django_user_model.objects.create_superuser(
|
||||||
|
username="admin", email="admin@example.com", password="admin-pass"
|
||||||
|
)
|
||||||
|
client.force_login(admin)
|
||||||
|
response = client.get("/cms/articles/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Listed Article" in response.content.decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
||||||
|
def test_dashboard_panel_renders(client, django_user_model, home_page):
|
||||||
|
"""The Wagtail admin dashboard should include the articles summary panel."""
|
||||||
|
admin = django_user_model.objects.create_superuser(
|
||||||
|
username="admin", email="admin@example.com", password="admin-pass"
|
||||||
|
)
|
||||||
|
client.force_login(admin)
|
||||||
|
response = client.get("/cms/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.content.decode()
|
||||||
|
assert "Articles overview" in content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
||||||
|
def test_dashboard_panel_shows_drafts(client, django_user_model, home_page):
|
||||||
|
"""Dashboard panel should list draft articles."""
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
draft = ArticlePage(
|
||||||
|
title="My Draft Post",
|
||||||
|
slug="draft-post",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
)
|
||||||
|
index.add_child(instance=draft)
|
||||||
|
draft.save_revision() # save revision but don't publish
|
||||||
|
|
||||||
|
admin = django_user_model.objects.create_superuser(
|
||||||
|
username="admin", email="admin@example.com", password="admin-pass"
|
||||||
|
)
|
||||||
|
client.force_login(admin)
|
||||||
|
response = client.get("/cms/")
|
||||||
|
content = response.content.decode()
|
||||||
|
assert "My Draft Post" in content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
||||||
|
def test_article_edit_page_has_tabbed_interface(client, django_user_model, home_page):
|
||||||
|
"""ArticlePage editor should have tabbed panels (Content, Metadata, Publishing, SEO)."""
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="Tabbed",
|
||||||
|
slug="tabbed",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
)
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save_revision().publish()
|
||||||
|
|
||||||
|
admin = django_user_model.objects.create_superuser(
|
||||||
|
username="admin", email="admin@example.com", password="admin-pass"
|
||||||
|
)
|
||||||
|
client.force_login(admin)
|
||||||
|
response = client.get(f"/cms/pages/{article.pk}/edit/")
|
||||||
|
content = response.content.decode()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Content" in content
|
||||||
|
assert "Metadata" in content
|
||||||
|
assert "Publishing" in content
|
||||||
|
assert "SEO" in content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
||||||
|
def test_articles_listing_has_status_filter(client, django_user_model, home_page):
|
||||||
|
"""The Articles listing should accept status filter parameter."""
|
||||||
|
admin = django_user_model.objects.create_superuser(
|
||||||
|
username="admin", email="admin@example.com", password="admin-pass"
|
||||||
|
)
|
||||||
|
client.force_login(admin)
|
||||||
|
response = client.get("/cms/articles/?status=live")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
||||||
|
def test_articles_listing_has_tag_filter(client, django_user_model, home_page):
|
||||||
|
"""The Articles listing should accept tag filter parameter."""
|
||||||
|
admin = django_user_model.objects.create_superuser(
|
||||||
|
username="admin", email="admin@example.com", password="admin-pass"
|
||||||
|
)
|
||||||
|
client.force_login(admin)
|
||||||
|
response = client.get("/cms/articles/?tag=1")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_listing_default_ordering():
|
||||||
|
"""ArticlePageListingViewSet should default to -published_date ordering."""
|
||||||
|
from apps.blog.wagtail_hooks import ArticlePageListingViewSet
|
||||||
|
|
||||||
|
assert ArticlePageListingViewSet.default_ordering == "-published_date"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_search_fields_include_summary():
|
||||||
|
"""ArticlePage.search_fields should index the summary field."""
|
||||||
|
field_names = [
|
||||||
|
f.field_name for f in ArticlePage.search_fields if hasattr(f, "field_name")
|
||||||
|
]
|
||||||
|
assert "summary" in field_names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_admin_form_relaxes_initial_required_fields(article_index, django_user_model):
|
||||||
|
"""Slug/author/category/summary should not block initial draft validation."""
|
||||||
|
user = django_user_model.objects.create_user(
|
||||||
|
username="writer",
|
||||||
|
email="writer@example.com",
|
||||||
|
password="writer-pass",
|
||||||
|
)
|
||||||
|
form_class = ArticlePage.get_edit_handler().get_form_class()
|
||||||
|
form = form_class(parent_page=article_index, for_user=user)
|
||||||
|
|
||||||
|
assert form.fields["slug"].required is False
|
||||||
|
assert form.fields["author"].required is False
|
||||||
|
assert form.fields["category"].required is False
|
||||||
|
assert form.fields["summary"].required is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_admin_form_clean_applies_defaults(article_index, django_user_model, monkeypatch):
|
||||||
|
"""Form clean should populate defaults before parent validation runs."""
|
||||||
|
user = django_user_model.objects.create_user(
|
||||||
|
username="writer",
|
||||||
|
email="writer@example.com",
|
||||||
|
password="writer-pass",
|
||||||
|
first_name="Writer",
|
||||||
|
last_name="User",
|
||||||
|
)
|
||||||
|
form_class = ArticlePage.get_edit_handler().get_form_class()
|
||||||
|
form = form_class(parent_page=article_index, for_user=user)
|
||||||
|
|
||||||
|
body = [
|
||||||
|
SimpleNamespace(block_type="code", value=SimpleNamespace(raw_code="print('ignore')")),
|
||||||
|
SimpleNamespace(block_type="rich_text", value=SimpleNamespace(source="<p>Hello world body text.</p>")),
|
||||||
|
]
|
||||||
|
form.cleaned_data = {
|
||||||
|
"title": "Auto Defaults Title",
|
||||||
|
"slug": "",
|
||||||
|
"author": None,
|
||||||
|
"category": None,
|
||||||
|
"summary": "",
|
||||||
|
"body": body,
|
||||||
|
}
|
||||||
|
observed = {}
|
||||||
|
|
||||||
|
def fake_super_clean(_self):
|
||||||
|
observed["slug_before_parent_clean"] = _self.cleaned_data.get("slug")
|
||||||
|
return _self.cleaned_data
|
||||||
|
|
||||||
|
mro = form.__class__.__mro__
|
||||||
|
super_form_class = mro[mro.index(ArticlePageAdminForm) + 1]
|
||||||
|
monkeypatch.setattr(super_form_class, "clean", fake_super_clean)
|
||||||
|
cleaned = form.clean()
|
||||||
|
|
||||||
|
assert observed["slug_before_parent_clean"] == "auto-defaults-title"
|
||||||
|
assert cleaned["slug"] == "auto-defaults-title"
|
||||||
|
assert cleaned["author"] is not None
|
||||||
|
assert cleaned["author"].user_id == user.id
|
||||||
|
assert cleaned["category"] is not None
|
||||||
|
assert cleaned["category"].slug == "general"
|
||||||
|
assert cleaned["summary"] == "Hello world body text."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_seo_tab_fields_not_duplicated():
|
||||||
|
"""SEO tab should include each promote/SEO field only once."""
|
||||||
|
handler = ArticlePage.get_edit_handler()
|
||||||
|
seo_tab = next(panel for panel in handler.children if panel.heading == "SEO")
|
||||||
|
|
||||||
|
def flatten_field_names(panel):
|
||||||
|
names = []
|
||||||
|
for child in panel.children:
|
||||||
|
if hasattr(child, "field_name"):
|
||||||
|
names.append(child.field_name)
|
||||||
|
else:
|
||||||
|
names.extend(flatten_field_names(child))
|
||||||
|
return names
|
||||||
|
|
||||||
|
field_names = flatten_field_names(seo_tab)
|
||||||
|
assert field_names.count("slug") == 1
|
||||||
|
assert field_names.count("seo_title") == 1
|
||||||
|
assert field_names.count("search_description") == 1
|
||||||
|
assert field_names.count("show_in_menus") == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_save_autogenerates_summary_when_missing(article_index):
|
||||||
|
"""Model save fallback should generate summary from prose blocks."""
|
||||||
|
category = Category.objects.create(name="Guides", slug="guides")
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="Summary Auto",
|
||||||
|
slug="summary-auto",
|
||||||
|
author=author,
|
||||||
|
category=category,
|
||||||
|
summary="",
|
||||||
|
body=[
|
||||||
|
("code", {"language": "python", "filename": "", "raw_code": "print('skip')"}),
|
||||||
|
("rich_text", "<p>This should become the summary text.</p>"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
article_index.add_child(instance=article)
|
||||||
|
article.save()
|
||||||
|
|
||||||
|
assert article.summary == "This should become the summary text."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_category_verbose_name_plural():
|
||||||
|
"""Category Meta should define verbose_name_plural as 'categories'."""
|
||||||
|
assert Category._meta.verbose_name_plural == "categories"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
||||||
|
def test_snippet_category_listing_shows_categories(client, django_user_model):
|
||||||
|
"""Categories created in the database should appear in the Snippets listing."""
|
||||||
|
Category.objects.create(name="Reviews", slug="reviews")
|
||||||
|
Category.objects.create(name="Tutorials", slug="tutorials")
|
||||||
|
|
||||||
|
admin = django_user_model.objects.create_superuser(
|
||||||
|
username="admin-cat", email="admin-cat@example.com", password="admin-pass"
|
||||||
|
)
|
||||||
|
client.force_login(admin)
|
||||||
|
response = client.get("/cms/snippets/blog/category/")
|
||||||
|
content = response.content.decode()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Reviews" in content
|
||||||
|
assert "Tutorials" in content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_admin_form_clean_auto_populates_search_description(article_index, django_user_model, monkeypatch):
|
||||||
|
"""Form clean should auto-populate search_description from summary."""
|
||||||
|
user = django_user_model.objects.create_user(
|
||||||
|
username="writer",
|
||||||
|
email="writer@example.com",
|
||||||
|
password="writer-pass",
|
||||||
|
first_name="Writer",
|
||||||
|
last_name="User",
|
||||||
|
)
|
||||||
|
form_class = ArticlePage.get_edit_handler().get_form_class()
|
||||||
|
form = form_class(parent_page=article_index, for_user=user)
|
||||||
|
|
||||||
|
body = [
|
||||||
|
SimpleNamespace(block_type="rich_text", value=SimpleNamespace(source="<p>Article body text.</p>")),
|
||||||
|
]
|
||||||
|
form.cleaned_data = {
|
||||||
|
"title": "SEO Test",
|
||||||
|
"slug": "",
|
||||||
|
"author": None,
|
||||||
|
"category": None,
|
||||||
|
"summary": "",
|
||||||
|
"search_description": "",
|
||||||
|
"body": body,
|
||||||
|
}
|
||||||
|
|
||||||
|
mro = form.__class__.__mro__
|
||||||
|
super_form_class = mro[mro.index(ArticlePageAdminForm) + 1]
|
||||||
|
monkeypatch.setattr(super_form_class, "clean", lambda _self: _self.cleaned_data)
|
||||||
|
cleaned = form.clean()
|
||||||
|
|
||||||
|
assert cleaned["summary"] == "Article body text."
|
||||||
|
assert cleaned["search_description"] == "Article body text."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_admin_form_preserves_explicit_search_description(article_index, django_user_model, monkeypatch):
|
||||||
|
"""Form clean should not overwrite an explicit search_description."""
|
||||||
|
user = django_user_model.objects.create_user(
|
||||||
|
username="writer2",
|
||||||
|
email="writer2@example.com",
|
||||||
|
password="writer-pass",
|
||||||
|
)
|
||||||
|
form_class = ArticlePage.get_edit_handler().get_form_class()
|
||||||
|
form = form_class(parent_page=article_index, for_user=user)
|
||||||
|
|
||||||
|
body = [
|
||||||
|
SimpleNamespace(block_type="rich_text", value=SimpleNamespace(source="<p>Body.</p>")),
|
||||||
|
]
|
||||||
|
form.cleaned_data = {
|
||||||
|
"title": "SEO Explicit Test",
|
||||||
|
"slug": "seo-explicit-test",
|
||||||
|
"author": None,
|
||||||
|
"category": None,
|
||||||
|
"summary": "My summary.",
|
||||||
|
"search_description": "Custom SEO text.",
|
||||||
|
"body": body,
|
||||||
|
}
|
||||||
|
|
||||||
|
mro = form.__class__.__mro__
|
||||||
|
super_form_class = mro[mro.index(ArticlePageAdminForm) + 1]
|
||||||
|
monkeypatch.setattr(super_form_class, "clean", lambda _self: _self.cleaned_data)
|
||||||
|
cleaned = form.clean()
|
||||||
|
|
||||||
|
assert cleaned["search_description"] == "Custom SEO text."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_page_omits_admin_messages_on_frontend(article_page, rf):
|
||||||
|
"""Frontend templates should not render admin session messages."""
|
||||||
|
request = rf.get(article_page.url)
|
||||||
|
SessionMiddleware(lambda req: None).process_request(request)
|
||||||
|
request.session.save()
|
||||||
|
setattr(request, "_messages", FallbackStorage(request))
|
||||||
|
messages.success(request, "Page 'Test' has been published.")
|
||||||
|
|
||||||
|
response = article_page.serve(request)
|
||||||
|
response.render()
|
||||||
|
content = response.content.decode()
|
||||||
|
|
||||||
|
assert "Page 'Test' has been published." not in content
|
||||||
|
assert 'aria-label="Messages"' not in content
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from apps.blog.feeds import AllArticlesFeed
|
from apps.blog.feeds import AllArticlesFeed
|
||||||
|
from apps.blog.models import ArticleIndexPage, ArticlePage, Category
|
||||||
|
from apps.blog.tests.factories import AuthorFactory
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -16,3 +18,32 @@ def test_all_feed_methods(article_page):
|
|||||||
def test_tag_feed_not_found(client):
|
def test_tag_feed_not_found(client):
|
||||||
resp = client.get("/feed/tag/does-not-exist/")
|
resp = client.get("/feed/tag/does-not-exist/")
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_category_feed_endpoint(client, home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
category = Category.objects.create(name="Reviews", slug="reviews")
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="Feed Review",
|
||||||
|
slug="feed-review",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>Body</p>")],
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save_revision().publish()
|
||||||
|
|
||||||
|
resp = client.get("/feed/category/reviews/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp["Content-Type"].startswith("application/rss+xml")
|
||||||
|
assert "Feed Review" in resp.content.decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_category_feed_not_found(client):
|
||||||
|
resp = client.get("/feed/category/does-not-exist/")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ import pytest
|
|||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from taggit.models import Tag
|
from taggit.models import Tag
|
||||||
|
|
||||||
from apps.blog.models import ArticleIndexPage, ArticlePage, HomePage, TagMetadata
|
from apps.blog.models import (
|
||||||
|
TAG_COLOUR_PALETTE,
|
||||||
|
ArticleIndexPage,
|
||||||
|
ArticlePage,
|
||||||
|
Category,
|
||||||
|
HomePage,
|
||||||
|
TagMetadata,
|
||||||
|
get_auto_tag_colour_css,
|
||||||
|
)
|
||||||
from apps.blog.tests.factories import AuthorFactory
|
from apps.blog.tests.factories import AuthorFactory
|
||||||
|
|
||||||
|
|
||||||
@@ -37,6 +45,163 @@ def test_article_compute_read_time_excludes_code(home_page):
|
|||||||
def test_tag_metadata_css_and_uniqueness():
|
def test_tag_metadata_css_and_uniqueness():
|
||||||
tag = Tag.objects.create(name="llms", slug="llms")
|
tag = Tag.objects.create(name="llms", slug="llms")
|
||||||
meta = TagMetadata.objects.create(tag=tag, colour="cyan")
|
meta = TagMetadata.objects.create(tag=tag, colour="cyan")
|
||||||
assert meta.get_css_classes()["bg"].startswith("bg-cyan")
|
assert meta.get_css_classes()["bg"] == "bg-brand-cyan/10"
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
TagMetadata.objects.create(tag=tag, colour="pink")
|
TagMetadata.objects.create(tag=tag, colour="pink")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_default_category_is_assigned(home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="Categorised",
|
||||||
|
slug="categorised",
|
||||||
|
author=author,
|
||||||
|
summary="s",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
)
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save()
|
||||||
|
assert article.category.slug == "general"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_read_time_is_not_recomputed_when_body_text_is_unchanged(home_page, monkeypatch):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="Stable read time",
|
||||||
|
slug="stable-read-time",
|
||||||
|
author=author,
|
||||||
|
summary="s",
|
||||||
|
body=[("rich_text", "<p>body words</p>")],
|
||||||
|
)
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save()
|
||||||
|
|
||||||
|
def fail_compute():
|
||||||
|
raise AssertionError("read time should not be recomputed when body text is unchanged")
|
||||||
|
|
||||||
|
monkeypatch.setattr(article, "_compute_read_time", fail_compute)
|
||||||
|
article.title = "Retitled"
|
||||||
|
article.save()
|
||||||
|
article.refresh_from_db()
|
||||||
|
|
||||||
|
assert article.read_time_mins == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_category_ordering():
|
||||||
|
Category.objects.get_or_create(name="General", slug="general")
|
||||||
|
Category.objects.create(name="Z", slug="z", sort_order=2)
|
||||||
|
Category.objects.create(name="A", slug="a", sort_order=1)
|
||||||
|
names = list(Category.objects.values_list("name", flat=True))
|
||||||
|
assert names == ["General", "A", "Z"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auto tag colour tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_tag_colour_is_deterministic():
|
||||||
|
"""Same tag name always produces the same colour."""
|
||||||
|
css1 = get_auto_tag_colour_css("python")
|
||||||
|
css2 = get_auto_tag_colour_css("python")
|
||||||
|
assert css1 == css2
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_tag_colour_is_case_insensitive():
|
||||||
|
"""Tag colour assignment is case-insensitive."""
|
||||||
|
assert get_auto_tag_colour_css("Python") == get_auto_tag_colour_css("python")
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_tag_colour_returns_valid_palette_entry():
|
||||||
|
"""Returned CSS dict must be from the palette."""
|
||||||
|
css = get_auto_tag_colour_css("llms")
|
||||||
|
assert css in TAG_COLOUR_PALETTE
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_tag_colour_distributes_across_palette():
|
||||||
|
"""Different tag names should map to multiple palette entries."""
|
||||||
|
sample_tags = ["python", "javascript", "rust", "go", "ruby", "java",
|
||||||
|
"typescript", "css", "html", "sql", "llms", "mlops"]
|
||||||
|
colours = {get_auto_tag_colour_css(t)["text"] for t in sample_tags}
|
||||||
|
assert len(colours) >= 3, "Tags should spread across at least 3 palette colours"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_tag_without_metadata_uses_auto_colour():
|
||||||
|
"""Tags without TagMetadata should get auto-assigned colour, not neutral."""
|
||||||
|
tag = Tag.objects.create(name="fastapi", slug="fastapi")
|
||||||
|
expected = get_auto_tag_colour_css("fastapi")
|
||||||
|
# Verify no metadata exists
|
||||||
|
assert not TagMetadata.objects.filter(tag=tag).exists()
|
||||||
|
# The template tag helper should fall back to auto colour
|
||||||
|
from apps.core.templatetags.core_tags import _resolve_tag_css
|
||||||
|
assert _resolve_tag_css(tag) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_tag_with_metadata_overrides_auto_colour():
|
||||||
|
"""Tags with explicit TagMetadata should use that colour."""
|
||||||
|
tag = Tag.objects.create(name="django", slug="django")
|
||||||
|
TagMetadata.objects.create(tag=tag, colour="pink")
|
||||||
|
from apps.core.templatetags.core_tags import _resolve_tag_css
|
||||||
|
css = _resolve_tag_css(tag)
|
||||||
|
assert css["text"] == "text-brand-pink"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auto slug tests ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_save_auto_generates_slug_from_title(home_page):
|
||||||
|
"""Model save should auto-generate slug from title when slug is empty."""
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="My Great Article",
|
||||||
|
slug="",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
)
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.refresh_from_db()
|
||||||
|
assert article.slug == "my-great-article"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_save_auto_generates_search_description(article_index):
|
||||||
|
"""Model save should populate search_description from summary."""
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="SEO Auto",
|
||||||
|
slug="seo-auto",
|
||||||
|
author=author,
|
||||||
|
summary="This is the article summary.",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
)
|
||||||
|
article_index.add_child(instance=article)
|
||||||
|
article.save()
|
||||||
|
assert article.search_description == "This is the article summary."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_save_preserves_explicit_search_description(article_index):
|
||||||
|
"""Explicit search_description should not be overwritten."""
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="SEO Explicit",
|
||||||
|
slug="seo-explicit",
|
||||||
|
author=author,
|
||||||
|
summary="Generated summary.",
|
||||||
|
search_description="Custom SEO description.",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
)
|
||||||
|
article_index.add_child(instance=article)
|
||||||
|
article.save()
|
||||||
|
assert article.search_description == "Custom SEO description."
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from apps.blog.models import TagMetadata
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_home_context_lists_articles(home_page, article_page):
|
def test_home_context_lists_articles(home_page, article_page):
|
||||||
@@ -22,6 +20,7 @@ def test_get_related_articles_fallback(article_page, article_index):
|
|||||||
assert isinstance(related, list)
|
assert isinstance(related, list)
|
||||||
|
|
||||||
|
|
||||||
def test_tag_metadata_fallback_classes():
|
def test_auto_tag_colour_returns_valid_css():
|
||||||
css = TagMetadata.get_fallback_css()
|
from apps.blog.models import get_auto_tag_colour_css
|
||||||
|
css = get_auto_tag_colour_css("test-tag")
|
||||||
assert css["bg"].startswith("bg-")
|
assert css["bg"].startswith("bg-")
|
||||||
|
|||||||
140
apps/blog/tests/test_search.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from apps.blog.models import ArticleIndexPage, ArticlePage
|
||||||
|
from apps.blog.tests.factories import AuthorFactory
|
||||||
|
from apps.blog.views import MAX_QUERY_LENGTH
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def search_articles(home_page):
|
||||||
|
"""Create an article index with searchable articles."""
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
articles = []
|
||||||
|
for title, summary in [
|
||||||
|
("Understanding LLM Benchmarks", "A deep dive into how language models are evaluated"),
|
||||||
|
("Local Models on Apple Silicon", "Running open-source models on your MacBook"),
|
||||||
|
("Agent Frameworks Compared", "Comparing LangChain, CrewAI, and AutoGen"),
|
||||||
|
]:
|
||||||
|
a = ArticlePage(
|
||||||
|
title=title,
|
||||||
|
slug=title.lower().replace(" ", "-"),
|
||||||
|
author=author,
|
||||||
|
summary=summary,
|
||||||
|
body=[("rich_text", f"<p>{summary} in detail.</p>")],
|
||||||
|
)
|
||||||
|
index.add_child(instance=a)
|
||||||
|
a.save_revision().publish()
|
||||||
|
articles.append(a)
|
||||||
|
return articles
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestSearchView:
|
||||||
|
def test_empty_query_returns_no_results(self, client, home_page):
|
||||||
|
resp = client.get("/search/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.context["query"] == ""
|
||||||
|
assert resp.context["results"] is None
|
||||||
|
|
||||||
|
def test_whitespace_query_returns_no_results(self, client, home_page):
|
||||||
|
resp = client.get("/search/?q= ")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.context["query"] == ""
|
||||||
|
assert resp.context["results"] is None
|
||||||
|
|
||||||
|
def test_search_returns_matching_articles(self, client, search_articles):
|
||||||
|
resp = client.get("/search/?q=benchmarks")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.context["query"] == "benchmarks"
|
||||||
|
assert resp.context["results"] is not None
|
||||||
|
|
||||||
|
def test_search_no_match_returns_empty_page(self, client, search_articles):
|
||||||
|
resp = client.get("/search/?q=zzzznonexistent")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.context["query"] == "zzzznonexistent"
|
||||||
|
# Either None or empty page object
|
||||||
|
results = resp.context["results"]
|
||||||
|
if results is not None:
|
||||||
|
assert len(list(results)) == 0
|
||||||
|
|
||||||
|
def test_query_is_truncated_to_max_length(self, client, home_page):
|
||||||
|
long_query = "a" * 500
|
||||||
|
resp = client.get(f"/search/?q={long_query}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.context["query"]) <= MAX_QUERY_LENGTH
|
||||||
|
|
||||||
|
def test_query_preserved_in_template(self, client, search_articles):
|
||||||
|
resp = client.get("/search/?q=LLM")
|
||||||
|
html = resp.content.decode()
|
||||||
|
assert 'value="LLM"' in html
|
||||||
|
|
||||||
|
def test_search_results_page_renders(self, client, search_articles):
|
||||||
|
resp = client.get("/search/?q=models")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.content.decode()
|
||||||
|
assert "Search" in html
|
||||||
|
|
||||||
|
def test_search_url_resolves(self, client, home_page):
|
||||||
|
from django.urls import reverse
|
||||||
|
assert reverse("search") == "/search/"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestSearchFields:
|
||||||
|
def test_search_fields_include_summary(self):
|
||||||
|
field_names = [
|
||||||
|
f.field_name for f in ArticlePage.search_fields if hasattr(f, "field_name")
|
||||||
|
]
|
||||||
|
assert "summary" in field_names
|
||||||
|
|
||||||
|
def test_search_fields_include_body_text(self):
|
||||||
|
field_names = [
|
||||||
|
f.field_name for f in ArticlePage.search_fields if hasattr(f, "field_name")
|
||||||
|
]
|
||||||
|
assert "body_text" in field_names
|
||||||
|
|
||||||
|
def test_search_fields_include_autocomplete_title(self):
|
||||||
|
from wagtail.search.index import AutocompleteField
|
||||||
|
autocomplete_fields = [
|
||||||
|
f for f in ArticlePage.search_fields if isinstance(f, AutocompleteField)
|
||||||
|
]
|
||||||
|
assert any(f.field_name == "title" for f in autocomplete_fields)
|
||||||
|
|
||||||
|
def test_search_fields_include_related_tags(self):
|
||||||
|
from wagtail.search.index import RelatedFields
|
||||||
|
related = [f for f in ArticlePage.search_fields if isinstance(f, RelatedFields)]
|
||||||
|
assert any(f.field_name == "tags" for f in related)
|
||||||
|
|
||||||
|
def test_body_text_excludes_code_blocks(self):
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="Test",
|
||||||
|
slug="test",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[
|
||||||
|
("rich_text", "<p>prose content here</p>"),
|
||||||
|
("code", {"language": "python", "filename": "", "raw_code": "def secret(): pass"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert "prose content here" in article.body_text
|
||||||
|
assert "secret" not in article.body_text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestSearchNavIntegration:
|
||||||
|
def test_nav_contains_search_form(self, client, home_page):
|
||||||
|
resp = client.get("/")
|
||||||
|
html = resp.content.decode()
|
||||||
|
assert 'role="search"' in html
|
||||||
|
assert 'name="q"' in html
|
||||||
|
assert 'placeholder="Search articles..."' in html
|
||||||
|
|
||||||
|
def test_article_index_contains_search_form(self, client, home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
resp = client.get("/articles/")
|
||||||
|
html = resp.content.decode()
|
||||||
|
assert 'name="q"' in html
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from taggit.models import Tag
|
from taggit.models import Tag
|
||||||
|
|
||||||
from apps.blog.models import ArticleIndexPage, ArticlePage
|
from apps.blog.models import ArticleIndexPage, ArticlePage, Category
|
||||||
from apps.blog.tests.factories import AuthorFactory
|
from apps.blog.tests.factories import AuthorFactory
|
||||||
from apps.comments.models import Comment
|
from apps.comments.models import Comment
|
||||||
|
|
||||||
@@ -69,8 +71,13 @@ def test_newsletter_forms_render_in_nav_and_footer(client, home_page):
|
|||||||
resp = client.get("/")
|
resp = client.get("/")
|
||||||
html = resp.content.decode()
|
html = resp.content.decode()
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert 'name="source" value="nav"' in html
|
# Nav has a search form instead of Subscribe CTA
|
||||||
assert 'name="source" value="footer"' in html
|
assert 'role="search"' in html
|
||||||
|
assert 'name="q"' in html
|
||||||
|
# Footer has Connect section with social/RSS links (no newsletter form)
|
||||||
|
assert "Connect" in html
|
||||||
|
assert 'name="source" value="nav"' not in html
|
||||||
|
assert 'name="source" value="footer"' not in html
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -133,6 +140,54 @@ def test_article_page_renders_approved_comments_and_reply_form(client, home_page
|
|||||||
assert "Top level" in html
|
assert "Top level" in html
|
||||||
assert "Reply" in html
|
assert "Reply" in html
|
||||||
assert f'name="parent_id" value="{comment.id}"' in html
|
assert f'name="parent_id" value="{comment.id}"' in html
|
||||||
|
match = re.search(r'id="comments-empty-state"[^>]*class="([^"]+)"', html)
|
||||||
|
assert match is not None
|
||||||
|
assert "hidden" in match.group(1).split()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_page_shows_empty_state_when_no_approved_comments(client, home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="Main",
|
||||||
|
slug="main",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
)
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save_revision().publish()
|
||||||
|
|
||||||
|
resp = client.get("/articles/main/")
|
||||||
|
html = resp.content.decode()
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert 'id="comments-empty-state"' in html
|
||||||
|
assert "No comments yet. Be the first to comment." in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_page_loads_comment_client_script(client, home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="Main",
|
||||||
|
slug="main",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
)
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save_revision().publish()
|
||||||
|
|
||||||
|
resp = client.get("/articles/main/")
|
||||||
|
html = resp.content.decode()
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert 'src="/static/js/comments.js"' in html
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -157,3 +212,86 @@ def test_article_index_renders_tag_filter_controls(client, home_page):
|
|||||||
html = resp.content.decode()
|
html = resp.content.decode()
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "/articles/?tag=tag-one" in html
|
assert "/articles/?tag=tag-one" in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_index_category_route_filters_articles(client, home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
reviews = Category.objects.create(name="Reviews", slug="reviews")
|
||||||
|
tutorials = Category.objects.create(name="Tutorials", slug="tutorials")
|
||||||
|
review_article = ArticlePage(
|
||||||
|
title="Review A",
|
||||||
|
slug="review-a",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
category=reviews,
|
||||||
|
)
|
||||||
|
tutorial_article = ArticlePage(
|
||||||
|
title="Tutorial A",
|
||||||
|
slug="tutorial-a",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
category=tutorials,
|
||||||
|
)
|
||||||
|
index.add_child(instance=review_article)
|
||||||
|
review_article.save_revision().publish()
|
||||||
|
index.add_child(instance=tutorial_article)
|
||||||
|
tutorial_article.save_revision().publish()
|
||||||
|
|
||||||
|
resp = client.get("/articles/category/reviews/")
|
||||||
|
html = resp.content.decode()
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Review A" in html
|
||||||
|
assert "Tutorial A" not in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_index_category_route_supports_tag_filter(client, home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
reviews = Category.objects.create(name="Reviews", slug="reviews")
|
||||||
|
keep = ArticlePage(
|
||||||
|
title="Keep Me",
|
||||||
|
slug="keep-me",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
category=reviews,
|
||||||
|
)
|
||||||
|
drop = ArticlePage(
|
||||||
|
title="Drop Me",
|
||||||
|
slug="drop-me",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
category=reviews,
|
||||||
|
)
|
||||||
|
index.add_child(instance=keep)
|
||||||
|
keep.save_revision().publish()
|
||||||
|
index.add_child(instance=drop)
|
||||||
|
drop.save_revision().publish()
|
||||||
|
target_tag = Tag.objects.create(name="Python", slug="python")
|
||||||
|
keep.tags.add(target_tag)
|
||||||
|
keep.save_revision().publish()
|
||||||
|
|
||||||
|
resp = client.get("/articles/category/reviews/?tag=python")
|
||||||
|
html = resp.content.decode()
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Keep Me" in html
|
||||||
|
assert "Drop Me" not in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_index_category_route_allows_empty_existing_category(client, home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
Category.objects.create(name="Opinion", slug="opinion")
|
||||||
|
|
||||||
|
resp = client.get("/articles/category/opinion/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "No articles found." in resp.content.decode()
|
||||||
|
|||||||
43
apps/blog/views.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.template.response import TemplateResponse
|
||||||
|
|
||||||
|
from apps.blog.models import ArticlePage
|
||||||
|
|
||||||
|
RESULTS_PER_PAGE = 12
|
||||||
|
MAX_QUERY_LENGTH = 200
|
||||||
|
|
||||||
|
|
||||||
|
def search(request: HttpRequest) -> HttpResponse:
|
||||||
|
query = request.GET.get("q", "").strip()[:MAX_QUERY_LENGTH]
|
||||||
|
results_page = None
|
||||||
|
paginator = None
|
||||||
|
|
||||||
|
if query:
|
||||||
|
results = (
|
||||||
|
ArticlePage.objects.live()
|
||||||
|
.public()
|
||||||
|
.select_related("author", "category")
|
||||||
|
.prefetch_related("tags__metadata")
|
||||||
|
.search(query)
|
||||||
|
)
|
||||||
|
paginator = Paginator(results, RESULTS_PER_PAGE)
|
||||||
|
page_num = request.GET.get("page")
|
||||||
|
try:
|
||||||
|
results_page = paginator.page(page_num)
|
||||||
|
except PageNotAnInteger:
|
||||||
|
results_page = paginator.page(1)
|
||||||
|
except EmptyPage:
|
||||||
|
results_page = paginator.page(paginator.num_pages)
|
||||||
|
|
||||||
|
return TemplateResponse(
|
||||||
|
request,
|
||||||
|
"blog/search_results.html",
|
||||||
|
{
|
||||||
|
"query": query,
|
||||||
|
"results": results_page,
|
||||||
|
"paginator": paginator,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -1,7 +1,22 @@
|
|||||||
|
import django_filters
|
||||||
|
from taggit.models import Tag
|
||||||
|
from wagtail import hooks
|
||||||
|
from wagtail.admin.filters import WagtailFilterSet
|
||||||
|
from wagtail.admin.ui.components import Component
|
||||||
|
from wagtail.admin.ui.tables import Column, DateColumn
|
||||||
|
from wagtail.admin.ui.tables.pages import BulkActionsColumn, PageStatusColumn, PageTitleColumn
|
||||||
|
from wagtail.admin.viewsets.pages import PageListingViewSet
|
||||||
from wagtail.snippets.models import register_snippet
|
from wagtail.snippets.models import register_snippet
|
||||||
from wagtail.snippets.views.snippets import SnippetViewSet
|
from wagtail.snippets.views.snippets import SnippetViewSet
|
||||||
|
|
||||||
from apps.blog.models import TagMetadata
|
from apps.authors.models import Author
|
||||||
|
from apps.blog.models import ArticlePage, Category, TagMetadata
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
("live", "Published"),
|
||||||
|
("draft", "Draft"),
|
||||||
|
("scheduled", "Scheduled"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TagMetadataViewSet(SnippetViewSet):
|
class TagMetadataViewSet(SnippetViewSet):
|
||||||
@@ -11,3 +26,106 @@ class TagMetadataViewSet(SnippetViewSet):
|
|||||||
|
|
||||||
|
|
||||||
register_snippet(TagMetadataViewSet)
|
register_snippet(TagMetadataViewSet)
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryViewSet(SnippetViewSet):
|
||||||
|
model = Category
|
||||||
|
icon = "folder-open-inverse"
|
||||||
|
list_display = ["name", "slug", "show_in_nav", "sort_order"]
|
||||||
|
list_filter = ["show_in_nav"]
|
||||||
|
ordering = ["sort_order", "name"]
|
||||||
|
|
||||||
|
|
||||||
|
register_snippet(CategoryViewSet)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Articles page listing ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class StatusFilter(django_filters.ChoiceFilter):
|
||||||
|
def filter(self, qs, value): # noqa: A003
|
||||||
|
if value == "live":
|
||||||
|
return qs.filter(live=True)
|
||||||
|
if value == "draft":
|
||||||
|
return qs.filter(live=False, go_live_at__isnull=True)
|
||||||
|
if value == "scheduled":
|
||||||
|
return qs.filter(live=False, go_live_at__isnull=False)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleFilterSet(WagtailFilterSet):
|
||||||
|
category = django_filters.ModelChoiceFilter(
|
||||||
|
queryset=Category.objects.all(),
|
||||||
|
empty_label="All categories",
|
||||||
|
)
|
||||||
|
author = django_filters.ModelChoiceFilter(
|
||||||
|
queryset=Author.objects.all(),
|
||||||
|
empty_label="All authors",
|
||||||
|
)
|
||||||
|
status = StatusFilter(
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
empty_label="All statuses",
|
||||||
|
)
|
||||||
|
tag = django_filters.ModelChoiceFilter(
|
||||||
|
field_name="tags",
|
||||||
|
queryset=Tag.objects.all(),
|
||||||
|
empty_label="All tags",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ArticlePage
|
||||||
|
fields = []
|
||||||
|
|
||||||
|
|
||||||
|
class ArticlePageListingViewSet(PageListingViewSet):
|
||||||
|
model = ArticlePage
|
||||||
|
icon = "doc-full"
|
||||||
|
menu_label = "Articles"
|
||||||
|
menu_order = 200
|
||||||
|
add_to_admin_menu = True
|
||||||
|
name = "articles"
|
||||||
|
columns = [
|
||||||
|
BulkActionsColumn("bulk_actions"),
|
||||||
|
PageTitleColumn("title", classname="title"),
|
||||||
|
Column("author", label="Author", sort_key="author__name"),
|
||||||
|
Column("category", label="Category"),
|
||||||
|
DateColumn("published_date", label="Published", sort_key="published_date"),
|
||||||
|
PageStatusColumn("status", sort_key="live"),
|
||||||
|
]
|
||||||
|
filterset_class = ArticleFilterSet
|
||||||
|
default_ordering = "-published_date"
|
||||||
|
|
||||||
|
|
||||||
|
@hooks.register("register_admin_viewset")
|
||||||
|
def register_article_listing():
|
||||||
|
return ArticlePageListingViewSet("articles")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dashboard panel ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class ArticlesSummaryPanel(Component):
|
||||||
|
name = "articles_summary"
|
||||||
|
template_name = "blog/panels/articles_summary.html"
|
||||||
|
order = 110
|
||||||
|
|
||||||
|
def get_context_data(self, parent_context):
|
||||||
|
context = super().get_context_data(parent_context)
|
||||||
|
context["drafts"] = (
|
||||||
|
ArticlePage.objects.not_live()
|
||||||
|
.order_by("-latest_revision_created_at")[:5]
|
||||||
|
)
|
||||||
|
context["scheduled"] = (
|
||||||
|
ArticlePage.objects.filter(go_live_at__isnull=False, live=False)
|
||||||
|
.order_by("go_live_at")[:5]
|
||||||
|
)
|
||||||
|
context["recent"] = (
|
||||||
|
ArticlePage.objects.live()
|
||||||
|
.order_by("-published_date")[:5]
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@hooks.register("construct_homepage_panels")
|
||||||
|
def add_articles_summary_panel(request, panels):
|
||||||
|
panels.append(ArticlesSummaryPanel())
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from datetime import timedelta
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.comments.models import Comment
|
from apps.comments.models import Comment, CommentReaction
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -29,3 +29,10 @@ class Command(BaseCommand):
|
|||||||
.update(author_email="", ip_address=None)
|
.update(author_email="", ip_address=None)
|
||||||
)
|
)
|
||||||
self.stdout.write(self.style.SUCCESS(f"Purged personal data for {purged} comment(s)."))
|
self.stdout.write(self.style.SUCCESS(f"Purged personal data for {purged} comment(s)."))
|
||||||
|
|
||||||
|
reactions_purged = (
|
||||||
|
CommentReaction.objects.filter(created_at__lt=cutoff)
|
||||||
|
.exclude(session_key="")
|
||||||
|
.update(session_key="")
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Purged session keys for {reactions_purged} reaction(s)."))
|
||||||
|
|||||||
27
apps/comments/migrations/0002_commentreaction.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-03-03 22:49
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('comments', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CommentReaction',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('reaction_type', models.CharField(choices=[('heart', '❤️'), ('plus_one', '👍')], max_length=20)),
|
||||||
|
('session_key', models.CharField(max_length=64)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='comments.comment')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('comment', 'reaction_type', 'session_key'), name='unique_comment_reaction_per_session')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -23,3 +23,21 @@ class Comment(models.Model):
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"Comment by {self.author_name}"
|
return f"Comment by {self.author_name}"
|
||||||
|
|
||||||
|
|
||||||
|
class CommentReaction(models.Model):
|
||||||
|
comment = models.ForeignKey(Comment, on_delete=models.CASCADE, related_name="reactions")
|
||||||
|
reaction_type = models.CharField(max_length=20, choices=[("heart", "❤️"), ("plus_one", "👍")])
|
||||||
|
session_key = models.CharField(max_length=64)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["comment", "reaction_type", "session_key"],
|
||||||
|
name="unique_comment_reaction_per_session",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.reaction_type} on comment {self.comment_id}"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
from apps.blog.models import ArticleIndexPage, ArticlePage
|
from apps.blog.models import ArticleIndexPage, ArticlePage
|
||||||
from apps.blog.tests.factories import AuthorFactory
|
from apps.blog.tests.factories import AuthorFactory
|
||||||
@@ -79,3 +80,18 @@ def test_bulk_approve_action_marks_selected_pending_comments_as_approved(home_pa
|
|||||||
assert child_updates == 0
|
assert child_updates == 0
|
||||||
assert pending.is_approved is True
|
assert pending.is_approved is True
|
||||||
assert approved.is_approved is True
|
assert approved.is_approved is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
||||||
|
def test_comments_snippet_index_page_loads(client, django_user_model, home_page):
|
||||||
|
admin = django_user_model.objects.create_superuser(
|
||||||
|
username="admin",
|
||||||
|
email="admin@example.com",
|
||||||
|
password="admin-pass",
|
||||||
|
)
|
||||||
|
client.force_login(admin)
|
||||||
|
|
||||||
|
response = client.get("/cms/snippets/comments/comment/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
from apps.comments.forms import CommentForm
|
from apps.comments.forms import CommentForm
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ def test_comment_form_rejects_blank_body():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
@override_settings(COMMENT_RATE_LIMIT_PER_MINUTE=3)
|
||||||
def test_comment_rate_limit(client, article_page):
|
def test_comment_rate_limit(client, article_page):
|
||||||
cache.clear()
|
cache.clear()
|
||||||
payload = {
|
payload = {
|
||||||
|
|||||||
350
apps/comments/tests/test_v2.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
"""Tests for Comments v2: HTMX, Turnstile, reactions, polling, CSP."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.blog.models import ArticleIndexPage, ArticlePage
|
||||||
|
from apps.blog.tests.factories import AuthorFactory
|
||||||
|
from apps.comments.models import Comment, CommentReaction
|
||||||
|
|
||||||
|
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _article(home_page):
|
||||||
|
"""Create a published article with comments enabled."""
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="Test Article",
|
||||||
|
slug="test-article",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
)
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save_revision().publish()
|
||||||
|
return article
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def approved_comment(_article):
|
||||||
|
return Comment.objects.create(
|
||||||
|
article=_article,
|
||||||
|
author_name="Alice",
|
||||||
|
author_email="alice@example.com",
|
||||||
|
body="Great article!",
|
||||||
|
is_approved=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _post_comment(client, article, extra=None, htmx=False):
|
||||||
|
cache.clear()
|
||||||
|
payload = {
|
||||||
|
"article_id": article.id,
|
||||||
|
"author_name": "Test",
|
||||||
|
"author_email": "test@example.com",
|
||||||
|
"body": "Hello world",
|
||||||
|
"honeypot": "",
|
||||||
|
}
|
||||||
|
if extra:
|
||||||
|
payload.update(extra)
|
||||||
|
headers = {}
|
||||||
|
if htmx:
|
||||||
|
headers["HTTP_HX_REQUEST"] = "true"
|
||||||
|
return client.post("/comments/post/", payload, **headers)
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTMX Response Contracts ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_htmx_post_returns_form_with_moderation_on_success(client, _article):
|
||||||
|
"""HTMX POST with Turnstile disabled returns fresh form + moderation message."""
|
||||||
|
resp = _post_comment(client, _article, htmx=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"awaiting moderation" in resp.content
|
||||||
|
# Response swaps the form container (contains form + success message)
|
||||||
|
assert b"comment-form-container" in resp.content
|
||||||
|
assert "HX-Request" in resp["Vary"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
|
||||||
|
def test_htmx_post_returns_form_plus_oob_comment_when_approved(client, _article):
|
||||||
|
"""HTMX POST with successful Turnstile returns fresh form + OOB comment."""
|
||||||
|
with patch("apps.comments.views._verify_turnstile", return_value=True):
|
||||||
|
resp = _post_comment(client, _article, extra={"cf-turnstile-response": "tok"}, htmx=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
content = resp.content.decode()
|
||||||
|
# Fresh form container is the primary response
|
||||||
|
assert "comment-form-container" in content
|
||||||
|
assert "Comment posted!" in content
|
||||||
|
# OOB swap appends the comment to #comments-list
|
||||||
|
assert "hx-swap-oob" in content
|
||||||
|
assert "Hello world" in content
|
||||||
|
assert 'id="comments-empty-state" hx-swap-oob="delete"' in content
|
||||||
|
comment = Comment.objects.get()
|
||||||
|
assert comment.is_approved is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_htmx_post_returns_form_with_errors_on_invalid(client, _article):
|
||||||
|
"""HTMX POST with invalid data returns form with errors (HTTP 200)."""
|
||||||
|
cache.clear()
|
||||||
|
resp = client.post(
|
||||||
|
"/comments/post/",
|
||||||
|
{"article_id": _article.id, "author_name": "T", "author_email": "t@t.com", "body": " ", "honeypot": ""},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"comment-form-container" in resp.content
|
||||||
|
assert b"Comment form errors" in resp.content
|
||||||
|
assert "HX-Request" in resp["Vary"]
|
||||||
|
assert Comment.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
|
||||||
|
def test_htmx_reply_returns_oob_reply_when_approved(client, _article, approved_comment):
|
||||||
|
"""Approved reply via HTMX returns compact reply partial via OOB swap."""
|
||||||
|
cache.clear()
|
||||||
|
with patch("apps.comments.views._verify_turnstile", return_value=True):
|
||||||
|
resp = client.post(
|
||||||
|
"/comments/post/",
|
||||||
|
{
|
||||||
|
"article_id": _article.id,
|
||||||
|
"parent_id": approved_comment.id,
|
||||||
|
"author_name": "Replier",
|
||||||
|
"author_email": "r@r.com",
|
||||||
|
"body": "Nice reply",
|
||||||
|
"honeypot": "",
|
||||||
|
"cf-turnstile-response": "tok",
|
||||||
|
},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
content = resp.content.decode()
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# OOB targets a stable, explicit replies container for the parent comment.
|
||||||
|
assert f'hx-swap-oob="beforeend:#replies-for-{approved_comment.id}"' in content
|
||||||
|
# Verify content is rendered (not empty due to context mismatch)
|
||||||
|
assert "Replier" in content
|
||||||
|
assert "Nice reply" in content
|
||||||
|
reply = Comment.objects.exclude(pk=approved_comment.pk).get()
|
||||||
|
assert f"comment-{reply.id}" in content
|
||||||
|
assert reply.parent_id == approved_comment.id
|
||||||
|
assert reply.is_approved is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_non_htmx_post_still_redirects(client, _article):
|
||||||
|
"""Non-HTMX POST continues to redirect (progressive enhancement)."""
|
||||||
|
resp = _post_comment(client, _article)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert resp["Location"].endswith("?commented=pending")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_htmx_error_with_tampered_parent_id_falls_back_to_main_form(client, _article):
|
||||||
|
"""Tampered/non-numeric parent_id falls back to main form error response."""
|
||||||
|
cache.clear()
|
||||||
|
resp = client.post(
|
||||||
|
"/comments/post/",
|
||||||
|
{"article_id": _article.id, "parent_id": "not-a-number", "author_name": "T",
|
||||||
|
"author_email": "t@t.com", "body": " ", "honeypot": ""},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"comment-form-container" in resp.content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_htmx_invalid_reply_rerenders_reply_form_with_values(client, _article, approved_comment):
|
||||||
|
"""Invalid reply keeps user input and returns the reply form container."""
|
||||||
|
cache.clear()
|
||||||
|
resp = client.post(
|
||||||
|
"/comments/post/",
|
||||||
|
{
|
||||||
|
"article_id": _article.id,
|
||||||
|
"parent_id": approved_comment.id,
|
||||||
|
"author_name": "Reply User",
|
||||||
|
"author_email": "reply@example.com",
|
||||||
|
"body": " ",
|
||||||
|
"honeypot": "",
|
||||||
|
},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
content = resp.content.decode()
|
||||||
|
assert f'id="reply-form-container-{approved_comment.id}"' in content
|
||||||
|
assert "Comment form errors" in content
|
||||||
|
assert 'value="Reply User"' in content
|
||||||
|
assert "reply@example.com" in content
|
||||||
|
|
||||||
|
|
||||||
|
# ── Turnstile Integration ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
|
||||||
|
def test_turnstile_failure_keeps_comment_unapproved(client, _article):
|
||||||
|
"""When Turnstile verification fails, comment stays unapproved."""
|
||||||
|
with patch("apps.comments.views._verify_turnstile", return_value=False):
|
||||||
|
_post_comment(client, _article, extra={"cf-turnstile-response": "bad-tok"})
|
||||||
|
comment = Comment.objects.get()
|
||||||
|
assert comment.is_approved is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_turnstile_disabled_keeps_comment_unapproved(client, _article):
|
||||||
|
"""When TURNSTILE_SECRET_KEY is empty, comment stays unapproved."""
|
||||||
|
_post_comment(client, _article)
|
||||||
|
comment = Comment.objects.get()
|
||||||
|
assert comment.is_approved is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(TURNSTILE_SECRET_KEY="test-secret", TURNSTILE_EXPECTED_HOSTNAME="nohypeai.com")
|
||||||
|
def test_turnstile_hostname_mismatch_rejects(client, _article):
|
||||||
|
"""Turnstile hostname mismatch keeps comment unapproved."""
|
||||||
|
mock_resp = type("R", (), {"json": lambda self: {"success": True, "hostname": "evil.com"}})()
|
||||||
|
with patch("apps.comments.views.http_requests.post", return_value=mock_resp):
|
||||||
|
_post_comment(client, _article, extra={"cf-turnstile-response": "tok"})
|
||||||
|
comment = Comment.objects.get()
|
||||||
|
assert comment.is_approved is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
|
||||||
|
def test_turnstile_timeout_fails_closed(client, _article):
|
||||||
|
"""Network error during Turnstile verification fails closed."""
|
||||||
|
with patch("apps.comments.views.http_requests.post", side_effect=Exception("timeout")):
|
||||||
|
_post_comment(client, _article, extra={"cf-turnstile-response": "tok"})
|
||||||
|
comment = Comment.objects.get()
|
||||||
|
assert comment.is_approved is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Polling ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_comment_poll_returns_new_comments(_article, client, approved_comment):
|
||||||
|
"""Poll endpoint returns only comments after the given ID."""
|
||||||
|
resp = client.get(f"/comments/poll/{_article.id}/?after_id=0")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"Alice" in resp.content
|
||||||
|
|
||||||
|
resp2 = client.get(f"/comments/poll/{_article.id}/?after_id={approved_comment.id}")
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
assert b"Alice" not in resp2.content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_comment_poll_no_duplicates(_article, client, approved_comment):
|
||||||
|
"""Polling with current latest ID returns empty."""
|
||||||
|
resp = client.get(f"/comments/poll/{_article.id}/?after_id={approved_comment.id}")
|
||||||
|
assert b"comment-" not in resp.content
|
||||||
|
|
||||||
|
|
||||||
|
# ── Reactions ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_react_creates_reaction(client, approved_comment):
|
||||||
|
cache.clear()
|
||||||
|
resp = client.post(
|
||||||
|
f"/comments/{approved_comment.id}/react/",
|
||||||
|
{"reaction_type": "heart"},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert CommentReaction.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_react_toggle_removes_reaction(client, approved_comment):
|
||||||
|
"""Second reaction of same type removes it (toggle)."""
|
||||||
|
cache.clear()
|
||||||
|
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "heart"})
|
||||||
|
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "heart"})
|
||||||
|
assert CommentReaction.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_react_different_types_coexist(client, approved_comment):
|
||||||
|
cache.clear()
|
||||||
|
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "heart"})
|
||||||
|
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "plus_one"})
|
||||||
|
assert CommentReaction.objects.count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_react_invalid_type_returns_400(client, approved_comment):
|
||||||
|
cache.clear()
|
||||||
|
resp = client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "invalid"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_react_on_unapproved_comment_returns_404(client, _article):
|
||||||
|
cache.clear()
|
||||||
|
comment = Comment.objects.create(
|
||||||
|
article=_article, author_name="B", author_email="b@b.com", body="x", is_approved=False,
|
||||||
|
)
|
||||||
|
resp = client.post(f"/comments/{comment.id}/react/", {"reaction_type": "heart"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(REACTION_RATE_LIMIT_PER_MINUTE=2)
|
||||||
|
def test_react_rate_limit(client, approved_comment):
|
||||||
|
cache.clear()
|
||||||
|
for _ in range(2):
|
||||||
|
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "heart"})
|
||||||
|
resp = client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "plus_one"})
|
||||||
|
assert resp.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
# ── CSP ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_csp_allows_turnstile(client, _article):
|
||||||
|
"""CSP header includes Cloudflare Turnstile domains."""
|
||||||
|
resp = client.get(_article.url)
|
||||||
|
csp = resp.get("Content-Security-Policy", "")
|
||||||
|
assert "challenges.cloudflare.com" in csp
|
||||||
|
assert "frame-src" in csp
|
||||||
|
|
||||||
|
|
||||||
|
# ── Purge Command Extension ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_purge_clears_reaction_session_keys(home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "<p>b</p>")])
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save_revision().publish()
|
||||||
|
|
||||||
|
comment = Comment.objects.create(
|
||||||
|
article=article, author_name="X", author_email="x@x.com", body="y", is_approved=True,
|
||||||
|
)
|
||||||
|
reaction = CommentReaction.objects.create(
|
||||||
|
comment=comment, reaction_type="heart", session_key="abc123",
|
||||||
|
)
|
||||||
|
CommentReaction.objects.filter(pk=reaction.pk).update(created_at=timezone.now() - timedelta(days=800))
|
||||||
|
|
||||||
|
call_command("purge_old_comment_data")
|
||||||
|
reaction.refresh_from_db()
|
||||||
|
assert reaction.session_key == ""
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
@@ -28,10 +30,64 @@ def test_comment_post_flow(client, home_page):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 302
|
assert resp.status_code == 302
|
||||||
assert resp["Location"].endswith("?commented=1")
|
assert resp["Location"].endswith("?commented=pending")
|
||||||
assert Comment.objects.count() == 1
|
assert Comment.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_comment_post_redirect_banner_renders_on_article_page(client, home_page):
|
||||||
|
cache.clear()
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "<p>body</p>")])
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save_revision().publish()
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/comments/post/",
|
||||||
|
{
|
||||||
|
"article_id": article.id,
|
||||||
|
"author_name": "Test",
|
||||||
|
"author_email": "test@example.com",
|
||||||
|
"body": "Hello",
|
||||||
|
"honeypot": "",
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"Your comment has been posted and is awaiting moderation." in resp.content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
|
||||||
|
def test_comment_post_redirect_banner_renders_approved_state(client, home_page):
|
||||||
|
cache.clear()
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "<p>body</p>")])
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save_revision().publish()
|
||||||
|
|
||||||
|
with patch("apps.comments.views._verify_turnstile", return_value=True):
|
||||||
|
resp = client.post(
|
||||||
|
"/comments/post/",
|
||||||
|
{
|
||||||
|
"article_id": article.id,
|
||||||
|
"author_name": "Test",
|
||||||
|
"author_email": "test@example.com",
|
||||||
|
"body": "Hello",
|
||||||
|
"honeypot": "",
|
||||||
|
"cf-turnstile-response": "tok",
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"Comment posted!" in resp.content
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_comment_post_rejected_when_comments_disabled(client, home_page):
|
def test_comment_post_rejected_when_comments_disabled(client, home_page):
|
||||||
cache.clear()
|
cache.clear()
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from apps.comments.views import CommentCreateView
|
from apps.comments.views import CommentCreateView, comment_poll, comment_react
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("post/", CommentCreateView.as_view(), name="comment_post"),
|
path("post/", CommentCreateView.as_view(), name="comment_post"),
|
||||||
|
path("poll/<int:article_id>/", comment_poll, name="comment_poll"),
|
||||||
|
path("<int:comment_id>/react/", comment_react, name="comment_react"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import requests as http_requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.http import HttpResponse
|
from django.db import IntegrityError
|
||||||
|
from django.db.models import Count, Prefetch
|
||||||
|
from django.http import HttpResponse, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.cache import patch_vary_headers
|
||||||
from django.views import View
|
from django.views import View
|
||||||
|
from django.views.decorators.http import require_GET, require_POST
|
||||||
|
|
||||||
from apps.blog.models import ArticlePage
|
from apps.blog.models import ArticlePage
|
||||||
from apps.comments.forms import CommentForm
|
from apps.comments.forms import CommentForm
|
||||||
from apps.comments.models import Comment
|
from apps.comments.models import Comment, CommentReaction
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def client_ip_from_request(request) -> str:
|
def client_ip_from_request(request) -> str:
|
||||||
@@ -22,18 +31,166 @@ def client_ip_from_request(request) -> str:
|
|||||||
return remote_addr
|
return remote_addr
|
||||||
|
|
||||||
|
|
||||||
|
def _is_htmx(request) -> bool:
|
||||||
|
return request.headers.get("HX-Request") == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def _add_vary_header(response):
|
||||||
|
patch_vary_headers(response, ["HX-Request"])
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _comment_redirect(article: ArticlePage, *, approved: bool):
|
||||||
|
state = "approved" if approved else "pending"
|
||||||
|
return redirect(f"{article.url}?commented={state}")
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_turnstile(token: str, ip: str) -> bool:
|
||||||
|
secret = getattr(settings, "TURNSTILE_SECRET_KEY", "")
|
||||||
|
if not secret:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
resp = http_requests.post(
|
||||||
|
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||||
|
data={"secret": secret, "response": token, "remoteip": ip},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
result = resp.json()
|
||||||
|
if not result.get("success"):
|
||||||
|
return False
|
||||||
|
expected_hostname = getattr(settings, "TURNSTILE_EXPECTED_HOSTNAME", "")
|
||||||
|
if expected_hostname and result.get("hostname") != expected_hostname:
|
||||||
|
logger.warning("Turnstile hostname mismatch: %s", result.get("hostname"))
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Turnstile verification failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _turnstile_enabled() -> bool:
|
||||||
|
return bool(getattr(settings, "TURNSTILE_SECRET_KEY", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_session_key(request) -> str:
|
||||||
|
session = getattr(request, "session", None)
|
||||||
|
return (session.session_key or "") if session else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _turnstile_site_key():
|
||||||
|
return getattr(settings, "TURNSTILE_SITE_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _annotate_reaction_counts(comments, session_key=""):
|
||||||
|
"""Hydrate each comment with reaction_counts dict and user_reacted set."""
|
||||||
|
comment_ids = [c.id for c in comments]
|
||||||
|
if not comment_ids:
|
||||||
|
return comments
|
||||||
|
|
||||||
|
counts_qs = (
|
||||||
|
CommentReaction.objects.filter(comment_id__in=comment_ids)
|
||||||
|
.values("comment_id", "reaction_type")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
)
|
||||||
|
counts_map = {}
|
||||||
|
for row in counts_qs:
|
||||||
|
counts_map.setdefault(row["comment_id"], {"heart": 0, "plus_one": 0})
|
||||||
|
counts_map[row["comment_id"]][row["reaction_type"]] = row["count"]
|
||||||
|
|
||||||
|
user_map = {}
|
||||||
|
if session_key:
|
||||||
|
user_qs = CommentReaction.objects.filter(
|
||||||
|
comment_id__in=comment_ids, session_key=session_key
|
||||||
|
).values_list("comment_id", "reaction_type")
|
||||||
|
for cid, rtype in user_qs:
|
||||||
|
user_map.setdefault(cid, set()).add(rtype)
|
||||||
|
|
||||||
|
for comment in comments:
|
||||||
|
comment.reaction_counts = counts_map.get(comment.id, {"heart": 0, "plus_one": 0})
|
||||||
|
comment.user_reacted = user_map.get(comment.id, set())
|
||||||
|
|
||||||
|
return comments
|
||||||
|
|
||||||
|
|
||||||
|
def _comment_template_context(comment, article, request):
|
||||||
|
"""Build template context for a single comment partial."""
|
||||||
|
_annotate_reaction_counts([comment], _get_session_key(request))
|
||||||
|
return {
|
||||||
|
"comment": comment,
|
||||||
|
"page": article,
|
||||||
|
"turnstile_site_key": _turnstile_site_key(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class CommentCreateView(View):
|
class CommentCreateView(View):
|
||||||
def _render_article_with_errors(self, request, article, form):
|
def _render_htmx_error(self, request, article, form):
|
||||||
context = article.get_context(request)
|
"""Return error form partial for HTMX — swaps the form container itself."""
|
||||||
context["page"] = article
|
raw_parent_id = request.POST.get("parent_id")
|
||||||
context["comment_form"] = form
|
if raw_parent_id:
|
||||||
return render(request, "blog/article_page.html", context, status=200)
|
try:
|
||||||
|
parent_id = int(raw_parent_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
parent_id = None
|
||||||
|
parent = Comment.objects.filter(pk=parent_id, article=article).first() if parent_id else None
|
||||||
|
if parent:
|
||||||
|
ctx = {
|
||||||
|
"comment": parent, "page": article,
|
||||||
|
"turnstile_site_key": _turnstile_site_key(),
|
||||||
|
"reply_form_errors": form.errors,
|
||||||
|
"reply_form": form,
|
||||||
|
}
|
||||||
|
return _add_vary_header(render(request, "comments/_reply_form.html", ctx))
|
||||||
|
ctx = {
|
||||||
|
"comment_form": form, "page": article,
|
||||||
|
"turnstile_site_key": _turnstile_site_key(),
|
||||||
|
}
|
||||||
|
return _add_vary_header(render(request, "comments/_comment_form.html", ctx))
|
||||||
|
|
||||||
|
def _render_htmx_success(self, request, article, comment):
|
||||||
|
"""Return fresh form + OOB-appended comment (if approved)."""
|
||||||
|
tsk = _turnstile_site_key()
|
||||||
|
oob_parts = []
|
||||||
|
if comment.is_approved:
|
||||||
|
ctx = _comment_template_context(comment, article, request)
|
||||||
|
if comment.parent_id:
|
||||||
|
# _reply.html expects 'reply' context key
|
||||||
|
reply_ctx = ctx.copy()
|
||||||
|
reply_ctx["reply"] = reply_ctx.pop("comment")
|
||||||
|
comment_html = render_to_string("comments/_reply.html", reply_ctx, request)
|
||||||
|
oob_parts.append(
|
||||||
|
f'<div hx-swap-oob="beforeend:#replies-for-{comment.parent_id}">{comment_html}</div>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
comment_html = render_to_string("comments/_comment.html", ctx, request)
|
||||||
|
oob_parts.append(f'<div hx-swap-oob="beforeend:#comments-list">{comment_html}</div>')
|
||||||
|
# Ensure stale empty-state copy is removed when the first approved comment appears.
|
||||||
|
oob_parts.append('<div id="comments-empty-state" hx-swap-oob="delete"></div>')
|
||||||
|
|
||||||
|
if comment.parent_id:
|
||||||
|
parent = Comment.objects.filter(pk=comment.parent_id, article=article).first()
|
||||||
|
msg = "Reply posted!" if comment.is_approved else "Your reply is awaiting moderation."
|
||||||
|
form_html = render_to_string("comments/_reply_form.html", {
|
||||||
|
"comment": parent, "page": article,
|
||||||
|
"turnstile_site_key": tsk, "reply_success_message": msg,
|
||||||
|
}, request)
|
||||||
|
else:
|
||||||
|
msg = (
|
||||||
|
"Comment posted!" if comment.is_approved
|
||||||
|
else "Your comment has been posted and is awaiting moderation."
|
||||||
|
)
|
||||||
|
form_html = render_to_string("comments/_comment_form.html", {
|
||||||
|
"page": article, "turnstile_site_key": tsk, "success_message": msg,
|
||||||
|
}, request)
|
||||||
|
|
||||||
|
resp = HttpResponse(form_html + "".join(oob_parts))
|
||||||
|
return _add_vary_header(resp)
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
ip = client_ip_from_request(request)
|
ip = client_ip_from_request(request)
|
||||||
key = f"comment-rate:{ip}"
|
key = f"comment-rate:{ip}"
|
||||||
count = cache.get(key, 0)
|
count = cache.get(key, 0)
|
||||||
if count >= 3:
|
rate_limit = getattr(settings, "COMMENT_RATE_LIMIT_PER_MINUTE", 3)
|
||||||
|
if count >= rate_limit:
|
||||||
return HttpResponse(status=429)
|
return HttpResponse(status=429)
|
||||||
cache.set(key, count + 1, timeout=60)
|
cache.set(key, count + 1, timeout=60)
|
||||||
|
|
||||||
@@ -44,9 +201,21 @@ class CommentCreateView(View):
|
|||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
if form.cleaned_data.get("honeypot"):
|
if form.cleaned_data.get("honeypot"):
|
||||||
return redirect(f"{article.url}?commented=1")
|
if _is_htmx(request):
|
||||||
|
return _add_vary_header(
|
||||||
|
render(request, "comments/_comment_success.html", {"message": "Comment posted!"})
|
||||||
|
)
|
||||||
|
return _comment_redirect(article, approved=True)
|
||||||
|
|
||||||
|
# Turnstile verification
|
||||||
|
turnstile_ok = False
|
||||||
|
if _turnstile_enabled():
|
||||||
|
token = request.POST.get("cf-turnstile-response", "")
|
||||||
|
turnstile_ok = _verify_turnstile(token, ip)
|
||||||
|
|
||||||
comment = form.save(commit=False)
|
comment = form.save(commit=False)
|
||||||
comment.article = article
|
comment.article = article
|
||||||
|
comment.is_approved = turnstile_ok
|
||||||
parent_id = form.cleaned_data.get("parent_id")
|
parent_id = form.cleaned_data.get("parent_id")
|
||||||
if parent_id:
|
if parent_id:
|
||||||
comment.parent = Comment.objects.filter(pk=parent_id, article=article).first()
|
comment.parent = Comment.objects.filter(pk=parent_id, article=article).first()
|
||||||
@@ -55,9 +224,96 @@ class CommentCreateView(View):
|
|||||||
comment.full_clean()
|
comment.full_clean()
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
form.add_error(None, "Reply depth exceeds the allowed limit")
|
form.add_error(None, "Reply depth exceeds the allowed limit")
|
||||||
return self._render_article_with_errors(request, article, form)
|
if _is_htmx(request):
|
||||||
|
return self._render_htmx_error(request, article, form)
|
||||||
|
context = article.get_context(request)
|
||||||
|
context.update({"page": article, "comment_form": form})
|
||||||
|
return render(request, "blog/article_page.html", context, status=200)
|
||||||
comment.save()
|
comment.save()
|
||||||
messages.success(request, "Your comment is awaiting moderation")
|
|
||||||
return redirect(f"{article.url}?commented=1")
|
|
||||||
|
|
||||||
return self._render_article_with_errors(request, article, form)
|
if _is_htmx(request):
|
||||||
|
return self._render_htmx_success(request, article, comment)
|
||||||
|
|
||||||
|
return _comment_redirect(article, approved=comment.is_approved)
|
||||||
|
|
||||||
|
if _is_htmx(request):
|
||||||
|
return self._render_htmx_error(request, article, form)
|
||||||
|
context = article.get_context(request)
|
||||||
|
context.update({"page": article, "comment_form": form})
|
||||||
|
return render(request, "blog/article_page.html", context, status=200)
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
def comment_poll(request, article_id):
|
||||||
|
"""Return comments newer than after_id for HTMX polling."""
|
||||||
|
article = get_object_or_404(ArticlePage, pk=article_id)
|
||||||
|
after_id = request.GET.get("after_id", "0")
|
||||||
|
try:
|
||||||
|
after_id = int(after_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
after_id = 0
|
||||||
|
|
||||||
|
approved_replies = Comment.objects.filter(is_approved=True).select_related("parent")
|
||||||
|
comments = list(
|
||||||
|
article.comments.filter(is_approved=True, parent__isnull=True, id__gt=after_id)
|
||||||
|
.prefetch_related(Prefetch("replies", queryset=approved_replies))
|
||||||
|
.order_by("created_at", "id")
|
||||||
|
)
|
||||||
|
|
||||||
|
_annotate_reaction_counts(comments, _get_session_key(request))
|
||||||
|
|
||||||
|
resp = render(request, "comments/_comment_list_inner.html", {
|
||||||
|
"approved_comments": comments,
|
||||||
|
"page": article,
|
||||||
|
"turnstile_site_key": _turnstile_site_key(),
|
||||||
|
})
|
||||||
|
return _add_vary_header(resp)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def comment_react(request, comment_id):
|
||||||
|
"""Toggle a reaction on a comment."""
|
||||||
|
ip = client_ip_from_request(request)
|
||||||
|
key = f"reaction-rate:{ip}"
|
||||||
|
count = cache.get(key, 0)
|
||||||
|
rate_limit = getattr(settings, "REACTION_RATE_LIMIT_PER_MINUTE", 20)
|
||||||
|
if count >= rate_limit:
|
||||||
|
return HttpResponse(status=429)
|
||||||
|
cache.set(key, count + 1, timeout=60)
|
||||||
|
|
||||||
|
comment = get_object_or_404(Comment, pk=comment_id, is_approved=True)
|
||||||
|
reaction_type = request.POST.get("reaction_type", "heart")
|
||||||
|
if reaction_type not in ("heart", "plus_one"):
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
if not request.session.session_key:
|
||||||
|
request.session.create()
|
||||||
|
session_key = request.session.session_key
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing = CommentReaction.objects.filter(
|
||||||
|
comment=comment, reaction_type=reaction_type, session_key=session_key
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
existing.delete()
|
||||||
|
else:
|
||||||
|
CommentReaction.objects.create(
|
||||||
|
comment=comment, reaction_type=reaction_type, session_key=session_key
|
||||||
|
)
|
||||||
|
except IntegrityError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
counts = {}
|
||||||
|
for rt in ("heart", "plus_one"):
|
||||||
|
counts[rt] = comment.reactions.filter(reaction_type=rt).count()
|
||||||
|
user_reacted = set(
|
||||||
|
comment.reactions.filter(session_key=session_key).values_list("reaction_type", flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
if _is_htmx(request):
|
||||||
|
resp = render(request, "comments/_reactions.html", {
|
||||||
|
"comment": comment, "counts": counts, "user_reacted": user_reacted,
|
||||||
|
})
|
||||||
|
return _add_vary_header(resp)
|
||||||
|
|
||||||
|
return JsonResponse({"counts": counts, "user_reacted": list(user_reacted)})
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django.db.models import Count, Q
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.utils.translation import ngettext
|
from django.utils.translation import ngettext
|
||||||
from wagtail import hooks
|
from wagtail import hooks
|
||||||
from wagtail.admin.ui.tables import BooleanColumn
|
from wagtail.admin.ui.tables import BooleanColumn, Column
|
||||||
from wagtail.snippets.bulk_actions.snippet_bulk_action import SnippetBulkAction
|
from wagtail.snippets.bulk_actions.snippet_bulk_action import SnippetBulkAction
|
||||||
from wagtail.snippets.models import register_snippet
|
from wagtail.snippets.models import register_snippet
|
||||||
from wagtail.snippets.permissions import get_permission_name
|
from wagtail.snippets.permissions import get_permission_name
|
||||||
@@ -41,11 +41,45 @@ class ApproveCommentBulkAction(SnippetBulkAction):
|
|||||||
) % {"count": num_parent_objects}
|
) % {"count": num_parent_objects}
|
||||||
|
|
||||||
|
|
||||||
|
class UnapproveCommentBulkAction(SnippetBulkAction):
|
||||||
|
display_name = _("Unapprove")
|
||||||
|
action_type = "unapprove"
|
||||||
|
aria_label = _("Unapprove selected comments")
|
||||||
|
template_name = "comments/confirm_bulk_unapprove.html"
|
||||||
|
action_priority = 30
|
||||||
|
models = [Comment]
|
||||||
|
|
||||||
|
def check_perm(self, snippet):
|
||||||
|
if getattr(self, "can_change_items", None) is None:
|
||||||
|
self.can_change_items = self.request.user.has_perm(get_permission_name("change", self.model))
|
||||||
|
return self.can_change_items
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def execute_action(cls, objects, **kwargs):
|
||||||
|
updated = kwargs["self"].model.objects.filter(pk__in=[obj.pk for obj in objects], is_approved=True).update(
|
||||||
|
is_approved=False
|
||||||
|
)
|
||||||
|
return updated, 0
|
||||||
|
|
||||||
|
def get_success_message(self, num_parent_objects, num_child_objects):
|
||||||
|
return ngettext(
|
||||||
|
"%(count)d comment unapproved.",
|
||||||
|
"%(count)d comments unapproved.",
|
||||||
|
num_parent_objects,
|
||||||
|
) % {"count": num_parent_objects}
|
||||||
|
|
||||||
|
|
||||||
class CommentViewSet(SnippetViewSet):
|
class CommentViewSet(SnippetViewSet):
|
||||||
model = Comment
|
model = Comment
|
||||||
queryset = Comment.objects.all()
|
queryset = Comment.objects.all()
|
||||||
icon = "comment"
|
icon = "comment"
|
||||||
list_display = ["author_name", "article", BooleanColumn("is_approved"), "pending_in_article", "created_at"]
|
list_display = [
|
||||||
|
"author_name",
|
||||||
|
"article",
|
||||||
|
BooleanColumn("is_approved"),
|
||||||
|
Column("pending_in_article", label="Pending (article)"),
|
||||||
|
"created_at",
|
||||||
|
]
|
||||||
list_filter = ["is_approved"]
|
list_filter = ["is_approved"]
|
||||||
search_fields = ["author_name", "body"]
|
search_fields = ["author_name", "body"]
|
||||||
add_to_admin_menu = True
|
add_to_admin_menu = True
|
||||||
@@ -62,11 +96,6 @@ class CommentViewSet(SnippetViewSet):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def pending_in_article(self, obj):
|
|
||||||
return obj.pending_in_article
|
|
||||||
|
|
||||||
pending_in_article.short_description = "Pending (article)" # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
|
|
||||||
register_snippet(CommentViewSet)
|
register_snippet(CommentViewSet)
|
||||||
hooks.register("register_bulk_action", ApproveCommentBulkAction)
|
hooks.register("register_bulk_action", ApproveCommentBulkAction)
|
||||||
|
hooks.register("register_bulk_action", UnapproveCommentBulkAction)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from django.conf import settings as django_settings
|
||||||
from wagtail.models import Site
|
from wagtail.models import Site
|
||||||
|
|
||||||
from apps.core.models import SiteSettings
|
from apps.core.models import SiteSettings
|
||||||
@@ -6,4 +7,7 @@ from apps.core.models import SiteSettings
|
|||||||
def site_settings(request):
|
def site_settings(request):
|
||||||
site = Site.find_for_request(request)
|
site = Site.find_for_request(request)
|
||||||
settings_obj = SiteSettings.for_site(site) if site else None
|
settings_obj = SiteSettings.for_site(site) if site else None
|
||||||
return {"site_settings": settings_obj}
|
return {
|
||||||
|
"site_settings": settings_obj,
|
||||||
|
"turnstile_site_key": getattr(django_settings, "TURNSTILE_SITE_KEY", ""),
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from taggit.models import Tag
|
from taggit.models import Tag
|
||||||
from wagtail.models import Page, Site
|
from wagtail.models import Page, Site
|
||||||
|
|
||||||
from apps.authors.models import Author
|
from apps.authors.models import Author
|
||||||
from apps.blog.models import AboutPage, ArticleIndexPage, ArticlePage, HomePage, TagMetadata
|
from apps.blog.models import AboutPage, ArticleIndexPage, ArticlePage, HomePage, TagMetadata
|
||||||
|
from apps.comments.models import Comment
|
||||||
|
from apps.core.models import NavigationMenuItem, SiteSettings, SocialMediaLink
|
||||||
from apps.legal.models import LegalIndexPage, LegalPage
|
from apps.legal.models import LegalIndexPage, LegalPage
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Seed deterministic content for E2E checks."
|
help = "Seed deterministic content for E2E checks."
|
||||||
@@ -15,6 +22,8 @@ class Command(BaseCommand):
|
|||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
root = Page.get_first_root_node()
|
root = Page.get_first_root_node()
|
||||||
|
|
||||||
home = HomePage.objects.child_of(root).first()
|
home = HomePage.objects.child_of(root).first()
|
||||||
@@ -38,6 +47,9 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Primary article — comments enabled, used by nightly journey test
|
# Primary article — comments enabled, used by nightly journey test
|
||||||
|
# published_date is set explicitly to ensure deterministic ordering
|
||||||
|
# (most recent first) so this article appears at the top of listings.
|
||||||
|
now = timezone.now()
|
||||||
article = ArticlePage.objects.child_of(article_index).filter(slug="nightly-playwright-journey").first()
|
article = ArticlePage.objects.child_of(article_index).filter(slug="nightly-playwright-journey").first()
|
||||||
if article is None:
|
if article is None:
|
||||||
article = ArticlePage(
|
article = ArticlePage(
|
||||||
@@ -47,11 +59,24 @@ class Command(BaseCommand):
|
|||||||
summary="Seeded article for nightly browser journey.",
|
summary="Seeded article for nightly browser journey.",
|
||||||
body=[("rich_text", "<p>Seeded article body for nightly browser checks.</p>")],
|
body=[("rich_text", "<p>Seeded article body for nightly browser checks.</p>")],
|
||||||
comments_enabled=True,
|
comments_enabled=True,
|
||||||
|
published_date=now,
|
||||||
)
|
)
|
||||||
article_index.add_child(instance=article)
|
article_index.add_child(instance=article)
|
||||||
article.save_revision().publish()
|
article.save_revision().publish()
|
||||||
|
# Ensure deterministic ordering — primary article always newest
|
||||||
|
ArticlePage.objects.filter(pk=article.pk).update(published_date=now)
|
||||||
|
|
||||||
|
# Seed one approved top-level comment on the primary article for reply E2E tests
|
||||||
|
if not Comment.objects.filter(article=article, author_name="E2E Approved Commenter").exists():
|
||||||
|
Comment.objects.create(
|
||||||
|
article=article,
|
||||||
|
author_name="E2E Approved Commenter",
|
||||||
|
author_email="approved@example.com",
|
||||||
|
body="This is a seeded approved comment for reply testing.",
|
||||||
|
is_approved=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Tagged article — used by tag-filter E2E tests
|
|
||||||
tag, _ = Tag.objects.get_or_create(name="AI Tools", slug="ai-tools")
|
tag, _ = Tag.objects.get_or_create(name="AI Tools", slug="ai-tools")
|
||||||
TagMetadata.objects.get_or_create(tag=tag, defaults={"colour": "cyan"})
|
TagMetadata.objects.get_or_create(tag=tag, defaults={"colour": "cyan"})
|
||||||
tagged_article = ArticlePage.objects.child_of(article_index).filter(slug="e2e-tagged-article").first()
|
tagged_article = ArticlePage.objects.child_of(article_index).filter(slug="e2e-tagged-article").first()
|
||||||
@@ -63,9 +88,13 @@ class Command(BaseCommand):
|
|||||||
summary="An article with tags for E2E filter tests.",
|
summary="An article with tags for E2E filter tests.",
|
||||||
body=[("rich_text", "<p>This article is tagged with AI Tools.</p>")],
|
body=[("rich_text", "<p>This article is tagged with AI Tools.</p>")],
|
||||||
comments_enabled=True,
|
comments_enabled=True,
|
||||||
|
published_date=now - datetime.timedelta(hours=1),
|
||||||
)
|
)
|
||||||
article_index.add_child(instance=tagged_article)
|
article_index.add_child(instance=tagged_article)
|
||||||
tagged_article.save_revision().publish()
|
tagged_article.save_revision().publish()
|
||||||
|
ArticlePage.objects.filter(pk=tagged_article.pk).update(
|
||||||
|
published_date=now - datetime.timedelta(hours=1)
|
||||||
|
)
|
||||||
tagged_article.tags.add(tag)
|
tagged_article.tags.add(tag)
|
||||||
tagged_article.save()
|
tagged_article.save()
|
||||||
|
|
||||||
@@ -79,6 +108,7 @@ class Command(BaseCommand):
|
|||||||
summary="An article with comments disabled.",
|
summary="An article with comments disabled.",
|
||||||
body=[("rich_text", "<p>Comments are disabled on this one.</p>")],
|
body=[("rich_text", "<p>Comments are disabled on this one.</p>")],
|
||||||
comments_enabled=False,
|
comments_enabled=False,
|
||||||
|
published_date=now - datetime.timedelta(hours=2),
|
||||||
)
|
)
|
||||||
article_index.add_child(instance=no_comments_article)
|
article_index.add_child(instance=no_comments_article)
|
||||||
# Explicitly persist False after add_child (which internally calls save())
|
# Explicitly persist False after add_child (which internally calls save())
|
||||||
@@ -86,6 +116,9 @@ class Command(BaseCommand):
|
|||||||
ArticlePage.objects.filter(pk=no_comments_article.pk).update(comments_enabled=False)
|
ArticlePage.objects.filter(pk=no_comments_article.pk).update(comments_enabled=False)
|
||||||
no_comments_article.comments_enabled = False
|
no_comments_article.comments_enabled = False
|
||||||
no_comments_article.save_revision().publish()
|
no_comments_article.save_revision().publish()
|
||||||
|
ArticlePage.objects.filter(pk=no_comments_article.pk).update(
|
||||||
|
published_date=now - datetime.timedelta(hours=2)
|
||||||
|
)
|
||||||
|
|
||||||
# About page
|
# About page
|
||||||
if not AboutPage.objects.child_of(home).filter(slug="about").exists():
|
if not AboutPage.objects.child_of(home).filter(slug="about").exists():
|
||||||
@@ -128,4 +161,53 @@ class Command(BaseCommand):
|
|||||||
site.is_default_site = True
|
site.is_default_site = True
|
||||||
site.save()
|
site.save()
|
||||||
|
|
||||||
|
# Navigation menu items and social links — always reconcile to
|
||||||
|
# match the pages we just created (the data migration may have
|
||||||
|
# seeded partial items before these pages existed).
|
||||||
|
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||||
|
NavigationMenuItem.objects.filter(settings=settings).delete()
|
||||||
|
article_index_page = ArticleIndexPage.objects.child_of(home).filter(slug="articles").first()
|
||||||
|
about_page = AboutPage.objects.child_of(home).filter(slug="about").first()
|
||||||
|
nav_items = [
|
||||||
|
NavigationMenuItem(settings=settings, link_page=home, link_title="Home", sort_order=0),
|
||||||
|
]
|
||||||
|
if article_index_page:
|
||||||
|
nav_items.append(
|
||||||
|
NavigationMenuItem(
|
||||||
|
settings=settings, link_page=article_index_page,
|
||||||
|
link_title="Articles", sort_order=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if about_page:
|
||||||
|
nav_items.append(
|
||||||
|
NavigationMenuItem(
|
||||||
|
settings=settings, link_page=about_page,
|
||||||
|
link_title="About", sort_order=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
NavigationMenuItem.objects.bulk_create(nav_items)
|
||||||
|
|
||||||
|
SocialMediaLink.objects.filter(settings=settings).delete()
|
||||||
|
SocialMediaLink.objects.bulk_create(
|
||||||
|
[
|
||||||
|
SocialMediaLink(
|
||||||
|
settings=settings, platform="twitter",
|
||||||
|
url="https://twitter.com/nohypeai",
|
||||||
|
label="Twitter (X)", sort_order=0,
|
||||||
|
),
|
||||||
|
SocialMediaLink(
|
||||||
|
settings=settings, platform="rss",
|
||||||
|
url="/feed/", label="RSS Feed", sort_order=1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Admin user for E2E admin tests — only when E2E_MODE is set
|
||||||
|
if os.environ.get("E2E_MODE") and not User.objects.filter(username="e2e-admin").exists():
|
||||||
|
User.objects.create_superuser(
|
||||||
|
username="e2e-admin",
|
||||||
|
email="e2e-admin@example.com",
|
||||||
|
password="e2e-admin-pass",
|
||||||
|
)
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS("Seeded E2E content."))
|
self.stdout.write(self.style.SUCCESS("Seeded E2E content."))
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from django.contrib.messages import get_messages
|
||||||
|
|
||||||
from .consent import ConsentService
|
from .consent import ConsentService
|
||||||
|
|
||||||
@@ -18,20 +21,47 @@ class SecurityHeadersMiddleware:
|
|||||||
def __init__(self, get_response):
|
def __init__(self, get_response):
|
||||||
self.get_response = get_response
|
self.get_response = get_response
|
||||||
|
|
||||||
|
ADMIN_PREFIXES = ("/cms/", "/django-admin/")
|
||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
nonce = secrets.token_urlsafe(16)
|
nonce = secrets.token_urlsafe(16)
|
||||||
request.csp_nonce = nonce
|
request.csp_nonce = nonce
|
||||||
response = self.get_response(request)
|
response = self.get_response(request)
|
||||||
|
if request.path.startswith(self.ADMIN_PREFIXES):
|
||||||
|
return response
|
||||||
response["Content-Security-Policy"] = (
|
response["Content-Security-Policy"] = (
|
||||||
f"default-src 'self'; "
|
f"default-src 'self'; "
|
||||||
f"script-src 'self' 'nonce-{nonce}'; "
|
f"script-src 'self' 'nonce-{nonce}' https://challenges.cloudflare.com; "
|
||||||
"style-src 'self' https://fonts.googleapis.com; "
|
"style-src 'self' https://fonts.googleapis.com; "
|
||||||
"img-src 'self' data: blob:; "
|
"img-src 'self' data: blob:; "
|
||||||
"font-src 'self' https://fonts.gstatic.com; "
|
"font-src 'self' https://fonts.gstatic.com; "
|
||||||
"connect-src 'self'; "
|
"connect-src 'self' https://challenges.cloudflare.com; "
|
||||||
|
"frame-src https://challenges.cloudflare.com; "
|
||||||
"object-src 'none'; "
|
"object-src 'none'; "
|
||||||
"base-uri 'self'; "
|
"base-uri 'self'; "
|
||||||
"frame-ancestors 'self'"
|
"frame-ancestors 'self'"
|
||||||
)
|
)
|
||||||
response["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
response["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class AdminMessageGuardMiddleware:
|
||||||
|
ADMIN_PREFIXES = ("/cms/", "/django-admin/")
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
# The public site has no legitimate use of Django's shared flash queue.
|
||||||
|
# Drain any stale admin messages before frontend rendering can see them.
|
||||||
|
if not request.path.startswith(self.ADMIN_PREFIXES):
|
||||||
|
storage = cast(Any, get_messages(request))
|
||||||
|
list(storage)
|
||||||
|
storage._queued_messages = []
|
||||||
|
storage._loaded_data = []
|
||||||
|
for sub_storage in getattr(storage, "storages", []):
|
||||||
|
sub_storage._queued_messages = []
|
||||||
|
sub_storage._loaded_data = []
|
||||||
|
sub_storage.used = True
|
||||||
|
storage.used = True
|
||||||
|
return self.get_response(request)
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-03-02 18:39
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import modelcluster.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0001_initial'),
|
||||||
|
('wagtailcore', '0094_alter_page_locale'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sitesettings',
|
||||||
|
name='copyright_text',
|
||||||
|
field=models.CharField(default='No Hype AI. All rights reserved.', max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sitesettings',
|
||||||
|
name='footer_description',
|
||||||
|
field=models.TextField(blank=True, default='In-depth reviews and benchmarks of the latest AI coding tools.\nHonest analysis for developers.'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sitesettings',
|
||||||
|
name='site_name',
|
||||||
|
field=models.CharField(default='NO HYPE AI', max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sitesettings',
|
||||||
|
name='tagline',
|
||||||
|
field=models.CharField(default='Honest AI tool reviews for developers.', max_length=200),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='NavigationMenuItem',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
|
||||||
|
('link_url', models.URLField(blank=True, default='', help_text='External URL (used only when no page is selected).')),
|
||||||
|
('link_title', models.CharField(blank=True, default='', help_text='Override the display text. If blank, the page title is used.', max_length=100)),
|
||||||
|
('open_in_new_tab', models.BooleanField(default=False)),
|
||||||
|
('show_in_header', models.BooleanField(default=True)),
|
||||||
|
('show_in_footer', models.BooleanField(default=True)),
|
||||||
|
('link_page', models.ForeignKey(blank=True, help_text='Link to an internal page. If unpublished, the link is hidden automatically.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.page')),
|
||||||
|
('settings', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='navigation_items', to='core.sitesettings')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['sort_order'],
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SocialMediaLink',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
|
||||||
|
('platform', models.CharField(choices=[('twitter', 'Twitter / X'), ('github', 'GitHub'), ('rss', 'RSS Feed'), ('linkedin', 'LinkedIn'), ('youtube', 'YouTube'), ('mastodon', 'Mastodon'), ('bluesky', 'Bluesky')], max_length=30)),
|
||||||
|
('url', models.URLField()),
|
||||||
|
('label', models.CharField(blank=True, default='', help_text='Display label. If blank, the platform name is used.', max_length=100)),
|
||||||
|
('settings', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='social_links', to='core.sitesettings')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['sort_order'],
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
105
apps/core/migrations/0003_seed_navigation_data.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-03-02 18:39
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def seed_navigation_data(apps, schema_editor):
|
||||||
|
Site = apps.get_model("wagtailcore", "Site")
|
||||||
|
SiteSettings = apps.get_model("core", "SiteSettings")
|
||||||
|
NavigationMenuItem = apps.get_model("core", "NavigationMenuItem")
|
||||||
|
SocialMediaLink = apps.get_model("core", "SocialMediaLink")
|
||||||
|
Page = apps.get_model("wagtailcore", "Page")
|
||||||
|
|
||||||
|
for site in Site.objects.all():
|
||||||
|
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||||
|
|
||||||
|
# Only seed if no nav items exist yet
|
||||||
|
if NavigationMenuItem.objects.filter(settings=settings).exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
root_page = site.root_page
|
||||||
|
if not root_page:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find pages by slug under the site root using tree path
|
||||||
|
home_page = root_page
|
||||||
|
# In Wagtail's treebeard, direct children share the root's path prefix
|
||||||
|
articles_page = Page.objects.filter(
|
||||||
|
depth=root_page.depth + 1,
|
||||||
|
path__startswith=root_page.path,
|
||||||
|
slug__startswith="articles",
|
||||||
|
).first()
|
||||||
|
about_page = Page.objects.filter(
|
||||||
|
depth=root_page.depth + 1,
|
||||||
|
path__startswith=root_page.path,
|
||||||
|
slug__startswith="about",
|
||||||
|
).first()
|
||||||
|
|
||||||
|
nav_items = []
|
||||||
|
if home_page:
|
||||||
|
nav_items.append(
|
||||||
|
NavigationMenuItem(
|
||||||
|
settings=settings,
|
||||||
|
link_page=home_page,
|
||||||
|
link_title="Home",
|
||||||
|
show_in_header=True,
|
||||||
|
show_in_footer=True,
|
||||||
|
sort_order=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if articles_page:
|
||||||
|
nav_items.append(
|
||||||
|
NavigationMenuItem(
|
||||||
|
settings=settings,
|
||||||
|
link_page=articles_page,
|
||||||
|
link_title="Articles",
|
||||||
|
show_in_header=True,
|
||||||
|
show_in_footer=True,
|
||||||
|
sort_order=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if about_page:
|
||||||
|
nav_items.append(
|
||||||
|
NavigationMenuItem(
|
||||||
|
settings=settings,
|
||||||
|
link_page=about_page,
|
||||||
|
link_title="About",
|
||||||
|
show_in_header=True,
|
||||||
|
show_in_footer=True,
|
||||||
|
sort_order=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
NavigationMenuItem.objects.bulk_create(nav_items)
|
||||||
|
|
||||||
|
# Social links
|
||||||
|
if not SocialMediaLink.objects.filter(settings=settings).exists():
|
||||||
|
SocialMediaLink.objects.bulk_create(
|
||||||
|
[
|
||||||
|
SocialMediaLink(
|
||||||
|
settings=settings,
|
||||||
|
platform="twitter",
|
||||||
|
url="https://twitter.com/nohypeai",
|
||||||
|
label="Twitter (X)",
|
||||||
|
sort_order=0,
|
||||||
|
),
|
||||||
|
SocialMediaLink(
|
||||||
|
settings=settings,
|
||||||
|
platform="rss",
|
||||||
|
url="/feed/",
|
||||||
|
label="RSS Feed",
|
||||||
|
sort_order=1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0002_sitesettings_copyright_text_and_more'),
|
||||||
|
('wagtailcore', '0094_alter_page_locale'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_navigation_data, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-03-02 19:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0003_seed_navigation_data'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='navigationmenuitem',
|
||||||
|
name='link_url',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='URL or path (used only when no page is selected).', max_length=500),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='socialmedialink',
|
||||||
|
name='url',
|
||||||
|
field=models.CharField(help_text='URL or path (e.g. https://twitter.com/… or /feed/).', max_length=500),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,10 +1,24 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import SET_NULL
|
from django.db.models import SET_NULL
|
||||||
|
from modelcluster.fields import ParentalKey
|
||||||
|
from modelcluster.models import ClusterableModel
|
||||||
|
from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel
|
||||||
from wagtail.contrib.settings.models import BaseSiteSetting, register_setting
|
from wagtail.contrib.settings.models import BaseSiteSetting, register_setting
|
||||||
|
from wagtail.models import Orderable
|
||||||
|
|
||||||
|
SOCIAL_ICON_CHOICES = [
|
||||||
|
("twitter", "Twitter / X"),
|
||||||
|
("github", "GitHub"),
|
||||||
|
("rss", "RSS Feed"),
|
||||||
|
("linkedin", "LinkedIn"),
|
||||||
|
("youtube", "YouTube"),
|
||||||
|
("mastodon", "Mastodon"),
|
||||||
|
("bluesky", "Bluesky"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@register_setting
|
@register_setting
|
||||||
class SiteSettings(BaseSiteSetting):
|
class SiteSettings(ClusterableModel, BaseSiteSetting):
|
||||||
default_og_image = models.ForeignKey(
|
default_og_image = models.ForeignKey(
|
||||||
"wagtailimages.Image",
|
"wagtailimages.Image",
|
||||||
null=True,
|
null=True,
|
||||||
@@ -19,3 +33,141 @@ class SiteSettings(BaseSiteSetting):
|
|||||||
on_delete=SET_NULL,
|
on_delete=SET_NULL,
|
||||||
related_name="+",
|
related_name="+",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Branding
|
||||||
|
site_name = models.CharField(max_length=100, default="NO HYPE AI")
|
||||||
|
tagline = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
default="Honest AI tool reviews for developers.",
|
||||||
|
)
|
||||||
|
footer_description = models.TextField(
|
||||||
|
default="In-depth reviews and benchmarks of the latest AI coding tools.\nHonest analysis for developers.",
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
copyright_text = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
default="No Hype AI. All rights reserved.",
|
||||||
|
)
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
MultiFieldPanel(
|
||||||
|
[
|
||||||
|
FieldPanel("site_name"),
|
||||||
|
FieldPanel("tagline"),
|
||||||
|
FieldPanel("footer_description"),
|
||||||
|
FieldPanel("copyright_text"),
|
||||||
|
],
|
||||||
|
heading="Branding",
|
||||||
|
),
|
||||||
|
MultiFieldPanel(
|
||||||
|
[
|
||||||
|
FieldPanel("default_og_image"),
|
||||||
|
FieldPanel("privacy_policy_page"),
|
||||||
|
],
|
||||||
|
heading="SEO & Legal",
|
||||||
|
),
|
||||||
|
InlinePanel("navigation_items", label="Navigation Menu Items"),
|
||||||
|
InlinePanel("social_links", label="Social Media Links"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class NavigationMenuItem(Orderable):
|
||||||
|
settings = ParentalKey(
|
||||||
|
SiteSettings,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="navigation_items",
|
||||||
|
)
|
||||||
|
link_page = models.ForeignKey(
|
||||||
|
"wagtailcore.Page",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
help_text="Link to an internal page. If unpublished, the link is hidden automatically.",
|
||||||
|
)
|
||||||
|
link_url = models.CharField(
|
||||||
|
max_length=500,
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="URL or path (used only when no page is selected).",
|
||||||
|
)
|
||||||
|
link_title = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="Override the display text. If blank, the page title is used.",
|
||||||
|
)
|
||||||
|
open_in_new_tab = models.BooleanField(default=False)
|
||||||
|
show_in_header = models.BooleanField(default=True)
|
||||||
|
show_in_footer = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
FieldPanel("link_page"),
|
||||||
|
FieldPanel("link_url"),
|
||||||
|
FieldPanel("link_title"),
|
||||||
|
FieldPanel("open_in_new_tab"),
|
||||||
|
FieldPanel("show_in_header"),
|
||||||
|
FieldPanel("show_in_footer"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self):
|
||||||
|
if self.link_title:
|
||||||
|
return self.link_title
|
||||||
|
if self.link_page:
|
||||||
|
return self.link_page.title
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
if self.link_page:
|
||||||
|
return self.link_page.url
|
||||||
|
return self.link_url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_live(self):
|
||||||
|
"""Return False if linked to an unpublished/non-live page."""
|
||||||
|
if self.link_page_id:
|
||||||
|
return self.link_page.live
|
||||||
|
return bool(self.link_url)
|
||||||
|
|
||||||
|
class Meta(Orderable.Meta):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SocialMediaLink(Orderable):
|
||||||
|
settings = ParentalKey(
|
||||||
|
SiteSettings,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="social_links",
|
||||||
|
)
|
||||||
|
platform = models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
choices=SOCIAL_ICON_CHOICES,
|
||||||
|
)
|
||||||
|
url = models.CharField(max_length=500, help_text="URL or path (e.g. https://twitter.com/… or /feed/).")
|
||||||
|
label = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="Display label. If blank, the platform name is used.",
|
||||||
|
)
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
FieldPanel("platform"),
|
||||||
|
FieldPanel("url"),
|
||||||
|
FieldPanel("label"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_label(self):
|
||||||
|
if self.label:
|
||||||
|
return self.label
|
||||||
|
return dict(SOCIAL_ICON_CHOICES).get(self.platform, self.platform)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def icon_template(self):
|
||||||
|
return f"components/icons/{self.platform}.html"
|
||||||
|
|
||||||
|
class Meta(Orderable.Meta):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ from django import template
|
|||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from wagtail.models import Site
|
from wagtail.models import Site
|
||||||
|
|
||||||
from apps.blog.models import TagMetadata
|
from apps.blog.models import ArticleIndexPage, Category, TagMetadata, get_auto_tag_colour_css
|
||||||
|
from apps.core.models import SiteSettings
|
||||||
from apps.legal.models import LegalPage
|
from apps.legal.models import LegalPage
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
@@ -20,11 +21,73 @@ def get_legal_pages(context):
|
|||||||
return pages
|
return pages
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
@register.simple_tag(takes_context=True)
|
||||||
@register.filter
|
def get_nav_items(context, location="header"):
|
||||||
def get_tag_css(tag):
|
request = context.get("request")
|
||||||
|
site = Site.find_for_request(request) if request else None
|
||||||
|
settings = SiteSettings.for_site(site) if site else None
|
||||||
|
if not settings:
|
||||||
|
return []
|
||||||
|
items = settings.navigation_items.all()
|
||||||
|
if location == "header":
|
||||||
|
items = items.filter(show_in_header=True)
|
||||||
|
elif location == "footer":
|
||||||
|
items = items.filter(show_in_footer=True)
|
||||||
|
return [item for item in items if item.is_live]
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag(takes_context=True)
|
||||||
|
def get_social_links(context):
|
||||||
|
request = context.get("request")
|
||||||
|
site = Site.find_for_request(request) if request else None
|
||||||
|
settings = SiteSettings.for_site(site) if site else None
|
||||||
|
if not settings:
|
||||||
|
return []
|
||||||
|
return list(settings.social_links.all())
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag(takes_context=True)
|
||||||
|
def get_categories_nav(context):
|
||||||
|
request = context.get("request")
|
||||||
|
if not request:
|
||||||
|
return []
|
||||||
|
site = Site.find_for_request(request) if request else None
|
||||||
|
index_qs = ArticleIndexPage.objects.live().public()
|
||||||
|
if site:
|
||||||
|
index_qs = index_qs.in_site(site)
|
||||||
|
index_page = index_qs.first()
|
||||||
|
if not index_page:
|
||||||
|
return []
|
||||||
|
categories = Category.objects.filter(show_in_nav=True).order_by("sort_order", "name")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": category.name,
|
||||||
|
"slug": category.slug,
|
||||||
|
"url": index_page.get_category_url(category),
|
||||||
|
"article_count": index_page.get_articles().filter(category=category).count(),
|
||||||
|
}
|
||||||
|
for category in categories
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_tag_css(tag) -> dict[str, str]:
|
||||||
|
"""Return CSS classes for a tag, using TagMetadata if set, else auto-colour."""
|
||||||
meta = getattr(tag, "metadata", None)
|
meta = getattr(tag, "metadata", None)
|
||||||
if meta is None:
|
if meta is None:
|
||||||
meta = TagMetadata.objects.filter(tag=tag).first()
|
meta = TagMetadata.objects.filter(tag=tag).first()
|
||||||
classes = meta.get_css_classes() if meta else TagMetadata.get_fallback_css()
|
if meta:
|
||||||
|
return meta.get_css_classes()
|
||||||
|
return get_auto_tag_colour_css(tag.name)
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
@register.filter
|
||||||
|
def get_tag_css(tag):
|
||||||
|
classes = _resolve_tag_css(tag)
|
||||||
return mark_safe(f"{classes['bg']} {classes['text']}")
|
return mark_safe(f"{classes['bg']} {classes['text']}")
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def get_tag_border_css(tag):
|
||||||
|
classes = _resolve_tag_css(tag)
|
||||||
|
return mark_safe(classes.get("border", ""))
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ def test_check_content_integrity_fails_for_blank_summary(home_page):
|
|||||||
)
|
)
|
||||||
index.add_child(instance=article)
|
index.add_child(instance=article)
|
||||||
article.save_revision().publish()
|
article.save_revision().publish()
|
||||||
|
# Simulate legacy/bad data by bypassing model save() auto-summary fallback.
|
||||||
|
ArticlePage.objects.filter(pk=article.pk).update(summary=" ")
|
||||||
|
|
||||||
with pytest.raises(CommandError, match="empty summary"):
|
with pytest.raises(CommandError, match="empty summary"):
|
||||||
call_command("check_content_integrity")
|
call_command("check_content_integrity")
|
||||||
|
|||||||
120
apps/core/tests/test_message_handling.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.contrib.messages import get_messages
|
||||||
|
from django.contrib.messages.storage.fallback import FallbackStorage
|
||||||
|
from django.contrib.sessions.middleware import SessionMiddleware
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.test import RequestFactory, override_settings
|
||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
|
from apps.core.middleware import AdminMessageGuardMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def admin_message_test_view(request):
|
||||||
|
messages.success(request, "Page 'Test page' has been updated.")
|
||||||
|
messages.success(request, "Page 'Test page' has been published.")
|
||||||
|
return render(request, "wagtailadmin/base.html", {})
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("cms/__tests__/admin-messages/", admin_message_test_view),
|
||||||
|
path("", include("config.urls")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_request(rf: RequestFactory, path: str):
|
||||||
|
request = rf.get(path)
|
||||||
|
SessionMiddleware(lambda req: None).process_request(request)
|
||||||
|
request.session.save()
|
||||||
|
request.user = AnonymousUser()
|
||||||
|
setattr(request, "_messages", FallbackStorage(request))
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_admin_message_guard_clears_stale_messages_on_frontend(rf):
|
||||||
|
request = _build_request(rf, "/articles/test/")
|
||||||
|
messages.success(request, "Page 'Test page' has been updated.")
|
||||||
|
|
||||||
|
response = AdminMessageGuardMiddleware(lambda req: HttpResponse("ok"))(request)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert list(get_messages(request)) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_admin_message_guard_preserves_admin_messages(rf):
|
||||||
|
request = _build_request(rf, "/cms/pages/1/edit/")
|
||||||
|
messages.success(request, "Page 'Test page' has been updated.")
|
||||||
|
|
||||||
|
response = AdminMessageGuardMiddleware(lambda req: HttpResponse("ok"))(request)
|
||||||
|
remaining = list(get_messages(request))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(remaining) == 1
|
||||||
|
assert remaining[0].message == "Page 'Test page' has been updated."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="apps.core.tests.test_message_handling")
|
||||||
|
def test_admin_messages_have_auto_clear(client, django_user_model):
|
||||||
|
"""The messages container must set auto-clear so messages dismiss themselves."""
|
||||||
|
admin = django_user_model.objects.create_superuser(
|
||||||
|
username="admin-autoclear",
|
||||||
|
email="admin-autoclear@example.com",
|
||||||
|
password="admin-pass",
|
||||||
|
)
|
||||||
|
client.force_login(admin)
|
||||||
|
|
||||||
|
response = client.get("/cms/__tests__/admin-messages/")
|
||||||
|
content = response.content.decode()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "data-w-messages-auto-clear-value" in content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="apps.core.tests.test_message_handling")
|
||||||
|
def test_server_rendered_messages_have_auto_dismiss_script(client, django_user_model):
|
||||||
|
"""Server-rendered messages must include an inline script that removes them
|
||||||
|
after a timeout, because the w-messages Stimulus controller only auto-clears
|
||||||
|
messages added via JavaScript — not ones already in the HTML."""
|
||||||
|
admin = django_user_model.objects.create_superuser(
|
||||||
|
username="admin-dismiss",
|
||||||
|
email="admin-dismiss@example.com",
|
||||||
|
password="admin-pass",
|
||||||
|
)
|
||||||
|
client.force_login(admin)
|
||||||
|
|
||||||
|
response = client.get("/cms/__tests__/admin-messages/")
|
||||||
|
content = response.content.decode()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Messages are rendered with the data-server-rendered marker
|
||||||
|
assert "data-server-rendered" in content
|
||||||
|
# The auto-dismiss script targets those markers
|
||||||
|
assert "querySelectorAll" in content
|
||||||
|
assert "[data-server-rendered]" in content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="apps.core.tests.test_message_handling")
|
||||||
|
def test_admin_messages_render_all_messages(client, django_user_model):
|
||||||
|
"""All messages should be rendered (no de-duplication filtering)."""
|
||||||
|
admin = django_user_model.objects.create_superuser(
|
||||||
|
username="admin-render",
|
||||||
|
email="admin-render@example.com",
|
||||||
|
password="admin-pass",
|
||||||
|
)
|
||||||
|
client.force_login(admin)
|
||||||
|
|
||||||
|
response = client.get("/cms/__tests__/admin-messages/")
|
||||||
|
content = response.content.decode()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "has been updated." in content
|
||||||
|
assert "has been published." in content
|
||||||
@@ -21,10 +21,20 @@ def test_context_processor_returns_sitesettings(home_page):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_get_tag_css_fallback():
|
def test_get_tag_css_auto_colour():
|
||||||
|
"""Tags without metadata get a deterministic auto-assigned colour."""
|
||||||
tag = Tag.objects.create(name="x", slug="x")
|
tag = Tag.objects.create(name="x", slug="x")
|
||||||
value = core_tags.get_tag_css(tag)
|
value = core_tags.get_tag_css(tag)
|
||||||
assert "bg-zinc" in value
|
assert "bg-" in value
|
||||||
|
assert "text-" in value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_get_tag_border_css_auto_colour():
|
||||||
|
"""Tags without metadata get a deterministic auto-assigned border colour."""
|
||||||
|
tag = Tag.objects.create(name="y", slug="y")
|
||||||
|
value = core_tags.get_tag_border_css(tag)
|
||||||
|
assert "border-" in value
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|||||||
191
apps/core/tests/test_navigation.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import pytest
|
||||||
|
from wagtail.models import Site
|
||||||
|
|
||||||
|
from apps.blog.models import AboutPage, ArticleIndexPage
|
||||||
|
from apps.core.models import NavigationMenuItem, SiteSettings, SocialMediaLink
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def site_with_nav(home_page):
|
||||||
|
"""Create SiteSettings with nav items and social links for testing."""
|
||||||
|
site = Site.objects.get(is_default_site=True)
|
||||||
|
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||||
|
|
||||||
|
# Clear any items seeded by the data migration
|
||||||
|
settings.navigation_items.all().delete()
|
||||||
|
settings.social_links.all().delete()
|
||||||
|
|
||||||
|
# Create article index and about page
|
||||||
|
article_index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=article_index)
|
||||||
|
article_index.save_revision().publish()
|
||||||
|
|
||||||
|
about = AboutPage(
|
||||||
|
title="About",
|
||||||
|
slug="about",
|
||||||
|
mission_statement="Test mission",
|
||||||
|
body="<p>About page</p>",
|
||||||
|
)
|
||||||
|
home_page.add_child(instance=about)
|
||||||
|
about.save_revision().publish()
|
||||||
|
|
||||||
|
# Create nav items
|
||||||
|
NavigationMenuItem.objects.create(
|
||||||
|
settings=settings,
|
||||||
|
link_page=home_page,
|
||||||
|
link_title="Home",
|
||||||
|
show_in_header=True,
|
||||||
|
show_in_footer=True,
|
||||||
|
sort_order=0,
|
||||||
|
)
|
||||||
|
NavigationMenuItem.objects.create(
|
||||||
|
settings=settings,
|
||||||
|
link_page=article_index,
|
||||||
|
link_title="Articles",
|
||||||
|
show_in_header=True,
|
||||||
|
show_in_footer=True,
|
||||||
|
sort_order=1,
|
||||||
|
)
|
||||||
|
NavigationMenuItem.objects.create(
|
||||||
|
settings=settings,
|
||||||
|
link_page=about,
|
||||||
|
link_title="About",
|
||||||
|
show_in_header=True,
|
||||||
|
show_in_footer=False,
|
||||||
|
sort_order=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Social links
|
||||||
|
SocialMediaLink.objects.create(
|
||||||
|
settings=settings,
|
||||||
|
platform="twitter",
|
||||||
|
url="https://twitter.com/nohypeai",
|
||||||
|
label="Twitter (X)",
|
||||||
|
sort_order=0,
|
||||||
|
)
|
||||||
|
SocialMediaLink.objects.create(
|
||||||
|
settings=settings,
|
||||||
|
platform="rss",
|
||||||
|
url="/feed/",
|
||||||
|
label="RSS Feed",
|
||||||
|
sort_order=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestNavigationMenuItem:
|
||||||
|
def test_live_page_is_rendered(self, site_with_nav):
|
||||||
|
items = [i for i in site_with_nav.navigation_items.all() if i.is_live]
|
||||||
|
assert len(items) == 3
|
||||||
|
|
||||||
|
def test_unpublished_page_excluded(self, site_with_nav):
|
||||||
|
about_item = site_with_nav.navigation_items.get(link_title="About")
|
||||||
|
about_item.link_page.unpublish()
|
||||||
|
items = [i for i in site_with_nav.navigation_items.all() if i.is_live]
|
||||||
|
assert len(items) == 2
|
||||||
|
assert all(i.link_title != "About" for i in items)
|
||||||
|
|
||||||
|
def test_external_url_item(self, site_with_nav):
|
||||||
|
NavigationMenuItem.objects.create(
|
||||||
|
settings=site_with_nav,
|
||||||
|
link_url="https://example.com",
|
||||||
|
link_title="External",
|
||||||
|
sort_order=10,
|
||||||
|
)
|
||||||
|
item = site_with_nav.navigation_items.get(link_title="External")
|
||||||
|
assert item.is_live is True
|
||||||
|
assert item.url == "https://example.com"
|
||||||
|
assert item.title == "External"
|
||||||
|
|
||||||
|
def test_title_falls_back_to_page_title(self, site_with_nav):
|
||||||
|
item = site_with_nav.navigation_items.get(sort_order=0)
|
||||||
|
item.link_title = ""
|
||||||
|
item.save()
|
||||||
|
assert item.title == item.link_page.title
|
||||||
|
|
||||||
|
def test_header_footer_filtering(self, site_with_nav):
|
||||||
|
header_items = site_with_nav.navigation_items.filter(show_in_header=True)
|
||||||
|
footer_items = site_with_nav.navigation_items.filter(show_in_footer=True)
|
||||||
|
assert header_items.count() == 3
|
||||||
|
assert footer_items.count() == 2 # About excluded from footer
|
||||||
|
|
||||||
|
def test_sort_order_respected(self, site_with_nav):
|
||||||
|
items = list(site_with_nav.navigation_items.all().order_by("sort_order"))
|
||||||
|
assert [i.link_title for i in items] == ["Home", "Articles", "About"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestSocialMediaLink:
|
||||||
|
def test_display_label_from_field(self, site_with_nav):
|
||||||
|
link = site_with_nav.social_links.get(platform="twitter")
|
||||||
|
assert link.display_label == "Twitter (X)"
|
||||||
|
|
||||||
|
def test_display_label_fallback(self, site_with_nav):
|
||||||
|
link = site_with_nav.social_links.get(platform="twitter")
|
||||||
|
link.label = ""
|
||||||
|
assert link.display_label == "Twitter / X"
|
||||||
|
|
||||||
|
def test_icon_template_path(self, site_with_nav):
|
||||||
|
link = site_with_nav.social_links.get(platform="rss")
|
||||||
|
assert link.icon_template == "components/icons/rss.html"
|
||||||
|
|
||||||
|
def test_ordering(self, site_with_nav):
|
||||||
|
links = list(site_with_nav.social_links.all().order_by("sort_order"))
|
||||||
|
assert [link.platform for link in links] == ["twitter", "rss"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestSiteSettingsDefaults:
|
||||||
|
def test_default_site_name(self, home_page):
|
||||||
|
site = Site.objects.get(is_default_site=True)
|
||||||
|
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||||
|
assert settings.site_name == "NO HYPE AI"
|
||||||
|
|
||||||
|
def test_default_copyright(self, home_page):
|
||||||
|
site = Site.objects.get(is_default_site=True)
|
||||||
|
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||||
|
assert settings.copyright_text == "No Hype AI. All rights reserved."
|
||||||
|
|
||||||
|
def test_default_tagline(self, home_page):
|
||||||
|
site = Site.objects.get(is_default_site=True)
|
||||||
|
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||||
|
assert settings.tagline == "Honest AI tool reviews for developers."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestNavRendering:
|
||||||
|
def test_header_shows_nav_items(self, client, site_with_nav):
|
||||||
|
resp = client.get("/")
|
||||||
|
content = resp.content.decode()
|
||||||
|
assert "Home" in content
|
||||||
|
assert "Articles" in content
|
||||||
|
assert "About" in content
|
||||||
|
|
||||||
|
def test_unpublished_page_not_in_header(self, client, site_with_nav):
|
||||||
|
about_item = site_with_nav.navigation_items.get(link_title="About")
|
||||||
|
about_item.link_page.unpublish()
|
||||||
|
resp = client.get("/")
|
||||||
|
content = resp.content.decode()
|
||||||
|
# About should not appear as a nav link (but might appear elsewhere on page)
|
||||||
|
assert 'href="/about/"' not in content
|
||||||
|
|
||||||
|
def test_footer_shows_nav_items(self, client, site_with_nav):
|
||||||
|
resp = client.get("/")
|
||||||
|
content = resp.content.decode()
|
||||||
|
# Footer should have social links
|
||||||
|
assert "Twitter (X)" in content
|
||||||
|
assert "RSS Feed" in content
|
||||||
|
|
||||||
|
def test_footer_shows_branding(self, client, site_with_nav):
|
||||||
|
site_with_nav.site_name = "TEST SITE"
|
||||||
|
site_with_nav.save()
|
||||||
|
resp = client.get("/")
|
||||||
|
content = resp.content.decode()
|
||||||
|
assert "TEST SITE" in content
|
||||||
|
|
||||||
|
def test_footer_shows_copyright(self, client, site_with_nav):
|
||||||
|
resp = client.get("/")
|
||||||
|
content = resp.content.decode()
|
||||||
|
assert "No Hype AI. All rights reserved." in content
|
||||||
@@ -32,7 +32,7 @@ def test_nightly_playwright_journey() -> None:
|
|||||||
|
|
||||||
article_url = article_href if article_href.startswith("http") else f"{base_url}{article_href}"
|
article_url = article_href if article_href.startswith("http") else f"{base_url}{article_href}"
|
||||||
page.goto(article_url, wait_until="networkidle")
|
page.goto(article_url, wait_until="networkidle")
|
||||||
expect(page.get_by_role("heading", name="Comments")).to_be_visible()
|
expect(page.get_by_role("heading", name="Comments", exact=True)).to_be_visible()
|
||||||
expect(page.get_by_role("button", name="Post comment")).to_be_visible()
|
expect(page.get_by_role("button", name="Post comment")).to_be_visible()
|
||||||
|
|
||||||
page.goto(f"{base_url}/feed/", wait_until="networkidle")
|
page.goto(f"{base_url}/feed/", wait_until="networkidle")
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from apps.blog.models import ArticleIndexPage, ArticlePage, Category
|
||||||
|
from apps.blog.tests.factories import AuthorFactory
|
||||||
from apps.legal.models import LegalIndexPage, LegalPage
|
from apps.legal.models import LegalIndexPage, LegalPage
|
||||||
|
|
||||||
|
|
||||||
@@ -13,3 +15,36 @@ def test_get_legal_pages_tag(client, home_page):
|
|||||||
|
|
||||||
resp = client.get("/")
|
resp = client.get("/")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_categories_nav_tag_renders_category_link(client, home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
category = Category.objects.create(name="Reviews", slug="reviews", show_in_nav=True)
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="R1",
|
||||||
|
slug="r1",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save_revision().publish()
|
||||||
|
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "/articles/category/reviews/" in resp.content.decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_categories_nav_tag_includes_empty_nav_category(client, home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
Category.objects.create(name="Benchmarks", slug="benchmarks", show_in_nav=True)
|
||||||
|
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "/articles/category/benchmarks/" in resp.content.decode()
|
||||||
|
|||||||
1
apps/health/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
6
apps/health/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class HealthConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.health"
|
||||||
80
apps/health/checks.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
|
BACKUP_MAX_AGE_SECONDS = 48 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def check_db() -> dict[str, float | str]:
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT 1")
|
||||||
|
except Exception as exc:
|
||||||
|
return {"status": "fail", "detail": str(exc)}
|
||||||
|
return {"status": "ok", "latency_ms": (time.perf_counter() - started) * 1000}
|
||||||
|
|
||||||
|
|
||||||
|
def check_cache() -> dict[str, float | str]:
|
||||||
|
cache_key = f"health:{uuid.uuid4().hex}"
|
||||||
|
probe_value = uuid.uuid4().hex
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
cache.set(cache_key, probe_value, timeout=5)
|
||||||
|
cached_value = cache.get(cache_key)
|
||||||
|
if cached_value != probe_value:
|
||||||
|
return {"status": "fail", "detail": "Cache probe returned unexpected value"}
|
||||||
|
cache.delete(cache_key)
|
||||||
|
except Exception as exc:
|
||||||
|
return {"status": "fail", "detail": str(exc)}
|
||||||
|
return {"status": "ok", "latency_ms": (time.perf_counter() - started) * 1000}
|
||||||
|
|
||||||
|
|
||||||
|
def check_celery() -> dict[str, str]:
|
||||||
|
broker_url = os.environ.get("CELERY_BROKER_URL")
|
||||||
|
if not broker_url:
|
||||||
|
return {"status": "ok", "detail": "Celery not configured: CELERY_BROKER_URL is unset"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
kombu = importlib.import_module("kombu")
|
||||||
|
except ImportError:
|
||||||
|
return {"status": "ok", "detail": "Celery broker check skipped: kombu is not installed"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with kombu.Connection(broker_url, connect_timeout=3) as broker_connection:
|
||||||
|
broker_connection.ensure_connection(max_retries=1)
|
||||||
|
except Exception as exc:
|
||||||
|
return {"status": "fail", "detail": str(exc)}
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def check_backup() -> dict[str, str]:
|
||||||
|
backup_status_file = os.environ.get("BACKUP_STATUS_FILE")
|
||||||
|
if not backup_status_file:
|
||||||
|
return {"status": "fail", "detail": "Backup monitoring not configured: BACKUP_STATUS_FILE is unset"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_timestamp = Path(backup_status_file).read_text(encoding="utf-8").strip()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"status": "fail", "detail": f"Backup status file not found: {backup_status_file}"}
|
||||||
|
except OSError as exc:
|
||||||
|
return {"status": "fail", "detail": str(exc)}
|
||||||
|
|
||||||
|
try:
|
||||||
|
last_backup_at = float(raw_timestamp)
|
||||||
|
except ValueError:
|
||||||
|
return {"status": "fail", "detail": "Invalid backup status file"}
|
||||||
|
|
||||||
|
age_seconds = time.time() - last_backup_at
|
||||||
|
if age_seconds > BACKUP_MAX_AGE_SECONDS:
|
||||||
|
age_hours = age_seconds / 3600
|
||||||
|
return {"status": "fail", "detail": f"Last backup is {age_hours:.1f} hours old (> 48 h)"}
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
1
apps/health/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
205
apps/health/tests/test_checks.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import time
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.db.utils import OperationalError
|
||||||
|
|
||||||
|
from apps.health import checks
|
||||||
|
|
||||||
|
|
||||||
|
class SuccessfulCursor:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute(self, query):
|
||||||
|
self.query = query
|
||||||
|
|
||||||
|
|
||||||
|
class FailingCursor:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute(self, query):
|
||||||
|
raise OperationalError("database unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCache:
|
||||||
|
def __init__(self, value_to_return=None):
|
||||||
|
self.value_to_return = value_to_return
|
||||||
|
self.stored = {}
|
||||||
|
|
||||||
|
def set(self, key, value, timeout=None):
|
||||||
|
self.stored[key] = value
|
||||||
|
|
||||||
|
def get(self, key):
|
||||||
|
if self.value_to_return is not None:
|
||||||
|
return self.value_to_return
|
||||||
|
return self.stored.get(key)
|
||||||
|
|
||||||
|
def delete(self, key):
|
||||||
|
self.stored.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_db_ok(monkeypatch):
|
||||||
|
monkeypatch.setattr(checks.connection, "cursor", lambda: SuccessfulCursor())
|
||||||
|
|
||||||
|
result = checks.check_db()
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert "latency_ms" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_db_fail(monkeypatch):
|
||||||
|
monkeypatch.setattr(checks.connection, "cursor", lambda: FailingCursor())
|
||||||
|
|
||||||
|
result = checks.check_db()
|
||||||
|
|
||||||
|
assert result == {"status": "fail", "detail": "database unavailable"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_cache_ok(monkeypatch):
|
||||||
|
monkeypatch.setattr(checks, "cache", FakeCache())
|
||||||
|
|
||||||
|
result = checks.check_cache()
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert "latency_ms" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_cache_fail(monkeypatch):
|
||||||
|
monkeypatch.setattr(checks, "cache", FakeCache(value_to_return="wrong-value"))
|
||||||
|
|
||||||
|
result = checks.check_cache()
|
||||||
|
|
||||||
|
assert result == {"status": "fail", "detail": "Cache probe returned unexpected value"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_celery_no_broker(monkeypatch):
|
||||||
|
monkeypatch.delenv("CELERY_BROKER_URL", raising=False)
|
||||||
|
|
||||||
|
result = checks.check_celery()
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert "CELERY_BROKER_URL is unset" in result["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_celery_no_kombu(monkeypatch):
|
||||||
|
monkeypatch.setenv("CELERY_BROKER_URL", "redis://broker")
|
||||||
|
|
||||||
|
def raise_import_error(name):
|
||||||
|
raise ImportError(name)
|
||||||
|
|
||||||
|
monkeypatch.setattr(importlib, "import_module", raise_import_error)
|
||||||
|
|
||||||
|
result = checks.check_celery()
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert "kombu is not installed" in result["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_celery_ok(monkeypatch):
|
||||||
|
monkeypatch.setenv("CELERY_BROKER_URL", "redis://broker")
|
||||||
|
|
||||||
|
class FakeBrokerConnection:
|
||||||
|
def __init__(self, url, connect_timeout):
|
||||||
|
self.url = url
|
||||||
|
self.connect_timeout = connect_timeout
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def ensure_connection(self, max_retries):
|
||||||
|
self.max_retries = max_retries
|
||||||
|
|
||||||
|
monkeypatch.setattr(importlib, "import_module", lambda name: SimpleNamespace(Connection=FakeBrokerConnection))
|
||||||
|
|
||||||
|
result = checks.check_celery()
|
||||||
|
|
||||||
|
assert result == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_celery_fail(monkeypatch):
|
||||||
|
monkeypatch.setenv("CELERY_BROKER_URL", "redis://broker")
|
||||||
|
|
||||||
|
class BrokenBrokerConnection:
|
||||||
|
def __init__(self, url, connect_timeout):
|
||||||
|
self.url = url
|
||||||
|
self.connect_timeout = connect_timeout
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
raise OSError("broker down")
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(importlib, "import_module", lambda name: SimpleNamespace(Connection=BrokenBrokerConnection))
|
||||||
|
|
||||||
|
result = checks.check_celery()
|
||||||
|
|
||||||
|
assert result == {"status": "fail", "detail": "broker down"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_no_env(monkeypatch):
|
||||||
|
monkeypatch.delenv("BACKUP_STATUS_FILE", raising=False)
|
||||||
|
|
||||||
|
result = checks.check_backup()
|
||||||
|
|
||||||
|
assert result["status"] == "fail"
|
||||||
|
assert "BACKUP_STATUS_FILE is unset" in result["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_missing_file(monkeypatch, tmp_path):
|
||||||
|
status_file = tmp_path / "missing-backup-status"
|
||||||
|
monkeypatch.setenv("BACKUP_STATUS_FILE", str(status_file))
|
||||||
|
|
||||||
|
result = checks.check_backup()
|
||||||
|
|
||||||
|
assert result == {"status": "fail", "detail": f"Backup status file not found: {status_file}"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_fresh(monkeypatch, tmp_path):
|
||||||
|
status_file = tmp_path / "backup-status"
|
||||||
|
status_file.write_text(str(time.time() - 60), encoding="utf-8")
|
||||||
|
monkeypatch.setenv("BACKUP_STATUS_FILE", str(status_file))
|
||||||
|
|
||||||
|
result = checks.check_backup()
|
||||||
|
|
||||||
|
assert result == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_stale(monkeypatch, tmp_path):
|
||||||
|
status_file = tmp_path / "backup-status"
|
||||||
|
stale_timestamp = time.time() - (checks.BACKUP_MAX_AGE_SECONDS + 1)
|
||||||
|
status_file.write_text(str(stale_timestamp), encoding="utf-8")
|
||||||
|
monkeypatch.setenv("BACKUP_STATUS_FILE", str(status_file))
|
||||||
|
|
||||||
|
result = checks.check_backup()
|
||||||
|
|
||||||
|
assert result["status"] == "fail"
|
||||||
|
assert "Last backup is" in result["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_invalid(monkeypatch, tmp_path):
|
||||||
|
status_file = tmp_path / "backup-status"
|
||||||
|
status_file.write_text("not-a-timestamp", encoding="utf-8")
|
||||||
|
monkeypatch.setenv("BACKUP_STATUS_FILE", str(status_file))
|
||||||
|
|
||||||
|
result = checks.check_backup()
|
||||||
|
|
||||||
|
assert result == {"status": "fail", "detail": "Invalid backup status file"}
|
||||||
103
apps/health/tests/test_views.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_checks(monkeypatch, **overrides):
|
||||||
|
payloads = {
|
||||||
|
"db": {"status": "ok", "latency_ms": 1.0},
|
||||||
|
"cache": {"status": "ok", "latency_ms": 1.0},
|
||||||
|
"celery": {"status": "ok"},
|
||||||
|
"backup": {"status": "ok"},
|
||||||
|
}
|
||||||
|
payloads.update(overrides)
|
||||||
|
|
||||||
|
monkeypatch.setattr("apps.health.views.check_db", lambda: payloads["db"])
|
||||||
|
monkeypatch.setattr("apps.health.views.check_cache", lambda: payloads["cache"])
|
||||||
|
monkeypatch.setattr("apps.health.views.check_celery", lambda: payloads["celery"])
|
||||||
|
monkeypatch.setattr("apps.health.views.check_backup", lambda: payloads["backup"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_healthy(client, monkeypatch):
|
||||||
|
_mock_checks(monkeypatch)
|
||||||
|
|
||||||
|
response = client.get("/health/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_degraded_celery(client, monkeypatch):
|
||||||
|
_mock_checks(monkeypatch, celery={"status": "fail", "detail": "broker down"})
|
||||||
|
|
||||||
|
response = client.get("/health/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "degraded"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_degraded_backup(client, monkeypatch):
|
||||||
|
_mock_checks(monkeypatch, backup={"status": "fail", "detail": "backup missing"})
|
||||||
|
|
||||||
|
response = client.get("/health/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "degraded"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_unhealthy_db(client, monkeypatch):
|
||||||
|
_mock_checks(monkeypatch, db={"status": "fail", "detail": "db down"})
|
||||||
|
|
||||||
|
response = client.get("/health/")
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert response.json()["status"] == "unhealthy"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_unhealthy_cache(client, monkeypatch):
|
||||||
|
_mock_checks(monkeypatch, cache={"status": "fail", "detail": "cache down"})
|
||||||
|
|
||||||
|
response = client.get("/health/")
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert response.json()["status"] == "unhealthy"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_response_shape(client, monkeypatch):
|
||||||
|
_mock_checks(monkeypatch)
|
||||||
|
|
||||||
|
payload = client.get("/health/").json()
|
||||||
|
|
||||||
|
assert set(payload) == {"status", "version", "checks", "timestamp"}
|
||||||
|
assert set(payload["version"]) == {"git_sha", "build"}
|
||||||
|
assert set(payload["checks"]) == {"db", "cache", "celery", "backup"}
|
||||||
|
assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", payload["timestamp"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_version_fields(client, monkeypatch):
|
||||||
|
_mock_checks(monkeypatch)
|
||||||
|
monkeypatch.setenv("GIT_SHA", "59cc1c4")
|
||||||
|
monkeypatch.setenv("BUILD_ID", "build-20260306-59cc1c4")
|
||||||
|
|
||||||
|
payload = client.get("/health/").json()
|
||||||
|
|
||||||
|
assert payload["version"]["git_sha"] == "59cc1c4"
|
||||||
|
assert payload["version"]["build"] == "build-20260306-59cc1c4"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_no_cache_headers(client, monkeypatch):
|
||||||
|
_mock_checks(monkeypatch)
|
||||||
|
|
||||||
|
response = client.get("/health/")
|
||||||
|
|
||||||
|
assert "no-cache" in response["Cache-Control"]
|
||||||
7
apps/health/urls.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.health.views import health_view
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", health_view, name="health"),
|
||||||
|
]
|
||||||
42
apps/health/views.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.views.decorators.cache import never_cache
|
||||||
|
|
||||||
|
from apps.health.checks import check_backup, check_cache, check_celery, check_db
|
||||||
|
|
||||||
|
CRITICAL_CHECKS = {"db", "cache"}
|
||||||
|
|
||||||
|
|
||||||
|
@never_cache
|
||||||
|
def health_view(request):
|
||||||
|
checks: dict[str, Mapping[str, object]] = {
|
||||||
|
"db": check_db(),
|
||||||
|
"cache": check_cache(),
|
||||||
|
"celery": check_celery(),
|
||||||
|
"backup": check_backup(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(cast(str, checks[name]["status"]) == "fail" for name in CRITICAL_CHECKS):
|
||||||
|
overall_status = "unhealthy"
|
||||||
|
elif any(cast(str, check["status"]) == "fail" for check in checks.values()):
|
||||||
|
overall_status = "degraded"
|
||||||
|
else:
|
||||||
|
overall_status = "ok"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"status": overall_status,
|
||||||
|
"version": {
|
||||||
|
"git_sha": os.environ.get("GIT_SHA", "unknown"),
|
||||||
|
"build": os.environ.get("BUILD_ID", "unknown"),
|
||||||
|
},
|
||||||
|
"checks": checks,
|
||||||
|
"timestamp": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
}
|
||||||
|
response_status = 503 if overall_status == "unhealthy" else 200
|
||||||
|
return JsonResponse(payload, status=response_status)
|
||||||
@@ -29,6 +29,7 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"django.contrib.sitemaps",
|
"django.contrib.sitemaps",
|
||||||
|
"django.contrib.postgres",
|
||||||
"taggit",
|
"taggit",
|
||||||
"modelcluster",
|
"modelcluster",
|
||||||
"wagtail.contrib.forms",
|
"wagtail.contrib.forms",
|
||||||
@@ -47,6 +48,8 @@ INSTALLED_APPS = [
|
|||||||
"wagtailseo",
|
"wagtailseo",
|
||||||
"tailwind",
|
"tailwind",
|
||||||
"theme",
|
"theme",
|
||||||
|
"django_htmx",
|
||||||
|
"apps.health",
|
||||||
"apps.core",
|
"apps.core",
|
||||||
"apps.blog",
|
"apps.blog",
|
||||||
"apps.authors",
|
"apps.authors",
|
||||||
@@ -64,7 +67,9 @@ MIDDLEWARE = [
|
|||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"apps.core.middleware.AdminMessageGuardMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
"django_htmx.middleware.HtmxMiddleware",
|
||||||
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
|
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
|
||||||
"apps.core.middleware.ConsentMiddleware",
|
"apps.core.middleware.ConsentMiddleware",
|
||||||
]
|
]
|
||||||
@@ -142,6 +147,25 @@ X_CONTENT_TYPE_OPTIONS = "nosniff"
|
|||||||
CSRF_TRUSTED_ORIGINS = [u for u in os.getenv("CSRF_TRUSTED_ORIGINS", "http://localhost:8035").split(",") if u]
|
CSRF_TRUSTED_ORIGINS = [u for u in os.getenv("CSRF_TRUSTED_ORIGINS", "http://localhost:8035").split(",") if u]
|
||||||
TRUSTED_PROXY_IPS = [ip.strip() for ip in os.getenv("TRUSTED_PROXY_IPS", "").split(",") if ip.strip()]
|
TRUSTED_PROXY_IPS = [ip.strip() for ip in os.getenv("TRUSTED_PROXY_IPS", "").split(",") if ip.strip()]
|
||||||
|
|
||||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
STORAGES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||||
|
},
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
TAILWIND_APP_NAME = "theme"
|
TAILWIND_APP_NAME = "theme"
|
||||||
|
|
||||||
|
# Cloudflare Turnstile (comment spam protection)
|
||||||
|
TURNSTILE_SITE_KEY = os.getenv("TURNSTILE_SITE_KEY", "")
|
||||||
|
TURNSTILE_SECRET_KEY = os.getenv("TURNSTILE_SECRET_KEY", "")
|
||||||
|
TURNSTILE_EXPECTED_HOSTNAME = os.getenv("TURNSTILE_EXPECTED_HOSTNAME", "")
|
||||||
|
|
||||||
|
WAGTAILSEARCH_BACKENDS = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "wagtail.search.backends.database",
|
||||||
|
"SEARCH_CONFIG": "english",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ INTERNAL_IPS = ["127.0.0.1"]
|
|||||||
# media files natively when DEBUG=True (via django.contrib.staticfiles + the
|
# media files natively when DEBUG=True (via django.contrib.staticfiles + the
|
||||||
# media URL pattern in urls.py).
|
# media URL pattern in urls.py).
|
||||||
MIDDLEWARE = [m for m in MIDDLEWARE if m != "whitenoise.middleware.WhiteNoiseMiddleware"]
|
MIDDLEWARE = [m for m in MIDDLEWARE if m != "whitenoise.middleware.WhiteNoiseMiddleware"]
|
||||||
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
|
STORAGES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||||
|
},
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import debug_toolbar # noqa: F401
|
import debug_toolbar # noqa: F401
|
||||||
@@ -18,3 +26,5 @@ try:
|
|||||||
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE]
|
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
COMMENT_RATE_LIMIT_PER_MINUTE = 100
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ from django.views.generic import RedirectView
|
|||||||
from wagtail import urls as wagtail_urls
|
from wagtail import urls as wagtail_urls
|
||||||
from wagtail.contrib.sitemaps.views import sitemap
|
from wagtail.contrib.sitemaps.views import sitemap
|
||||||
|
|
||||||
from apps.blog.feeds import AllArticlesFeed, TagArticlesFeed
|
from apps.blog.feeds import AllArticlesFeed, CategoryArticlesFeed, TagArticlesFeed
|
||||||
|
from apps.blog.views import search as search_view
|
||||||
from apps.core.views import consent_view, robots_txt
|
from apps.core.views import consent_view, robots_txt
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -14,13 +15,16 @@ urlpatterns = [
|
|||||||
path("cms/", include("wagtail.admin.urls")),
|
path("cms/", include("wagtail.admin.urls")),
|
||||||
path("documents/", include("wagtail.documents.urls")),
|
path("documents/", include("wagtail.documents.urls")),
|
||||||
path("comments/", include("apps.comments.urls")),
|
path("comments/", include("apps.comments.urls")),
|
||||||
|
path("health/", include("apps.health.urls")),
|
||||||
path("newsletter/", include("apps.newsletter.urls")),
|
path("newsletter/", include("apps.newsletter.urls")),
|
||||||
path("consent/", consent_view, name="consent"),
|
path("consent/", consent_view, name="consent"),
|
||||||
path("robots.txt", robots_txt, name="robots_txt"),
|
path("robots.txt", robots_txt, name="robots_txt"),
|
||||||
path("feed/", AllArticlesFeed(), name="rss_feed"),
|
path("feed/", AllArticlesFeed(), name="rss_feed"),
|
||||||
|
path("feed/category/<slug:category_slug>/", CategoryArticlesFeed(), name="rss_feed_by_category"),
|
||||||
path("feed/tag/<slug:tag_slug>/", TagArticlesFeed(), name="rss_feed_by_tag"),
|
path("feed/tag/<slug:tag_slug>/", TagArticlesFeed(), name="rss_feed_by_tag"),
|
||||||
path("sitemap.xml", sitemap),
|
path("sitemap.xml", sitemap),
|
||||||
path("admin/", RedirectView.as_view(url="/cms/", permanent=False)),
|
path("admin/", RedirectView.as_view(url="/cms/", permanent=False)),
|
||||||
|
path("search/", search_view, name="search"),
|
||||||
path("", include(wagtail_urls)),
|
path("", include(wagtail_urls)),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
nohypeai.net, www.nohypeai.net {
|
www.nohypeai.net {
|
||||||
|
redir https://nohypeai.net{uri} permanent
|
||||||
|
}
|
||||||
|
|
||||||
|
nohypeai.net {
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
|
|
||||||
header {
|
header {
|
||||||
X-Content-Type-Options nosniff
|
X-Content-Type-Options nosniff
|
||||||
X-Frame-Options DENY
|
|
||||||
Referrer-Policy strict-origin-when-cross-origin
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||||
X-Forwarded-Proto https
|
X-Forwarded-Proto https
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ cd "${SITE_DIR}"
|
|||||||
echo "==> Pulling latest code"
|
echo "==> Pulling latest code"
|
||||||
git -C "${APP_DIR}" pull origin main
|
git -C "${APP_DIR}" pull origin main
|
||||||
|
|
||||||
|
GIT_SHA=$(git -C "${APP_DIR}" rev-parse --short HEAD)
|
||||||
|
BUILD_ID="build-$(date +%Y%m%d)-${GIT_SHA}"
|
||||||
|
export GIT_SHA BUILD_ID
|
||||||
|
|
||||||
echo "==> Updating compose file"
|
echo "==> Updating compose file"
|
||||||
cp "${APP_DIR}/docker-compose.prod.yml" "${SITE_DIR}/docker-compose.prod.yml"
|
cp "${APP_DIR}/docker-compose.prod.yml" "${SITE_DIR}/docker-compose.prod.yml"
|
||||||
|
|
||||||
@@ -22,7 +26,7 @@ docker compose -f "${SITE_DIR}/docker-compose.prod.yml" up -d --no-deps --build
|
|||||||
|
|
||||||
echo "==> Waiting for health check"
|
echo "==> Waiting for health check"
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
if curl -fsS -H "Host: nohypeai.net" http://localhost:8001/ >/dev/null 2>&1; then
|
if curl -fsS -H "Host: nohypeai.net" http://localhost:8001/health/ >/dev/null 2>&1; then
|
||||||
echo "==> Site is up"
|
echo "==> Site is up"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ python manage.py tailwind install --no-input
|
|||||||
python manage.py tailwind build
|
python manage.py tailwind build
|
||||||
python manage.py migrate --noinput
|
python manage.py migrate --noinput
|
||||||
python manage.py collectstatic --noinput
|
python manage.py collectstatic --noinput
|
||||||
|
python manage.py update_index
|
||||||
|
|
||||||
# Set Wagtail site hostname from first entry in ALLOWED_HOSTS
|
# Set Wagtail site hostname from WAGTAILADMIN_BASE_URL when available.
|
||||||
|
# This keeps preview/page URLs on the same origin as the admin host.
|
||||||
python manage.py shell -c "
|
python manage.py shell -c "
|
||||||
from wagtail.models import Site
|
from wagtail.models import Site
|
||||||
import os
|
import os
|
||||||
hostname = os.environ.get('ALLOWED_HOSTS', 'localhost').split(',')[0].strip()
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
admin_base = os.environ.get('WAGTAILADMIN_BASE_URL', '').strip()
|
||||||
|
parsed = urlparse(admin_base) if admin_base else None
|
||||||
|
hostname = parsed.hostname if parsed and parsed.hostname else os.environ.get('ALLOWED_HOSTS', 'localhost').split(',')[0].strip()
|
||||||
Site.objects.update(hostname=hostname, port=443, site_name='No Hype AI')
|
Site.objects.update(hostname=hostname, port=443, site_name='No Hype AI')
|
||||||
"
|
"
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build: app
|
build:
|
||||||
|
context: app
|
||||||
|
args:
|
||||||
|
GIT_SHA: ${GIT_SHA:-unknown}
|
||||||
|
BUILD_ID: ${BUILD_ID:-unknown}
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
command: /app/deploy/entrypoint.prod.sh
|
command: /app/deploy/entrypoint.prod.sh
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
|
BACKUP_STATUS_FILE: /srv/sum/nohype/backup_status
|
||||||
DJANGO_SETTINGS_MODULE: config.settings.production
|
DJANGO_SETTINGS_MODULE: config.settings.production
|
||||||
volumes:
|
volumes:
|
||||||
|
- /srv/sum/nohype:/srv/sum/nohype:ro
|
||||||
- /srv/sum/nohype/static:/app/staticfiles
|
- /srv/sum/nohype/static:/app/staticfiles
|
||||||
- /srv/sum/nohype/media:/app/media
|
- /srv/sum/nohype/media:/app/media
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ services:
|
|||||||
EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend
|
EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend
|
||||||
DEFAULT_FROM_EMAIL: hello@nohypeai.com
|
DEFAULT_FROM_EMAIL: hello@nohypeai.com
|
||||||
NEWSLETTER_PROVIDER: buttondown
|
NEWSLETTER_PROVIDER: buttondown
|
||||||
|
E2E_MODE: "1"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
56
e2e/test_admin_experience.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""E2E tests for Wagtail admin editor experience improvements."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
|
||||||
|
|
||||||
|
def admin_login(page: Page, base_url: str) -> None:
|
||||||
|
"""Log in to the Wagtail admin using the seeded E2E admin user."""
|
||||||
|
page.goto(f"{base_url}/cms/login/", wait_until="networkidle")
|
||||||
|
page.fill('input[name="username"]', "e2e-admin")
|
||||||
|
page.fill('input[name="password"]', "e2e-admin-pass")
|
||||||
|
page.click('button[type="submit"]')
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
def test_articles_menu_item_visible(page: Page, base_url: str) -> None:
|
||||||
|
"""The admin sidebar should contain an 'Articles' menu item."""
|
||||||
|
admin_login(page, base_url)
|
||||||
|
sidebar = page.locator("#wagtail-sidebar")
|
||||||
|
articles_link = sidebar.get_by_role("link", name="Articles")
|
||||||
|
expect(articles_link).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
def test_articles_listing_page_loads(page: Page, base_url: str) -> None:
|
||||||
|
"""Clicking 'Articles' should load the articles listing with seeded articles."""
|
||||||
|
admin_login(page, base_url)
|
||||||
|
page.goto(f"{base_url}/cms/articles/", wait_until="networkidle")
|
||||||
|
expect(page.get_by_role("heading").first).to_be_visible()
|
||||||
|
# Seeded articles should appear
|
||||||
|
expect(page.get_by_text("Nightly Playwright Journey")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
def test_dashboard_has_articles_panel(page: Page, base_url: str) -> None:
|
||||||
|
"""The admin dashboard should include the articles summary panel."""
|
||||||
|
admin_login(page, base_url)
|
||||||
|
page.goto(f"{base_url}/cms/", wait_until="networkidle")
|
||||||
|
expect(page.get_by_text("Articles overview")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
def test_article_editor_has_tabs(page: Page, base_url: str) -> None:
|
||||||
|
"""The article editor should have Content, Metadata, Publishing, and SEO tabs."""
|
||||||
|
admin_login(page, base_url)
|
||||||
|
page.goto(f"{base_url}/cms/articles/", wait_until="networkidle")
|
||||||
|
# Click the first article title link to edit it
|
||||||
|
page.get_by_role("link", name="Nightly Playwright Journey").first.click()
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
expect(page.get_by_role("tab", name="Content")).to_be_visible()
|
||||||
|
expect(page.get_by_role("tab", name="Metadata")).to_be_visible()
|
||||||
|
expect(page.get_by_role("tab", name="Publishing")).to_be_visible()
|
||||||
|
expect(page.get_by_role("tab", name="SEO")).to_be_visible()
|
||||||
@@ -12,39 +12,96 @@ def _go_to_article(page: Page, base_url: str) -> None:
|
|||||||
page.goto(f"{base_url}/articles/{ARTICLE_SLUG}/", wait_until="networkidle")
|
page.goto(f"{base_url}/articles/{ARTICLE_SLUG}/", wait_until="networkidle")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.e2e
|
def _submit_comment(page: Page, *, name: str = "E2E Tester", email: str = "e2e@example.com", body: str) -> None:
|
||||||
def test_valid_comment_submission_redirects(page: Page, base_url: str) -> None:
|
"""Fill and submit the main (non-reply) comment form."""
|
||||||
_go_to_article(page, base_url)
|
form = page.locator("form[data-comment-form]")
|
||||||
|
form.locator('input[name="author_name"]').fill(name)
|
||||||
# Fill the main comment form (not a reply form)
|
form.locator('input[name="author_email"]').fill(email)
|
||||||
form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Post comment"))
|
form.locator('textarea[name="body"]').fill(body)
|
||||||
form.locator('input[name="author_name"]').fill("E2E Tester")
|
|
||||||
form.locator('input[name="author_email"]').fill("e2e@example.com")
|
|
||||||
form.locator('textarea[name="body"]').fill("This is a test comment from Playwright.")
|
|
||||||
form.get_by_role("button", name="Post comment").click()
|
form.get_by_role("button", name="Post comment").click()
|
||||||
|
|
||||||
# Successful submission redirects back to the article with ?commented=1
|
|
||||||
page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000)
|
@pytest.mark.e2e
|
||||||
assert "commented=1" in page.url
|
def test_valid_comment_shows_moderation_message(page: Page, base_url: str) -> None:
|
||||||
|
"""Successful comment submission must show the awaiting-moderation message."""
|
||||||
|
_go_to_article(page, base_url)
|
||||||
|
_submit_comment(page, body="This is a test comment from Playwright.")
|
||||||
|
|
||||||
|
# HTMX swaps the form container inline — wait for the moderation message
|
||||||
|
expect(page.get_by_text("awaiting moderation")).to_be_visible(timeout=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
def test_valid_comment_not_immediately_visible(page: Page, base_url: str) -> None:
|
||||||
|
"""Submitted comment must NOT appear in the comments list before moderation."""
|
||||||
|
_go_to_article(page, base_url)
|
||||||
|
unique_body = "Unique unmoderated comment body xq7z"
|
||||||
|
_submit_comment(page, body=unique_body)
|
||||||
|
|
||||||
|
# Wait for HTMX response to settle
|
||||||
|
expect(page.get_by_text("awaiting moderation")).to_be_visible(timeout=10_000)
|
||||||
|
expect(page.get_by_text(unique_body)).not_to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.e2e
|
@pytest.mark.e2e
|
||||||
def test_empty_body_shows_form_errors(page: Page, base_url: str) -> None:
|
def test_empty_body_shows_form_errors(page: Page, base_url: str) -> None:
|
||||||
_go_to_article(page, base_url)
|
_go_to_article(page, base_url)
|
||||||
|
_submit_comment(page, body=" ") # whitespace-only body
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Post comment"))
|
expect(page.locator('[aria-label="Comment form errors"]')).to_be_visible(timeout=10_000)
|
||||||
form.locator('input[name="author_name"]').fill("E2E Tester")
|
assert "commented=1" not in page.url
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
def test_missing_name_shows_form_errors(page: Page, base_url: str) -> None:
|
||||||
|
_go_to_article(page, base_url)
|
||||||
|
|
||||||
|
form = page.locator("form[data-comment-form]")
|
||||||
|
form.locator('input[name="author_name"]').fill("")
|
||||||
form.locator('input[name="author_email"]').fill("e2e@example.com")
|
form.locator('input[name="author_email"]').fill("e2e@example.com")
|
||||||
form.locator('textarea[name="body"]').fill(" ") # whitespace-only body
|
form.locator('textarea[name="body"]').fill("Comment without a name.")
|
||||||
form.get_by_role("button", name="Post comment").click()
|
form.get_by_role("button", name="Post comment").click()
|
||||||
page.wait_for_load_state("networkidle")
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
# The page re-renders with the error summary visible
|
|
||||||
expect(page.locator('[aria-label="Comment form errors"]')).to_be_visible()
|
|
||||||
# URL must NOT have ?commented=1 — form was not accepted
|
|
||||||
assert "commented=1" not in page.url
|
assert "commented=1" not in page.url
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
def test_reply_form_visible_on_approved_comment(page: Page, base_url: str) -> None:
|
||||||
|
"""An approved seeded comment must display a reply form."""
|
||||||
|
_go_to_article(page, base_url)
|
||||||
|
|
||||||
|
# The seeded approved comment should be visible (as author name)
|
||||||
|
expect(page.get_by_text("E2E Approved Commenter", exact=True)).to_be_visible()
|
||||||
|
# And a Reply toggle for it
|
||||||
|
expect(page.locator("summary").filter(has_text="Reply")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
def test_reply_submission_shows_moderation_message(page: Page, base_url: str) -> None:
|
||||||
|
"""Submitting a reply to an approved comment should show moderation message."""
|
||||||
|
_go_to_article(page, base_url)
|
||||||
|
|
||||||
|
# Click the Reply toggle (summary element)
|
||||||
|
page.locator("summary").filter(has_text="Reply").first.click()
|
||||||
|
|
||||||
|
# The reply form should now be visible
|
||||||
|
post_reply_btn = page.get_by_test_id("post-reply-btn").first
|
||||||
|
expect(post_reply_btn).to_be_visible()
|
||||||
|
|
||||||
|
# Fill the form fields
|
||||||
|
# Use a locator that finds the container for this reply form (the details element)
|
||||||
|
reply_container = page.locator("details").filter(has=post_reply_btn).first
|
||||||
|
reply_container.locator('input[name="author_name"]').fill("E2E Replier")
|
||||||
|
reply_container.locator('input[name="author_email"]').fill("replier@example.com")
|
||||||
|
reply_container.locator('textarea[name="body"]').fill("This is a test reply.")
|
||||||
|
post_reply_btn.click()
|
||||||
|
|
||||||
|
# HTMX swaps the reply form container inline
|
||||||
|
expect(page.get_by_text("awaiting moderation")).to_be_visible(timeout=10_000)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.e2e
|
@pytest.mark.e2e
|
||||||
def test_comments_section_absent_when_disabled(page: Page, base_url: str) -> None:
|
def test_comments_section_absent_when_disabled(page: Page, base_url: str) -> None:
|
||||||
"""Article with comments_enabled=False must not show the comments section."""
|
"""Article with comments_enabled=False must not show the comments section."""
|
||||||
@@ -52,8 +109,7 @@ def test_comments_section_absent_when_disabled(page: Page, base_url: str) -> Non
|
|||||||
assert response is not None and response.status == 200, (
|
assert response is not None and response.status == 200, (
|
||||||
f"Expected 200 for e2e-no-comments article, got {response and response.status}"
|
f"Expected 200 for e2e-no-comments article, got {response and response.status}"
|
||||||
)
|
)
|
||||||
# Confirm we're on the right page
|
|
||||||
expect(page.get_by_role("heading", level=1)).to_have_text("No Comments Article")
|
expect(page.get_by_role("heading", level=1)).to_have_text("No Comments Article")
|
||||||
# Comments section must be absent — exact=True prevents matching "No Comments Article" h1
|
|
||||||
expect(page.get_by_role("heading", name="Comments", exact=True)).to_have_count(0)
|
expect(page.get_by_role("heading", name="Comments", exact=True)).to_have_count(0)
|
||||||
expect(page.get_by_role("button", name="Post comment")).to_have_count(0)
|
expect(page.get_by_role("button", name="Post comment")).to_have_count(0)
|
||||||
|
|
||||||
|
|||||||
@@ -37,12 +37,10 @@ def test_theme_toggle_adds_dark_class(page: Page, base_url: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.e2e
|
@pytest.mark.e2e
|
||||||
def test_newsletter_form_in_nav(page: Page, base_url: str) -> None:
|
def test_nav_search_box_present(page: Page, base_url: str) -> None:
|
||||||
page.goto(f"{base_url}/", wait_until="networkidle")
|
page.goto(f"{base_url}/", wait_until="networkidle")
|
||||||
# The nav contains a newsletter form with an email input
|
|
||||||
nav = page.locator("nav")
|
nav = page.locator("nav")
|
||||||
expect(nav.locator('input[type="email"]')).to_be_visible()
|
expect(nav.locator('input[name="q"]')).to_be_visible()
|
||||||
expect(nav.get_by_role("button", name="Subscribe")).to_be_visible()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.e2e
|
@pytest.mark.e2e
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from playwright.sync_api import Page, expect
|
|||||||
|
|
||||||
|
|
||||||
def _nav_newsletter_form(page: Page):
|
def _nav_newsletter_form(page: Page):
|
||||||
return page.locator("nav").locator("form[data-newsletter-form]")
|
"""Return the newsletter form in the home page sidebar aside."""
|
||||||
|
return page.locator("aside").locator("form[data-newsletter-form]").first
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.e2e
|
@pytest.mark.e2e
|
||||||
@@ -28,7 +29,7 @@ def test_subscribe_invalid_email_shows_error(page: Page, base_url: str) -> None:
|
|||||||
form = _nav_newsletter_form(page)
|
form = _nav_newsletter_form(page)
|
||||||
# Disable the browser's native HTML5 email validation so the JS handler
|
# Disable the browser's native HTML5 email validation so the JS handler
|
||||||
# fires and sends the bad value to the server (which returns 400).
|
# fires and sends the bad value to the server (which returns 400).
|
||||||
page.evaluate("document.querySelector('nav form[data-newsletter-form]').setAttribute('novalidate', '')")
|
page.evaluate("document.querySelector('aside form[data-newsletter-form]').setAttribute('novalidate', '')")
|
||||||
form.locator('input[type="email"]').fill("not-an-email")
|
form.locator('input[type="email"]').fill("not-an-email")
|
||||||
form.get_by_role("button", name="Subscribe").click()
|
form.get_by_role("button", name="Subscribe").click()
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ ignore_missing_imports = true
|
|||||||
module = ["apps.authors.models"]
|
module = ["apps.authors.models"]
|
||||||
ignore_errors = true
|
ignore_errors = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = ["apps.comments.views"]
|
||||||
|
ignore_errors = true
|
||||||
|
|
||||||
[tool.django-stubs]
|
[tool.django-stubs]
|
||||||
django_settings_module = "config.settings.development"
|
django_settings_module = "config.settings.development"
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ Django~=5.2.0
|
|||||||
wagtail~=7.0.0
|
wagtail~=7.0.0
|
||||||
wagtail-seo~=3.1.1
|
wagtail-seo~=3.1.1
|
||||||
psycopg2-binary~=2.9.0
|
psycopg2-binary~=2.9.0
|
||||||
Pillow~=11.0.0
|
Pillow~=12.1
|
||||||
django-taggit~=6.0.0
|
django-taggit~=6.0.0
|
||||||
whitenoise~=6.0.0
|
whitenoise~=6.0.0
|
||||||
gunicorn~=23.0.0
|
gunicorn~=23.0.0
|
||||||
@@ -10,6 +10,8 @@ python-dotenv~=1.0.0
|
|||||||
dj-database-url~=2.2.0
|
dj-database-url~=2.2.0
|
||||||
django-tailwind~=3.8.0
|
django-tailwind~=3.8.0
|
||||||
django-csp~=3.8.0
|
django-csp~=3.8.0
|
||||||
|
django-htmx~=1.21.0
|
||||||
|
requests~=2.32.0
|
||||||
pytest~=8.3.0
|
pytest~=8.3.0
|
||||||
pytest-django~=4.9.0
|
pytest-django~=4.9.0
|
||||||
pytest-cov~=5.0.0
|
pytest-cov~=5.0.0
|
||||||
|
|||||||
4
static/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" fill="#09090b"/>
|
||||||
|
<text x="16" y="24" text-anchor="middle" font-family="'Space Grotesk',sans-serif" font-weight="700" font-size="22" fill="#fafafa">/</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 257 B |
91
static/js/comments.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
(function () {
|
||||||
|
function renderTurnstileWidgets(root) {
|
||||||
|
if (!root || !window.turnstile || typeof window.turnstile.render !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgets = [];
|
||||||
|
if (root.matches && root.matches(".cf-turnstile")) {
|
||||||
|
widgets.push(root);
|
||||||
|
}
|
||||||
|
if (root.querySelectorAll) {
|
||||||
|
widgets.push(...root.querySelectorAll(".cf-turnstile"));
|
||||||
|
}
|
||||||
|
|
||||||
|
widgets.forEach(function (widget) {
|
||||||
|
if (widget.dataset.turnstileRendered === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (widget.querySelector("iframe")) {
|
||||||
|
widget.dataset.turnstileRendered = "true";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sitekey = widget.dataset.sitekey;
|
||||||
|
if (!sitekey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
sitekey: sitekey,
|
||||||
|
theme: widget.dataset.theme || "auto",
|
||||||
|
};
|
||||||
|
if (widget.dataset.size) {
|
||||||
|
options.size = widget.dataset.size;
|
||||||
|
}
|
||||||
|
if (widget.dataset.action) {
|
||||||
|
options.action = widget.dataset.action;
|
||||||
|
}
|
||||||
|
if (widget.dataset.appearance) {
|
||||||
|
options.appearance = widget.dataset.appearance;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.turnstile.render(widget, options);
|
||||||
|
widget.dataset.turnstileRendered = "true";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCommentsEmptyState() {
|
||||||
|
const emptyState = document.getElementById("comments-empty-state");
|
||||||
|
const commentsList = document.getElementById("comments-list");
|
||||||
|
if (!emptyState || !commentsList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasComments = commentsList.querySelector("[data-comment-item='true']") !== null;
|
||||||
|
emptyState.classList.toggle("hidden", hasComments);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTurnstileReady(root) {
|
||||||
|
if (!window.turnstile || typeof window.turnstile.ready !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.turnstile.ready(function () {
|
||||||
|
renderTurnstileWidgets(root || document);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
syncCommentsEmptyState();
|
||||||
|
onTurnstileReady(document);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("htmx:afterSwap", function (event) {
|
||||||
|
const target = event.detail && event.detail.target ? event.detail.target : document;
|
||||||
|
syncCommentsEmptyState();
|
||||||
|
onTurnstileReady(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("toggle", function (event) {
|
||||||
|
const details = event.target;
|
||||||
|
if (!details || details.tagName !== "DETAILS" || !details.open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onTurnstileReady(details);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("load", function () {
|
||||||
|
syncCommentsEmptyState();
|
||||||
|
onTurnstileReady(document);
|
||||||
|
});
|
||||||
|
})();
|
||||||
1
static/js/htmx.min.js
vendored
Normal file
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{% block title %}No Hype AI{% endblock %}</title>
|
<title>{% block title %}No Hype AI{% endblock %}</title>
|
||||||
{% block head_meta %}{% endblock %}
|
{% block head_meta %}{% endblock %}
|
||||||
|
<link rel="icon" href="{% static 'favicon.svg' %}" type="image/svg+xml" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;700;900&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;700;900&display=swap" rel="stylesheet">
|
||||||
@@ -17,18 +18,14 @@
|
|||||||
<script src="{% static 'js/theme.js' %}" defer></script>
|
<script src="{% static 'js/theme.js' %}" defer></script>
|
||||||
<script src="{% static 'js/prism.js' %}" defer></script>
|
<script src="{% static 'js/prism.js' %}" defer></script>
|
||||||
<script src="{% static 'js/newsletter.js' %}" defer></script>
|
<script src="{% static 'js/newsletter.js' %}" defer></script>
|
||||||
|
<script src="{% static 'js/comments.js' %}" defer></script>
|
||||||
|
<script src="{% static 'js/htmx.min.js' %}" nonce="{{ request.csp_nonce|default:'' }}" defer></script>
|
||||||
|
{% if turnstile_site_key %}<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer nonce="{{ request.csp_nonce|default:'' }}"></script>{% endif %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-brand-light dark:bg-brand-dark text-brand-dark dark:text-brand-light antialiased min-h-screen flex flex-col relative">
|
<body class="bg-brand-light dark:bg-brand-dark text-brand-dark dark:text-brand-light antialiased min-h-screen flex flex-col relative" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
|
||||||
<div class="fixed inset-0 bg-grid-pattern pointer-events-none z-[-1]"></div>
|
<div class="fixed inset-0 bg-grid-pattern pointer-events-none z-[-1]"></div>
|
||||||
{% include 'components/nav.html' %}
|
{% include 'components/nav.html' %}
|
||||||
{% include 'components/cookie_banner.html' %}
|
{% include 'components/cookie_banner.html' %}
|
||||||
{% if messages %}
|
|
||||||
<section aria-label="Messages" class="max-w-7xl mx-auto px-6 py-2">
|
|
||||||
{% for message in messages %}
|
|
||||||
<p class="font-mono text-sm py-2 px-4 bg-brand-cyan/10 text-brand-cyan border border-brand-cyan/20 mb-2">{{ message }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
<main class="flex-grow w-full max-w-7xl mx-auto px-6 py-8">{% block content %}{% endblock %}</main>
|
<main class="flex-grow w-full max-w-7xl mx-auto px-6 py-8">{% block content %}{% endblock %}</main>
|
||||||
{% include 'components/footer.html' %}
|
{% include 'components/footer.html' %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -13,13 +13,37 @@
|
|||||||
|
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<div class="py-8 md:py-12 border-b border-zinc-200 dark:border-zinc-800 mb-12">
|
<div class="py-8 md:py-12 border-b border-zinc-200 dark:border-zinc-800 mb-12">
|
||||||
<h1 class="font-display font-black text-4xl md:text-6xl mb-6">{{ page.title }}</h1>
|
{% if active_category %}
|
||||||
|
<nav aria-label="Breadcrumb" class="font-mono text-xs text-zinc-500 mb-4">
|
||||||
|
<a href="/" class="hover:text-brand-cyan">Home</a> / <a href="/articles/" class="hover:text-brand-cyan">Articles</a> / <span>{{ active_category.name }}</span>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
<h1 class="font-display font-black text-4xl md:text-6xl mb-3">{% if active_category %}{{ active_category.name }}{% else %}{{ page.title }}{% endif %}</h1>
|
||||||
|
{% if active_category.description %}
|
||||||
|
<p class="text-zinc-600 dark:text-zinc-400 mb-6">{{ active_category.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Filters / Search -->
|
||||||
|
<div class="flex flex-col md:flex-row justify-between gap-6 mb-4">
|
||||||
|
<!-- Category Filters -->
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<a href="/articles/{% if active_tag %}?tag={{ active_tag }}{% endif %}" class="px-4 py-2 font-mono text-sm font-bold border transition-colors {% if not active_category %}bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark border-transparent{% else %}bg-transparent text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-zinc-700 hover:text-brand-dark dark:hover:text-brand-light hover:border-brand-dark dark:hover:border-brand-light{% endif %}" {% if not active_category %}aria-current="page"{% endif %}>Categories</a>
|
||||||
|
{% for category_link in category_links %}
|
||||||
|
<a href="{{ category_link.url }}{% if active_tag %}?tag={{ active_tag }}{% endif %}" class="px-4 py-2 font-mono text-sm font-bold border transition-colors {% if active_category and active_category.slug == category_link.category.slug %}bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark border-transparent{% else %}bg-transparent text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-zinc-700 hover:text-brand-dark dark:hover:text-brand-light hover:border-brand-dark dark:hover:border-brand-light{% endif %}" {% if active_category and active_category.slug == category_link.category.slug %}aria-current="page"{% endif %}>{{ category_link.category.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<form action="{% url 'search' %}" method="get" role="search" class="relative w-full md:w-64">
|
||||||
|
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
|
||||||
|
<input type="search" name="q" placeholder="Search articles..." aria-label="Search articles"
|
||||||
|
class="w-full bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-300 dark:border-zinc-700 pl-10 pr-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-cyan dark:focus:border-brand-cyan focus:ring-1 focus:ring-brand-cyan transition-shadow" />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tag Filters -->
|
<!-- Tag Filters -->
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<a href="/articles/" class="px-4 py-2 font-mono text-sm font-bold border transition-colors {% if not active_tag %}bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark border-transparent{% else %}bg-transparent text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-zinc-700 hover:text-brand-dark dark:hover:text-brand-light hover:border-brand-dark dark:hover:border-brand-light{% endif %}" {% if not active_tag %}aria-current="page"{% endif %}>All</a>
|
<a href="{% if active_category %}{{ active_category_url }}{% else %}/articles/{% endif %}" class="px-4 py-2 font-mono text-sm font-bold border transition-colors {% if not active_tag %}bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark border-transparent{% else %}bg-transparent text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-zinc-700 hover:text-brand-dark dark:hover:text-brand-light hover:border-brand-dark dark:hover:border-brand-light{% endif %}" {% if not active_tag %}aria-current="page"{% endif %}>All</a>
|
||||||
{% for tag in available_tags %}
|
{% for tag in available_tags %}
|
||||||
<a href="/articles/?tag={{ tag.slug }}" class="px-4 py-2 font-mono text-sm font-bold border transition-colors {% if active_tag == tag.slug %}bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark border-transparent{% else %}bg-transparent text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-zinc-700 hover:text-brand-dark dark:hover:text-brand-light hover:border-brand-dark dark:hover:border-brand-light{% endif %}" {% if active_tag == tag.slug %}aria-current="page"{% endif %}>{{ tag.name }}</a>
|
<a href="{% if active_category %}{{ active_category_url }}{% else %}/articles/{% endif %}?tag={{ tag.slug }}" class="px-4 py-2 font-mono text-sm font-bold border transition-colors {% if active_tag == tag.slug %}bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark border-transparent{% else %}bg-transparent text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-zinc-700 hover:text-brand-dark dark:hover:text-brand-light hover:border-brand-dark dark:hover:border-brand-light{% endif %}" {% if active_tag == tag.slug %}aria-current="page"{% endif %}>{{ tag.name }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,10 +29,10 @@
|
|||||||
<header class="mb-12 border-b border-zinc-200 dark:border-zinc-800 pb-12">
|
<header class="mb-12 border-b border-zinc-200 dark:border-zinc-800 pb-12">
|
||||||
<div class="flex gap-3 mb-6 items-center flex-wrap">
|
<div class="flex gap-3 mb-6 items-center flex-wrap">
|
||||||
{% for tag in page.tags.all %}
|
{% for tag in page.tags.all %}
|
||||||
<span class="text-xs font-mono font-bold px-2 py-1 {{ tag|get_tag_css }} border border-current/20">{{ tag.name }}</span>
|
<span class="text-xs font-mono font-bold px-2 py-1 {{ tag|get_tag_css }} border {{ tag|get_tag_border_css }}">{{ tag.name }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<span class="text-sm font-mono text-zinc-500">{{ page.first_published_at|date:"M j, Y" }}</span>
|
<span class="text-sm font-mono text-zinc-500"><svg class="w-4 h-4 inline mr-1 -mt-1" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" /></svg> {{ page.first_published_at|date:"M j, Y" }}</span>
|
||||||
<span class="text-sm font-mono text-zinc-500">{{ page.read_time_mins }} min read</span>
|
<span class="text-sm font-mono text-zinc-500"><svg class="w-4 h-4 inline mr-1 -mt-1" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg> {{ page.read_time_mins }} min read</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="font-display font-black text-4xl md:text-6xl lg:text-7xl leading-tight mb-8">{{ page.title }}</h1>
|
<h1 class="font-display font-black text-4xl md:text-6xl lg:text-7xl leading-tight mb-8">{{ page.title }}</h1>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
@@ -140,87 +140,27 @@
|
|||||||
<!-- Comments -->
|
<!-- Comments -->
|
||||||
{% if page.comments_enabled %}
|
{% if page.comments_enabled %}
|
||||||
<section class="mt-16 pt-12 border-t border-zinc-200 dark:border-zinc-800">
|
<section class="mt-16 pt-12 border-t border-zinc-200 dark:border-zinc-800">
|
||||||
<h2 class="font-display font-bold text-3xl mb-8">Comments</h2>
|
<div class="h-1 w-24 bg-gradient-to-r from-brand-cyan to-brand-pink mb-6"></div>
|
||||||
|
<h2 class="font-display font-bold text-3xl">Comments</h2>
|
||||||
{% if approved_comments %}
|
<p class="mt-2 mb-6 font-mono text-xs uppercase tracking-wider text-zinc-500">
|
||||||
<div class="space-y-8 mb-12">
|
{{ approved_comments|length }} public comment{{ approved_comments|length|pluralize }}
|
||||||
{% for comment in approved_comments %}
|
</p>
|
||||||
<article id="comment-{{ comment.id }}" class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6">
|
{% if request.GET.commented %}
|
||||||
<div class="flex items-center gap-3 mb-3">
|
<div class="mb-6 rounded-md border border-brand-cyan/20 bg-brand-cyan/10 px-4 py-3 font-mono text-sm text-brand-cyan">
|
||||||
<div class="w-8 h-8 bg-gradient-to-tr from-brand-cyan to-brand-pink shrink-0"></div>
|
{% if request.GET.commented == "approved" %}
|
||||||
<div>
|
Comment posted!
|
||||||
<div class="font-display font-bold text-sm">{{ comment.author_name }}</div>
|
|
||||||
<div class="font-mono text-xs text-zinc-500">{{ comment.created_at|date:"M j, Y" }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-zinc-700 dark:text-zinc-300 text-sm leading-relaxed">{{ comment.body }}</p>
|
|
||||||
{% for reply in comment.replies.all %}
|
|
||||||
<article id="comment-{{ reply.id }}" class="mt-6 ml-8 bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 p-4">
|
|
||||||
<div class="flex items-center gap-3 mb-2">
|
|
||||||
<div class="w-6 h-6 bg-gradient-to-tr from-brand-pink to-brand-cyan shrink-0"></div>
|
|
||||||
<div>
|
|
||||||
<div class="font-display font-bold text-sm">{{ reply.author_name }}</div>
|
|
||||||
<div class="font-mono text-xs text-zinc-500">{{ reply.created_at|date:"M j, Y" }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-zinc-700 dark:text-zinc-300 text-sm leading-relaxed">{{ reply.body }}</p>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
<form method="post" action="{% url 'comment_post' %}" class="mt-4 pt-4 border-t border-zinc-100 dark:border-zinc-800">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
|
||||||
<input type="hidden" name="parent_id" value="{{ comment.id }}" />
|
|
||||||
<div class="flex gap-3 mb-3">
|
|
||||||
<input type="text" name="author_name" required placeholder="Your name"
|
|
||||||
class="flex-1 bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
|
|
||||||
<input type="email" name="author_email" required placeholder="your@email.com"
|
|
||||||
class="flex-1 bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
|
|
||||||
</div>
|
|
||||||
<textarea name="body" required placeholder="Write a reply..." rows="2"
|
|
||||||
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors mb-3 resize-none"></textarea>
|
|
||||||
<input type="text" name="honeypot" style="display:none" />
|
|
||||||
<button type="submit" class="px-4 py-2 bg-zinc-200 dark:bg-zinc-800 font-display font-bold text-sm hover:bg-brand-pink hover:text-white transition-colors">Reply</button>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="font-mono text-sm text-zinc-500 mb-12">No comments yet. Be the first to comment.</p>
|
Your comment has been posted and is awaiting moderation.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if comment_form and comment_form.errors %}
|
|
||||||
<div aria-label="Comment form errors" class="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 font-mono text-sm text-red-600 dark:text-red-400">
|
|
||||||
{{ comment_form.non_field_errors }}
|
|
||||||
{% for field in comment_form %}{{ field.errors }}{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6">
|
{% include "comments/_comment_list.html" %}
|
||||||
<h3 class="font-display font-bold text-xl mb-6">Post a Comment</h3>
|
<div id="comments-empty-state" class="mb-8 rounded-md border border-zinc-200 bg-zinc-50 p-4 text-center dark:border-zinc-800 dark:bg-zinc-900/40 {% if approved_comments %}hidden{% endif %}">
|
||||||
<form method="post" action="{% url 'comment_post' %}" class="space-y-4">
|
<p class="font-mono text-sm text-zinc-500">No comments yet. Be the first to comment.</p>
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block font-mono text-xs text-zinc-500 mb-1 uppercase tracking-wider">Name *</label>
|
|
||||||
<input type="text" name="author_name" value="{% if comment_form %}{{ comment_form.author_name.value|default:'' }}{% endif %}" required
|
|
||||||
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block font-mono text-xs text-zinc-500 mb-1 uppercase tracking-wider">Email *</label>
|
|
||||||
<input type="email" name="author_email" value="{% if comment_form %}{{ comment_form.author_email.value|default:'' }}{% endif %}" required
|
|
||||||
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block font-mono text-xs text-zinc-500 mb-1 uppercase tracking-wider">Comment *</label>
|
|
||||||
<textarea name="body" required rows="5"
|
|
||||||
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors resize-none">{% if comment_form %}{{ comment_form.body.value|default:'' }}{% endif %}</textarea>
|
|
||||||
</div>
|
|
||||||
<input type="text" name="honeypot" style="display:none" />
|
|
||||||
<button type="submit" class="px-6 py-3 bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark font-display font-bold hover:-translate-y-1 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all">Post comment</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include "comments/_comment_form.html" %}
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% load wagtailcore_tags %}
|
{% load wagtailcore_tags %}
|
||||||
<div class="my-8 overflow-hidden bg-[#0d1117] border border-zinc-800 shadow-xl">
|
<div class="my-8 rounded-md overflow-hidden bg-[#0d1117] border border-zinc-800 shadow-xl">
|
||||||
<div class="flex items-center justify-between px-4 py-2 bg-[#161b22] border-b border-zinc-800">
|
<div class="flex items-center justify-between px-4 py-2 bg-[#161b22] border-b border-zinc-800">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<div class="w-3 h-3 rounded-full bg-red-500"></div>
|
<div class="w-3 h-3 rounded-full bg-red-500"></div>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
{% for tag in featured_article.tags.all %}
|
{% for tag in featured_article.tags.all %}
|
||||||
<span class="text-xs font-mono font-bold px-2 py-1 {{ tag|get_tag_css }}">{{ tag.name }}</span>
|
<span class="text-xs font-mono font-bold px-2 py-1 {{ tag|get_tag_css }}">{{ tag.name }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<span class="text-sm font-mono text-zinc-500">{{ featured_article.read_time_mins }} min read</span>
|
<span class="text-sm font-mono text-zinc-500"><svg class="w-3 h-3 inline mr-1 -mt-0.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>{{ featured_article.read_time_mins }} min read</span>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ featured_article.url }}">
|
<a href="{{ featured_article.url }}">
|
||||||
<h2 class="font-display font-black text-3xl md:text-5xl mb-4 group-hover:text-brand-cyan transition-colors leading-[1.1]">{{ featured_article.title }}</h2>
|
<h2 class="font-display font-black text-3xl md:text-5xl mb-4 group-hover:text-brand-cyan transition-colors leading-[1.1]">{{ featured_article.title }}</h2>
|
||||||
@@ -140,6 +140,16 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if available_tags %}
|
{% if available_tags %}
|
||||||
|
{% if available_categories %}
|
||||||
|
<div>
|
||||||
|
<h4 class="font-display font-bold mb-4 uppercase tracking-widest text-zinc-500 text-sm">Browse Categories</h4>
|
||||||
|
<div class="flex flex-wrap gap-2 mb-3">
|
||||||
|
{% for category in available_categories %}
|
||||||
|
<a href="/articles/category/{{ category.slug }}/" class="px-3 py-1.5 border border-zinc-200 dark:border-zinc-800 text-sm font-mono hover:border-brand-cyan hover:text-brand-cyan transition-colors">{{ category.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-display font-bold mb-4 uppercase tracking-widest text-zinc-500 text-sm">Explore Topics</h4>
|
<h4 class="font-display font-bold mb-4 uppercase tracking-widest text-zinc-500 text-sm">Explore Topics</h4>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
|
|||||||
62
templates/blog/panels/articles_summary.html
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{% load wagtailadmin_tags %}
|
||||||
|
<section class="nice-padding">
|
||||||
|
<h2 class="visuallyhidden">Articles overview</h2>
|
||||||
|
|
||||||
|
{% if drafts %}
|
||||||
|
<div class="w-mb-4">
|
||||||
|
<h3><svg class="icon icon-doc-empty" aria-hidden="true"><use href="#icon-doc-empty"></use></svg> Drafts</h3>
|
||||||
|
<table class="listing">
|
||||||
|
<tbody>
|
||||||
|
{% for page in drafts %}
|
||||||
|
<tr>
|
||||||
|
<td class="title">
|
||||||
|
<a href="{% url 'wagtailadmin_pages:edit' page.pk %}">{{ page.title }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ page.latest_revision_created_at|timesince }} ago</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if scheduled %}
|
||||||
|
<div class="w-mb-4">
|
||||||
|
<h3><svg class="icon icon-time" aria-hidden="true"><use href="#icon-time"></use></svg> Scheduled</h3>
|
||||||
|
<table class="listing">
|
||||||
|
<tbody>
|
||||||
|
{% for page in scheduled %}
|
||||||
|
<tr>
|
||||||
|
<td class="title">
|
||||||
|
<a href="{% url 'wagtailadmin_pages:edit' page.pk %}">{{ page.title }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ page.go_live_at|date:"N j, Y H:i" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if recent %}
|
||||||
|
<div class="w-mb-4">
|
||||||
|
<h3><svg class="icon icon-doc-full" aria-hidden="true"><use href="#icon-doc-full"></use></svg> Recently published</h3>
|
||||||
|
<table class="listing">
|
||||||
|
<tbody>
|
||||||
|
{% for page in recent %}
|
||||||
|
<tr>
|
||||||
|
<td class="title">
|
||||||
|
<a href="{% url 'wagtailadmin_pages:edit' page.pk %}">{{ page.title }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ page.published_date|timesince }} ago</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not drafts and not scheduled and not recent %}
|
||||||
|
<p>No articles yet. <a href="{% url 'articles:choose_parent' %}">Create one</a>.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
59
templates/blog/search_results.html
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}{% if query %}Search: {{ query }}{% else %}Search{% endif %} | No Hype AI{% endblock %}
|
||||||
|
{% block head_meta %}
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="py-8 md:py-12 border-b border-zinc-200 dark:border-zinc-800 mb-12">
|
||||||
|
<h1 class="font-display font-black text-4xl md:text-6xl mb-6">Search</h1>
|
||||||
|
<form action="{% url 'search' %}" method="get" role="search" class="relative w-full md:w-96">
|
||||||
|
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
|
||||||
|
<input type="search" name="q" value="{{ query }}" placeholder="Search articles..." autofocus
|
||||||
|
class="w-full bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-300 dark:border-zinc-700 pl-11 pr-4 py-3 font-mono text-sm focus:outline-none focus:border-brand-cyan dark:focus:border-brand-cyan focus:ring-1 focus:ring-brand-cyan transition-shadow" />
|
||||||
|
</form>
|
||||||
|
{% if query %}
|
||||||
|
<p class="mt-4 font-mono text-sm text-zinc-500">
|
||||||
|
{% if results %}{{ results.paginator.count }} result{{ results.paginator.count|pluralize }} for "{{ query }}"{% else %}No results for "{{ query }}"{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if results %}
|
||||||
|
<!-- Results -->
|
||||||
|
<div class="space-y-8">
|
||||||
|
{% for article in results %}
|
||||||
|
{% include 'components/article_card.html' with article=article %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if results.has_previous or results.has_next %}
|
||||||
|
<nav aria-label="Pagination" class="mt-12 flex justify-center items-center gap-4 font-mono text-sm">
|
||||||
|
{% if results.has_previous %}
|
||||||
|
<a href="?q={{ query|urlencode }}&page={{ results.previous_page_number }}" class="px-6 py-3 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors">← Previous</a>
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-zinc-500">Page {{ results.number }} of {{ paginator.num_pages }}</span>
|
||||||
|
{% if results.has_next %}
|
||||||
|
<a href="?q={{ query|urlencode }}&page={{ results.next_page_number }}" class="px-6 py-3 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors">Next →</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% elif query %}
|
||||||
|
<!-- No Results -->
|
||||||
|
<div class="py-16 text-center">
|
||||||
|
<svg class="w-16 h-16 text-zinc-300 dark:text-zinc-700 mx-auto mb-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
|
||||||
|
<p class="font-mono text-zinc-500 mb-2">No articles match your search.</p>
|
||||||
|
<p class="font-mono text-sm text-zinc-400">Try different keywords or browse <a href="/articles/" class="text-brand-cyan hover:underline">all articles</a>.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div class="py-16 text-center">
|
||||||
|
<svg class="w-16 h-16 text-zinc-300 dark:text-zinc-700 mx-auto mb-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
|
||||||
|
<p class="font-mono text-zinc-500">Enter a search term to find articles.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
38
templates/comments/_comment.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<div class="group">
|
||||||
|
<article
|
||||||
|
id="comment-{{ comment.id }}"
|
||||||
|
data-comment-item="true"
|
||||||
|
class="rounded-lg border border-zinc-200 bg-brand-surfaceLight p-5 shadow-sm transition-colors hover:border-zinc-300 dark:border-zinc-800 dark:bg-brand-surfaceDark dark:hover:border-zinc-700 sm:p-6"
|
||||||
|
>
|
||||||
|
<header class="mb-3 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<span class="font-display text-base font-bold text-zinc-900 dark:text-zinc-100">{{ comment.author_name }}</span>
|
||||||
|
<time datetime="{{ comment.created_at|date:'c' }}" class="font-mono text-[11px] uppercase tracking-wider text-zinc-500">
|
||||||
|
{{ comment.created_at|date:"M j, Y" }}
|
||||||
|
</time>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="prose prose-sm mt-2 max-w-none leading-relaxed text-zinc-700 dark:prose-invert dark:text-zinc-300">
|
||||||
|
{{ comment.body|linebreaks }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 border-t border-zinc-100 pt-4 dark:border-zinc-800">
|
||||||
|
{% include "comments/_reactions.html" with comment=comment counts=comment.reaction_counts user_reacted=comment.user_reacted %}
|
||||||
|
|
||||||
|
<details class="group/details mt-3">
|
||||||
|
<summary class="list-none cursor-pointer font-mono text-xs font-bold uppercase tracking-wider text-zinc-500 transition-colors hover:text-brand-cyan [&::-webkit-details-marker]:hidden">
|
||||||
|
<span class="group-open/details:hidden">Reply</span>
|
||||||
|
<span class="hidden group-open/details:inline">Cancel reply</span>
|
||||||
|
</summary>
|
||||||
|
<div class="mt-4 rounded-md border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-950">
|
||||||
|
{% include "comments/_reply_form.html" with page=page comment=comment %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div id="replies-for-{{ comment.id }}" class="replies-container mt-3 space-y-3 border-l-2 border-zinc-100 pl-4 sm:ml-8 sm:pl-6 dark:border-zinc-800">
|
||||||
|
{% for reply in comment.replies.all %}
|
||||||
|
{% include "comments/_reply.html" with reply=reply %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
98
templates/comments/_comment_form.html
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
{% load static %}
|
||||||
|
<div id="comment-form-container" class="rounded-lg border border-zinc-200 bg-brand-surfaceLight p-6 shadow-sm dark:border-zinc-800 dark:bg-brand-surfaceDark sm:p-8">
|
||||||
|
<div class="max-w-3xl">
|
||||||
|
<h3 class="font-display text-2xl font-bold text-zinc-900 dark:text-zinc-100">Leave a comment</h3>
|
||||||
|
<p class="mt-1 font-mono text-xs uppercase tracking-wider text-zinc-500">
|
||||||
|
Keep it constructive. Your email will not be shown publicly.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if success_message %}
|
||||||
|
<div class="mt-5 rounded-md border border-brand-cyan/30 bg-brand-cyan/10 p-3 font-mono text-sm text-brand-cyan">
|
||||||
|
{{ success_message }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if comment_form.errors %}
|
||||||
|
<div aria-label="Comment form errors" class="mt-5 rounded-md border border-red-500/30 bg-red-500/10 p-4 font-mono text-sm text-red-500">
|
||||||
|
<div class="mb-2 text-xs font-bold uppercase tracking-wider">There were some errors:</div>
|
||||||
|
<ul class="list-disc list-inside space-y-1">
|
||||||
|
{% if comment_form.non_field_errors %}
|
||||||
|
{% for error in comment_form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% for field in comment_form %}
|
||||||
|
{% if field.errors %}
|
||||||
|
{% for error in field.errors %}<li>{{ field.label }}: {{ error }}</li>{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="{% url 'comment_post' %}"
|
||||||
|
data-comment-form
|
||||||
|
class="mt-6 space-y-5"
|
||||||
|
hx-post="{% url 'comment_post' %}"
|
||||||
|
hx-target="#comment-form-container"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="comment-author-name" class="mb-1 block font-mono text-xs font-semibold uppercase tracking-wider text-zinc-500">Name</label>
|
||||||
|
<input
|
||||||
|
id="comment-author-name"
|
||||||
|
type="text"
|
||||||
|
name="author_name"
|
||||||
|
value="{% if comment_form %}{{ comment_form.author_name.value|default:'' }}{% endif %}"
|
||||||
|
required
|
||||||
|
class="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-brand-cyan focus:outline-none focus:ring-2 focus:ring-brand-cyan/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="comment-author-email" class="mb-1 block font-mono text-xs font-semibold uppercase tracking-wider text-zinc-500">Email</label>
|
||||||
|
<input
|
||||||
|
id="comment-author-email"
|
||||||
|
type="email"
|
||||||
|
name="author_email"
|
||||||
|
value="{% if comment_form %}{{ comment_form.author_email.value|default:'' }}{% endif %}"
|
||||||
|
required
|
||||||
|
class="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-brand-cyan focus:outline-none focus:ring-2 focus:ring-brand-cyan/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="comment-body" class="mb-1 block font-mono text-xs font-semibold uppercase tracking-wider text-zinc-500">Comment</label>
|
||||||
|
<textarea
|
||||||
|
id="comment-body"
|
||||||
|
name="body"
|
||||||
|
required
|
||||||
|
rows="5"
|
||||||
|
class="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-brand-cyan focus:outline-none focus:ring-2 focus:ring-brand-cyan/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
|
>{% if comment_form %}{{ comment_form.body.value|default:'' }}{% endif %}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="text" name="honeypot" hidden />
|
||||||
|
|
||||||
|
{% if turnstile_site_key %}
|
||||||
|
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="auto"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="group relative inline-flex items-center gap-3 px-8 py-4 bg-brand-pink text-white font-display font-bold uppercase tracking-widest text-sm hover:-translate-y-1 transition-all active:translate-y-0"
|
||||||
|
>
|
||||||
|
<span>Post comment</span>
|
||||||
|
<svg class="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
6
templates/comments/_comment_list.html
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<div id="comments-list" class="space-y-6 mb-8"
|
||||||
|
hx-get="{% url 'comment_poll' article_id=page.id %}" hx-trigger="every 30s" hx-swap="innerHTML">
|
||||||
|
{% for comment in approved_comments %}
|
||||||
|
{% include "comments/_comment.html" with comment=comment page=page %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
3
templates/comments/_comment_list_inner.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{% for comment in approved_comments %}
|
||||||
|
{% include "comments/_comment.html" with comment=comment page=page %}
|
||||||
|
{% endfor %}
|
||||||
3
templates/comments/_comment_success.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<div id="comment-notice" class="mb-4 p-3 font-mono text-sm bg-brand-cyan/10 text-brand-cyan border border-brand-cyan/20">
|
||||||
|
{{ message|default:"Your comment has been posted and is awaiting moderation." }}
|
||||||
|
</div>
|
||||||
12
templates/comments/_reactions.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<div class="flex gap-3 mt-3 items-center" id="reactions-{{ comment.id }}">
|
||||||
|
<button hx-post="{% url 'comment_react' comment.id %}" hx-target="#reactions-{{ comment.id }}" hx-swap="outerHTML"
|
||||||
|
hx-vals='{"reaction_type": "heart"}' class="flex items-center gap-1 font-mono text-xs {% if 'heart' in user_reacted %}text-brand-pink{% else %}text-zinc-400 hover:text-brand-pink{% endif %} transition-colors hover:scale-110 transition-transform">
|
||||||
|
<svg class="w-4 h-4" fill="{% if 'heart' in user_reacted %}currentColor{% else %}none{% endif %}" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" /></svg>
|
||||||
|
<span>{{ counts.heart|default:"0" }}</span>
|
||||||
|
</button>
|
||||||
|
<button hx-post="{% url 'comment_react' comment.id %}" hx-target="#reactions-{{ comment.id }}" hx-swap="outerHTML"
|
||||||
|
hx-vals='{"reaction_type": "plus_one"}' class="flex items-center gap-1 font-mono text-xs {% if 'plus_one' in user_reacted %}text-brand-cyan{% else %}text-zinc-400 hover:text-brand-cyan{% endif %} transition-colors hover:scale-110 transition-transform">
|
||||||
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" d="M6.633 10.25c.806 0 1.533-.446 2.031-1.08a9.041 9.041 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V2.75a.75.75 0 0 1 .75-.75 2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282m0 0h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H13.48c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23H5.904m10.598-9.75H14.25M5.904 18.5c.083.205.173.405.27.602.197.4-.078.898-.523.898h-.908c-.889 0-1.713-.518-1.972-1.368a12 12 0 0 1-.521-3.507c0-1.553.295-3.036.831-4.398C3.387 9.953 4.167 9.5 5 9.5h1.053c.472 0 .745.556.5.96a8.958 8.958 0 0 0-1.302 4.665c0 1.194.232 2.333.654 3.375Z" /></svg>
|
||||||
|
<span>{{ counts.plus_one|default:"0" }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
9
templates/comments/_reply.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<article id="comment-{{ reply.id }}" class="rounded-md border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900/40">
|
||||||
|
<header class="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<span class="font-display text-sm font-bold text-zinc-900 dark:text-zinc-100">{{ reply.author_name }}</span>
|
||||||
|
<time datetime="{{ reply.created_at|date:'c' }}" class="font-mono text-[10px] uppercase tracking-wider text-zinc-500">{{ reply.created_at|date:"M j, Y" }}</time>
|
||||||
|
</header>
|
||||||
|
<div class="prose prose-sm max-w-none text-sm leading-relaxed text-zinc-700 dark:prose-invert dark:text-zinc-300">
|
||||||
|
{{ reply.body|linebreaks }}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
77
templates/comments/_reply_form.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{% load static %}
|
||||||
|
<div id="reply-form-container-{{ comment.id }}">
|
||||||
|
<h4 class="mb-3 font-display text-sm font-bold uppercase tracking-wider text-zinc-700 dark:text-zinc-200">Reply to {{ comment.author_name }}</h4>
|
||||||
|
|
||||||
|
{% if reply_success_message %}
|
||||||
|
<div class="mb-4 rounded-md border border-brand-cyan/30 bg-brand-cyan/10 p-3 font-mono text-sm text-brand-cyan">
|
||||||
|
{{ reply_success_message }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if reply_form_errors %}
|
||||||
|
<div aria-label="Comment form errors" class="mb-4 rounded-md border border-red-500/30 bg-red-500/10 p-3 font-mono text-sm text-red-500">
|
||||||
|
<div class="mb-2 text-xs font-bold uppercase tracking-wider">Errors:</div>
|
||||||
|
<ul class="list-disc list-inside space-y-1">
|
||||||
|
{% for field, errors in reply_form_errors.items %}
|
||||||
|
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="{% url 'comment_post' %}"
|
||||||
|
hx-post="{% url 'comment_post' %}"
|
||||||
|
hx-target="#reply-form-container-{{ comment.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="space-y-3"
|
||||||
|
>
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
||||||
|
<input type="hidden" name="parent_id" value="{{ comment.id }}" />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="author_name"
|
||||||
|
required
|
||||||
|
placeholder="Name"
|
||||||
|
value="{% if reply_form %}{{ reply_form.author_name.value|default:'' }}{% endif %}"
|
||||||
|
class="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-brand-cyan focus:outline-none focus:ring-2 focus:ring-brand-cyan/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="author_email"
|
||||||
|
required
|
||||||
|
placeholder="Email"
|
||||||
|
value="{% if reply_form %}{{ reply_form.author_email.value|default:'' }}{% endif %}"
|
||||||
|
class="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-brand-cyan focus:outline-none focus:ring-2 focus:ring-brand-cyan/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
name="body"
|
||||||
|
required
|
||||||
|
placeholder="Write your reply"
|
||||||
|
rows="3"
|
||||||
|
class="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-brand-cyan focus:outline-none focus:ring-2 focus:ring-brand-cyan/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
|
>{% if reply_form %}{{ reply_form.body.value|default:'' }}{% endif %}</textarea>
|
||||||
|
|
||||||
|
<input type="text" name="honeypot" hidden />
|
||||||
|
|
||||||
|
{% if turnstile_site_key %}
|
||||||
|
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="auto" data-size="flexible"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex justify-start">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
data-testid="post-reply-btn"
|
||||||
|
class="px-6 py-2 bg-brand-pink text-white font-display font-bold text-sm shadow-solid-dark hover:-translate-y-0.5 hover:shadow-solid-dark/80 transition-all active:translate-y-0"
|
||||||
|
>
|
||||||
|
Post Reply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
53
templates/comments/confirm_bulk_unapprove.html
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends 'wagtailadmin/bulk_actions/confirmation/base.html' %}
|
||||||
|
{% load i18n wagtailusers_tags wagtailadmin_tags %}
|
||||||
|
|
||||||
|
{% block titletag %}
|
||||||
|
{% if items|length == 1 %}
|
||||||
|
{% blocktrans trimmed with snippet_type_name=model_opts.verbose_name %}Unapprove {{ snippet_type_name }}{% endblocktrans %} - {{ items.0.item }}
|
||||||
|
{% else %}
|
||||||
|
{% blocktrans trimmed with count=items|length|intcomma %}Unapprove {{ count }} comments{% endblocktrans %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
{% trans "Unapprove" as unapprove_str %}
|
||||||
|
{% if items|length == 1 %}
|
||||||
|
{% include "wagtailadmin/shared/header.html" with title=unapprove_str subtitle=items.0.item icon=header_icon only %}
|
||||||
|
{% else %}
|
||||||
|
{% include "wagtailadmin/shared/header.html" with title=unapprove_str subtitle=model_opts.verbose_name_plural|capfirst icon=header_icon only %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock header %}
|
||||||
|
|
||||||
|
{% block items_with_access %}
|
||||||
|
{% if items %}
|
||||||
|
{% if items|length == 1 %}
|
||||||
|
<p>{% blocktrans trimmed with snippet_type_name=model_opts.verbose_name %}Unapprove this {{ snippet_type_name }}?{% endblocktrans %}</p>
|
||||||
|
{% else %}
|
||||||
|
<p>{% blocktrans trimmed with count=items|length|intcomma %}Unapprove {{ count }} selected comments?{% endblocktrans %}</p>
|
||||||
|
<ul>
|
||||||
|
{% for snippet in items %}
|
||||||
|
<li><a href="{{ snippet.edit_url }}" target="_blank" rel="noreferrer">{{ snippet.item }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock items_with_access %}
|
||||||
|
|
||||||
|
{% block items_with_no_access %}
|
||||||
|
{% if items_with_no_access|length == 1 %}
|
||||||
|
{% trans "You don't have permission to unapprove this comment" as no_access_msg %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "You don't have permission to unapprove these comments" as no_access_msg %}
|
||||||
|
{% endif %}
|
||||||
|
{% include 'wagtailsnippets/bulk_actions/list_items_with_no_access.html' with items=items_with_no_access no_access_msg=no_access_msg %}
|
||||||
|
{% endblock items_with_no_access %}
|
||||||
|
|
||||||
|
{% block form_section %}
|
||||||
|
{% if items %}
|
||||||
|
{% trans "Yes, unapprove" as action_button_text %}
|
||||||
|
{% trans "No, go back" as no_action_button_text %}
|
||||||
|
{% include 'wagtailadmin/bulk_actions/confirmation/form.html' %}
|
||||||
|
{% else %}
|
||||||
|
{% include 'wagtailadmin/bulk_actions/confirmation/go_back.html' %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock form_section %}
|
||||||
@@ -20,12 +20,9 @@
|
|||||||
<h2 class="font-display font-bold text-2xl md:text-3xl mb-3 group-hover:text-brand-cyan transition-colors">{{ article.title }}</h2>
|
<h2 class="font-display font-bold text-2xl md:text-3xl mb-3 group-hover:text-brand-cyan transition-colors">{{ article.title }}</h2>
|
||||||
</a>
|
</a>
|
||||||
<p class="text-zinc-600 dark:text-zinc-400 mb-6 max-w-2xl line-clamp-2">{{ article.summary }}</p>
|
<p class="text-zinc-600 dark:text-zinc-400 mb-6 max-w-2xl line-clamp-2">{{ article.summary }}</p>
|
||||||
<div class="flex items-center justify-between mt-auto">
|
<a href="{{ article.url }}" class="flex items-center gap-2 mt-auto text-sm font-bold font-mono group-hover:text-brand-cyan transition-colors">
|
||||||
<span class="text-sm font-mono text-zinc-500">{{ article.read_time_mins }} min read</span>
|
|
||||||
<a href="{{ article.url }}" class="flex items-center gap-2 text-sm font-bold font-mono group-hover:text-brand-cyan transition-colors">
|
|
||||||
Read Article
|
Read Article
|
||||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" /></svg>
|
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" /></svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
{% load core_tags %}
|
{% load core_tags %}
|
||||||
|
{% get_nav_items "footer" as footer_nav_items %}
|
||||||
|
{% get_social_links as social_links %}
|
||||||
<footer class="border-t border-zinc-200 dark:border-zinc-800 bg-brand-light dark:bg-brand-dark mt-12 py-12 text-center md:text-left">
|
<footer class="border-t border-zinc-200 dark:border-zinc-800 bg-brand-light dark:bg-brand-dark mt-12 py-12 text-center md:text-left">
|
||||||
<div class="max-w-7xl mx-auto px-6 grid grid-cols-1 md:grid-cols-4 gap-8">
|
<div class="max-w-7xl mx-auto px-6 grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<a href="/" class="font-display font-bold text-2xl tracking-tight mb-4 inline-block">NO HYPE AI</a>
|
<a href="/" class="font-display font-bold text-2xl tracking-tight mb-4 inline-block">{{ site_settings.site_name|default:"NO HYPE AI" }}</a>
|
||||||
<p class="text-zinc-500 font-mono text-sm max-w-sm mx-auto md:mx-0">
|
<p class="text-zinc-500 font-mono text-sm max-w-sm mx-auto md:mx-0">
|
||||||
In-depth reviews and benchmarks of the latest AI coding tools.<br>
|
{{ site_settings.footer_description|default:"In-depth reviews and benchmarks of the latest AI coding tools.\nHonest analysis for developers."|linebreaksbr }}
|
||||||
Honest analysis for developers.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-display font-bold mb-4 uppercase text-sm tracking-widest text-zinc-400">Navigation</h4>
|
<h4 class="font-display font-bold mb-4 uppercase text-sm tracking-widest text-zinc-400">Navigation</h4>
|
||||||
<ul class="space-y-2 font-mono text-sm text-zinc-500">
|
<ul class="space-y-2 font-mono text-sm text-zinc-500">
|
||||||
<li><a href="/" class="hover:text-brand-cyan transition-colors">Home</a></li>
|
{% for item in footer_nav_items %}
|
||||||
<li><a href="/articles/" class="hover:text-brand-cyan transition-colors">Articles</a></li>
|
<li><a href="{{ item.url }}" class="hover:text-brand-cyan transition-colors"{% if item.open_in_new_tab %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.title }}</a></li>
|
||||||
<li><a href="/about/" class="hover:text-brand-pink transition-colors">About</a></li>
|
{% endfor %}
|
||||||
{% get_legal_pages as legal_pages %}
|
{% get_legal_pages as legal_pages %}
|
||||||
{% for page in legal_pages %}
|
{% for page in legal_pages %}
|
||||||
<li><a href="{{ page.url }}" class="hover:text-brand-pink transition-colors">{{ page.title }}</a></li>
|
<li><a href="{{ page.url }}" class="hover:text-brand-pink transition-colors">{{ page.title }}</a></li>
|
||||||
@@ -21,13 +22,21 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-display font-bold mb-4 uppercase text-sm tracking-widest text-zinc-400">Newsletter</h4>
|
<h4 class="font-display font-bold mb-4 uppercase text-sm tracking-widest text-zinc-400">Connect</h4>
|
||||||
<p class="text-zinc-500 font-mono text-sm mb-4">Get weekly AI tool reviews.</p>
|
<ul class="space-y-2 font-mono text-sm text-zinc-500">
|
||||||
{% include 'components/newsletter_form.html' with source='footer' label='Newsletter' %}
|
{% for link in social_links %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ link.url }}" class="hover:text-brand-cyan transition-colors flex items-center justify-center md:justify-start gap-2"{% if link.url != "/feed/" %} target="_blank" rel="noopener noreferrer"{% endif %}>
|
||||||
|
{% include link.icon_template %}
|
||||||
|
{{ link.display_label }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="max-w-7xl mx-auto px-6 mt-12 pt-8 border-t border-zinc-200 dark:border-zinc-800 text-center font-mono text-xs text-zinc-500 flex flex-col md:flex-row justify-between items-center gap-4">
|
<div class="max-w-7xl mx-auto px-6 mt-12 pt-8 border-t border-zinc-200 dark:border-zinc-800 text-center font-mono text-xs text-zinc-500 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
<p>© {% now "Y" %} No Hype AI. All rights reserved.</p>
|
<p>© {% now "Y" %} {{ site_settings.copyright_text|default:"No Hype AI. All rights reserved." }}</p>
|
||||||
<p>Honest AI tool reviews for developers.</p>
|
<p>{{ site_settings.tagline|default:"Honest AI tool reviews for developers." }}</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
1
templates/components/icons/bluesky.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15a4.5 4.5 0 0 0 4.5 4.5H18a3.75 3.75 0 0 0 1.332-7.257 3 3 0 0 0-3.758-3.848 5.25 5.25 0 0 0-10.233 2.33A4.502 4.502 0 0 0 2.25 15Z" /></svg>
|
||||||
|
After Width: | Height: | Size: 334 B |
1
templates/components/icons/github.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" /></svg>
|
||||||
|
After Width: | Height: | Size: 266 B |
1
templates/components/icons/linkedin.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0 1 12 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 0 1-.673-.38m0 0A2.18 2.18 0 0 1 3 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 0 1 3.413-.387m7.5 0V5.25A2.25 2.25 0 0 0 13.5 3h-3a2.25 2.25 0 0 0-2.25 2.25v.894m7.5 0a48.667 48.667 0 0 0-7.5 0M12 12.75h.008v.008H12v-.008Z" /></svg>
|
||||||
|
After Width: | Height: | Size: 783 B |
1
templates/components/icons/mastodon.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /></svg>
|
||||||
|
After Width: | Height: | Size: 671 B |
1
templates/components/icons/rss.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-.75a7.5 7.5 0 0 0-7.5-7.5H4.5m0-6.75h.75c7.87 0 14.25 6.38 14.25 14.25v.75M6 18.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" /></svg>
|
||||||
|
After Width: | Height: | Size: 330 B |
1
templates/components/icons/twitter.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6.633 10.25c.806 0 1.533-.446 2.031-1.08a9.041 9.041 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V2.75a.75.75 0 0 1 .75-.75 2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282m0 0h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H13.48c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23H5.904m10.598-9.75H14.25M5.904 18.5c.083.205.173.405.27.602.197.4-.078.898-.523.898h-.908c-.889 0-1.713-.518-1.972-1.368a12 12 0 0 1-.521-3.507c0-1.553.295-3.036.831-4.398C3.387 9.953 4.167 9.5 5 9.5h1.053c.472 0 .745.556.5.96a8.958 8.958 0 0 0-1.302 4.665c0 1.194.232 2.333.654 3.375Z" /></svg>
|
||||||
|
After Width: | Height: | Size: 919 B |
1
templates/components/icons/youtube.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-8.9a.75.75 0 0 0-.53-1.1H13.5l-1.5 3-1.5-3H4.06a.75.75 0 0 0-.53 1.1l4.72 8.9-4.72 8.9a.75.75 0 0 0 .53 1.1H10.5l1.5-3 1.5 3h6.44a.75.75 0 0 0 .53-1.1l-4.72-8.9Z" /></svg>
|
||||||
|
After Width: | Height: | Size: 374 B |
@@ -1,4 +1,6 @@
|
|||||||
{% load static %}
|
{% load static core_tags %}
|
||||||
|
{% get_nav_items "header" as header_items %}
|
||||||
|
{% get_categories_nav as category_nav_items %}
|
||||||
<nav class="sticky top-0 z-50 backdrop-blur-md bg-brand-light/80 dark:bg-brand-dark/80 border-b border-zinc-200 dark:border-zinc-800 transition-colors">
|
<nav class="sticky top-0 z-50 backdrop-blur-md bg-brand-light/80 dark:bg-brand-dark/80 border-b border-zinc-200 dark:border-zinc-800 transition-colors">
|
||||||
<div class="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
<div class="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
@@ -6,22 +8,21 @@
|
|||||||
<div class="w-8 h-8 bg-brand-dark dark:bg-brand-light text-brand-light dark:text-brand-dark flex items-center justify-center font-display font-bold text-xl group-hover:rotate-12 transition-transform">
|
<div class="w-8 h-8 bg-brand-dark dark:bg-brand-light text-brand-light dark:text-brand-dark flex items-center justify-center font-display font-bold text-xl group-hover:rotate-12 transition-transform">
|
||||||
/
|
/
|
||||||
</div>
|
</div>
|
||||||
<span class="font-display font-bold text-2xl tracking-tight">NO HYPE AI</span>
|
<span class="font-display font-bold text-2xl tracking-tight">{{ site_settings.site_name|default:"NO HYPE AI" }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Desktop Links -->
|
<!-- Desktop Links -->
|
||||||
<div class="hidden md:flex items-center gap-8 font-medium">
|
<div class="hidden md:flex items-center gap-8 font-medium">
|
||||||
<a href="/" class="hover:text-brand-cyan transition-colors">Home</a>
|
{% for item in header_items %}
|
||||||
<a href="/articles/" class="hover:text-brand-cyan transition-colors">Articles</a>
|
<a href="{{ item.url }}" class="hover:text-brand-cyan transition-colors"{% if item.open_in_new_tab %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.title }}</a>
|
||||||
<a href="/about/" class="hover:text-brand-pink transition-colors">About</a>
|
{% endfor %}
|
||||||
<form method="post" action="/newsletter/subscribe/" data-newsletter-form class="flex items-center gap-2" id="nav-newsletter">
|
{% for category in category_nav_items %}
|
||||||
{% csrf_token %}
|
<a href="{{ category.url }}" class="hover:text-brand-cyan transition-colors {% if category.url in request.path %}text-brand-cyan{% endif %}" {% if category.url in request.path %}aria-current="page"{% endif %}>{{ category.name }}</a>
|
||||||
<input type="hidden" name="source" value="nav" />
|
{% endfor %}
|
||||||
<input type="email" name="email" required placeholder="dev@example.com"
|
<form action="{% url 'search' %}" method="get" role="search" class="relative">
|
||||||
class="bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 text-brand-dark dark:text-brand-light font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors w-44" />
|
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
|
||||||
<input type="text" name="honeypot" style="display:none" />
|
<input type="search" name="q" placeholder="Search articles..." aria-label="Search articles"
|
||||||
<button type="submit" class="px-5 py-2.5 bg-brand-dark dark:bg-brand-light text-brand-light dark:text-brand-dark font-display font-bold hover:-translate-y-1 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all border border-transparent dark:border-zinc-700 whitespace-nowrap">Subscribe</button>
|
class="w-48 bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-300 dark:border-zinc-700 pl-9 pr-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-cyan dark:focus:border-brand-cyan focus:ring-1 focus:ring-brand-cyan transition-shadow" />
|
||||||
<p data-newsletter-message aria-live="polite" class="font-mono text-xs text-brand-cyan min-h-[1rem]"></p>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,15 +42,25 @@
|
|||||||
<!-- Mobile Menu (outside <nav> to avoid duplicate form[data-newsletter-form] in nav scope) -->
|
<!-- Mobile Menu (outside <nav> to avoid duplicate form[data-newsletter-form] in nav scope) -->
|
||||||
<div id="mobile-menu" class="md:hidden hidden sticky top-20 z-40 border-b border-zinc-200 dark:border-zinc-800 bg-brand-light/95 dark:bg-brand-dark/95 backdrop-blur-md">
|
<div id="mobile-menu" class="md:hidden hidden sticky top-20 z-40 border-b border-zinc-200 dark:border-zinc-800 bg-brand-light/95 dark:bg-brand-dark/95 backdrop-blur-md">
|
||||||
<div class="max-w-7xl mx-auto px-6 py-4 flex flex-col gap-4">
|
<div class="max-w-7xl mx-auto px-6 py-4 flex flex-col gap-4">
|
||||||
<a href="/" class="font-medium py-2 hover:text-brand-cyan transition-colors">Home</a>
|
{% for item in header_items %}
|
||||||
<a href="/articles/" class="font-medium py-2 hover:text-brand-cyan transition-colors">Articles</a>
|
<a href="{{ item.url }}" class="font-medium py-2 hover:text-brand-cyan transition-colors"{% if item.open_in_new_tab %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.title }}</a>
|
||||||
<a href="/about/" class="font-medium py-2 hover:text-brand-pink transition-colors">About</a>
|
{% endfor %}
|
||||||
|
{% for category in category_nav_items %}
|
||||||
|
<a href="{{ category.url }}" class="font-medium py-2 hover:text-brand-cyan transition-colors {% if category.url in request.path %}text-brand-cyan{% endif %}" {% if category.url in request.path %}aria-current="page"{% endif %}>{{ category.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
<form action="{% url 'search' %}" method="get" role="search" class="pt-2 border-t border-zinc-200 dark:border-zinc-800">
|
||||||
|
<div class="relative">
|
||||||
|
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
|
||||||
|
<input type="search" name="q" placeholder="Search articles..." aria-label="Search articles"
|
||||||
|
class="w-full bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-300 dark:border-zinc-700 pl-9 pr-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-cyan dark:focus:border-brand-cyan focus:ring-1 focus:ring-brand-cyan transition-shadow" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
<form method="post" action="/newsletter/subscribe/" data-newsletter-form class="space-y-2 pt-2 border-t border-zinc-200 dark:border-zinc-800" id="mobile-newsletter">
|
<form method="post" action="/newsletter/subscribe/" data-newsletter-form class="space-y-2 pt-2 border-t border-zinc-200 dark:border-zinc-800" id="mobile-newsletter">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="source" value="nav-mobile" />
|
<input type="hidden" name="source" value="nav-mobile" />
|
||||||
<input type="email" name="email" required placeholder="dev@example.com"
|
<input type="email" name="email" required placeholder="dev@example.com"
|
||||||
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 text-brand-dark dark:text-brand-light font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
|
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 text-brand-dark dark:text-brand-light font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
|
||||||
<input type="text" name="honeypot" style="display:none" />
|
<input type="text" name="honeypot" hidden />
|
||||||
<button type="submit" class="w-full px-4 py-2.5 bg-brand-dark dark:bg-brand-light text-brand-light dark:text-brand-dark font-display font-bold transition-colors">Subscribe</button>
|
<button type="submit" class="w-full px-4 py-2.5 bg-brand-dark dark:bg-brand-light text-brand-light dark:text-brand-dark font-display font-bold transition-colors">Subscribe</button>
|
||||||
<p data-newsletter-message aria-live="polite" class="font-mono text-xs text-brand-cyan min-h-[1rem]"></p>
|
<p data-newsletter-message aria-live="polite" class="font-mono text-xs text-brand-cyan min-h-[1rem]"></p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<input type="hidden" name="source" value="{{ source|default:'unknown' }}" />
|
<input type="hidden" name="source" value="{{ source|default:'unknown' }}" />
|
||||||
<input type="email" name="email" required placeholder="dev@example.com"
|
<input type="email" name="email" required placeholder="dev@example.com"
|
||||||
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-4 py-2 text-brand-dark dark:text-brand-light font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
|
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-4 py-2 text-brand-dark dark:text-brand-light font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
|
||||||
<input type="text" name="honeypot" style="display:none" />
|
<input type="text" name="honeypot" hidden />
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="w-full bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark font-display font-bold py-2 hover:bg-brand-pink dark:hover:bg-brand-pink hover:text-white transition-colors">
|
class="w-full bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark font-display font-bold py-2 hover:bg-brand-pink dark:hover:bg-brand-pink hover:text-white transition-colors">
|
||||||
{{ label|default:"Subscribe" }}
|
{{ label|default:"Subscribe" }}
|
||||||
|
|||||||
67
templates/wagtailadmin/base.html
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{% extends "wagtailadmin/admin_base.html" %}
|
||||||
|
{% load wagtailadmin_tags wagtailcore_tags i18n %}
|
||||||
|
|
||||||
|
{% block furniture %}
|
||||||
|
<template data-wagtail-sidebar-branding-logo>{% block branding_logo %}{% endblock %}</template>
|
||||||
|
{% sidebar_props %}
|
||||||
|
<aside id="wagtail-sidebar" class="sidebar-loading" data-wagtail-sidebar aria-label="{% trans 'Sidebar' %}"></aside>
|
||||||
|
{% keyboard_shortcuts_dialog %}
|
||||||
|
<main class="content-wrapper w-overflow-x-hidden" id="main">
|
||||||
|
<div class="content">
|
||||||
|
{# Always show messages div so it can be appended to by JS #}
|
||||||
|
<div class="messages" role="status" data-controller="w-messages" data-action="w-messages:add@document->w-messages#add" data-w-messages-added-class="new" data-w-messages-show-class="appear" data-w-messages-show-delay-value="100" data-w-messages-auto-clear-value="8000">
|
||||||
|
<ul data-w-messages-target="container">
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
{% message_level_tag message as level_tag %}
|
||||||
|
<li class="{% message_tags message %}" data-server-rendered>
|
||||||
|
{% if level_tag == "error" %}
|
||||||
|
{% icon name="warning" classname="messages-icon" %}
|
||||||
|
{% elif message.extra_tags == "lock" %}
|
||||||
|
{% icon name="lock" classname="messages-icon" %}
|
||||||
|
{% elif message.extra_tags == "unlock" %}
|
||||||
|
{% icon name="lock-open" classname="messages-icon" %}
|
||||||
|
{% else %}
|
||||||
|
{% icon name=level_tag classname="messages-icon" %}
|
||||||
|
{% endif %}
|
||||||
|
{{ message }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
<template data-w-messages-target="template" data-type="success">
|
||||||
|
<li class="success">{% icon name="success" classname="messages-icon" %}<span></span></li>
|
||||||
|
</template>
|
||||||
|
<template data-w-messages-target="template" data-type="error">
|
||||||
|
<li class="error">{% icon name="warning" classname="messages-icon" %}<span></span></li>
|
||||||
|
</template>
|
||||||
|
<template data-w-messages-target="template" data-type="warning">
|
||||||
|
<li class="warning">{% icon name="warning" classname="messages-icon" %}<span></span></li>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
Wagtail's w-messages Stimulus controller only auto-clears messages
|
||||||
|
added dynamically via JavaScript (the add() method). Server-rendered
|
||||||
|
messages — the <li> elements above — have no connect() handler and
|
||||||
|
sit in the DOM forever. This script schedules their removal so they
|
||||||
|
auto-dismiss after the same timeout used for dynamic messages.
|
||||||
|
{% endcomment %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var items = document.querySelectorAll('[data-server-rendered]');
|
||||||
|
if (!items.length) return;
|
||||||
|
setTimeout(function () {
|
||||||
|
items.forEach(function (el) { el.remove(); });
|
||||||
|
var ul = document.querySelector('[data-w-messages-target="container"]');
|
||||||
|
if (ul && !ul.children.length) {
|
||||||
|
document.body.classList.remove('has-messages');
|
||||||
|
}
|
||||||
|
}, 8000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,6 +3,7 @@ module.exports = {
|
|||||||
content: [
|
content: [
|
||||||
"../../templates/**/*.html",
|
"../../templates/**/*.html",
|
||||||
"../../apps/**/templates/**/*.html",
|
"../../apps/**/templates/**/*.html",
|
||||||
|
"../../apps/blog/models.py",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
@@ -27,6 +28,7 @@ module.exports = {
|
|||||||
'neon-pink': '0 0 20px rgba(236, 72, 153, 0.3)',
|
'neon-pink': '0 0 20px rgba(236, 72, 153, 0.3)',
|
||||||
'solid-dark': '6px 6px 0px 0px #09090b',
|
'solid-dark': '6px 6px 0px 0px #09090b',
|
||||||
'solid-light': '6px 6px 0px 0px #e4e4e7',
|
'solid-light': '6px 6px 0px 0px #e4e4e7',
|
||||||
|
'solid-pink': '6px 6px 0px 0px #ec4899',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||