From ba4fee848873028e542cf913b354b69839435e95 Mon Sep 17 00:00:00 2001 From: Brian Pepple Date: Thu, 5 Nov 2026 10:37:00 -0500 Subject: [PATCH 1/7] Add dep on django-compressor --- pyproject.toml | 1 + uv.lock | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 33c857f5..28033473 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "chartkick", "disposable-email-domains>=0.0.281", "django", + "django-compressor>=4.6.0", "django-countries", "django-filter", "django-htmx", diff --git a/uv.lock b/uv.lock index 6843b550..08f03b30 100644 --- a/uv.lock +++ b/uv.lock @@ -241,6 +241,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f2/adbe0352c6673f7b608724be5f69962c80ba1d06f8ed60fa4bfbec258b08/django-6.1.3-py3-none-any.whl", hash = "sha256:a0530e3fdac55d34ccc5893c36f6c57d7a7083064f3d414537e51473648b3290", size = 8424610, upload-time = "2026-11-03T15:21:19.64Z" }, ] +[[package]] +name = "django-appconf" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/a2/e58bec8d7941b914af52a67c35b5709eceed2caa2848f28437f1666ed668/django_appconf-1.2.0.tar.gz", hash = "sha256:15a88d60dd942d6059f467412fe4581db632ef03018a3c183fb415d6fc9e5cec", size = 16127, upload-time = "2025-11-08T15:46:27.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/e6/4c34d94dfb74bbcbc489606e61f1924933de30d22c593dd1f429f35fbd7f/django_appconf-1.2.0-py3-none-any.whl", hash = "sha256:b81bce5ef0ceb9d84df48dfb623a32235d941c78cc5e45dbb6947f154ea277f4", size = 6500, upload-time = "2025-11-08T15:46:25.957Z" }, +] + [[package]] name = "django-classy-tags" version = "4.1.0" @@ -253,6 +265,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/10/13afc12b7d9657ed67f8d49c1cf0585a4331b197ed98ea654e9ed8034e79/django_classy_tags-4.1.0-py3-none-any.whl", hash = "sha256:1c784cf1bac49c20a77b8f7d1541867c64076642a160a847ff449588d4e01e55", size = 14095, upload-time = "2023-07-29T09:06:16.951Z" }, ] +[[package]] +name = "django-compressor" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-appconf" }, + { name = "rcssmin" }, + { name = "rjsmin" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/e4/c6d87b1341d744ceafa85eeceb2adabb1c62b795b8207cbc580fb70df8f4/django_compressor-4.6.0.tar.gz", hash = "sha256:c7478feab98f3368780591f9ee28a433350f5277dd28811f7f710f5bc6dff3c0", size = 99735, upload-time = "2025-11-10T13:12:11.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/9d/9a0ba39f33574994e5b33aea55a68e8fad72b8dd923a82300e4e91774f59/django_compressor-4.6.0-py3-none-any.whl", hash = "sha256:6e7b21020a0d86272c5e37000c33accc4ebeb77394a3dd86d775a09aae7aade4", size = 96828, upload-time = "2025-11-10T13:12:10.001Z" }, +] + [[package]] name = "django-countries" version = "9.1.0" @@ -703,6 +730,7 @@ dependencies = [ { name = "chartkick" }, { name = "disposable-email-domains" }, { name = "django" }, + { name = "django-compressor" }, { name = "django-countries" }, { name = "django-filter" }, { name = "django-htmx" }, @@ -750,6 +778,7 @@ requires-dist = [ { name = "chartkick" }, { name = "disposable-email-domains", specifier = ">=0.0.281" }, { name = "django" }, + { name = "django-compressor", specifier = ">=4.6.0" }, { name = "django-countries" }, { name = "django-filter" }, { name = "django-htmx" }, @@ -1221,6 +1250,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "rcssmin" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/71/a3f1836b88f557185ccfd38d156e149db24c276ac1280336ba967e656434/rcssmin-1.3.0.tar.gz", hash = "sha256:ff15a3890eb350f1aa9ec34998f914c4e2fb13f949496f7c25e807578281adcf", size = 588994, upload-time = "2026-10-10T16:31:39.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/e0/c8e2370fc04773bb1931132cb6311b54cf896c15b25f5e45f374ac8ea805/rcssmin-1.3.0-cp314-cp314-manylinux1_i686.whl", hash = "sha256:c753ba4216894ebe14d3e6a6f3b5d48a8d878d3094b5d718cae4ecaaa64972e4", size = 49000, upload-time = "2026-10-10T16:32:56.345Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2c/142a6d11ee58d93e108e5c7e1947ceb13a1d5b8824fddfd7cb3013580dea/rcssmin-1.3.0-cp314-cp314-manylinux1_x86_64.whl", hash = "sha256:4c38da10a9717db10595ba0c94803bccd78ed72948b2222b815c76053d5e2f96", size = 49491, upload-time = "2026-10-10T16:32:58.399Z" }, + { url = "https://files.pythonhosted.org/packages/be/25/cccf8ee7d7157eec5f06b52247adce26459ec39c06baaf025815c4d41931/rcssmin-1.3.0-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:d2298258fdb42db6d0227d921b6b0d5daa2287f943b2a1ecd3eae69eba13010e", size = 50368, upload-time = "2026-10-10T16:33:00.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/45/49beae5d75470b31769dc439eb4cef8fbe83e8c2dddcb2545a8fe0429a2d/rcssmin-1.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d8173243493ac101f48edcfd1315225d22f3a0f4248bdcd51093e6c67a7e6944", size = 51001, upload-time = "2026-10-10T16:33:02.023Z" }, + { url = "https://files.pythonhosted.org/packages/fd/92/65ccd21bdbdecf48be43b1a007ac6139b8562f0f73ad9fcdce6f2fa08931/rcssmin-1.3.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6de48f314f075d528561bceb12929cc0a23fc4dc9796588a35834cb05c21fa59", size = 52493, upload-time = "2026-10-10T16:33:03.417Z" }, + { url = "https://files.pythonhosted.org/packages/42/5f/bf037b4077637328776cd996cc5f67bed7513495c1badbd9de53c191bf32/rcssmin-1.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:564960a8efbd2841b3915f94eaab16503d41704998bd069660f96aed6b6eedc8", size = 50574, upload-time = "2026-10-10T16:33:05.018Z" }, + { url = "https://files.pythonhosted.org/packages/c9/08/20a21df9ce56a0ea073e9f3ed84134269522bb8353b08d2b47dca13580cd/rcssmin-1.3.0-cp314-cp314t-manylinux1_i686.whl", hash = "sha256:867ea50fa3b43c145f660addc3266df52a6998a48fcbb8b088dd4576c0770215", size = 51224, upload-time = "2026-10-10T16:33:06.261Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/331939cfb686f8d94405805cf08317270d55390f1612a541fecc0d035745/rcssmin-1.3.0-cp314-cp314t-manylinux1_x86_64.whl", hash = "sha256:952637cbd2e982bf0777950d3a2545856aa9d861633e2d3bb3ca400a1930b1e5", size = 51578, upload-time = "2026-10-10T16:33:07.622Z" }, + { url = "https://files.pythonhosted.org/packages/05/fa/c5a26de2512a906edfbe034b2c302bac4b00155d504a610b2db552c5bcd8/rcssmin-1.3.0-cp314-cp314t-manylinux2014_aarch64.whl", hash = "sha256:4d47ccfc075cd276ebc9b98471e6db80c9bb248a6e31cf5932c260b23c5e5676", size = 53336, upload-time = "2026-10-10T16:33:08.974Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/d553a5fd908af1d0be71f30e702884c7c10e061f289b90cdf865fc7b8c69/rcssmin-1.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:13cfa028fc795749a58461ecda3c87fd92b0f3dafec2163918c6d7dd4a8a1f3c", size = 53091, upload-time = "2026-10-10T16:33:10.441Z" }, + { url = "https://files.pythonhosted.org/packages/9a/31/2dcac8a788acd8ffd6224f1041615e9924b88939d70918aff979c1b53b31/rcssmin-1.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:43e8134f207b9355566ccbd0d0efac07bd5de62717b9441936e793b796b9e9be", size = 54264, upload-time = "2026-10-10T16:33:11.733Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9d/a3c5c85b7542fdc0af89475ca320aece91d31eb895285301b0c440fd2bbc/rcssmin-1.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f7f16a4bfc863853c3058bdf95b5a1dcbbb02fdcbba8528a2e93d5eff8b9f153", size = 52422, upload-time = "2026-10-10T16:33:13.087Z" }, + { url = "https://files.pythonhosted.org/packages/51/b4/bec3a45790bfcfeb73861d988459bf3b9d08a7e0b1b35e518aeb0478a81e/rcssmin-1.3.0-cp315-cp315-manylinux1_i686.whl", hash = "sha256:955fe49c56fa76249d93c810ade487b640a11d6cfd3f648c4b3824056ed6d79a", size = 50388, upload-time = "2026-10-10T16:33:14.44Z" }, + { url = "https://files.pythonhosted.org/packages/23/f7/b3fdd27476d3747bd2974a62be8e64db00aabe0d7f7c8cc2e72ff9fff13e/rcssmin-1.3.0-cp315-cp315-manylinux1_x86_64.whl", hash = "sha256:f2dcccf95def8453d75116ed219638ba8e54a10de9f6691fed70212886aec9f9", size = 50000, upload-time = "2026-10-10T16:33:15.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/2a/01344b88dd52c3a9cd44ac53da74e406b7d9ecb919842a946feb660d2bb9/rcssmin-1.3.0-cp315-cp315-manylinux2014_aarch64.whl", hash = "sha256:b715c445a02d2ddb2131de7b72171c61f750d48d9279289c6f91857b6ee27728", size = 51100, upload-time = "2026-10-10T16:33:17.17Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f8/1431f85f13bc95dc1d6017dcaec15d0d93209830500de850a6967ed62f5b/rcssmin-1.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9c85b3aebec2107a709e6b56c4d28bc670f2367ccb341cc70ca7914dc00a7cca", size = 51285, upload-time = "2026-10-10T16:33:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6b/c7d1c8cd637fdeebe67cbf73f1895b6c628366fe2e1f8a5b8fc316c7ae52/rcssmin-1.3.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:97b4c9fcf98db91f987fdf885ee530fbc94b01d296214f766c20594f8d088f99", size = 52488, upload-time = "2026-10-10T16:33:19.946Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0e/d79534b429638c04229b954b14d70690b5a88abd4e9b1cbabe65b3c43d53/rcssmin-1.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:aae81d6b8be707c7564aa5e82656b77be04af138826ad76b0b83c9a5fc3286cb", size = 50850, upload-time = "2026-10-10T16:33:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/40/65/e02bf1c285137c2dd0fe04b929b7d1ce78d822f30dec5a622dd464d0aae1/rcssmin-1.3.0-cp315-cp315t-manylinux1_i686.whl", hash = "sha256:29c63e2a1e4d5e5b361b4b63895f7fac01fc8842e25243ad4296f7e4e24bf540", size = 52231, upload-time = "2026-10-10T16:33:22.682Z" }, + { url = "https://files.pythonhosted.org/packages/51/4a/fafb8493d31d7963b265931d64d712a92039a2c04fdbc5ebac7ea3ecf432/rcssmin-1.3.0-cp315-cp315t-manylinux1_x86_64.whl", hash = "sha256:387a4b1c71c61eb052e8cb154811ad791ec2d95e9f5e55017e250e321cf17840", size = 51772, upload-time = "2026-10-10T16:33:24.003Z" }, + { url = "https://files.pythonhosted.org/packages/68/85/a3e0b5023eb8f488095a533a7605f0130d2427920c4596ef004f8d141776/rcssmin-1.3.0-cp315-cp315t-manylinux2014_aarch64.whl", hash = "sha256:95d565b931321f3d9fddad5c68bda212f0f691b513243a67dc3ef6874f4636f9", size = 53391, upload-time = "2026-10-10T16:33:25.792Z" }, + { url = "https://files.pythonhosted.org/packages/4b/28/5e4c858d32903285df702fb9794699f1683ae629300c4a7438cf1d9a2fbb/rcssmin-1.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a344fa602072a57fae1066a8417d862f79ad1f6d6ad29ecfd091cb754d1ef71c", size = 53209, upload-time = "2026-10-10T16:33:27.385Z" }, + { url = "https://files.pythonhosted.org/packages/7b/97/8fc790fc714ba4a7b77d323a8f045a38c3da333cbe553cca0f540a70cfd2/rcssmin-1.3.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:b63c3bb729c8bc7a9b69985453441cf629a4fe3beeda496425976cd2e1204360", size = 54011, upload-time = "2026-10-10T16:33:29.009Z" }, + { url = "https://files.pythonhosted.org/packages/96/2a/18916aa35f6350159e974ed8cb4a2ca87e6f2ca34ff1a826c24414179553/rcssmin-1.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:76af331d361770dd0d91309f7bb91272e024e70f63112cec9a180d2be9003c38", size = 52490, upload-time = "2026-10-10T16:33:30.279Z" }, +] + [[package]] name = "redis" version = "8.1.0" @@ -1315,6 +1376,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/bc/97b2245c8c7b2dd01f2db74f2bea003cd33c15009b4996a2447f46b5325c/regex-2026.9.29-cp315-cp315t-win_arm64.whl", hash = "sha256:e8c65ef3862a8ad6e86492b6ed9327805dd66904c012bd3649dc67d822ed6c34", size = 285955, upload-time = "2026-09-29T00:49:55.655Z" }, ] +[[package]] +name = "rjsmin" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/7e/1a5e8fa9cf68e9147b4bc041e247783117a9d100cdec91d0efaea785d035/rjsmin-1.3.0.tar.gz", hash = "sha256:7c2ef57d55e2d76db0c0d0f7399c6c5efde995c677b190ba30fb94019f94a07e", size = 427569, upload-time = "2026-10-10T16:32:12.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/37/1f7dcaf0834a0a8d6f7dbcd5fe15447cc4cbd475b152a0acfc7fcf2adda9/rjsmin-1.3.0-cp314-cp314-manylinux1_i686.whl", hash = "sha256:bab857bc74fd2c0f70b16d44a3ffdc9814230afcea495a40b3c217e931b42220", size = 31988, upload-time = "2026-10-10T16:33:08.247Z" }, + { url = "https://files.pythonhosted.org/packages/c8/5e/a4b061e5c797b08832fc1a0e03ff79cbca8c5f1ab34f46313f5686420ef1/rjsmin-1.3.0-cp314-cp314-manylinux1_x86_64.whl", hash = "sha256:cd4a2ee73a7e012cbf3a5c11708c1e2f57f555457d0cae099adcee8101ebebf1", size = 31997, upload-time = "2026-10-10T16:33:09.638Z" }, + { url = "https://files.pythonhosted.org/packages/58/28/33b57831776d2081b6025bd0824cb7ba167c9cb604ffeb2cc8e152450d56/rjsmin-1.3.0-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:ea98b441cca662185e18de95cbd5ea7b522f6ced60dde201335d1473c06dd7fa", size = 32426, upload-time = "2026-10-10T16:33:11.046Z" }, + { url = "https://files.pythonhosted.org/packages/b3/26/b7bfbe285f6c379b14621929f22b0b31732ef9e7dc892b13fba58f01d910/rjsmin-1.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c7bab8e15dc8f555dc0b306f37fe28579a46ce43ac7efcf0702450467914c5f0", size = 32919, upload-time = "2026-10-10T16:33:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/96/7a/e9655ecbd79a6c6c0078a14da5376228ce647148660107cd5696b4702394/rjsmin-1.3.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:40454fd01b8acd039233f2e11e85204b0d3e591dfe7cf1e777b71119e458ae78", size = 32955, upload-time = "2026-10-10T16:33:13.727Z" }, + { url = "https://files.pythonhosted.org/packages/2a/65/19894478636ea166a54251e4cf00b23a23a8f2484a145e1d2e72863ced67/rjsmin-1.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc79f06230db0061d5245094e81bed7be55bdc9b5a383b35d6068e45917215ea", size = 32463, upload-time = "2026-10-10T16:33:15.209Z" }, + { url = "https://files.pythonhosted.org/packages/74/83/4f1054e5a6de03894381fbf6545c2cd1d50a4f0ddeed05560edbbd61bf48/rjsmin-1.3.0-cp314-cp314t-manylinux1_i686.whl", hash = "sha256:c0a7e58b3f65865f4e9925449d81db8242233066c276fc17a34764cc2cdb9cd7", size = 34119, upload-time = "2026-10-10T16:33:16.506Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ff/95adcdd99d3d006e373f6c6a246a469d9953ded9aa5a08f77f81c6f7f790/rjsmin-1.3.0-cp314-cp314t-manylinux1_x86_64.whl", hash = "sha256:4cc7ac80adb33e53c598c9f1afe4b390d3b6631fc9a2b05dabdce9f5400fda1f", size = 33960, upload-time = "2026-10-10T16:33:17.934Z" }, + { url = "https://files.pythonhosted.org/packages/e4/8c/238c9e15495726419f44ca48747d3acdaebc53f8693140f3e03e6be73d2b/rjsmin-1.3.0-cp314-cp314t-manylinux2014_aarch64.whl", hash = "sha256:a8a41fa57ef5b3c930bdd42cd62f18807a7b088064280bab376e9a5ca328d4e1", size = 34595, upload-time = "2026-10-10T16:33:19.257Z" }, + { url = "https://files.pythonhosted.org/packages/69/23/0181994478008cbbb67a1c46e4481330d53821c8e8b72578b74782e4a634/rjsmin-1.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:67690b4bbe8c39cf21362fe3ae389169133a9787b9192244e4459e13835f1711", size = 34842, upload-time = "2026-10-10T16:33:20.587Z" }, + { url = "https://files.pythonhosted.org/packages/12/0f/b3bcb118b86fa8dd6a592b673886fbd2dd948ecf39f629697586989ee234/rjsmin-1.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d473f9e2d855d5578f8579bf8dc58b16170c7e14b833e1f3e392c621b3dc588e", size = 34690, upload-time = "2026-10-10T16:33:21.931Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/a0a5a79707c867973f358fac3df6c155a03f22a40ad81e4c4194ce67ab59/rjsmin-1.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:303f021ea53064b86f090303b6a28217aa08ed89e25da62c45bdb3d0ac121bf6", size = 34159, upload-time = "2026-10-10T16:33:23.317Z" }, + { url = "https://files.pythonhosted.org/packages/cc/5a/acad8dbac532c113eafc9bde01cf3b556b18762a5dd3fcf62c7c04956da2/rjsmin-1.3.0-cp315-cp315-manylinux1_i686.whl", hash = "sha256:719b949efea978e435ff22447f9dd8004f680862ee1d9d559151c966d67ca50f", size = 32520, upload-time = "2026-10-10T16:33:25.063Z" }, + { url = "https://files.pythonhosted.org/packages/00/00/48631d59fabbffde8a21a9494422a9d1617e1dac17ad31058a96609c611b/rjsmin-1.3.0-cp315-cp315-manylinux1_x86_64.whl", hash = "sha256:bb223344438e77d74c5e41d5a07fb754c42e9b04bab0c004d08ca6022c885d72", size = 32167, upload-time = "2026-10-10T16:33:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/fd/81/1977433e16146575269bc81ab118bcc4012a3814ae1787450dd12d03927e/rjsmin-1.3.0-cp315-cp315-manylinux2014_aarch64.whl", hash = "sha256:da4961eb74c563094e931f7d09bf2fbd12d1690ec567a6fbea3964e5a142b80e", size = 32690, upload-time = "2026-10-10T16:33:27.983Z" }, + { url = "https://files.pythonhosted.org/packages/77/7b/d45832af516bc9fae2bbdd929be97a3edfdf7ba30e3c351bb60c092a4237/rjsmin-1.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:30625ba457151b52f7a262169187f0bf1def5e25418381282a0891a560afc0e0", size = 33235, upload-time = "2026-10-10T16:33:29.59Z" }, + { url = "https://files.pythonhosted.org/packages/30/81/c1373e2bc61c21957474c13f42776c71c2dbebf06400f9a218c566b52d09/rjsmin-1.3.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:9d08552e90f5f6b7e79838a23190bc89ba6ccbcad74b9cca923bfb4596d5415d", size = 33454, upload-time = "2026-10-10T16:33:30.94Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/c5f46e4cedaf95b414f6701c8cced668aa1328b4f588e27590ad3535ab70/rjsmin-1.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:adccd1027c095ad49408802a77ad030ad567a337d938031c42bbbccce22d93c8", size = 32819, upload-time = "2026-10-10T16:33:32.294Z" }, + { url = "https://files.pythonhosted.org/packages/e1/20/7af2475fa7a6ce3fde9ccdd40ff31b489d633f6b76a87664691a66d14dac/rjsmin-1.3.0-cp315-cp315t-manylinux1_i686.whl", hash = "sha256:a49363b26e4fa35f4a56f1a0102bcb81e0502ad98d0802cc0eabee54c38a5a3a", size = 34172, upload-time = "2026-10-10T16:33:33.634Z" }, + { url = "https://files.pythonhosted.org/packages/c6/79/bbaacb8e52691c2c4eac47cf1e03cd124b28d77328f99d366c282da97396/rjsmin-1.3.0-cp315-cp315t-manylinux1_x86_64.whl", hash = "sha256:9fb12bc2939e2037c4c1fa36dffd46229f0a6c9ca7e5a18e7ff4841bc7f3f47b", size = 33823, upload-time = "2026-10-10T16:33:35.255Z" }, + { url = "https://files.pythonhosted.org/packages/7b/6c/7e3bf4a66bea608b805a6cb80ab497356d38f4929bf28e33b28a0246e910/rjsmin-1.3.0-cp315-cp315t-manylinux2014_aarch64.whl", hash = "sha256:4eaed13693f43b52ced8266923d56c9e03c11fc788a834312ea3b498cc80871c", size = 34647, upload-time = "2026-10-10T16:33:36.652Z" }, + { url = "https://files.pythonhosted.org/packages/37/25/f924b49524e3e2dbd9f577c3eb2a3533862803a15c14bd4fef196f1c3b5a/rjsmin-1.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9dbda7b1423b7e50590dc60aee22bdf14c51b52edc2f23823ced8e7e054a1cd7", size = 34815, upload-time = "2026-10-10T16:33:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/68/43/e06b06b5ada1c62a0527896d43cd7c5b896a5d419f49fb1b4079526c07c5/rjsmin-1.3.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:5e957e788256bd23141786e6646bc2062b7fa78de6f4eb8b155f47a54524c990", size = 34966, upload-time = "2026-10-10T16:33:39.336Z" }, + { url = "https://files.pythonhosted.org/packages/a9/9c/1ecf761d5a9cdf1610d90a9c42710680773788eb5b178196ddaf81fec85b/rjsmin-1.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:bc0d1f930dfb64195394d121a746431674a310a26a3205423b8236a6144192a4", size = 34267, upload-time = "2026-10-10T16:33:40.65Z" }, +] + [[package]] name = "rpds-py" version = "2026.9.1" From 0e8aba5feec7a536b15bbb33827b89505a5e511c Mon Sep 17 00:00:00 2001 From: Brian Pepple Date: Thu, 5 Nov 2026 10:38:27 -0500 Subject: [PATCH 2/7] Configure django-compressor for offline compression Register the compressor app and its static-file finder. Compression stays off whenever DEBUG is on (development and tests). In production it runs offline and writes bundles and the manifest (CACHE/manifest.json) to the Spaces bucket. Set COMPRESS_ROOT explicitly because production has no STATIC_ROOT. Also pin the offline manifest storage name, since django-compressor 4.6 defaults to the misspelled "compressor-offine". --- metron/settings.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/metron/settings.py b/metron/settings.py index 12619734..8a3d07ae 100644 --- a/metron/settings.py +++ b/metron/settings.py @@ -50,6 +50,7 @@ "django.contrib.messages", "django.contrib.postgres", "django.contrib.staticfiles", + "compressor", "django.contrib.sites", "django.contrib.humanize", "django.forms", @@ -358,8 +359,23 @@ "django.contrib.staticfiles.finders.AppDirectoriesFinder", # Now add our custom SimpleBulma one. "django_simple_bulma.finders.SimpleBulmaFinder", + "compressor.finders.CompressorFinder", ] +# django-compressor: bundles and minifies the local CSS/JS inside {% compress %} blocks. +# COMPRESS_ENABLED defaults to `not DEBUG`, so development serves the original files. +# Production compresses offline: run `manage.py compress` after `collectstatic` on deploy. +COMPRESS_OFFLINE = not DEBUG +COMPRESS_FILTERS = { + "css": [ + "compressor.filters.css_default.CssAbsoluteFilter", + "compressor.filters.cssmin.rCSSMinFilter", + ], + "js": ["compressor.filters.jsmin.rJSMinFilter"], +} +# Pin the alias name: django-compressor 4.6 defaults to the misspelled "compressor-offine". +COMPRESS_OFFLINE_MANIFEST_STORAGE_ALIAS = "compressor-offline" + IZAYA_CLASSES = { "input": "input", "chip": "tag is-link is-medium", @@ -433,11 +449,20 @@ STORAGES = { "default": {"BACKEND": "metron.storage_backends.MediaStorage"}, "staticfiles": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}, + # Compressed bundles and the offline manifest live alongside the other static files. + "compressor": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}, + "compressor-offline": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}, } STATICFILES_DIRS = (BASE_DIR / "static",) STATIC_URL = f"{AWS_S3_CUSTOM_DOMAIN}/{AWS_LOCATION}/" + COMPRESS_URL = STATIC_URL + # There is no STATIC_ROOT in production; compressor only reads source files from here + # (falling back to the staticfiles finders) and writes its output to S3. + COMPRESS_ROOT = BASE_DIR / "static" + COMPRESS_OFFLINE_MANIFEST = "CACHE/manifest.json" + else: STATICFILES_DIRS = (BASE_DIR / "static",) STATIC_URL = "/static/" From f58aced646ff96ab4e2a613510c29e786c63c608 Mon Sep 17 00:00:00 2001 From: Brian Pepple Date: Thu, 5 Nov 2026 10:42:46 -0500 Subject: [PATCH 3/7] Add note about django-compressor bug --- metron/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/metron/settings.py b/metron/settings.py index 8a3d07ae..831c2c53 100644 --- a/metron/settings.py +++ b/metron/settings.py @@ -374,6 +374,7 @@ "js": ["compressor.filters.jsmin.rJSMinFilter"], } # Pin the alias name: django-compressor 4.6 defaults to the misspelled "compressor-offine". +# This bug was fixed upstream in #1325, remove once a release includes it COMPRESS_OFFLINE_MANIFEST_STORAGE_ALIAS = "compressor-offline" IZAYA_CLASSES = { From 0038f0de5154de9f104340bab9d5787f910b8b17 Mon Sep 17 00:00:00 2001 From: Brian Pepple Date: Thu, 5 Nov 2026 10:44:57 -0500 Subject: [PATCH 4/7] Bundle local CSS and JS with django-compressor Wrap each template's local stylesheet and script tags in {% compress %} blocks so production serves minified bundles with content-hashed filenames, which also busts the browser cache on each deploy. - base.html: bundle base.css, izaya/layout.css and izaya.css into one stylesheet, and compress izaya.js (it keeps its defer attribute) - Bundle page-level assets on home, timeline, statistics, the issue and attribution forms, and the collection form, detail and stats pages - Load issue-form-usability.js next to bulma-calendar so the two share a bundle; it doesn't depend on the inline form script - collection_stats: bundle Chart.js, the inline dark-mode script and chartkick in their original order {% bulma %}, {% font_awesome %}, CDN scripts and inline scripts that use template variables stay outside the compress blocks. --- comicsdb/templates/comicsdb/home.html | 6 +- comicsdb/templates/comicsdb/issue_form.html | 22 +++-- .../comicsdb/model_with_attribution_form.html | 22 +++-- comicsdb/templates/comicsdb/statistics.html | 8 +- templates/base.html | 14 +-- tests/metron/test_compress.py | 51 +++++++++++ timeline/templates/timeline.html | 6 +- .../user_collection/collection_detail.html | 12 ++- .../user_collection/collection_form.html | 12 ++- .../user_collection/collection_stats.html | 86 ++++++++++--------- 10 files changed, 159 insertions(+), 80 deletions(-) create mode 100644 tests/metron/test_compress.py diff --git a/comicsdb/templates/comicsdb/home.html b/comicsdb/templates/comicsdb/home.html index 572d96e1..8c9b54a3 100644 --- a/comicsdb/templates/comicsdb/home.html +++ b/comicsdb/templates/comicsdb/home.html @@ -1,8 +1,10 @@ {% extends parent_template|default:"base.html" %} -{% load i18n static %} +{% load i18n static compress %} {% load cache %} {% block headcss %} - + {% compress css %} + + {% endcompress %} {% endblock %} {% block sitedesc %} {% trans "Metron is a community-based comic book database with a public REST API. Search and discover comic books with comprehensive metadata." as home_description %} diff --git a/comicsdb/templates/comicsdb/issue_form.html b/comicsdb/templates/comicsdb/issue_form.html index f076ce7d..e648972f 100644 --- a/comicsdb/templates/comicsdb/issue_form.html +++ b/comicsdb/templates/comicsdb/issue_form.html @@ -1,15 +1,17 @@ {% extends parent_template|default:"base.html" %} -{% load i18n static %} +{% load i18n static compress %} {% load bulma_tags %} {% block title %} {% trans "Issue Form" %} {% endblock title %} {% block headcss %} - - {# Sticky bar, section nav, grouped fields #} - + {% compress css %} + + {# Sticky bar, section nav, grouped fields #} + + {% endcompress %} {% endblock %} {% block content %}
@@ -143,7 +145,11 @@

{% trans "Attribution" %}

{% endif %} {% endblock %} {% block js %} - + {% compress js %} + + {# Usability behaviour (section nav active-state); independent of the inline script below #} + + {% endcompress %} - {# Usability behaviour — section nav active-state #} - {% endblock %} diff --git a/comicsdb/templates/comicsdb/model_with_attribution_form.html b/comicsdb/templates/comicsdb/model_with_attribution_form.html index 6da2c30d..51c20e3e 100644 --- a/comicsdb/templates/comicsdb/model_with_attribution_form.html +++ b/comicsdb/templates/comicsdb/model_with_attribution_form.html @@ -1,16 +1,18 @@ {% extends parent_template|default:"base.html" %} -{% load i18n static %} +{% load i18n static compress %} {% load bulma_tags %} {% block title %} {% trans "Form" as default_title %} {{ title|default:default_title }} {% endblock title %} {% block headcss %} - - {# usability styles — shared with issue_form.html #} - + {% compress css %} + + {# usability styles — shared with issue_form.html #} + + {% endcompress %} {% endblock %} {% block content %} {% trans "Form" as default_title %} @@ -53,7 +55,11 @@

{% trans "Attribution" %}

{% endblock %} {% block js %} - + {% compress js %} + + {# Usability behaviour (section nav active-state); independent of the inline script below #} + + {% endcompress %} - {# Tier 3 usability behaviour — shared with issue_form.html (section nav; role chips no-op here) #} - {% endblock %} diff --git a/comicsdb/templates/comicsdb/statistics.html b/comicsdb/templates/comicsdb/statistics.html index a7f573cd..d816c890 100644 --- a/comicsdb/templates/comicsdb/statistics.html +++ b/comicsdb/templates/comicsdb/statistics.html @@ -1,9 +1,11 @@ {% extends parent_template|default:"base.html" %} -{% load i18n static %} +{% load i18n static compress %} {% load humanize %} {% block headscripts %} - - + {% compress js %} + + + {% endcompress %} {% endblock %} {% block title %} {% trans "Statistics - Metron" %} diff --git a/templates/base.html b/templates/base.html index 8e1311c9..dbe21176 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,4 +1,4 @@ -{% load static django_htmx i18n %} +{% load static django_htmx i18n compress %} @@ -34,14 +34,18 @@ {% load django_simple_bulma %} {% bulma %} {% font_awesome %} - - - + {% compress css %} + + + + {% endcompress %} {% block headcss %}{% endblock %} {# Head Scripts #} {% htmx_script %} {# Lookup widgets rely on this; it only adds document listeners, so every page can load it #} - + {% compress js %} + + {% endcompress %} {% block headscripts %}{% endblock %} ', content) + # Bulma itself is intentionally left outside the compress blocks. + assert "bulma" in content + + +def test_assets_are_not_bundled_when_disabled(db, client, settings): + settings.COMPRESS_ENABLED = False + resp = client.get(reverse("home")) + content = resp.content.decode() + assert "site/css/base.css" in content + assert "/CACHE/css/" not in content diff --git a/timeline/templates/timeline.html b/timeline/templates/timeline.html index 80646320..ad3f522e 100644 --- a/timeline/templates/timeline.html +++ b/timeline/templates/timeline.html @@ -1,9 +1,11 @@ {% extends parent_template|default:"base.html" %} -{% load static i18n %} +{% load static i18n compress %} {% block title %}{% trans "Timeline" %}{% endblock %} {% block headcss %} - + {% compress css %} + + {% endcompress %} {% endblock %} {% block content %} diff --git a/user_collection/templates/user_collection/collection_detail.html b/user_collection/templates/user_collection/collection_detail.html index dd165ad2..a9b4fdf7 100644 --- a/user_collection/templates/user_collection/collection_detail.html +++ b/user_collection/templates/user_collection/collection_detail.html @@ -1,10 +1,12 @@ {% extends parent_template|default:"base.html" %} -{% load static i18n %} +{% load static i18n compress %} {% load thumbnail %} {% block title %}{% blocktrans with issue=item.issue %}{{ issue }} - My Collection - Metron{% endblocktrans %}{% endblock %} {% block headcss %} - + {% compress css %} + + {% endcompress %} {% endblock %} {% block content %}
@@ -196,7 +198,9 @@

{% trans "Collection Details" %}

{% endblock %} {% block js %} - + {% compress js %} + + {% endcompress %} + {% compress js %} + + {% endcompress %} - + - + })(); + + + {% endcompress %} {% endblock %} {% block title %}{% trans "Collection Statistics - Metron" %}{% endblock %} {% block content %} From bcf6dcdc410bf2f550cc1a75b129dbf61cf931d6 Mon Sep 17 00:00:00 2001 From: Brian Pepple Date: Thu, 5 Nov 2026 10:46:08 -0500 Subject: [PATCH 5/7] Document running django-compressor's compress step on deploy Production compresses offline. If compress hasn't been run for the current templates, pages fail with OfflineGenerationError, so run it on every deploy. - Fresh install: run compress after collectstatic, then restart metron-web, because each gunicorn worker caches the manifest in memory - Updates: run compress from the newly built image in a one-off container before restarting, so workers never start against a stale manifest; collectstatic moves to the same one-off form - Mention the harmless django-filters crispy_form.html warning --- DEPLOYMENT.md | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index cb3da715..388f62ca 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -411,11 +411,20 @@ systemctl --user start metron-nginx > ```bash > podman exec metron-web python manage.py migrate > podman exec metron-web python manage.py collectstatic --no-input +> podman exec metron-web python manage.py compress --force +> systemctl --user restart metron-web > ``` > > When restoring from an existing droplet the schema and static files are > already in place — `migrate` is a no-op and static files live on -> DigitalOcean Spaces, so `collectstatic` is not needed either. +> DigitalOcean Spaces, so `collectstatic` is not needed either. Run +> `compress` anyway if the bucket has no `static/CACHE/manifest.json` yet. +> +> `compress` builds the minified CSS/JS bundles for every `{% compress %}` +> block in the templates (django-compressor, offline mode) and uploads them +> plus `static/CACHE/manifest.json` to Spaces. Each gunicorn worker caches +> the manifest in memory, so restart `metron-web` after compressing. A page +> whose bundle is missing from the manifest fails with `OfflineGenerationError`. The container services start automatically on boot without needing `systemctl enable` — the `WantedBy=default.target` line in each `.container` @@ -542,13 +551,27 @@ cp ~/metron/.quadlet/* ~/.config/containers/systemd/ systemctl --user daemon-reload podman build -t localhost/metron:latest . + +# Re-run collectstatic only if static assets changed: +# podman run --rm --network host --env-file ~/.config/containers/metron.env \ +# localhost/metron:latest python manage.py collectstatic --no-input + +# Rebuild the CSS/JS bundles and manifest from the *new* image before restarting, +# so workers never start with templates the manifest doesn't know about. +podman run --rm --network host --env-file ~/.config/containers/metron.env \ + localhost/metron:latest python manage.py compress --force + systemctl --user restart metron-web metron-anubis podman exec metron-web python manage.py migrate -# Re-run collectstatic only if static assets changed: -# podman exec metron-web python manage.py collectstatic --no-input ``` +> **Why `compress` every deploy:** production uses django-compressor in offline +> mode. If a template's `{% compress %}` block or any bundled CSS/JS file changes +> and `compress` isn't re-run, those pages fail with `OfflineGenerationError`. +> It is cheap to run, so run it on every deploy. (The "Invalid template +> django_filters/rest_framework/crispy_form.html" warning it prints is harmless.) + --- ## History cleanup From b17c6f756858cd79b23faf7168755f258f0701a4 Mon Sep 17 00:00:00 2001 From: Brian Pepple Date: Thu, 5 Nov 2026 11:15:12 -0500 Subject: [PATCH 6/7] Fix django-compressor production config and review follow-ups - Set COMPRESS_URL to an absolute https:// URL. S3Boto3Storage emits https:// asset URLs, so the scheme-less STATIC_URL made offline compress reject every file. - Give each release its own offline manifest (CACHE/manifest-.json). The value comes from a new Containerfile build arg, so running compress during a deploy no longer changes the manifest that running containers read, and rollbacks find their manifest still in place. Update DEPLOYMENT.md to pass the arg. - Wrap the bulma-calendar tags in issue_filter.html in compress blocks. - Put Chart.js, chartkick and bulma-calendar in their own compress blocks so each library keeps one URL across pages and the browser can reuse its cached copy. - Tests: assert on the actual Bulma stylesheet link, and give compressor an isolated in-memory cache instead of the shared Redis cache. --- Containerfile | 4 + DEPLOYMENT.md | 21 +++-- comicsdb/templates/comicsdb/issue_form.html | 4 + .../comicsdb/model_with_attribution_form.html | 4 + .../comicsdb/partials/issue_filter.html | 10 ++- comicsdb/templates/comicsdb/statistics.html | 2 + metron/settings.py | 9 ++- tests/metron/test_compress.py | 15 ++-- .../user_collection/collection_stats.html | 80 ++++++++++--------- 9 files changed, 93 insertions(+), 56 deletions(-) diff --git a/Containerfile b/Containerfile index 83e951e1..f1a52b01 100644 --- a/Containerfile +++ b/Containerfile @@ -25,6 +25,10 @@ COPY . . ENV PATH="/app/.venv/bin:$PATH" +# Release id (e.g. git short SHA); selects this image's django-compressor offline manifest. +ARG METRON_RELEASE=dev +ENV METRON_RELEASE=$METRON_RELEASE + # Compile translation catalogs into the image. Uses django-admin (not # manage.py) so it doesn't require settings/env vars (DB, SECRET_KEY, etc.) # to be configured at build time. gettext (msgfmt) is only needed here, so diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 388f62ca..3557c51e 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -322,7 +322,8 @@ systemctl --user list-timers certbot-renew.timer ```bash cd ~/metron -podman build -t localhost/metron:latest . +podman build --build-arg METRON_RELEASE=$(git rev-parse --short HEAD) \ + -t localhost/metron:latest . ``` --- @@ -418,11 +419,12 @@ systemctl --user start metron-nginx > When restoring from an existing droplet the schema and static files are > already in place — `migrate` is a no-op and static files live on > DigitalOcean Spaces, so `collectstatic` is not needed either. Run -> `compress` anyway if the bucket has no `static/CACHE/manifest.json` yet. +> `compress` anyway: the image's release manifest +> (`static/CACHE/manifest-.json`) won't be in the bucket yet. > > `compress` builds the minified CSS/JS bundles for every `{% compress %}` > block in the templates (django-compressor, offline mode) and uploads them -> plus `static/CACHE/manifest.json` to Spaces. Each gunicorn worker caches +> plus `static/CACHE/manifest-.json` to Spaces. Each gunicorn worker caches > the manifest in memory, so restart `metron-web` after compressing. A page > whose bundle is missing from the manifest fails with `OfflineGenerationError`. @@ -550,14 +552,16 @@ git pull cp ~/metron/.quadlet/* ~/.config/containers/systemd/ systemctl --user daemon-reload -podman build -t localhost/metron:latest . +podman build --build-arg METRON_RELEASE=$(git rev-parse --short HEAD) \ + -t localhost/metron:latest . # Re-run collectstatic only if static assets changed: # podman run --rm --network host --env-file ~/.config/containers/metron.env \ # localhost/metron:latest python manage.py collectstatic --no-input -# Rebuild the CSS/JS bundles and manifest from the *new* image before restarting, -# so workers never start with templates the manifest doesn't know about. +# Build the CSS/JS bundles and this release's manifest from the *new* image before +# restarting. Each release gets its own manifest-.json, so containers still running +# the previous release keep reading their own manifest. podman run --rm --network host --env-file ~/.config/containers/metron.env \ localhost/metron:latest python manage.py compress --force @@ -569,7 +573,10 @@ podman exec metron-web python manage.py migrate > **Why `compress` every deploy:** production uses django-compressor in offline > mode. If a template's `{% compress %}` block or any bundled CSS/JS file changes > and `compress` isn't re-run, those pages fail with `OfflineGenerationError`. -> It is cheap to run, so run it on every deploy. (The "Invalid template +> It is cheap to run, so run it on every deploy. The manifest name includes the +> `METRON_RELEASE` build arg, so always pass `--build-arg METRON_RELEASE=...` when +> building; rolling back to an earlier image reuses that image's manifest, which +> is still in the bucket. (The "Invalid template > django_filters/rest_framework/crispy_form.html" warning it prints is harmless.) --- diff --git a/comicsdb/templates/comicsdb/issue_form.html b/comicsdb/templates/comicsdb/issue_form.html index e648972f..3aa40b3f 100644 --- a/comicsdb/templates/comicsdb/issue_form.html +++ b/comicsdb/templates/comicsdb/issue_form.html @@ -8,6 +8,8 @@ {% compress css %} + {% endcompress %} + {% compress css %} {# Sticky bar, section nav, grouped fields #} @@ -147,6 +149,8 @@

{% trans "Attribution" %}

{% block js %} {% compress js %} + {% endcompress %} + {% compress js %} {# Usability behaviour (section nav active-state); independent of the inline script below #} {% endcompress %} diff --git a/comicsdb/templates/comicsdb/model_with_attribution_form.html b/comicsdb/templates/comicsdb/model_with_attribution_form.html index 51c20e3e..2fb72425 100644 --- a/comicsdb/templates/comicsdb/model_with_attribution_form.html +++ b/comicsdb/templates/comicsdb/model_with_attribution_form.html @@ -9,6 +9,8 @@ {% compress css %} + {% endcompress %} + {% compress css %} {# usability styles — shared with issue_form.html #} @@ -57,6 +59,8 @@

{% trans "Attribution" %}

{% block js %} {% compress js %} + {% endcompress %} + {% compress js %} {# Usability behaviour (section nav active-state); independent of the inline script below #} {% endcompress %} diff --git a/comicsdb/templates/comicsdb/partials/issue_filter.html b/comicsdb/templates/comicsdb/partials/issue_filter.html index d9dc9ab6..d35d3b2f 100644 --- a/comicsdb/templates/comicsdb/partials/issue_filter.html +++ b/comicsdb/templates/comicsdb/partials/issue_filter.html @@ -1,4 +1,4 @@ -{% load i18n range_tags static %} +{% load i18n range_tags static compress %} {% comment %} USABILITY CHANGE: the ~14 advanced filter fields are now organised under labelled sub-sections (Series / Issue Numbers / Dates / Codes & IDs) @@ -446,8 +446,12 @@

{% trans "Description" %}

- - +{% compress css %} + +{% endcompress %} +{% compress js %} + +{% endcompress %} + {% endcompress %} + {% compress js %} {% endcompress %} {% endblock %} diff --git a/metron/settings.py b/metron/settings.py index 831c2c53..f6b004f8 100644 --- a/metron/settings.py +++ b/metron/settings.py @@ -458,11 +458,16 @@ STATICFILES_DIRS = (BASE_DIR / "static",) STATIC_URL = f"{AWS_S3_CUSTOM_DOMAIN}/{AWS_LOCATION}/" - COMPRESS_URL = STATIC_URL + # S3Boto3Storage.url() emits absolute https:// URLs, and compressor only bundles files + # whose URL starts with COMPRESS_URL, so this must include the scheme (STATIC_URL doesn't). + COMPRESS_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/{AWS_LOCATION}/" # There is no STATIC_ROOT in production; compressor only reads source files from here # (falling back to the staticfiles finders) and writes its output to S3. COMPRESS_ROOT = BASE_DIR / "static" - COMPRESS_OFFLINE_MANIFEST = "CACHE/manifest.json" + # One manifest per release (METRON_RELEASE is baked into the image at build time), so + # running `compress` for a new release never changes the manifest the old containers read, + # and rolling back to an earlier image finds that image's manifest still in place. + COMPRESS_OFFLINE_MANIFEST = f"CACHE/manifest-{config('METRON_RELEASE', default='dev')}.json" else: STATICFILES_DIRS = (BASE_DIR / "static",) diff --git a/tests/metron/test_compress.py b/tests/metron/test_compress.py index 1d3adc83..42109d94 100644 --- a/tests/metron/test_compress.py +++ b/tests/metron/test_compress.py @@ -1,21 +1,21 @@ """Smoke tests for the django-compressor integration. Compression is disabled under DEBUG (development and tests), so these tests switch it on -with online compression and send the generated bundles to a temporary directory. -Note that compressor caches rendered tag output in the default (Redis) cache, so a run may -reuse output from a previous run instead of writing new bundles. +with online compression, send the generated bundles to a temporary directory, and give +compressor a fresh in-memory cache so rendered output is never reused from an earlier run. """ import re import pytest +from django.core.cache.backends.locmem import LocMemCache from django.urls import reverse HTML_OK_CODE = 200 @pytest.fixture -def compress_enabled(settings, tmp_path): +def compress_enabled(settings, tmp_path, monkeypatch): settings.COMPRESS_ENABLED = True settings.COMPRESS_OFFLINE = False settings.STORAGES = { @@ -25,6 +25,11 @@ def compress_enabled(settings, tmp_path): "OPTIONS": {"location": str(tmp_path), "base_url": "/static/"}, }, } + # Compressor resolves its cache lazily once per process, so patch the object itself + # rather than COMPRESS_CACHE_BACKEND. + cache = LocMemCache(f"compress-test-{tmp_path.name}", {}) + monkeypatch.setattr("compressor.cache.cache", cache) + monkeypatch.setattr("compressor.filters.base.cache", cache) return tmp_path @@ -40,7 +45,7 @@ def test_base_template_assets_are_bundled(db, client, compress_enabled): # The izaya bundle keeps its defer attribute. assert re.search(r'', content) # Bulma itself is intentionally left outside the compress blocks. - assert "bulma" in content + assert '' in content def test_assets_are_not_bundled_when_disabled(db, client, settings): diff --git a/user_collection/templates/user_collection/collection_stats.html b/user_collection/templates/user_collection/collection_stats.html index 2322f4d4..f91a3448 100644 --- a/user_collection/templates/user_collection/collection_stats.html +++ b/user_collection/templates/user_collection/collection_stats.html @@ -3,50 +3,52 @@ {% block headscripts %} {% compress js %} - + }); + })(); + + {% compress js %} {% endcompress %} {% endblock %} From 7952f179f671224f04f13b884580a4e29aa50a8c Mon Sep 17 00:00:00 2001 From: Brian Pepple Date: Fri, 6 Nov 2026 16:57:59 -0500 Subject: [PATCH 7/7] Document PostgreSQL minor and major version upgrades Add an "Upgrading PostgreSQL" section to DEPLOYMENT.md covering routine minor updates and a 16 -> 18 dump/restore procedure. The major upgrade steps cover two things that a plain restore gets wrong: - The postgres:18 image moved PGDATA to /var/lib/postgresql/18/docker, so the volume must be mounted at /var/lib/postgresql. - pg_dump doesn't save the ALTER FUNCTION unaccent(text) IMMUTABLE change from migration 0050, because unaccent(text) belongs to an extension. Without redoing it before the restore, the *_name_unaccent_trgm_idx indexes fail to build and pg_restore keeps going anyway. --- DEPLOYMENT.md | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 3557c51e..a707ede2 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -38,6 +38,9 @@ with Quadlet (systemd-managed containers). - [Manual backup](#manual-backup) - [Automated backups with a systemd timer](#automated-backups-with-a-systemd-timer) - [Restoring from a backup](#restoring-from-a-backup) +- [Upgrading PostgreSQL](#upgrading-postgresql) + - [Minor version updates](#minor-version-updates) + - [Major version upgrade (16 → 18)](#major-version-upgrade-16--18) - [Enable persistent journals](#enable-persistent-journals) - [Fail2ban](#fail2ban) - [Install](#install) @@ -825,6 +828,171 @@ systemctl --user start metron-web --- +## Upgrading PostgreSQL + +### Minor version updates + +Minor releases (e.g. 16.4 → 16.10) never change the on-disk format, so updating +is just a pull and restart: + +```bash +podman pull docker.io/library/postgres:16 +systemctl --user restart metron-postgres +podman exec metron-postgres postgres --version +``` + +### Major version upgrade (16 → 18) + +A new major version uses a different data-directory format, and `postgres:18` +will not start on a volume initialized by 16, so changing the `Image=` tag alone +does not work. The procedure below dumps the database, starts 18 on a **new** +volume, and restores into it. The old volume is left in place as the rollback. +Restoring from a dump also rebuilds every index, which avoids the collation +problems that can come with a newer glibc in the new image. + +Two things are different from a normal restore: + +1. **The 18 image moved its data directory.** `PGDATA` is now + `/var/lib/postgresql/18/docker`, and the volume must be mounted at + `/var/lib/postgresql`, not `/var/lib/postgresql/data`. +2. **The `unaccent` change must be redone before restoring.** Migration + `0050` runs `ALTER FUNCTION unaccent(text) IMMUTABLE`. `unaccent(text)` + belongs to the `unaccent` extension, and `pg_dump` does not save changes + made to extension members. A fresh database therefore gets the stock + STABLE `unaccent()`, and the five `*_name_unaccent_trgm_idx` indexes fail + with *"functions in index expression must be marked IMMUTABLE"*. `pg_restore` + logs the error and keeps going, so the site comes up with slow name + searches and nothing obviously broken. Step 6 below handles this. + +Expect downtime from step 2 until step 9. Rehearse the restore locally against +a production dump first: it shows how long the restore takes and catches problems +before you're working on production. + +**1. Record a baseline** to compare against after the restore: + +```bash +podman exec metron-postgres psql -U {db_username} -d metron -Atc \ + "SELECT count(*) FROM pg_indexes WHERE schemaname = 'public';" +podman exec metron-postgres psql -U {db_username} -d metron -Atc \ + "SELECT count(*) FROM comicsdb_issue;" +``` + +**2. Stop everything that writes to the database**, including the timers: + +```bash +systemctl --user stop metron-nginx metron-anubis metron-web +systemctl --user stop metron-backup.timer metron-history-cleanup.timer \ + metron-opencollective-sync.timer +``` + +**3. Take the final dump.** Run the backup script first so there is also an +off-site copy in Spaces: + +```bash +bash ~/metron/scripts/backup.sh +podman exec metron-postgres pg_dump -U {db_username} -Fc metron -f /tmp/final.dump +podman cp metron-postgres:/tmp/final.dump ~/backups/metron-pg16-final.dump +``` + +**4. Stop Postgres 16.** Do not remove its volume (`systemd-metron-postgres`): +it is the rollback. + +```bash +systemctl --user stop metron-postgres +``` + +**5. Point the quadlet at 18 and a new volume.** Create the volume unit: + +```bash +printf '[Volume]\n' > ~/.config/containers/systemd/metron-postgres18.volume +``` + +Then edit `~/.config/containers/systemd/metron-postgres.container` and change +these two lines: + +```ini +Image=docker.io/library/postgres:18 +Volume=metron-postgres18.volume:/var/lib/postgresql +``` + +Make the same change in the repo's `.quadlet/` files, but merge it only when you +do the upgrade. Otherwise the next routine deploy (`cp ~/metron/.quadlet/* ...`) +would switch Postgres over by accident. + +```bash +systemctl --user daemon-reload +systemctl --user start metron-postgres # initializes the empty volume +podman exec metron-postgres postgres --version +``` + +**6. Prepare the empty database before restoring.** The dump uses +`CREATE EXTENSION IF NOT EXISTS`, so creating the extensions first doesn't +conflict with the restore: + +```bash +podman exec metron-postgres psql -U {db_username} -d metron -c " + CREATE EXTENSION IF NOT EXISTS unaccent; + CREATE EXTENSION IF NOT EXISTS pg_trgm; + ALTER FUNCTION unaccent(text) IMMUTABLE;" +``` + +**7. Restore, then rebuild planner statistics.** A dump does not carry +statistics, and queries will be slow until `ANALYZE` has run: + +```bash +podman cp ~/backups/metron-pg16-final.dump metron-postgres:/tmp/final.dump +podman exec metron-postgres pg_restore \ + --username {db_username} \ + --dbname metron \ + --no-owner \ + --role {db_username} \ + /tmp/final.dump 2>&1 | tee ~/pg18-restore.log + +grep -i error ~/pg18-restore.log # should print nothing +podman exec metron-postgres vacuumdb -U {db_username} -d metron --analyze-in-stages +podman exec metron-postgres rm /tmp/final.dump +``` + +**8. Verify** that both counts match the baseline from step 1, and that the +`unaccent` indexes exist: + +```bash +podman exec metron-postgres psql -U {db_username} -d metron -Atc \ + "SELECT count(*) FROM pg_indexes WHERE schemaname = 'public';" +podman exec metron-postgres psql -U {db_username} -d metron -Atc \ + "SELECT count(*) FROM comicsdb_issue;" +podman exec metron-postgres psql -U {db_username} -d metron -Atc \ + "SELECT indexname FROM pg_indexes WHERE indexname LIKE '%unaccent_trgm_idx';" +``` + +**9. Bring the site back** and restart the timers: + +```bash +systemctl --user start metron-web metron-anubis metron-nginx +systemctl --user start metron-backup.timer metron-history-cleanup.timer \ + metron-opencollective-sync.timer +``` + +Check that the site loads, that a name search with an accented name works +(e.g. "Pokémon"), and that `journalctl --user -u metron-web -n 50` is free of +database errors. Run the backup service once (`systemctl --user start +metron-backup`) to confirm backups work against 18. + +**Rollback:** stop `metron-postgres`, put the old `Image=` and `Volume=` lines +back (`postgres:16`, `metron-postgres.volume:/var/lib/postgresql/data`), run +`systemctl --user daemon-reload`, and start it again. Anything written after the +cutover is lost, so decide early. + +**Cleanup:** after a week or two without problems, remove the old volume and +image: + +```bash +podman volume rm systemd-metron-postgres +podman rmi docker.io/library/postgres:16 +``` + +--- + ## Enable persistent journals On CentOS, journal logs are not persisted across reboots by default. Run this