Matthias Kestenholz: Posts about Djangohttps://406.ch/writing/category-django/2026-09-30T12:00:00ZMatthias KestenholzWeeknotes (2026 week 40)https://406.ch/writing/weeknotes-2026-week-40/2026-09-30T12:00:00Z2026-09-30T12:00:00Z<h1 id="weeknotes-2026-week-40"><a class="toclink" href="#weeknotes-2026-week-40">Weeknotes (2026 week 40)</a></h1>
<p>I have been at <a href="https://djangomed.eu/">Django on the Med 🏖️</a> and already wrote a <a href="https://406.ch/writing/looking-back-at-django-on-the-med-2026/">lengthy post</a> about that.</p>
<p>I did a lot of work on a <a href="https://github.com/django/deps/pull/101">DEP for adding import map support to Django</a> which is currently also being discussed on the <a href="https://forum.djangoproject.com/t/integrate-importmap/35243">forum</a>. Apart from that I’m not going to repeat anything from the post linked above, so check it out if you want to know more.</p>
<p>Motivated by a discussion I had at the sprint I also improved my release process. I now have a <code>make-release</code> script in my dotfiles which updates the CHANGELOG with the version, bumps the version itself in the repo and commits and tags the release. The rest is handled by <a href="https://406.ch/writing/switching-all-of-my-python-packages-to-pypi-trusted-publishing/">trusted publishing</a>. I’m now finally also properly handling patch releases so that you don’t have to check the history to know what’s in a patch release. I already did that for minor and major version bumps, but was a bit too lazy. Now I can be even lazier and still more correct, and it feels great.</p>
<p>Next, I refactored the static site generator script for this blog to be much faster. I now do not have to wait when saving before refreshing the browser. Much nicer.</p>
<h2 id="releases-from-the-last-three-weeks"><a class="toclink" href="#releases-from-the-last-three-weeks">Releases from the last three weeks</a></h2>
<h3 id="django-js-asset"><a class="toclink" href="#django-js-asset">django-js-asset</a></h3>
<p><a href="https://pypi.org/project/django-js-asset/">django-js-asset 5.0a1</a> implements the API proposed in the <a href="https://github.com/django/deps/pull/101">DEP</a>. This is an alpha release because the DEP is still being discussed and I don’t want to break people’s code again and again if, during discussion, it appears that the API should be different.</p>
<h3 id="django-json-schema-editor-django-prose-editor-and-django-content-editor"><a class="toclink" href="#django-json-schema-editor-django-prose-editor-and-django-content-editor">django-json-schema-editor, django-prose-editor and django-content-editor</a></h3>
<p>The three alpha releases <a href="https://pypi.org/project/django-json-schema-editor/">django-json-schema-editor 0.15a1</a>, <a href="https://pypi.org/project/django-prose-editor/">django-prose-editor 0.28a2</a> and <a href="https://pypi.org/project/django-content-editor/">django-content-editor 9.1a1</a> depend on django-js-asset 5.0a1 mentioned above and implement the necessary changes for the new import map definition style.</p>
<h3 id="django-authlib"><a class="toclink" href="#django-authlib">django-authlib</a></h3>
<p><a href="https://pypi.org/project/django-authlib/">django-authlib 0.20</a> adds support for specifying the tenant when using Microsoft Entra ID.</p>
<h3 id="feincms3-downloads"><a class="toclink" href="#feincms3-downloads">feincms3-downloads</a></h3>
<p><a href="https://pypi.org/project/feincms3-downloads/">feincms3-downloads 0.6</a> includes translation fixes, uses different error codes when <code>pdftocairo</code> or <code>convert</code> are missing, and changed the <code>PATH</code> environment variable handling to be less annoying for local development.</p>
Looking back at Django on the Med 🏖️ 2026https://406.ch/writing/looking-back-at-django-on-the-med-2026/2026-09-28T12:00:00Z2026-09-28T12:00:00Z<h1 id="looking-back-at-django-on-the-med-2026"><a class="toclink" href="#looking-back-at-django-on-the-med-2026">Looking back at Django on the Med 🏖️ 2026</a></h1>
<p>I haven’t been to a programming conference in a really long time. That was
mostly due to laziness, wanting to stay at home and decision fatigue because I
didn’t know how to travel sustainably and didn’t know where to stay during the
conference.</p>
<p>I had been talking online to Carlton for some time and when Django on the Med
🏖️ 2026 was announced I knew I had to go. What’s not to like about a
conference in Italy with all the good food and the Mediterranean Sea? I managed
to overcome my inner Schweinehund (the German term for the lazy voice in
your head that tells you to stay on the couch) and reserved both the (free) ticket
for the conference itself and also the train ticket to go from Zurich to
Pescara. The train takes 8 or 9 hours depending on the connection with a single
change in Milano.</p>
<p>The conference itself consists only of development sprints and socialising – no talks and nothing to prepare in advance for participants, except taking the computer with you and optionally having some ideas about what you want to work on.</p>
<h2 id="the-import-map-django-enhancement-proposal-dep-i-worked-on"><a class="toclink" href="#the-import-map-django-enhancement-proposal-dep-i-worked-on">The import map Django Enhancement Proposal (DEP) I worked on</a></h2>
<p>The forum discussion about <a href="https://forum.djangoproject.com/t/rejuvenating-vs-deprecating-form-media/21285">rejuvenating Django’s <code>forms.Media</code></a> sparked my interest in reviving Thibaud’s DEP draft in early 2025. Some features which were mentioned in the early draft, such as a <code>Stylesheet</code> object for including additional stylesheet attributes in <code>class Media</code> and CSP support, have been added to Django 6.1 in the meantime. My main motivation was and still is to bring import map support to Django. I changed <a href="https://pypi.org/project/django-prose-editor/">django-prose-editor</a> to use import maps back then and wrote a first draft but then got stuck while trying to write a good DEP.</p>
<p>For those who don’t know import maps: ES modules import each other by URL. When static file storages add hashes to file names for cache busting, those URLs change on every deployment. Import maps solve this using a web standard: Modules import stable identifiers such as <code>my-library</code>, and the import map tells the browser which file to actually load.</p>
<p>If all browsers were to support multiple import maps, the DEP would maybe not be necessary. People could just ship an <code>ImportMap</code> media object and include it in <code>forms.Media(js=[...])</code> before the ES modules actually using it and things would just work. At the time of writing this post Chromium and Safari support multiple import maps but Firefox still doesn’t. So, if we want to use this feature without having import map merging we will have to wait several years for browser support to be widespread enough. And since third-party Django apps and Django websites which want to use import maps have to agree on a common implementation it makes most sense to me to propose adding this to Django core. The proposal lives in the <a href="https://github.com/django/deps/pull/101">DEP pull request</a> and the accompanying <a href="https://github.com/django/new-features/issues/214">new feature ticket</a>.</p>
<p>As an aside: Django has been famous for not having a frontend story for the longest time. I think this is mostly a strength, because Django has therefore allowed everyone to use the frontend technologies they want and hasn’t decided on a particular technology, library or framework which, in the meantime, would have become obsolete or not really state of the art. ES modules and import maps aren’t opinionated in the same way that, for example, jQuery, htmx, React or Svelte are; they really are a basic implementation of modules, namespaces and a specification of how those modules should be loaded in the browser. So, I don’t think it would be fair to reject adding better support for these things on the grounds that Django wants to be agnostic to the frontend. I’m not saying here that the DEP has to be accepted or that there cannot be good reasons to reject or modify it further – I’m sure there are. I’m just proposing that we cannot just use the old arguments to argue against it.</p>
<h2 id="the-diary"><a class="toclink" href="#the-diary">The diary</a></h2>
<p>Enough about the DEP. Now I am going to recap the last few days. I’m not adding any pictures to this post, but you can head over to Mastodon and check out the #DjangoOnTheMed tag, for example <a href="https://hachyderm.io/tags/DjangoOnTheMed">here on hachyderm.io</a>.</p>
<h3 id="tuesday"><a class="toclink" href="#tuesday">Tuesday</a></h3>
<p>On Tuesday morning I left home to take the train towards Pescara. I was really glad I packed my headphones with noise cancelation. After about 9 hours I arrived in the late afternoon, checked into the hotel, took my swimming trunks and immediately went to the beach.</p>
<p>In the evening we had dinner at the seaside at Lido Aurora Pescara. It was a pleasure to finally meet some of the people I have been either working with or just following for a long time. A special surprise was meeting Simon again. We met at Django Under the Hood in Amsterdam in 2016 and he helped get my first pull request<sup id="fnref:pr"><a class="footnote-ref" href="#fn:pr">1</a></sup> to Django off the ground back then.</p>
<p>During the course of the conference we went back there several times. Good food, and luckily not just options with fish and seafood, but also good vegetarian options. (I’m not strictly vegetarian, but I sometimes prefer vegetarian or even vegan food.)</p>
<p>After a long day I slept surprisingly well. That’s not saying much, but it certainly was a welcome surprise given my issues with my back and hips.</p>
<h3 id="wednesday"><a class="toclink" href="#wednesday">Wednesday</a></h3>
<p>The sprint officially started on Wednesday with a warm-up session. The three questions asked (paraphrased because I don’t remember the exact wording) were “What is great about Django?”, “What are the risks, or what could be better?” and “What are you planning to work on?”</p>
<p>I discussed ways of adding import maps with Joe, the author of <a href="https://github.com/codingjoe/django-esm">django-esm</a> and <a href="https://github.com/codingjoe/esimport">esimport</a>. I thought we had completely different and conflicting ways of thinking about and using import maps. After talking it over it became clear quite quickly that, while we don’t have to use them the same way, our ways of using them do not have to be in conflict. I think this is one of the big advantages of events like this: The same discussion would have taken weeks or months in an issue tracker, if it ever happened, and in person it was a question of sitting together for an hour, hashing it out, and then you potentially have a basic agreement and an idea for which direction to go.</p>
<p>As already alluded to above I restarted my work on the DEP. It basically needed a complete rewrite since (excitingly!) so much has already landed in Django in the last ~18 months.</p>
<p>We went back to Lido Aurora for lunch. In the afternoon, almost everyone went for a bike ride along an old railway track which has been converted into a bike lane along the sea. I already knew a similar thing from Liguria near Levanto/Bonassola but it was nice encountering the same idea near Pescara. (The new railway track has been built further inland.) We also had some nice Spritz abruzzese. Generally, Spritz isn’t really my thing but those were a bit more bitter and earthy and less sweet and therefore we soon ordered a second round.</p>
<p>In the evening a small group went to Flower Burger for some vegan food. As expected it was really tasty. While vegan options in restaurants can unfortunately sometimes be quite bland, vegan restaurants in my experience are often some of the best: You really have to know your ingredients and can’t just add bacon to everything. (Nothing against bacon, but still.)</p>
<h3 id="thursday"><a class="toclink" href="#thursday">Thursday</a></h3>
<p>Again I started the day with a great coffee and an overly sweet breakfast (for my taste). Italian food is generally great but the breakfast isn’t my thing. I like my müesli and whole grain bread. Anyway!</p>
<p>I continued working on the DEP and started tweaking <a href="https://github.com/feincms/django-js-asset">django-js-asset</a> to serve as a proving ground for the ideas. Towards the end of Thursday’s sprint I had a first rough draft ready and Joe provided some great feedback on it. One of the most important points for me was to care about the API and not the implementation at this stage in the process. The DEP draft contained too much detail related to implementation and not enough examples showing off more complex use cases.</p>
<p>I only ate a small lunch since we were promised a bus tour with a lot of great food in the afternoon and evening. It still proved to be too much as we would learn later.</p>
<p>In the afternoon we took a bus to a local vineyard. We were shown around and had a look at the machines and learned a few things about the winemaking process. That was followed by a wine tasting and some bread and antipasti.</p>
<p>Next, we went to Penne and were shown the city and were told a little bit of the local history. Everything’s built from bricks. Brick buildings and earthquakes are not a great fit it seems to me – the last larger earthquake hit the region less than 10 years ago. It’s interesting that people are always rebuilding the houses anyway. From Penne we had a really nice view over the region, from the hill we were standing on all the way to Pescara and to the Adriatic.</p>
<p>We took the bus again to the restaurant and ate until 11 in the evening. At a certain point I couldn’t go on anymore. No matter how great the food, there comes a moment when eating more seems impossible, and in my case that moment came before i secondi. The secondi were arrosticini. I could only eat one so now I’m wondering if I am a persona non grata in Pescara 🤣. Also, I was in pain from too much sitting – sitting isn’t good since I’m still recovering from a herniated disc and the associated follow-up issues in the hip. Still, I had a great day and wouldn’t have wanted to miss any of it.</p>
<p>Back in the hotel I couldn’t sleep immediately so I addressed some of the feedback I got earlier in the day and then went to sleep.</p>
<h3 id="friday"><a class="toclink" href="#friday">Friday</a></h3>
<p>The night wasn’t very restorative but I was awake anyway in the morning so I got up and started the day again on time. I finished applying the feedback to the DEP and to django-js-asset and continued refining them and filling in holes. I also modified some of the packages I’m developing to use the new way of defining media using import maps just to get a feeling for whether the API is nice or not.</p>
<p>During the coffee break I asked around if the changes to the way import maps are defined would break existing code. Luckily it seems that django-prose-editor is “just used” and people weren’t relying on being able to define import maps themselves yet. Or, if so, I don’t know about it. Maybe <a href="https://djangoprobe.org/">django-probe</a> could help with that, but we obviously aren’t there yet.</p>
<p>We finished the sprint with a group picture and closed off the official part. Most of us went out for lunch together. Some people had to leave after that; I drank an espresso in a nice coffee bar, said goodbye to them and went to the beach afterwards.</p>
<p>In the evening we went to the excellent Sesamo Nero restaurant. I was happy that I wasn’t the only one who was surprised to learn that the restaurant wasn’t that close after all and arrived a little bit later than expected.</p>
<h3 id="saturday"><a class="toclink" href="#saturday">Saturday</a></h3>
<p>Almost everybody had either left or had to leave on Saturday morning so I didn’t expect to meet with others anymore. I finally went for some long walks, explored other parts of the town and went to see the long bridge/walkway/passeggiata/whatever and some parks. I also took some time to note down both what we did and what I thought of finally going back to a conference.</p>
<p>In the evening I ran into Mark and Becky twice and we decided to have a beer together before saying goodbye again and for the final time for this sprint.</p>
<p>Having an additional day there was a win. I had less time during the sprints to visit the city itself and really did appreciate the extra day I allowed myself.</p>
<h3 id="sunday"><a class="toclink" href="#sunday">Sunday</a></h3>
<p>I ate breakfast, went for a short walk around the city and to the beach and then took the train back home. Up to Milano it was uneventful. The train from Milano to Zurich was cancelled, but I didn’t lose more than half an hour in the end.</p>
<h2 id="finally"><a class="toclink" href="#finally">Finally</a></h2>
<p>I’m wondering about the DEP process now and what happens with the pull request I submitted. I’m linking to the <a href="https://github.com/django/new-features/issues/214">new feature ticket</a>, the <a href="https://github.com/django/deps/pull/101">DEP pull request</a> and what I consider to be the <a href="https://github.com/feincms/django-js-asset">proving ground</a> again. Because django-js-asset has to stay backwards compatible, an implementation in Django itself could be quite a bit simpler.</p>
<p>I will be in Liguria, Italy again in just one week. Feels a bit stupid to cross the Alps only to cross them again a few days later, but I’m really looking forward to sleeping in my own bed for a few days.</p>
<p>I can very well imagine going to more conferences and sprints again. The value of meeting in person is unquestionable. It might be hard though to improve on the experience we had here with the location, the nice weather and the social events. The selection of events is large and I certainly won’t be going to all of them, but I am looking at going to the next Django on the Med in Malta, and maybe also to PyCon Italia and/or DjangoCon EU. We will see!</p>
<div class="footnote"><hr><ol><li id="fn:pr"><p>I had been contributing bug reports, tests and help much earlier than that but I was somewhat intimidated by Django’s processes. <a class="footnote-backref" href="#fnref:pr" title="Jump back to footnote 1 in the text">↩</a></p></li></ol></div>Weeknotes (2026 week 37)https://406.ch/writing/weeknotes-2026-week-37/2026-09-09T12:00:00Z2026-09-09T12:00:00Z<h1 id="weeknotes-2026-week-37"><a class="toclink" href="#weeknotes-2026-week-37">Weeknotes (2026 week 37)</a></h1>
<p>Tonight the temperature will drop to 10°C. I like it when it’s hot, but now I really enjoy the slightly cooler temperatures in Europe. The <a href="/writing/weeknotes-2026-week-33/#holidays-and-the-heat-wave">last post</a> was written in the middle of the heat wave. Yesterday we still had temperatures of over 30°C, which is a lot for September. But it will be the new normal. <a href="https://en.wikipedia.org/wiki/El_Ni%C3%B1o%E2%80%93Southern_Oscillation">Next year probably won’t be cooler</a>.</p>
<h2 id="releases-from-the-last-four-weeks"><a class="toclink" href="#releases-from-the-last-four-weeks">Releases from the last four weeks</a></h2>
<p>Lots of activity!</p>
<h3 id="django-authlib"><a class="toclink" href="#django-authlib">django-authlib</a></h3>
<p><a href="https://pypi.org/project/django-authlib/">django-authlib 0.19</a> hardens the OAuth2 implementation a bit against replay attacks, provides utilities for removing password logins from the Django administration interface, and removes some of the confusion around role-based permissions. Upgrading is recommended.</p>
<h3 id="django-content-editor"><a class="toclink" href="#django-content-editor">django-content-editor</a></h3>
<p><a href="https://pypi.org/project/django-content-editor/">django-content-editor 9.0.2</a> fixes a bug in the function which allows cloning plugins from other regions. The bug was most visible when using something like <a href="https://django-json-schema-editor.readthedocs.io/">django-json-schema-editor</a>’s plugins which are only <a href="https://docs.djangoproject.com/en/6.1/topics/db/models/#proxy-models">proxy models</a>. Their type was lost because I was using <code>_base_manager</code>, which didn’t do the downcasting properly. (Sorry for the word salad.)</p>
<h3 id="django-tree-queries"><a class="toclink" href="#django-tree-queries">django-tree-queries</a></h3>
<p><a href="https://pypi.org/project/django-tree-queries/">django-tree-queries 0.26.1</a> now uses annotations instead of <code>.extra()</code> to add the <code>tree_path</code> and <code>tree_depth</code> fields. This is great because the ORM knows these fields properly and we can now use e.g. <code>.filter(tree_depth=1)</code> instead of <code>.extra(where=["..."])</code>.</p>
<p>This change was mainly motivated by the renewed interest in deprecating <code>.extra()</code> at some unknown future point in time.</p>
<h3 id="django-debug-toolbar"><a class="toclink" href="#django-debug-toolbar">django-debug-toolbar</a></h3>
<p><a href="https://pypi.org/project/django-debug-toolbar/">django-debug-toolbar 8.0</a> ships a new design! I again didn’t contribute a lot, but I’m very proud that we were able to ship this as a team.</p>
<h3 id="feincms3"><a class="toclink" href="#feincms3">feincms3</a></h3>
<p><a href="https://pypi.org/project/feincms3/">feincms3 6.0.2</a> ships new utilities and, most importantly, fixes crashes when passing <code>%00</code> to the root middleware. Using null bytes when comparing text field values on PostgreSQL leads to crashes. These are mostly an annoyance without data exposure, but definitely worth fixing.</p>
<p>The reason for the major version bump is that we finally dropped support for Django 3.2. That’s the only breaking change, so upgrading should be easy for everyone.</p>
<h3 id="django-js-asset"><a class="toclink" href="#django-js-asset">django-js-asset</a></h3>
<p><a href="https://pypi.org/project/django-js-asset/">django-js-asset 4.1</a> fixes issues around lazy CSP nonce handling as well as the same issue Django itself had around <a href="https://code.djangoproject.com/ticket/37262">rendering HTML-safe strings</a>.</p>
<h3 id="django-prose-editor"><a class="toclink" href="#django-prose-editor">django-prose-editor</a></h3>
<p><a href="https://pypi.org/project/django-prose-editor/">django-prose-editor 0.27.2</a> adds a default menu item for the code block extension and updates ProseMirror and Tiptap. It also adapts to the changed CSS of the Django 6.1 administration interface.</p>
<h3 id="django-json-schema-editor"><a class="toclink" href="#django-json-schema-editor">django-json-schema-editor</a></h3>
<p><a href="https://pypi.org/project/django-json-schema-editor/">django-json-schema-editor 0.14.1</a> includes a fix for saving JSON schema fields when the schema doesn’t contain any properties, plus fixes for the Django 6.1 admin CSS.</p>
<h3 id="django-admin-ordering"><a class="toclink" href="#django-admin-ordering">django-admin-ordering</a></h3>
<p><a href="https://pypi.org/project/django-admin-ordering/">django-admin-ordering 0.21</a> now activates orderables properly when the same model was orderable both in the change list and in the change form. I developed the fix for this in early 2025 but never released it. It’s what happens.</p>
<h3 id="feincms3-cookiecontrol"><a class="toclink" href="#feincms3-cookiecontrol">feincms3-cookiecontrol</a></h3>
<p><a href="https://pypi.org/project/feincms3-cookiecontrol/">feincms3-cookiecontrol 1.7.2</a> includes bugfixes for edge cases while bringing down the byte count for the cookie banner and media embedding script from 3961 (in 1.7.1) to 3909 bytes.</p>
<h3 id="feincms3-data"><a class="toclink" href="#feincms3-data">feincms3-data</a></h3>
<p><a href="https://pypi.org/project/feincms3-data/">feincms3-data 0.11.1</a> brings fixes for handling unique fields when the same unique value is removed and then re-added with a different primary key.</p>
<h3 id="django-translated-fields"><a class="toclink" href="#django-translated-fields">django-translated-fields</a></h3>
<p><a href="https://pypi.org/project/django-translated-fields/">django-translated-fields 0.14</a> contains no relevant code changes to the module itself at all. It was just time to release a new version more than two years after the last release to show that the project is still active and update the Trove classifiers.</p>
Weeknotes (2026 week 33)https://406.ch/writing/weeknotes-2026-week-33/2026-08-12T12:00:00Z2026-08-12T12:00:00Z<h1 id="weeknotes-2026-week-33"><a class="toclink" href="#weeknotes-2026-week-33">Weeknotes (2026 week 33)</a></h1>
<h2 id="holidays-and-the-heat-wave"><a class="toclink" href="#holidays-and-the-heat-wave">Holidays and the heat wave</a></h2>
<p>I had four weeks of holidays this summer. The timing couldn’t have been much better with the heat wave – doing much thinking seems to be impossible anyway. I organized a multi-day feast with a few friends and with much help from others. We built up the site and installations over the course of multiple days and spent some days tearing most of it down afterwards. I started back to the office job physically tired but mentally rested. That’s good. I’m really looking forward to seeing the pictures people took.</p>
<p>What’s less good is that we’re living through the <a href="https://www.ipcc.ch/report/ar1/wg1/">projections</a> which climate scientists warned us about decades ago. Or worse, even, since Switzerland is one of the regions where the temperature increased more than the global average. The member of Switzerland’s <a href="https://www.admin.ch/en/the-federal-council">Federal Council</a> heading the Federal Department of the Environment, Transport, Energy and Communications reportedly said that he didn’t <a href="https://www.tagesanzeiger.ch/albert-roesti-diese-hitze-haette-ich-nicht-erwartet-276779425825">expect such intense heat</a>. Of course, it was reported earlier in the same week that the same member was responsible for <a href="https://www.srf.ch/news/schweiz/hitzeresistente-baeume-bundesrat-roesti-streicht-mittel-fuer-klimatauglichen-wald">removing funding for a more resilient forest</a> from the budget for the next fiscal year. This is unfortunately to be expected: he has long been connected to the fossil energy industry. After all, he was also the president of <a href="https://www.watson.ch/schweiz/klima/206672787-albert-roesti-laesst-swissoil-mandat-von-webseite-verschwinden">Swissoil</a> and <a href="https://de.wikipedia.org/wiki/Albert_R%C3%B6sti">Auto Schweiz</a>. It’s really frustrating. None of this is news to climate scientists, and it hasn’t been news <a href="/writing/category-climate/">here</a> either – these posts start in 2005, back when I was studying environmental sciences at ETH with a focus on atmospheric physics.</p>
<h2 id="scripts-for-auto-merging-dependabot-and-pre-commit-pull-requests"><a class="toclink" href="#scripts-for-auto-merging-dependabot-and-pre-commit-pull-requests">Scripts for auto-merging dependabot and pre-commit pull requests</a></h2>
<p>I let Claude write some scripts for automatically merging pull requests created by various bots, <a href="https://github.com/feincms/.github">see here</a>. The script finds pull requests created by a predefined list of bots in a defined list of accounts (organizations or users) and squash-merges them if the CI run is green and there are no conflicts. It’s a dry run by default; <code>--apply</code> is required to actually merge anything. It doesn’t look at reviews and doesn’t care whether a bump is major or minor – I’m relying on the test suites for that.</p>
<p>The <a href="https://astral.sh/blog/ruff-v0.16.0">ruff 0.16</a> update was a bit painful because ruff now enables 413 rules by default, up from 59. Recurring themes were warnings about mutable class variables (which are common when using Django), blind <code>except</code> clauses and underspecified dates without time zones, but none of them in scenarios where they actually hurt.</p>
<p>So, instead of just running the merge script, I had to fix up dozens of <code>pyproject.toml</code> files and projects. Oh well, next time will be smooth again.</p>
<h2 id="releases"><a class="toclink" href="#releases">Releases</a></h2>
<p>Since I’ve been away from the computer for so long, the list of releases from the start of July onwards is quite short.</p>
<h3 id="django-authlib"><a class="toclink" href="#django-authlib">django-authlib</a></h3>
<p><a href="https://pypi.org/project/django-authlib/">django-authlib 0.18</a> now also supports Microsoft Entra ID logins. The admin integration also has support for Microsoft accounts, not just for Google.</p>
<h3 id="django-content-editor"><a class="toclink" href="#django-content-editor">django-content-editor</a></h3>
<p>The <a href="https://pypi.org/project/django-content-editor/">django-content-editor 9.0.1</a> just contains a small fix which avoids submitting the form that allows cloning content between regions when cancelling the dialog.</p>
Migrating from FeinCMS to feincms3https://406.ch/writing/migrating-from-feincms-to-feincms3/2026-07-18T12:00:00Z2026-07-18T12:00:00Z<h1 id="migrating-from-feincms-to-feincms3"><a class="toclink" href="#migrating-from-feincms-to-feincms3">Migrating from FeinCMS to feincms3</a></h1>
<p><a href="https://github.com/feincms/feincms">FeinCMS</a> is still actively maintained, but development, bugfixes and new features mostly land on <a href="https://github.com/feincms/feincms3">feincms3</a> and <a href="https://github.com/feincms/django-content-editor">django-content-editor</a> these days, not on FeinCMS itself. That’s reason enough to eventually move a project over.</p>
<p>Someone <a href="https://github.com/feincms/feincms3/issues/59">asked on the feincms3 issue tracker</a> whether there’s a guide for making that move. There isn’t one yet, so I thought I’d expand on my comment in the issue tracker and post it here too in the hope that it’s useful to others. The post is based on a gradual migration <a href="https://feinheit.ch/">we</a> did in a large, long-lived Django project – a textbook publishing platform with years of content. Unfortunately I can’t show more details since it’s a commercial, closed source project. During the migration, the platform stayed in production the whole time, aside from the inevitable bug here and there.</p>
<p>The most important insight is that FeinCMS 1 content types and feincms3 plugins are close enough that we could keep using the same underlying database tables. An export/import step isn’t required at all.</p>
<h2 id="the-overall-shape-of-the-migration"><a class="toclink" href="#the-overall-shape-of-the-migration">The overall shape of the migration</a></h2>
<ul>
<li>FeinCMS keeps managing the plugin tables as usual, for now.</li>
<li>New feincms3 plugin models get added alongside the old ones, with <code>managed = False</code> in their <code>Meta</code> and <code>db_table</code> pointing at the exact table FeinCMS already created. Django doesn’t think these are new or different tables, it just uses the ones that already exist.</li>
<li>The admin gets migrated from <code>ItemEditor</code> to <code>django-content-editor</code>’s <code>ContentEditor</code>. If your project has more than one independent hierarchy (in our case, one per book series instead of a single global page tree) you can migrate them independently instead of having to do it all in one step.</li>
<li>Rendering moves over from FeinCMS’s rendering machinery to feincms3’s, again piece by piece: the HTML generation for the frontend, any full text search indexers, anything else that touches the content.</li>
</ul>
<p>The examples below use <code>Chapter</code> and <code>ChapterPlugin</code> because that’s the hierarchy in this particular project, one tree per book series rather than one global page tree. Most feincms3 projects center on a single <code>Page</code> model instead, so read <code>Page</code> wherever you see <code>Chapter</code>; the rest still applies.</p>
<h2 id="getting-the-migration-state-right"><a class="toclink" href="#getting-the-migration-state-right">Getting the migration state right</a></h2>
<p>Here’s a concrete proxy model from that migration, simplified. <code>RichTextContent</code> is our own abstract mixin, holding nothing but the actual <code>text</code> field, shared between the FeinCMS and feincms3 sides of the code base. The same role is played by <a href="https://github.com/feincms/feincms3/blob/main/feincms3/plugins/richtext.py"><code>feincms3.plugins.richtext.RichText</code></a> if you use feincms3’s bundled plugin instead of your own. <code>ChapterPlugin</code> in turn only wires up the boilerplate that’s specific to being a plugin content type of our <code>Chapter</code> model:</p>
<div class="chl"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">RichTextContent</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
<span class="n">text</span> <span class="o">=</span> <span class="n">RichTextField</span><span class="p">()</span>
<span class="k">class</span><span class="w"> </span><span class="nc">Meta</span><span class="p">:</span>
<span class="n">abstract</span> <span class="o">=</span> <span class="kc">True</span>
<span class="k">class</span><span class="w"> </span><span class="nc">RichText</span><span class="p">(</span><span class="n">ChapterPlugin</span><span class="p">,</span> <span class="n">RichTextContent</span><span class="p">):</span>
<span class="k">class</span><span class="w"> </span><span class="nc">Meta</span><span class="p">:</span>
<span class="n">db_table</span> <span class="o">=</span> <span class="s2">"textbooks_chapter_richtextcontent"</span>
<span class="n">managed</span> <span class="o">=</span> <span class="kc">False</span> <span class="c1"># Proxy for the real model still owned by FeinCMS</span>
</code></pre></div>
<p>The exact table name depends on how FeinCMS auto-generated it for your project (a mix of the app label, the base model and the content type name), so look it up rather than guessing. <code>./manage.py sqlmigrate</code> or just inspecting the database will tell you.</p>
<p>For the very last step, once a plugin has been converted and <code>managed = False</code> can go away, use Django’s <a href="https://docs.djangoproject.com/en/stable/ref/migration-operations/#separatedatabaseandstate"><code>migrations.SeparateDatabaseAndState</code></a>. It lets you change the migration <em>state</em> without touching the database at all. That’s exactly what you need here since the table already exists and already has the right shape; only Django’s idea of what the schema is needs to catch up.</p>
<p>You don’t even have to write these migrations by hand. Remove <code>managed = False</code>, run <code>./manage.py makemigrations</code> as usual, and Django writes out the normal operations it would generate for any new model. The only manual step is moving that generated list of operations into the <code>state_operations</code> argument of a single <code>SeparateDatabaseAndState</code> operation, redacted and shortened here:</p>
<div class="chl"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">Migration</span><span class="p">(</span><span class="n">migrations</span><span class="o">.</span><span class="n">Migration</span><span class="p">):</span>
<span class="n">dependencies</span> <span class="o">=</span> <span class="p">[</span>
<span class="p">(</span><span class="s2">"textbooks"</span><span class="p">,</span> <span class="s2">"0096_remove_richtextcontent_managed"</span><span class="p">),</span>
<span class="p">]</span>
<span class="n">operations</span> <span class="o">=</span> <span class="p">[</span>
<span class="n">migrations</span><span class="o">.</span><span class="n">SeparateDatabaseAndState</span><span class="p">(</span>
<span class="n">state_operations</span><span class="o">=</span><span class="p">[</span>
<span class="n">migrations</span><span class="o">.</span><span class="n">CreateModel</span><span class="p">(</span>
<span class="n">name</span><span class="o">=</span><span class="s2">"RichText"</span><span class="p">,</span>
<span class="n">fields</span><span class="o">=</span><span class="p">[</span>
<span class="p">(</span><span class="s2">"id"</span><span class="p">,</span> <span class="n">models</span><span class="o">.</span><span class="n">AutoField</span><span class="p">(</span><span class="n">primary_key</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">serialize</span><span class="o">=</span><span class="kc">False</span><span class="p">)),</span>
<span class="p">(</span><span class="s2">"text"</span><span class="p">,</span> <span class="n">models</span><span class="o">.</span><span class="n">TextField</span><span class="p">()),</span>
<span class="p">(</span><span class="s2">"chapter"</span><span class="p">,</span> <span class="n">models</span><span class="o">.</span><span class="n">ForeignKey</span><span class="p">(</span><span class="n">to</span><span class="o">=</span><span class="s2">"textbooks.Chapter"</span><span class="p">,</span> <span class="o">...</span><span class="p">)),</span>
<span class="p">],</span>
<span class="n">options</span><span class="o">=</span><span class="p">{</span><span class="s2">"db_table"</span><span class="p">:</span> <span class="s2">"textbooks_chapter_richtextcontent"</span><span class="p">},</span>
<span class="p">),</span>
<span class="p">],</span>
<span class="c1"># No database_operations -- the table is already exactly like this.</span>
<span class="p">),</span>
<span class="p">]</span>
</code></pre></div>
<p>As always, test this on a copy of the database first and make sure you have backups. Hand-editing migrations isn’t generally recommended, but it’s safe if you know exactly what you’re doing. Treat it with the same care as any other schema change.</p>
<h2 id="the-page-tree-mptt-vs-tree-queries"><a class="toclink" href="#the-page-tree-mptt-vs-tree-queries">The page tree: mptt vs. tree-queries</a></h2>
<p>FeinCMS 1’s bundled <code>Page</code> model uses <a href="https://github.com/django-mptt/django-mptt">django-mptt</a> for the page hierarchy, while feincms3’s page comes with <a href="https://github.com/feincms/django-tree-queries">django-tree-queries</a> instead. If you’re using FeinCMS’s own <code>Page</code> model and migrating to feincms3’s, this can be a little bit painful since the database schema is different. There’s a <a href="https://django-tree-queries.readthedocs.io/en/latest/#migrating-from-django-mptt">dedicated guide for migrating from django-mptt</a> in the django-tree-queries documentation.</p>
<p>Once the last plugin has switched over, FeinCMS can be dropped from the project entirely, including from the requirements. The models end up exactly where they would have been if you’d started the project with feincms3 in the first place – and you never had to export or import a single row to get there.</p>
Weeknotes (2026 week 27)https://406.ch/writing/weeknotes-2026-week-27/2026-07-01T12:00:00Z2026-07-01T12:00:00Z<h1 id="weeknotes-2026-week-27"><a class="toclink" href="#weeknotes-2026-week-27">Weeknotes (2026 week 27)</a></h1>
<p>The <a href="https://406.ch/writing/weeknotes-2026-week-17/">last entry in this series</a> was published 10 weeks ago so it really is time for another review of the releases I did during this time.</p>
<h2 id="releases"><a class="toclink" href="#releases">Releases</a></h2>
<h3 id="feincms3-forms"><a class="toclink" href="#feincms3-forms">feincms3-forms</a></h3>
<p>The <a href="https://pypi.org/project/feincms3-forms/">feincms3-forms</a> forms builder has gained a <a href="https://feincms3-forms.readthedocs.io/">documentation page</a> on the wonderful Read the Docs service. The 0.6.1 release doesn’t contain any code changes, just <code>pyproject.toml</code> updates and the mentioned documentation rework.</p>
<h3 id="django-imagefield"><a class="toclink" href="#django-imagefield">django-imagefield</a></h3>
<p>django-imagefield 0.23 is still in alpha. The handling of image fields when using libvips is optimized to use less memory hopefully. We’ll see. I also added some tests to verify that <code>.mpo</code> files are handled properly.</p>
<h3 id="feincms3"><a class="toclink" href="#feincms3">feincms3</a></h3>
<p>The Vimeo embed now always sets the <code>dnt=1</code> parameter on the <code><iframe></code>, which asks Vimeo to not track the user.</p>
<h3 id="django-mptt"><a class="toclink" href="#django-mptt">django-mptt</a></h3>
<p>I wrote about the <a href="https://406.ch/writing/anything-new/">somewhat annoying maintenance</a> again. The library is still officially unmaintained, but I did a lot of work either just closing issues or also fixing them. The docs also contain many clarifications. I only released 0.19rc1 for now.</p>
<h3 id="feincms3-sites-and-feincms3-language-sites"><a class="toclink" href="#feincms3-sites-and-feincms3-language-sites">feincms3-sites and feincms3-language-sites</a></h3>
<p><a href="https://406.ch/writing/weeknotes-2026-week-17/#feincms3-sites-and-feincms3-language-sites">Last time</a> I mentioned that default HTTP/S ports are now stripped so that the host matching can determine the correct site. Now a new case appeared where trailing dots weren’t stripped. The normalization of hosts has been extended. I’m sure we’re still missing some exotic cases where we should do more normalization, but we’ll cross that bridge when we get there.</p>
<h3 id="django-prose-editor-and-django-js-asset"><a class="toclink" href="#django-prose-editor-and-django-js-asset">django-prose-editor and django-js-asset</a></h3>
<p>Various upgrades to the editor and especially the importmaps rework in both packages – the importmap infrastructure should now be CSP-compatible! I wrote more about that in the last post <a href="https://406.ch/writing/the-2026-way-of-using-importmaps-in-django/">The 2026 way of using importmaps in Django</a>.</p>
<h3 id="django-content-editor"><a class="toclink" href="#django-content-editor">django-content-editor</a></h3>
<p>Minor bugfixes and a major version bump because of the rework of the JavaScript code into multiple ES modules. The content editor now uses importmaps as well.</p>
<h3 id="django-fhadmin"><a class="toclink" href="#django-fhadmin">django-fhadmin</a></h3>
<p>Small bugfix so that links aren’t underlined in the app groups list when they shouldn’t be, matching how the Django admin itself behaves.</p>
<h3 id="django-cabinet"><a class="toclink" href="#django-cabinet">django-cabinet</a></h3>
<p>The cabinet / prose editor integration for the file (or image) picker is final and released as a stable version.</p>
<h3 id="django-json-schema-editor"><a class="toclink" href="#django-json-schema-editor">django-json-schema-editor</a></h3>
<p>This small release only contains more correct German translations of strings.</p>
<h3 id="honorable-mention-django-debug-toolbar"><a class="toclink" href="#honorable-mention-django-debug-toolbar">Honorable mention: django-debug-toolbar</a></h3>
<p>I didn’t actually create this release, but I contributed various changes to it. The changelog for 7.0 is <a href="https://django-debug-toolbar.readthedocs.io/en/latest/changes.html">here</a>.</p>
The 2026 way of using importmaps in Djangohttps://406.ch/writing/the-2026-way-of-using-importmaps-in-django/2026-06-17T12:00:00Z2026-06-17T12:00:00Z<h1 id="the-2026-way-of-using-importmaps-in-django"><a class="toclink" href="#the-2026-way-of-using-importmaps-in-django">The 2026 way of using importmaps in Django</a></h1>
<p>I last wrote about <a href="/writing/django-javascript-modules-and-importmaps/">Django, JavaScript modules and importmaps</a> in May 2025, slightly over a year ago.</p>
<p>The main topic of this post is the <a href="https://github.com/feincms/django-js-asset">django-js-asset</a> 4.0 release. The library is used in many places, some of the more well-known packages using it are <a href="https://github.com/django-mptt/django-mptt">django-mptt</a> and <a href="https://github.com/django-ckeditor/django-ckeditor">django-ckeditor</a>.
I have since done a lot of work evolving the ways of integrating importmaps but the efforts to standardize upon an approach have stalled a bit.
The main reason for this, apart from time and energy, was that I wasn’t really all that happy with the global importmap. When I had only a few modules using the importmap facility, I didn’t care all that much. Now that the recently released <a href="https://github.com/feincms/django-content-editor">django-content-editor 9.0</a> also uses importmaps for shipping a refactored, much more modular JavaScript implementation while still keeping all the benefits of cache busting using <code>ManifestStaticFilesStorage</code><sup id="fnref:manifest"><a class="footnote-ref" href="#fn:manifest">1</a></sup>, having a global importmap got annoying. The content editor JavaScript is only used within the <a href="/writing/the-django-admin-is-a-cms/">Django administration interface</a>, but when using a single global importmap object, the importmap entries were always there on each page that used an importmap at all.</p>
<p>A better solution was needed. I’m a big fan of using <a href="https://docs.djangoproject.com/en/6.1/topics/forms/media/"><code>forms.Media</code></a> for collecting CSS and JavaScript from widgets, forms and utilities. It helps me avoid inline JavaScript since <a href="https://406.ch/writing/django-admin-apps-and-content-security-policy-compliance/">at least 2017</a>. I’m not using it for site-wide CSS and JavaScript, I’m still transpiling, PostCSS-ing and bundling the assets using <a href="https://rspack.rs/">rspack</a> as for example written about <a href="https://406.ch/writing/avoiding-empty-javascript-files-in-css-only-entrypoints-from-rspack-builds/">here</a> and <a href="https://406.ch/writing/how-i-m-bundling-frontend-assets-using-django-and-rspack-these-days/">here</a>.</p>
<h2 id="why-importmaps"><a class="toclink" href="#why-importmaps">Why importmaps?</a></h2>
<p>A quick refresher on why this matters at all. Django’s <code>ManifestStaticFilesStorage</code> hashes the contents of each file into its name for cache busting, but out of the box it doesn’t rewrite the <code>import</code> statements inside JavaScript modules. Importmaps bridge the gap: your code imports a stable name:</p>
<div class="chl"><pre><span></span><code><span class="k">import</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">initializeEditors</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="kr">from</span><span class="w"> </span><span class="s2">"django-prose-editor/editor"</span>
</code></pre></div>
<p>and the importmap tells the browser where that name actually lives:</p>
<div class="chl"><pre><span></span><code><span class="p"><</span><span class="nt">script</span> <span class="na">type</span><span class="o">=</span><span class="s">"importmap"</span><span class="p">></span>
<span class="p">{</span><span class="s2">"imports"</span><span class="o">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="s2">"django-prose-editor/editor"</span><span class="o">:</span><span class="w"> </span><span class="s2">"/static/django_prose_editor/editor.6e8dd4c12e2e.js"</span>
<span class="p">}}</span>
<span class="p"></</span><span class="nt">script</span><span class="p">></span>
</code></pre></div>
<p>So the import stays clean and constant while the file behind it can get a new hash on every deploy.</p>
<h2 id="django-js-asset-40"><a class="toclink" href="#django-js-asset-40">django-js-asset 4.0</a></h2>
<p>The updated <a href="https://github.com/feincms/django-js-asset">django-js-asset 4.0</a> doesn’t ship the old, global importmap at all. This means the upgrade might require some work. Instead of one importmap shared across the whole site, you now get a specific importmap assembled for the context at hand – either by Django itself when it collects the media of your forms, widgets and the admin, or explicitly by you in a view or context processor. The building block in both cases is the <code>ImportMap</code> object; when it travels through <code>js_asset.Media</code> (a subclass of <code>django.forms.Media</code>) the maps are automatically merged into a single <code><script type="importmap"></code>, by customizing and extending what Django does already when merging media instances.</p>
<p>The <a href="https://github.com/feincms/django-js-asset/blob/main/CHANGELOG.rst">release notes</a> go into more detail.</p>
<h2 id="in-practice"><a class="toclink" href="#in-practice">In practice</a></h2>
<p>If you’re using a package such as <a href="https://github.com/feincms/django-prose-editor">django-prose-editor</a> in the Django admin you don’t have to do anything, things should just work.</p>
<p>If you’re using such a package outside the admin, you have to remove <code>"js_asset.context_processors.importmap"</code> from your list of context processors. On one particular website the prose editor is the only package with importmap entries outside the admin, so I have to add the <code>importmap</code> to the template context myself:</p>
<div class="chl"><pre><span></span><code><span class="kn">from</span><span class="w"> </span><span class="nn">django_prose_editor.widgets</span><span class="w"> </span><span class="kn">import</span> <span class="n">importmap</span>
<span class="k">def</span><span class="w"> </span><span class="nf">view</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="o">...</span><span class="p">):</span>
<span class="k">return</span> <span class="n">render</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="s2">"template.html"</span><span class="p">,</span> <span class="p">{</span>
<span class="c1"># ...</span>
<span class="s2">"importmap"</span><span class="p">:</span> <span class="n">importmap</span><span class="p">,</span>
<span class="p">})</span>
</code></pre></div>
<p>The template then just renders it in the <code><head></code>:</p>
<div class="chl"><pre><span></span><code>... {{ importmap }}<span class="p"></</span><span class="nt">head</span><span class="p">></span>
</code></pre></div>
<p>On a different site, I have a slightly more involved scenario where I previously used <code>importmap.update(...)</code> to add my own entries to the importmap. There, I’m using a custom context processor to always add these entries to the importmap too:</p>
<div class="chl"><pre><span></span><code><span class="kn">from</span><span class="w"> </span><span class="nn">django_prose_editor.widgets</span><span class="w"> </span><span class="kn">import</span> <span class="n">importmap</span> <span class="k">as</span> <span class="n">dpe_importmap</span>
<span class="kn">from</span><span class="w"> </span><span class="nn">js_asset</span><span class="w"> </span><span class="kn">import</span> <span class="n">ImportMap</span><span class="p">,</span> <span class="n">static_lazy</span>
<span class="n">_site_importmap</span> <span class="o">=</span> <span class="n">ImportMap</span><span class="p">({</span>
<span class="s2">"imports"</span><span class="p">:</span> <span class="p">{</span>
<span class="s2">"my-module"</span><span class="p">:</span> <span class="n">static_lazy</span><span class="p">(</span><span class="s2">"my-module.js"</span><span class="p">),</span>
<span class="p">}</span>
<span class="p">})</span>
<span class="n">_importmap</span> <span class="o">=</span> <span class="n">dpe_importmap</span> <span class="o">|</span> <span class="n">_site_importmap</span>
<span class="k">def</span><span class="w"> </span><span class="nf">importmap</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
<span class="k">return</span> <span class="p">{</span><span class="s2">"importmap"</span><span class="p">:</span> <span class="n">_importmap</span><span class="p">}</span>
</code></pre></div>
<p>This importmap is merged once at server startup and then served repeatedly to the client. Because we use the lazy version of the <code>static</code> function we can do this during startup and not worry about files not yet collected by <code>collectstatic</code> – we’ll get the correct paths later.</p>
<p>On the same site as the previous example, I also have an admin inline which requires some JavaScript and also an importmap:</p>
<div class="chl"><pre><span></span><code><span class="kn">from</span><span class="w"> </span><span class="nn">django.contrib</span><span class="w"> </span><span class="kn">import</span> <span class="n">admin</span>
<span class="kn">from</span><span class="w"> </span><span class="nn">django.forms</span><span class="w"> </span><span class="kn">import</span> <span class="n">Script</span>
<span class="kn">from</span><span class="w"> </span><span class="nn">js_asset</span><span class="w"> </span><span class="kn">import</span> <span class="n">Media</span><span class="p">,</span> <span class="n">ImportMap</span>
<span class="c1"># Initializing this once. Not necessary but I like it better that way.</span>
<span class="n">_importmap</span> <span class="o">=</span> <span class="n">ImportMap</span><span class="p">({</span>
<span class="s2">"imports"</span><span class="p">:</span> <span class="p">{</span>
<span class="c1"># ...</span>
<span class="p">}</span>
<span class="p">})</span>
<span class="k">class</span><span class="w"> </span><span class="nc">ModelInline</span><span class="p">(</span><span class="n">admin</span><span class="o">.</span><span class="n">StackedInline</span><span class="p">):</span>
<span class="nd">@property</span>
<span class="k">def</span><span class="w"> </span><span class="nf">media</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
<span class="k">return</span> <span class="n">Media</span><span class="p">(</span>
<span class="n">js</span><span class="o">=</span><span class="p">[</span>
<span class="n">_importmap</span><span class="p">,</span>
<span class="n">Script</span><span class="p">(</span><span class="s2">"module.js"</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="s2">"module"</span><span class="p">),</span>
<span class="p">]</span>
<span class="p">)</span>
</code></pre></div>
<p>As of 4.0, <code>JS</code> and <code>CSS</code> produce Django’s own <code>Script</code> and <code>Stylesheet</code> objects, so you can import and use <code>Script</code> directly from <code>django.forms</code> as shown above (on Django 4.2–5.1, import it from <code>js_asset</code> instead, which backports it). The familiar <code>JS("module.js", {"type": "module"})</code> wrapper still works unchanged if you prefer it — it just takes a positional dict instead of keyword arguments.</p>
<p>Here, it’s really important to use the <code>js_asset.Media</code> and not <code>django.forms.Media</code>. <code>js_asset.Media</code> knows how to handle importmaps – all importmaps are collected from all media lists, merged and added to the output before all other CSS and especially JavaScript. The reason for that is that browsers only honour a single importmap per page, and it really has to appear before all JavaScript modules referencing any entries in the importmap.</p>
<p>The nice thing about <code>js_asset.Media</code> is that it doesn’t have to appear first in the list of media classes which are merged – it can also appear in the middle or last, and still can do its magic after all <code>Media</code> objects have been merged into a single one.</p>
<p>The rest is handled by Django itself, since it already supports collecting media assets. The missing piece was just the importmap object and the <code>js_asset.Media</code> class which knows how to special case them, and which – through the power of overriding <code>__add__</code> and <code>__radd__</code> takes over all the other media instances.</p>
<h2 id="whats-next"><a class="toclink" href="#whats-next">What’s next</a></h2>
<p>I haven’t yet used CSP nonces using <a href="https://docs.djangoproject.com/en/6.1/ref/templates/builtins/#std-templatetag-csp_nonce_attr"><code>{% csp_nonce_attr media %}</code></a> in production myself, but it should just work, even with importmaps and everything else. Given that I have a passing test suite I have no reason to believe it doesn’t already work, but I’d like to have a confirmation.</p>
<p>I’m hoping to standardize some more. If we could get something like this in Django core that would be really nice. Maybe I’ll be able to work on that at <a href="https://djangomed.eu/">Django on the Med 🏖️</a>. Since no browser supports multiple importmaps as of today having multiple implementations of importmaps in the Django ecosystem will lead to trouble down the road. I think there is a clear case to be made for importmap support in Django and I would obviously love it if the approach implemented today in django-js-asset would be the basis for the official solution.</p>
<div class="footnote"><hr><ol><li id="fn:manifest"><p>Without having to do any overrides to enable ESM support. <a class="footnote-backref" href="#fnref:manifest" title="Jump back to footnote 1 in the text">↩</a></p></li></ol></div>«Anything new?»https://406.ch/writing/anything-new/2026-06-03T12:00:00Z2026-06-03T12:00:00Z<h1 id="anything-new"><a class="toclink" href="#anything-new">«Anything new?»</a></h1>
<p>A lot of time has passed since I officially announced that I want to <a href="https://406.ch/writing/weeknotes-2021-week-10/#django-mptt-is-not-maintained-anymore">step down from maintaining django-mptt</a>. I started contributing around 2009, tagged the <a href="https://github.com/django-mptt/django-mptt/tree/0.3.0">0.3 release in April 2010</a>, and have been the sole active maintainer since somewhere around 2019. The post about <a href="https://406.ch/writing/django-tree-queries/">django-tree-queries</a> has more background, but that’s not today’s topic.</p>
<h2 id="stepping-away-isnt-easy"><a class="toclink" href="#stepping-away-isnt-easy">Stepping away isn’t easy</a></h2>
<p>For me, abandoning a project is a bit like stepping out of a relationship: negative emotions end up being a somewhat necessary driver, because the absence of positive events alone rarely provides enough force on its own. I get a lot of satisfaction from a job well done, and walking away means letting that go.</p>
<p>Even with time set aside for open source in my work day, I still have to choose where that time goes. django-mptt stopped being where it needed to go.</p>
<h2 id="the-sense-of-entitlement"><a class="toclink" href="#the-sense-of-entitlement">The sense of entitlement</a></h2>
<p>When a project is obviously unmaintained, asking for free labor is walking a tightrope. It takes real care not to rekindle exactly the frustrations that led maintainers away in the first place.</p>
<p>It takes energy not to clap back when someone is being rude or insensitive in the issue tracker. Asking “Anything new?” on a ticket where the next steps were outlined clearly and obviously nothing happened in the meantime is just one variant of this.</p>
<p>Quietly quitting isn’t what I want to do — and as a user of django-mptt myself, I can’t really do that either. Taking the high road is the professional choice. But it costs something.</p>
<p>I keep coming back to <a href="https://youtu.be/Amj3QG2s1BI?t=226">Mona Eltahawy on refusing to be civil</a>. She’s speaking about something quite different, and I’m aware I write this as a white man. The situations aren’t the same at all. But she articulated something I haven’t managed to put into words as well myself and I like the idea of speaking up and taking the fight to those who awaken these feelings instead of taking the high road.</p>
<h2 id="doing-it-with-ai"><a class="toclink" href="#doing-it-with-ai">Doing it with AI</a></h2>
<p>No post these days is complete without the obligatory AI mention, but there’s some relevancy to it.</p>
<p>I fixed and closed almost all open django-mptt issues in a two-hour Claude session. I’ve previously written about using <a href="https://406.ch/writing/llms-for-open-source-maintenance-a-cautious-case/">LLMs for open source maintenance</a>, and the productivity gain is real whatever the detractors say. And the quality isn’t suddenly getting worse. Code wasn’t perfect before either. The test suite allows a certain degree of trust in the result and according to <a href="https://406.ch/writing/my-rules-for-releasing-open-source-software/">my rules for releasing Open Source software</a> we don’t have to require more than that.</p>
<p>It doesn’t change the underlying dynamic, though. <a href="https://medium.com/@tridge60/rsync-and-outrage-d9849599e5a0">rsync and outrage</a> illustrates the trap neatly: Tridgell got flooded with AI-generated security reports, used AI to handle them, and then got criticized for using AI. The tools that created the workload aren’t allowed to address it. The expectation is that the work has to involve sweat and tears and uncountable unpaid hours.</p>
<p>The common goal should be more and better open source software. What we get as Open Source maintainers is shit from both sides: One side took our free work and trained models on it without asking, the other side complains about the supposedly unethical use of AI while acting in unethical ways themselves.</p>
<p>There’s something Kantian about how open source contribution gets framed. <a href="https://en.wikipedia.org/wiki/Immanuel_Kant">Kant</a>’s argument was that the only truly moral acts are those driven by duty and good will — not by desire, inclination, or any expectation of compensation. By that logic, I’m only acting morally if I keep going despite the burnout and the entitlement. If I stop, I’m not.</p>
<p>It’s bleak. The problems with AI are real. The people controlling the large models are assholes. But I have to work in the world as it is while also trying to change it for the better.</p>
Weeknotes (2026 week 17)https://406.ch/writing/weeknotes-2026-week-17/2026-05-20T12:00:00Z2026-05-20T12:00:00Z<h1 id="weeknotes-2026-week-17"><a class="toclink" href="#weeknotes-2026-week-17">Weeknotes (2026 week 17)</a></h1>
<p>I published the <a href="https://406.ch/writing/weeknotes-2026-week-11/">last entry</a> near the beginning of March. I’m really starting to see a theme in my Weeknotes publishing schedule.</p>
<h2 id="releases-since-the-first-weeks-of-march"><a class="toclink" href="#releases-since-the-first-weeks-of-march">Releases since the first weeks of March</a></h2>
<p>I’m trying out a longer-form version of those notes here than in the past. I think it’s worth going into some detail and not just listing releases with half a sentence each.</p>
<h3 id="feincms3-sites-and-feincms3-language-sites"><a class="toclink" href="#feincms3-sites-and-feincms3-language-sites">feincms3-sites and feincms3-language-sites</a></h3>
<p>I released updates to <a href="https://pypi.org/project/feincms3-sites/">feincms3-sites</a> and <a href="https://pypi.org/project/feincms3-language-sites/">feincms3-language-sites</a> fixing the same issue in both projects: When an HTTP client didn’t strip the default ports :80 (for HTTP) or :443 (for HTTPS) from a request, finding the correct site would fail. Browsers generally strip the port already, but some other HTTP clients do not.</p>
<h3 id="django-tree-queries"><a class="toclink" href="#django-tree-queries">django-tree-queries</a></h3>
<p>As I wrote <a href="/writing/llms-for-open-source-maintenance-a-cautious-case/">elsewhere</a> I closed many issues in the repositories, mostly documentation issues but also some bugs. <code>{% recursetree %}</code> should now work properly and not cache old data anymore, using the primary key in <code>.tree_fields()</code> now raises an intelligible error, and I also fixed a bug with table quoting when using <a href="https://pypi.org/project/django-tree-queries/">django-tree-queries</a> with the not yet released Django 6.1+.</p>
<h3 id="feincms3-cookiecontrol"><a class="toclink" href="#feincms3-cookiecontrol">feincms3-cookiecontrol</a></h3>
<p><a href="https://pypi.org/project/feincms3-cookiecontrol/">feincms3-cookiecontrol</a> not only offers a cookie consent banner (which actually supports <a href="https://406.ch/writing/reusable-cookie-consent-app-for-django/">only embedding tracking scripts when users give consent</a>) but also a third-party content embedding functionality which allows allowlisting individual services.</p>
<p>The privacy policies of these services are now linked inline instead of with an ugly extra link. This reduces content inside the embed which helps on small screens.</p>
<p>Version 1.7 used a buggy trusted publishing workflow so I immediately published 1.7.1.</p>
<h3 id="django-cabinet-and-django-prose-editor"><a class="toclink" href="#django-cabinet-and-django-prose-editor">django-cabinet and django-prose-editor</a></h3>
<p><a href="https://pypi.org/project/django-cabinet/">django-cabinet</a> can now be used as a media library directly inside <a href="https://pypi.org/project/django-prose-editor/">django-prose-editor</a>. I’m (ab)using the CKEditor 4 protocol for embedding, which uses <code>window.opener.CKEDITOR.callFunction</code> to send data back from the file manager popup into the editor. It feels icky but works nicely. This is only available if you’re installing the alpha prereleases, but I’m already testing the functionality in production somewhere, so I feel quite good about it.</p>
<p>django-prose-editor now also ships brand new <a href="https://django-prose-editor.readthedocs.io/en/latest/classloom.html"><code>ClassLoom</code></a> and <a href="https://django-prose-editor.readthedocs.io/en/latest/styleloom.html"><code>StyleLoom</code></a> extensions. Both extensions allow adding either classes or inline styles to text spans or nodes. In an ideal world we might not use something like this, but to make the editor more useful in the real world, editors need more flexibility. These two extensions provide that. I already mentioned <code>ClassLoom</code> <a href="/writing/rich-text-editors-how-restrictive-can-we-be/#combining-css-classes">in December</a>, now it’s actually available. I’m not completely sold on how they work yet, but both of them are already solving real issues.</p>
<h3 id="honorable-mentions"><a class="toclink" href="#honorable-mentions">Honorable mentions</a></h3>
<p><a href="https://pypi.org/project/django-debug-toolbar/">django-debug-toolbar 6.3</a> has been released, I only contributed reviews during this cycle.</p>
Switching all of my Python packages to PyPI trusted publishinghttps://406.ch/writing/switching-all-of-my-python-packages-to-pypi-trusted-publishing/2026-04-08T12:00:00Z2026-04-08T12:00:00Z<h1 id="switching-all-of-my-python-packages-to-pypi-trusted-publishing"><a class="toclink" href="#switching-all-of-my-python-packages-to-pypi-trusted-publishing">Switching all of my Python packages to PyPI trusted publishing</a></h1>
<p>As I have teased on <a href="https://hachyderm.io/@matthiask/116300209761371116">Mastodon</a>, I’m switching all of my packages to PyPI trusted publishing. I have been using it to release the <a href="github.com/django-commons/django-debug-toolbar">django-debug-toolbar</a> a few times but never set it up myself. The process seemed tedious.</p>
<p>The malicious releases uploaded to PyPI two weeks ago and the blog post about <a href="https://snarky.ca/why-pylock-toml-includes-digital-attestations/">digital attestations in <code>pylock.toml</code></a> finally pushed me to make the switch. All of my PyPI tokens have been revoked so there is no quick shortcut.</p>
<blockquote class="markdown-alert-note">
<p>I’m also looking at other code hosting platforms. I have been using git before GitHub existed and I’ll probably still use git when GitHub has completed its enshittification. For now the cost/benefit ratio of staying on GitHub is still positive for me. Trusted publishing isn’t available everywhere, so for now it is GitHub anyway.</p>
</blockquote>
<p>In the end, switching an existing project was easier than expected. I have completed the process for <a href="https://github.com/feincms/django-prose-editor">django-prose-editor</a> and <a href="https://github.com/feincms/feincms3-cookiecontrol/">feincms3-cookiecontrol</a>.</p>
<p>For my future benefit, here are the step by step instructions I have to follow:</p>
<ol start="0">
<li>
<p>Have a package which is buildable using e.g. <code>uvx build</code></p>
</li>
<li>
<p>On PyPI add a trusted publisher in the project’s publishing settings:</p>
<ul>
<li>Owner: <code>matthiask</code>, <code>feincms</code>, <code>feinheit</code>, whatever the user or organization’s name is.</li>
<li>Repository: <code>django-prose-editor</code></li>
<li>Workflow name: <code>publish.yml</code></li>
<li>Environment: <code>release</code></li>
</ul>
</li>
<li>
<p>In the GitHub repository, create a <code>release</code> environment in Settings / Environments. If I want to manually approve releases I’ll add myself and potentially also other releasers as required reviewers.</p>
</li>
<li>
<p>Run <code>git tag x.y.z</code> and <code>git push</code>, no more <code>uvx twine</code> or <code>hatch publish</code>.</p>
</li>
<li>
<p>Approve the release in the actions tab on the repository.</p>
</li>
<li>
<p>Either enjoy or swear and repeat the steps.</p>
</li>
</ol>
<p>I’m happy with testing the release process in production. The older I get the less I care if people think I’m stupid. That’s also why feincms3-cookiecontrol 1.7.0 doesn’t exist, only 1.7.1 – the process failed and I had to bump the patch version and try again. Copy the <code>publish.yml</code> from a known good place, for example from the <a href="https://github.com/feincms/django-prose-editor/blob/main/.github/workflows/publish.yml">django-prose-editor repository</a>. I have added the <code>if: github.repository == 'feincms/django-prose-editor'</code> statement which ensures that the workflow only runs in the main repository, but that’s optional if you don’t care about failing workflows.</p>
LLMs for Open Source maintenance: a cautious casehttps://406.ch/writing/llms-for-open-source-maintenance-a-cautious-case/2026-03-25T12:00:00Z2026-03-25T12:00:00Z<h1 id="llms-for-open-source-maintenance-a-cautious-case"><a class="toclink" href="#llms-for-open-source-maintenance-a-cautious-case">LLMs for Open Source maintenance: a cautious case</a></h1>
<p>When ChatGPT appeared on the scene I was very annoyed at all the hype surrounding it. Since I’m working in the fast moving and low margin business of communication and campaigning agencies I’m surrounded by people eager to jump on the hype train when a tool promises to lessen the workload and take stuff from everyone’s plate.</p>
<p>These discussions coupled with the fact that the training of these tools required unfathomable amounts of <strong>stealing</strong> were the reason for a big reluctance on my part when trying them out. I’m using the word stealing here on purpose, since that’s exactly the crime <a href="https://en.wikipedia.org/wiki/Aaron_Swartz">Aaron Swartz</a> <a href="https://web.archive.org/web/20120526080523/http://www.justice.gov/usao/ma/news/2011/July/SwartzAaronPR.html">was accused of by the attorney’s office of the district of Massachusetts</a>. It’s frustrating that some people can get away with the same crime when it is so much bigger. For example, OpenAI and Anthropic downloaded much more data than Aaron ever did.</p>
<p>A somewhat related thing happened with the too-big-to-fail banks: There, the people at the top were even compensated with <a href="https://en.wikipedia.org/wiki/Golden_parachute">golden parachutes</a> at the end. LLM companies seem to be above accountability too.</p>
<p>Despite all this, I have slowly started integrating these tools into my workflows. I don’t remember the exact point in time, but since some time in 2025 my opinions on their utility has started to change. At the beginning, I always removed the attribution and took great care to write and rewrite the code myself, only using the LLMs for inspiration and maybe to generate integration tests. More and more I have to admit that they are useful, especially in time constrained projects with a clear focus and purpose.</p>
<p>Last month I fixed and/or closed all open issues in the <a href="https://github.com/feincms/django-tree-queries">django-tree-queries</a> repository with the help of Claude Code. Is that a good thing? It could be argued I should have done the work myself. But I wouldn’t have — I have other things I want to do with my time. I don’t want to (always) work on Open Source software in the evening. I definitely also have leaned heavily on LLMs when working on <a href="https://github.com/feincms/django-prose-editor">django-prose-editor</a>.</p>
<h2 id="is-faster-better"><a class="toclink" href="#is-faster-better">Is faster better?</a></h2>
<p>We can produce more code, more features and close tickets faster than before. In my experience the speed up isn’t as big as some people may want us to believe, but it’s there. And contrary to what people in my LinkedIn feed say, that’s not an obviously good thing. Is it a race to the bottom where we drown in LLM-generated slop in quantities impossible to maintain? It doesn’t feel like that — but it’s a race that could go both ways. Throwaway code can be thrown away though, and well tested code does what the tests say, which is good enough according to my <a href="https://406.ch/writing/my-rules-for-releasing-open-source-software/">rules for releasing open source software</a>.</p>
<p>Speaking as someone who has put more into the training set than they’ve taken out so far, I don’t feel all that bad using the tools. Coding agents can already be run locally with reasonable hardware requirements, at least during inference, which is where the ongoing cost sits. Maybe using them is still rationalization. But contribution and profit needing to stay in some rough balance feels like the right frame. Total abstinence isn’t the only ethical choice we have.</p>
<h2 id="community-tensions"><a class="toclink" href="#community-tensions">Community tensions</a></h2>
<p>What makes me less comfortable is how communities are reacting. There are real concerns within the Django world, and not just the practical one of overworked maintainers wading through hastily generated patches that don’t actually fix anything. The deeper worry is about the communal nature of contribution: that working on Django is supposed to be a learning experience, a way into the community, and that using an LLM as a vehicle rather than a tool hollows out that process. Reviewers end up interacting with what is essentially a facade, unable to tell whether anyone actually understood the problem. That’s a real concern and I don’t want to dismiss it.</p>
<p>But it maps onto a different situation from what I’ve been describing. Using Claude Code to close issues in projects I maintain and understand is not the same as using it to paper over gaps in comprehension on a ticket in someone else’s project. Whether LLM-assisted contributions to Django itself are appropriate is a difficult question; whether it’s appropriate to use them when maintaining your own software less so.</p>
<p>There’s also a harder tension around quality. Django’s conservatism has real value: rigorous review, minimal magic, a coherent philosophy. The ORM and template system don’t need to reinvent themselves, they work well, are still evolving while staying rock-solid for all my use cases. And reading the release notes always brings me joy. But it could be more exciting more often. Quality isn’t a strictly positive thing. Everything has costs. It’s not great if the price of the bar is that legitimate bugs sit open for years because nobody has a few evenings to spend on them. It happened with django-tree-queries before I went through it with Claude Code. I think the bar for contributing to Django is too high. I would value a little more motion and a little less stability, even as someone running dozens of Django websites and apps.</p>
<p>Then there’s the pile-on dynamic that plays out on Mastodon and GitHub. When the Harfbuzz and chardet maintainers disclosed LLM usage, the reaction from some corners was something to behold. People expressing what amounted to personal grievance over tooling choices in projects they may not even use. There’s a particular kind of entitlement in telling a maintainer – who is keeping software alive, possibly even in their spare time – that the way they choose to do that work is an affront. Open source is a gift, whether paid or not, and nobody has to accept it, but disclosing your tooling isn’t an invitation for complaints. The ethical concerns about training data, resource use and other negative externalities are legitimate and worth raising. Performative outrage directed at individual maintainers is not the same thing.</p>
<p>I don’t have an easy conclusion. The tools are useful, the ethics are murky, and communities are still figuring out how to respond. A cautious, honest use of them feels better to me than the alternatives.</p>
Weeknotes (2026 week 11)https://406.ch/writing/weeknotes-2026-week-11/2026-03-11T12:00:00Z2026-03-11T12:00:00Z<h1 id="weeknotes-2026-week-11"><a class="toclink" href="#weeknotes-2026-week-11">Weeknotes (2026 week 11)</a></h1>
<p>Last time I wrote that I seem to be publishing weeknotes monthly. Now, a quarter of a year has passed since the <a href="https://406.ch/writing/weeknotes-2025-week-49/">last entry</a>. I do enjoy the fact that I have published more posts focused on a single topic. That said, what has been going on in open source land is certainly interesting too.</p>
<h2 id="llms-in-open-source"><a class="toclink" href="#llms-in-open-source">LLMs in Open Source</a></h2>
<p>I have started a longer piece to think about my stance regarding using LLMs in Open Source. The argument I’m thinking about is that there’s a balance between LLMs having ingested all of my published open source code and myself using them now to help myself and others again.</p>
<p>The happenings in the last two weeks (think Pentagon, Iran, and the bombings of schools) have again brought to the foreground the perils of using those tools. I therefore haven’t been motivated to pursue this train of thought for the moment. When the upsides are somewhat questionable and tentative and the downsides are so clear and impossible to miss, it’s hard to use my voice to speak in favor of these tools.</p>
<p>That said, all the shaming when someone uses an LLM that I see in my Mastodon feed also annoys me. I’ll quote part of a post here which I liked and leave it at that for the moment:</p>
<blockquote>
<p>The AI hype-cyclone is bad, but so is the anti-AI witch hunt. Commits co-authored by Claude do not mean that a project has “abandoned engineering as a serious endeavor”</p>
<p>[…]</p>
</blockquote>
<p>– <a href="https://hachyderm.io/@nedbat/116133445557306539">@nedbat on Mastodon</a></p>
<h2 id="other-goings-on"><a class="toclink" href="#other-goings-on">Other goings-on</a></h2>
<ul>
<li><strong>Health:</strong> My back continues to improve. Some days are still bad, but the idea that the <a href="https://406.ch/writing/my-2025-in-review/#sports-and-health">herniation</a> may go away entirely doesn’t sound totally unreasonable anymore.</li>
<li><strong>Gardening:</strong> We started weeding the garden last week. Lots to do! Being outside is fun. Weeding isn’t the greatest part ever, but it’s meditative.</li>
</ul>
<h2 id="releases-since-december"><a class="toclink" href="#releases-since-december">Releases since December</a></h2>
<ul>
<li><a href="https://pypi.org/project/django-json-schema-editor/">django-json-schema-editor 0.12.1</a>: CSS fixes. I have again looked at other, more modern JSON schema editor implementations but all of them are more limited than is acceptable to act as a replacement.</li>
<li><a href="https://pypi.org/project/django-debug-toolbar/">django-debug-toolbar 6.2</a>: I haven’t done much work here! Just some reviewing.</li>
<li><a href="https://pypi.org/project/django-content-editor/">django-content-editor 8.1</a>: Started emitting warnings when using non-abstract base classes for plugins. Using multi table inheritance is mostly an accident and not intended in my experience when using django-content-editor, therefore we have started detecting this case and emitting system checks (warnings, not errors).</li>
<li><a href="https://pypi.org/project/django-imagefield/">django-imagefield 0.23.0a3</a>: We have done some work on supporting <a href="https://www.libvips.org/">libvips</a> as an alternative backend to Pillow because I hoped that memory usage in Kubernetes pods might go down a bit. Results are not conclusive yet, and I’m not yet convinced the additional code complexity is worth it. Debugging and monitoring continues.</li>
<li><a href="https://pypi.org/project/FeinCMS/">FeinCMS 26.2.1</a>: Released a few bugfixes. FeinCMS is still being maintained ~17 years later!</li>
<li><a href="https://pypi.org/project/django-auto-admin-fieldsets/">django-auto-admin-fieldsets 0.3</a>: Added a helper to remove fields from the fieldsets structure.</li>
<li><a href="https://pypi.org/project/django-tree-queries/">django-tree-queries 0.23.1</a>: Shipped a small bugfix for <code>{% recursetree %}</code> which unintentionally cached children across invocations.</li>
<li><a href="https://pypi.org/project/feincms3-downloads/">feincms3-downloads</a>: Used <code>PATH</code> from the environment instead of using a very restricted allowlist so that <code>convert</code> and <code>pdftocairo</code> are detected in more locations. This should help with local development for example on macOS.</li>
<li><a href="https://pypi.org/project/django-prose-editor/">django-prose-editor 0.24.1</a>: Read the <a href="https://django-prose-editor.readthedocs.io/en/latest/changelog.html">CHANGELOG</a>; there’s too much in there for a short notice.</li>
<li><a href="https://pypi.org/project/form-designer/">form-designer 0.27.3</a>: Mosparo captcha support, bugfixes and additional translations.</li>
<li><a href="https://pypi.org/project/feincms3/">feincms3 5.5</a>: Started using the <code>OrderableTreeNode</code> from django-tree-queries.</li>
</ul>
Rich text editors: How restrictive can we be?https://406.ch/writing/rich-text-editors-how-restrictive-can-we-be/2025-12-17T12:00:00Z2025-12-17T12:00:00Z<h1 id="rich-text-editors-how-restrictive-can-we-be"><a class="toclink" href="#rich-text-editors-how-restrictive-can-we-be">Rich text editors: How restrictive can we be?</a></h1>
<p>How restrictive should a rich text editor be? It’s a question I keep coming back to as I work on FeinCMS and Django-based content management systems.</p>
<p>I published the last blog post on <a href="https://github.com/feincms/django-prose-editor">django-prose-editor</a> specifically in August 2025, <a href="https://406.ch/writing/menu-improvements-in-django-prose-editor/">Menu improvements in django-prose-editor</a>. The most interesting part of the blog post was the short mention of the <code>TextClass</code> extension at the bottom which allows adding a predefined list of CSS classes to arbitrary spans of text.</p>
<p>In the meantime, I have spent a lot of time working on extensions that try to answer this question: the <a href="https://django-prose-editor.readthedocs.io/en/latest/textclass.html"><code>TextClass</code> extension</a> for adding CSS classes to inline text, and more recently the <a href="https://django-prose-editor.readthedocs.io/en/latest/nodeclass.html"><code>NodeClass</code> extension</a> for adding classes to nodes and marks. It’s high time to write a post about it.</p>
<h2 id="rich-text-editing-philosophy"><a class="toclink" href="#rich-text-editing-philosophy">Rich Text editing philosophy</a></h2>
<blockquote>
<p>All of this convinced me that offering the user a rich text editor with too much capabilities is a really bad idea. The rich text editor in FeinCMS only has bold, italic, bullets, link and headlines activated (and the HTML code button, because that’s sort of inevitable – sometimes the rich text editor messes up and you cannot fix it other than going directly into the HTML code. Plus, if someone really knows what they are doing, I’d still like to give them the power to shoot their own foot).</p>
</blockquote>
<p>– <a href="https://github.com/feincms/feincms/commit/70cd7a1244438d2ba97852256f77daa2c870c345#diff-556c5559a716059d4fb714ad34de6a9845870e8d55bbd2cb9d77c732eb961388">Commit in the FeinCMS repository, August 2009</a>, current version from <a href="https://django-content-editor.readthedocs.io/en/latest/design-decisions.html">django-content-editor design decisions</a></p>
<h2 id="should-we-let-users-shoot-themselves-in-the-foot"><a class="toclink" href="#should-we-let-users-shoot-themselves-in-the-foot">Should we let users shoot themselves in the foot?</a></h2>
<p>Giving power users an HTML code button would have been somewhat fine if only the editors themselves were affected. Unfortunately, that was not the case.</p>
<p>As a team we have spent more time than we ever wanted debugging strange problems only to find out that the culprit was a blob of CSS or JavaScript inserted directly into an unsanitized rich text editor field. We saw everything from a few reasonable and well scoped lines of CSS to hundreds of KiBs of hotlinked JavaScript code that broke layouts, caused performance issues, and possibly even created security vulnerabilities.</p>
<p>We have one more case of <a href="https://en.wikipedia.org/wiki/Betteridge%27s_law_of_headlines">Betteridge’s law of headlines</a> here.</p>
<h2 id="the-pendulum-swings"><a class="toclink" href="#the-pendulum-swings">The pendulum swings</a></h2>
<p>The first version of django-prose-editor which replaced the venerable CKEditor 4 in our project was much more strict and reduced – no attributes, no classes, just a very short list of allowlisted HTML tags in the schema.</p>
<p>We quickly hit some snags. When users needed similar headings with different styles, we worked around it by using H2 and H3 — not semantic at all. I wasn’t exactly involved in this decision; I just didn’t want to rock the boat too much, since I was so happy that we were even able to use the more restricted editor at all in this project.</p>
<p>Everything was good for a while, but more and more use cases crept up until it was clear that something had to be done about it. First, the <a href="https://django-prose-editor.readthedocs.io/en/latest/textclass.html"><code>TextClass</code> extension</a> was introduced to allow adding classes to inline text, and later also the <a href="https://django-prose-editor.readthedocs.io/en/latest/nodeclass.html"><code>NodeClass</code> extension</a> mentioned above. This was a compromise: The customer wanted inline styles, we wanted as little customizability as possible without getting in the way.</p>
<p>That said, we obviously had to move a bit. After all, going back to a less strict editor or even offering a HTML blob injection would be worse. If we try to be too restrictive we will probably have to go back to allowing everything some way or the other, after all:</p>
<blockquote>
<p>The more you tighten your grip, Tarkin, the more star systems will slip through your fingers.</p>
</blockquote>
<p>– Princess Leia</p>
<h2 id="combining-css-classes"><a class="toclink" href="#combining-css-classes">Combining CSS classes</a></h2>
<p>The last words are definitely not spoken just yet. As <a href="https://hachyderm.io/@matthiask/115650714479718340">teased on Mastodon</a> at the beginning of this month I am working on an even more flexible extension which unifies the <code>NodeClass</code> and <code>TextClass</code> extensions into a single <code>ClassLoom</code> extension.</p>
<p>The code is getting real world use now, but I’m not ready to integrate it yet into the official repository. However, you can use it if you want, it’s 1:1 the version from a project repository. <a href="https://gist.github.com/matthiask/64ea64b539d63d45ff71467752c2f307">Get the <code>ClassLoom</code> extension here</a>.</p>
<p>This extension also allows combining classes on a single element. If you have 5 colors and 3 text styles, you’d have to add 15 combinations if you were only able to apply a single class. Allowing combinations brings the number of classes down to manageable levels.</p>
<h2 id="conclusion"><a class="toclink" href="#conclusion">Conclusion</a></h2>
<p>So, back to the original question: How restrictive can we be?</p>
<p>The journey from CKEditor 4’s permissiveness through django-prose-editor’s initial strictness to today’s <code>ClassLoom</code> extension has been one of finding that balance. Each extension — <code>TextClass</code>, <code>NodeClass</code>, and now <code>ClassLoom</code> — represents a step toward controlled flexibility: giving content editors the styling options they need while keeping the content structured, maintainable, and safe.</p>
Weeknotes (2025 week 49)https://406.ch/writing/weeknotes-2025-week-49/2025-12-05T12:00:00Z2025-12-05T12:00:00Z<h1 id="weeknotes-2025-week-49"><a class="toclink" href="#weeknotes-2025-week-49">Weeknotes (2025 week 49)</a></h1>
<p>I seem to be publishing weeknotes monthly, so I’m now thinking about renaming the category :-)</p>
<h2 id="mosparo"><a class="toclink" href="#mosparo">Mosparo</a></h2>
<p>I have started using a self-hosted <a href="https://mosparo.io/">mosparo</a> instance for my captcha needs. It’s nicer than Google reCAPTCHA. Also, not sending data to Google and not training AI models on traffic signs feels better.</p>
<h2 id="fixes-for-the-youtube-153-error"><a class="toclink" href="#fixes-for-the-youtube-153-error">Fixes for the YouTube 153 error</a></h2>
<p>Simon Willison published a nice writeup about <a href="https://simonwillison.net/2025/Dec/1/youtube-embed-153-error/">YouTube embeds failing with a 153 error</a>. We have also encountered this problem in the wild and fixed the <a href="https://feincms3.readthedocs.io/en/latest/ref/embedding.html">feincms3</a> embedding code to <a href="https://github.com/feincms/feincms3/commit/3f00e5d2a15991d52f9ae0118b49fe231ea328d0">also set the required referrerpolicy attribute</a>.</p>
<h2 id="updated-packages-since-2025-11-04"><a class="toclink" href="#updated-packages-since-2025-11-04">Updated packages since 2025-11-04</a></h2>
<ul>
<li><a href="https://pypi.org/project/django-sitemaps/">django-sitemaps 2.0.2</a>: Uploaded a new release which includes a wheel build. Rebuilding the wheel all the time when creating new container images was getting annoying. The code itself is unchanged.</li>
<li><a href="https://pypi.org/project/django_prune_uploads/">django-prune-uploads 0.3.1</a>: The package now supports pruning a storage backed by django-s3-storage efficiently. I have also looked at <a href="https://pypi.org/project/django-prune-media/">django-prune-media</a> but since the package uses the storage API instead of enumerating files using boto3 directly it’s unusably slow for my use case.</li>
<li><a href="https://pypi.org/project/feincms3-forms/">feincms3-forms 0.6</a>: Much better docs and a new way to reference <a href="https://github.com/feincms/feincms3-forms?tab=readme-ov-file#custom-templates-for-compound-fields">individual form fields in custom templates</a>.</li>
<li><a href="https://pypi.org/project/django-json-schema-editor/">django-json-schema-editor 0.11</a>: Switched from JSON paths to <code>jmespath</code> instances. Made the JSON model instance reference support easier and more fun to use. Added new ways of customizing the generated proxy model for individual JSON plugin instances.</li>
<li><a href="https://pypi.org/project/form-designer/">form-designer 0.27.1</a>: Added support for the <a href="https://mosparo.io/">mosparo captcha</a> to the default list of field types.</li>
<li><a href="https://pypi.org/project/asgi-plausible/">asgi-plausible 0.1.1</a>: No code change really, just added required dependencies to the package metadata.</li>
<li><a href="https://pypi.org/project/django-tree-queries/">django-tree-queries 0.23</a>: The package now ships a <code>OrderableTreeNode</code> base model which you can use when you want to order siblings manually. feincms3 already uses this base model for its pages model.</li>
<li><a href="https://pypi.org/project/feincms3-data/">feincms3-data 0.10</a>: This is quite a big one. I discovered issues with the way <code>save_as_new</code> (to copy data) and <code>delete_missing</code> interacted. First the code was cleaned up to delete less data, and then to delete enough data. I’m now somewhat confident that the code does what it should again.</li>
<li><a href="https://pypi.org/project/django-prose-editor/">django-prose-editor 0.22.3</a>: Started returning an empty string for an empty document instead of <code><p></p></code> also when using the frontend integration; previously, this transformation was only implemented when using at least the form if not the model field. Also, <code><ol></code> tags now have a <code>data-type</code> attribute since Chrome cannot case-sensitively match e.g. <code>type="a"</code> vs <code>type="A"</code> for lowercase or uppercase letters. I previously only tested the code in Firefox and there it worked nicely.</li>
</ul>
Thoughts about Django-based content management systemshttps://406.ch/writing/thoughts-about-django-based-content-management-systems/2025-11-05T12:00:00Z2025-11-05T12:00:00Z<h1 id="thoughts-about-django-based-content-management-systems"><a class="toclink" href="#thoughts-about-django-based-content-management-systems">Thoughts about Django-based content management systems</a></h1>
<p>I have almost exclusively used Django for implementing content management
systems (and other backends) since 2008.</p>
<p>In this time, content management systems have come and gone. The big three
systems many years back were <a href="https://www.django-cms.org/">django CMS</a>,
<a href="https://github.com/stephenmcd/mezzanine">Mezzanine</a> and
<a href="https://feinheit.ch">our</a> own
<a href="https://406.ch/writing/the-future-of-feincms/">FeinCMS</a>.</p>
<p>During all this time I have always kept an eye open for other CMS than our own
but have steadily continued working in my small corner of the Django space. I
think it’s time to write down why I have been doing this all this time, for
myself and possibly also for other interested parties.</p>
<h2 id="why-not-use-wagtail-django-cms-or-any-of-those-alternatives"><a class="toclink" href="#why-not-use-wagtail-django-cms-or-any-of-those-alternatives">Why not use Wagtail, django CMS or any of those alternatives?</a></h2>
<p>Let’s start with the big one. Why not use Wagtail?</p>
<p>The Django administration interface is actually great. Even though some people
say that it should be treated as a tool for developers only, recent
improvements to the accessibility and the general usability suggest otherwise.
I have written more about my views on this in <a href="https://406.ch/writing/the-django-admin-is-a-cms/">The Django admin is a
CMS</a>. Using and building on
top of the Django admin is a great way to immediately profit from all current
and future improvements without having to reimplement anything.</p>
<p>I don’t want to have to reimplement Django’s features, I want to add what I
need on top.</p>
<h2 id="faster-updates"><a class="toclink" href="#faster-updates">Faster updates</a></h2>
<p>Everyone implementing and maintaining other CMS is doing a great job and I
don’t want to throw any shade. I still feel that it’s important to point out
that systems can make it hard to adopt new Django versions on release day:</p>
<ul>
<li>The update cycle of many large apps using Django lag behind Django. Wagtail
declares an <a href="https://github.com/wagtail/wagtail/discussions/12574">upper version boundary for
Django</a> which makes it
hard to adopt Django versions faster than Wagtail releases updates.</li>
<li>Some django CMS components such as
<a href="https://github.com/django-cms/django-filer">django-filer</a> have lagged behind
in the past. Looking at the project’s CI matrix and activity suggests that
this is not the case anymore. That said, a <a href="https://406.ch/writing/django-cabinet-a-media-library-for-django/">simpler alternative
exists</a>.</li>
</ul>
<p>These larger systems have many more (very talented) people working on them. I’m
not saying I’m doing a better job. I’m only pointing out that I’m following a
different philosophy where I’m <a href="https://406.ch/writing/run-less-code-in-production-or-youll-end-up-paying-the-price-later/">conservative about running code in
production</a>
and I’d rather <a href="https://406.ch/writing/low-maintenance-software/">have less features when the price is a lot of maintenance
later</a>. I’m always thinking
about long term maintenance. I really don’t want to maintain some of these
larger projects, or even parts of them. So I’d rather not adopt them for
projects which hopefully will be developed and maintained for a long time to
come. By the way: This experience has been earned the hard way.</p>
<h2 id="the-rule-of-least-power"><a class="toclink" href="#the-rule-of-least-power">The rule of least power</a></h2>
<p>From <a href="https://en.wikipedia.org/wiki/Rule_of_least_power">Wikipedia</a>:</p>
<blockquote>
<p>In programming, the rule of least power is a design principle that “suggests choosing the least powerful [computer] language suitable for a given purpose”. Stated alternatively, given a choice among computer languages, classes of which range from descriptive (or declarative) to procedural, the less procedural, more descriptive the language one chooses, the more one can do with the data stored in that language.</p>
</blockquote>
<p>Django itself already provides lots and lots of power. I’d argue that a very
powerful platform on top of Django may be too much of a good thing. I’d rather
keep it simple and stupid.</p>
<h2 id="editing-heterogenous-collections-of-content"><a class="toclink" href="#editing-heterogenous-collections-of-content">Editing heterogenous collections of content</a></h2>
<p>Django admin’s inlines are great, but they are not sufficient for building a
CMS. You need something to manage different types. django-content-editor does
that and has done that since 2009.</p>
<p><a href="https://torchbox.com/blog/rich-text-fields-and-faster-horses/">When Wagtail introduced the StreamField in
2015</a> it was
definitely a great update to an already great CMS but it wasn’t a new idea
generally and not a new thing in Django land. They didn’t say it was and
<a href="https://406.ch/writing/i-just-learned-about-wagtail-s-streamfield/">welcomed the fact that they also started using a better way to structure
content</a>.</p>
<p>Structured content is great. Putting everything into one large rich text area
isn’t what I want. Django’s ORM and admin interface are great for actually
modelling the data in a reusable way. And when you need more flexibility than
what’s offered by Django’s forms, the community offers many projects extending
the admin. These days, I really like working with the
<a href="https://406.ch/writing/django-json-schema-editor/">django-json-schema-editor</a>
component; I even reference other model instances in the database and let the
JSON editor handle the referential integrity transparently for me (so that
referenced model instances do not silently disappear).</p>
<h2 id="more-reading"><a class="toclink" href="#more-reading">More reading</a></h2>
<p><a href="https://406.ch/writing/the-future-of-feincms/">The future of FeinCMS</a> and the <a href="https://406.ch/writing/category-feincms/">feincms category</a> may be interesting. Also, I’d love to talk about these thoughts, either by email or on Mastodon.</p>
Weeknotes (2025 week 45)https://406.ch/writing/weeknotes-2025-week-45/2025-11-04T12:00:00Z2025-11-04T12:00:00Z<h1 id="weeknotes-2025-week-45"><a class="toclink" href="#weeknotes-2025-week-45">Weeknotes (2025 week 45)</a></h1>
<h2 id="autumn-is-nice"><a class="toclink" href="#autumn-is-nice">Autumn is nice</a></h2>
<p>I love walking through the forest with all the colors and the rustling when you walk through the leaves on the ground.</p>
<h2 id="updated-packages-since-2025-10-23"><a class="toclink" href="#updated-packages-since-2025-10-23">Updated packages since 2025-10-23</a></h2>
<ul>
<li><a href="https://pypi.org/project/feincms3/">feincms3 5.4.3</a>: Small fix for the
YouTube IFRAME; it seems that the <code>referrerpolicy</code> attribute is now necessary
for the embed to work everywhere.</li>
<li><a href="https://pypi.org/project/django-json-schema-editor/">django-json-schema-editor 0.8.2</a>:
Allowed forwarding more options to the prose editor component; specifically,
not just <code>extensions</code> but also the undocumented <code>js_modules</code> entry. This
means that custom extensions are now also supported inside the JSON editor
component.</li>
<li><a href="https://pypi.org/project/django-prose-editor/">django-prose-editor 0.20</a>:
I reworked the menu extension to be customizable more easily (you can now specify <a href="https://django-prose-editor.readthedocs.io/en/latest/menu.html#defining-button-groups-and-dropdowns">button groups and dropdowns</a> directly without using JavaScript code) and I also extended the <a href="https://django-prose-editor.readthedocs.io/en/latest/nodeclass.html"><code>NodeClass</code> extension</a> to allow assigning predefined CSS classes not only to nodes but also to marks.</li>
</ul>
Weeknotes (2025 week 43)https://406.ch/writing/weeknotes-2025-week-43/2025-10-23T12:00:00Z2025-10-23T12:00:00Z<h1 id="weeknotes-2025-week-43"><a class="toclink" href="#weeknotes-2025-week-43">Weeknotes (2025 week 43)</a></h1>
<p>I published the last weeknotes entry in the first half of September.</p>
<h2 id="drama-in-oss"><a class="toclink" href="#drama-in-oss">Drama in OSS</a></h2>
<p>I have been following the Ruby gems debacle a bit. Initially at <a href="https://feinheit.ch/">Feinheit</a> we used our own PHP-based framework <a href="https://github.com/matthiask/swisdk2">swisdk2</a> to build websites. This obviously didn’t scale and I was very annoyed with PHP, so I was looking for alternatives.</p>
<p>I remember comparing Ruby on Rails and Django, and decided to switch from PHP/swisdk2 to Python/Django for two reasons: The automatically generated admin interface and the fact that Ruby source code just had too much punctuation characters for my taste. It’s a very whimsical reason and I do not put any weight on that. That being said, given how some of the exponents in Ruby/Rails land behave I’m very very glad to have chosen Python and Django. While not everything is perfect (it never is) at least those communities agree that trying to behave nicely to each other is something to be cheered and not something to be sneered at.</p>
<h2 id="copilot"><a class="toclink" href="#copilot">Copilot</a></h2>
<p>I assigned some GitHub issues to Copilot. The result wasn’t very useful. I don’t know if I want to repeat it, local tools work fine for when I really need them.</p>
<h2 id="python-and-django-compatibility"><a class="toclink" href="#python-and-django-compatibility">Python and Django compatibility</a></h2>
<p>It’s the time again to update the GitHub actions matrix and Trove identifiers. I do not like doing it. You can expect all maintained packages to be compatible with the latest and best versions, no upper bounds necessary. Man, if only AI could automate <em>those</em> tasks…</p>
<h2 id="updated-packages-since-2025-09-10"><a class="toclink" href="#updated-packages-since-2025-09-10">Updated packages since 2025-09-10</a></h2>
<ul>
<li><a href="https://pypi.org/project/feincms3/">feincms3</a>: We use <code>hashlib.md5</code>, but not for security.</li>
<li><a href="https://pypi.org/project/django-tree-queries/">django-tree-queries 0.21.2</a>: django-tree-queries now bundles an admin interface for trees.</li>
<li><a href="https://pypi.org/project/django-content-editor/">django-content-editor 8.0.3</a>: Minor CSS fix so that the editor looks nicer with Django 6.0.</li>
<li><a href="https://pypi.org/project/django-json-schema-editor/">django-json-schema-editor 0.8.1</a>: Fixes for multiple JSON editors in the same window, better <code>JSONPlugin.__str__</code> default implementation.</li>
<li><a href="https://pypi.org/project/django-cabinet/">django-cabinet 0.18.1</a>: Fix a minor bug around the selected folder handling. Invalid folder ID values could crash the backend under some circumstances.</li>
<li><a href="https://pypi.org/project/django-prose-editor/">django-prose-editor 0.18.5</a>: No large changes, mainly Tiptap updates.</li>
</ul>
My favorite Django packageshttps://406.ch/writing/my-favorite-django-packages/2025-10-22T12:00:00Z2025-10-22T12:00:00Z<h1 id="my-favorite-django-packages"><a class="toclink" href="#my-favorite-django-packages">My favorite Django packages</a></h1>
<p>Inspired by other posts I also wanted to write up a list of my favorite Django
packages. Since I’ve been working in this space for so long and since I’m
maintaining quite a large list of packages I worry a bit about tooting my own
horn too much here; that said, the reasons for choosing some packages hopefully
speak for themselves.</p>
<p>Also, I’m sure I’m forgetting many many packages here. Sorry for that in advance.</p>
<h2 id="core-django"><a class="toclink" href="#core-django">Core Django</a></h2>
<ul>
<li><a href="https://pypi.org/project/speckenv/">speckenv</a>: Loads environment variables from <code>.env</code> and automatically converts them to their Python equivalent if <a href="https://docs.python.org/3/library/ast.html#ast.literal_eval"><code>ast.literal_eval</code></a> understands it. Also contains implementations for loading database, cache, email and storage configuration from environment variables (similar to dj-database-url). I added this functionality to speckenv when some of the available environment configuration apps’ maintenance state was somewhat questionable.</li>
<li><a href="https://pypi.org/project/django-cors-headers/">django-cors-headers</a>: CORS header support for Django. This would be a nice addition to Django itself.</li>
<li><a href="https://pypi.org/project/sentry-sdk/">sentry-sdk</a>: I’m using Sentry in almost all projects.</li>
<li><a href="https://pypi.org/project/django-template-partials/">django-template-partials</a>: Template partials for Django! This has been added to the upcoming 6.0 release of Django. While the Django template language has always been evolving and improving, this feels like the first larger step in a long time. As I and others have said, the combination of <a href="https://htmx.org/">htmx</a> and django-template-partials is really really nice. It isn’t surprising at all that htmx is so well loved in the Django community.</li>
<li><a href="https://pypi.org/project/django-s3-storage/">django-s3-storage</a>: Yes, django-storages exist, but I prefer django-s3-storage because of its focus and simplicity.</li>
<li><a href="https://pypi.org/project/django-imagefield/">django-imagefield</a>: Image field which validates images deeply, and supports pre-rendering thumbnails etc. I understand why Django only superficially validates uploaded images because of the danger of denial of service attacks, but I’d rather not have files on the sites I manage which the great <a href="https://pillow.readthedocs.io/">Pillow</a> library doesn’t support.</li>
<li><a href="https://pypi.org/project/psycopg/">psycopg</a>: Whenever I can I work with PostgreSQL, and psycopg is how I interface with the database.</li>
</ul>
<h2 id="data-structures"><a class="toclink" href="#data-structures">Data structures</a></h2>
<ul>
<li><a href="https://pypi.org/project/django-tree-queries/">django-tree-queries</a>: My
favorite way to work with trees except when talking about real trees.</li>
<li><a href="https://pypi.org/project/django-translated-fields/">django-translated-fields</a>: My preferred way to do model-level internationalization.</li>
</ul>
<h2 id="cms-building"><a class="toclink" href="#cms-building">CMS building</a></h2>
<p>I have been working on <a href="https://github.com/feincms/feincms/">FeinCMS</a> since 2009. So, it shouldn’t surprise anyone that this is still my favorite way to
build CMS on top of Django. I like that it’s basically a thin layer on top of
Django’s administration interface and doesn’t want to take over the whole admin
interface or even the whole website.</p>
<ul>
<li><a href="https://pypi.org/project/feincms3/">feincms3</a>: The modern, focussed foundation replacing FeinCMS.</li>
<li><a href="https://pypi.org/project/django-content-editor/">django-content-editor</a>: The admin interface extension providing core components to build content editing interfaces.</li>
<li><a href="https://pypi.org/project/django-prose-editor/">django-prose-editor</a>: A nice HTML editor by yours truly.</li>
<li><a href="https://pypi.org/project/django-json-schema-editor/">django-json-schema-editor</a>: JSON schema-based editing using <a href="https://www.npmjs.com/package/@json-editor/json-editor">@json-editor/json-editor</a>.</li>
</ul>
<h2 id="working-with-external-content"><a class="toclink" href="#working-with-external-content">Working with external content</a></h2>
<ul>
<li><a href="https://pypi.org/project/micawber/">micawber</a>: Micawber is my favorite tool to embed third party content (YouTube, Vimeo, whatever) when feincms3’s own very limited embedding functionality is insufficient.</li>
<li><a href="https://pypi.org/project/feincms3-cookiecontrol/">feincms3-cookiecontrol</a>: Everyone likes cookie banner (or not). feincms3-cookiecontrol implements not just an informative cookie banner, but actually supports not embedding tracking scripts and third party content unless the user consented, either a blanket or a per-provider consent for embedded media. It does NOT support the very annoying interface where you have to deselect each tracking service individually.</li>
</ul>
<h2 id="pdf-generation"><a class="toclink" href="#pdf-generation">PDF generation</a></h2>
<ul>
<li><a href="https://pypi.org/project/reportlab/">Reportlab</a>: Reportlab is nice if you need fine-grained control over PDF generation. Reportlab has created more than 10’000 invoices for the company I work at, so I’m definitely grateful for it :-)</li>
<li><a href="https://weasyprint.org/">Weasyprint</a>: I have been using Weasyprint more and more for generating PDFs. Using HTML and CSS can be nicer than using Reportlab’s Platypus module. Weasyprint doesn’t instrument a webbrowser to produce PDFs but instead implements the rendering engine itself. It works really well.</li>
</ul>
<h2 id="testing-and-development"><a class="toclink" href="#testing-and-development">Testing and development</a></h2>
<ul>
<li>I actually do like unittest. I have started using <a href="https://docs.pytest.org/en/stable/">pytest</a> somewhat more often because using <code>-k keyword</code> to filter tests to run is great.</li>
<li><a href="https://pypi.org/project/factory-boy/">factory-boy</a>: This package has always treated me well when creating data for tests.</li>
<li><a href="https://playwright.dev/">playwright</a>: Playwright is the end-to-end test browser automation library I prefer.</li>
</ul>
<p>Last but not least, I really like <a href="https://pypi.org/project/django-debug-toolbar/">django-debug-toolbar</a>. So much, that I’m even helping with the maintenance since 2016.</p>
<h2 id="serving"><a class="toclink" href="#serving">Serving</a></h2>
<p>We mostly use Kubernetes to serve websites these days. Inside the pods, I’m working with the <a href="https://pypi.org/project/granian/">granian</a> RSGI/ASGI server and with <a href="https://pypi.org/project/blacknoise/">blacknoise</a> for serving static files.</p>
LLMs are making me a better programmer...https://406.ch/writing/llms-are-making-me-a-better-programmer/2025-09-12T12:00:00Z2025-09-12T12:00:00Z<h1 id="llms-are-making-me-a-better-programmer"><a class="toclink" href="#llms-are-making-me-a-better-programmer">LLMs are making me a better programmer…</a></h1>
<p>I’m still undecided about LLMs for programming. Sometimes they are very useful, especially when working on a clearly stated problem within a delimited area. Cleaning the code up afterwards is painful and takes a long time though. Even for small changes I’m unsure if using LLMs is a way to save (any) resources, be it time, water, energy or whatever.</p>
<p>They do help me get started, and help me be more ambitious. That’s not a new idea. <a href="https://simonwillison.net/2023/Mar/27/ai-enhanced-development/">Simon Willison wrote a post about this in 2023</a> and the more I think about it or work with AI the more I think it’s a good way to look at it.</p>
<p>A recent example which comes to mind is writing end to end tests. I can’t say I had a love-hate relationship with end to end testing, it was mostly a hate-hate relationship. I hate writing them because it’s so tedious and I hate debugging them because of all the timing issues and the general flakyness of end to end testing. And I especially hate the fact that those tests break all the time when changing the code, even when changes are mostly unrelated.</p>
<p>When I discovered that I could just use Claude Code to write those end to end tests I was ecstatic. Finally a way to add relevant tests to some of my open source projects without having to do all this annoying work myself! Unfortunately, I quickly discovered that Claude Code decided (ha!) it’s more important to make tests pass than actually exercising the functionality in question. When some HTML/JavaScript widget wouldn’t initialize, why not just manipulate <code>innerHTML</code> so that the DOM looks as if the JavaScript actually ran? Of course, that’s a completely useless test. The amount of prodding and instructing the LLM agent required to stop adding workarounds and fallbacks everywhere was mindboggling. Also, since tests are also code which has to be maintained in the future, does generating a whole lot of code actually help or not? Of course, the amount of code involved wasn’t exactly a big help when I really had to dig into the code to debug a gnarly issue, and the way the test was written didn’t exactly help!</p>
<p>I didn’t want to go back to the previous state of things when I had only backend tests though, so I had to find a better way.</p>
<h2 id="playwright-codegen-to-the-rescue"><a class="toclink" href="#playwright-codegen-to-the-rescue">Playwright codegen to the rescue</a></h2>
<p>I already had some experience with <a href="https://playwright.dev/docs/codegen-intro">Playwright codegen</a>, having used it for testing some complex onboarding code for a client project I worked on a few years back, so I was already aware of the fact that I could run the browser, click through the interface myself, and playwright would actually generate some of the required Python code for the test itself.</p>
<p>This worked fine for a project, but what about libraries? There, I generally do not have a full project ready to be used with <code>./manage.py runserver</code> and Playwright. So, I needed a different solution: Running Playwright from inside a test!</p>
<p>If your test uses the <a href="https://docs.djangoproject.com/en/5.2/topics/testing/tools/#django.test.LiveServerTestCase"><code>LiveServerTestCase</code></a> all you have to do is insert the following lines into the body of your test, directly after creating the necessary data in the database (using fixtures, or probably better yet using something like <a href="https://pypi.org/project/factory-boy/">factory-boy</a>):</p>
<div class="chl"><pre><span></span><code><span class="kn">import</span><span class="w"> </span><span class="nn">subprocess</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">"Live server URL: </span><span class="si">{</span><span class="n">live_server</span><span class="o">.</span><span class="n">url</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
<span class="n">subprocess</span><span class="o">.</span><span class="n">Popen</span><span class="p">([</span><span class="s2">"playwright"</span><span class="p">,</span> <span class="s2">"codegen"</span><span class="p">,</span> <span class="sa">f</span><span class="s2">"</span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">live_server_url</span><span class="si">}</span><span class="s2">/admin/"</span><span class="p">])</span>
<span class="nb">input</span><span class="p">(</span><span class="s2">"Press Enter when done with codegen..."</span><span class="p">)</span>
</code></pre></div>
<p>Or of course the equivalent invocation using <code>live_server.url</code> when using the <code>live_server</code> fixture from <a href="https://pytest-django.readthedocs.io/en/latest/helpers.html#live-server">pytest-django</a>.</p>
<p>Of course Tim <a href="https://mastodon.social/@CodenameTim/115096138737981083">pointed me towards <code>page.pause()</code></a>. I didn’t know about it; I think it’s even better than what I discovered, so I’m probably going to use that one instead. I still think writing down the discovery process makes sense.</p>
<p>So now, when <code>LiveServerTestCase</code> is already set up and I already have a sync Playwright context lying around, I can just do:</p>
<div class="chl"><pre><span></span><code><span class="n">page</span> <span class="o">=</span> <span class="n">context</span><span class="o">.</span><span class="n">new_page</span><span class="p">()</span>
<span class="n">page</span><span class="o">.</span><span class="n">pause</span><span class="p">()</span>
</code></pre></div>
<h2 id="tldr"><a class="toclink" href="#tldr">TLDR</a></h2>
<p>Claude Code helped getting me to get off the ground with adding end to end tests to my projects. Now, my tests are better because – at least for now – I’m not using AI tools anymore.</p>
Weeknotes (2025 week 37)https://406.ch/writing/weeknotes-2025-week-37/2025-09-10T12:00:00Z2025-09-10T12:00:00Z<h1 id="weeknotes-2025-week-37"><a class="toclink" href="#weeknotes-2025-week-37">Weeknotes (2025 week 37)</a></h1>
<p>I’m having a slow week after the last wisdom tooth extraction. Finally! I’m slowly recuperating from that.</p>
<p>I’m trying to split up the blog posts a bit and writing more standalone pieces instead of putting everything into weeknotes. Publishing more focussed pieces sounds like a good thing and should also help me with finding my own writing later.</p>
<h2 id="releases"><a class="toclink" href="#releases">Releases</a></h2>
<ul>
<li><a href="https://pypi.org/project/django-content-editor/">django-content-editor 8.0.2</a>: I fixed the ordering calculation in the cloning functionality; the tests are a bit too forgiving for my taste now but I just can’t figure out why the gap for inserting cloned items is sometimes larger than it should be. It doesn’t matter though, since ordering values do not have any significance, they only have to provide a definite ordering for content items.</li>
<li><a href="https://pypi.org/project/django-prose-editor/">django-prose-editor 0.18.2</a>: Cleaned up the documentation after the 0.18 cleanup (where automatic dependency management has been removed), fixed table styles when using dark mode.</li>
</ul>