diff --git a/Makefile b/Makefile
index cb0c6182c..d3fb22aae 100644
--- a/Makefile
+++ b/Makefile
@@ -12,8 +12,8 @@ help: ## ⁉️ - Display help comments for each make command
| sort
setup: build ## 🔨 - Set instance up
- docker-compose run web django-admin migrate
- docker-compose run web django-admin createcachetable
+ docker-compose run --rm web django-admin migrate
+ docker-compose run --rm web django-admin createcachetable
build: ## 🔨 - Build Docker container
bash -c "docker-compose build --build-arg UID=$$(id -u) --build-arg GID=$$(id -g)"
@@ -28,10 +28,10 @@ runserver: ## 🏃 - Run Django server
docker-compose exec web django-admin runserver 0.0.0.0:8000
superuser: ## 🔒 - Create superuser
- docker-compose run web django-admin createsuperuser
+ docker-compose run --rm web django-admin createsuperuser
migrations: ## 🧳 - Make migrations
- docker-compose run web django-admin makemigrations
+ docker-compose run --rm web django-admin makemigrations
migrate: ## 🧳 - Migrate
- docker-compose run web django-admin migrate
+ docker-compose run --rm web django-admin migrate
diff --git a/fabfile.py b/fabfile.py
index 0a2a98d20..eacaa62aa 100644
--- a/fabfile.py
+++ b/fabfile.py
@@ -114,7 +114,7 @@ def import_data(
def delete_local_renditions(c, local_database_name=LOCAL_DATABASE_NAME):
- psql(c, "DELETE FROM images_rendition;")
+ psql(c, "DELETE FROM wagtailimages_rendition;")
#########
diff --git a/poetry.lock b/poetry.lock
index d3bfbc29f..ada1ce76e 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand.
[[package]]
name = "anyascii"
@@ -645,6 +645,23 @@ Django = ">=3.2"
dev = ["black (==24.1.1)", "blacken-docs (==1.16.0)", "coverage (==7.3.4)", "django-stubs[compatible-mypy] (==4.2.7)", "flake8 (==7.0.0)", "flake8-bugbear", "flake8-comprehensions", "isort (==5.13.2)", "mypy (==1.7.1)", "pre-commit (==3.4.0)", "tox (==4.12.1)", "tox-gh-actions (==3.2.0)", "types-requests (==2.31.0.20240125)", "virtualenv-pyenv (==0.4.0)"]
testing = ["coverage (==7.3.4)", "dj-database-url (==2.1.0)"]
+[[package]]
+name = "mailchimp-marketing"
+version = "3.0.80"
+description = "Mailchimp Marketing API"
+optional = false
+python-versions = "*"
+files = [
+ {file = "mailchimp_marketing-3.0.80-py3-none-any.whl", hash = "sha256:a5bcb3ebd3be60908c65af765f8195724e6fd2d61ecf6da667a794c5bc7b84d3"},
+]
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+python-dateutil = ">=2.1"
+requests = ">=2.23"
+six = ">=1.10"
+urllib3 = ">=1.23"
+
[[package]]
name = "markdown"
version = "3.7"
@@ -660,6 +677,90 @@ files = [
docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
testing = ["coverage", "pyyaml"]
+[[package]]
+name = "mrml"
+version = "0.2.0"
+description = "A Python wrapper for MRML (Rust port of MJML)."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "mrml-0.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f750a2fe54b51b01b7218f3a94ffb5f94596522d750716263a885d50ff6d513"},
+ {file = "mrml-0.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d9a1e3519b8558e80eb17047524b213c4c3533a2ed98c8d5c6a218abfc6e08c1"},
+ {file = "mrml-0.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aaa9a3840354161b626c59912b96902e90dbd3903ee2dd286c4f00292eff2afd"},
+ {file = "mrml-0.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72608e541d72c4be5176f94a6a598bb03f4f4deaefa911084c075ee3817de2e4"},
+ {file = "mrml-0.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad775127a66149947795cb1a6139f8de7f66eafc20db69053824ca0731a04c27"},
+ {file = "mrml-0.2.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:e60cc563a033152063a64c3e24aedc86c209b4c22ebfd2658716895f179de715"},
+ {file = "mrml-0.2.0-cp310-cp310-win32.whl", hash = "sha256:2c749075da547475894cbca25b975c5c9dbe4617ecc5fef16934a274be16eb51"},
+ {file = "mrml-0.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:031ea109fd5518680f0c464202e6d5b7ceb2d4d3a78351941fc8869af6772394"},
+ {file = "mrml-0.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:edef9deb51e0ee64b18fd6016281d03cddbf6a25e705da6a79c0818c24b684d6"},
+ {file = "mrml-0.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3d00a8599b7be3ee1b405fb780a141ac81fa383e404e1618d551d2a59ca76289"},
+ {file = "mrml-0.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ea7913ef29a779349f6270cbf6473579c136aa3456c01650ead6365121717f5c"},
+ {file = "mrml-0.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe793a5000cce0f2c9267239ac3e8f51479c86ea2f6d0afc4f55f168fdc9a167"},
+ {file = "mrml-0.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3cd384069d98fd8515a84cc37f2ab2e1cf85fb88a6db568c76682038909c45b3"},
+ {file = "mrml-0.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bfa7bec9fd9e531fa560f4b36520a51f129fe94f56f4b8bcbf2a146d23448ce"},
+ {file = "mrml-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af3e6aea8e3d26489be397ba466d0e7d75747a5f904300ade14a5592e172a9f6"},
+ {file = "mrml-0.2.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:66771a03eb17ad6d2264489802c89e849b0f3af791c0ed71d9679b8462322aaf"},
+ {file = "mrml-0.2.0-cp311-cp311-win32.whl", hash = "sha256:b821a410c4b74ad3892e1f280823144d6511cd12a22d7ab16ed72b89773382e5"},
+ {file = "mrml-0.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:66ecc03a0d964f8f307b67e3f2a5cbfe17401f3454ace6e9567b079150efcf54"},
+ {file = "mrml-0.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b24f3e3a0924387c5927aa3b4750b0b006dedf8df97a7911a6a1a971ae2c94d2"},
+ {file = "mrml-0.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:09a1cb67b8a9973226459d40d5ed61474aedc2d81222cb502bccb842a762e60f"},
+ {file = "mrml-0.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:412db41d313f405f745468ea3474abef2f824e18e55dec1ee73453c924a8e1dc"},
+ {file = "mrml-0.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d98e108eef1277c699541cb102516b23b6078aae5bdb74b06cffaa0aebeda388"},
+ {file = "mrml-0.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eca55a6b10a877559c2ca5ce71f83989a14696a000215b4fcba3fc81c55b8912"},
+ {file = "mrml-0.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb375c91d15fe223cffa38831e7be01843977fb2cb91ea9d66ffd671077f08f1"},
+ {file = "mrml-0.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5e6bfac107a9e7e4142a18fccfb5475e13504a0b317a90659c685952120f204"},
+ {file = "mrml-0.2.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c14756543e2fb59c04b804211d5438eac79e19290ee56552ded936b29119c8ec"},
+ {file = "mrml-0.2.0-cp312-cp312-win32.whl", hash = "sha256:a4707afeb41804a1a7a5c3891b795b2debc4ee3c3b3133c4841ccc8a5f65f25a"},
+ {file = "mrml-0.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:4cc5c733fa37b5adb9d7793a1b4d415e8222ae9c8f40454c32febae791c7bd65"},
+ {file = "mrml-0.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d1a5a55aed7425c59e5b77024ea3876c71a6ea62792cf4ce38eb04100fbb0a61"},
+ {file = "mrml-0.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d6f09ca2076b4d1080fdf0cd1a3a2ad85faa88f64690cef13d696c3bc30d5a5b"},
+ {file = "mrml-0.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:986b71ab1c0ff78aef6d7aaac967418083146a6003de5ca5f7275f5ded190b09"},
+ {file = "mrml-0.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:219a840471e87e4d3d5db5b9c9ff65a96410643f02994fa46b15a308c8001f54"},
+ {file = "mrml-0.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eba7ae5cbc4263fa3ba94230d6c66008c33890b5372385ed6ab8951bac336771"},
+ {file = "mrml-0.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df66107b0dd7556b21cd3156876f2d5389d276b70cf29b8796141b0553a9615e"},
+ {file = "mrml-0.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35233367f3ce503f87f47c04838ae8a298b5b08a212b1753e998279b09f1ab46"},
+ {file = "mrml-0.2.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:0b0915d8ec0964333c123d462bfd68284a3d4715971d978426e282e11d08ebb4"},
+ {file = "mrml-0.2.0-cp313-cp313-win32.whl", hash = "sha256:302cbbc0846fc158d2ec6b3e7c59dad6f5a9adcd3bd9d26b2dd148d39a274711"},
+ {file = "mrml-0.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:29d95f366dd2a55d152965ec90035b5c4a89d2f9028bd4bb2342e2f3585363e9"},
+ {file = "mrml-0.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13159e3aa30696ac64af5176a1a75020a7097fe2338e126d2ddc734f73549a10"},
+ {file = "mrml-0.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1becb9f3e278027c3d99747e30f8e8915b9569b6756028f2e824fdf35745e37"},
+ {file = "mrml-0.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7e3755fbc661115e3d7f1614230ac1c0bb9e8fdb4213d36aa1ad057f8fcf18c"},
+ {file = "mrml-0.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:55a82f3ed95d6b402e39446762f7bbeeb2a68084b82a10abe2923f0abe73259b"},
+ {file = "mrml-0.2.0-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6d2c9995e8ca89ca5efd5db646556350e8149de3b84cfa39ee44c27afe5e913"},
+ {file = "mrml-0.2.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b29eefe8a800cb7550927c4cd1888c5e98aa9f6fe6f2ed19efa80e47ba1f31a0"},
+ {file = "mrml-0.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4e3fd2e2ea79589c23d0f2bed268241db7a6747983d7a443e36d7b190394d31b"},
+ {file = "mrml-0.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fdb4f8eaa4fc80c606e1dbe03730facf60463a1476e9ccf47b8e28b04e01d29"},
+ {file = "mrml-0.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09bd9472b5349a870e315910ac67a8329fa6b6e24c7ae71e770d09221707c55a"},
+ {file = "mrml-0.2.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:2c4382214f735f3c976e694d2ad33ad62ef190f52d407088b8836acfe90c2223"},
+ {file = "mrml-0.2.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6202c860a9aa73f2d3a964a74836dd43d14f248a5a2c07b73cf61b099c802257"},
+ {file = "mrml-0.2.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:337a5089e1c797dd62c9ccfb3a67cdb516596829db0905ea9fac953fef197f20"},
+ {file = "mrml-0.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba5ce8f6bf84f20a2ed496b8e3aee5b088a9e1a629ae3aa22bdc2a8dde0c1848"},
+ {file = "mrml-0.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:019abb39c821cc995b315eabec8847fadad860c881bfbec94497da54c8dd1b89"},
+ {file = "mrml-0.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dea2a4da41e7c20ca1879dc4e70f990d9e757477fae9a83332ddbc5acbe05164"},
+ {file = "mrml-0.2.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:2fc984f65613b4521449c78d1527bd81c3e8d7fbc7c22aeab13928936d3a140d"},
+ {file = "mrml-0.2.0-cp38-cp38-win32.whl", hash = "sha256:10f9c989296420ca5dddfe944e7552286f0db93150802a2ca94b4a3e49c6765c"},
+ {file = "mrml-0.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:d1556767f1fa480443c9cf246ee3dd2f023e4a044e3eafe0b9ac695abd1f1cc5"},
+ {file = "mrml-0.2.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ef0fa91540f3b1f9a806659be7af0075426a84532691692e0df6f1432909d95"},
+ {file = "mrml-0.2.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2afd6b5267ca8f6b8dbe9f2607c37c21817620aa7a1345dd4dce4d9dc227711b"},
+ {file = "mrml-0.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ea20b3f879323bdad9dac1374d26f09c13700863c82e995b722627bad6ec458f"},
+ {file = "mrml-0.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:56055e1940506c08f5982bb711b48b1f7b70d01fef68c6db9f1a280ba8195444"},
+ {file = "mrml-0.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3745cd07fa437eee1714d2791c42ed90a31d52f32490f8c1cef2af278c1229cd"},
+ {file = "mrml-0.2.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:ef2cddc25bbd7a96201ee5eacc58082e5f13fd9bea96d3acb278a6cf7258b770"},
+ {file = "mrml-0.2.0-cp39-cp39-win32.whl", hash = "sha256:8417589a0ff3c42be896f3a50933b3daced790552ea2f03551e312648a57f199"},
+ {file = "mrml-0.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b665085282b224632f17c679fbd3f0f1e95c1746d1cbcdad1a86202829fa573"},
+ {file = "mrml-0.2.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:777695190e15e88d7f28310f23f3e684db52969e4545a8ef115ba9a896651341"},
+ {file = "mrml-0.2.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56098aa83ae53b37e7fd751791b968519ca2905475594e907601e2e1415d2de0"},
+ {file = "mrml-0.2.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89f208f1acac13a09cd742e8a51f964b6a84161b07a275e3476372de8e440038"},
+ {file = "mrml-0.2.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9a883e58ad0c863c848f2f4e84bb251ce3596aba4323314a5b2e56892e87810f"},
+ {file = "mrml-0.2.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7d54d73a9571cc2b9060133ecdcc968f0fb42b523dfb8cb046d29bfcdab11b9"},
+ {file = "mrml-0.2.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:46a2737ef31016c8c78683cf923f9d2fd2ae02cbbab90806b45e017948f1a7f2"},
+ {file = "mrml-0.2.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:99b5a8f93ff8bb7a150ac98a2b3fda16ae19f01c8f52f615f2daae6cdbfbace4"},
+ {file = "mrml-0.2.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:934cc47209c24f786d68724b89caad270500bfaf05d24ce57ee3845f555e36e9"},
+ {file = "mrml-0.2.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e933f0372b640c0eb99008de192c497685c9d4f0d7fed973b7e30b7ccfba4c79"},
+ {file = "mrml-0.2.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8f0d25550d110d9a9c4ee9bada85170332a1c6bd4854c44a6f8779b802bad393"},
+ {file = "mrml-0.2.0.tar.gz", hash = "sha256:97e8eb99d6e42821a020bdc76a7415fb714a2d8b8a8c5c22803dcd5a809f2edd"},
+]
+
[[package]]
name = "nodeenv"
version = "1.9.1"
@@ -1060,6 +1161,23 @@ files = [
{file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
]
+[[package]]
+name = "queryish"
+version = "0.2"
+description = "A library for constructing queries on arbitrary data sources following Django's QuerySet API"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "queryish-0.2-py3-none-any.whl", hash = "sha256:2e460537f6b7cd5af187b78a3635e80ceec421221adb62883282d419dc170ea8"},
+ {file = "queryish-0.2.tar.gz", hash = "sha256:60150be41673af3d0597f78fb5e77be0e30dc49658a83d274b2a0959c4f97c1b"},
+]
+
+[package.dependencies]
+requests = ">=2.28,<3.0"
+
+[package.extras]
+testing = ["responses (>=0.23,<1.0)"]
+
[[package]]
name = "redis"
version = "6.4.0"
@@ -1430,6 +1548,31 @@ files = [
{file = "wagtail_font_awesome_svg-1.1.tar.gz", hash = "sha256:f2a19c23d071e58157a3ac4cea61c1dfeddf6cc337d8c99bc88aa0fe52d1ff61"},
]
+[[package]]
+name = "wagtail-newsletter"
+version = "0.2.3"
+description = "Turn Wagtail pages into newsletters."
+optional = false
+python-versions = ">=3.10"
+files = [
+ {file = "wagtail_newsletter-0.2.3-py3-none-any.whl", hash = "sha256:84c6de483041ed372bbaf1dd088b5a9d3d5a446d7ae1efc2476d764c495272e9"},
+ {file = "wagtail_newsletter-0.2.3.tar.gz", hash = "sha256:c1699dba67cb69da716518356856259d834eae6e05e3729f122a760e70c8616d"},
+]
+
+[package.dependencies]
+Django = ">=4.2"
+mailchimp-marketing = {version = ">=3.0.80", optional = true, markers = "extra == \"mailchimp\""}
+mrml = {version = ">=0.2", optional = true, markers = "extra == \"mrml\""}
+queryish = ">=0.2"
+Wagtail = ">=6.3"
+
+[package.extras]
+dev = ["flit", "psycopg", "wagtail-newsletter[docs,mailchimp,mrml,testing]"]
+docs = ["sphinx", "sphinx-autobuild", "sphinx-wagtail-theme"]
+mailchimp = ["mailchimp-marketing (>=3.0.80)"]
+mrml = ["mrml (>=0.2)"]
+testing = ["dj-database-url", "django-debug-toolbar", "django-stubs", "pyright", "pytest", "pytest-cov", "pytest-django"]
+
[[package]]
name = "wagtailmedia"
version = "0.16.0"
@@ -1579,4 +1722,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "^3.13"
-content-hash = "f032ed0d17ee92fd7c6c4106aa7eec7e2f037bad7c686c98a873eec89325fd55"
+content-hash = "0a6b1f48b1a41208bf043aa3485e60b1f4d31e7c855bf6cf3b3773f716f8f184"
diff --git a/pyproject.toml b/pyproject.toml
index 9f9146b01..f86041d45 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,6 +34,7 @@ wagtail-font-awesome-svg = "~1.1"
wagtailmedia = "~0.16"
whitenoise = "~6.9"
django-redis = "~6.0"
+wagtail-newsletter = {extras = ["mailchimp", "mrml"], version = "^0.2.3"}
[tool.poetry.group.dev.dependencies]
pre-commit = "4.3.0"
diff --git a/wagtailio/newsletter/blocks.py b/wagtailio/newsletter/blocks.py
new file mode 100644
index 000000000..1ea0a6e13
--- /dev/null
+++ b/wagtailio/newsletter/blocks.py
@@ -0,0 +1,89 @@
+from django.core.exceptions import ValidationError
+
+from wagtail.blocks import (
+ CharBlock,
+ PageChooserBlock,
+ StreamBlock,
+ StructBlock,
+ StructValue,
+ URLBlock,
+)
+from wagtail.blocks import RichTextBlock as WagtailRichTextBlock
+from wagtail.images.blocks import ImageChooserBlock
+
+
+NEWSLETTER_RICHTEXT_FEATURES = ["h2", "bold", "italic", "ol", "ul", "hr", "link"]
+
+
+class HeadingBlock(CharBlock):
+ class Meta:
+ icon = "title"
+ form_classname = "title"
+ template = "newsletter/blocks/heading.mjml"
+ label = "Heading"
+
+
+class RichTextBlock(WagtailRichTextBlock):
+ class Meta:
+ icon = "pilcrow"
+ template = "newsletter/blocks/rich_text.mjml"
+ label = "Rich Text"
+
+
+class AccentRichTextBlock(WagtailRichTextBlock):
+ class Meta:
+ icon = "pilcrow"
+ template = "newsletter/blocks/accent_rich_text.mjml"
+ label = "Accent Rich Text"
+
+
+class ImageBlock(ImageChooserBlock):
+ class Meta:
+ icon = "image"
+ template = "newsletter/blocks/image.mjml"
+ label = "Image"
+
+
+class ButtonValue(StructValue):
+ @property
+ def link_url(self):
+ if self.get("url"):
+ return self.get("url")
+ elif self.get("page"):
+ return self.get("page").url
+ return None
+
+
+class ButtonBlock(StructBlock):
+ text = CharBlock(required=True)
+ url = URLBlock(required=False, help_text="External URL to link to")
+ page = PageChooserBlock(required=False, help_text="Internal page to link to")
+
+ def clean(self, value):
+ cleaned_data = super().clean(value)
+ url = cleaned_data.get("url")
+ page = cleaned_data.get("page")
+
+ if not url and not page:
+ raise ValidationError(
+ "Please provide either a URL or select a page to link to."
+ )
+
+ if url and page:
+ raise ValidationError("Please provide either a URL or a page, not both.")
+
+ return cleaned_data
+
+ class Meta:
+ icon = "link"
+ template = "newsletter/blocks/button.mjml"
+ label = "Button"
+ value_class = ButtonValue
+
+
+class NewsletterContentBlock(StreamBlock):
+ heading = HeadingBlock()
+ rich_text = RichTextBlock(features=NEWSLETTER_RICHTEXT_FEATURES)
+ accent_rich_text = AccentRichTextBlock(features=NEWSLETTER_RICHTEXT_FEATURES)
+ image = ImageBlock()
+ button = ButtonBlock()
diff --git a/wagtailio/newsletter/management/__init__.py b/wagtailio/newsletter/management/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/wagtailio/newsletter/management/commands/__init__.py b/wagtailio/newsletter/management/commands/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/wagtailio/newsletter/management/commands/import_newsletter.py b/wagtailio/newsletter/management/commands/import_newsletter.py
new file mode 100644
index 000000000..0caa99e35
--- /dev/null
+++ b/wagtailio/newsletter/management/commands/import_newsletter.py
@@ -0,0 +1,177 @@
+from datetime import datetime
+from io import BytesIO
+from pprint import pprint
+
+from django.core.files.images import ImageFile
+from django.core.management.base import BaseCommand
+
+from bs4 import BeautifulSoup, Tag
+import requests
+import willow
+
+from wagtailio.images.models import WagtailIOImage
+from wagtailio.newsletter.models import NewsletterIndexPage, NewsletterPage
+
+
+def clean_tag(tag):
+ if tag.name == "a":
+ href = tag.get("href", "")
+ tag.attrs = {"href": href} if href else {}
+ else:
+ tag.attrs = {}
+ for child in tag.find_all(True):
+ clean_tag(child)
+ return tag
+
+
+def parse_newsletter_html(soup: Tag):
+ h1 = soup.select("h1")[0]
+ title_tr = h1.findParent("tr")
+
+ while not title_tr.previous_sibling:
+ # On issue 160, the title is inside two nested `tr` tags.
+ # On issue 187, it's 12 nested `tr` tags deep, hence the loop. Don't ask.
+ title_tr = title_tr.findParent("tr")
+
+ tr = title_tr.previous_sibling # the row that contains the date
+ while tr:
+ yield tr
+ tr = tr.next_sibling
+
+
+def parse_date(date_str):
+ return datetime.strptime(date_str, "%d %B %Y").date()
+
+
+def get_or_create_image(image_url):
+ description = f"newsletter - downloaded from {image_url}"
+ existing_image = WagtailIOImage.objects.filter(description=description).first()
+ if existing_image:
+ return existing_image
+
+ response = requests.get(image_url, timeout=10)
+ response.raise_for_status()
+
+ filename = image_url.split("/")[-1]
+
+ img_bytes = BytesIO(response.content)
+ willow_image = willow.Image.open(img_bytes)
+ width, height = willow_image.get_size()
+
+ image = WagtailIOImage(
+ title=filename,
+ description=description,
+ file=ImageFile(BytesIO(response.content), name=filename),
+ width=width,
+ height=height,
+ )
+ image.save()
+ return image
+
+
+def process_block_content(block):
+ h1 = block.find("h1")
+ if h1:
+ return {"type": "heading", "value": h1.get_text().strip()}
+
+ button_table = block.select_one("table.mceButtonContainer") or block.select_one(
+ "td.mceButton"
+ )
+ if button_table:
+ link = button_table.find("a")
+ if link:
+ return {
+ "type": "button",
+ "value": {
+ "text": link.get_text().strip(),
+ "url": link.get("href", ""),
+ "page": None,
+ },
+ }
+
+ img = block.find("img")
+ if img and img.get("src"):
+ image_url = img.get("src")
+ image = get_or_create_image(image_url)
+ return {
+ "type": "image",
+ "value": image.id,
+ }
+
+ paragraphs = block.find_all("p")
+ if paragraphs:
+ cleaned_paragraphs = [clean_tag(p) for p in paragraphs]
+ content = "
".join(p.decode_contents() for p in cleaned_paragraphs)
+ return {"type": "rich_text", "value": f"
{content}
"} + + +def process_newsletter_content(url): + response = requests.get(url, timeout=10) + response.raise_for_status() + html_content = response.text + + soup = BeautifulSoup(html_content, "html.parser") + + newsletter_subject = soup.title.string.strip() + if newsletter_subject.startswith("This Week in Wagtail:"): + newsletter_subject = soup.title.string.split(":", 1)[1].strip() + + blocks_iterator = parse_newsletter_html(soup) + + date_block = next(blocks_iterator) + date_text = date_block.get_text().strip() + newsletter_date = parse_date(date_text) + + body = [] + for block in blocks_iterator: + text = block.get_text().strip() + if text.startswith("Until next time, thank you for reading"): + break + + if block_content := process_block_content(block): + body.append(block_content) + + return { + "date": newsletter_date, + "body": body, + "newsletter_subject": newsletter_subject, + } + + +class Command(BaseCommand): + help = "Import newsletter content from HTML files" + + def add_arguments(self, parser): + parser.add_argument("url", type=str, help="URL of the newsletter HTML file") + parser.add_argument("title", type=str, help="Title for the newsletter page") + parser.add_argument( + "--debug", + action="store_true", + help="Print debug information", + ) + + def handle(self, *args, **options): + url = options["url"] + title = options["title"] + index_page = NewsletterIndexPage.objects.get() + + newsletter_data = process_newsletter_content(url) + newsletter_data["title"] = title + + if options["debug"]: + pprint(newsletter_data["body"]) # noqa: T203 + + existing_page = ( + NewsletterPage.objects.child_of(index_page).filter(title=title).first() + ) + + if existing_page: + existing_page.body = newsletter_data["body"] + existing_page.newsletter_subject = newsletter_data["newsletter_subject"] + existing_page.date = newsletter_data["date"] + existing_page.save_revision().publish() + else: + newsletter_page = NewsletterPage(**newsletter_data) + + index_page.add_child(instance=newsletter_page) + newsletter_page.save_revision().publish() diff --git a/wagtailio/newsletter/management/commands/import_newsletter_archive.py b/wagtailio/newsletter/management/commands/import_newsletter_archive.py new file mode 100644 index 000000000..042028a8c --- /dev/null +++ b/wagtailio/newsletter/management/commands/import_newsletter_archive.py @@ -0,0 +1,76 @@ +from django.core.management import call_command +from django.core.management.base import BaseCommand + + +ARCHIVE_URLS = { + "137": "http://eepurl.com/ivTG6c", + "138": "http://eepurl.com/iwZ7G6", + "139": "http://eepurl.com/ixNV4w", + "140": "http://eepurl.com/iyhGYM", + "141": "http://eepurl.com/iyLQJc", + "142": "http://eepurl.com/iz9nMQ", + "143": "http://eepurl.com/iA6xHw", + "144": "http://eepurl.com/iB42Jg", + "145": "http://eepurl.com/iC0wWc", + "146": "http://eepurl.com/iDy-C6", + "147": "http://eepurl.com/iD5k_E", + "148": "http://eepurl.com/iE1QSQ", + "149": "http://eepurl.com/iF-z82", + "150": "http://eepurl.com/iHNgmU", + "151": "http://eepurl.com/iIIKyA", + "152": "http://eepurl.com/iJFzbY", + "153": "http://eepurl.com/iKHkas", + "154": "http://eepurl.com/iLyeCs", + "155": "http://eepurl.com/iMuT3E", + "156": "http://eepurl.com/iNnQKE", + "157": "http://eepurl.com/iOc4L2", + "158": "http://eepurl.com/iPcRkw", + "159": "http://eepurl.com/iP2mTE", + "160": "http://eepurl.com/iQW_y2", + "161": "http://eepurl.com/iRVoYw", + "162": "http://eepurl.com/iSHTWI", + "164": "http://eepurl.com/iTA9b-", + "165": "http://eepurl.com/iUnY6Y", + "166": "http://eepurl.com/iWD_hE", + "167": "http://eepurl.com/iXooRM", + "168": "http://eepurl.com/iYlhYU", + "169": "http://eepurl.com/iYMLro", + "170": "http://eepurl.com/i0FTI2", + "171": "http://eepurl.com/i0GS8s", + "172": "http://eepurl.com/i1BZRU", + "173": "http://eepurl.com/i2KzkE", + "174": "http://eepurl.com/i367AI", + "175": "http://eepurl.com/i4ZGe6", + "176": "http://eepurl.com/i53IVA", + "177": "http://eepurl.com/i7ouKk", + "178": "http://eepurl.com/i8devU", + "179": "http://eepurl.com/i87DBY", + "180": "http://eepurl.com/i-gM12", + "181": "http://eepurl.com/i_rL9Y", + "182": "http://eepurl.com/jarS22", + "183": "http://eepurl.com/jbyaXQ", + "184": "http://eepurl.com/jcHhc6", + "185": "http://eepurl.com/jdEVZo", + "186": "http://eepurl.com/jepIFY", + "187": "http://eepurl.com/jeIw_Y", + "188": "https://mailchi.mp/wagtail/twiw-11036059", + "189": "https://mailchi.mp/wagtail/twiw-11036356", + "190": "https://mailchi.mp/wagtail/twiw-11036662", + "191": "https://mailchi.mp/wagtail/twiw-11036961", + "192": "https://mailchi.mp/wagtail/twiw-11037103", + "193": "https://mailchi.mp/wagtail/twiw-11037192", + "194": "https://mailchi.mp/wagtail/twiw-11037409", + "195": "https://mailchi.mp/wagtail/twiw-11037646", + "196": "https://mailchi.mp/wagtail/twiw-11037909", +} + + +class Command(BaseCommand): + help = "Import all archived newsletters" + + def handle(self, *args, **options): + for issue_num, url in ARCHIVE_URLS.items(): + title = f"Issue #{issue_num}" + self.stdout.write(f"Importing {title}...") + call_command("import_newsletter", url, title) + self.stdout.write(self.style.SUCCESS(f"Successfully imported {title}")) diff --git a/wagtailio/newsletter/migrations/0004_wagtail_newsletter_model.py b/wagtailio/newsletter/migrations/0004_wagtail_newsletter_model.py new file mode 100644 index 000000000..126f002e3 --- /dev/null +++ b/wagtailio/newsletter/migrations/0004_wagtail_newsletter_model.py @@ -0,0 +1,200 @@ +# Generated by Django 5.1.6 on 2025-04-16 13:02 + +from django.db import migrations, models +import django.db.models.deletion + +import wagtail.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("newsletter", "0003_newsletteremailaddress_signed_up_at"), + ("wagtail_newsletter", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="NewsletterSettings", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "footer", + wagtail.fields.StreamField( + [ + ("heading", 0), + ("rich_text", 1), + ("accent_rich_text", 2), + ("image", 3), + ("button", 7), + ], + blank=True, + block_lookup={ + 0: ("wagtailio.newsletter.blocks.HeadingBlock", (), {}), + 1: ( + "wagtailio.newsletter.blocks.RichTextBlock", + (), + { + "features": [ + "h2", + "bold", + "italic", + "ol", + "ul", + "hr", + "link", + ] + }, + ), + 2: ( + "wagtailio.newsletter.blocks.AccentRichTextBlock", + (), + { + "features": [ + "h2", + "bold", + "italic", + "ol", + "ul", + "hr", + "link", + ] + }, + ), + 3: ("wagtailio.newsletter.blocks.ImageBlock", (), {}), + 4: ("wagtail.blocks.CharBlock", (), {"required": True}), + 5: ( + "wagtail.blocks.URLBlock", + (), + { + "help_text": "External URL to link to", + "required": False, + }, + ), + 6: ( + "wagtail.blocks.PageChooserBlock", + (), + { + "help_text": "Internal page to link to", + "required": False, + }, + ), + 7: ( + "wagtail.blocks.StructBlock", + [[("text", 4), ("url", 5), ("page", 6)]], + {}, + ), + }, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="newsletterpage", + name="preview", + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name="newsletterpage", + name="content", + field=wagtail.fields.StreamField( + [ + ("heading", 0), + ("rich_text", 1), + ("accent_rich_text", 2), + ("image", 3), + ("button", 7), + ], + blank=True, + block_lookup={ + 0: ("wagtailio.newsletter.blocks.HeadingBlock", (), {}), + 1: ( + "wagtailio.newsletter.blocks.RichTextBlock", + (), + { + "features": [ + "h2", + "bold", + "italic", + "ol", + "ul", + "hr", + "link", + ] + }, + ), + 2: ( + "wagtailio.newsletter.blocks.AccentRichTextBlock", + (), + { + "features": [ + "h2", + "bold", + "italic", + "ol", + "ul", + "hr", + "link", + ] + }, + ), + 3: ("wagtailio.newsletter.blocks.ImageBlock", (), {}), + 4: ("wagtail.blocks.CharBlock", (), {"required": True}), + 5: ( + "wagtail.blocks.URLBlock", + (), + {"help_text": "External URL to link to", "required": False}, + ), + 6: ( + "wagtail.blocks.PageChooserBlock", + (), + {"help_text": "Internal page to link to", "required": False}, + ), + 7: ( + "wagtail.blocks.StructBlock", + [[("text", 4), ("url", 5), ("page", 6)]], + {}, + ), + }, + ), + ), + migrations.AddField( + model_name="newsletterpage", + name="newsletter_campaign", + field=models.CharField(blank=True, max_length=1000), + ), + migrations.AddField( + model_name="newsletterpage", + name="newsletter_recipients", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="wagtail_newsletter.newsletterrecipients", + ), + ), + migrations.AddField( + model_name="newsletterpage", + name="newsletter_subject", + field=models.CharField( + blank=True, + help_text="Subject for the newsletter. Defaults to page title if blank.", + max_length=1000, + ), + ), + migrations.AlterField( + model_name="newsletterpage", + name="date", + field=models.DateField(), + ), + ] diff --git a/wagtailio/newsletter/migrations/0005_wagtail_newsletter_data.py b/wagtailio/newsletter/migrations/0005_wagtail_newsletter_data.py new file mode 100644 index 000000000..360436a51 --- /dev/null +++ b/wagtailio/newsletter/migrations/0005_wagtail_newsletter_data.py @@ -0,0 +1,48 @@ +# Generated by Django 5.1.6 on 2025-04-16 13:02 + +import re + +from django.db import migrations + + +def convert_body_to_streamfield(apps, schema_editor): + from bs4 import BeautifulSoup + + NewsletterPage = apps.get_model("newsletter", "NewsletterPage") + for page in NewsletterPage.objects.all(): + soup = BeautifulSoup(page.intro, "html.parser") + preview = soup.get_text(strip=True) + if preview: + page.preview = preview + page.save() + + if page.body: + # Split content on image embeds + pattern = r'(