128 Commits

Author SHA1 Message Date
Mark
393a574500 Restore exact original comment/reply button styling
- use exact pre-makeover main comment button classes/spacing/icon sizing
- use exact pre-makeover reply button classes/text casing
- rebuild Tailwind CSS
2026-03-04 12:48:31 +00:00
Mark
2f9babe18e Adjust comment UX per feedback: reply alignment and Turnstile init
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m29s
CI / ci (pull_request) Successful in 1m39s
- move reply controls into normal left-aligned flow
- restore stronger primary/reply button treatments
- render Turnstile when reply details panels are opened
- rebuild Tailwind CSS
2026-03-04 12:32:55 +00:00
d39fff2be0 Merge pull request 'Fix comments section UX regressions and HTMX reply/Turnstile behavior' (#49) from fix/comments-standardize-htmx-turnstile into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
Reviewed-on: #49
Reviewed-by: codex_a <codex_a@linteldigital.com>
2026-03-04 12:22:54 +00:00
Mark
badd61b0aa Rebuild Tailwind CSS for comments UI updates
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m31s
CI / ci (pull_request) Successful in 1m38s
2026-03-04 12:17:18 +00:00
Mark
a001ac1de6 Fix comments UX regressions and HTMX/Turnstile behavior
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m32s
CI / ci (pull_request) Failing after 1m39s
- standardize comment and reply UI layout
- target replies with stable OOB container IDs
- remove stale empty-state on approved HTMX comments
- initialize Turnstile widgets after HTMX swaps
- add regression tests for empty-state, OOB targets, and reply form rerender

Refs #48
2026-03-04 11:46:15 +00:00
9bee1b9a12 Merge pull request 'fix: pin deploy job to agent-workspace runner' (#47) from fix/deploy-runner-label into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
Reviewed-on: #47
2026-03-04 11:24:17 +00:00
Mark
4796a08acc fix: pin deploy job to agent-workspace runner
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m33s
CI / ci (pull_request) Successful in 1m37s
The deploy job uses SSH to connect to lintel-prod-01. When the
lintel-prod-01 runner picks up the job, the Docker container cannot
SSH back to its own host, causing 'dial tcp: i/o timeout'. Pin to
the 'deploy' label which only exists on agent-workspace.
2026-03-04 11:20:07 +00:00
17484fa815 Merge pull request 'ci: retrigger deploy after fixing PROD_SSH_KEY secret' (#46) from ci/retrigger-deploy into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Failing after 32s
2026-03-04 11:16:13 +00:00
Mark
96b49bb064 ci: retrigger deploy after fixing PROD_SSH_KEY secret
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m33s
CI / ci (pull_request) Successful in 1m37s
2026-03-04 11:11:29 +00:00
3ccb872cc3 Merge pull request 'feat: redesign comments section for better UX/UI' (#45) from feature/comments-design-makeover into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Failing after 32s
Reviewed-on: #45
Reviewed-by: codex_a <codex_a@linteldigital.com>
2026-03-04 11:00:55 +00:00
Mark
b2ea693d9d fix: resolve review blockers for comments redesign
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m36s
CI / ci (pull_request) Successful in 1m38s
- Fix context key mismatch in _render_htmx_success ('reply' vs 'comment')
- Update OOB swap selector to match new sibling relationship for replies container
- Update HTMX reply tests to verify correct OOB selector and content rendering
- Fix variable naming in _reply.html to match parent context
2026-03-04 10:54:25 +00:00
Mark
48f395866b chore: rebuild Tailwind CSS for comments redesign
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m35s
CI / ci (pull_request) Successful in 1m37s
2026-03-04 10:31:04 +00:00
Mark
c8e01f5201 feat: align comments redesign with new partials structure
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m34s
CI / ci (pull_request) Failing after 1m36s
- Re-applied redesign to new partial templates (_comment.html, _reply.html, etc.)
- Preserved HTMX and reactions functionality from v2 update
- Improved spacing and typography across all comment components
- Verified all E2E tests pass with new structure
2026-03-04 10:28:19 +00:00
Mark
380dcb22c3 feat: redesign comments section for better UX/UI
- Redesigned comment cards with improved spacing and typography
- Added vertical line indicator for reply nesting
- Implemented native details/summary toggle for reply forms (replacing JS)
- Styled 'Join the conversation' section to be more distinct from existing comments
- Added solid-pink shadow to Tailwind configuration
- Updated E2E tests to match new UI structure and elements
2026-03-04 10:24:37 +00:00
ed878bbdae Merge pull request 'feat(comments): v2 — HTMX, Turnstile, reactions, design refresh' (#44) from feature/comments-v2 into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / deploy (push) Has been skipped
CI / nightly-e2e (push) Failing after 1m47s
Reviewed-on: #44
Reviewed-by: codex_a <codex_a@linteldigital.com>
2026-03-04 00:04:42 +00:00
Mark
0eddb9696a fix: validate parent_id in error path, rebuild Tailwind CSS
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m29s
CI / pr-e2e (pull_request) Successful in 1m44s
- Defensively parse parent_id in _render_htmx_error: coerce to int,
  fallback to main form if non-numeric or parent not found
- Rebuild Tailwind CSS to include new utility classes from templates
- Add test for tampered parent_id falling back to main form

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 00:00:23 +00:00
Mark
c01fc14258 fix: resolve review round 2, E2E failures, and mypy error
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m30s
CI / ci (pull_request) Failing after 1m48s
Review blocker A — form error swap and false success:
- Change HTMX contract so forms target their own container (outerHTML)
  instead of appending to #comments-list
- Use OOB swaps to append approved comments to the correct target
- Add success/error message display inside form templates
- Remove hx-on::after-request handlers (no longer needed)

Review blocker B — reply rendering shape:
- Create _reply.html partial with compact reply markup
- Approved replies via HTMX now use compact template + OOB swap
  into parent's .replies-container
- Reply form errors render inside reply form container

E2E test fixes:
- Update 4 failing tests to wait for inline HTMX messages instead
  of redirect-based URL assertions
- Add aria-label='Comment form errors' to form error display
- Rename test_reply_submission_redirects to
  test_reply_submission_shows_moderation_message

Mypy internal error workaround:
- Add mypy override for apps.comments.views (django-stubs triggers
  internal error on ORM annotate() chain with mypy 1.11.2)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 23:47:12 +00:00
Mark
88ce59aecc fix: resolve 5 PR review blockers for comments v2
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Failing after 2m7s
CI / ci (pull_request) Failing after 2m43s
1. Reply HTMX target: server sends HX-Retarget/HX-Reswap headers to
   insert replies inside parent comment's .replies-container div
2. Empty thread swap target: always render #comments-list container
   even when no approved comments exist
3. Reaction hydration: add _annotate_reaction_counts() helper that
   hydrates reaction_counts and user_reacted on comments in
   get_context(), comment_poll(), and single-comment responses
4. HTMX error swap: return 200 instead of 422 for form errors since
   HTMX 2 doesn't swap 4xx responses by default
5. Vary header: use patch_vary_headers() instead of direct assignment
   to avoid overwriting existing Vary directives

Also fixes _get_session_key() to handle missing session attribute
(e.g. from RequestFactory in performance tests).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 23:24:20 +00:00
Mark
a118df487d fix(comments): resolve ruff lint errors
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Failing after 1m39s
CI / pr-e2e (pull_request) Failing after 2m4s
Remove unused imports (urlencode, F) and fix import sort order in
test_v2.py.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 22:56:38 +00:00
Mark
d0a550fee6 feat(comments): v2 — HTMX, Turnstile, reactions, design refresh
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Failing after 37s
CI / pr-e2e (pull_request) Failing after 2m58s
- Extract comment templates into reusable partials (_comment.html,
  _comment_form.html, _comment_list.html, _reply_form.html, etc.)
- Add HTMX progressive enhancement: inline form submission with
  partial responses, delta polling for live updates, form reset on
  success, success/moderation toast feedback
- Integrate Cloudflare Turnstile for invisible bot protection:
  server-side token validation with hostname check, fail-closed on
  errors/timeouts, feature-flagged via TURNSTILE_SECRET_KEY env var
- Auto-approve comments that pass Turnstile; keep manual approval
  as fallback when Turnstile is disabled (model default stays False)
- Add CommentReaction model with UniqueConstraint for session-based
  anonymous reactions (heart/thumbs-up), toggle support, separate
  rate-limit bucket (20/min)
- Add comment poll endpoint (GET /comments/poll/<id>/?after_id=N)
  for HTMX delta polling without duplicates
- Update CSP middleware to allow challenges.cloudflare.com in
  script-src, connect-src, and frame-src
- Self-host htmx.min.js (v2.0.4) to minimize CSP surface area
- Add django-htmx middleware and requests to dependencies
- Add Unapprove bulk action to Wagtail admin for moderation
- Extend PII purge command to anonymize reaction session_key
- Design refresh: neon glow avatars, solid hover shadows, gradient
  section header, cyan reply borders, grid-pattern empty state,
  neon-pink focus glow on form inputs
- Add turnstile_site_key to template context via context processor
- 18 new tests covering HTMX contracts, Turnstile success/failure/
  timeout/hostname-mismatch, polling deltas, reaction toggle/dedup/
  rate-limit, CSP headers, and PII purge extension

Closes #43

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 22:52:59 +00:00
cc25d2ad2e Merge pull request 'feat: implement article search with PostgreSQL full-text search' (#42) from feature/article-search into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 26s
Reviewed-on: #42
Reviewed-by: codex_a <codex_a@linteldigital.com>
2026-03-03 21:58:07 +00:00
Mark
99b06d1f3b chore: rebuild Tailwind CSS for search template classes
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m21s
CI / pr-e2e (pull_request) Successful in 1m34s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 21:30:26 +00:00
Mark
906206d4cd feat: implement article search with PostgreSQL full-text search
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Failing after 1m21s
CI / pr-e2e (pull_request) Successful in 1m33s
- Configure Wagtail database search backend with English search config
- Add django.contrib.postgres to INSTALLED_APPS for full PG FTS support
- Expand ArticlePage.search_fields: body_text (excl. code blocks),
  AutocompleteField(title), RelatedFields(tags), FilterFields
- Add search view at /search/?q= with query guards (strip, max 200 chars,
  empty/whitespace handling) and pagination preserving query param
- Replace nav Subscribe CTA with compact search box (desktop + mobile)
- Add search box to article index page alongside category/tag filters
- Create search results template reusing article_card component
- Add update_index to deploy entrypoint for automated reindexing
- Update existing tests for nav change, add comprehensive search tests

Closes #41

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 21:25:11 +00:00
eebd5c9978 Merge pull request 'feat: improve Wagtail admin editor experience for articles' (#40) from feature/improve-editor-experience into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
Reviewed-on: #40
Reviewed-by: codex_a <codex_a@linteldigital.com>
2026-03-03 20:46:30 +00:00
Mark
2acb194d40 Add E2E_MODE=1 to CI E2E containers for admin user seeding
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m19s
CI / pr-e2e (pull_request) Successful in 1m33s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 20:42:33 +00:00
Mark
b897447296 Address PR review feedback
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m19s
CI / pr-e2e (pull_request) Failing after 2m13s
- Gate e2e-admin superuser behind E2E_MODE env var (security)
- Add status and tag filters to ArticleFilterSet
- Set default_ordering to -published_date on listing viewset
- Add summary to ArticlePage.search_fields for search support
- Add 4 new tests for filters, ordering, and search fields

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 20:37:58 +00:00
Mark
d387bf4f03 Rebuild Tailwind CSS for new dashboard panel template
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m30s
CI / ci (pull_request) Successful in 1m31s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 20:04:02 +00:00
Mark
be8d6d4a12 fix: resolve ruff/mypy lint errors and fix E2E test failures
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Failing after 1m19s
CI / pr-e2e (pull_request) Successful in 1m31s
- Use datetime.timedelta instead of timezone.timedelta (mypy)
- Fix import ordering (ruff I001)
- Fix admin sidebar E2E selector: use #wagtail-sidebar (Wagtail 7)
- Set deterministic published_date on seeded E2E articles for stable ordering
- Fix nightly test strict-mode violation: exact=True for Comments heading

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 14:24:01 +00:00
Mark
2b1e7ff4eb fix: resolve ruff lint errors (unused imports, import sorting)
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Failing after 28s
CI / pr-e2e (pull_request) Failing after 1m37s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 14:10:10 +00:00
Mark
2c94040221 feat: improve Wagtail admin editor experience for articles
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Failing after 9s
CI / pr-e2e (pull_request) Failing after 1m38s
- Add published_date field to ArticlePage with auto-populate from
  first_published_at on first publish, plus data migration backfill
- Surface go_live_at/expire_at scheduling fields in editor panels
- Reorganise ArticlePage editor with TabbedInterface (Content,
  Metadata, Publishing, SEO tabs)
- Add Articles PageListingViewSet to admin menu with custom columns
  (author, category, published date, status) and category/author filters
- Add Articles summary dashboard panel showing drafts, scheduled,
  and recently published articles
- Update all front-end queries and RSS feeds to use published_date
- Add 10 unit tests and 4 E2E tests for new admin features

Closes #39

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 14:07:27 +00:00
2d93555c60 Merge pull request 'Fix Comments admin 500 on snippet index' (#38) from fix/comments-admin-500-issue-37 into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
Reviewed-on: #38
Reviewed-by: codex_a <codex_a@linteldigital.com>
2026-03-03 13:31:40 +00:00
Mark
73b023dca2 Fix comments snippet admin 500
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m15s
CI / pr-e2e (pull_request) Successful in 1m36s
Use an explicit Wagtail Column for pending_in_article in CommentViewSet list_display and add a regression test for /cms/snippets/comments/comment/.

Fixes #37

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 13:21:01 +00:00
6555fdc41e Merge pull request 'Implement category taxonomy and navigation (Issue #35)' (#36) from feature/category-navigation-system into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
Reviewed-on: #36
Reviewed-by: codex_a <codex_a@linteldigital.com>
2026-03-03 13:03:26 +00:00
Mark
e8b835e6fc Make Playwright cache runner-agnostic
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m17s
CI / ci (pull_request) Successful in 1m22s
Replace hardcoded /opt/playwright-tools mount with a persistent Docker volume cache and install Chromium into that cache before E2E jobs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 12:56:37 +00:00
Mark
04a55844fd Fix empty-category nav and route behavior
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m17s
CI / pr-e2e (pull_request) Failing after 44s
Use category-state-driven queries for nav and category listing routes, and add regression tests for empty but valid categories.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 12:39:47 +00:00
Mark
f7ca4bc44b Fix mypy relation resolution in CI
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m16s
CI / ci (pull_request) Successful in 1m23s
Disable reverse manager generation on ArticlePage.category and switch category selection to id-based queries so CI mypy can resolve models reliably.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 11:37:20 +00:00
Mark
7669a5049c Fix lint and E2E filter regression
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Failing after 19s
CI / pr-e2e (pull_request) Failing after 44s
Wrap long lines for Ruff and restore a single 'All' tag-reset link to avoid Playwright strict-mode collisions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 11:29:21 +00:00
Mark
e2f71a801c Add category taxonomy and navigation integration
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Failing after 14s
CI / pr-e2e (pull_request) Failing after 1m19s
Implements Issue #35 with category snippets, article category routing, category-aware templates, and category RSS feeds with tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 11:20:07 +00:00
49baf6a37d Merge pull request 'fix: align templates with wireframe styling' (#34) from fix/wireframe-styling-audit into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / deploy (push) Has been skipped
CI / nightly-e2e (push) Failing after 44s
Reviewed-on: #34
2026-03-02 23:21:56 +00:00
Mark
d65a802afb fix: align templates with wireframe styling
All checks were successful
CI / deploy (pull_request) Has been skipped
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m14s
CI / ci (pull_request) Successful in 1m20s
- Fix article header tag borders: replace broken border-current/20
  (Tailwind can't apply opacity to currentColor) with per-tag border
  colour classes via new get_tag_border_css filter
- Add calendar icon before article date in article header
- Add clock icon before read time in article header and home featured
- Match article card footer to wireframe (remove extra min-read span)
- Add rounded-md to code block matching wireframe

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 20:07:40 +00:00
6342133851 Merge pull request 'feat: replace hardcoded navigation with CMS-managed models' (#33) from feature/navigation-overhaul into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
Reviewed-on: #33
Reviewed-by: codex_b <codex_b@linteldigital.com>
2026-03-02 19:52:00 +00:00
Mark
d3687779a2 fix: address review feedback — URLField→CharField, safe reverse migration
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m14s
CI / ci (pull_request) Successful in 1m20s
- Change SocialMediaLink.url and NavigationMenuItem.link_url from
  URLField to CharField(max_length=500) to support internal paths
  like /feed/ that fail URLField validation
- Replace destructive reverse_seed (deleted ALL rows) with
  RunPython.noop to prevent data loss on rollback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 19:36:27 +00:00
Mark
1c5ba6cf90 feat: replace hardcoded navigation with CMS-managed models
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m15s
CI / ci (pull_request) Successful in 1m20s
Replace static nav/footer links with Wagtail-managed NavigationMenuItem
and SocialMediaLink orderables on SiteSettings. Unpublished pages are
automatically excluded from rendering, fixing the dead-link problem.

- Extend SiteSettings with site_name, tagline, footer_description,
  copyright_text branding fields
- Add NavigationMenuItem orderable (link_page/link_url, show_in_header,
  show_in_footer, sort_order) with automatic live-page filtering
- Add SocialMediaLink orderable with platform icon templates
- New template tags: get_nav_items, get_social_links
- Update nav.html and footer.html to render from CMS data
- Data migration seeds existing hardcoded values for zero-change deploy
- Update seed_e2e_content command for test/dev environments
- 18 new tests covering models, template tags, and rendered output

Closes #32

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 19:07:35 +00:00
22d596d666 Merge pull request 'ci: re-trigger deploy after fixing PROD_SSH_HOST secret' (#31) from ci/retrigger-deploy into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
Reviewed-on: #31
2026-03-02 18:25:17 +00:00
Mark
987f308e06 ci: re-trigger deploy after fixing PROD_SSH_HOST secret
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m10s
CI / ci (pull_request) Successful in 1m14s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 18:22:50 +00:00
bcc9305a00 Merge pull request 'feat: add SVG favicon matching header logo' (#30) from fix/favicon into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Failing after 32s
Reviewed-on: #30
2026-03-02 18:15:30 +00:00
Mark
62ff7f5792 feat: add SVG favicon matching header logo
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m13s
CI / ci (pull_request) Successful in 1m15s
Create a static SVG favicon replicating the nav logo — a forward slash
on a dark (#09090b) square with the brand light (#fafafa) text colour.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 18:14:10 +00:00
ad271aa817 Merge pull request 'fix: match tag colours to wireframe neon style' (#29) from fix/tag-neon-colours into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 22s
Reviewed-on: #29
2026-03-02 17:13:04 +00:00
Mark
8a97b6e2a0 fix: match tag colours to wireframe neon style
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m11s
CI / ci (pull_request) Successful in 1m16s
Update TagMetadata CSS classes to use brand colours with translucent
backgrounds matching the wireframe design:
- cyan:    bg-brand-cyan/10 text-brand-cyan
- pink:    bg-brand-pink/10 text-brand-pink
- neutral: bg-zinc-800 text-white (dark: bg-zinc-100 text-black)

Previously used muted Tailwind defaults (bg-cyan-100/text-cyan-900)
which appeared as soft pastels instead of the intended neon look.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 17:10:38 +00:00
43e7068110 Merge pull request 'fix: include blog models in Tailwind content scan for tag colours' (#28) from fix/tag-colour-safelist into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 23s
Reviewed-on: #28
2026-03-02 16:29:55 +00:00
Mark
6bae864c1e fix: include blog models in Tailwind content scan for tag colours
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m11s
CI / ci (pull_request) Successful in 1m16s
Tag colour classes (bg-cyan-100, text-cyan-900, etc.) are generated
dynamically in TagMetadata.get_css_classes() in apps/blog/models.py.
Tailwind's content scanner only covered HTML templates, so these classes
were purged from the CSS build — rendering tags as white-on-white.

Add apps/blog/models.py to the Tailwind content array so the JIT
compiler detects and retains the dynamic colour classes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 16:28:06 +00:00
17d30a4073 Merge pull request 'fix: upgrade Pillow to 12.x for native AVIF support' (#27) from fix/pillow-avif-support into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 1m24s
Reviewed-on: #27
2026-03-02 16:15:54 +00:00
Mark
0818f71566 fix: upgrade Pillow to 12.x for native AVIF support
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m38s
CI / ci (pull_request) Successful in 2m9s
Pillow 11.0.x did not include native AVIF support — that was added in
11.1.0. The ~=11.0.0 pin restricted upgrades to 11.0.x, so the
libavif-dev system package installed in the Dockerfile was never used
(pip installs pre-compiled wheels that bundle their own libraries).

Bump to Pillow ~=12.1 which ships native AVIF encoding/decoding in its
PyPI wheels out of the box.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 16:10:26 +00:00
3799d76bed Merge pull request 'fix(docker): add libavif-dev for AVIF image upload support' (#26) from fix/avif-support into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 1m33s
Reviewed-on: #26
2026-03-02 16:00:17 +00:00
Mark
fbe8546b37 fix(docker): add libavif-dev for AVIF image upload support
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 2m53s
CI / ci (pull_request) Successful in 3m27s
Pillow 11 supports AVIF natively but requires libavif to be installed
at the system level. Without it, uploading AVIF images via Wagtail's
image chooser causes an unhandled 500 error.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 15:52:57 +00:00
a59d21cfcb Merge pull request 'fix(csp): skip restrictive CSP on Wagtail/Django admin paths' (#25) from fix/csp-wagtail-admin into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 23s
Reviewed-on: #25
2026-03-02 15:36:12 +00:00
Mark
43594777e0 fix(csp): skip restrictive CSP on Wagtail/Django admin paths
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m11s
CI / ci (pull_request) Successful in 1m22s
The SecurityHeadersMiddleware applied a strict style-src policy to all
responses, blocking inline styles that Wagtail admin relies on for
layout. Skip the custom CSP for /cms/ and /django-admin/ paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 15:34:09 +00:00
f7c89be05c Merge pull request 'fix(makefile): point DC at prod compose file' (#24) from fix/makefile-prod-compose into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
Reviewed-on: #24
2026-03-02 15:03:15 +00:00
Mark
2e7949ac23 fix(makefile): point DC at prod compose file
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m11s
CI / ci (pull_request) Successful in 1m23s
The Makefile used bare 'docker compose' which picks up the dev
docker-compose.yml when run from the app directory on prod. Point
it at the absolute path to docker-compose.prod.yml instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 15:01:27 +00:00
f5c2f87820 Merge pull request 'feat: add Makefile for Docker and Django ops' (#23) from feat/makefile into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / deploy (push) Has been skipped
CI / nightly-e2e (push) Failing after 54s
Reviewed-on: #23
2026-03-01 14:26:51 +00:00
codex_a
abbc3c3d1d feat: add Makefile for Docker and Django ops
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m13s
CI / ci (pull_request) Successful in 1m25s
Covers:
- Docker: build, up, run, down, restart, logs, ps, bash, psql
- Django: migrate, makemigrations, showmigrations, createsuperuser,
  collectstatic, shell, dbshell
- Tailwind: install, build, watch
- Testing: pytest unit and E2E targets
- Custom commands: seed, check-content, purge-comments

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 14:24:13 +00:00
c028a83bef Merge pull request 'fix: nav/footer wireframe alignment, honeypot CSP fix, comment E2E coverage' (#22) from fix/ui-cleanup into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
Reviewed-on: #22
2026-03-01 12:35:20 +00:00
codex_a
155c8f7569 fix: nav/footer wireframe, honeypot CSP, explore topics, comment E2E coverage
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m11s
CI / ci (pull_request) Successful in 1m25s
- Replace nav inline newsletter form with Subscribe CTA link per wireframe
- Remove newsletter form from footer; add Connect section with social/RSS links
- Fix honeypot inputs using hidden attribute (inline style blocked by CSP)
- Add available_tags to HomePage.get_context for Explore Topics section
- Add data-comment-form attribute to main comment form for reliable locating
- Seed approved comment in E2E content for reply flow testing
- Expand test_comments.py: moderation message, not-immediately-visible,
  missing fields, reply form visible, reply submission
- Make COMMENT_RATE_LIMIT_PER_MINUTE configurable; set 100 in dev to prevent
  E2E test exhaustion; update rate limit unit test with override_settings
- Update newsletter/home E2E tests to reflect nav form removal
- Update unit test to assert no nav/footer newsletter forms

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 12:17:55 +00:00
d83f7db57c Merge pull request 'fix: migrate STATICFILES_STORAGE to STORAGES (Django 5.2)' (#21) from fix/storages-django52 into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
Reviewed-on: #21
2026-03-01 11:51:04 +00:00
codex_a
221c8c19c2 fix: migrate STATICFILES_STORAGE to STORAGES for Django 5.2 compat
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m6s
CI / ci (pull_request) Successful in 1m25s
Django 5.1+ removes STATICFILES_STORAGE in favour of the STORAGES dict.
The old setting was silently ignored on Django 5.2, causing StaticFilesStorage
(the default) to be used instead of CompressedManifestStaticFilesStorage.

Result: no content-hashed filenames, no staticfiles.json manifest, and
Cloudflare caching /static/css/styles.css indefinitely with no cache
busting on deploy.

Fix: use STORAGES in base.py (CompressedManifestStaticFilesStorage) and
development.py (plain StaticFilesStorage, whitenoise disabled in dev).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 11:47:37 +00:00
c0cd4e5037 Merge pull request 'fix: allow Google Fonts in CSP' (#20) from fix/csp-google-fonts into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 23s
Reviewed-on: #20
2026-03-01 11:35:13 +00:00
codex_a
78c4313874 fix: allow Google Fonts in CSP
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m8s
CI / ci (pull_request) Successful in 1m25s
style-src and font-src were 'self' only, blocking fonts.googleapis.com
stylesheet and fonts.gstatic.com font files. Add both origins so
Space Grotesk, Inter and Fira Code load correctly in production.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 11:31:41 +00:00
ec89a5fe35 Merge pull request 'feat: implement Tailwind CSS styling' (#19) from feat/styling into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 22s
Reviewed-on: #19
Reviewed-by: codex_b <codex_b@linteldigital.com>
2026-03-01 11:27:33 +00:00
codex_a
71fe06edd1 fix: address QA review findings
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m6s
CI / ci (pull_request) Successful in 1m24s
1. Typography: install @tailwindcss/typography and add to plugins so prose
   classes render correctly in article/about/legal templates.

2. Callout block: fix icon branches to match CalloutBlock.ICON_CHOICES
   (info/warning/trophy/tip). Previous template branched on error/success
   which are unreachable; info fell through to else silently.

3. Nav newsletter feedback: remove 'hidden' class from desktop nav
   data-newsletter-message element. JS sets textContent only; hidden
   class prevented message from ever being visible.

4. Popular Articles sidebar: add numbered Popular Articles widget to home
   page sidebar matching wireframe, using latest_articles context with
   alternating cyan/pink number accents and read_time_mins.

Rebuild CSS: typography plugin grows output from 24KB to 47KB.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 11:20:57 +00:00
codex_a
bff59eec06 fix: move mobile menu outside nav to prevent playwright strict mode violation
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m23s
Playwright strict mode requires exactly one match for form[data-newsletter-form]
inside nav. Having desktop + mobile forms both inside <nav> caused a strict
violation. Mobile menu div is now outside the closing </nav> tag; JS toggle
uses getElementById so position doesn't matter.

Rebuild CSS to match template changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 10:43:17 +00:00
codex_a
8b83712cbf fix: address code review issues and rebuild CSS
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Failing after 1m4s
CI / ci (pull_request) Successful in 1m22s
- nav: add functional mobile menu panel with JS toggle
- nav: hamburger now shows/hides mobile-menu with aria-expanded state
- about_page: full styled layout (header, prose body, author aside)
- legal_page: full styled layout (header with last-updated, max-w-3xl prose)
- article: fix aside newsletter label to 'Subscribe' for E2E test
- CSS: rebuild after all template changes (4.8KB → 24.3KB)
  Committed CSS must match CI build — rebuilt after ALL template edits

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 10:25:33 +00:00
codex_a
73ef38a144 fix: resolve E2E test failures
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Failing after 1m23s
CI / pr-e2e (pull_request) Failing after 1m35s
- nav: replace hidden newsletter form + Subscribe link with visible inline form
- nav: fix theme toggle aria-label to 'Toggle theme' (was 'Toggle Dark Mode')
- article_page: wrap share buttons in <section aria-label='Share this article'>

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 10:14:36 +00:00
codex_a
1c7b96f723 feat: implement Tailwind CSS styling based on wireframe design
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m24s
CI / pr-e2e (pull_request) Failing after 3m21s
- Add brand colours, fonts (Space Grotesk/Inter/Fira Code), box shadows to tailwind.config.js
- Add bg-grid-pattern, text-gradient, scrollbar, selection styles to input.css
- Add Google Fonts link and dark-mode body classes to base.html
- Style nav, footer, cookie banner, newsletter form components
- Style homepage: featured article, 12-col editorial grid, sidebar widgets
- Style article list: header, tag filters, horizontal article cards, pagination
- Style article page: hero header, prose body, share sidebar, related cards, comments
- Style code blocks and callout blocks
- CSS output grows from 4.8KB to 24KB with all brand utilities compiled

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 10:02:23 +00:00
a880643d65 Merge pull request 'fix: cd to site dir before docker compose commands' (#18) from fix/deploy-workdir into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
2026-03-01 09:40:43 +00:00
codex_a
229c0f8b48 fix: cd to site dir before docker compose commands
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m7s
CI / ci (pull_request) Successful in 1m15s
docker compose stats the cwd when parsing compose files; if cwd is
not accessible to the deploy user the command fails.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 09:38:54 +00:00
d7bfb44ced Merge pull request 'fix: remove sudo from deploy script' (#17) from fix/deploy-no-sudo into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / deploy (push) Has been skipped
CI / nightly-e2e (push) Failing after 44s
2026-02-28 22:16:30 +00:00
codex_a
ec3e1ee1bf fix: remove sudo from deploy script, use docker compose directly
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Failing after 42s
CI / ci (pull_request) Successful in 1m1s
deploy user has no sudo for systemctl. Instead:
- Use 'docker compose up -d --force-recreate' to recreate the web
  container without needing systemctl
- Change Restart=always so systemd re-attaches after the container
  is recreated
- Replace 'sudo journalctl' with 'docker compose logs' in error path

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 22:15:02 +00:00
44ffae7f99 Merge pull request 'fix: auto-set Wagtail site hostname on startup' (#16) from fix/prod-site-hostname into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Failing after 2m30s
2026-02-28 22:08:29 +00:00
codex_a
c9dab3e93b fix: use correct Host header in deploy health check
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Failing after 44s
CI / ci (pull_request) Successful in 1m2s
ALLOWED_HOSTS doesn't include localhost, so curl with default Host
header always gets a 400 and the health check fails.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 21:55:58 +00:00
codex_a
349f1db721 fix: auto-set Wagtail site hostname from ALLOWED_HOSTS on startup
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m15s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 21:54:27 +00:00
de56b564c5 Merge pull request 'feat: production deploy pipeline' (#15) from feat/deploy-pipeline into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Failing after 1s
2026-02-28 21:50:04 +00:00
codex_a
833ff378ea fix: use entrypoint script for prod container startup
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m15s
YAML folded block scalar (>) was preserving newlines for more-indented
continuation lines, so gunicorn received no arguments and defaulted to
binding on 127.0.0.1:8000. Replace with an explicit entrypoint script
so all args are passed correctly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 21:47:02 +00:00
codex_a
754b0ca5f6 fix: set build context to app/ in prod compose
The compose file lives in /srv/sum/nohype/ while the code is in
/srv/sum/nohype/app/ so the Dockerfile is at app/Dockerfile.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 21:42:06 +00:00
03fcbdb5ad Merge pull request 'feat: production deploy pipeline' (#14) from feat/deploy-pipeline into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Failing after 5s
Reviewed-on: #14
2026-02-28 21:40:03 +00:00
codex_a
0cbac68ec1 feat: add production deploy pipeline and fix dev CSS
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m23s
Dev:
- Add tailwind install + build to docker-compose startup so CSS is built
  inside the container — not dependent on local filesystem

Production (docker-compose.prod.yml):
- Gunicorn on 127.0.0.1:8001, bind-mounted static/media to host paths
  so Caddy can serve them directly
- Runs migrate, tailwind build, collectstatic on startup

Settings (production.py):
- Disable SECURE_SSL_REDIRECT (Caddy handles redirects; Django would loop)
- Add CSRF_TRUSTED_ORIGINS for nohypeai.net

CI (.gitea/workflows/ci.yml):
- Add push-to-main trigger
- Add deploy job: SSHes to lintel-prod-01 as deploy, runs deploy/deploy.sh

Server config (deploy/):
- deploy/caddy/nohype.caddy — Caddy site config for nohypeai.net
- deploy/sum-nohype.service — systemd unit for the compose stack
- deploy/deploy.sh — deploy script (pull, build, restart)

One-time manual steps required on lintel-prod-01 (need root):
  sudo cp deploy/sum-nohype.service /etc/systemd/system/
  sudo cp deploy/caddy/nohype.caddy /etc/caddy/sites-enabled/
  sudo systemctl daemon-reload && sudo systemctl enable sum-nohype
  sudo systemctl reload caddy

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 21:34:13 +00:00
4c27cfe1dd Merge pull request 'fix: make docker compose up work out of the box' (#13) from fix/dev-setup into main
Reviewed-on: #13
2026-02-28 21:00:10 +00:00
codex_a
a598727888 fix: fix all dev static/media serving issues
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m22s
- Remove WhiteNoise from MIDDLEWARE in development: it intercepts /static/
  requests and serves from STATIC_ROOT, which is empty without collectstatic.
  Django's runserver serves static files natively with DEBUG=True.
- Switch to StaticFilesStorage in dev: no manifest required.
- Add media URL pattern in DEBUG mode: runserver does not serve MEDIA_ROOT
  automatically, so uploaded images were 404ing in local dev.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 20:57:54 +00:00
36eb0f1dd2 Merge pull request 'fix: use plain StaticFilesStorage in dev settings' (#10) from fix/seed-default-site into main
Reviewed-on: #10
2026-02-28 20:48:44 +00:00
codex_a
f950e3cd5e fix: use plain StaticFilesStorage in dev settings
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m24s
CompressedManifestStaticFilesStorage requires collectstatic to generate a
manifest before it can serve anything. Dev containers never run collectstatic
so every static asset 404s. Override to StaticFilesStorage in dev so Django
serves files directly from STATICFILES_DIRS and app static directories.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 20:46:23 +00:00
311ad80320 Merge pull request 'fix: update ALL site records in seed, not just is_default_site' (#9) from fix/seed-default-site into main
Reviewed-on: #9
2026-02-28 20:42:24 +00:00
codex_a
08e003e165 fix: update ALL site records in seed, not just is_default_site
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m22s
Wagtail's initial migration creates a localhost:80 site. Wagtail matches
incoming requests by hostname before ever checking is_default_site, so
updating only the is_default_site record left localhost:80 still pointing
at the Welcome page. Fix by updating root_page on every site.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 20:40:20 +00:00
076aaa0b9e Merge pull request 'fix: update existing default site in seed command' (#8) from fix/seed-default-site into main
Reviewed-on: #8
2026-02-28 20:35:15 +00:00
codex_a
7b2ad4cfe5 fix: remove server-specific playwright volume from dev compose, auto-seed on startup
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m6s
CI / ci (pull_request) Successful in 1m23s
- /opt/playwright-tools/browsers only exists on agent-workspace; mounting it
  in docker-compose.yml breaks local dev for everyone else
- seed_e2e_content is idempotent so safe to run on every startup; removes the
  manual step that nobody knew about

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 20:33:40 +00:00
codex_a
56e53478ea fix: update existing default site in seed command instead of hardcoding 127.0.0.1
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m10s
CI / ci (pull_request) Successful in 1m22s
Wagtail initialises the default site with hostname 'localhost'. The previous
get_or_create on '127.0.0.1' left the localhost site intact (still pointing
to the Welcome page), so browsers got the wrong root page.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 20:31:20 +00:00
96f9eca19d Merge pull request 'feat: comprehensive Playwright E2E test suite' (#7) from tests/e2e into main
Reviewed-on: #7
2026-02-28 20:25:29 +00:00
codex_a
f6edcadd46 fix: run E2E tests properly with mounted browsers and real postgres
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m33s
CI / ci (pull_request) Successful in 2m18s
- Mount /opt/playwright-tools/browsers into web container (docker-compose.yml
  and CI docker run) — never download browsers, use the ones on this host
- Set PLAYWRIGHT_BROWSERS_PATH in all container envs (compose + CI)
- Drop 'playwright install chromium' steps from pr-e2e and nightly-e2e jobs
- Bump playwright requirement to ~1.57.0 to match the installed browser builds
- Fix seed_e2e_content: de-duplicate default Site entries left by unit test
  fixtures so Wagtail always routes to the seeded home page
- Fix test_comments_section_absent_when_disabled: use exact=True on heading
  locator to avoid matching 'No Comments Article' h1 as 'Comments' heading
- Fix test_copy_link_button_updates_text: use [data-copy-link] data-attr
  locator (stable across text change) and force-override clipboard.writeText
  via page.evaluate() rather than relying on init_script polyfill

E2E suite verified locally: 34 passed via docker compose exec

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 20:20:18 +00:00
codex_a
4992b0cb9d fix: resolve 5 E2E test failures from first CI run
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m22s
CI / pr-e2e (pull_request) Failing after 1m32s
- test_homepage_title_contains_brand: to_have_title() requires a string or
  regex, not a lambda; switch to re.compile('No Hype AI')
- test_granular_preferences_save_dismisses_banner: wrong element clicked to
  open <details>; use 'details summary' locator directly
- test_subscribe_invalid_email_shows_error: browser HTML5 email validation
  swallows the submit event before the JS handler fires; add 'novalidate' via
  evaluate() so the fetch still runs and the server returns 400
- test_copy_link_button_updates_text: clipboard API unavailable in headless
  Docker; add polyfill + pre-grant permissions in conftest page fixture so
  the JS success path runs and button text becomes 'Copied'
- test_comments_section_absent_when_disabled: guard against Wagtail's
  add_child() resetting BooleanField defaults by calling an explicit
  .update(comments_enabled=False) + re-setting on the instance before
  save_revision().publish(); also tighten test to assert 200 + correct title

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 19:47:13 +00:00
codex_a
9d323d2040 feat: add comprehensive Playwright E2E test suite
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m22s
CI / pr-e2e (pull_request) Failing after 3m28s
- Create e2e/ directory with 7 test modules covering:
  - Home page: title, nav links, theme toggle, newsletter form
  - Cookie consent: accept all, reject all, granular prefs, persistence
  - Article index: loads, tag filter, click-through navigation
  - Article detail: title/read-time, share section, comments, newsletter aside, related
  - Comments: valid submit → redirect, empty body → error display, disabled check
  - Newsletter: JS confirmation message, invalid email error, aside form, duplicate
  - Feeds: RSS/sitemap/robots.txt validity, tag feed, seeded content present
- Extend seed_e2e_content management command with tagged article, about page,
  no-comments article, and legal pages for richer test coverage
- Add seed command tests (create + idempotency) to keep coverage ≥ 90%
- Add pr-e2e CI job (runs on pull_request): builds image, starts postgres + app,
  installs playwright, runs pytest e2e/
- Update nightly-e2e to run full e2e/ suite alongside legacy journey test
- Add --ignore=e2e to unit-test pytest step (coverage must not include browser tests)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 19:30:43 +00:00
aeb0afb2ea Merge pull request 'Run migrations before starting dev server' (#6) from fix/run-migrations-on-startup into main
Reviewed-on: #6
2026-02-28 19:13:57 +00:00
codex_a
b73a2d4d72 Run migrations before starting dev server
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m21s
Chains 'migrate --noinput' before 'runserver' in the web
service command so migrations are applied automatically on
'docker compose up'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 18:53:32 +00:00
7d226bc4ef Merge pull request 'fix: docker fix' (#5) from fix/docker-apt-retries into main
Reviewed-on: #5
2026-02-28 18:32:29 +00:00
Mark
9b677741cb fix: docker fix
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 3m27s
2026-02-28 18:27:56 +00:00
c5f2902417 Merge pull request 'Harden Docker apt install against transient mirror failures' (#4) from fix/docker-apt-retries into main
Reviewed-on: #4
2026-02-28 18:15:52 +00:00
Mark
5d1c5f43bc Harden Docker apt install with retry logic
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 3m48s
2026-02-28 18:09:24 +00:00
027dff20e3 Merge pull request 'Corrective implementation of implementation.md (containerized Django/Wagtail)' (#3) from codex_b/implementation-e2e into main
Reviewed-on: #3
Reviewed-by: codex_a <codex_a@linteldigital.com>
2026-02-28 17:55:13 +00:00
Mark
c4fde90a9c fix(spec): enforce read-time budget and re-render invalid comment submissions
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m1s
2026-02-28 17:36:34 +00:00
Mark
cfe0cbca62 fix(mypy): work around django-plugin annotate crash in comment viewset
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m20s
2026-02-28 17:21:29 +00:00
Mark
5adff60d4b docs+comments: align plan with gitea PR-only CI and close remaining blockers
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Failing after 20s
2026-02-28 17:17:19 +00:00
Mark
0c9340d279 chore(ci): remove github workflow mirror and use gitea as source
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m1s
2026-02-28 16:49:34 +00:00
Mark
ebddb6c904 fix(ci): validate tailwind output without host bind mount
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m21s
2026-02-28 16:47:58 +00:00
Mark
29e3589b1a fix(ci): avoid docker subnet exhaustion and harden nightly feed check
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Failing after 3m40s
2026-02-28 16:43:20 +00:00
Mark
14db1bb57e fix(ci): address PR blockers and move CI/nightly off sqlite
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Failing after 35s
2026-02-28 16:38:37 +00:00
Codex_B
36ac487cbd Resolve PR review gaps across comments, security, feeds, and UX
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 48s
2026-02-28 13:47:21 +00:00
Codex_B
932b05cc02 Add performance regression tests for core page flows
All checks were successful
CI / ci (pull_request) Successful in 48s
2026-02-28 13:23:24 +00:00
Codex_B
683cba4280 Complete missing UX flows and production integrity commands
All checks were successful
CI / ci (pull_request) Successful in 32s
2026-02-28 13:20:25 +00:00
Codex_B
2cb1e622e2 Run PR CI via docker build/run without compose networks
All checks were successful
CI / ci (pull_request) Successful in 43s
2026-02-28 13:05:28 +00:00
Codex_B
11b89e9e1c Stabilize PR CI and harden compose startup
Some checks failed
CI / ci (pull_request) Failing after 6s
2026-02-28 13:01:27 +00:00
Codex_B
06be5d6752 CI: isolate compose projects and remove runner container conflicts
Some checks failed
CI / lint (pull_request) Failing after 6s
CI / typecheck (pull_request) Failing after 6s
CI / tests (pull_request) Failing after 8s
2026-02-28 12:56:44 +00:00
Codex_B
ebdf20e708 CI: remove buildx action dependency for runner compatibility
Some checks failed
CI / typecheck (pull_request) Failing after 31s
CI / lint (pull_request) Successful in 2m8s
CI / tests (pull_request) Failing after 2m8s
2026-02-28 12:51:24 +00:00
Codex_B
47e8afea18 CI: use Docker Compose checks for runner compatibility
Some checks failed
CI / typecheck (pull_request) Failing after 2m11s
CI / lint (pull_request) Failing after 2m51s
CI / tests (pull_request) Failing after 2m56s
2026-02-28 12:47:54 +00:00
Codex_B
630c86221f CI: mirror workflow under .gitea/workflows for Gitea Actions
Some checks failed
CI / lint (pull_request) Failing after 6s
CI / tests (pull_request) Failing after 6s
CI / typecheck (pull_request) Failing after 7s
2026-02-28 12:46:43 +00:00
Codex_B
2d2edd8605 Make Docker workflows independent of local .env file
Some checks failed
CI / lint (pull_request) Failing after 6s
CI / tests (pull_request) Failing after 6s
CI / typecheck (pull_request) Failing after 9s
2026-02-28 12:44:41 +00:00
Codex_B
0b5fca3be6 CI: switch to uv with caching and cancel in-progress PR runs
Some checks failed
CI / lint (pull_request) Failing after 12s
CI / tests (pull_request) Failing after 10s
CI / typecheck (pull_request) Failing after 39s
2026-02-28 12:43:40 +00:00
Codex_B
eb2cdfc5f2 Add granular consent preference flow and regression tests
Some checks failed
CI / typecheck (pull_request) Failing after 2m13s
CI / lint (pull_request) Failing after 2m20s
CI / tests (pull_request) Failing after 2m41s
2026-02-28 12:41:26 +00:00
Codex_B
82e6bc2ee0 Add security regression tests for headers, robots and CSRF forms
Some checks failed
CI / typecheck (pull_request) Failing after 2m21s
CI / tests (pull_request) Failing after 3m14s
CI / lint (pull_request) Failing after 3m16s
2026-02-28 12:40:20 +00:00
Codex_B
e279e15c9c Add canonical and social SEO meta tags for core page templates
Some checks failed
CI / typecheck (pull_request) Failing after 2m20s
CI / lint (pull_request) Failing after 3m3s
CI / tests (pull_request) Failing after 3m7s
2026-02-28 12:39:12 +00:00
Codex_B
6fc28f9d9a Implement newsletter double opt-in email flow and CSP nonce headers
Some checks failed
CI / lint (pull_request) Failing after 2m13s
CI / tests (pull_request) Failing after 2m18s
CI / typecheck (pull_request) Failing after 2m39s
2026-02-28 12:37:32 +00:00
Codex_B
ca211c14e9 CI: run lint/typecheck/tests on pull requests only
Some checks failed
CI / typecheck (pull_request) Failing after 2m11s
CI / lint (pull_request) Failing after 2m47s
CI / tests (pull_request) Failing after 2m57s
2026-02-28 12:32:38 +00:00
131 changed files with 7380 additions and 218 deletions

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
.git
.gitea
.github
.venv
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
.benchmarks/
media/
staticfiles/

226
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,226 @@
name: CI
on:
pull_request:
push:
branches:
- main
schedule:
- cron: "0 2 * * *"
concurrency:
group: ci-pr-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
ci:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
env:
CI_IMAGE: nohype-ci:${{ github.run_id }}
steps:
- uses: actions/checkout@v4
- name: Build
run: docker build -t "$CI_IMAGE" .
- name: Start PostgreSQL
run: |
docker run -d --name ci-postgres \
-e POSTGRES_DB=nohype \
-e POSTGRES_USER=nohype \
-e POSTGRES_PASSWORD=nohype \
postgres:16-alpine
for i in $(seq 1 30); do
if docker exec ci-postgres pg_isready -U nohype -d nohype >/dev/null; then
exit 0
fi
sleep 2
done
docker logs ci-postgres || true
exit 1
- name: Ruff
run: docker run --rm --network container:ci-postgres -e SECRET_KEY=ci-secret-key -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype "$CI_IMAGE" ruff check .
- name: Mypy
run: docker run --rm --network container:ci-postgres -e SECRET_KEY=ci-secret-key -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype "$CI_IMAGE" mypy apps config
- name: Pytest
run: docker run --rm --network container:ci-postgres -e SECRET_KEY=ci-secret-key -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype "$CI_IMAGE" pytest --ignore=e2e
- name: Tailwind build (assert generated diff is clean)
run: |
docker run --name ci-tailwind \
--network container:ci-postgres \
-e SECRET_KEY=ci-secret-key \
-e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
"$CI_IMAGE" \
sh -lc "python manage.py tailwind install --no-input && python manage.py tailwind build"
docker cp ci-tailwind:/app/theme/static/css/styles.css /tmp/ci-styles.css
docker rm -f ci-tailwind
cmp -s theme/static/css/styles.css /tmp/ci-styles.css
- name: Remove PostgreSQL
if: always()
run: |
docker rm -f ci-postgres || true
- name: Remove CI image
if: always()
run: docker image rm -f "$CI_IMAGE" || true
pr-e2e:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
env:
CI_IMAGE: nohype-ci-e2e:${{ github.run_id }}
PLAYWRIGHT_CACHE_VOLUME: nohype-playwright-browsers
steps:
- uses: actions/checkout@v4
- name: Build
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
run: |
docker run -d --name pr-e2e-postgres \
-e POSTGRES_DB=nohype \
-e POSTGRES_USER=nohype \
-e POSTGRES_PASSWORD=nohype \
postgres:16-alpine
for i in $(seq 1 30); do
if docker exec pr-e2e-postgres pg_isready -U nohype -d nohype >/dev/null; then
exit 0
fi
sleep 2
done
docker logs pr-e2e-postgres || true
exit 1
- name: Start app with seeded content
run: |
docker run -d --name pr-e2e-app --network container:pr-e2e-postgres \
-v "$PLAYWRIGHT_CACHE_VOLUME:/ms-playwright:ro" \
-e SECRET_KEY=ci-secret-key \
-e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
-e CONSENT_POLICY_VERSION=1 \
-e EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend \
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
-e NEWSLETTER_PROVIDER=buttondown \
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
-e E2E_MODE=1 \
"$CI_IMAGE" \
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
if docker exec pr-e2e-app curl -fsS http://127.0.0.1:8000/ >/dev/null; then
exit 0
fi
sleep 2
done
docker logs pr-e2e-app || true
exit 1
- name: Run E2E tests
run: |
docker exec -e E2E_BASE_URL=http://127.0.0.1:8000 pr-e2e-app \
pytest e2e/ -o addopts='' -q --tb=short
- name: Remove containers
if: always()
run: |
docker rm -f pr-e2e-app || true
docker rm -f pr-e2e-postgres || true
- name: Remove CI image
if: always()
run: docker image rm -f "$CI_IMAGE" || true
nightly-e2e:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
env:
CI_IMAGE: nohype-ci-nightly:${{ github.run_id }}
PLAYWRIGHT_CACHE_VOLUME: nohype-playwright-browsers
steps:
- uses: actions/checkout@v4
- name: Build
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
run: |
docker run -d --name nightly-postgres \
-e POSTGRES_DB=nohype \
-e POSTGRES_USER=nohype \
-e POSTGRES_PASSWORD=nohype \
postgres:16-alpine
for i in $(seq 1 30); do
if docker exec nightly-postgres pg_isready -U nohype -d nohype >/dev/null; then
exit 0
fi
sleep 2
done
docker logs nightly-postgres || true
exit 1
- name: Start dev server with seeded content
run: |
docker run -d --name nightly-e2e --network container:nightly-postgres \
-v "$PLAYWRIGHT_CACHE_VOLUME:/ms-playwright:ro" \
-e SECRET_KEY=ci-secret-key \
-e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
-e CONSENT_POLICY_VERSION=1 \
-e EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend \
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
-e NEWSLETTER_PROVIDER=buttondown \
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
-e E2E_MODE=1 \
"$CI_IMAGE" \
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
if docker exec nightly-e2e curl -fsS http://127.0.0.1:8000/ >/dev/null; then
exit 0
fi
sleep 2
done
docker logs nightly-e2e || true
exit 1
- name: Run Playwright E2E tests
run: |
docker exec -e E2E_BASE_URL=http://127.0.0.1:8000 nightly-e2e \
pytest e2e/ apps/core/tests/test_nightly_e2e_playwright.py -o addopts='' -q --tb=short
- name: Remove nightly container
if: always()
run: |
docker rm -f nightly-e2e || true
docker rm -f nightly-postgres || true
- name: Remove CI image
if: always()
run: docker image rm -f "$CI_IMAGE" || true
deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: deploy
steps:
- name: Deploy to lintel-prod-01
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_SSH_HOST }}
username: deploy
key: ${{ secrets.PROD_SSH_KEY }}
script: bash /srv/sum/nohype/app/deploy/deploy.sh

View File

@@ -1,20 +0,0 @@
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build
run: docker compose build
- name: Pytest
run: docker compose run --rm web pytest
- name: Ruff
run: docker compose run --rm web ruff check .
- name: Mypy
run: docker compose run --rm web mypy apps config

View File

@@ -10,3 +10,6 @@
- Added newsletter subscription + confirmation flow with provider sync abstraction.
- Added templates/static assets baseline for homepage, article index/read, legal, about.
- Added pytest suite with >90% coverage enforcement and passing Docker CI checks.
- Added PR-only containerized CI path (`docker build` + `docker run`) to avoid compose-network exhaustion on shared runners.
- Added newsletter signup forms in nav/footer/article, client-side progressive submit UX, and article social share controls.
- Added content integrity management command and comment data-retention purge command with automated tests.

View File

@@ -1,16 +1,47 @@
FROM python:3.12-slim
FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update && apt-get install -y --no-install-recommends \
RUN set -eux; \
sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources; \
printf '%s\n' \
'Acquire::Retries "8";' \
'Acquire::http::No-Cache "true";' \
'Acquire::https::No-Cache "true";' \
'Acquire::http::Pipeline-Depth "0";' \
'Acquire::BrokenProxy "true";' \
> /etc/apt/apt.conf.d/99docker-hardening; \
apt-get update; \
for attempt in 1 2 3; do \
apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
libavif-dev \
curl \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcups2 \
libgbm1 \
libgtk-3-0 \
libnss3 \
libx11-xcb1 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
fonts-liberation \
&& break; \
if [ "$attempt" -eq 3 ]; then exit 1; fi; \
rm -rf /var/lib/apt/lists/*; \
sleep "$((attempt * 5))"; \
apt-get update; \
done; \
rm -rf /var/lib/apt/lists/*
WORKDIR /app

123
Makefile Normal file
View 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),)

View File

@@ -48,6 +48,8 @@ git pull origin main
pip install -r requirements/production.txt
python manage.py migrate --run-syncdb
python manage.py collectstatic --noinput
python manage.py tailwind build
python manage.py check_content_integrity
sudo systemctl reload gunicorn
```
@@ -55,3 +57,11 @@ sudo systemctl reload gunicorn
- PostgreSQL dump daily: `pg_dump | gzip > backup-$(date +%Y%m%d).sql.gz`
- `MEDIA_ROOT` rsynced offsite daily
- Restore DB: `gunzip -c backup-YYYYMMDD.sql.gz | psql "$DATABASE_URL"`
- Restore media: `rsync -avz <backup-host>:/path/to/media/ /srv/nohypeai/media/`
## Runtime Notes
- Keep Caddy serving `/static/` and `/media/` directly in production.
- Keep Gunicorn behind Caddy and run from a systemd service/socket pair.
- Use `python manage.py purge_old_comment_data --months 24` in cron for comment-data retention.

View File

@@ -3,7 +3,7 @@ from django.contrib.syndication.views import Feed
from django.shortcuts import get_object_or_404
from taggit.models import Tag
from apps.blog.models import ArticlePage
from apps.blog.models import ArticlePage, Category
class AllArticlesFeed(Feed):
@@ -11,8 +11,12 @@ class AllArticlesFeed(Feed):
link = "/articles/"
description = "Honest AI coding tool reviews for developers."
def get_object(self, request):
self.request = request
return None
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):
return item.title
@@ -21,21 +25,38 @@ class AllArticlesFeed(Feed):
return item.summary
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):
return item.author.name
def item_link(self, item: ArticlePage):
return f"{settings.WAGTAILADMIN_BASE_URL}{item.url}"
if hasattr(self, "request") and self.request is not None:
full_url = item.get_full_url(self.request)
if full_url:
return full_url
return f"{settings.WAGTAILADMIN_BASE_URL.rstrip('/')}{item.url}"
class TagArticlesFeed(AllArticlesFeed):
def get_object(self, request, tag_slug: str):
self.request = request
return get_object_or_404(Tag, slug=tag_slug)
def title(self, obj):
return f"No Hype AI — {obj.name}"
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]

View 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",
),
),
]

View 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),
),
]

View 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),
]

View File

@@ -6,13 +6,16 @@ from typing import Any
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db import models
from django.db.models import CASCADE, PROTECT, SET_NULL
from django.db.models import CASCADE, PROTECT, SET_NULL, Prefetch
from django.shortcuts import get_object_or_404
from modelcluster.contrib.taggit import ClusterTaggableManager
from modelcluster.fields import ParentalKey
from taggit.models import TaggedItemBase
from wagtail.admin.panels import FieldPanel, PageChooserPanel
from taggit.models import Tag, TaggedItemBase
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.models import Page
from wagtail.search import index
from wagtailseo.models import SeoMixin
from apps.blog.blocks import ARTICLE_BODY_BLOCKS
@@ -31,20 +34,27 @@ class HomePage(Page):
def get_context(self, request, *args, **kwargs):
ctx = super().get_context(request, *args, **kwargs)
articles = (
articles_qs = (
ArticlePage.objects.live()
.public()
.select_related("author")
.select_related("author", "category")
.prefetch_related("tags__metadata")
.order_by("-first_published_at")
.order_by("-published_date")
)
articles = list(articles_qs[:5])
ctx["featured_article"] = self.featured_article
ctx["latest_articles"] = articles[:5]
ctx["latest_articles"] = articles
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
class ArticleIndexPage(Page):
class ArticleIndexPage(RoutablePageMixin, Page):
parent_page_types = ["blog.HomePage"]
subpage_types = ["blog.ArticlePage"]
ARTICLES_PER_PAGE = 12
@@ -53,15 +63,27 @@ class ArticleIndexPage(Page):
return (
ArticlePage.objects.child_of(self)
.live()
.select_related("author")
.select_related("author", "category")
.prefetch_related("tags__metadata")
.order_by("-first_published_at")
.order_by("-published_date")
)
def get_context(self, request, *args, **kwargs):
ctx = super().get_context(request, *args, **kwargs)
def get_category_url(self, category):
return f"{self.url}category/{category.slug}/"
def get_listing_context(self, request, active_category=None):
tag_slug = request.GET.get("tag")
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 = (
Tag.objects.filter(id__in=articles.values_list("tags__id", flat=True)).distinct().order_by("name")
)
if tag_slug:
articles = articles.filter(tags__slug=tag_slug)
paginator = Paginator(articles, self.ARTICLES_PER_PAGE)
@@ -72,9 +94,25 @@ class ArticleIndexPage(Page):
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.page(paginator.num_pages)
ctx["articles"] = page_obj
ctx["paginator"] = paginator
ctx["active_tag"] = tag_slug
return {
"articles": page_obj,
"paginator": paginator,
"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
@@ -82,6 +120,36 @@ class ArticleTag(TaggedItemBase):
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"]
def __str__(self):
return self.name
class TagMetadata(models.Model):
COLOUR_CHOICES = [("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")]
@@ -90,18 +158,31 @@ class TagMetadata(models.Model):
@classmethod
def get_fallback_css(cls) -> dict[str, str]:
return {"bg": "bg-zinc-100", "text": "text-zinc-800"}
return {
"bg": "bg-zinc-800 dark:bg-zinc-100",
"text": "text-white dark:text-black",
"border": "border-zinc-600/20 dark:border-zinc-400/20",
}
def get_css_classes(self) -> dict[str, str]:
mapping = {
"cyan": {"bg": "bg-cyan-100", "text": "text-cyan-900"},
"pink": {"bg": "bg-pink-100", "text": "text-pink-900"},
"cyan": {
"bg": "bg-brand-cyan/10",
"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": self.get_fallback_css(),
}
return mapping.get(self.colour, self.get_fallback_css())
class ArticlePage(SeoMixin, Page):
category = models.ForeignKey("blog.Category", on_delete=PROTECT, related_name="+")
author = models.ForeignKey("authors.Author", on_delete=PROTECT)
hero_image = models.ForeignKey(
"wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+"
@@ -111,24 +192,78 @@ class ArticlePage(SeoMixin, Page):
tags = ClusterTaggableManager(through="blog.ArticleTag", blank=True)
read_time_mins = models.PositiveIntegerField(editable=False, default=1)
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"]
subpage_types: list[str] = []
content_panels = Page.content_panels + [
FieldPanel("author"),
FieldPanel("hero_image"),
content_panels = [
FieldPanel("title"),
FieldPanel("summary"),
FieldPanel("body"),
]
metadata_panels = [
FieldPanel("category"),
FieldPanel("author"),
FieldPanel("tags"),
FieldPanel("hero_image"),
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(
Page.promote_panels + 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:
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.published_date and self.first_published_at:
self.published_date = self.first_published_at
self.read_time_mins = self._compute_read_time()
return super().save(*args, **kwargs)
@@ -153,14 +288,14 @@ class ArticlePage(SeoMixin, Page):
.filter(tags__in=tag_ids)
.exclude(pk=self.pk)
.distinct()
.order_by("-first_published_at")[:count]
.order_by("-published_date")[:count]
)
if len(related) < count:
exclude_ids = [a.pk for a in related] + [self.pk]
fallback = list(
ArticlePage.objects.live()
.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
@@ -168,9 +303,20 @@ class ArticlePage(SeoMixin, Page):
def get_context(self, request, *args, **kwargs):
ctx = super().get_context(request, *args, **kwargs)
ctx["related_articles"] = self.get_related_articles()
ctx["approved_comments"] = self.comments.filter(is_approved=True, parent__isnull=True).select_related(
"parent"
from django.conf import settings
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")
comments = list(
self.comments.filter(is_approved=True, parent__isnull=True).prefetch_related(
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

View File

@@ -37,6 +37,7 @@ class ArticlePageFactory(wagtail_factories.PageFactory):
summary = "Summary"
body = [("rich_text", "<p>Hello world</p>")]
first_published_at = factory.LazyFunction(timezone.now)
published_date = factory.LazyFunction(timezone.now)
class LegalIndexPageFactory(wagtail_factories.PageFactory):

View File

@@ -0,0 +1,275 @@
from datetime import timedelta
import pytest
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
@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

View File

@@ -1,4 +1,8 @@
import pytest
from django.test import override_settings
from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory
@pytest.mark.django_db
@@ -6,3 +10,25 @@ def test_feed_endpoint(client):
resp = client.get("/feed/")
assert resp.status_code == 200
assert resp["Content-Type"].startswith("application/rss+xml")
@pytest.mark.django_db
@override_settings(WAGTAILADMIN_BASE_URL="http://wrong-host.example")
def test_feed_uses_request_host_for_item_links(client, home_page):
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(
title="Feed Article",
slug="feed-article",
author=author,
summary="summary",
body=[("rich_text", "<p>Body</p>")],
)
index.add_child(instance=article)
article.save_revision().publish()
resp = client.get("/feed/")
body = resp.content.decode()
assert resp.status_code == 200
assert "http://localhost/articles/feed-article/" in body

View File

@@ -1,6 +1,8 @@
import pytest
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
@@ -16,3 +18,32 @@ def test_all_feed_methods(article_page):
def test_tag_feed_not_found(client):
resp = client.get("/feed/tag/does-not-exist/")
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

View File

@@ -2,7 +2,7 @@ import pytest
from django.db import IntegrityError
from taggit.models import Tag
from apps.blog.models import ArticleIndexPage, ArticlePage, HomePage, TagMetadata
from apps.blog.models import ArticleIndexPage, ArticlePage, Category, HomePage, TagMetadata
from apps.blog.tests.factories import AuthorFactory
@@ -37,6 +37,32 @@ def test_article_compute_read_time_excludes_code(home_page):
def test_tag_metadata_css_and_uniqueness():
tag = Tag.objects.create(name="llms", slug="llms")
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):
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_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"]

View 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

View File

@@ -0,0 +1,35 @@
import pytest
from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory
@pytest.mark.django_db
def test_article_page_renders_core_seo_meta(client, home_page):
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(
title="SEO Article",
slug="seo-article",
author=author,
summary="Summary content",
body=[("rich_text", "<p>Body</p>")],
)
index.add_child(instance=article)
article.save_revision().publish()
resp = client.get("/articles/seo-article/")
html = resp.content.decode()
assert resp.status_code == 200
assert '<link rel="canonical" href="http' in html
assert 'property="og:type" content="article"' in html
assert 'name="twitter:card" content="summary_large_image"' in html
@pytest.mark.django_db
def test_homepage_renders_website_og_type(client, home_page):
resp = client.get("/")
html = resp.content.decode()
assert resp.status_code == 200
assert 'property="og:type" content="website"' in html

View File

@@ -1,7 +1,11 @@
import pytest
import re
from apps.blog.models import ArticleIndexPage, ArticlePage
import pytest
from taggit.models import Tag
from apps.blog.models import ArticleIndexPage, ArticlePage, Category
from apps.blog.tests.factories import AuthorFactory
from apps.comments.models import Comment
@pytest.mark.django_db
@@ -29,6 +33,7 @@ def test_article_index_pagination_and_tag_filter(client, home_page):
resp = client.get("/articles/?page=2")
assert resp.status_code == 200
assert resp.context["articles"].number == 2
assert "Pagination" in resp.content.decode()
@pytest.mark.django_db
@@ -59,3 +64,234 @@ def test_article_page_related_context(client, home_page):
resp = client.get("/articles/main/")
assert resp.status_code == 200
assert "related_articles" in resp.context
@pytest.mark.django_db
def test_newsletter_forms_render_in_nav_and_footer(client, home_page):
resp = client.get("/")
html = resp.content.decode()
assert resp.status_code == 200
# Nav has a search form instead of Subscribe CTA
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
def test_article_page_renders_share_links_and_newsletter_form(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 "Share on X" in html
assert "Share on LinkedIn" in html
assert 'data-copy-link' in html
assert 'name="source" value="article"' in html
@pytest.mark.django_db
def test_article_page_renders_approved_comments_and_reply_form(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()
comment = Comment.objects.create(
article=article,
author_name="A",
author_email="a@example.com",
body="Top level",
is_approved=True,
)
Comment.objects.create(
article=article,
parent=comment,
author_name="B",
author_email="b@example.com",
body="Reply",
is_approved=True,
)
resp = client.get("/articles/main/")
html = resp.content.decode()
assert resp.status_code == 200
assert "Top level" in html
assert "Reply" 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
def test_article_index_renders_tag_filter_controls(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()
tag = Tag.objects.create(name="TagOne", slug="tag-one")
article.tags.add(tag)
article.save_revision().publish()
resp = client.get("/articles/")
html = resp.content.decode()
assert resp.status_code == 200
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
View 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,
},
)

View File

@@ -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.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):
@@ -11,3 +26,106 @@ class TagMetadataViewSet(SnippetViewSet):
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())

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from apps.comments.models import Comment, CommentReaction
class Command(BaseCommand):
help = "Nullify comment personal data for comments older than the retention window."
def add_arguments(self, parser):
parser.add_argument(
"--months",
type=int,
default=24,
help="Retention window in months before personal data is purged (default: 24).",
)
def handle(self, *args, **options):
months = options["months"]
cutoff = timezone.now() - timedelta(days=30 * months)
purged = (
Comment.objects.filter(created_at__lt=cutoff)
.exclude(author_email="")
.update(author_email="", ip_address=None)
)
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)."))

View 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')],
},
),
]

View File

@@ -23,3 +23,21 @@ class Comment(models.Model):
def __str__(self) -> str:
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}"

View File

@@ -0,0 +1,97 @@
import pytest
from django.test import override_settings
from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory
from apps.comments.models import Comment
from apps.comments.wagtail_hooks import ApproveCommentBulkAction, CommentViewSet
@pytest.mark.django_db
def test_comment_viewset_annotates_pending_in_article(rf, 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>body</p>")])
index.add_child(instance=article)
article.save_revision().publish()
pending = Comment.objects.create(
article=article,
author_name="Pending",
author_email="pending@example.com",
body="Awaiting moderation",
is_approved=False,
)
Comment.objects.create(
article=article,
author_name="Pending2",
author_email="pending2@example.com",
body="Awaiting moderation too",
is_approved=False,
)
Comment.objects.create(
article=article,
author_name="Approved",
author_email="approved@example.com",
body="Already approved",
is_approved=True,
)
viewset = CommentViewSet()
qs = viewset.get_queryset(rf.get("/cms/snippets/comments/comment/"))
annotated = qs.get(pk=pending.pk)
assert annotated.pending_in_article == 2
@pytest.mark.django_db
def test_bulk_approve_action_marks_selected_pending_comments_as_approved(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>body</p>")])
index.add_child(instance=article)
article.save_revision().publish()
pending = Comment.objects.create(
article=article,
author_name="Pending",
author_email="pending@example.com",
body="Awaiting moderation",
is_approved=False,
)
approved = Comment.objects.create(
article=article,
author_name="Approved",
author_email="approved@example.com",
body="Already approved",
is_approved=True,
)
class _Context:
model = Comment
updated, child_updates = ApproveCommentBulkAction.execute_action([pending, approved], self=_Context())
pending.refresh_from_db()
approved.refresh_from_db()
assert updated == 1
assert child_updates == 0
assert pending.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

View File

@@ -0,0 +1,40 @@
from datetime import timedelta
import pytest
from django.core.management import call_command
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
@pytest.mark.django_db
def test_purge_old_comment_data_clears_personal_fields(home_page):
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(
title="Article",
slug="article",
author=author,
summary="summary",
body=[("rich_text", "<p>body</p>")],
)
index.add_child(instance=article)
article.save_revision().publish()
old_comment = Comment.objects.create(
article=article,
author_name="Old",
author_email="old@example.com",
body="legacy",
ip_address="127.0.0.1",
)
Comment.objects.filter(pk=old_comment.pk).update(created_at=timezone.now() - timedelta(days=800))
call_command("purge_old_comment_data")
old_comment.refresh_from_db()
assert old_comment.author_email == ""
assert old_comment.ip_address is None

View File

@@ -1,5 +1,6 @@
import pytest
from django.core.cache import cache
from django.test import override_settings
from apps.comments.forms import CommentForm
@@ -11,6 +12,7 @@ def test_comment_form_rejects_blank_body():
@pytest.mark.django_db
@override_settings(COMMENT_RATE_LIMIT_PER_MINUTE=3)
def test_comment_rate_limit(client, article_page):
cache.clear()
payload = {

View 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=1")
@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 == ""

View File

@@ -1,5 +1,6 @@
import pytest
from django.core.cache import cache
from django.test import override_settings
from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory
@@ -27,6 +28,7 @@ def test_comment_post_flow(client, home_page):
},
)
assert resp.status_code == 302
assert resp["Location"].endswith("?commented=1")
assert Comment.objects.count() == 1
@@ -59,3 +61,100 @@ def test_comment_post_rejected_when_comments_disabled(client, home_page):
)
assert resp.status_code == 404
assert Comment.objects.count() == 0
@pytest.mark.django_db
def test_invalid_comment_post_rerenders_form_with_errors(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": " ",
"honeypot": "",
},
)
assert resp.status_code == 200
assert b'aria-label="Comment form errors"' in resp.content
assert b'value="Test"' in resp.content
assert b"test@example.com" in resp.content
assert Comment.objects.count() == 0
@pytest.mark.django_db
def test_comment_reply_depth_is_enforced(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()
parent = Comment.objects.create(
article=article,
author_name="Parent",
author_email="p@example.com",
body="Parent",
is_approved=True,
)
child = Comment.objects.create(
article=article,
parent=parent,
author_name="Child",
author_email="c@example.com",
body="Child",
is_approved=True,
)
resp = client.post(
"/comments/post/",
{
"article_id": article.id,
"parent_id": child.id,
"author_name": "TooDeep",
"author_email": "deep@example.com",
"body": "Nope",
"honeypot": "",
},
)
assert resp.status_code == 200
assert b"Reply depth exceeds the allowed limit" in resp.content
assert Comment.objects.count() == 2
@pytest.mark.django_db
@override_settings(TRUSTED_PROXY_IPS=[])
def test_comment_uses_remote_addr_when_proxy_untrusted(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()
client.post(
"/comments/post/",
{
"article_id": article.id,
"author_name": "Test",
"author_email": "test@example.com",
"body": "Hello",
"honeypot": "",
},
REMOTE_ADDR="10.0.0.1",
HTTP_X_FORWARDED_FOR="203.0.113.7",
)
comment = Comment.objects.get()
assert comment.ip_address == "10.0.0.1"

View File

@@ -1,7 +1,9 @@
from django.urls import path
from apps.comments.views import CommentCreateView
from apps.comments.views import CommentCreateView, comment_poll, comment_react
urlpatterns = [
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"),
]

View File

@@ -1,22 +1,192 @@
from __future__ import annotations
import logging
import requests as http_requests
from django.conf import settings
from django.contrib import messages
from django.core.cache import cache
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.core.exceptions import ValidationError
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.template.loader import render_to_string
from django.utils.cache import patch_vary_headers
from django.views import View
from django.views.decorators.http import require_GET, require_POST
from apps.blog.models import ArticlePage
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:
remote_addr = request.META.get("REMOTE_ADDR", "").strip()
trusted_proxies = getattr(settings, "TRUSTED_PROXY_IPS", [])
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
if remote_addr in trusted_proxies and x_forwarded_for:
return x_forwarded_for.split(",")[0].strip()
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 _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):
def _render_htmx_error(self, request, article, form):
"""Return error form partial for HTMX — swaps the form container itself."""
raw_parent_id = request.POST.get("parent_id")
if raw_parent_id:
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):
ip = (request.META.get("HTTP_X_FORWARDED_FOR") or request.META.get("REMOTE_ADDR", "")).split(",")[0].strip()
ip = client_ip_from_request(request)
key = f"comment-rate:{ip}"
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)
cache.set(key, count + 1, timeout=60)
@@ -27,16 +197,123 @@ class CommentCreateView(View):
if form.is_valid():
if form.cleaned_data.get("honeypot"):
if _is_htmx(request):
return _add_vary_header(
render(request, "comments/_comment_success.html", {"message": "Comment posted!"})
)
return redirect(f"{article.url}?commented=1")
# 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.article = article
comment.is_approved = turnstile_ok
parent_id = form.cleaned_data.get("parent_id")
if parent_id:
comment.parent = Comment.objects.filter(pk=parent_id, article=article).first()
comment.ip_address = ip or None
try:
comment.full_clean()
except ValidationError:
form.add_error(None, "Reply depth exceeds the allowed limit")
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()
messages.success(request, "Your comment is awaiting moderation")
if _is_htmx(request):
return self._render_htmx_success(request, article, comment)
messages.success(
request,
"Comment posted!" if comment.is_approved else "Your comment is awaiting moderation",
)
return redirect(f"{article.url}?commented=1")
messages.error(request, "Please correct the form errors")
return redirect(article.url)
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)})

View File

@@ -1,20 +1,101 @@
from wagtail.admin.ui.tables import BooleanColumn
from typing import Any, cast
from django.db.models import Count, Q
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext
from wagtail import hooks
from wagtail.admin.ui.tables import BooleanColumn, Column
from wagtail.snippets.bulk_actions.snippet_bulk_action import SnippetBulkAction
from wagtail.snippets.models import register_snippet
from wagtail.snippets.permissions import get_permission_name
from wagtail.snippets.views.snippets import SnippetViewSet
from apps.comments.models import Comment
class ApproveCommentBulkAction(SnippetBulkAction):
display_name = _("Approve")
action_type = "approve"
aria_label = _("Approve selected comments")
template_name = "comments/confirm_bulk_approve.html"
action_priority = 20
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=False).update(
is_approved=True
)
return updated, 0
def get_success_message(self, num_parent_objects, num_child_objects):
return ngettext(
"%(count)d comment approved.",
"%(count)d comments approved.",
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):
model = Comment
queryset = Comment.objects.all()
icon = "comment"
list_display = ["author_name", "article", BooleanColumn("is_approved"), "created_at"]
list_display = [
"author_name",
"article",
BooleanColumn("is_approved"),
Column("pending_in_article", label="Pending (article)"),
"created_at",
]
list_filter = ["is_approved"]
search_fields = ["author_name", "body"]
add_to_admin_menu = True
def get_queryset(self, request):
return super().get_queryset(request).select_related("article", "parent")
base_qs = self.model.objects.all().select_related("article", "parent")
# mypy-django-plugin currently crashes on QuerySet.annotate() in this file.
typed_qs = cast(Any, base_qs)
return typed_qs.annotate(
pending_in_article=Count(
"article__comments",
filter=Q(article__comments__is_approved=False),
distinct=True,
)
)
register_snippet(CommentViewSet)
hooks.register("register_bulk_action", ApproveCommentBulkAction)
hooks.register("register_bulk_action", UnapproveCommentBulkAction)

View File

@@ -1,3 +1,4 @@
from django.conf import settings as django_settings
from wagtail.models import Site
from apps.core.models import SiteSettings
@@ -6,4 +7,7 @@ from apps.core.models import SiteSettings
def site_settings(request):
site = Site.find_for_request(request)
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", ""),
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from django.core.management.base import BaseCommand, CommandError
from django.db.models.functions import Trim
from wagtail.models import Site
from apps.blog.models import ArticlePage
from apps.core.models import SiteSettings
class Command(BaseCommand):
help = "Validate content-integrity constraints for live article pages."
def handle(self, *args, **options):
errors: list[str] = []
missing_summary = ArticlePage.objects.live().annotate(summary_trimmed=Trim("summary")).filter(
summary_trimmed=""
)
if missing_summary.exists():
errors.append(f"{missing_summary.count()} live article(s) have an empty summary.")
missing_author = ArticlePage.objects.live().filter(author__isnull=True)
if missing_author.exists():
errors.append(f"{missing_author.count()} live article(s) have no author.")
default_site = Site.objects.filter(is_default_site=True).first()
default_og_image = None
if default_site:
default_og_image = SiteSettings.for_site(default_site).default_og_image
if default_og_image is None:
missing_hero = ArticlePage.objects.live().filter(hero_image__isnull=True)
if missing_hero.exists():
errors.append(
f"{missing_hero.count()} live article(s) have no hero image and no site default OG image is set."
)
if errors:
raise CommandError("Content integrity check failed: " + " ".join(errors))
self.stdout.write(self.style.SUCCESS("Content integrity check passed."))

View File

@@ -0,0 +1,213 @@
from __future__ import annotations
import os
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from taggit.models import Tag
from wagtail.models import Page, Site
from apps.authors.models import Author
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
User = get_user_model()
class Command(BaseCommand):
help = "Seed deterministic content for E2E checks."
def handle(self, *args, **options):
import datetime
from django.utils import timezone
root = Page.get_first_root_node()
home = HomePage.objects.child_of(root).first()
if home is None:
home = HomePage(title="No Hype AI", slug="nohype-home")
root.add_child(instance=home)
home.save_revision().publish()
article_index = ArticleIndexPage.objects.child_of(home).filter(slug="articles").first()
if article_index is None:
article_index = ArticleIndexPage(title="Articles", slug="articles")
home.add_child(instance=article_index)
article_index.save_revision().publish()
author, _ = Author.objects.get_or_create(
slug="e2e-author",
defaults={
"name": "E2E Author",
"bio": "Seeded nightly test author.",
},
)
# 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()
if article is None:
article = ArticlePage(
title="Nightly Playwright Journey",
slug="nightly-playwright-journey",
author=author,
summary="Seeded article for nightly browser journey.",
body=[("rich_text", "<p>Seeded article body for nightly browser checks.</p>")],
comments_enabled=True,
published_date=now,
)
article_index.add_child(instance=article)
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,
)
tag, _ = Tag.objects.get_or_create(name="AI Tools", slug="ai-tools")
TagMetadata.objects.get_or_create(tag=tag, defaults={"colour": "cyan"})
tagged_article = ArticlePage.objects.child_of(article_index).filter(slug="e2e-tagged-article").first()
if tagged_article is None:
tagged_article = ArticlePage(
title="Tagged Article",
slug="e2e-tagged-article",
author=author,
summary="An article with tags for E2E filter tests.",
body=[("rich_text", "<p>This article is tagged with AI Tools.</p>")],
comments_enabled=True,
published_date=now - datetime.timedelta(hours=1),
)
article_index.add_child(instance=tagged_article)
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.save()
# Third article — comments disabled
no_comments_article = ArticlePage.objects.child_of(article_index).filter(slug="e2e-no-comments").first()
if no_comments_article is None:
no_comments_article = ArticlePage(
title="No Comments Article",
slug="e2e-no-comments",
author=author,
summary="An article with comments disabled.",
body=[("rich_text", "<p>Comments are disabled on this one.</p>")],
comments_enabled=False,
published_date=now - datetime.timedelta(hours=2),
)
article_index.add_child(instance=no_comments_article)
# Explicitly persist False after add_child (which internally calls save())
# to guard against any field reset in the page tree insertion path.
ArticlePage.objects.filter(pk=no_comments_article.pk).update(comments_enabled=False)
no_comments_article.comments_enabled = False
no_comments_article.save_revision().publish()
ArticlePage.objects.filter(pk=no_comments_article.pk).update(
published_date=now - datetime.timedelta(hours=2)
)
# About page
if not AboutPage.objects.child_of(home).filter(slug="about").exists():
about = AboutPage(
title="About",
slug="about",
mission_statement="Honest AI coding tool reviews for developers.",
body="<p>We benchmark, so you don't have to.</p>",
)
home.add_child(instance=about)
about.save_revision().publish()
# Legal pages
legal_index = LegalIndexPage.objects.child_of(home).filter(slug="legal").first()
if legal_index is None:
legal_index = LegalIndexPage(title="Legal", slug="legal")
home.add_child(instance=legal_index)
legal_index.save_revision().publish()
if not LegalPage.objects.child_of(legal_index).filter(slug="privacy-policy").exists():
privacy = LegalPage(
title="Privacy Policy",
slug="privacy-policy",
body="<p>We take your privacy seriously.</p>",
last_updated=datetime.date.today(),
show_in_footer=True,
)
legal_index.add_child(instance=privacy)
privacy.save_revision().publish()
# Point every existing Site at the real home page and mark exactly one
# as the default. Wagtail's initial migration creates a localhost:80
# site that matches incoming requests by hostname before the
# is_default_site fallback is ever reached, so we must update *all*
# sites, not just the is_default_site one.
Site.objects.all().update(root_page=home, site_name="No Hype AI", is_default_site=False)
site = Site.objects.first()
if site is None:
site = Site(hostname="localhost", port=80)
site.is_default_site = True
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."))

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
import secrets
from .consent import ConsentService
@@ -10,3 +12,31 @@ class ConsentMiddleware:
def __call__(self, request):
request.consent = ConsentService.get_consent(request)
return self.get_response(request)
class SecurityHeadersMiddleware:
def __init__(self, get_response):
self.get_response = get_response
ADMIN_PREFIXES = ("/cms/", "/django-admin/")
def __call__(self, request):
nonce = secrets.token_urlsafe(16)
request.csp_nonce = nonce
response = self.get_response(request)
if request.path.startswith(self.ADMIN_PREFIXES):
return response
response["Content-Security-Policy"] = (
f"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' https://challenges.cloudflare.com; "
"style-src 'self' https://fonts.googleapis.com; "
"img-src 'self' data: blob:; "
"font-src 'self' https://fonts.gstatic.com; "
"connect-src 'self' https://challenges.cloudflare.com; "
"frame-src https://challenges.cloudflare.com; "
"object-src 'none'; "
"base-uri 'self'; "
"frame-ancestors 'self'"
)
response["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
return response

View File

@@ -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,
},
),
]

View 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),
]

View File

@@ -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),
),
]

View File

@@ -1,10 +1,24 @@
from django.db import models
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.models import Orderable
SOCIAL_ICON_CHOICES = [
("twitter", "Twitter / X"),
("github", "GitHub"),
("rss", "RSS Feed"),
("linkedin", "LinkedIn"),
("youtube", "YouTube"),
("mastodon", "Mastodon"),
("bluesky", "Bluesky"),
]
@register_setting
class SiteSettings(BaseSiteSetting):
class SiteSettings(ClusterableModel, BaseSiteSetting):
default_og_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
@@ -19,3 +33,141 @@ class SiteSettings(BaseSiteSetting):
on_delete=SET_NULL,
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

View File

@@ -4,7 +4,8 @@ from django import template
from django.utils.safestring import mark_safe
from wagtail.models import Site
from apps.blog.models import TagMetadata
from apps.blog.models import ArticleIndexPage, Category, TagMetadata
from apps.core.models import SiteSettings
from apps.legal.models import LegalPage
register = template.Library()
@@ -20,6 +21,55 @@ def get_legal_pages(context):
return pages
@register.simple_tag(takes_context=True)
def get_nav_items(context, location="header"):
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
]
@register.simple_tag
@register.filter
def get_tag_css(tag):
@@ -28,3 +78,12 @@ def get_tag_css(tag):
meta = TagMetadata.objects.filter(tag=tag).first()
classes = meta.get_css_classes() if meta else TagMetadata.get_fallback_css()
return mark_safe(f"{classes['bg']} {classes['text']}")
@register.filter
def get_tag_border_css(tag):
meta = getattr(tag, "metadata", None)
if meta is None:
meta = TagMetadata.objects.filter(tag=tag).first()
classes = meta.get_css_classes() if meta else TagMetadata.get_fallback_css()
return mark_safe(classes.get("border", ""))

View File

@@ -11,16 +11,33 @@ from apps.core.models import SiteSettings
register = template.Library()
def _article_image_url(request, article) -> str:
site_settings = SiteSettings.for_request(request)
image = article.hero_image or site_settings.default_og_image
if isinstance(image, Image):
rendition = image.get_rendition("fill-1200x630")
return request.build_absolute_uri(rendition.url)
return ""
@register.simple_tag(takes_context=True)
def canonical_url(context, page=None) -> str:
request = context["request"]
target = page or context.get("page")
if target and hasattr(target, "get_full_url"):
return target.get_full_url(request)
return request.build_absolute_uri()
@register.simple_tag(takes_context=True)
def article_og_image_url(context, article) -> str:
return _article_image_url(context["request"], article)
@register.simple_tag(takes_context=True)
def article_json_ld(context, article):
request = context["request"]
site_settings = SiteSettings.for_request(request)
image = article.hero_image or site_settings.default_og_image
image_url = ""
if isinstance(image, Image):
rendition = image.get_rendition("fill-1200x630")
image_url = request.build_absolute_uri(rendition.url)
nonce = getattr(request, "csp_nonce", "")
data = {
"@context": "https://schema.org",
"@type": "Article",
@@ -30,8 +47,12 @@ def article_json_ld(context, article):
"dateModified": article.last_published_at.isoformat() if article.last_published_at else "",
"description": article.search_description or article.summary,
"url": article.get_full_url(request),
"image": image_url,
"image": _article_image_url(request, article),
}
return mark_safe(
'<script type="application/ld+json">' + json.dumps(data, ensure_ascii=True) + "</script>"
'<script type="application/ld+json" nonce="'
+ nonce
+ '">'
+ json.dumps(data, ensure_ascii=True)
+ "</script>"
)

View File

@@ -0,0 +1,56 @@
import pytest
from django.core.management import call_command
from django.core.management.base import CommandError
from apps.blog.models import AboutPage, ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory
@pytest.mark.django_db
def test_check_content_integrity_passes_when_requirements_met(home_page):
call_command("check_content_integrity")
@pytest.mark.django_db
def test_check_content_integrity_fails_for_blank_summary(home_page):
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(
title="Article",
slug="article",
author=author,
summary=" ",
body=[("rich_text", "<p>body</p>")],
)
index.add_child(instance=article)
article.save_revision().publish()
with pytest.raises(CommandError, match="empty summary"):
call_command("check_content_integrity")
@pytest.mark.django_db
def test_seed_e2e_content_creates_expected_pages():
call_command("seed_e2e_content")
assert ArticlePage.objects.filter(slug="nightly-playwright-journey").exists()
assert ArticlePage.objects.filter(slug="e2e-tagged-article").exists()
assert ArticlePage.objects.filter(slug="e2e-no-comments").exists()
assert AboutPage.objects.filter(slug="about").exists()
# Tagged article must carry the seeded tag
tagged = ArticlePage.objects.get(slug="e2e-tagged-article")
assert tagged.tags.filter(slug="ai-tools").exists()
# No-comments article must have comments disabled
no_comments = ArticlePage.objects.get(slug="e2e-no-comments")
assert no_comments.comments_enabled is False
@pytest.mark.django_db
def test_seed_e2e_content_is_idempotent():
"""Running the command twice must not raise or create duplicates."""
call_command("seed_e2e_content")
call_command("seed_e2e_content")
assert ArticlePage.objects.filter(slug="nightly-playwright-journey").count() == 1

View File

@@ -21,3 +21,47 @@ def test_consent_post_view(client):
resp = client.post("/consent/", {"accept_all": "1"}, follow=False)
assert resp.status_code == 302
assert CONSENT_COOKIE_NAME in resp.cookies
@pytest.mark.django_db
def test_consent_get_without_cookie_defaults_false():
request = HttpRequest()
state = ConsentService.get_consent(request)
assert state.analytics is False
assert state.advertising is False
assert state.requires_prompt is True
@pytest.mark.django_db
def test_consent_malformed_cookie_returns_safe_default():
request = HttpRequest()
request.COOKIES[CONSENT_COOKIE_NAME] = "not=a=valid%%%cookie"
state = ConsentService.get_consent(request)
assert state.analytics is False
assert state.advertising is False
@pytest.mark.django_db
def test_consent_post_preferences(client):
resp = client.post("/consent/", {"analytics": "1", "advertising": ""})
assert resp.status_code == 302
value = resp.cookies[CONSENT_COOKIE_NAME].value
assert "a=1" in value
assert "d=0" in value
@pytest.mark.django_db
def test_consent_get_method_not_allowed(client):
resp = client.get("/consent/")
assert resp.status_code == 405
@pytest.mark.django_db
def test_cookie_banner_hides_after_consent(client, home_page):
first = client.get("/")
assert "id=\"cookie-banner\"" in first.content.decode()
consented = client.post("/consent/", {"accept_all": "1"})
cookie_value = consented.cookies[CONSENT_COOKIE_NAME].value
client.cookies[CONSENT_COOKIE_NAME] = cookie_value
second = client.get("/")
assert "id=\"cookie-banner\"" not in second.content.decode()

View File

@@ -27,6 +27,13 @@ def test_get_tag_css_fallback():
assert "bg-zinc" in value
@pytest.mark.django_db
def test_get_tag_border_css_fallback():
tag = Tag.objects.create(name="y", slug="y")
value = core_tags.get_tag_border_css(tag)
assert "border-zinc" in value
@pytest.mark.django_db
def test_get_legal_pages_tag_callable(home_page):
legal_index = LegalIndexPage(title="Legal", slug="legal")

View 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

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
import os
import pytest
from playwright.sync_api import expect, sync_playwright
@pytest.mark.e2e
def test_nightly_playwright_journey() -> None:
base_url = os.getenv("E2E_BASE_URL")
if not base_url:
pytest.skip("E2E_BASE_URL is not set")
base_url = base_url.rstrip("/")
with sync_playwright() as pw:
browser = pw.chromium.launch()
page = browser.new_page()
page.goto(f"{base_url}/", wait_until="networkidle")
expect(page.locator("#cookie-banner")).to_be_visible()
page.get_by_role("button", name="Toggle theme").click()
page.get_by_role("button", name="Accept all").first.click()
expect(page.locator("#cookie-banner")).to_have_count(0)
page.goto(f"{base_url}/articles/", wait_until="networkidle")
first_article_link = page.locator("main article a").first
expect(first_article_link).to_be_visible()
article_href = first_article_link.get_attribute("href")
assert article_href
article_url = article_href if article_href.startswith("http") else f"{base_url}{article_href}"
page.goto(article_url, wait_until="networkidle")
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()
page.goto(f"{base_url}/feed/", wait_until="networkidle")
feed_content = page.content()
assert (
"<rss" in feed_content
or "<feed" in feed_content
or "&lt;rss" in feed_content
or "&lt;feed" in feed_content
)
browser.close()

View File

@@ -0,0 +1,75 @@
import pytest
from taggit.models import Tag
from wagtail.models import Site
from apps.blog.models import ArticleIndexPage, ArticlePage, HomePage, TagMetadata
from apps.blog.tests.factories import AuthorFactory
def _build_article_tree(home_page: HomePage, count: int = 12):
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
tag = Tag.objects.create(name="Bench", slug="bench")
TagMetadata.objects.create(tag=tag, colour="cyan")
for n in range(count):
article = ArticlePage(
title=f"Article {n}",
slug=f"article-{n}",
author=author,
summary="summary",
body=[("rich_text", "<p>body words</p>")],
)
index.add_child(instance=article)
article.tags.add(tag)
article.save_revision().publish()
return index
@pytest.mark.django_db
def test_homepage_query_budget(rf, home_page, django_assert_num_queries):
_build_article_tree(home_page, count=8)
request = rf.get("/")
request.site = Site.objects.get(is_default_site=True)
with django_assert_num_queries(10, exact=False):
context = home_page.get_context(request)
list(context["latest_articles"])
list(context["more_articles"])
assert len(context["latest_articles"]) <= 5
@pytest.mark.django_db
def test_article_index_query_budget(rf, home_page, django_assert_num_queries):
index = _build_article_tree(home_page, count=12)
request = rf.get("/articles/")
request.site = Site.objects.get(is_default_site=True)
with django_assert_num_queries(12, exact=False):
context = index.get_context(request)
list(context["articles"])
list(context["available_tags"])
assert context["paginator"].count == 12
@pytest.mark.django_db
def test_article_read_query_budget(rf, home_page, django_assert_num_queries):
index = _build_article_tree(home_page, count=4)
article = ArticlePage.objects.child_of(index).live().first()
assert article is not None
request = rf.get(article.url)
request.site = Site.objects.get(is_default_site=True)
with django_assert_num_queries(8, exact=False):
context = article.get_context(request)
list(context["related_articles"])
list(context["approved_comments"])
assert context["related_articles"] is not None
def test_read_time_benchmark(benchmark):
author = AuthorFactory.build()
body = [("rich_text", "<p>" + "word " * 1000 + "</p>")]
article = ArticlePage(title="Bench", slug="bench", author=author, summary="summary", body=body)
result = benchmark(article._compute_read_time)
assert result >= 1
assert benchmark.stats.stats.mean < 0.05

View File

@@ -0,0 +1,101 @@
import re
import pytest
from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory
@pytest.mark.django_db
def test_security_headers_present(client, home_page):
resp = client.get("/")
assert resp.status_code == 200
assert "Content-Security-Policy" in resp
assert "Permissions-Policy" in resp
assert "unsafe-inline" not in resp["Content-Security-Policy"]
assert "script-src" in resp["Content-Security-Policy"]
assert resp["X-Frame-Options"] == "SAMEORIGIN"
assert "strict-origin-when-cross-origin" in resp["Referrer-Policy"]
@pytest.mark.django_db
def test_csp_nonce_applied_to_inline_script(client, home_page):
resp = client.get("/")
csp = resp["Content-Security-Policy"]
match = re.search(r"nonce-([^' ;]+)", csp)
assert match
nonce = match.group(1)
html = resp.content.decode()
assert f'nonce="{nonce}"' in html
@pytest.mark.django_db
def test_robots_disallows_cms_and_contains_sitemap(client):
resp = client.get("/robots.txt")
body = resp.content.decode()
assert resp.status_code == 200
assert "Disallow: /cms/" in body
assert "Sitemap:" in body
@pytest.mark.django_db
def test_admin_obscured_path_redirects_to_cms(client):
resp = client.get("/admin/")
assert resp.status_code == 302
assert resp["Location"] == "/cms/"
@pytest.mark.django_db
def test_article_comment_form_contains_csrf_token(client, home_page):
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(
title="CSRF Article",
slug="csrf-article",
author=author,
summary="summary",
body=[("rich_text", "<p>Body</p>")],
)
index.add_child(instance=article)
article.save_revision().publish()
resp = client.get("/articles/csrf-article/")
html = resp.content.decode()
assert resp.status_code == 200
assert "csrfmiddlewaretoken" in html
@pytest.mark.django_db
def test_consent_rejects_open_redirect(client, home_page):
resp = client.post(
"/consent/",
{"reject_all": "1"},
HTTP_REFERER="https://evil.example.com/phish",
)
assert resp.status_code == 302
assert resp["Location"] == "/"
@pytest.mark.django_db
def test_article_json_ld_script_has_csp_nonce(client, home_page):
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(
title="Nonce Article",
slug="nonce-article",
author=author,
summary="summary",
body=[("rich_text", "<p>Body</p>")],
)
index.add_child(instance=article)
article.save_revision().publish()
resp = client.get("/articles/nonce-article/")
csp = resp["Content-Security-Policy"]
match = re.search(r"nonce-([^' ;]+)", csp)
assert match
nonce = match.group(1)
html = resp.content.decode()
assert f'type="application/ld+json" nonce="{nonce}"' in html

View File

@@ -1,5 +1,7 @@
import pytest
from apps.blog.models import ArticleIndexPage, ArticlePage, Category
from apps.blog.tests.factories import AuthorFactory
from apps.legal.models import LegalIndexPage, LegalPage
@@ -13,3 +15,36 @@ def test_get_legal_pages_tag(client, home_page):
resp = client.get("/")
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()

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed
from django.shortcuts import redirect, render
from django.utils.http import url_has_allowed_host_and_scheme
from apps.core.consent import ConsentService
@@ -24,6 +25,12 @@ def consent_view(request: HttpRequest) -> HttpResponse:
advertising = request.POST.get("advertising") in {"true", "1", "on"}
target = request.META.get("HTTP_REFERER", "/")
if not url_has_allowed_host_and_scheme(
url=target,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
target = "/"
response = redirect(target)
ConsentService.set_consent(response, analytics=analytics, advertising=advertising)
return response

View File

@@ -1,6 +1,9 @@
from __future__ import annotations
import logging
import os
import requests
logger = logging.getLogger(__name__)
@@ -15,9 +18,26 @@ class ProviderSyncService:
class ButtondownSyncService(ProviderSyncService):
endpoint = "https://api.buttondown.email/v1/subscribers"
def sync(self, subscription):
logger.info("Synced subscription %s", subscription.email)
api_key = os.getenv("BUTTONDOWN_API_KEY", "")
if not api_key:
raise ProviderSyncError("BUTTONDOWN_API_KEY is not configured")
response = requests.post(
self.endpoint,
headers={"Authorization": f"Token {api_key}", "Content-Type": "application/json"},
json={"email": subscription.email},
timeout=10,
)
if response.status_code >= 400:
raise ProviderSyncError(f"Buttondown sync failed: {response.status_code}")
logger.info("Synced subscription %s to Buttondown", subscription.email)
def get_provider_service() -> ProviderSyncService:
provider = os.getenv("NEWSLETTER_PROVIDER", "buttondown").lower().strip()
if provider != "buttondown":
raise ProviderSyncError(f"Unsupported newsletter provider: {provider}")
return ButtondownSyncService()

View File

@@ -12,6 +12,24 @@ def test_subscribe_ok(client):
assert NewsletterSubscription.objects.filter(email="a@example.com").exists()
@pytest.mark.django_db
def test_subscribe_sends_confirmation_email(client, mailoutbox):
resp = client.post("/newsletter/subscribe/", {"email": "new@example.com", "source": "nav"})
assert resp.status_code == 200
assert len(mailoutbox) == 1
assert "Confirm your No Hype AI newsletter subscription" in mailoutbox[0].subject
@pytest.mark.django_db
def test_duplicate_subscribe_returns_ok_without_extra_email(client, mailoutbox):
client.post("/newsletter/subscribe/", {"email": "dupe@example.com", "source": "nav"})
assert len(mailoutbox) == 1
resp = client.post("/newsletter/subscribe/", {"email": "dupe@example.com", "source": "footer"})
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
assert len(mailoutbox) == 1
@pytest.mark.django_db
def test_subscribe_invalid(client):
resp = client.post("/newsletter/subscribe/", {"email": "bad"})

View File

@@ -1,8 +1,13 @@
from __future__ import annotations
import logging
from django.core import signing
from django.core.mail import EmailMultiAlternatives
from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.template.loader import render_to_string
from django.urls import reverse
from django.views import View
from apps.newsletter.forms import SubscriptionForm
@@ -10,6 +15,27 @@ from apps.newsletter.models import NewsletterSubscription
from apps.newsletter.services import ProviderSyncError, get_provider_service
CONFIRMATION_TOKEN_MAX_AGE_SECONDS = 60 * 60 * 24 * 2
logger = logging.getLogger(__name__)
def confirmation_token(email: str) -> str:
return signing.dumps(email, salt="newsletter-confirm")
def send_confirmation_email(request, subscription: NewsletterSubscription) -> None:
token = confirmation_token(subscription.email)
confirm_url = request.build_absolute_uri(reverse("newsletter_confirm", args=[token]))
context = {"confirmation_url": confirm_url, "subscription": subscription}
subject = render_to_string("newsletter/email/confirmation_subject.txt", context).strip()
text_body = render_to_string("newsletter/email/confirmation_body.txt", context)
html_body = render_to_string("newsletter/email/confirmation_body.html", context)
message = EmailMultiAlternatives(
subject=subject,
body=text_body,
to=[subscription.email],
)
message.attach_alternative(html_body, "text/html")
message.send()
class SubscribeView(View):
@@ -20,9 +46,14 @@ class SubscribeView(View):
if form.cleaned_data.get("honeypot"):
return JsonResponse({"status": "ok"})
email = form.cleaned_data["email"]
email = form.cleaned_data["email"].lower().strip()
source = form.cleaned_data.get("source") or "unknown"
NewsletterSubscription.objects.get_or_create(email=email, defaults={"source": source})
subscription, created = NewsletterSubscription.objects.get_or_create(
email=email,
defaults={"source": source},
)
if created and not subscription.confirmed:
send_confirmation_email(request, subscription)
return JsonResponse({"status": "ok"})
@@ -42,10 +73,6 @@ class ConfirmView(View):
service = get_provider_service()
try:
service.sync(subscription)
except ProviderSyncError:
pass
except ProviderSyncError as exc:
logger.exception("Newsletter provider sync failed: %s", exc)
return redirect("/")
def confirmation_token(email: str) -> str:
return signing.dumps(email, salt="newsletter-confirm")

View File

@@ -4,13 +4,20 @@ import os
from pathlib import Path
import dj_database_url
from django.core.exceptions import ImproperlyConfigured
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parents[2]
SECRET_KEY = os.getenv("SECRET_KEY", "unsafe-dev-secret")
SECRET_KEY = os.getenv("SECRET_KEY")
if not SECRET_KEY:
raise ImproperlyConfigured("SECRET_KEY environment variable is required.")
DATABASE_URL = os.getenv("DATABASE_URL")
if not DATABASE_URL:
raise ImproperlyConfigured("DATABASE_URL environment variable is required.")
DEBUG = os.getenv("DEBUG", "0") == "1"
ALLOWED_HOSTS = [h.strip() for h in os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") if h.strip()]
@@ -22,6 +29,7 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sitemaps",
"django.contrib.postgres",
"taggit",
"modelcluster",
"wagtail.contrib.forms",
@@ -39,6 +47,8 @@ INSTALLED_APPS = [
"wagtail",
"wagtailseo",
"tailwind",
"theme",
"django_htmx",
"apps.core",
"apps.blog",
"apps.authors",
@@ -49,6 +59,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"apps.core.middleware.SecurityHeadersMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
@@ -56,6 +67,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware",
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
"apps.core.middleware.ConsentMiddleware",
]
@@ -80,9 +92,7 @@ TEMPLATES = [
WSGI_APPLICATION = "config.wsgi.application"
DATABASES = {
"default": dj_database_url.parse(os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR / 'db.sqlite3'}"))
}
DATABASES = {"default": dj_database_url.parse(DATABASE_URL)}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
@@ -130,7 +140,30 @@ CACHES = {
X_FRAME_OPTIONS = "SAMEORIGIN"
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
SECURE_CONTENT_TYPE_NOSNIFF = True
X_CONTENT_TYPE_OPTIONS = "nosniff"
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()]
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
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",
}
}

View File

@@ -4,6 +4,21 @@ DEBUG = True
INTERNAL_IPS = ["127.0.0.1"]
# Drop WhiteNoise in dev — it serves from STATIC_ROOT which is empty without
# collectstatic, so it 404s every asset. Django's runserver serves static and
# media files natively when DEBUG=True (via django.contrib.staticfiles + the
# media URL pattern in urls.py).
MIDDLEWARE = [m for m in MIDDLEWARE if m != "whitenoise.middleware.WhiteNoiseMiddleware"]
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
try:
import debug_toolbar # noqa: F401
@@ -11,3 +26,5 @@ try:
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE]
except Exception:
pass
COMMENT_RATE_LIMIT_PER_MINUTE = 100

View File

@@ -2,8 +2,16 @@ from .base import * # noqa
DEBUG = False
# Behind Caddy: trust the forwarded proto header so Django knows it's HTTPS.
# SECURE_SSL_REDIRECT is intentionally off — Caddy handles HTTPS redirects
# before the request reaches Django; enabling it here causes redirect loops.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
SECURE_SSL_REDIRECT = True
SECURE_SSL_REDIRECT = False
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
CSRF_TRUSTED_ORIGINS = [
"https://nohypeai.net",
"https://www.nohypeai.net",
]

View File

@@ -1,10 +1,13 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from django.views.generic import RedirectView
from wagtail import urls as wagtail_urls
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
urlpatterns = [
@@ -16,8 +19,13 @@ urlpatterns = [
path("consent/", consent_view, name="consent"),
path("robots.txt", robots_txt, name="robots_txt"),
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("sitemap.xml", sitemap),
path("admin/", RedirectView.as_view(url="/cms/", permanent=False)),
path("search/", search_view, name="search"),
path("", include(wagtail_urls)),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

23
deploy/caddy/nohype.caddy Normal file
View File

@@ -0,0 +1,23 @@
nohypeai.net, www.nohypeai.net {
encode gzip zstd
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy "geolocation=(), microphone=(), camera=()"
X-Forwarded-Proto https
}
handle_path /static/* {
root * /srv/sum/nohype/static
file_server
}
handle_path /media/* {
root * /srv/sum/nohype/media
file_server
}
reverse_proxy localhost:8001
}

33
deploy/deploy.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Deploy script for No Hype AI — runs on lintel-prod-01 as deploy user.
# Called by CI after a successful push to main.
set -euo pipefail
SITE_DIR=/srv/sum/nohype
APP_DIR=${SITE_DIR}/app
cd "${SITE_DIR}"
echo "==> Pulling latest code"
git -C "${APP_DIR}" pull origin main
echo "==> Updating compose file"
cp "${APP_DIR}/docker-compose.prod.yml" "${SITE_DIR}/docker-compose.prod.yml"
echo "==> Ensuring static/media directories exist"
mkdir -p "${SITE_DIR}/static" "${SITE_DIR}/media"
echo "==> Rebuilding and recreating web container"
docker compose -f "${SITE_DIR}/docker-compose.prod.yml" up -d --no-deps --build --force-recreate web
echo "==> Waiting for health check"
for i in $(seq 1 30); do
if curl -fsS -H "Host: nohypeai.net" http://localhost:8001/ >/dev/null 2>&1; then
echo "==> Site is up"
exit 0
fi
sleep 3
done
echo "ERROR: site did not come up after 90s" >&2
docker compose -f "${SITE_DIR}/docker-compose.prod.yml" logs --tail=50 web
exit 1

23
deploy/entrypoint.prod.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/sh
set -e
python manage.py tailwind install --no-input
python manage.py tailwind build
python manage.py migrate --noinput
python manage.py collectstatic --noinput
python manage.py update_index
# Set Wagtail site hostname from first entry in ALLOWED_HOSTS
python manage.py shell -c "
from wagtail.models import Site
import os
hostname = os.environ.get('ALLOWED_HOSTS', 'localhost').split(',')[0].strip()
Site.objects.update(hostname=hostname, port=443, site_name='No Hype AI')
"
exec gunicorn config.wsgi:application \
--workers 3 \
--bind 0.0.0.0:8000 \
--access-logfile - \
--error-logfile - \
--capture-output

26
deploy/sum-nohype.service Normal file
View File

@@ -0,0 +1,26 @@
[Unit]
Description=No Hype AI (Docker Compose)
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=simple
User=deploy
Group=www-data
WorkingDirectory=/srv/sum/nohype
ExecStartPre=docker compose -f docker-compose.prod.yml pull --ignore-pull-failures
ExecStart=docker compose -f docker-compose.prod.yml up --build
ExecStop=docker compose -f docker-compose.prod.yml down
Restart=always
RestartSec=10
TimeoutStartSec=300
TimeoutStopSec=30
StandardOutput=journal
StandardError=journal
SyslogIdentifier=sum-nohype
[Install]
WantedBy=multi-user.target

7
docker-compose.ci.yml Normal file
View File

@@ -0,0 +1,7 @@
services:
web:
volumes: []
ports: []
db:
ports: []

36
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,36 @@
services:
web:
build: app
working_dir: /app
command: /app/deploy/entrypoint.prod.sh
env_file: .env
environment:
DJANGO_SETTINGS_MODULE: config.settings.production
volumes:
- /srv/sum/nohype/static:/app/staticfiles
- /srv/sum/nohype/media:/app/media
ports:
- "127.0.0.1:8001:8000"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
env_file: .env
environment:
POSTGRES_DB: nohype
POSTGRES_USER: nohype
volumes:
- nohype_pg:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nohype -d nohype"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
restart: unless-stopped
volumes:
nohype_pg:

View File

@@ -1,31 +1,48 @@
services:
web:
build: .
container_name: nohype-web
command: python manage.py runserver 0.0.0.0:8000
working_dir: /app
command: >
sh -c "python manage.py tailwind install --no-input &&
python manage.py tailwind build &&
python manage.py migrate --noinput &&
python manage.py seed_e2e_content &&
python manage.py runserver 0.0.0.0:8000"
volumes:
- .:/app
ports:
- "8035:8000"
env_file:
- .env
environment:
SECRET_KEY: dev-secret-key
DEBUG: "1"
ALLOWED_HOSTS: localhost,127.0.0.1,web
WAGTAIL_SITE_NAME: No Hype AI
DATABASE_URL: postgres://nohype:nohype@db:5432/nohype
DJANGO_SETTINGS_MODULE: config.settings.development
WAGTAILADMIN_BASE_URL: http://localhost:8035
CONSENT_POLICY_VERSION: "1"
EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend
DEFAULT_FROM_EMAIL: hello@nohypeai.com
NEWSLETTER_PROVIDER: buttondown
E2E_MODE: "1"
depends_on:
- db
db:
condition: service_healthy
db:
image: postgres:16-alpine
container_name: nohype-db
environment:
POSTGRES_DB: nohype
POSTGRES_USER: nohype
POSTGRES_PASSWORD: nohype
ports:
- "5545:5432"
volumes:
- nohype_pg:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nohype -d nohype"]
interval: 5s
timeout: 5s
retries: 10
start_period: 5s
volumes:
nohype_pg:

0
e2e/__init__.py Normal file
View File

57
e2e/conftest.py Normal file
View File

@@ -0,0 +1,57 @@
"""Shared fixtures for E2E Playwright tests.
All tests in this directory require a running application server pointed to by
the E2E_BASE_URL environment variable. Tests are automatically skipped when
the variable is absent, making them safe to collect in any environment.
"""
from __future__ import annotations
import os
from collections.abc import Generator
import pytest
from playwright.sync_api import Browser, BrowserContext, Page, sync_playwright
@pytest.fixture(scope="session")
def base_url() -> str:
url = os.getenv("E2E_BASE_URL", "").rstrip("/")
if not url:
pytest.skip("E2E_BASE_URL not set start a server and export E2E_BASE_URL to run E2E tests")
return url
@pytest.fixture(scope="session")
def _browser(base_url: str) -> Generator[Browser, None, None]: # noqa: ARG001
"""Session-scoped Chromium instance (headless)."""
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
yield browser
browser.close()
@pytest.fixture()
def page(_browser: Browser) -> Generator[Page, None, None]:
"""Fresh browser context + page per test — no shared state between tests.
Clipboard permissions are pre-granted so copy-link and similar interactions
work in headless Chromium without triggering the permissions dialog.
"""
ctx: BrowserContext = _browser.new_context(
permissions=["clipboard-read", "clipboard-write"],
)
# Polyfill clipboard in environments where the native API is unavailable
# (e.g. non-HTTPS Docker CI). The polyfill stores writes in a variable so
# the JS success path still runs and button text updates as expected.
ctx.add_init_script("""
if (!navigator.clipboard || !navigator.clipboard.writeText) {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: () => Promise.resolve() },
configurable: true,
});
}
""")
pg: Page = ctx.new_page()
yield pg
ctx.close()

View 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()

View File

@@ -0,0 +1,72 @@
"""E2E tests for article detail pages."""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
ARTICLE_SLUG = "nightly-playwright-journey"
def _go_to_article(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/articles/{ARTICLE_SLUG}/", wait_until="networkidle")
@pytest.mark.e2e
def test_article_title_visible(page: Page, base_url: str) -> None:
_go_to_article(page, base_url)
h1 = page.get_by_role("heading", level=1)
expect(h1).to_be_visible()
assert h1.inner_text().strip() != ""
@pytest.mark.e2e
def test_article_read_time_visible(page: Page, base_url: str) -> None:
_go_to_article(page, base_url)
# Read time is rendered as "N min read"
expect(page.get_by_text("min read")).to_be_visible()
@pytest.mark.e2e
def test_article_share_section_present(page: Page, base_url: str) -> None:
_go_to_article(page, base_url)
share_section = page.get_by_role("region", name="Share this article")
expect(share_section).to_be_visible()
expect(share_section.get_by_role("link", name="Share on X")).to_be_visible()
expect(share_section.get_by_role("link", name="Share on LinkedIn")).to_be_visible()
expect(share_section.get_by_role("button", name="Copy link")).to_be_visible()
@pytest.mark.e2e
def test_article_comments_section_present(page: Page, base_url: str) -> None:
_go_to_article(page, base_url)
# The article has comments_enabled=True
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()
@pytest.mark.e2e
def test_article_newsletter_aside_present(page: Page, base_url: str) -> None:
_go_to_article(page, base_url)
# There's a Newsletter aside within the article page
aside = page.locator("aside")
expect(aside).to_be_visible()
expect(aside.locator('input[type="email"]')).to_be_visible()
@pytest.mark.e2e
def test_article_related_section_present(page: Page, base_url: str) -> None:
_go_to_article(page, base_url)
# Related section heading
expect(page.get_by_role("heading", name="Related")).to_be_visible()
@pytest.mark.e2e
def test_copy_link_button_updates_text(page: Page, base_url: str) -> None:
_go_to_article(page, base_url)
copy_btn = page.locator("[data-copy-link]")
expect(copy_btn).to_be_visible()
# Force-override clipboard so writeText always resolves, even in non-HTTPS headless context
page.evaluate("navigator.clipboard.writeText = () => Promise.resolve()")
copy_btn.click()
expect(copy_btn).to_have_text("Copied")

59
e2e/test_articles.py Normal file
View File

@@ -0,0 +1,59 @@
"""E2E tests for the article index page."""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
@pytest.mark.e2e
def test_article_index_loads(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/articles/", wait_until="networkidle")
expect(page.get_by_role("heading", level=1)).to_be_visible()
# At least one article card must be present after seeding
expect(page.locator("main article").first).to_be_visible()
@pytest.mark.e2e
def test_tag_filter_shows_tagged_articles(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/articles/", wait_until="networkidle")
# The seeded "AI Tools" tag link must be present
tag_link = page.get_by_role("link", name="AI Tools")
expect(tag_link).to_be_visible()
tag_link.click()
page.wait_for_load_state("networkidle")
# URL should now contain ?tag=ai-tools
assert "tag=ai-tools" in page.url
# The tagged article must appear; no-tag articles may be absent
expect(page.get_by_text("Tagged Article")).to_be_visible()
@pytest.mark.e2e
def test_all_tag_clears_filter(page: Page, base_url: str) -> None:
# Start with the tag filter applied
page.goto(f"{base_url}/articles/?tag=ai-tools", wait_until="networkidle")
# Clicking "All" should return to unfiltered list
page.get_by_role("link", name="All").click()
page.wait_for_load_state("networkidle")
assert "tag=" not in page.url
# All seeded articles should now be visible
expect(page.locator("main article").first).to_be_visible()
@pytest.mark.e2e
def test_article_card_navigates_to_detail(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/articles/", wait_until="networkidle")
first_link = page.locator("main article a").first
expect(first_link).to_be_visible()
href = first_link.get_attribute("href")
assert href, "Article card must have an href"
first_link.click()
page.wait_for_load_state("networkidle")
# We should be on an article detail page
expect(page.get_by_role("heading", level=1)).to_be_visible()

115
e2e/test_comments.py Normal file
View File

@@ -0,0 +1,115 @@
"""E2E tests for the comment submission flow."""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
ARTICLE_SLUG = "nightly-playwright-journey"
def _go_to_article(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/articles/{ARTICLE_SLUG}/", wait_until="networkidle")
def _submit_comment(page: Page, *, name: str = "E2E Tester", email: str = "e2e@example.com", body: str) -> None:
"""Fill and submit the main (non-reply) comment form."""
form = page.locator("form[data-comment-form]")
form.locator('input[name="author_name"]').fill(name)
form.locator('input[name="author_email"]').fill(email)
form.locator('textarea[name="body"]').fill(body)
form.get_by_role("button", name="Post comment").click()
@pytest.mark.e2e
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
def test_empty_body_shows_form_errors(page: Page, base_url: str) -> None:
_go_to_article(page, base_url)
_submit_comment(page, body=" ") # whitespace-only body
page.wait_for_load_state("networkidle")
expect(page.locator('[aria-label="Comment form errors"]')).to_be_visible(timeout=10_000)
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('textarea[name="body"]').fill("Comment without a name.")
form.get_by_role("button", name="Post comment").click()
page.wait_for_load_state("networkidle")
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
def test_comments_section_absent_when_disabled(page: Page, base_url: str) -> None:
"""Article with comments_enabled=False must not show the comments section."""
response = page.goto(f"{base_url}/articles/e2e-no-comments/", wait_until="networkidle")
assert response is not None and response.status == 200, (
f"Expected 200 for e2e-no-comments article, got {response and response.status}"
)
expect(page.get_by_role("heading", level=1)).to_have_text("No Comments Article")
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)

View File

@@ -0,0 +1,70 @@
"""E2E tests for the cookie consent banner."""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
def _open_fresh_page(page: Page, url: str) -> None:
"""Navigate to URL with no existing consent cookie (fresh context guarantees this)."""
page.goto(url, wait_until="networkidle")
@pytest.mark.e2e
def test_banner_visible_on_first_visit(page: Page, base_url: str) -> None:
_open_fresh_page(page, f"{base_url}/")
expect(page.locator("#cookie-banner")).to_be_visible()
@pytest.mark.e2e
def test_accept_all_dismisses_banner(page: Page, base_url: str) -> None:
_open_fresh_page(page, f"{base_url}/")
banner = page.locator("#cookie-banner")
expect(banner).to_be_visible()
page.get_by_role("button", name="Accept all").first.click()
page.wait_for_load_state("networkidle")
expect(banner).to_have_count(0)
@pytest.mark.e2e
def test_reject_all_dismisses_banner(page: Page, base_url: str) -> None:
_open_fresh_page(page, f"{base_url}/")
banner = page.locator("#cookie-banner")
expect(banner).to_be_visible()
page.get_by_role("button", name="Reject all").first.click()
page.wait_for_load_state("networkidle")
expect(banner).to_have_count(0)
@pytest.mark.e2e
def test_granular_preferences_save_dismisses_banner(page: Page, base_url: str) -> None:
_open_fresh_page(page, f"{base_url}/")
banner = page.locator("#cookie-banner")
expect(banner).to_be_visible()
# Click the <summary> element to expand <details> inside the banner
banner.locator("details summary").click()
# Analytics checkbox is now revealed; check it and save
analytics_checkbox = banner.locator('input[name="analytics"]')
expect(analytics_checkbox).to_be_visible()
analytics_checkbox.check()
# Submit granular preferences
page.get_by_role("button", name="Save preferences").click()
page.wait_for_load_state("networkidle")
expect(banner).to_have_count(0)
@pytest.mark.e2e
def test_banner_absent_after_consent_cookie_set(page: Page, base_url: str) -> None:
"""After accepting consent, subsequent page loads must not show the banner."""
_open_fresh_page(page, f"{base_url}/")
# Accept consent
page.get_by_role("button", name="Accept all").first.click()
page.wait_for_load_state("networkidle")
# Navigate to another page in the same context — cookie should persist
page.goto(f"{base_url}/articles/", wait_until="networkidle")
expect(page.locator("#cookie-banner")).to_have_count(0)

61
e2e/test_feeds.py Normal file
View File

@@ -0,0 +1,61 @@
"""E2E tests for RSS feed, sitemap, and robots.txt."""
from __future__ import annotations
import pytest
from playwright.sync_api import Page
@pytest.mark.e2e
def test_rss_feed_returns_valid_xml(page: Page, base_url: str) -> None:
response = page.goto(f"{base_url}/feed/", wait_until="networkidle")
assert response is not None
assert response.status == 200
content = page.content()
assert "<rss" in content or "<feed" in content or "&lt;rss" in content or "&lt;feed" in content, (
"RSS feed response must contain a <rss or <feed root element"
)
@pytest.mark.e2e
def test_rss_feed_contains_seeded_article(page: Page, base_url: str) -> None:
response = page.goto(f"{base_url}/feed/", wait_until="networkidle")
assert response is not None and response.status == 200
content = page.content()
assert "Nightly Playwright Journey" in content, "Seeded article title must appear in the feed"
@pytest.mark.e2e
def test_sitemap_returns_valid_xml(page: Page, base_url: str) -> None:
response = page.goto(f"{base_url}/sitemap.xml", wait_until="networkidle")
assert response is not None
assert response.status == 200
content = page.content()
assert "urlset" in content or "&lt;urlset" in content, "Sitemap must contain urlset element"
@pytest.mark.e2e
def test_sitemap_contains_article_url(page: Page, base_url: str) -> None:
response = page.goto(f"{base_url}/sitemap.xml", wait_until="networkidle")
assert response is not None and response.status == 200
content = page.content()
assert "nightly-playwright-journey" in content, "Seeded article URL must appear in sitemap"
@pytest.mark.e2e
def test_robots_txt_is_accessible(page: Page, base_url: str) -> None:
response = page.goto(f"{base_url}/robots.txt", wait_until="networkidle")
assert response is not None
assert response.status == 200
content = page.content()
assert "User-agent" in content, "robots.txt must contain User-agent directive"
@pytest.mark.e2e
def test_tag_rss_feed(page: Page, base_url: str) -> None:
"""Tag-specific feed must return 200 and valid XML for a seeded tag."""
response = page.goto(f"{base_url}/feed/tag/ai-tools/", wait_until="networkidle")
assert response is not None
assert response.status == 200
content = page.content()
assert "<rss" in content or "<feed" in content or "&lt;rss" in content or "&lt;feed" in content

52
e2e/test_home.py Normal file
View File

@@ -0,0 +1,52 @@
"""E2E tests for the home page."""
from __future__ import annotations
import re
import pytest
from playwright.sync_api import Page, expect
@pytest.mark.e2e
def test_homepage_title_contains_brand(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/", wait_until="networkidle")
expect(page).to_have_title(re.compile("No Hype AI"))
@pytest.mark.e2e
def test_nav_links_present(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/", wait_until="networkidle")
nav = page.locator("nav")
expect(nav.get_by_role("link", name="Home")).to_be_visible()
expect(nav.get_by_role("link", name="Articles")).to_be_visible()
expect(nav.get_by_role("link", name="About")).to_be_visible()
@pytest.mark.e2e
def test_theme_toggle_adds_dark_class(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/", wait_until="networkidle")
toggle = page.get_by_role("button", name="Toggle theme")
expect(toggle).to_be_visible()
# Initial state: html may or may not have dark class
html = page.locator("html")
before = "dark" in (html.get_attribute("class") or "")
toggle.click()
after = "dark" in (html.get_attribute("class") or "")
assert before != after, "Theme toggle must flip the dark class on <html>"
@pytest.mark.e2e
def test_nav_search_box_present(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/", wait_until="networkidle")
nav = page.locator("nav")
expect(nav.locator('input[name="q"]')).to_be_visible()
@pytest.mark.e2e
def test_home_shows_articles(page: Page, base_url: str) -> None:
"""Latest articles section is populated after seeding."""
page.goto(f"{base_url}/", wait_until="networkidle")
# Seeded content means there should be at least one article card link
article_links = page.locator("main article a")
expect(article_links.first).to_be_visible()

66
e2e/test_newsletter.py Normal file
View File

@@ -0,0 +1,66 @@
"""E2E tests for the newsletter subscription form."""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
def _nav_newsletter_form(page: Page):
"""Return the newsletter form in the home page sidebar aside."""
return page.locator("aside").locator("form[data-newsletter-form]").first
@pytest.mark.e2e
def test_subscribe_valid_email_shows_confirmation(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/", wait_until="networkidle")
form = _nav_newsletter_form(page)
form.locator('input[type="email"]').fill("playwright-test@example.com")
form.get_by_role("button", name="Subscribe").click()
# JS sets the data-newsletter-message text on success
message = form.locator("[data-newsletter-message]")
expect(message).to_have_text("Check your email to confirm your subscription.", timeout=5_000)
@pytest.mark.e2e
def test_subscribe_invalid_email_shows_error(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/", wait_until="networkidle")
form = _nav_newsletter_form(page)
# Disable the browser's native HTML5 email validation so the JS handler
# fires and sends the bad value to the server (which returns 400).
page.evaluate("document.querySelector('aside form[data-newsletter-form]').setAttribute('novalidate', '')")
form.locator('input[type="email"]').fill("not-an-email")
form.get_by_role("button", name="Subscribe").click()
message = form.locator("[data-newsletter-message]")
expect(message).to_have_text("Please enter a valid email.", timeout=5_000)
@pytest.mark.e2e
def test_subscribe_from_article_aside(page: Page, base_url: str) -> None:
"""Newsletter form embedded in the article aside also works."""
page.goto(f"{base_url}/articles/nightly-playwright-journey/", wait_until="networkidle")
aside_form = page.locator("aside").locator("form[data-newsletter-form]")
aside_form.locator('input[type="email"]').fill("aside-test@example.com")
aside_form.get_by_role("button", name="Subscribe").click()
message = aside_form.locator("[data-newsletter-message]")
expect(message).to_have_text("Check your email to confirm your subscription.", timeout=5_000)
@pytest.mark.e2e
def test_subscribe_duplicate_email_still_shows_confirmation(page: Page, base_url: str) -> None:
"""Submitting the same address twice must not expose an error to the user."""
email = "dupe-e2e@example.com"
page.goto(f"{base_url}/", wait_until="networkidle")
form = _nav_newsletter_form(page)
form.locator('input[type="email"]').fill(email)
form.get_by_role("button", name="Subscribe").click()
message = form.locator("[data-newsletter-message]")
expect(message).to_have_text("Check your email to confirm your subscription.", timeout=5_000)
# Second submission — form resets after first, so fill again
form.locator('input[type="email"]').fill(email)
form.get_by_role("button", name="Subscribe").click()
expect(message).to_have_text("Check your email to confirm your subscription.", timeout=5_000)

View File

@@ -179,8 +179,8 @@ Every milestone follows the **Red → Green → Refactor** cycle. No production
### 3.3 Coverage Requirements
- **Minimum 90% line coverage** on all `apps/` code, enforced via `pytest-cov` in CI
- Coverage reports generated on every push; PRs blocked below threshold
- E2E tests run nightly, not on every push (they are slow)
- Coverage reports generated on every pull request; PRs blocked below threshold
- E2E tests run nightly, not on every pull request (they are slow)
### 3.4 Test Organisation
@@ -212,10 +212,10 @@ class ArticlePageFactory(wagtail_factories.PageFactory):
# Note: no is_featured — featured article is set on HomePage.featured_article only
```
### 3.6 CI Pipeline (GitHub Actions)
### 3.6 CI Pipeline (Gitea Actions)
```
on: [push, pull_request]
on: [pull_request]
jobs:
test:
@@ -232,6 +232,8 @@ jobs:
- Run Playwright suite
```
Rationale: all merges should flow through pull requests. Running the same checks on both `push` and `pull_request` duplicates work and wastes compute.
---
## Milestone 0 — Project Scaffold & Tooling
@@ -242,7 +244,7 @@ jobs:
- `./manage.py runserver` starts without errors
- `pytest` runs and exits 0 (no tests yet = trivially passing)
- `ruff` and `mypy` pass on an empty codebase
- GitHub Actions workflow file committed and green
- Gitea Actions workflow file committed and green
### M0 — Tasks
@@ -271,7 +273,7 @@ jobs:
- Add Prism.js and Alpine.js to `static/js/`; wire into `base.html`
#### M0.5 — CI
- Create `.github/workflows/ci.yml`
- Create `.gitea/workflows/ci.yml`
- Install `pytest-django`, `pytest-cov`, `ruff`, `mypy`, `factory_boy`, `wagtail-factories`
- Create `pytest.ini` / `pyproject.toml` config pointing at `config.settings.development`
- Write the only M0 test: a trivial smoke test that asserts `1 == 1` to confirm CI runs

View File

@@ -28,6 +28,10 @@ ignore_missing_imports = true
module = ["apps.authors.models"]
ignore_errors = true
[[tool.mypy.overrides]]
module = ["apps.comments.views"]
ignore_errors = true
[tool.django-stubs]
django_settings_module = "config.settings.development"

View File

@@ -2,3 +2,5 @@
DJANGO_SETTINGS_MODULE = config.settings.development
python_files = test_*.py
addopts = -q --cov=apps --cov-report=term-missing --cov-fail-under=90
markers =
e2e: browser-based end-to-end test suite for nightly jobs

View File

@@ -2,7 +2,7 @@ Django~=5.2.0
wagtail~=7.0.0
wagtail-seo~=3.1.1
psycopg2-binary~=2.9.0
Pillow~=11.0.0
Pillow~=12.1
django-taggit~=6.0.0
whitenoise~=6.0.0
gunicorn~=23.0.0
@@ -10,6 +10,8 @@ python-dotenv~=1.0.0
dj-database-url~=2.2.0
django-tailwind~=3.8.0
django-csp~=3.8.0
django-htmx~=1.21.0
requests~=2.32.0
pytest~=8.3.0
pytest-django~=4.9.0
pytest-cov~=5.0.0
@@ -17,6 +19,8 @@ pytest-benchmark~=4.0.0
factory-boy~=3.3.0
wagtail-factories~=4.2.0
feedparser~=6.0.0
playwright~=1.57.0
pytest-playwright~=0.7.0
ruff~=0.6.0
mypy~=1.11.0
django-stubs~=5.1.0

4
static/favicon.svg Normal file
View 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
View 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

File diff suppressed because one or more lines are too long

54
static/js/newsletter.js Normal file
View File

@@ -0,0 +1,54 @@
(() => {
const setMessage = (form, text) => {
const target = form.querySelector("[data-newsletter-message]");
if (target) {
target.textContent = text;
}
};
const bindNewsletterForms = () => {
const forms = document.querySelectorAll("form[data-newsletter-form]");
forms.forEach((form) => {
form.addEventListener("submit", async (event) => {
event.preventDefault();
const formData = new FormData(form);
try {
const response = await fetch(form.action, {
method: "POST",
body: formData,
});
if (!response.ok) {
setMessage(form, "Please enter a valid email.");
return;
}
setMessage(form, "Check your email to confirm your subscription.");
form.reset();
} catch (error) {
setMessage(form, "Subscription failed. Please try again.");
}
});
});
};
const bindCopyLink = () => {
const button = document.querySelector("[data-copy-link]");
if (!button) {
return;
}
button.addEventListener("click", async () => {
const url = button.getAttribute("data-copy-url");
if (!url) {
return;
}
try {
await navigator.clipboard.writeText(url);
button.textContent = "Copied";
} catch (error) {
button.textContent = "Copy failed";
}
});
};
bindNewsletterForms();
bindCopyLink();
})();

View File

@@ -4,4 +4,11 @@
root.classList.toggle('dark');
localStorage.setItem('theme', root.classList.contains('dark') ? 'dark' : 'light');
};
document.addEventListener('DOMContentLoaded', function onReady() {
const toggle = document.querySelector('[data-theme-toggle]');
if (toggle) {
toggle.addEventListener('click', window.toggleTheme);
}
});
})();

View File

@@ -5,17 +5,35 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}No Hype AI{% endblock %}</title>
{% 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.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 rel="stylesheet" href="{% static 'css/styles.css' %}" />
<script nonce="{{ request.csp_nonce|default:'' }}">
(function(){try{if(localStorage.getItem('theme')==='light'){document.documentElement.classList.remove('dark');}}catch(e){}})();
</script>
<script src="{% static 'js/consent.js' %}" nonce="{{ request.csp_nonce|default:'' }}"></script>
<script src="{% static 'js/theme.js' %}" defer></script>
<script src="{% static 'js/prism.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>
<body>
<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>
{% include 'components/nav.html' %}
{% include 'components/cookie_banner.html' %}
<main>{% block content %}{% endblock %}</main>
{% 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>
{% include 'components/footer.html' %}
</body>
</html>

View File

@@ -1,14 +1,40 @@
{% extends 'base.html' %}
{% load wagtailimages_tags wagtailcore_tags %}
{% block title %}{{ page.title }} | No Hype AI{% endblock %}
{% block content %}
<h1>{{ page.title }}</h1>
<p>{{ page.mission_statement }}</p>
{{ page.body|richtext }}
{% if page.featured_author %}
<h2>{{ page.featured_author.name }}</h2>
<p>{{ page.featured_author.bio }}</p>
{% if page.featured_author.avatar %}
{% image page.featured_author.avatar fill-200x200 %}
<!-- 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-4">{{ page.title }}</h1>
{% if page.mission_statement %}
<p class="text-xl md:text-2xl text-zinc-600 dark:text-zinc-400 font-medium max-w-2xl">{{ page.mission_statement }}</p>
{% endif %}
{% endif %}
</div>
<!-- Body -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12">
<div class="lg:col-span-8 prose prose-lg dark:prose-invert max-w-none
prose-headings:font-display prose-headings:font-bold
prose-a:text-brand-cyan hover:prose-a:text-brand-pink prose-a:transition-colors prose-a:no-underline hover:prose-a:underline">
{{ page.body|richtext }}
</div>
{% if page.featured_author %}
<aside class="lg:col-span-4">
<div class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6 relative overflow-hidden">
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-brand-cyan to-brand-pink"></div>
{% if page.featured_author.avatar %}
<div class="w-20 h-20 mb-4 overflow-hidden border border-zinc-200 dark:border-zinc-800">
{% image page.featured_author.avatar fill-80x80 class="w-full h-full object-cover" %}
</div>
{% else %}
<div class="w-20 h-20 mb-4 bg-gradient-to-tr from-brand-cyan to-brand-pink"></div>
{% endif %}
<h2 class="font-display font-bold text-xl mb-1">{{ page.featured_author.name }}</h2>
{% if page.featured_author.role %}<p class="font-mono text-xs text-zinc-500 mb-3">{{ page.featured_author.role }}</p>{% endif %}
{% if page.featured_author.bio %}<p class="text-sm text-zinc-600 dark:text-zinc-400">{{ page.featured_author.bio }}</p>{% endif %}
</div>
</aside>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,11 +1,72 @@
{% extends 'base.html' %}
{% load core_tags %}
{% load core_tags seo_tags %}
{% block title %}Articles | No Hype AI{% endblock %}
{% block content %}
<h1>{{ page.title }}</h1>
{% for article in articles %}
{% include 'components/article_card.html' with article=article %}
{% empty %}
<p>No articles found.</p>
{% endfor %}
{% block head_meta %}
{% canonical_url page as canonical %}
<link rel="canonical" href="{{ canonical }}" />
<meta name="description" content="Latest No Hype AI articles and benchmark-driven reviews." />
<meta property="og:type" content="website" />
<meta property="og:title" content="Articles | No Hype AI" />
<meta property="og:url" content="{{ canonical }}" />
{% endblock %}
{% block content %}
<!-- Page Header -->
<div class="py-8 md:py-12 border-b border-zinc-200 dark:border-zinc-800 mb-12">
{% 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 -->
<div class="flex flex-wrap gap-3">
<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 %}
<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 %}
</div>
</div>
<!-- Article List -->
<div class="space-y-8">
{% for article in articles %}
{% include 'components/article_card.html' with article=article %}
{% empty %}
<p class="font-mono text-zinc-500 py-12 text-center">No articles found.</p>
{% endfor %}
</div>
<!-- Pagination -->
{% if articles.has_previous or articles.has_next %}
<nav aria-label="Pagination" class="mt-12 flex justify-center items-center gap-4 font-mono text-sm">
{% if articles.has_previous %}
<a href="?page={{ articles.previous_page_number }}{% if active_tag %}&tag={{ active_tag }}{% endif %}" 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 {{ articles.number }} of {{ paginator.num_pages }}</span>
{% if articles.has_next %}
<a href="?page={{ articles.next_page_number }}{% if active_tag %}&tag={{ active_tag }}{% endif %}" 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 %}
{% endblock %}

View File

@@ -1,33 +1,157 @@
{% extends 'base.html' %}
{% load wagtailcore_tags wagtailimages_tags seo_tags %}
{% load wagtailcore_tags wagtailimages_tags seo_tags core_tags %}
{% block title %}{{ page.title }} | No Hype AI{% endblock %}
{% block head_meta %}
{% canonical_url page as canonical %}
{% article_og_image_url page as og_image %}
<link rel="canonical" href="{{ canonical }}" />
<meta name="description" content="{{ page.search_description|default:page.summary }}" />
<meta property="og:type" content="article" />
<meta property="og:title" content="{{ page.title }} | No Hype AI" />
<meta property="og:description" content="{{ page.search_description|default:page.summary }}" />
<meta property="og:url" content="{{ canonical }}" />
{% if og_image %}<meta property="og:image" content="{{ og_image }}" />{% endif %}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{{ page.title }} | No Hype AI" />
<meta name="twitter:description" content="{{ page.search_description|default:page.summary }}" />
{% if og_image %}<meta name="twitter:image" content="{{ og_image }}" />{% endif %}
{% endblock %}
{% block content %}
<article>
<h1>{{ page.title }}</h1>
<p>{{ page.read_time_mins }} min read</p>
{% if page.hero_image %}
{% image page.hero_image fill-1200x630 %}
{% endif %}
<!-- Breadcrumb -->
<div class="mb-8 font-mono text-sm text-zinc-500">
<a href="/" class="hover:text-brand-cyan transition-colors">Home</a> /
<a href="/articles/" class="hover:text-brand-cyan transition-colors">Articles</a> /
<span class="text-brand-dark dark:text-brand-light">{{ page.title|truncatechars:40 }}</span>
</div>
<!-- Article Header -->
<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">
{% for tag in page.tags.all %}
<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 %}
<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"><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>
<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="w-12 h-12 bg-gradient-to-tr from-brand-cyan to-brand-pink shrink-0"></div>
<div>
<div class="font-bold font-display text-lg">{{ page.author.name }}</div>
{% if page.author.role %}<div class="font-mono text-xs text-zinc-500">{{ page.author.role }}</div>{% endif %}
</div>
</div>
</header>
{% if page.hero_image %}
<div class="mb-12 border border-zinc-200 dark:border-zinc-800 overflow-hidden">
{% image page.hero_image width-1200 class="w-full h-auto" %}
</div>
{% endif %}
<!-- Main Content Layout -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12">
<!-- Article Body -->
<article class="lg:col-span-8 prose prose-lg dark:prose-invert max-w-none
prose-headings:font-display prose-headings:font-bold
prose-a:text-brand-cyan hover:prose-a:text-brand-pink prose-a:transition-colors prose-a:no-underline hover:prose-a:underline
prose-img:border prose-img:border-zinc-200 dark:prose-img:border-zinc-800
prose-blockquote:border-l-brand-pink prose-blockquote:bg-brand-pink/5 prose-blockquote:py-2 prose-blockquote:not-italic
prose-code:font-mono prose-code:text-brand-cyan prose-code:bg-zinc-100 dark:prose-code:bg-zinc-900 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none">
{{ page.body }}
{% article_json_ld page %}
</article>
<section>
<h2>Related</h2>
</article>
<!-- Sidebar -->
<aside class="lg:col-span-4 space-y-8">
<div class="sticky top-28">
<!-- Share -->
<section aria-label="Share this article" class="mb-8">
<h3 class="font-display font-bold text-lg mb-4 uppercase tracking-widest text-zinc-500 text-sm">Share Article</h3>
<div class="flex gap-2">
<a href="https://x.com/intent/post?url={{ request.build_absolute_uri|urlencode }}&text={{ page.title|urlencode }}" target="_blank" rel="noopener noreferrer"
class="w-10 h-10 flex items-center justify-center bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 hover:border-brand-cyan transition-colors hover:text-brand-cyan" aria-label="Share on X">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.748l7.73-8.835L1.254 2.25H8.08l4.259 5.63L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>
</a>
<a href="https://www.linkedin.com/sharing/share-offsite/?url={{ request.build_absolute_uri|urlencode }}" target="_blank" rel="noopener noreferrer"
class="w-10 h-10 flex items-center justify-center bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 hover:border-blue-600 transition-colors hover:text-blue-600" aria-label="Share on LinkedIn">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
</a>
<button type="button" data-copy-link data-copy-url="{{ request.build_absolute_uri }}"
class="w-10 h-10 flex items-center justify-center bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 hover:border-brand-pink transition-colors hover:text-brand-pink" aria-label="Copy link">
<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="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" /></svg>
</button>
</div>
</section>
<!-- Newsletter -->
<div class="bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark p-6 border border-transparent dark:border-zinc-700 shadow-solid-dark dark:shadow-solid-light">
<h3 class="font-display font-bold text-xl mb-2">Subscribe for Updates</h3>
<p class="text-sm opacity-80 mb-4">Get our latest articles and coding benchmarks delivered to your inbox every week.</p>
{% include 'components/newsletter_form.html' with source='article' label='Subscribe' %}
</div>
</div>
</aside>
</div>
<!-- Related Articles -->
{% if related_articles %}
<section class="mt-16 md:mt-24 pt-12 border-t border-zinc-200 dark:border-zinc-800">
<div class="flex items-center justify-between mb-8">
<h3 class="font-display font-bold text-3xl">Related Articles</h3>
<a href="/articles/" class="font-mono text-sm text-brand-cyan hover:underline">View All</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
{% for article in related_articles %}
<a href="{{ article.url }}">{{ article.title }}</a>
<article class="group flex flex-col h-full bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 hover:-translate-y-2 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all duration-300">
<a href="{{ article.url }}" class="h-48 overflow-hidden relative bg-zinc-900 flex items-center justify-center border-b border-zinc-200 dark:border-zinc-800 shrink-0 block">
{% if article.hero_image %}
{% image article.hero_image fill-400x300 class="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500" %}
{% else %}
<svg class="w-16 h-16 text-brand-cyan opacity-40 group-hover:opacity-80 transition-all duration-500 group-hover:scale-110" 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="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 1-6.23-.693L5 14.5m14.8.8 1.402 1.402c1 1 .03 2.798-1.442 2.798H4.24c-1.47 0-2.44-1.798-1.442-2.798L4.2 15.3" /></svg>
{% endif %}
</a>
<div class="p-6 flex flex-col flex-grow">
<div class="flex gap-2 mb-3 flex-wrap">
{% for tag in article.tags.all %}
<span class="text-xs font-mono font-bold px-2 py-1 {{ tag|get_tag_css }}">{{ tag.name }}</span>
{% endfor %}
</div>
<a href="{{ article.url }}">
<h4 class="font-display font-bold text-xl mb-2 group-hover:text-brand-cyan transition-colors">{{ article.title }}</h4>
</a>
<p class="text-zinc-600 dark:text-zinc-400 text-sm mb-6 line-clamp-2">{{ article.summary }}</p>
<div class="mt-auto pt-4 border-t border-zinc-100 dark:border-zinc-800 flex items-center gap-2 text-sm font-bold font-mono group-hover:text-brand-cyan transition-colors">
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>
</div>
</div>
</article>
{% endfor %}
</div>
</section>
{% endif %}
<!-- Comments -->
{% if page.comments_enabled %}
<section>
<form method="post" action="{% url 'comment_post' %}">
{% csrf_token %}
<input type="hidden" name="article_id" value="{{ page.id }}" />
<input type="text" name="author_name" required />
<input type="email" name="author_email" required />
<textarea name="body" required></textarea>
<input type="text" name="honeypot" style="display:none" />
<button type="submit">Post comment</button>
</form>
<section class="mt-16 pt-12 border-t border-zinc-200 dark:border-zinc-800">
<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>
<p class="mt-2 mb-6 font-mono text-xs uppercase tracking-wider text-zinc-500">
{{ approved_comments|length }} public comment{{ approved_comments|length|pluralize }}
</p>
{% include "comments/_comment_list.html" %}
<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 %}">
<p class="font-mono text-sm text-zinc-500">No comments yet. Be the first to comment.</p>
</div>
{% include "comments/_comment_form.html" %}
</section>
{% endif %}
{% endblock %}

View File

@@ -1,4 +1,20 @@
<div class="callout icon-{{ value.icon }}">
<h3>{{ value.heading }}</h3>
{{ value.body }}
<div class="bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 p-6 my-8 flex items-start gap-4
{% if value.icon == 'warning' %}border-l-4 border-l-yellow-400{% elif value.icon == 'trophy' %}border-l-4 border-l-brand-pink{% elif value.icon == 'tip' %}border-l-4 border-l-green-500{% else %}border-l-4 border-l-brand-cyan{% endif %}">
<div class="shrink-0 mt-0.5">
{% if value.icon == 'warning' %}
<svg class="w-6 h-6 text-yellow-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="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" /></svg>
{% elif value.icon == 'trophy' %}
<svg class="w-6 h-6 text-brand-pink" 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="M16.5 18.75h-9m9 0a3 3 0 0 1 3 3h-15a3 3 0 0 1 3-3m9 0v-3.375c0-.621-.503-1.125-1.125-1.125h-.871M7.5 18.75v-3.375c0-.621.504-1.125 1.125-1.125h.872m5.007 0H9.497m5.007 0a7.454 7.454 0 0 1-.982-3.172M9.497 14.25a7.454 7.454 0 0 0 .981-3.172M5.25 4.236c-.982.143-1.954.317-2.916.52A6.003 6.003 0 0 0 7.73 9.728M5.25 4.236V4.5c0 2.108.966 3.99 2.48 5.228M5.25 4.236V2.721C7.456 2.41 9.71 2.25 12 2.25c2.291 0 4.545.16 6.75.47v1.516M7.73 9.728a6.726 6.726 0 0 0 2.748 1.35m8.272-6.842V4.5c0 2.108-.966 3.99-2.48 5.228m2.48-5.492a46.32 46.32 0 0 1 2.916.52 6.003 6.003 0 0 1-5.395 4.972m0 0a6.726 6.726 0 0 1-2.749 1.35m0 0a6.772 6.772 0 0 1-3.044 0" /></svg>
{% elif value.icon == 'tip' %}
<svg class="w-6 h-6 text-green-500" 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 18v-5.25m0 0a6.01 6.01 0 0 0 1.5-.189m-1.5.189a6.01 6.01 0 0 1-1.5-.189m3.75 7.478a12.06 12.06 0 0 1-4.5 0m3.75 2.383a14.406 14.406 0 0 1-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 1 0-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" /></svg>
{% else %}
<svg class="w-6 h-6 text-brand-cyan" 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="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" /></svg>
{% endif %}
</div>
<div class="min-w-0">
{% if value.heading %}
<h4 class="font-display font-bold text-lg mb-2">{{ value.heading }}</h4>
{% endif %}
<div class="text-zinc-700 dark:text-zinc-300 text-sm leading-relaxed">{{ value.body }}</div>
</div>
</div>

View File

@@ -1,5 +1,19 @@
{% load wagtailcore_tags %}
<div class="code-block">
{% if value.filename %}<div>{{ value.filename }}</div>{% endif %}
<pre data-lang="{{ value.language }}"><code class="language-{{ value.language }}">{{ value.raw_code }}</code></pre>
<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 gap-2">
<div class="w-3 h-3 rounded-full bg-red-500"></div>
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<div class="w-3 h-3 rounded-full bg-green-500"></div>
</div>
{% if value.filename %}
<div class="font-mono text-xs text-zinc-500">{{ value.filename }}</div>
{% else %}
<div class="font-mono text-xs text-zinc-500">{{ value.language }}</div>
{% endif %}
<div class="w-8"></div>
</div>
<div class="overflow-x-auto">
<pre data-lang="{{ value.language }}" class="p-6 text-sm"><code class="language-{{ value.language }} font-mono text-zinc-300">{{ value.raw_code }}</code></pre>
</div>
</div>

View File

@@ -1,21 +1,166 @@
{% extends 'base.html' %}
{% load wagtailimages_tags seo_tags core_tags %}
{% block title %}No Hype AI{% endblock %}
{% block head_meta %}
{% canonical_url page as canonical %}
<link rel="canonical" href="{{ canonical }}" />
<meta name="description" content="Honest AI coding tool reviews for developers." />
<meta property="og:type" content="website" />
<meta property="og:title" content="No Hype AI" />
<meta property="og:description" content="Honest AI coding tool reviews for developers." />
<meta property="og:url" content="{{ canonical }}" />
{% endblock %}
{% block content %}
<section>
{% if featured_article %}
<h2>{{ featured_article.title }}</h2>
<p>{{ featured_article.author.name }}</p>
<p>{{ featured_article.read_time_mins }} min read</p>
<!-- Featured Article -->
{% if featured_article %}
<section class="mb-12 md:mb-16">
<div class="flex items-center gap-2 mb-6">
<span class="w-2 h-2 rounded-full bg-brand-pink animate-pulse"></span>
<span class="font-mono text-sm font-bold uppercase tracking-widest text-zinc-500">Featured Article</span>
</div>
<article class="group cursor-pointer grid grid-cols-1 lg:grid-cols-2 gap-8 items-center bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-4 md:p-6 hover:border-brand-cyan dark:hover:border-brand-cyan hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all duration-300">
<a href="{{ featured_article.url }}" class="w-full h-64 md:h-[400px] overflow-hidden relative bg-zinc-100 dark:bg-zinc-900 order-2 lg:order-1 border border-zinc-200 dark:border-zinc-800 block">
{% if featured_article.hero_image %}
{% image featured_article.hero_image fill-800x600 class="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-700 group-hover:scale-105" %}
{% else %}
<div class="absolute inset-0 flex items-center justify-center">
<svg class="w-20 h-20 text-brand-cyan opacity-30" 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="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 1-6.23-.693L5 14.5m14.8.8 1.402 1.402c1 1 .03 2.798-1.442 2.798H4.24c-1.47 0-2.44-1.798-1.442-2.798L4.2 15.3" /></svg>
</div>
{% endif %}
</section>
<section>
{% for article in latest_articles %}
{% include 'components/article_card.html' with article=article %}
</a>
<div class="flex flex-col py-2 order-1 lg:order-2">
<div class="flex gap-3 mb-4 items-center flex-wrap">
{% 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>
{% endfor %}
<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>
<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>
</a>
<p class="text-zinc-600 dark:text-zinc-400 mb-8 text-lg md:text-xl line-clamp-3">{{ featured_article.summary }}</p>
<div class="mt-auto flex items-center justify-between pt-4 border-t border-zinc-200 dark:border-zinc-800">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-tr from-brand-cyan to-brand-pink"></div>
<div>
<div class="text-sm font-bold font-display">{{ featured_article.author.name }}</div>
<div class="text-xs font-mono text-zinc-500">{{ featured_article.first_published_at|date:"M j, Y" }}</div>
</div>
</div>
<a href="{{ featured_article.url }}" class="text-sm font-bold font-mono group-hover:text-brand-cyan transition-colors flex items-center gap-1">
Read
<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="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" /></svg>
</a>
</div>
</div>
</article>
</section>
<section>
{% endif %}
<!-- 2-Column Editorial Layout -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12">
<!-- Main Feed -->
<div class="lg:col-span-8">
<div class="flex items-center justify-between mb-8 pb-4 border-b border-zinc-200 dark:border-zinc-800">
<h3 class="font-display font-bold text-3xl">Latest Articles</h3>
<a href="/articles/" class="font-mono text-sm text-brand-cyan hover:underline flex items-center gap-1">View All</a>
</div>
<div class="space-y-8">
{% for article in latest_articles %}
<article class="group flex flex-col md:flex-row gap-6 items-start pb-8 border-b border-zinc-200 dark:border-zinc-800 last:border-0">
<a href="{{ article.url }}" class="w-full md:w-48 h-48 md:h-32 shrink-0 overflow-hidden relative bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 flex items-center justify-center block">
{% if article.hero_image %}
{% image article.hero_image fill-200x130 class="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500" %}
{% else %}
<svg class="w-12 h-12 text-brand-cyan opacity-40 group-hover:opacity-80 transition-all duration-500 group-hover:scale-110" 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="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 1-6.23-.693L5 14.5m14.8.8 1.402 1.402c1 1 .03 2.798-1.442 2.798H4.24c-1.47 0-2.44-1.798-1.442-2.798L4.2 15.3" /></svg>
{% endif %}
</a>
<div class="flex flex-col w-full">
<div class="flex gap-3 mb-2 items-center flex-wrap">
{% for tag in article.tags.all %}
<span class="text-xs font-mono font-bold px-2 py-1 {{ tag|get_tag_css }}">{{ tag.name }}</span>
{% endfor %}
<span class="text-sm font-mono text-zinc-500">{{ article.first_published_at|date:"M j" }}</span>
</div>
<a href="{{ article.url }}">
<h4 class="font-display font-bold text-2xl mb-2 group-hover:text-brand-cyan transition-colors">{{ article.title }}</h4>
</a>
<p class="text-zinc-600 dark:text-zinc-400 text-sm line-clamp-2 mb-3">{{ article.summary }}</p>
<a href="{{ article.url }}" class="text-xs font-mono font-bold group-hover:text-brand-cyan transition-colors mt-auto">Read article →</a>
</div>
</article>
{% endfor %}
</div>
{% if more_articles %}
<div class="mt-10">
<div class="flex items-center justify-between mb-6 pb-4 border-b border-zinc-200 dark:border-zinc-800">
<h3 class="font-display font-bold text-2xl">More Articles</h3>
</div>
<div class="space-y-6">
{% for article in more_articles %}
{% include 'components/article_card.html' with article=article %}
{% endfor %}
</section>
</div>
</div>
{% endif %}
</div>
<!-- Sidebar -->
<aside class="lg:col-span-4 space-y-8">
<!-- Newsletter Widget -->
<div class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6 relative overflow-hidden">
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-brand-cyan to-brand-pink"></div>
<h4 class="font-display font-bold text-xl mb-2">Weekly Newsletter</h4>
<p class="text-zinc-600 dark:text-zinc-400 text-sm mb-4">Get our latest articles and coding benchmarks delivered to your inbox every week.</p>
{% include 'components/newsletter_form.html' with source='sidebar' label='Subscribe' %}
</div>
<!-- Popular Articles -->
{% if latest_articles %}
<div>
<h4 class="font-display font-bold mb-4 uppercase tracking-widest text-zinc-500 text-sm">Popular Articles</h4>
<ul class="space-y-4">
{% for article in latest_articles %}
<li class="group">
<a href="{{ article.url }}" class="flex gap-4 items-start">
<span class="font-display font-black text-2xl text-zinc-300 dark:text-zinc-800 group-hover:text-brand-{% cycle 'cyan' 'pink' 'cyan' 'pink' 'cyan' %} transition-colors">0{{ forloop.counter }}</span>
<div>
<h5 class="font-display font-bold text-sm leading-tight group-hover:text-brand-cyan transition-colors">{{ article.title }}</h5>
<div class="text-xs font-mono text-zinc-500 mt-1">{{ article.read_time_mins }} min read</div>
</div>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% 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>
<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">
{% for tag in available_tags %}
<a href="/articles/?tag={{ tag.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">#{{ tag.name }}</a>
{% endfor %}
</div>
</div>
{% endif %}
</aside>
</div>
{% endblock %}

View 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>

View 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 %}

View 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>

View 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>

Some files were not shown because too many files have changed in this diff Show More