Compare commits

..

7 commits

Author SHA1 Message Date
PCoder
751438f5eb Merge remote-tracking branch 'origin/master' into upgrade/django5.2-wagtail7.0 2026-03-19 00:06:22 +05:30
PCoder
e112569f11 Remove upgrade-publichealth.md from tracking and add upgrade* to .gitignore 2026-03-15 01:30:35 +05:30
PCoder
8575d26739 Fix startup warnings: naive datetime and WAGTAILADMIN_BASE_URL
- Replace datetime.datetime.now() with timezone.now() in
  feedler/models/models.py and publichealth/home/models/models.py
  (fixes RuntimeWarning about naive datetime with USE_TZ=True)
- Add WAGTAILADMIN_BASE_URL to dev.py and production.py
  (fixes wagtailadmin.W003 warning)
- All checks pass with -Wall, no warnings remain except
  third-party guess_language PyEnchant ImportWarning
2026-03-15 01:23:49 +05:30
PCoder
b5229e1eaf Upgrade Django 5.1.9 → 5.2.1 LTS + bump all dependencies
- Bump Django from 5.1.9 to 5.2.1 (target LTS, supported until April 2028)
- Bump django-anymail 11.0 → 12.0, django-colorful 1.3 → 1.4.0,
  django-crispy-forms 2.3 → 2.5, django-el-pagination 3.3.0 → 4.1.2,
  django-taggit >=5.0 → >=6.0, Pillow 10.4.0 → 11.1.0
- Remove django-filter <25 pin (no longer needed with Django 5.2)
- Add missing django-libsass==0.9 to requirements (SCSS compilation)
- No new migrations needed, all checks pass
- HTTP smoke tests: homepage, admin, search all return 200
2026-03-15 01:12:15 +05:30
PCoder
95edd2ab88 Upgrade Django 5.0.9 → 5.1.9
- Bump Django from 5.0.9 to 5.1.9 (intermediate step toward 5.2 LTS)
- Generate migrations for BigAutoField id fields and updated field definitions
- No code changes needed (STORAGES already used, no index_together)
- All checks pass, migrations applied cleanly
2026-03-15 01:08:33 +05:30
PCoder
5f1653bb6b Upgrade Wagtail 6.3.1 → 7.0.2 LTS
- Bump wagtail to 7.0.2 (target LTS)
- Bump wagtail-modeladmin to 2.2.0 (Wagtail 7.0 support)
- Bump django-filter to >=24.0,<25
- Switch elasticsearch backend from elasticsearch5 to elasticsearch7
  (elasticsearch5 backend removed in Wagtail 7.0)
- All checks pass, 1 migration applied cleanly
2026-03-15 01:06:53 +05:30
PCoder
2bee4e8904 Upgrade Wagtail 6.0.6 → 6.3.1 LTS
- Rename PASSWORD_REQUIRED_TEMPLATE to WAGTAIL_PASSWORD_REQUIRED_TEMPLATE
  (deprecated in Wagtail 6.1, removed in 7.0)
- Bump wagtail from 6.0.6 to 6.3.1 (previous LTS)
- All checks pass, 7 new migrations applied cleanly
2026-03-15 01:04:25 +05:30
16 changed files with 131 additions and 522 deletions

1
.gitignore vendored
View file

@ -20,3 +20,4 @@ env
/publichealth/static/libs
publichealth.home.json
.history
upgrade*

View file

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

View file

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

View file

@ -1,8 +0,0 @@
from django.apps import AppConfig
class HomeConfig(AppConfig):
name = "publichealth.home"
def ready(self):
from . import signals # noqa: F401

View file

@ -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}")

View file

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

View file

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

View file

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

View file

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

View file

@ -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/

View file

@ -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'

View file

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

View file

@ -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;

View file

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