Compare commits
7 commits
master
...
upgrade/dj
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
751438f5eb | ||
|
|
e112569f11 | ||
|
|
8575d26739 | ||
|
|
b5229e1eaf | ||
|
|
95edd2ab88 | ||
|
|
5f1653bb6b | ||
|
|
2bee4e8904 |
16 changed files with 131 additions and 522 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -20,3 +20,4 @@ env
|
|||
/publichealth/static/libs
|
||||
publichealth.home.json
|
||||
.history
|
||||
upgrade*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 5.1.9 on 2026-03-14 19:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('feedler', '0009_entry_expire_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='entry',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='feedlysettings',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='stream',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
|
||||
from django.db import models
|
||||
from django.utils import translation
|
||||
from django.utils import timezone, translation
|
||||
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from wagtail.models import Page, Orderable # wagtail.core moved to wagtail in v3+
|
||||
|
|
@ -75,7 +73,7 @@ class FeedPage(Page):
|
|||
def feedentries(self):
|
||||
if self.stream:
|
||||
entries = Entry.objects.filter(stream=self.stream).filter(
|
||||
models.Q(expire_at__isnull=True) | models.Q(expire_at__gt=datetime.datetime.now())
|
||||
models.Q(expire_at__isnull=True) | models.Q(expire_at__gt=timezone.now())
|
||||
)
|
||||
else:
|
||||
entries = Entry.objects.all()
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class HomeConfig(AppConfig):
|
||||
name = "publichealth.home"
|
||||
|
||||
def ready(self):
|
||||
from . import signals # noqa: F401
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
"""
|
||||
Find and repair Wagtail rich-text content with unbalanced inline-style or
|
||||
entity tags that crashes the Draftail editor's HTML->ContentState converter
|
||||
with `AssertionError: End of block reached without closing inline style /
|
||||
entity elements`.
|
||||
|
||||
The Draftail converter is strict about tag balance; older Wagtail editors
|
||||
or Word/Outlook paste sometimes left tags unclosed. The fix re-serializes
|
||||
through BeautifulSoup (Python's stdlib html.parser) which auto-closes
|
||||
inline tags, then verifies the output by feeding it back through the same
|
||||
converter — only saves if the result is accepted.
|
||||
|
||||
Defaults to dry-run. Pass --apply to write changes; add --publish to
|
||||
publish the new revision (otherwise it's saved as a draft for editor
|
||||
review). --page-id limits to a single page.
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from wagtail.models import Page
|
||||
from wagtail.fields import RichTextField, StreamField
|
||||
from wagtail.blocks import RichTextBlock
|
||||
|
||||
from publichealth.home.rich_text_sanitizer import (
|
||||
get_converter, is_broken, repair as _repair,
|
||||
)
|
||||
|
||||
|
||||
def _build_converter():
|
||||
converter = get_converter()
|
||||
# Best-effort: list the features the converter was built with for the
|
||||
# mode banner. Falls back gracefully if the converter doesn't expose them.
|
||||
features = sorted(getattr(converter, "features", []))
|
||||
return converter, features
|
||||
|
||||
|
||||
def _is_broken(converter, html):
|
||||
return is_broken(html)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Find and fix Wagtail rich-text fields with unbalanced tags."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--apply", action="store_true",
|
||||
help="Save changes (default: dry-run, report only).")
|
||||
parser.add_argument("--publish", action="store_true",
|
||||
help="Publish the cleaned revision (otherwise save as draft).")
|
||||
parser.add_argument("--page-id", type=int, default=None,
|
||||
help="Limit to a single page id.")
|
||||
parser.add_argument("--diff-file", type=str, default=None,
|
||||
help="Write full before/after HTML and a unified diff per block to this path.")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
import difflib
|
||||
apply_changes = options["apply"]
|
||||
publish = options["publish"]
|
||||
page_id = options["page_id"]
|
||||
diff_path = options["diff_file"]
|
||||
diff_fp = open(diff_path, "w", encoding="utf-8") if diff_path else None
|
||||
|
||||
def emit_diff(page, field_name, sub_path, old, new):
|
||||
if diff_fp is None:
|
||||
return
|
||||
diff_fp.write("=" * 78 + "\n")
|
||||
diff_fp.write(f"Page {page.id}: {page.title}\n")
|
||||
diff_fp.write(f" url_path: {page.url_path}\n")
|
||||
diff_fp.write(f" field: {field_name}{sub_path}\n")
|
||||
diff_fp.write("-" * 78 + "\n")
|
||||
diff_fp.write("OLD:\n")
|
||||
diff_fp.write(old + "\n")
|
||||
diff_fp.write("-" * 78 + "\n")
|
||||
diff_fp.write("NEW:\n")
|
||||
diff_fp.write(new + "\n")
|
||||
diff_fp.write("-" * 78 + "\n")
|
||||
diff_fp.write("UNIFIED DIFF:\n")
|
||||
old_lines = old.splitlines(keepends=True) or [""]
|
||||
new_lines = new.splitlines(keepends=True) or [""]
|
||||
for line in difflib.unified_diff(old_lines, new_lines,
|
||||
fromfile="old", tofile="new", n=1):
|
||||
diff_fp.write(line if line.endswith("\n") else line + "\n")
|
||||
diff_fp.write("\n")
|
||||
|
||||
converter, features = _build_converter()
|
||||
self.stdout.write(f"Detector using {len(features)} features.")
|
||||
self.stdout.write(f"Mode: {'APPLY' + (' + PUBLISH' if publish else ' (draft)') if apply_changes else 'DRY RUN'}")
|
||||
if diff_fp:
|
||||
self.stdout.write(f"Writing full diffs to: {diff_path}")
|
||||
|
||||
qs = Page.objects.all()
|
||||
if page_id is not None:
|
||||
qs = qs.filter(id=page_id)
|
||||
|
||||
total = qs.count()
|
||||
self.stdout.write(f"Scanning {total} page(s)...")
|
||||
|
||||
pages_with_issues = 0
|
||||
pages_fixed = 0
|
||||
pages_partial = 0
|
||||
blocks_fixed = 0
|
||||
blocks_unfixable = 0
|
||||
|
||||
for page in qs.iterator(chunk_size=50):
|
||||
specific = page.specific
|
||||
changes = [] # successful: (field, sub_path, old, new)
|
||||
unfixable = [] # broken but repair didn't help: (field, sub_path)
|
||||
stream_replacements = {} # field_name -> {index: new_html}
|
||||
rich_text_field_updates = {} # field_name -> new_html
|
||||
|
||||
for field in specific._meta.get_fields():
|
||||
if isinstance(field, RichTextField):
|
||||
html = getattr(specific, field.name, "") or ""
|
||||
if not _is_broken(converter, html):
|
||||
continue
|
||||
fixed = _repair(html)
|
||||
if _is_broken(converter, fixed):
|
||||
unfixable.append((field.name, ""))
|
||||
continue
|
||||
changes.append((field.name, "", html, fixed))
|
||||
rich_text_field_updates[field.name] = fixed
|
||||
|
||||
elif isinstance(field, StreamField):
|
||||
stream_value = getattr(specific, field.name, None)
|
||||
if stream_value is None:
|
||||
continue
|
||||
try:
|
||||
children = list(stream_value)
|
||||
except Exception:
|
||||
continue
|
||||
field_replacements = {}
|
||||
for i, child in enumerate(children):
|
||||
if not isinstance(child.block, RichTextBlock):
|
||||
continue
|
||||
v = child.value
|
||||
html = v.source if hasattr(v, "source") else str(v)
|
||||
if not _is_broken(converter, html):
|
||||
continue
|
||||
fixed = _repair(html)
|
||||
if _is_broken(converter, fixed):
|
||||
unfixable.append((field.name, f"[{i}]"))
|
||||
continue
|
||||
changes.append((field.name, f"[{i}]({child.block_type})", html, fixed))
|
||||
field_replacements[i] = fixed
|
||||
if field_replacements:
|
||||
stream_replacements[field.name] = (children, field_replacements)
|
||||
|
||||
if not changes and not unfixable:
|
||||
continue
|
||||
|
||||
pages_with_issues += 1
|
||||
if changes and not unfixable:
|
||||
pages_fixed += 1
|
||||
elif changes and unfixable:
|
||||
pages_partial += 1
|
||||
blocks_fixed += len(changes)
|
||||
blocks_unfixable += len(unfixable)
|
||||
|
||||
self.stdout.write("")
|
||||
self.stdout.write(f"Page {page.id}: {page.title!r} [{specific.__class__.__name__}]")
|
||||
self.stdout.write(f" url_path: {page.url_path}")
|
||||
for field_name, sub_path, old, new in changes:
|
||||
self.stdout.write(f" FIX {field_name}{sub_path}")
|
||||
self.stdout.write(f" old: {old[:160]!r}{'...' if len(old) > 160 else ''}")
|
||||
self.stdout.write(f" new: {new[:160]!r}{'...' if len(new) > 160 else ''}")
|
||||
emit_diff(page, field_name, sub_path, old, new)
|
||||
for field_name, sub_path in unfixable:
|
||||
self.stdout.write(f" SKIP {field_name}{sub_path} (auto-repair did not produce valid output)")
|
||||
|
||||
if not apply_changes:
|
||||
continue
|
||||
|
||||
# Apply
|
||||
for field_name, new_value in rich_text_field_updates.items():
|
||||
setattr(specific, field_name, new_value)
|
||||
|
||||
for field_name, (children, replacements) in stream_replacements.items():
|
||||
# Rebuild the StreamField as a list of raw {type, value} dicts.
|
||||
# Using `get_prep_value` for unchanged children gives the
|
||||
# JSON-serializable raw form Wagtail expects on assign;
|
||||
# replacements are already raw HTML strings (correct form for
|
||||
# RichTextBlock).
|
||||
new_list = []
|
||||
for i, child in enumerate(children):
|
||||
if i in replacements:
|
||||
new_list.append({"type": child.block_type, "value": replacements[i]})
|
||||
else:
|
||||
raw = child.block.get_prep_value(child.value)
|
||||
new_list.append({"type": child.block_type, "value": raw})
|
||||
setattr(specific, field_name, new_list)
|
||||
|
||||
try:
|
||||
revision = specific.save_revision()
|
||||
if publish:
|
||||
revision.publish()
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f" -> revision {revision.id} saved{' & published' if publish else ' (draft)'}"
|
||||
))
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f" -> SAVE FAILED: {type(e).__name__}: {e}"))
|
||||
|
||||
self.stdout.write("")
|
||||
self.stdout.write("=" * 70)
|
||||
self.stdout.write(f"Pages with issues: {pages_with_issues}")
|
||||
self.stdout.write(f"Pages fully fixable: {pages_fixed}")
|
||||
self.stdout.write(f"Pages partial: {pages_partial} (some blocks unfixable)")
|
||||
self.stdout.write(f"Blocks fixed: {blocks_fixed}")
|
||||
self.stdout.write(f"Blocks unfixable: {blocks_unfixable}")
|
||||
if not apply_changes:
|
||||
self.stdout.write("")
|
||||
self.stdout.write("DRY RUN — no changes saved. Re-run with --apply to write.")
|
||||
|
||||
if diff_fp:
|
||||
diff_fp.close()
|
||||
self.stdout.write(f"Diff log: {diff_path}")
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
# Generated by Django 5.1.9 on 2026-03-14 19:37
|
||||
|
||||
import wagtail.contrib.forms.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0037_auto_20230516_1241'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='articlerelatedlink',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='contact',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='contactform',
|
||||
name='from_address',
|
||||
field=models.EmailField(blank=True, max_length=255, verbose_name='from address'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='contactform',
|
||||
name='to_address',
|
||||
field=models.CharField(blank=True, help_text='Optional - form submissions will be emailed to these addresses. Separate multiple addresses by comma.', max_length=255, validators=[wagtail.contrib.forms.models.validate_to_address], verbose_name='to address'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='contactformfield',
|
||||
name='choices',
|
||||
field=models.TextField(blank=True, help_text='Comma or new line separated list of choices. Only applicable in checkboxes, radio and dropdown.', verbose_name='choices'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='contactformfield',
|
||||
name='default_value',
|
||||
field=models.TextField(blank=True, help_text='Default value. Comma or new line separated values supported for checkboxes.', verbose_name='default value'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='contactformfield',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='dataletssettings',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='socialcontact',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='socialcontact',
|
||||
name='network',
|
||||
field=models.CharField(choices=[('twitter', 'Twitter'), ('facebook', 'Facebook'), ('linkedin', 'LinkedIn')], default='twitter', max_length=16),
|
||||
),
|
||||
]
|
||||
|
|
@ -2,10 +2,8 @@
|
|||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db import models
|
||||
from django.utils import translation
|
||||
from django.utils import timezone, translation
|
||||
from django.conf import settings
|
||||
|
||||
from modelcluster.fields import ParentalKey
|
||||
|
|
@ -367,7 +365,7 @@ class HomePage(Page):
|
|||
def newsentries(self):
|
||||
# Get the last few news entries for the home page
|
||||
entries = Entry.objects.filter(
|
||||
models.Q(expire_at__isnull=True) | models.Q(expire_at__gt=datetime.datetime.now())
|
||||
models.Q(expire_at__isnull=True) | models.Q(expire_at__gt=timezone.now())
|
||||
).all().order_by('-published')
|
||||
# Filter out by current language
|
||||
curlang = translation.get_language()
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
"""
|
||||
Detect and repair Wagtail rich-text content with inline/entity tags wrapping
|
||||
block-level elements — a pattern that crashes the Draftail editor's
|
||||
HTML->ContentState converter (`AssertionError: End of block reached without
|
||||
closing inline style/entity elements`).
|
||||
|
||||
Used by:
|
||||
- the `fix_rich_text` management command, for one-off cleanup of legacy data
|
||||
- the pre-save signal handlers in `signals.py`, to prevent future paste-from-Word
|
||||
content from re-introducing the same patterns
|
||||
"""
|
||||
from bs4 import BeautifulSoup, NavigableString
|
||||
|
||||
from wagtail.admin.rich_text.converters.contentstate import ContentstateConverter
|
||||
from wagtail.rich_text import features as feature_registry
|
||||
|
||||
|
||||
_INLINE_TAGS = {"b", "i", "em", "strong", "code", "s", "sub", "sup", "u"}
|
||||
_ENTITY_TAGS = {"a"}
|
||||
_PROBLEM_WRAPPERS = list(_INLINE_TAGS | _ENTITY_TAGS)
|
||||
_BLOCK_TAGS = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "hr",
|
||||
"ul", "ol", "li", "blockquote", "div"]
|
||||
|
||||
|
||||
_converter = None
|
||||
|
||||
|
||||
def get_converter():
|
||||
"""Return a ContentstateConverter loaded with every Draftail-registered feature.
|
||||
|
||||
Lazy-built once per process — the converter init walks the feature registry
|
||||
which isn't ready at import time.
|
||||
"""
|
||||
global _converter
|
||||
if _converter is None:
|
||||
feature_registry.get_default_features()
|
||||
plugins_by_editor = getattr(feature_registry, "plugins_by_editor", {})
|
||||
draftail_plugins = list(plugins_by_editor.get("draftail", {}).keys())
|
||||
default_features = feature_registry.get_default_features()
|
||||
all_features = sorted(set(draftail_plugins) | set(default_features))
|
||||
_converter = ContentstateConverter(features=all_features)
|
||||
return _converter
|
||||
|
||||
|
||||
def is_broken(html):
|
||||
"""Return True if running `html` through the Draftail converter would crash."""
|
||||
if not html:
|
||||
return False
|
||||
try:
|
||||
get_converter().from_database_format(html)
|
||||
except Exception:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_significant_other_content(tag, block_child):
|
||||
for c in tag.children:
|
||||
if c is block_child:
|
||||
continue
|
||||
if isinstance(c, NavigableString):
|
||||
if c.strip():
|
||||
return True
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def repair(html):
|
||||
"""Return repaired HTML by lifting block-wrapping inline/entity tags.
|
||||
|
||||
For every inline (b/i/em/strong/code/s/sub/sup/u) or entity (a) tag that
|
||||
contains a block descendant:
|
||||
- If it has exactly one block child and nothing else significant, invert
|
||||
the nesting: `<a><p>X</p></a>` -> `<p><a>X</a></p>` (preserves link/style).
|
||||
- Otherwise, unwrap the offending tag (drops the styling, keeps content).
|
||||
"""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
while True:
|
||||
offender = None
|
||||
for tag in soup.find_all(_PROBLEM_WRAPPERS):
|
||||
if tag.find(_BLOCK_TAGS):
|
||||
offender = tag
|
||||
break
|
||||
if offender is None:
|
||||
break
|
||||
|
||||
block_children = [c for c in offender.children if getattr(c, "name", None) in _BLOCK_TAGS]
|
||||
if len(block_children) == 1 and not _has_significant_other_content(offender, block_children[0]):
|
||||
block = block_children[0]
|
||||
new_wrapper = soup.new_tag(offender.name, attrs=dict(offender.attrs))
|
||||
for child in list(block.contents):
|
||||
new_wrapper.append(child.extract())
|
||||
block.append(new_wrapper)
|
||||
offender.unwrap()
|
||||
else:
|
||||
offender.unwrap()
|
||||
return str(soup)
|
||||
|
||||
|
||||
def sanitize_if_needed(html):
|
||||
"""Return repaired HTML if the input would crash the editor, else the input unchanged.
|
||||
|
||||
Fast path: if the converter accepts the HTML, return it untouched (no parsing).
|
||||
Repair path: if the cleaned HTML still wouldn't load, return the original to
|
||||
avoid making things worse.
|
||||
"""
|
||||
if not is_broken(html):
|
||||
return html
|
||||
cleaned = repair(html)
|
||||
if is_broken(cleaned):
|
||||
return html
|
||||
return cleaned
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
"""
|
||||
Pre-save signal handlers that sanitize rich-text content before it lands
|
||||
in the database.
|
||||
|
||||
The Draftail editor renders the *latest revision* of a page (not the page
|
||||
row), so the most important hook is `Revision.pre_save` — that catches every
|
||||
admin draft-save and publish flow. `Page.pre_save` is a belt-and-braces
|
||||
catch for direct model writes (shell, migrations, etc).
|
||||
"""
|
||||
import json
|
||||
|
||||
from django.db.models.signals import pre_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from wagtail.models import Page
|
||||
from wagtail.fields import RichTextField, StreamField
|
||||
from wagtail.blocks import RichTextBlock
|
||||
|
||||
from .rich_text_sanitizer import sanitize_if_needed
|
||||
|
||||
try:
|
||||
from wagtail.models import Revision
|
||||
except ImportError: # pragma: no cover — fallback for older Wagtail layouts
|
||||
Revision = None
|
||||
|
||||
|
||||
def _sanitize_stream_items(items, stream_block):
|
||||
"""Sanitize a list of {type, value, id} dicts in place. Return True if any changed."""
|
||||
changed = False
|
||||
child_blocks = getattr(stream_block, "child_blocks", {})
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
block_def = child_blocks.get(item.get("type"))
|
||||
if isinstance(block_def, RichTextBlock):
|
||||
value = item.get("value")
|
||||
if isinstance(value, str):
|
||||
cleaned = sanitize_if_needed(value)
|
||||
if cleaned != value:
|
||||
item["value"] = cleaned
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
@receiver(pre_save)
|
||||
def sanitize_revision_content(sender, instance, **kwargs):
|
||||
"""Sanitize rich-text values inside Revision.content before it's persisted."""
|
||||
if Revision is None or sender is not Revision:
|
||||
return
|
||||
ct = getattr(instance, "content_type", None)
|
||||
if ct is None:
|
||||
return
|
||||
model_class = ct.model_class()
|
||||
if model_class is None or not issubclass(model_class, Page):
|
||||
return
|
||||
|
||||
raw = instance.content
|
||||
# Wagtail 4+ stores Revision.content as a JSONField (dict). Older Wagtail
|
||||
# versions and any string-cast paths may give a JSON-encoded string.
|
||||
if isinstance(raw, dict):
|
||||
content_dict = raw
|
||||
was_string = False
|
||||
elif isinstance(raw, str):
|
||||
try:
|
||||
content_dict = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
was_string = True
|
||||
else:
|
||||
return
|
||||
if not isinstance(content_dict, dict):
|
||||
return
|
||||
|
||||
changed = False
|
||||
for field in model_class._meta.get_fields():
|
||||
if isinstance(field, RichTextField):
|
||||
value = content_dict.get(field.name)
|
||||
if isinstance(value, str):
|
||||
cleaned = sanitize_if_needed(value)
|
||||
if cleaned != value:
|
||||
content_dict[field.name] = cleaned
|
||||
changed = True
|
||||
elif isinstance(field, StreamField):
|
||||
value = content_dict.get(field.name)
|
||||
stream_block = field.stream_block
|
||||
if isinstance(value, list):
|
||||
if _sanitize_stream_items(value, stream_block):
|
||||
changed = True
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
items = json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if isinstance(items, list) and _sanitize_stream_items(items, stream_block):
|
||||
content_dict[field.name] = json.dumps(items)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
instance.content = json.dumps(content_dict) if was_string else content_dict
|
||||
|
||||
|
||||
@receiver(pre_save)
|
||||
def sanitize_page_fields(sender, instance, **kwargs):
|
||||
"""Sanitize rich-text fields on the Page model itself, for direct writes."""
|
||||
if not isinstance(instance, Page):
|
||||
return
|
||||
|
||||
for field in instance._meta.get_fields():
|
||||
if isinstance(field, RichTextField):
|
||||
value = getattr(instance, field.name, "") or ""
|
||||
cleaned = sanitize_if_needed(value)
|
||||
if cleaned != value:
|
||||
setattr(instance, field.name, cleaned)
|
||||
elif isinstance(field, StreamField):
|
||||
stream_value = getattr(instance, field.name, None)
|
||||
if stream_value is None:
|
||||
continue
|
||||
try:
|
||||
children = list(stream_value)
|
||||
except Exception:
|
||||
continue
|
||||
replacements = {}
|
||||
for i, child in enumerate(children):
|
||||
if not isinstance(child.block, RichTextBlock):
|
||||
continue
|
||||
v = child.value
|
||||
html = v.source if hasattr(v, "source") else str(v)
|
||||
cleaned = sanitize_if_needed(html)
|
||||
if cleaned != html:
|
||||
replacements[i] = cleaned
|
||||
if not replacements:
|
||||
continue
|
||||
new_list = []
|
||||
for i, child in enumerate(children):
|
||||
if i in replacements:
|
||||
new_list.append({"type": child.block_type, "value": replacements[i]})
|
||||
else:
|
||||
raw = child.block.get_prep_value(child.value)
|
||||
new_list.append({"type": child.block_type, "value": raw})
|
||||
setattr(instance, field.name, new_list)
|
||||
|
|
@ -152,7 +152,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||
},
|
||||
]
|
||||
|
||||
PASSWORD_REQUIRED_TEMPLATE = 'password.html'
|
||||
WAGTAIL_PASSWORD_REQUIRED_TEMPLATE = 'password.html'
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/1.8/topics/i18n/
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ ALLOWED_HOSTS = ['*']
|
|||
INTERNAL_IPS = ('127.0.0.1', '10.0.2.2')
|
||||
|
||||
BASE_URL = 'http://localhost:8000'
|
||||
WAGTAILADMIN_BASE_URL = 'http://localhost:8000'
|
||||
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ if 'ALLOWED_HOSTS' in env:
|
|||
|
||||
if 'PRIMARY_HOST' in env:
|
||||
BASE_URL = 'http://%s/' % env['PRIMARY_HOST']
|
||||
WAGTAILADMIN_BASE_URL = 'https://%s' % env['PRIMARY_HOST']
|
||||
# Also add PRIMARY_HOST to CSRF_TRUSTED_ORIGINS
|
||||
CSRF_TRUSTED_ORIGINS = ['https://%s' % env['PRIMARY_HOST'], 'http://%s' % env['PRIMARY_HOST']]
|
||||
|
||||
|
|
@ -131,7 +132,7 @@ if REDIS_LOCATION is not None:
|
|||
if 'ELASTICSEARCH_URL' in env:
|
||||
WAGTAILSEARCH_BACKENDS = {
|
||||
'default': {
|
||||
'BACKEND': 'wagtail.search.backends.elasticsearch5',
|
||||
'BACKEND': 'wagtail.search.backends.elasticsearch7',
|
||||
'URLS': [env['ELASTICSEARCH_URL']],
|
||||
'INDEX': APP_NAME,
|
||||
'ATOMIC_REBUILD': True,
|
||||
|
|
|
|||
|
|
@ -66,20 +66,11 @@ section {
|
|||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
// Wagtail 4.0+ no longer wraps embedded rich-text images in a <p> block, so
|
||||
// floating them made supporter/sponsor logos collapse side by side and let
|
||||
// trailing text (captions, contact blocks) wrap beside the last logo. Render
|
||||
// rich-text images as stacked block elements instead: logos sit one under the
|
||||
// other and any following text drops below them. The left/right alignment
|
||||
// classes now position the block via auto margins rather than floating.
|
||||
.richtext-image.left,
|
||||
.richtext-image.right{
|
||||
display:block;
|
||||
float:none;
|
||||
clear:both;
|
||||
.richtext-image.left{
|
||||
float:left;
|
||||
}
|
||||
.richtext-image.right{
|
||||
margin-left:auto;
|
||||
float:right;
|
||||
}
|
||||
.responsive-object {
|
||||
position: relative;
|
||||
|
|
|
|||
|
|
@ -1,33 +1,34 @@
|
|||
# Updated: 2025-09-18 - Django 5.0 Upgrade
|
||||
# Updated: 2026-03-15 - Django 5.2 LTS + Wagtail 7.0 LTS Upgrade
|
||||
|
||||
# Core
|
||||
Django==5.0.9 # Django 5.0 (latest stable in 5.0 series)
|
||||
wagtail==6.0.6 # Wagtail 6.0 supports Django 5.0
|
||||
elasticsearch==7.17.9 # Compatible with Django 5.0
|
||||
Django==5.2.1 # Django 5.2 LTS (target)
|
||||
wagtail==7.0.2 # Wagtail 7.0 LTS (target)
|
||||
elasticsearch==7.17.9 # Compatible with Django 5.2
|
||||
|
||||
# Database
|
||||
psycopg2-binary==2.9.10 # Compatible with Django 5.0 and Python 3.13
|
||||
dj-database-url==2.2.0 # Updated for Django 5.0
|
||||
psycopg2-binary==2.9.10 # Compatible with Django 5.2 and Python 3.13
|
||||
dj-database-url==2.2.0 # Updated for Django 5.2
|
||||
|
||||
# Content
|
||||
# Note: puput needs update or replacement for Wagtail 6.x
|
||||
# Note: puput needs update or replacement for Wagtail 7.x
|
||||
# puput==2.0.0
|
||||
guess-language-spirit==0.5.3
|
||||
|
||||
# Caching
|
||||
django-redis==5.4.0 # Compatible with Django 5.0
|
||||
django-redis==5.4.0 # Compatible with Django 5.2
|
||||
redis==5.0.8
|
||||
|
||||
# Frontend
|
||||
django-anymail==11.0 # Updated for Django 5.0
|
||||
django-colorful==1.3
|
||||
django-contrib-comments==2.2.0 # May need testing with Django 5.0
|
||||
django-crispy-forms==2.3 # Updated for Django 5.0
|
||||
django-el-pagination==3.3.0
|
||||
django-compressor==4.5.1 # Updated for Django 5.0
|
||||
django-social-share==2.3.0 # May need testing with Django 5.0
|
||||
libsass==0.23.0 # Updated version
|
||||
Pillow==10.4.0 # Updated for Django 5.0 and Python 3.12+
|
||||
django-anymail==12.0 # Updated for Django 5.2
|
||||
django-colorful==1.4.0 # Updated for Django 5.2
|
||||
django-contrib-comments==2.2.0 # Kept as-is (unused in templates)
|
||||
django-crispy-forms==2.5 # Updated for Django 5.2
|
||||
django-el-pagination==4.1.2 # Updated (latest available)
|
||||
django-compressor==4.5.1 # Compatible with Django 5.2
|
||||
django-social-share==2.3.0 # Compatible with Django 5.2
|
||||
libsass==0.23.0
|
||||
django-libsass==0.9 # Required by COMPRESS_PRECOMPILERS setting
|
||||
Pillow==11.1.0 # Updated for Django 5.2 and Python 3.13
|
||||
|
||||
# Runtime dependencies
|
||||
gunicorn
|
||||
|
|
@ -36,19 +37,19 @@ python-dotenv
|
|||
#ConcurrentLogHandler
|
||||
unidecode
|
||||
|
||||
# Additional dependencies for Django 5.0 and Wagtail 6
|
||||
# Additional dependencies for Django 5.2 and Wagtail 7.0
|
||||
django-configurations
|
||||
django-taggit>=5.0.0 # Required by Wagtail 6
|
||||
wagtail-modeladmin==2.0.0 # Separate package for ModelAdmin in Wagtail 5+
|
||||
django-taggit>=6.0.0 # Required by Wagtail 7.0
|
||||
wagtail-modeladmin==2.2.0 # Separate package for ModelAdmin (supports Wagtail 7.0)
|
||||
djangorestframework>=3.15.0 # Required by Wagtail API
|
||||
django-modelcluster>=6.3 # Required by Wagtail 6
|
||||
django-treebeard>=4.7 # Required by Wagtail 6
|
||||
django-filter>=23.3,<24 # Required by Wagtail 6.0.6 (must be <24)
|
||||
django-modelcluster>=6.3 # Required by Wagtail 7.0
|
||||
django-treebeard>=4.7 # Required by Wagtail 7.0
|
||||
django-filter>=24.0 # Required by Wagtail 7.0
|
||||
Willow>=1.8.0 # Required by Wagtail for images
|
||||
beautifulsoup4>=4.12.0 # Required by Wagtail
|
||||
html5lib>=1.1 # Required by Wagtail
|
||||
openpyxl>=3.1.0 # For Excel export support
|
||||
l18n # For Wagtail localization
|
||||
telepath>=0.3.1 # Required by Wagtail 6
|
||||
# Django 5.0 timezone support (replaces pytz)
|
||||
tzdata # For timezone support in Django 5.0
|
||||
telepath>=0.3.1 # Required by Wagtail 7.0
|
||||
# Django 5.x timezone support (replaces pytz)
|
||||
tzdata # For timezone support in Django 5.x
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue