<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: django-admin</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/tags/django-admin.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2025-03-13T15:02:09+00:00</updated><author><name>Simon Willison</name></author><entry><title>Smoke test your Django admin site</title><link href="https://simonwillison.net/2025/Mar/13/smoke-test-your-django-admin/#atom-tag" rel="alternate"/><published>2025-03-13T15:02:09+00:00</published><updated>2025-03-13T15:02:09+00:00</updated><id>https://simonwillison.net/2025/Mar/13/smoke-test-your-django-admin/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://jmduke.com/posts/post/django-admin-changelist-test/"&gt;Smoke test your Django admin site&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Justin Duke demonstrates a neat pattern for running simple tests against your internal Django admin site: introspect every admin route via &lt;code&gt;django.urls.get_resolver()&lt;/code&gt; and loop through them with &lt;code&gt;@pytest.mark.parametrize&lt;/code&gt; to check they all return a 200 HTTP status code.&lt;/p&gt;
&lt;p&gt;This catches simple mistakes with the admin configuration that trigger exceptions that might otherwise go undetected.&lt;/p&gt;
&lt;p&gt;I rarely write automated tests against my own admin sites and often feel guilty about it. I wrote &lt;a href="https://til.simonwillison.net/django/testing-django-admin-with-pytest"&gt;some notes&lt;/a&gt; on testing it with &lt;a href="https://pytest-django.readthedocs.io/en/latest/helpers.html#fixtures"&gt;pytest-django fixtures&lt;/a&gt; a few years ago.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/testing"&gt;testing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pytest"&gt;pytest&lt;/a&gt;&lt;/p&gt;



</summary><category term="django"/><category term="django-admin"/><category term="python"/><category term="testing"/><category term="pytest"/></entry><entry><title>My approach to running a link blog</title><link href="https://simonwillison.net/2024/Dec/22/link-blog/#atom-tag" rel="alternate"/><published>2024-12-22T18:37:16+00:00</published><updated>2024-12-22T18:37:16+00:00</updated><id>https://simonwillison.net/2024/Dec/22/link-blog/#atom-tag</id><summary type="html">
    &lt;p&gt;I started running a basic link blog on this domain &lt;a href="https://simonwillison.net/2003/Nov/24/blogmarks/"&gt;back in November 2003&lt;/a&gt; - publishing links (which I called "blogmarks") with a title, URL, short snippet of commentary and a "via" link where appropriate.&lt;/p&gt;
&lt;p&gt;So far I've published &lt;a href="https://simonwillison.net/search/?type=blogmark"&gt;7,607 link blog posts&lt;/a&gt; and counting.&lt;/p&gt;
&lt;p&gt;In April of this year I finally &lt;a href="https://simonwillison.net/2024/Apr/25/blogmarks-that-use-markdown/"&gt;upgraded my link blog to support Markdown&lt;/a&gt;, allowing me to expand my link blog into something with a lot more room.&lt;/p&gt;
&lt;p&gt;The way I use my link blog has evolved substantially in the eight months since then. I'm going to describe the informal set of guidelines I've set myself for how I link blog, in the hope that it might encourage other people to give this a try themselves.&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Dec/22/link-blog/#writing-about-things-i-ve-found"&gt;Writing about things I've found&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Dec/22/link-blog/#trying-to-add-something-extra"&gt;Trying to add something extra&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Dec/22/link-blog/#the-technology"&gt;The technology&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2024/Dec/22/link-blog/#more-people-should-do-this"&gt;More people should do this&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="writing-about-things-i-ve-found"&gt;Writing about things I've found&lt;/h4&gt;
&lt;p&gt;Back in November 2022 I wrote &lt;a href="https://simonwillison.net/2022/Nov/6/what-to-blog-about/"&gt;What to blog about&lt;/a&gt;, which started with this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;You should start a blog. Having your own little corner of the internet is good for the soul!&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The point of that article was to emphasize that blogging doesn't have to be about unique insights. The value is in writing frequently and having something to show for it over time - worthwhile even if you don't attract much of an audience (or any audience at all).&lt;/p&gt;
&lt;p&gt;In that article I proposed two categories of content that are low stakes and high value: &lt;strong&gt;things I learned&lt;/strong&gt; and &lt;strong&gt;descriptions of my projects&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;I realize now that link blogging deserves to be included a third category of low stakes, high value writing. We could think of that category as &lt;strong&gt;things I've found&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That's the purpose of my link blog: it's an ongoing log of things I've found - effectively a combination of public bookmarks and my own thoughts and commentary on why those things are interesting.&lt;/p&gt;
&lt;h4 id="trying-to-add-something-extra"&gt;Trying to add something extra&lt;/h4&gt;
&lt;p&gt;When I first started link blogging I would often post a link with a one sentence summary of the linked content, and maybe a tiny piece of opinionated commentary.&lt;/p&gt;
&lt;p&gt;After I upgraded my link blog to support additional markup (links, images, quotations) I decided to be more ambitious. Here are some of the things I try to do:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I always include &lt;strong&gt;the names of the people&lt;/strong&gt; who created the content I am linking to, if I can figure that out. Credit is really important, and it's also useful for myself because I can later search for someone's name and find other interesting things they have created that I linked to in the past. If I've linked to someone's work three or more times I also try to notice and upgrade them to &lt;a href="https://simonwillison.net/tags/"&gt;a dedicated tag&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;I try to &lt;strong&gt;add something extra&lt;/strong&gt;. My goal with any link blog post is that if you read both my post and the source material you'll have an enhanced experience over if you read just the source material itself.
&lt;ul&gt;
&lt;li&gt;Ideally I'd like you to take something useful away even if you don't follow the link itself. This can be a slightly tricky balance: I don't want to steal attention from the authors and plagiarize their message. Generally I'll try to find some key idea that's worth emphasizing. Slightly cynically, I may try to capture that idea as backup against the original source vanishing from the internet. Link rot is real!&lt;/li&gt;
&lt;li&gt;My most basic version of this is trying to provide context as to why I think this particular thing is worth reading - especially important for longer content. A good recent example is my post about Anthropic's &lt;a href="https://simonwillison.net/2024/Dec/20/building-effective-agents/"&gt;Building effective agents&lt;/a&gt; essay the other day.&lt;/li&gt;
&lt;li&gt;I might tie it together to other similar concepts, including things I've written about in the past, for example linking &lt;a href="https://simonwillison.net/2024/Aug/14/prompt-caching-with-claude/"&gt;Prompt caching with Claude&lt;/a&gt; to my coverage of &lt;a href="https://simonwillison.net/2024/May/14/context-caching-for-google-gemini/"&gt;Context caching for Google Gemini&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;If part of the material is a video, I might &lt;strong&gt;quote a snippet of the transcript&lt;/strong&gt; (often extracted using MacWhisper) like I did in &lt;a href="https://simonwillison.net/2024/Dec/12/clio/"&gt;this post about Anthropic's Clio&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;A lot of stuff I link to involves programming. I'll often include a &lt;strong&gt;direct link to relevant code&lt;/strong&gt;, using the GitHub feature where I can link to a snippet as-of a particular commit. One example is the &lt;a href="https://simonwillison.net/2024/Oct/5/uv-with-github-actions-to-run-an-rss-to-readme-project/"&gt;fetch-rss.py link in this post&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;I'm liberal with &lt;strong&gt;quotations&lt;/strong&gt;. Finding and quoting a paragraph that captures the key theme of a post is a very quick and effective way to summarize it and help people decide if it's worth reading the whole thing. My post on &lt;a href="https://simonwillison.net/2024/Dec/20/openai-o3-breakthrough/"&gt;François Chollet's o3 ARC-AGI analysis&lt;/a&gt; is an example of that.&lt;/li&gt;
&lt;li&gt;If the original author reads my post, I want them to &lt;strong&gt;feel good about it&lt;/strong&gt;. I know from my own experience that often when you publish something online the silence can be deafening. Knowing that someone else read, appreciated, understood and then shared your work can be very pleasant.&lt;/li&gt;
&lt;li&gt;A slightly self-involved concern I have is that I like to &lt;strong&gt;prove that I've read it&lt;/strong&gt;. This is more for me than for anyone else: I don't like to recommend something if I've not read that thing myself, and sticking in a detail that shows I read past the first paragraph helps keep me honest about that.&lt;/li&gt;
&lt;li&gt;I've started leaning more into &lt;strong&gt;screenshots&lt;/strong&gt; and even short video or audio clips. A screenshot can be considered a visual quotation - I'll sometimes snap these from interesting frames in a YouTube video or live demo associated with the content I'm linking to. I used a screenshot of the Clay debugger in &lt;a href="https://simonwillison.net/2024/Dec/21/clay-ui-library/"&gt;my post about Clay&lt;/a&gt;.&lt;/li&gt;
&lt;p style="margin-top: 0.5em"&gt;There are a lot of great link blogs out there, but the one that has influenced me the most in how I approach my own is John Gruber's &lt;a href="https://daringfireball.net/"&gt;Daring Fireball&lt;/a&gt;. I really like the way he mixes commentary, quotations and value-added relevant information.&lt;/p&gt;
&lt;/ul&gt;
&lt;h4 id="the-technology"&gt;The technology&lt;/h4&gt;
&lt;p&gt;The technology behind my link blog is probably the least interesting thing about it. It's part of my &lt;a href="https://github.com/simonw/simonwillisonblog"&gt;simonwillisonblog&lt;/a&gt; Django application - the main model is called &lt;a href="https://github.com/simonw/simonwillisonblog/blob/c781a1a42ab0a0237f75c7790f069bacc2d70d3f/blog/models.py#L328-L337"&gt;Blogmark&lt;/a&gt; and it inherits from a &lt;a href="https://github.com/simonw/simonwillisonblog/blob/c781a1a42ab0a0237f75c7790f069bacc2d70d3f/blog/models.py#L172-L203"&gt;BaseModel&lt;/a&gt; defining things like tags and draft modes that are shared across my other types of content (entries and quotations).&lt;/p&gt;
&lt;p&gt;I use the Django Admin to create and edit entries, &lt;a href="https://github.com/simonw/simonwillisonblog/blob/c781a1a42ab0a0237f75c7790f069bacc2d70d3f/blog/admin.py#L73-L76"&gt;configured here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The most cumbersome part of link blogging for me right now is images. I convert these into smaller JPEGs using a &lt;a href="https://tools.simonwillison.net/image-resize-quality"&gt;tiny custom tool&lt;/a&gt; I built (&lt;a href="https://gist.github.com/simonw/58a06a8028515999e5949a0166cd4c4f"&gt;with Claude&lt;/a&gt;), then upload them to my &lt;code&gt;static.simonwillison.net&lt;/code&gt; S3 bucket using Transmit and drop them into my posts using a Markdown image reference. I generate a first draft of the alt text using a Claude Project with &lt;a href="https://gist.github.com/simonw/1fa7e4e3dcb18fdeca2b3d6ac2c6c628"&gt;these custom instructions&lt;/a&gt;, then usually make a few changes  before including that in the markup. At some point I'll wire together a UI that makes this process a little smoother.&lt;/p&gt;
&lt;p&gt;That &lt;code&gt;static.simonwillison.net&lt;/code&gt; bucket is then served via Cloudflare's free tier, which means I effectively never have to think about the cost of serving up those image files.&lt;/p&gt;
&lt;p&gt;I wrote up a TIL about &lt;a href="https://til.simonwillison.net/django/building-a-blog-in-django"&gt;Building a blog in Django&lt;/a&gt; a while ago which describes a similar setup to the one I'm using for my link blog, including how the RSS feed works (using &lt;a href="https://docs.djangoproject.com/en/4.2/ref/contrib/syndication/"&gt;Django's syndication framework&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;The most technically interesting component is my &lt;a href="https://simonwillison.net/search/?type=blogmark"&gt;search feature&lt;/a&gt;. I wrote about how that works in &lt;a href="https://simonwillison.net/2017/Oct/5/django-postgresql-faceted-search/"&gt;Implementing faceted search with Django and PostgreSQL&lt;/a&gt; - the most recent code for that can be found in &lt;a href="https://github.com/simonw/simonwillisonblog/blob/main/blog/search.py"&gt;blog/search.py&lt;/a&gt; on GitHub.&lt;/p&gt;
&lt;p&gt;One of the most useful small enhancements I added was &lt;a href="https://github.com/simonw/simonwillisonblog/issues/488"&gt;draft mode&lt;/a&gt;, which lets me assign a URL to an item and preview it in my browser without publishing it to the world. This really helps when I am editing posts on my mobile phone as it gives me a reliable preview so I can check for any markup mistakes.&lt;/p&gt;
&lt;p&gt;I also send out an approximately weekly &lt;a href="https://simonw.substack.com/"&gt;email newsletter&lt;/a&gt; version of my blog, for people who want to subscribe in their inbox. This is a straight copy of content from my blog - Substack doesn't have an API for this but their editor does accept copy and paste, so I have a delightful digital duct tape solution for assembling the newsletter which I described in &lt;a href="https://simonwillison.net/2023/Apr/4/substack-observable/"&gt;Semi-automating a Substack newsletter with an Observable notebook&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="more-people-should-do-this"&gt;More people should do this&lt;/h4&gt;
&lt;p&gt;I posted this on Bluesky &lt;a href="https://bsky.app/profile/simonwillison.net/post/3ldu6jywnos2j"&gt;last night&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I wish people would post more links to interesting things&lt;/p&gt;
&lt;p&gt;I feel like Twitter and LinkedIn and Instagram and TikTok have pushed a lot of people out of the habit of doing that, by penalizing shared links in the various "algorithms"&lt;/p&gt;
&lt;p&gt;Bluesky doesn't have that misfeature, thankfully!&lt;/p&gt;
&lt;p&gt;(In my ideal world everyone would get their own link blog too, but sharing links on Bluesky and Mastodon is almost as good)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Sharing interesting links with commentary is a low effort, high value way to contribute to internet life at large.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/blogging"&gt;blogging&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/john-gruber"&gt;john-gruber&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="blogging"/><category term="django"/><category term="django-admin"/><category term="john-gruber"/></entry><entry><title>nanodjango</title><link href="https://simonwillison.net/2024/Sep/24/nanodjango/#atom-tag" rel="alternate"/><published>2024-09-24T16:08:44+00:00</published><updated>2024-09-24T16:08:44+00:00</updated><id>https://simonwillison.net/2024/Sep/24/nanodjango/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/radiac/nanodjango"&gt;nanodjango&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Richard Terry demonstrated this in a lightning talk at DjangoCon US today. It's the latest in a long line of attempts to get Django to work with a single file (I had a go at this problem 15 years ago with &lt;a href="https://github.com/simonw/djng"&gt;djng&lt;/a&gt;) but this one is &lt;em&gt;really&lt;/em&gt; compelling.&lt;/p&gt;
&lt;p&gt;I tried nanodjango out just now and it works exactly as advertised. First install it like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install nanodjango
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create a &lt;code&gt;counter.py&lt;/code&gt; file:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;django&lt;/span&gt;.&lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;nanodjango&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;Django&lt;/span&gt;

&lt;span class="pl-s1"&gt;app&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;Django&lt;/span&gt;()

&lt;span class="pl-en"&gt;@&lt;span class="pl-s1"&gt;app&lt;/span&gt;.&lt;span class="pl-s1"&gt;admin&lt;/span&gt; &lt;span class="pl-c"&gt;# Registers with the Django admin&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-k"&gt;class&lt;/span&gt; &lt;span class="pl-v"&gt;CountLog&lt;/span&gt;(&lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;Model&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;timestamp&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;DateTimeField&lt;/span&gt;(&lt;span class="pl-s1"&gt;auto_now_add&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)

&lt;span class="pl-en"&gt;@&lt;span class="pl-s1"&gt;app&lt;/span&gt;.&lt;span class="pl-en"&gt;route&lt;/span&gt;(&lt;span class="pl-s"&gt;"/"&lt;/span&gt;)&lt;/span&gt;
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;count&lt;/span&gt;(&lt;span class="pl-s1"&gt;request&lt;/span&gt;):
    &lt;span class="pl-v"&gt;CountLog&lt;/span&gt;.&lt;span class="pl-s1"&gt;objects&lt;/span&gt;.&lt;span class="pl-en"&gt;create&lt;/span&gt;()
    &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s"&gt;f"&amp;lt;p&amp;gt;Number of page loads: &lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-v"&gt;CountLog&lt;/span&gt;.&lt;span class="pl-s1"&gt;objects&lt;/span&gt;.&lt;span class="pl-en"&gt;count&lt;/span&gt;()&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;&amp;lt;/p&amp;gt;"&lt;/span&gt;&lt;/pre&gt;

&lt;p&gt;Then run it like this (it will run migrations and create a superuser as part of that first run):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nanodjango run counter.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's it! This gave me a fully configured Django application with models, migrations, the Django Admin configured and a bunch of other goodies such as &lt;a href="https://django-ninja.dev/"&gt;Django Ninja&lt;/a&gt; for API endpoints.&lt;/p&gt;
&lt;p&gt;Here's the &lt;a href="https://nanodjango.readthedocs.io/"&gt;full documentation&lt;/a&gt;.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/radiac"&gt;radiac&lt;/a&gt;&lt;/p&gt;



</summary><category term="django"/><category term="django-admin"/><category term="python"/><category term="radiac"/></entry><entry><title>django-http-debug, a new Django app mostly written by Claude</title><link href="https://simonwillison.net/2024/Aug/8/django-http-debug/#atom-tag" rel="alternate"/><published>2024-08-08T15:26:27+00:00</published><updated>2024-08-08T15:26:27+00:00</updated><id>https://simonwillison.net/2024/Aug/8/django-http-debug/#atom-tag</id><summary type="html">
    &lt;p&gt;Yesterday I finally developed something I’ve been casually thinking about building for a long time: &lt;strong&gt;&lt;a href="https://github.com/simonw/django-http-debug"&gt;django-http-debug&lt;/a&gt;&lt;/strong&gt;. It’s a reusable Django app - something you can &lt;code&gt;pip install&lt;/code&gt; into any Django project - which provides tools for quickly setting up a URL that returns a canned HTTP response and logs the full details of any incoming request to a database table.&lt;/p&gt;
&lt;p&gt;This is ideal for any time you want to start developing against some external API that sends traffic to your own site - a webhooks provider &lt;a href="https://docs.stripe.com/webhooks"&gt;like Stripe&lt;/a&gt;, or an OAuth or OpenID connect integration (my task yesterday morning).&lt;/p&gt;
&lt;p&gt;You can install it right now in your own Django app: add &lt;code&gt;django-http-debug&lt;/code&gt; to your requirements (or just &lt;code&gt;pip install django-http-debug&lt;/code&gt;), then add the following to your &lt;code&gt;settings.py&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-v"&gt;INSTALLED_APPS&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; [
    &lt;span class="pl-c"&gt;# ...&lt;/span&gt;
    &lt;span class="pl-s"&gt;'django_http_debug'&lt;/span&gt;,
    &lt;span class="pl-c"&gt;# ...&lt;/span&gt;
]

&lt;span class="pl-v"&gt;MIDDLEWARE&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; [
    &lt;span class="pl-c"&gt;# ...&lt;/span&gt;
    &lt;span class="pl-s"&gt;"django_http_debug.middleware.DebugMiddleware"&lt;/span&gt;,
    &lt;span class="pl-c"&gt;# ...&lt;/span&gt;
]&lt;/pre&gt;
&lt;p&gt;You'll need to have the Django Admin app configured as well. The result will be two new models managed by the admin - one for endpoints:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/django-http-debug-add-endpoint-2.jpg" alt="Django admin screenshot: add debug endpoint. Path is set to hello-world, status code is 200, content-type is text/plain; charset=utf-8, headers is {&amp;quot;x-hello&amp;quot;: &amp;quot;world&amp;quot;}, content is Hello world, The is base 64 checkbox is blank and the logging enabled checkbox is checked." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;And a read-only model for viewing logged requests:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/django-http-debug-logs.jpg" alt="Django admin screenshot showing a list of three logged requests to the hello-world endpoint, all three have a timestamp, method and query string - the method is GET for them all but the query string is blank for one, a=b for another and c=d for a third." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;It’s possible to disable logging for an endpoint, which means &lt;code&gt;django-http-debug&lt;/code&gt; doubles as a tool for adding things like a &lt;code&gt;robots.txt&lt;/code&gt; to your site without needing to deploy any additional code.&lt;/p&gt;
&lt;h4 id="how-it-works"&gt;How it works&lt;/h4&gt;
&lt;p&gt;The key to how this works is &lt;a href="https://github.com/simonw/django-http-debug/blob/0.2/django_http_debug/middleware.py"&gt;this piece of middleware&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;class&lt;/span&gt; &lt;span class="pl-v"&gt;DebugMiddleware&lt;/span&gt;:
    &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;__init__&lt;/span&gt;(&lt;span class="pl-s1"&gt;self&lt;/span&gt;, &lt;span class="pl-s1"&gt;get_response&lt;/span&gt;):
        &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;get_response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;get_response&lt;/span&gt;

    &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;__call__&lt;/span&gt;(&lt;span class="pl-s1"&gt;self&lt;/span&gt;, &lt;span class="pl-s1"&gt;request&lt;/span&gt;):
        &lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-en"&gt;get_response&lt;/span&gt;(&lt;span class="pl-s1"&gt;request&lt;/span&gt;)
        &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt;.&lt;span class="pl-s1"&gt;status_code&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-c1"&gt;404&lt;/span&gt;:
            &lt;span class="pl-s1"&gt;path&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;request&lt;/span&gt;.&lt;span class="pl-s1"&gt;path&lt;/span&gt;.&lt;span class="pl-en"&gt;lstrip&lt;/span&gt;(&lt;span class="pl-s"&gt;"/"&lt;/span&gt;)
            &lt;span class="pl-s1"&gt;debug_response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;debug_view&lt;/span&gt;(&lt;span class="pl-s1"&gt;request&lt;/span&gt;, &lt;span class="pl-s1"&gt;path&lt;/span&gt;)
            &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;debug_response&lt;/span&gt;:
                &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;debug_response&lt;/span&gt;
        &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This dispatches to the default &lt;code&gt;get_response()&lt;/code&gt; function, then intercepts the result and checks if it's a 404. If so, it gives the &lt;code&gt;debug_view()&lt;/code&gt; function an opportunity to respond instead - which might return &lt;code&gt;None&lt;/code&gt;, in which case that original 404 is returned to the client.&lt;/p&gt;
&lt;p&gt;That &lt;code&gt;debug_view()&lt;/code&gt; function &lt;a href="https://github.com/simonw/django-http-debug/blob/0.2/django_http_debug/views.py"&gt;looks like this&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-en"&gt;@&lt;span class="pl-s1"&gt;csrf_exempt&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;debug_view&lt;/span&gt;(&lt;span class="pl-s1"&gt;request&lt;/span&gt;, &lt;span class="pl-s1"&gt;path&lt;/span&gt;):
    &lt;span class="pl-k"&gt;try&lt;/span&gt;:
        &lt;span class="pl-s1"&gt;endpoint&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;DebugEndpoint&lt;/span&gt;.&lt;span class="pl-s1"&gt;objects&lt;/span&gt;.&lt;span class="pl-en"&gt;get&lt;/span&gt;(&lt;span class="pl-s1"&gt;path&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;path&lt;/span&gt;)
    &lt;span class="pl-k"&gt;except&lt;/span&gt; &lt;span class="pl-v"&gt;DebugEndpoint&lt;/span&gt;.&lt;span class="pl-v"&gt;DoesNotExist&lt;/span&gt;:
        &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-c1"&gt;None&lt;/span&gt;  &lt;span class="pl-c"&gt;# Allow normal 404 handling to continue&lt;/span&gt;

    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;endpoint&lt;/span&gt;.&lt;span class="pl-s1"&gt;logging_enabled&lt;/span&gt;:
        &lt;span class="pl-s1"&gt;log_entry&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;RequestLog&lt;/span&gt;(
            &lt;span class="pl-s1"&gt;endpoint&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;endpoint&lt;/span&gt;,
            &lt;span class="pl-s1"&gt;method&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;request&lt;/span&gt;.&lt;span class="pl-s1"&gt;method&lt;/span&gt;,
            &lt;span class="pl-s1"&gt;query_string&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;request&lt;/span&gt;.&lt;span class="pl-v"&gt;META&lt;/span&gt;.&lt;span class="pl-en"&gt;get&lt;/span&gt;(&lt;span class="pl-s"&gt;"QUERY_STRING"&lt;/span&gt;, &lt;span class="pl-s"&gt;""&lt;/span&gt;),
            &lt;span class="pl-s1"&gt;headers&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-en"&gt;dict&lt;/span&gt;(&lt;span class="pl-s1"&gt;request&lt;/span&gt;.&lt;span class="pl-s1"&gt;headers&lt;/span&gt;),
        )
        &lt;span class="pl-s1"&gt;log_entry&lt;/span&gt;.&lt;span class="pl-en"&gt;set_body&lt;/span&gt;(&lt;span class="pl-s1"&gt;request&lt;/span&gt;.&lt;span class="pl-s1"&gt;body&lt;/span&gt;)
        &lt;span class="pl-s1"&gt;log_entry&lt;/span&gt;.&lt;span class="pl-en"&gt;save&lt;/span&gt;()

    &lt;span class="pl-s1"&gt;content&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;endpoint&lt;/span&gt;.&lt;span class="pl-s1"&gt;content&lt;/span&gt;
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;endpoint&lt;/span&gt;.&lt;span class="pl-s1"&gt;is_base64&lt;/span&gt;:
        &lt;span class="pl-s1"&gt;content&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;base64&lt;/span&gt;.&lt;span class="pl-en"&gt;b64decode&lt;/span&gt;(&lt;span class="pl-s1"&gt;content&lt;/span&gt;)

    &lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;HttpResponse&lt;/span&gt;(
        &lt;span class="pl-s1"&gt;content&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;content&lt;/span&gt;,
        &lt;span class="pl-s1"&gt;status&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;endpoint&lt;/span&gt;.&lt;span class="pl-s1"&gt;status_code&lt;/span&gt;,
        &lt;span class="pl-s1"&gt;content_type&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;endpoint&lt;/span&gt;.&lt;span class="pl-s1"&gt;content_type&lt;/span&gt;,
    )
    &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;key&lt;/span&gt;, &lt;span class="pl-s1"&gt;value&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;endpoint&lt;/span&gt;.&lt;span class="pl-s1"&gt;headers&lt;/span&gt;.&lt;span class="pl-en"&gt;items&lt;/span&gt;():
        &lt;span class="pl-s1"&gt;response&lt;/span&gt;[&lt;span class="pl-s1"&gt;key&lt;/span&gt;] &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;value&lt;/span&gt;

    &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;It checks the database for an endpoint matching the incoming path, then logs the response (if the endpoint has &lt;code&gt;logging_enabled&lt;/code&gt; set) and returns a canned response based on the endpoint configuration.&lt;/p&gt;
&lt;p&gt;Here are the &lt;a href="https://github.com/simonw/django-http-debug/blob/0.2/django_http_debug/models.py"&gt;models&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;django&lt;/span&gt;.&lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;
&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;base64&lt;/span&gt;


&lt;span class="pl-k"&gt;class&lt;/span&gt; &lt;span class="pl-v"&gt;DebugEndpoint&lt;/span&gt;(&lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;Model&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;path&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;CharField&lt;/span&gt;(&lt;span class="pl-s1"&gt;max_length&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;255&lt;/span&gt;, &lt;span class="pl-s1"&gt;unique&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;status_code&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;IntegerField&lt;/span&gt;(&lt;span class="pl-s1"&gt;default&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;200&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;content_type&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;CharField&lt;/span&gt;(&lt;span class="pl-s1"&gt;max_length&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;64&lt;/span&gt;, &lt;span class="pl-s1"&gt;default&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"text/plain; charset=utf-8"&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;headers&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;JSONField&lt;/span&gt;(&lt;span class="pl-s1"&gt;default&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;dict&lt;/span&gt;, &lt;span class="pl-s1"&gt;blank&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;content&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;TextField&lt;/span&gt;(&lt;span class="pl-s1"&gt;blank&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;is_base64&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;BooleanField&lt;/span&gt;(&lt;span class="pl-s1"&gt;default&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;False&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;logging_enabled&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;BooleanField&lt;/span&gt;(&lt;span class="pl-s1"&gt;default&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)

    &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;__str__&lt;/span&gt;(&lt;span class="pl-s1"&gt;self&lt;/span&gt;):
        &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;path&lt;/span&gt;

    &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;get_absolute_url&lt;/span&gt;(&lt;span class="pl-s1"&gt;self&lt;/span&gt;):
        &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s"&gt;f"/&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;path&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;"&lt;/span&gt;


&lt;span class="pl-k"&gt;class&lt;/span&gt; &lt;span class="pl-v"&gt;RequestLog&lt;/span&gt;(&lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;Model&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;endpoint&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;ForeignKey&lt;/span&gt;(&lt;span class="pl-v"&gt;DebugEndpoint&lt;/span&gt;, &lt;span class="pl-s1"&gt;on_delete&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;CASCADE&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;method&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;CharField&lt;/span&gt;(&lt;span class="pl-s1"&gt;max_length&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;10&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;query_string&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;CharField&lt;/span&gt;(&lt;span class="pl-s1"&gt;max_length&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;255&lt;/span&gt;, &lt;span class="pl-s1"&gt;blank&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;headers&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;JSONField&lt;/span&gt;()
    &lt;span class="pl-s1"&gt;body&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;TextField&lt;/span&gt;(&lt;span class="pl-s1"&gt;blank&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;is_base64&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;BooleanField&lt;/span&gt;(&lt;span class="pl-s1"&gt;default&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;False&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;timestamp&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;DateTimeField&lt;/span&gt;(&lt;span class="pl-s1"&gt;auto_now_add&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)

    &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;__str__&lt;/span&gt;(&lt;span class="pl-s1"&gt;self&lt;/span&gt;):
        &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s"&gt;f"&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;method&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;endpoint&lt;/span&gt;.&lt;span class="pl-s1"&gt;path&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt; at &lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;timestamp&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;"&lt;/span&gt;

    &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;set_body&lt;/span&gt;(&lt;span class="pl-s1"&gt;self&lt;/span&gt;, &lt;span class="pl-s1"&gt;body&lt;/span&gt;):
        &lt;span class="pl-k"&gt;try&lt;/span&gt;:
            &lt;span class="pl-c"&gt;# Try to decode as UTF-8&lt;/span&gt;
            &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;body&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;body&lt;/span&gt;.&lt;span class="pl-en"&gt;decode&lt;/span&gt;(&lt;span class="pl-s"&gt;"utf-8"&lt;/span&gt;)
            &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;is_base64&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;False&lt;/span&gt;
        &lt;span class="pl-k"&gt;except&lt;/span&gt; &lt;span class="pl-v"&gt;UnicodeDecodeError&lt;/span&gt;:
            &lt;span class="pl-c"&gt;# If that fails, store as base64&lt;/span&gt;
            &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;body&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;base64&lt;/span&gt;.&lt;span class="pl-en"&gt;b64encode&lt;/span&gt;(&lt;span class="pl-s1"&gt;body&lt;/span&gt;).&lt;span class="pl-en"&gt;decode&lt;/span&gt;(&lt;span class="pl-s"&gt;"ascii"&lt;/span&gt;)
            &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;is_base64&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;True&lt;/span&gt;

    &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;get_body&lt;/span&gt;(&lt;span class="pl-s1"&gt;self&lt;/span&gt;):
        &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;is_base64&lt;/span&gt;:
            &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;base64&lt;/span&gt;.&lt;span class="pl-en"&gt;b64decode&lt;/span&gt;(&lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;body&lt;/span&gt;.&lt;span class="pl-en"&gt;encode&lt;/span&gt;(&lt;span class="pl-s"&gt;"ascii"&lt;/span&gt;))
        &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;body&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;The admin screens are defined in &lt;a href="https://github.com/simonw/django-http-debug/blob/0.2/django_http_debug/admin.py"&gt;admin.py&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="claude-built-the-first-version-of-this-for-me"&gt;Claude built the first version of this for me&lt;/h4&gt;
&lt;p&gt;This is a classic example of a project that I couldn’t quite justify building without assistance from an LLM. I wanted it to exist, but I didn't want to spend a whole day building it.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://simonwillison.net/2024/Jun/20/claude-35-sonnet/"&gt;Claude 3.5 Sonnet&lt;/a&gt; got me 90% of the way to a working first version. I had to make a few tweaks to how the middleware worked, but having done that I had a working initial prototype within a few minutes of starting the project.&lt;/p&gt;
&lt;p&gt;Here’s the full sequence of prompts I used, each linking to the code that was produced for me (as a Claude artifact):&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I want a Django app I can use to help create HTTP debugging endpoints. It should let me configure a new path e.g. /webhooks/receive/ that the Django 404 handler then hooks into - if one is configured it can be told which HTTP status code, headers and content to return.&lt;/p&gt;
&lt;p&gt;ALL traffic to those endpoints is logged to a Django table - full details of incoming request headers, method and body. Those can be browsed read-only in the Django admin (and deleted)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Produced &lt;a href="https://claude.site/artifacts/d7da92c2-8a6e-4fd8-a6f2-b243523af1b4"&gt;Claude v1&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;make it so I don't have to put it in the urlpatterns because it hooks ito Django's 404 handling mechanism instead&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Produced &lt;a href="https://claude.site/artifacts/a1fb7996-e16b-403f-848c-e9ff0adcb9e3"&gt;Claude v2&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Suggestions for how this could handle request bodies that don't cleanly decode to utf-8&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Produced &lt;a href="https://claude.site/artifacts/9f1a2db7-d614-4fc0-9c84-860a2c1afa92"&gt;Claude v3&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;don't use a binary field, use a text field but still store base64 data in it if necessary and have a is_base64 boolean column that gets set to true if that happens&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Produced &lt;a href="https://claude.site/artifacts/c49367b9-b6f9-4634-be72-a266e01579fd"&gt;Claude v4&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I took that code and ran with it - I fired up a new skeleton library using my &lt;a href="https://github.com/simonw/python-lib"&gt;python-lib cookiecutter template&lt;/a&gt;, copied the code into it, made some tiny changes to get it to work and shipped it as &lt;a href="https://github.com/simonw/django-http-debug/releases/tag/0.1a0"&gt;an initial alpha release&lt;/a&gt; - mainly so I could start exercising it on a couple of sites I manage.&lt;/p&gt;
&lt;p&gt;Using it in the wild for a few minutes quickly identified changes I needed to make. I filed those as &lt;a href="https://github.com/simonw/django-http-debug/issues"&gt;issues&lt;/a&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/django-http-debug/issues/2"&gt;#2: Capture query string&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/django-http-debug/issues/3"&gt;#3: Don't show body field twice&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/django-http-debug/issues/4"&gt;#4: Field for content-type, plus base64 support&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/django-http-debug/issues/5"&gt;#5: Ability to disable logging for an endpoint&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/django-http-debug/issues/6"&gt;#6: Add automated tests&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then I worked though fixing each of those one at a time. I did most of this work myself, though GitHub Copilot helped me out be typing some of the code for me.&lt;/p&gt;
&lt;h4 id="adding-the-base64-preview"&gt;Adding the base64 preview&lt;/h4&gt;
&lt;p&gt;There was one slightly tricky feature I wanted to add that didn’t justify spending much time on but was absolutely a nice-to-have.&lt;/p&gt;
&lt;p&gt;The logging mechanism supports binary data: if incoming request data doesn’t cleanly encode as UTF-8 it gets stored as Base 64 text instead, with the &lt;code&gt;is_base64&lt;/code&gt; flag set to &lt;code&gt;True&lt;/code&gt; (see the &lt;code&gt;set_body()&lt;/code&gt; method in the &lt;code&gt;RequestLog&lt;/code&gt; model above).&lt;/p&gt;
&lt;p&gt;I asked Claude for a &lt;code&gt;curl&lt;/code&gt; one-liner to test this and it suggested:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;curl -X POST http://localhost:8000/foo/ \
  -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Content-Type: multipart/form-data&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  -F &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;image=@pixel.gif&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I do this a lot - knocking out quick &lt;code&gt;curl&lt;/code&gt; commands is an easy prompt, and you can tell it the URL and headers you want to use, saving you from having to edit the command yourself later on.&lt;/p&gt;
&lt;p&gt;I decided to have the Django Admin view display a decoded version of that Base 64 data. But how to render that, when things like binary file uploads may not be cleanly renderable as text?&lt;/p&gt;
&lt;p&gt;This is what I came up with:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2024/django-http-debug-binary.jpg" alt="Django admin screenshot showing &amp;quot;view request log&amp;quot; screen - a logged POST request to the hello-world endpoint. method is POST, headers is a detailed dictionary, Body is a base64 string but body display shows that decoded to a multi-part form data with a image/gif attachment - that starts with GIF89a and then shows hex byte pairs for the binary data. Is base64 shows a green checkmark." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;The trick here I'm using here is to display the decoded data as a mix between renderable characters and hex byte pairs, with those pairs rendered using a different font to make it clear that they are part of the binary data.&lt;/p&gt;
&lt;p&gt;This is achieved using a &lt;code&gt;body_display()&lt;/code&gt; method on the &lt;code&gt;RequestLogAdmin&lt;/code&gt; admin class, which is then listed in &lt;code&gt;readonly_fields&lt;/code&gt;. The &lt;a href="https://github.com/simonw/django-http-debug/blob/0.2/django_http_debug/admin.py"&gt;full code is here&lt;/a&gt;, this is that method:&lt;/p&gt;
&lt;pre&gt;    &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;body_display&lt;/span&gt;(&lt;span class="pl-s1"&gt;self&lt;/span&gt;, &lt;span class="pl-s1"&gt;obj&lt;/span&gt;):
        &lt;span class="pl-s1"&gt;body&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;obj&lt;/span&gt;.&lt;span class="pl-en"&gt;get_body&lt;/span&gt;()
        &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-c1"&gt;not&lt;/span&gt; &lt;span class="pl-en"&gt;isinstance&lt;/span&gt;(&lt;span class="pl-s1"&gt;body&lt;/span&gt;, &lt;span class="pl-s1"&gt;bytes&lt;/span&gt;):
            &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-en"&gt;format_html&lt;/span&gt;(&lt;span class="pl-s"&gt;"&amp;lt;pre&amp;gt;{}&amp;lt;/pre&amp;gt;"&lt;/span&gt;, &lt;span class="pl-s1"&gt;body&lt;/span&gt;)

        &lt;span class="pl-c"&gt;# Attempt to guess filetype&lt;/span&gt;
        &lt;span class="pl-s1"&gt;suggestion&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;None&lt;/span&gt;
        &lt;span class="pl-s1"&gt;match&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;filetype&lt;/span&gt;.&lt;span class="pl-en"&gt;guess&lt;/span&gt;(&lt;span class="pl-s1"&gt;body&lt;/span&gt;[:&lt;span class="pl-c1"&gt;1000&lt;/span&gt;])
        &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;match&lt;/span&gt;:
            &lt;span class="pl-s1"&gt;suggestion&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;"{} ({})"&lt;/span&gt;.&lt;span class="pl-en"&gt;format&lt;/span&gt;(&lt;span class="pl-s1"&gt;match&lt;/span&gt;.&lt;span class="pl-s1"&gt;extension&lt;/span&gt;, &lt;span class="pl-s1"&gt;match&lt;/span&gt;.&lt;span class="pl-s1"&gt;mime&lt;/span&gt;)

        &lt;span class="pl-s1"&gt;encoded&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;repr&lt;/span&gt;(&lt;span class="pl-s1"&gt;body&lt;/span&gt;)
        &lt;span class="pl-c"&gt;# Ditch the b' and trailing '&lt;/span&gt;
        &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;encoded&lt;/span&gt;.&lt;span class="pl-en"&gt;startswith&lt;/span&gt;(&lt;span class="pl-s"&gt;"b'"&lt;/span&gt;) &lt;span class="pl-c1"&gt;and&lt;/span&gt; &lt;span class="pl-s1"&gt;encoded&lt;/span&gt;.&lt;span class="pl-en"&gt;endswith&lt;/span&gt;(&lt;span class="pl-s"&gt;"'"&lt;/span&gt;):
            &lt;span class="pl-s1"&gt;encoded&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;encoded&lt;/span&gt;[&lt;span class="pl-c1"&gt;2&lt;/span&gt;:&lt;span class="pl-c1"&gt;-&lt;/span&gt;&lt;span class="pl-c1"&gt;1&lt;/span&gt;]

        &lt;span class="pl-c"&gt;# Split it into sequences of octets and characters&lt;/span&gt;
        &lt;span class="pl-s1"&gt;chunks&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;sequence_re&lt;/span&gt;.&lt;span class="pl-en"&gt;split&lt;/span&gt;(&lt;span class="pl-s1"&gt;encoded&lt;/span&gt;)
        &lt;span class="pl-s1"&gt;html&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; []
        &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;suggestion&lt;/span&gt;:
            &lt;span class="pl-s1"&gt;html&lt;/span&gt;.&lt;span class="pl-en"&gt;append&lt;/span&gt;(
                &lt;span class="pl-s"&gt;'&amp;lt;p style="margin-top: 0; font-family: monospace; font-size: 0.8em;"&amp;gt;Suggestion: {}&amp;lt;/p&amp;gt;'&lt;/span&gt;.&lt;span class="pl-en"&gt;format&lt;/span&gt;(
                    &lt;span class="pl-s1"&gt;suggestion&lt;/span&gt;
                )
            )
        &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;chunk&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;chunks&lt;/span&gt;:
            &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;sequence_re&lt;/span&gt;.&lt;span class="pl-en"&gt;match&lt;/span&gt;(&lt;span class="pl-s1"&gt;chunk&lt;/span&gt;):
                &lt;span class="pl-s1"&gt;octets&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;octet_re&lt;/span&gt;.&lt;span class="pl-en"&gt;findall&lt;/span&gt;(&lt;span class="pl-s1"&gt;chunk&lt;/span&gt;)
                &lt;span class="pl-s1"&gt;octets&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; [&lt;span class="pl-s1"&gt;o&lt;/span&gt;[&lt;span class="pl-c1"&gt;2&lt;/span&gt;:] &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;o&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;octets&lt;/span&gt;]
                &lt;span class="pl-s1"&gt;html&lt;/span&gt;.&lt;span class="pl-en"&gt;append&lt;/span&gt;(
                    &lt;span class="pl-s"&gt;'&amp;lt;code style="color: #999; font-family: monospace"&amp;gt;{}&amp;lt;/code&amp;gt;'&lt;/span&gt;.&lt;span class="pl-en"&gt;format&lt;/span&gt;(
                        &lt;span class="pl-s"&gt;" "&lt;/span&gt;.&lt;span class="pl-en"&gt;join&lt;/span&gt;(&lt;span class="pl-s1"&gt;octets&lt;/span&gt;).&lt;span class="pl-en"&gt;upper&lt;/span&gt;()
                    )
                )
            &lt;span class="pl-k"&gt;else&lt;/span&gt;:
                &lt;span class="pl-s1"&gt;html&lt;/span&gt;.&lt;span class="pl-en"&gt;append&lt;/span&gt;(&lt;span class="pl-s1"&gt;chunk&lt;/span&gt;.&lt;span class="pl-en"&gt;replace&lt;/span&gt;(&lt;span class="pl-s"&gt;"&lt;span class="pl-cce"&gt;\\&lt;/span&gt;&lt;span class="pl-cce"&gt;\\&lt;/span&gt;"&lt;/span&gt;, &lt;span class="pl-s"&gt;"&lt;span class="pl-cce"&gt;\\&lt;/span&gt;"&lt;/span&gt;))

        &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-en"&gt;mark_safe&lt;/span&gt;(&lt;span class="pl-s"&gt;" "&lt;/span&gt;.&lt;span class="pl-en"&gt;join&lt;/span&gt;(&lt;span class="pl-s1"&gt;html&lt;/span&gt;).&lt;span class="pl-en"&gt;strip&lt;/span&gt;().&lt;span class="pl-en"&gt;replace&lt;/span&gt;(&lt;span class="pl-s"&gt;"&lt;span class="pl-cce"&gt;\\&lt;/span&gt;r&lt;span class="pl-cce"&gt;\\&lt;/span&gt;n"&lt;/span&gt;, &lt;span class="pl-s"&gt;"&amp;lt;br&amp;gt;"&lt;/span&gt;))&lt;/pre&gt;
&lt;p&gt;I got Claude to write that using one of my favourite prompting tricks. I'd solved this problem once before in the past, &lt;a href="https://github.com/simonw/datasette-render-binary/blob/0.3.1/datasette_render_binary/__init__.py"&gt;in my datasette-render-binary&lt;/a&gt; project. So I pasted that code into Claude, told it:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;With that code as inspiration, modify the following Django Admin code to use that to display decoded base64 data:&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And then pasted in my existing Django admin class. You can see &lt;a href="https://gist.github.com/simonw/b2cfff8281d5681c30e54083a9882141"&gt;my full prompt here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Claude replied with &lt;a href="https://claude.site/artifacts/03454d25-9a1d-4b7d-b79f-a3a8707c58ad"&gt;this code&lt;/a&gt;, which almost worked exactly as intended - I had to make one change, swapping out the last line for this:&lt;/p&gt;
&lt;pre&gt;        &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-en"&gt;mark_safe&lt;/span&gt;(&lt;span class="pl-s"&gt;" "&lt;/span&gt;.&lt;span class="pl-en"&gt;join&lt;/span&gt;(&lt;span class="pl-s1"&gt;html&lt;/span&gt;).&lt;span class="pl-en"&gt;strip&lt;/span&gt;().&lt;span class="pl-en"&gt;replace&lt;/span&gt;(&lt;span class="pl-s"&gt;"&lt;span class="pl-cce"&gt;\\&lt;/span&gt;r&lt;span class="pl-cce"&gt;\\&lt;/span&gt;n"&lt;/span&gt;, &lt;span class="pl-s"&gt;"&amp;lt;br&amp;gt;"&lt;/span&gt;))&lt;/pre&gt;
&lt;p&gt;I love this pattern: "here's my existing code, here's some other code I wrote, combine them together to solve this problem". I wrote about this previously when I described &lt;a href="https://simonwillison.net/2024/Mar/30/ocr-pdfs-images/#ocr-how-i-built-this"&gt;how I built my PDF OCR JavaScript tool&lt;/a&gt; a few months ago.&lt;/p&gt;
&lt;h4 id="adding-automated-tests"&gt;Adding automated tests&lt;/h4&gt;
&lt;p&gt;The final challenge was the hardest: writing automated tests. This was difficult because Django tests need a full Django project configured for them, and I wasn’t confident about the best pattern for doing that in my standalone &lt;code&gt;django-http-debug&lt;/code&gt; repository since it wasn’t already part of an existing Django project.&lt;/p&gt;
&lt;p&gt;I decided to see if Claude could help me with that too, this time using my &lt;a href="https://github.com/simonw/files-to-prompt"&gt;files-to-prompt&lt;/a&gt; and &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt; command-line tools:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;files-to-prompt &lt;span class="pl-c1"&gt;.&lt;/span&gt; --ignore LICENSE &lt;span class="pl-k"&gt;|&lt;/span&gt; \
  llm -m claude-3.5-sonnet -s \
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;step by step advice on how to implement automated tests for this, which is hard because the tests need to work within a temporary Django project that lives in the tests/ directory somehow. Provide all code at the end.&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/a1c51e3a4f30d91eac4664ba84266ca1#response"&gt;Claude's full response&lt;/a&gt;. It almost worked! It gave me a minimal test project in &lt;a href="https://github.com/simonw/django-http-debug/tree/1d2fae7141b1bdd9b156858e689511e282bd7b5a/tests/test_project"&gt;tests/test_project&lt;/a&gt; and an initial set of &lt;a href="https://github.com/simonw/django-http-debug/blob/1d2fae7141b1bdd9b156858e689511e282bd7b5a/tests/test_django_http_debug.py"&gt;quite sensible tests&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Sadly it didn’t quite solve the most fiddly problem for me: configuring it so running &lt;code&gt;pytest&lt;/code&gt; would correctly set the Python path and &lt;code&gt;DJANGO_SETTINGS_MODULE&lt;/code&gt; in order run the tests. I saw this error instead:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;django.core.exceptions.ImproperlyConfigured: Requested setting INSTALLED_APPS, but settings are not configured. You must either define the environment variable DJANGO_SETTINGS_MODULE or call settings.configure() before accessing settings.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I spent some time with the &lt;a href="https://pytest-django.readthedocs.io/en/latest/managing_python_path.html"&gt;relevant pytest-django documentation&lt;/a&gt; and figure out a pattern that worked. Short version: I added this to my &lt;code&gt;pyproject.toml&lt;/code&gt; file:&lt;/p&gt;
&lt;div class="highlight highlight-source-toml"&gt;&lt;pre&gt;[&lt;span class="pl-en"&gt;tool&lt;/span&gt;.&lt;span class="pl-en"&gt;pytest&lt;/span&gt;.&lt;span class="pl-en"&gt;ini_options&lt;/span&gt;]
&lt;span class="pl-smi"&gt;DJANGO_SETTINGS_MODULE&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;tests.test_project.settings&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-smi"&gt;pythonpath&lt;/span&gt; = [&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;]&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;For the longer version, take a look at my full TIL: &lt;a href="https://til.simonwillison.net/django/pytest-django"&gt;Using pytest-django with a reusable Django application&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="test-supported-cleanup"&gt;Test-supported cleanup&lt;/h4&gt;
&lt;p&gt;The great thing about having comprehensive tests in place is it makes iterating on the project much faster. Claude had used some patterns that weren’t necessary. I spent a few minutes seeing if the tests still passed if I deleted various pieces of code, and &lt;a href="https://github.com/simonw/django-http-debug/compare/1d2fae7141b1bdd9b156858e689511e282bd7b5a...97bab5dd9c7f4363a49127711c4c68ef1f3b6ade/"&gt;cleaned things up quite a bit&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="was-claude-worth-it-"&gt;Was Claude worth it?&lt;/h4&gt;
&lt;p&gt;This entire project took about two hours - just within a tolerable amount of time for what was effectively a useful &lt;a href="https://simonwillison.net/2024/Mar/22/claude-and-chatgpt-case-study/"&gt;sidequest&lt;/a&gt; from my intended activity for the day.&lt;/p&gt;
&lt;p&gt;Claude didn't implement the whole project for me. The code it produced didn't quite work - I had to tweak just a few lines of code, but knowing which code to tweak took a development environment and manual testing and benefited greatly from my 20+ years of Django experience!&lt;/p&gt;
&lt;p&gt;This is yet another example of how LLMs don't replace human developers: they augment us.&lt;/p&gt;
&lt;p&gt;The end result is a tool that I'm already using to solve real-world problems, and a &lt;a href="https://github.com/simonw/django-http-debug"&gt;code repository&lt;/a&gt; that I'm proud to put my name to. Without LLM assistance this project would have stayed on my ever-growing list of "things I'd love to build one day".&lt;/p&gt;
&lt;p&gt;I'm also really happy to have my own &lt;a href="https://til.simonwillison.net/django/pytest-django"&gt;documented solution&lt;/a&gt; to the challenge of adding automated tests to a standalone reusable Django application. I was tempted to skip this step entirely, but thanks to Claude's assistance I was able to break that problem open and come up with a solution that I'm really happy with.&lt;/p&gt;
&lt;p&gt;Last year I wrote about how &lt;a href="https://simonwillison.net/2023/Mar/27/ai-enhanced-development/"&gt;AI-enhanced development makes me more ambitious with my projects&lt;/a&gt;. It's also helping me be more diligent in not taking shortcuts like skipping setting up automated tests.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/http"&gt;http&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webhooks"&gt;webhooks&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/anthropic"&gt;anthropic&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude"&gt;claude&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-3-5-sonnet"&gt;claude-3-5-sonnet&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="django"/><category term="django-admin"/><category term="http"/><category term="projects"/><category term="python"/><category term="webhooks"/><category term="ai"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="anthropic"/><category term="claude"/><category term="claude-3-5-sonnet"/></entry><entry><title>Weeknotes: Vaccinate The States, and how I learned that returning dozens of MB of JSON works just fine these days</title><link href="https://simonwillison.net/2021/Apr/26/vaccinate-the-states/#atom-tag" rel="alternate"/><published>2021-04-26T01:02:22+00:00</published><updated>2021-04-26T01:02:22+00:00</updated><id>https://simonwillison.net/2021/Apr/26/vaccinate-the-states/#atom-tag</id><summary type="html">
    &lt;p&gt;On Friday &lt;a href="https://www.vaccinateca.com/"&gt;VaccinateCA&lt;/a&gt; grew in scope, a lot: we launched a new website called &lt;a href="https://www.vaccinatethestates.com/"&gt;Vaccinate The States&lt;/a&gt;. Patrick McKenzie wrote &lt;a href="https://www.kalzumeus.com/2021/04/23/vaccinate-the-states/"&gt;more about the project here&lt;/a&gt; - the short version is that we're building the most comprehensive possible dataset of vaccine availability in the USA, using a combination of data collation, online research and continuing to make a huge number of phone calls.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of Vaccinate The States, showing a map with a LOT of markers on it" src="https://static.simonwillison.net/static/2021/vaccinate-the-states.png" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;VIAL, the Django application I've been working on &lt;a href="https://simonwillison.net/tags/vaccinateca/"&gt;since late February&lt;/a&gt;, had to go through some extensive upgrades to help support this effort!&lt;/p&gt;
&lt;p&gt;VIAL has a number of responsibilities. It acts as our central point of truth for the vaccination locations that we are tracking, powers the app used by our callers to serve up locations to call and record the results, and as-of this week it's also a central point for our efforts to combine data from multiple other providers and scrapers.&lt;/p&gt;
&lt;p&gt;The data ingestion work is happening in a public repository, &lt;a href="https://github.com/CAVaccineInventory/vaccine-feed-ingest"&gt;CAVaccineInventory/vaccine-feed-ingest&lt;/a&gt;. I have yet to write a single line of code there (and I thoroughly enjoy working on that kind of code) because I've been heads down working on VIAL itself to ensure it can support the ingestion efforts.&lt;/p&gt;
&lt;h4&gt;Matching and concordances&lt;/h4&gt;
&lt;p&gt;If you're combining data about vaccination locations from a range of different sources, one of the biggest challenges is de-duplicating the data: it's important the same location doesn't show up multiple times (potentially with slightly differing details) due to appearing in multiple sources.&lt;/p&gt;
&lt;p&gt;Our first step towards handling this involved the addition of "concordance identifiers" to VIAL.&lt;/p&gt;
&lt;p&gt;I first encountered the term "concordance" being used for this &lt;a href="https://whosonfirst.org/docs/concordances/"&gt;in the Who's On First project&lt;/a&gt;, which is building a gazetteer of every city/state/country/county/etc on earth.&lt;/p&gt;
&lt;p&gt;A concordance is an identifier in another system. Our location ID for RITE AID PHARMACY 05976 in Santa Clara is &lt;code&gt;receu5biMhfN8wH7P&lt;/code&gt; - which is &lt;code&gt;e3dfcda1-093f-479a-8bbb-14b80000184c&lt;/code&gt; in &lt;a href="https://vaccinefinder.org/"&gt;VaccineFinder&lt;/a&gt; and &lt;code&gt;7537904&lt;/code&gt; in &lt;a href="https://www.vaccinespotter.org/"&gt;Vaccine Spotter&lt;/a&gt; and &lt;code&gt;ChIJZaiURRPKj4ARz5nAXcWosUs&lt;/code&gt; in Google Places.&lt;/p&gt;
&lt;p&gt;We're storing them in a Django table called &lt;code&gt;ConcordanceIdentifier&lt;/code&gt;: each record has an &lt;code&gt;authority&lt;/code&gt; (e.g. &lt;code&gt;vaccinespotter_org&lt;/code&gt;) and an identifier (&lt;code&gt;7537904&lt;/code&gt;) and a many-to-many relationship to our &lt;code&gt;Location&lt;/code&gt; model.&lt;/p&gt;
&lt;p&gt;Why many-to-many? Surely we only want a single location for any one of these identifiers?&lt;/p&gt;
&lt;p&gt;Exactly! That's why it's many-to-many: because if we import the same location twice, then assign concordance identifiers to it, we can instantly spot that it's a duplicate and needs to be merged.&lt;/p&gt;
&lt;h4&gt;Raw data from scrapers&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;ConcordanceIdentifier&lt;/code&gt; also has a many-to-many relationship with a new table, called &lt;code&gt;SourceLocation&lt;/code&gt;. This table is essentially a PostgreSQL JSON column with a few other columns (including &lt;code&gt;latitude&lt;/code&gt; and &lt;code&gt;longitude&lt;/code&gt;) into which our scrapers and ingesters can dump raw data. This means we can use PostgreSQL queries to perform all kinds of analysis on the unprocessed data before it gets cleaned up, de-duplicated and loaded into our point-of-truth &lt;code&gt;Location&lt;/code&gt; table.&lt;/p&gt;
&lt;h4&gt;How to dedupe and match locations?&lt;/h4&gt;
&lt;p&gt;Initially I thought we would do the deduping and matching inside of VIAL itself, using the raw data that had been ingested into the &lt;code&gt;SourceLocation&lt;/code&gt; table.&lt;/p&gt;
&lt;p&gt;Since we were on a tight internal deadline it proved more practical for people to start experimenting with matching code outside of VIAL. But that meant they needed the raw data - 40,000+ location records (and growing rapidly).&lt;/p&gt;
&lt;p&gt;A few weeks ago I built a CSV export feature for the VIAL admin screens, using Django's &lt;a href="https://docs.djangoproject.com/en/3.2/ref/request-response/#django.http.StreamingHttpResponse"&gt;StreamingHttpResponse&lt;/a&gt; class combined with keyset pagination for bulk export without sucking the entire table into web server memory - &lt;a href="https://til.simonwillison.net/django/export-csv-from-django-admin"&gt;details in this TIL&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Our data ingestion team wanted a GeoJSON export - specifically newline-delimited GeoJSON - which they could then load into &lt;a href="https://geopandas.org/"&gt;GeoPandas&lt;/a&gt; to help run matching operations.&lt;/p&gt;
&lt;p&gt;So I built a simple "search API" which defaults to returning 20 results at a time, but also has an option to "give me everything" - using the same technique I used for the CSV export: keyset pagination combined with a &lt;code&gt;StreamingHttpResponse&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;And it worked! It turns out that if you're running on modern infrastructure (Cloud Run and Cloud SQL in our case) in 2021 getting Django to return 50+MB of JSON in a streaming response works just fine.&lt;/p&gt;
&lt;p&gt;Some of these exports are taking 20+ seconds, but for a small audience of trusted clients that's completely fine.&lt;/p&gt;
&lt;p&gt;While working on this I realized that my idea of what size of data is appropriate for a dynamic web application to return more or less formed back in 2005. I still think it's rude to serve multiple MBs of JavaScript up to an inexpensive mobile phone on an expensive connection, but for server-to-server or server-to-automation-script situations serving up 50+ MB of JSON in one go turns out to be a perfectly cromulent way of doing things.&lt;/p&gt;
&lt;h4&gt;Export full results from django-sql-dashboard&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/django-sql-dashboard"&gt;django-sql-dashboard&lt;/a&gt; is my Datasette-inspired library for adding read-only arbitrary SQL queries to any Django+PostgreSQL application.&lt;/p&gt;
&lt;p&gt;I built the first version &lt;a href="https://simonwillison.net/2021/Mar/14/weeknotes/"&gt;last month&lt;/a&gt; to help compensate for switching VaccinateCA away from Airtable - one of the many benefits of Airtable is that it allows all kinds of arbitrary reporting, and Datasette has shown me that bookmarkable SQL queries can provide a huge amount of that value with very little written code, especially within organizations where SQL is already widely understood.&lt;/p&gt;
&lt;p&gt;While it allows people to run any SQL they like (against a read-only PostgreSQL connection with a time limit) it restricts viewing to the first 1,000 records to be returned - because building robust, performant pagination against arbitrary SQL queries is a hard problem to solve.&lt;/p&gt;
&lt;p&gt;Today I released &lt;a href="https://github.com/simonw/django-sql-dashboard/releases/tag/0.10a0"&gt;django-sql-dashboard 0.10a0&lt;/a&gt; with the ability to export all results for a query as a downloadable CSV or TSV file, using the same &lt;code&gt;StreamingHttpResponse&lt;/code&gt; technique as my Django admin CSV export and all-results-at-once search endpoint.&lt;/p&gt;
&lt;p&gt;I expect it to be pretty useful! It means I can run any SQL query I like against a Django project and get back the full results - often dozens of MBs - in a form I can import into other tools (including Datasette).&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of the SQL Dashboard interface, showing the new 'Export as CSV/TSV' buttons which trigger a file download dialog" src="https://static.simonwillison.net/static/2021/export-csv-dashboard.png" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;h4&gt;TIL this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/til/blob/main/django/django-admin-horizontal-scroll.md"&gt;Usable horizontal scrollbars in the Django admin for mouse users&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/til/blob/main/django/filter-by-comma-separated-values.md"&gt;Filter by comma-separated values in the Django admin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/til/blob/main/postgresql/constructing-geojson-in-postgresql.md"&gt;Constructing GeoJSON in PostgreSQL&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/til/blob/main/django/export-csv-from-django-admin.md"&gt;Django Admin action for exporting selected rows as CSV&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Releases this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/django-sql-dashboard"&gt;django-sql-dashboard&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/django-sql-dashboard/releases/tag/0.10a1"&gt;0.10a1&lt;/a&gt; - (&lt;a href="https://github.com/simonw/django-sql-dashboard/releases"&gt;21 total releases&lt;/a&gt;) - 2021-04-25
&lt;br /&gt;Django app for building dashboards using raw SQL queries&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/csv"&gt;csv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/postgresql"&gt;postgresql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccines"&gt;vaccines&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/weeknotes"&gt;weeknotes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-sql-dashboard"&gt;django-sql-dashboard&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="csv"/><category term="django"/><category term="django-admin"/><category term="postgresql"/><category term="projects"/><category term="vaccines"/><category term="weeknotes"/><category term="vaccinate-ca"/><category term="django-sql-dashboard"/></entry><entry><title>Porting VaccinateCA to Django</title><link href="https://simonwillison.net/2021/Apr/12/porting-vaccinateca-to-django/#atom-tag" rel="alternate"/><published>2021-04-12T05:18:48+00:00</published><updated>2021-04-12T05:18:48+00:00</updated><id>https://simonwillison.net/2021/Apr/12/porting-vaccinateca-to-django/#atom-tag</id><summary type="html">
    &lt;p&gt;As I mentioned &lt;a href="https://simonwillison.net/2021/Feb/28/vaccinateca/"&gt;back in February&lt;/a&gt;, I've been working with the &lt;a href="https://www.vaccinateca.com/"&gt;VaccinateCA&lt;/a&gt; project to try to bring the pandemic to an end a little earlier by helping gather as accurate a model as possible of where the Covid vaccine is available in California and how people can get it.&lt;/p&gt;
&lt;p&gt;The key activity at VaccinateCA is calling places to check on their availability and eligibility criteria. Up until last night this was powered by a heavily customized Airtable instance, accompanied by a custom JavaScript app for the callers that communicated with the Airtable API via some Netlify functions.&lt;/p&gt;
&lt;p&gt;Today, the flow is powered by a new custom Django backend, running on top of PostgreSQL.&lt;/p&gt;
&lt;h4&gt;The thing you should never do&lt;/h4&gt;
&lt;blockquote class="twitter-tweet"&gt;&lt;p lang="en" dir="ltr"&gt;Here&amp;#39;s one that took me fifteen years to learn: &amp;quot;let&amp;#39;s build a new thing and replace this&amp;quot; is hideously dangerous: 90% of the time you won&amp;#39;t fully replace the old thing, and now you have two problems!&lt;/p&gt;- Simon Willison (@simonw) &lt;a href="https://twitter.com/simonw/status/1145114228170190848?ref_src=twsrc%5Etfw"&gt;June 29, 2019&lt;/a&gt;&lt;/blockquote&gt;
&lt;p&gt;Replacing an existing system with a from-scratch rewrite is risky. Replacing a system that is built on something as flexible as Airtable that is evolving on a daily basis is positively terrifying!&lt;/p&gt;
&lt;p&gt;Airtable served us extremely well, but unfortunately there are hard limits to the number of rows Airtable can handle and we've already bounced up against them and had to archive some of our data. To keep scaling the organization we needed to migrate away.&lt;/p&gt;
&lt;p&gt;We needed to build a matching relational database with a comprehensive, permission-controlled interface for editing it, plus APIs to drive our website and application. And we needed to do it using the most &lt;a href="http://boringtechnology.club/"&gt;boring technology&lt;/a&gt; possible, so we could focus on solving problems directly rather than researching anything new.&lt;/p&gt;
&lt;p&gt;It will never cease to surprise me that Django has attained boring technology status! VaccineCA sits firmly in Django's sweet-spot. So we used that to build our replacement.&lt;/p&gt;
&lt;p&gt;The new Django-based system is called VIAL, for "Vaccine Information Archive and Library" - a neat &lt;a href="https://twitter.com/obra"&gt;Jesse Vincent&lt;/a&gt; bacronym.&lt;/p&gt;
&lt;p&gt;We switched things over to VIAL last night, but we still have activity in Airtable as well. I expect we'll keep using Airtable for the lifetime of the organization - there are plenty of ad-hoc data projects for which it's a perfect fit.&lt;/p&gt;
&lt;p&gt;The most important thing here is to have a trusted single point of truth for any piece of information. I'm not quite ready to declare victory on that point just yet, but hopefully once things settle down over the next few days.&lt;/p&gt;

&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2021/vial-index.png" style="max-width: 100%" alt="Screenshot of the Django admin VIAL index page" /&gt;&lt;/p&gt;

&lt;h4&gt;Data synchronization patterns&lt;/h4&gt;
&lt;p&gt;The first challenge, before even writing any code, was how to get stuff out of Airtable. I built a tool for this a while ago called &lt;a href="https://datasette.io/tools/airtable-export"&gt;airtable-export&lt;/a&gt;, and it turned out the VaccinateCA team were using it already before I joined!&lt;/p&gt;
&lt;p&gt;&lt;code&gt;airtable-export&lt;/code&gt; was already running several times an hour, backing up the data in JSON format to a GitHub repository (a form of &lt;a href="https://simonwillison.net/2020/Oct/9/git-scraping/"&gt;Git scraping&lt;/a&gt;). This gave us a detailed history of changes to the Airtable data, which occasionally proved extremely useful for answering questions about when a specific record was changed or deleted.&lt;/p&gt;
&lt;p&gt;Having the data in a GitHub repository was also useful because it gave us somewhere to pull data from that wasn't governed by Airtable's rate limits.&lt;/p&gt;
&lt;p&gt;I iterated through a number of different approaches for writing importers for the data.&lt;/p&gt;
&lt;p&gt;Each Airtable table ended up as a single JSON file in our GitHub repository, containing an array of objects - those files got pretty big, topping out at about 80MB.&lt;/p&gt;
&lt;p&gt;I started out with Django management commands, which could be passed a file or a URL. A neat thing about using GitHub for this is that you can use the "raw data" link to obtain a URL with a short-lived token, which grants access to that file. So I could create a short-term URL and paste it directly to my import tool.&lt;/p&gt;
&lt;p&gt;I don't have a good pattern for running Django management commands on Google Cloud Run, so I started moving to API-based import scripts instead.&lt;/p&gt;
&lt;p&gt;The pattern that ended up working best was to provide a &lt;code&gt;/api/importRecords&lt;/code&gt; API endpoint which accepts a JSON array of items.&lt;/p&gt;
&lt;p&gt;The API expects the input to have a unique primary key in each record - &lt;code&gt;airtable_id&lt;/code&gt; in our case. It then uses Django's &lt;a href="https://docs.djangoproject.com/en/3.2/ref/models/querysets/#update-or-create"&gt;update_or_create()&lt;/a&gt; ORM method to create new records if they were missing, and update existing records otherwise.&lt;/p&gt;
&lt;p&gt;One remaining challenge: posting 80MB of JSON to an API in one go would likely run into resource limits. I needed a way to break that input up into smaller batches.&lt;/p&gt;
&lt;p&gt;I ended up building a new tool for this called &lt;a href="https://github.com/simonw/json-post"&gt;json-post&lt;/a&gt;. It has an extremely specific use-case: it's for when you want to POST a big JSON array to an API endpoint but you want to first break it up into batches!&lt;/p&gt;
&lt;p&gt;Here's how to break up the JSON in &lt;code&gt;Reports.json&lt;/code&gt; into 50 item arrays and send them to that API as separate POSTs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;json-post Reports.json \                              
   "https://example.com/api/importReports" \
   --batch-size 50
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here are some more complex options. Here we need to pass an &lt;code&gt;Authorization: Bearer XXXtokenXXX&lt;/code&gt; API key header, run the array in reverse, record our progress (the JSON responses from the API as newline-delimited JSON) to a log file, set a longer HTTP read timeout and filter for just specific items:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;% json-post Reports.json \                              
   "https://example.com/api/importReports" \
  -h Authorization 'Bearer XXXtokenXXX' \
  --batch-size 50 \
  --reverse \
  --log /tmp/progress.txt \
  --http-read-timeout 20 \
  --filter 'item.get("is_soft_deleted")'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;--filter&lt;/code&gt; option proved particularly useful. As we kicked the tires on VIAL we would spot new bugs - things like the import script failing to correctly record the &lt;code&gt;is_soft_deleted&lt;/code&gt; field we were using in Airtable. Being able to filter that input file with a command-line flag meant we could easily re-run the import just for a subset of reports that were affected by a particular bug.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;--filter&lt;/code&gt; takes a Python expression that gets compiled into a function and passed &lt;code&gt;item&lt;/code&gt; as the current item in the list. I borrowed the pattern from &lt;a href="https://datasette.io/tools/sqlite-transform#user-content-lambda-for-executing-your-own-code"&gt;my sqlite-transform tool&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="value-of-api-logs"&gt;The value of API logs&lt;/h4&gt;
&lt;p&gt;VaccineCA's JavaScript caller application used to send data to Airtable via a Netlify function, which allowed additional authentication to be added built using  Auth0.&lt;/p&gt;
&lt;p&gt;Back in February, the team had the bright idea to log the API traffic to that function to a separate base in Airtable - including full request and response bodies.&lt;/p&gt;
&lt;p&gt;This proved invaluable for debugging. It also meant that when I started building VIAL's alternative implementation of the "submit a call report" API I could replay historic API traffic that had been recorded in that table, giving me a powerful way to exercise the new API with real-world traffic.&lt;/p&gt;
&lt;p&gt;This meant that when we turned on VIAL we could switch our existing JavaScript SPA over to talking to it using a fully tested clone of the existing Airtable-backed API.&lt;/p&gt;
&lt;p&gt;VIAL implements this logging pattern again, this time using Django and PostgreSQL.&lt;/p&gt;
&lt;p&gt;Given that the writable APIs will recieve in the low thousands of requests a day, keeping them in a database table works great. The table has grown to 90MB so far. I'm hoping that the pandemic will be over before we have to worry about logging capacity!&lt;/p&gt;
&lt;p&gt;We're using PostgreSQL &lt;code&gt;jsonb&lt;/code&gt; columns to store the incoming and returned JSON, via Django's &lt;a href="https://docs.djangoproject.com/en/3.2/ref/models/fields/#jsonfield"&gt;JSONField&lt;/a&gt;. This means we can do in-depth API analysis using PostgreSQL's JSON SQL functions! Being able to examine returned JSON error messages or aggregate across incoming request bodies helped enormously when debugging problems with the API import scripts.&lt;/p&gt;
&lt;h4&gt;Storing the original JSON&lt;/h4&gt;
&lt;p&gt;Today, almost all of the data stored in VIAL originated in Airtable. One trick that has really helped build the system is that each of the tables that might contain imported data has both an &lt;code&gt;airtable_id&lt;/code&gt; nullable column and an &lt;code&gt;import_json&lt;/code&gt; JSON field.&lt;/p&gt;
&lt;p&gt;Any time we import a record from Airtable, we record both the ID and the full, original Airtable JSON that we used for the import.&lt;/p&gt;
&lt;p&gt;This is another powerful tool for debugging: we can view the original Airtable JSON directly in the Django admin interface for a record, and confirm that it matches the ORM fields that we set from that.&lt;/p&gt;
&lt;p&gt;I came up with a simple pattern for &lt;a href="https://til.simonwillison.net/django/pretty-print-json-admin"&gt;Pretty-printing all read-only JSON in the Django admin&lt;/a&gt; that helps with this too.&lt;/p&gt;
&lt;h4&gt;Staying as flexible as possible&lt;/h4&gt;
&lt;p&gt;The thing that worried me most about replacing Airtable with Django was Airtable's incredible flexibility. In the organization's short life it has already solved &lt;em&gt;so many&lt;/em&gt; problems by adding new columns in Airtable, or building new views.&lt;/p&gt;
&lt;p&gt;Is it possible to switch to custom software without losing that huge cultural advantage?&lt;/p&gt;
&lt;p&gt;This is the same reason it's so hard for custom software to compete with spreadsheets.&lt;/p&gt;
&lt;p&gt;We've only just made the switch, so we won't know for a while how well we've done at handling this. I have a few mechanisms in place that I'm hoping will help.&lt;/p&gt;
&lt;p&gt;The first is &lt;a href="https://github.com/simonw/django-sql-dashboard"&gt;django-sql-dashboard&lt;/a&gt;. I wrote about this project in previous weeknotes &lt;a href="https://simonwillison.net/2021/Mar/14/weeknotes/"&gt;here&lt;/a&gt; and &lt;a href="https://simonwillison.net/2021/Mar/21/django-sql-dashboard-widgets/"&gt;here&lt;/a&gt; - the goal is to bring some of the ideas from &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; into the Django/PostgreSQL world, by providing a read-only mechanism for constructing SQL queries, bookmarking and saving the results and outputting simple SQL-driven visualizations.&lt;/p&gt;
&lt;p&gt;We have a lot of SQL knowledge at VaccinateCA, so my hope is that people with SQL will be able to solve their own problems, and people who don't know SQL yet will have no trouble finding someone who can help them.&lt;/p&gt;
&lt;p&gt;In the &lt;a href="http://boringtechnology.club/#17"&gt;boring technology&lt;/a&gt; model of things, &lt;code&gt;django-sql-dashboard&lt;/code&gt; counts as the main innovation token I'm spending for this project. I'm optimistic that it will pay off.&lt;/p&gt;
&lt;p&gt;I'm also leaning heavily on Django's migration system, with the aim of making database migrations common and boring, rather than their usual default of being rare and exciting. We're up to 77 migrations already, in a codebase that is just over two months old!&lt;/p&gt;
&lt;p&gt;I think a culture that evolves the database schema quickly and with as little drama as possible is crucial to maintaining the agility that this kind of organization needs.&lt;/p&gt;
&lt;p&gt;Aside from the Django Admin providing the editing interface, everything that comes into and goes out of VIAL happens through APIs. These are fully documented: I want people to be able to build against the APIs independently, especially for things like data import.&lt;/p&gt;
&lt;p&gt;After seeing significant success with PostgreSQL JSON already, I'm considering using it to add even more API-driven flexbility to VIAL in the future. Allowing our client developers to start collecting a new piece of data from our volunteers in an existing JSON field, then migrating that into a separate column once it has proven its value, is very tempting indeed.&lt;/p&gt;
&lt;h4&gt;Open source tools we are using&lt;/h4&gt;
&lt;p&gt;An incomplete list of open source packages we are using for VIAL so far:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://pydantic-docs.helpmanual.io/"&gt;pydantic&lt;/a&gt; - as a validation layer for some of the API endpoints&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/python-social-auth/social-app-django"&gt;social-auth-app-django&lt;/a&gt; - to integrate with &lt;a href="https://auth0.com/"&gt;Auth0&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/adamchainz/django-cors-headers"&gt;django-cors-headers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/mpdavis/python-jose"&gt;python-jose&lt;/a&gt; - for JWTs, which were already in use by our Airtable caller app&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/etianen/django-reversion"&gt;django-reversion&lt;/a&gt; and &lt;a href="https://github.com/jedie/django-reversion-compare/"&gt;django-reversion-compare&lt;/a&gt; to provide a diffable, revertable history of some of our core models&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/django-admin-tools/django-admin-tools"&gt;django-admin-tools&lt;/a&gt; - which adds a handy customizable menu to the admin, good for linking to additional custom tools&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/3YOURMIND/django-migration-linter"&gt;django-migration-linter&lt;/a&gt; - to help avoid accidentally shipping migrations that could cause downtime during a deploy&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pytest-django.readthedocs.io/en/latest/"&gt;pytest-django&lt;/a&gt;, &lt;a href="https://github.com/adamchainz/time-machine"&gt;time-machine&lt;/a&gt; and &lt;a href="https://colin-b.github.io/pytest_httpx/"&gt;pytest-httpx&lt;/a&gt; for our unit tests&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.sentry.io/platforms/python/"&gt;sentry-sdk&lt;/a&gt;, &lt;a href="https://docs.honeycomb.io/getting-data-in/python/beeline/"&gt;honeycomb-beeline&lt;/a&gt; and  &lt;a href="https://github.com/prometheus/client_python"&gt;prometheus-client&lt;/a&gt; for error logging and observability&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Want to help out?&lt;/h4&gt;
&lt;p&gt;VaccinateCA &lt;a href="https://twitter.com/patio11/status/1379587878624190466"&gt;is hiring&lt;/a&gt;! It's an interesting gig, because the ultimate goal is to end the pandemic and put this non-profit permanently out of business. So if you want to help end things faster, get in touch.&lt;/p&gt;

&lt;blockquote class="twitter-tweet"&gt;&lt;p lang="en" dir="ltr"&gt;VaccinateCA is hiring a handful of engineers to help scale our data ingestion and display by more than an order of magnitude.&lt;br /&gt;&lt;br /&gt;If you&amp;#39;d like to register interest:&lt;a href="https://t.co/BSvi40sW1M"&gt;https://t.co/BSvi40sW1M&lt;/a&gt;&lt;br /&gt;&lt;br /&gt;Generalists welcome. Three subprojects; Python backend/pedestrian front-end JS.&lt;/p&gt;- Patrick McKenzie (@patio11) &lt;a href="https://twitter.com/patio11/status/1379587878624190466?ref_src=twsrc%5Etfw"&gt;April 7, 2021&lt;/a&gt;&lt;/blockquote&gt;
&lt;h4&gt;TIL this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/til/blob/main/vscode/language-specific-indentation-settings.md"&gt;Language-specific indentation settings in VS Code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/til/blob/main/django/efficient-bulk-deletions-in-django.md"&gt;Efficient bulk deletions in Django&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/til/blob/main/postgresql/unnest-csv.md"&gt;Using unnest() to use a comma-separated string as the input to an IN query&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Releases this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/json-post"&gt;json-post&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/json-post/releases/tag/0.2"&gt;0.2&lt;/a&gt; - (&lt;a href="https://github.com/simonw/json-post/releases"&gt;3 total releases&lt;/a&gt;) - 2021-04-11
&lt;br /&gt;Tool for posting JSON to an API, broken into pages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/airtable-export"&gt;airtable-export&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/airtable-export/releases/tag/0.7.1"&gt;0.7.1&lt;/a&gt; - (&lt;a href="https://github.com/simonw/airtable-export/releases"&gt;10 total releases&lt;/a&gt;) - 2021-04-09
&lt;br /&gt;Export Airtable data to YAML, JSON or SQLite files on disk&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/django-sql-dashboard"&gt;django-sql-dashboard&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/django-sql-dashboard/releases/tag/0.6a0"&gt;0.6a0&lt;/a&gt; - (&lt;a href="https://github.com/simonw/django-sql-dashboard/releases"&gt;13 total releases&lt;/a&gt;) - 2021-04-09
&lt;br /&gt;Django app for building dashboards using raw SQL queries&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/migrations"&gt;migrations&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/postgresql"&gt;postgresql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/weeknotes"&gt;weeknotes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/airtable"&gt;airtable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/boring-technology"&gt;boring-technology&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="django"/><category term="django-admin"/><category term="migrations"/><category term="postgresql"/><category term="weeknotes"/><category term="airtable"/><category term="vaccinate-ca"/><category term="boring-technology"/></entry><entry><title>Weeknotes: Datasette and Git scraping at NICAR, VaccinateCA</title><link href="https://simonwillison.net/2021/Mar/7/weeknotes/#atom-tag" rel="alternate"/><published>2021-03-07T07:29:00+00:00</published><updated>2021-03-07T07:29:00+00:00</updated><id>https://simonwillison.net/2021/Mar/7/weeknotes/#atom-tag</id><summary type="html">
    &lt;p&gt;This week I virtually attended the NICAR data journalism conference and made a ton of progress on the Django backend for VaccinateCA (see &lt;a href="https://simonwillison.net/2021/Feb/28/vaccinateca/"&gt;last week&lt;/a&gt;).&lt;/p&gt;
&lt;h4&gt;NICAR 2021&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.ire.org/training/conferences/nicar-2021/"&gt;NICAR&lt;/a&gt; stands for the National Institute for Computer Assisted Reporting - an acronym that reflects the age of the organization, which started teaching journalists data-driven reporting back in 1989, long before the term "data journalism" became commonplace.&lt;/p&gt;
&lt;p&gt;This was my third NICAR and it's now firly established itself at the top of the list of my favourite conferences. Every year it attracts over 1,000 of the highest quality data nerds - from data journalism veterans who've been breaking stories for decades to journalists who are just getting started with data and want to start learning Python or polish up their skills with Excel.&lt;/p&gt;
&lt;p&gt;I presented &lt;a href="https://nicar21.pathable.co/meetings/virtual/xEmubEJvwB5mv3Dfn"&gt;an hour long workshop&lt;/a&gt; on Datasette, which I'm planning to turn into the first official Datasette tutorial. I also got to pre-record a five minute lightning talk about Git scraping.&lt;/p&gt;
&lt;p&gt;I published &lt;a href="https://simonwillison.net/2021/Mar/5/git-scraping/"&gt;the video and notes for that&lt;/a&gt; yesterday. It really seemed to strike a nerve at the conference: I showed how you can set up a scheduled scraper using GitHub Actions with just a few lines of YAML configuration, and do so entirely through the GitHub web interface without even opening a text editor.&lt;/p&gt;
&lt;p&gt;Pretty much every data journalist wants to run scrapers, and understands the friction involved in maintaining your own dedicated server and crontabs and storage and backups for running them. Being able to do this for free on GitHub's infrastructure drops that friction down to almost nothing.&lt;/p&gt;
&lt;p&gt;The lightning talk lead to a last-minute GitHub Actions and Git scraping &lt;a href="https://nicar21.pathable.co/meetings/virtual/FTTWfJicMwFLP849H"&gt;office hours session&lt;/a&gt; being added to the schedule, and I was delighted to have &lt;a href="https://github.com/rdmurphy"&gt;Ryan Murphy&lt;/a&gt; from the LA Times join that session to demonstrate the incredible things the LA Times have been doing with scrapers and GitHub Actions. You can see some of their scrapers in the &lt;a href="https://github.com/datadesk/california-coronavirus-scrapers"&gt;datadesk/california-coronavirus-scrapers&lt;/a&gt; repo.&lt;/p&gt;
&lt;h4&gt;VaccinateCA&lt;/h4&gt;
&lt;p&gt;The race continues to build out a Django backend for the &lt;a href="https://www.vaccinateca.com/"&gt;VaccinateCA&lt;/a&gt; project, to collect data on vaccine availability from people making calls on that organization's behalf.&lt;/p&gt;
&lt;p&gt;The new backend is getting perilously close to launch. I'm leaning heavily on the Django admin for this, refreshing my knowledge of how to customize it with things like &lt;a href="https://docs.djangoproject.com/en/3.1/ref/contrib/admin/actions/"&gt;admin actions&lt;/a&gt; and &lt;a href="https://docs.djangoproject.com/en/3.1/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_filter"&gt;custom filters&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It's been quite a while since I've done anything sophisticated with the Django admin and it has evolved a LOT. In the past I've advised people to drop the admin for custom view functions the moment they want to do anything out-of-the-ordinary - I don't think that advice holds any more. It's got really good over the years!&lt;/p&gt;
&lt;p&gt;A very smart thing the team at VaccinateCA did a month ago is to start logging the full incoming POST bodies for every API request handled by their existing Netlify functions (which then write to Airtable).&lt;/p&gt;
&lt;p&gt;This has given me an invaluable tool for testing out the new replacement API: I wrote &lt;a href="https://gist.github.com/simonw/83e66d618f07aa3b19d2f1db58be78b8"&gt;a script&lt;/a&gt; which replays those API logs against my new implementation - allowing me to test that every one of several thousand previously recorded API requests will run without errors against my new code.&lt;/p&gt;
&lt;p&gt;Since this is so valuable, I've written code that will log API requests to the new stack directly to the database. Normally I'd shy away from a database table for logging data like this, but the expected traffic is the low thousands of API requests a day - and a few thousand extra database rows per day is a tiny price to pay for having such a high level of visibility into how the API is being used.&lt;/p&gt;
&lt;p&gt;(I'm also logging the API requests to PostgreSQL using Django's JSONField, which means I can analyze them in depth later on using PostgreSQL's JSON functionality!)&lt;/p&gt;
&lt;h4&gt;YouTube subtitles&lt;/h4&gt;
&lt;p&gt;I decided to add proper subtitles to my &lt;a href="https://www.youtube.com/watch?v=2CjA-03yK8I&amp;amp;t=1s"&gt;lightning talk video&lt;/a&gt;, and was delighted to learn that the YouTube subtitle editor pre-populates with an automatically generated transcript, which you can then edit in place to fix up spelling, grammar and remove the various "um" and "so" filler words.&lt;/p&gt;
&lt;p&gt;This makes creating high quality captions extremely productive. I've also added them to the 17 minute &lt;a href="https://simonwillison.net/2021/Feb/7/video/"&gt;Introduction to Datasette and sqlite-utils&lt;/a&gt; video that's embedded on the &lt;a href="https://datasette.io/"&gt;datasette.io&lt;/a&gt; homepage - editing the transcript for that only took about half an hour.&lt;/p&gt;
&lt;h4&gt;TIL this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/django/testing-django-admin-with-pytest"&gt;Writing tests for the Django admin with pytest-django&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/django/show-timezone-in-django-admin"&gt;Show the timezone for datetimes in the Django admin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/mediawiki/mediawiki-sqlite-macos"&gt;How to run MediaWiki with SQLite on a macOS laptop&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/data-journalism"&gt;data-journalism&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/youtube"&gt;youtube&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/weeknotes"&gt;weeknotes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/git-scraping"&gt;git-scraping&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/nicar"&gt;nicar&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="data-journalism"/><category term="django-admin"/><category term="youtube"/><category term="datasette"/><category term="weeknotes"/><category term="git-scraping"/><category term="vaccinate-ca"/><category term="nicar"/></entry><entry><title>The simplest possible call queue</title><link href="https://simonwillison.net/2021/Mar/6/vaccinateca-2021-03-06/#atom-tag" rel="alternate"/><published>2021-03-06T17:00:00+00:00</published><updated>2021-03-06T17:00:00+00:00</updated><id>https://simonwillison.net/2021/Mar/6/vaccinateca-2021-03-06/#atom-tag</id><summary type="html">
    &lt;p class="context"&gt;&lt;em&gt;Originally posted to my internal blog at VaccinateCA&lt;/em&gt;&lt;/p&gt;&lt;p&gt;Today I've been working on the queue calling mechanism for the new Django backend.&lt;/p&gt;
&lt;p&gt;We want to do a trial run of the system as soon as possible, so I opted to create the simplest possible version of this that could work.&lt;/p&gt;
&lt;p&gt;The Django app is going to work a little bit different from our existing Airtable system. The Airtable system defines a number of different filtered views for locations that we want to call, and leaves it to our caller app to load in rows from those views, select a random location from the options and "lock" that location so no-one else is given it to call for at least twenty minutes.&lt;/p&gt;
&lt;p&gt;For the new system, we're going to switch to having an explicit "locations to call" list, which our staff can add locations to and manipulate. This will continue to evolve over time - there's already discussion of having multiple queues, and assigning different callers to different queues depending on their status and trainining.&lt;/p&gt;
&lt;p&gt;For the moment though, the simplest thing is to have a database table with a list of locations that need to be called in it.&lt;/p&gt;
&lt;p&gt;I implemented the first version of this in &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/70"&gt;issue 70&lt;/a&gt;. I'm using Django's &lt;a href="https://docs.djangoproject.com/en/3.1/ref/contrib/admin/actions/" rel="nofollow"&gt;admin actions&lt;/a&gt; mechanism, which allows you to add bulk actions to the admin change list for any model.&lt;/p&gt;
&lt;p&gt;The neat thing about admin actions is that you can apply them to multiple rows in the Django admin action interface: by clicking their checkboxes, by shift-clicking a range of checkboxes or with a "select all" option that can apply to the current page or every row that matches the current set of filters.&lt;/p&gt;
&lt;p&gt;Combined with a rich set of filters this is a really powerful way of applying bulk actions to items in your database. The admin actions framework then provides a Django ORM queryset to your action code, which can act on it however it wants.&lt;/p&gt;
&lt;p&gt;My first version of the code adds a new admin action for each of the "call request reasons" in the database (currently four, but &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/72"&gt;more to come shortly&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;Here's an animated demo showing how the new mechanism works:&lt;/p&gt;
&lt;p&gt;&lt;img alt="queue" src="https://user-images.githubusercontent.com/9599/110231588-7918dc80-7ecd-11eb-9d5a-2e5959c8ce53.gif" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;Next up: implement the &lt;code&gt;/api/requestCall&lt;/code&gt; API that the caller app uses to fetch the next location that the user should call! &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/54"&gt;#54&lt;/a&gt;&lt;/p&gt;
&lt;h4&gt;
The Django app on Google Cloud&lt;/h4&gt;
&lt;p&gt;Alex Vandiver has done a superb job getting the Django app in a state where it can run on our Google Cloud infrastructure. Most of &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/commits/b2908650ea2c2326565d7e9e31eb4ef30357f3d5"&gt;today's commit activity&lt;/a&gt; was from Alex, and in addition to having continuous deployment running via Google Cloud Build and Cloud Run thanks to Alex we also have improvements like &lt;a href="https://pycqa.github.io/isort/" rel="nofollow"&gt;isort&lt;/a&gt;, &lt;a href="https://github.com/theskumar/python-dotenv"&gt;python-dotenv&lt;/a&gt; and the (new to me) &lt;a href="https://github.com/3YOURMIND/django-migration-linter"&gt;django-migration-linter&lt;/a&gt;.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca-blog"&gt;vaccinate-ca-blog&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="django-admin"/><category term="vaccinate-ca"/><category term="vaccinate-ca-blog"/></entry><entry><title>Django admin customization, JSON in our PostgreSQL</title><link href="https://simonwillison.net/2021/Feb/25/vaccinateca-2021-02-25/#atom-tag" rel="alternate"/><published>2021-02-25T17:00:00+00:00</published><updated>2021-02-25T17:00:00+00:00</updated><id>https://simonwillison.net/2021/Feb/25/vaccinateca-2021-02-25/#atom-tag</id><summary type="html">
    &lt;p class="context"&gt;&lt;em&gt;Originally posted to my internal blog at VaccinateCA&lt;/em&gt;&lt;/p&gt;&lt;p&gt;My progress slowed a bit today as I started digging into some things I'm less familiar with - but I've found some tricks that I think will help us out a lot.&lt;/p&gt;
&lt;h4&gt;
Django admin customization&lt;/h4&gt;
&lt;p&gt;In the past I've used the Django admin mostly as a database debugging tool, on the basis that once you get deep into building out an interface that's more than just a very basic CMS you're better off rolling something from scratch.&lt;/p&gt;
&lt;p&gt;Today my opinion changed. I think the Django admin may be the solution to a LOT of our problems, with very little extra customization.&lt;/p&gt;
&lt;p&gt;I was looking at a simple feature request: show a summary of calls made by a specific reporter. It turns out adding a custom templated block of text to an existing Django admin "change item" page is trivial, using &lt;a href="https://til.simonwillison.net/django/extra-read-only-admin-information" rel="nofollow"&gt;the pattern I wrote up here&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img alt="image" src="https://user-images.githubusercontent.com/9599/109248016-dc05c780-7799-11eb-9143-a41c83bfb24f.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/commit/c1f170c8776a194eb13195a15dd922ee2d8c5270"&gt;implementation for this is tiny&lt;/a&gt;. We can use this pattern to add SO much depth to our admin pages - and Django's default permission system is robust enough that we can give users access to these pages without them being able to make edits.&lt;/p&gt;
&lt;p&gt;I also added a column to the reporters table showing the number of calls each reporter has made, and made that column sortable! &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/commit/fad4c472856ef055891b8f5008e2f0adee4e75e9"&gt;Implementation here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img alt="image" src="https://user-images.githubusercontent.com/9599/109248301-677f5880-779a-11eb-8bc6-433d6d4808be.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;Once we're fully on Django we'll be able to turn around features like this in very little time. You don't need to be a Django expert to build these either - a tiny bit of Python and HTML knowledge should be enough to productively modify this interface.&lt;/p&gt;
&lt;h4&gt;
JSON in our PostgreSQL&lt;/h4&gt;
&lt;p&gt;My main goal for the day was to tighten up the call reports importer script I wrote yesterday.&lt;/p&gt;
&lt;p&gt;My big breakthrough on this came after a long and super-valuable conversation with Nicholas Schiefer, who's been heavily involved in the growth of our Airtable schema ever since the project started.&lt;/p&gt;
&lt;p&gt;Our Airtable data is complicated, because the way we write data to it has constantly evolved. Before the launch of &lt;code&gt;help.vaccinateca&lt;/code&gt; the data was all entered through a custom Airtable app, and many of the fields we are capturing now weren't being captured just a few weeks ago.&lt;/p&gt;
&lt;p&gt;Since writing a one-off importer that patches over all of these differences in a single go is virtually impossible, we decided to try an alternative track: my importer now saves the &lt;em&gt;entire&lt;/em&gt; original Airtable JSON to a PostgreSQL JSON column (using Django 3.1's brand new &lt;a href="https://docs.djangoproject.com/en/3.1/ref/models/fields/#jsonfield" rel="nofollow"&gt;JSONField&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;I taught the Django Admin to pretty-print the JSON (&lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/commit/ca1aa3af3d04721600096b677f7e01e57732c68a"&gt;implementation here&lt;/a&gt;):&lt;/p&gt;
&lt;p&gt;&lt;img alt="image" src="https://user-images.githubusercontent.com/9599/109248710-263b7880-779b-11eb-853c-b23cde4db0bf.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;The most obvious value of this is in debugging - it's much easier to look at a record now and compare it to the Airtable version.&lt;/p&gt;
&lt;p&gt;More importantly: if we make a mistake in the importer code today and don't notice for six months, that's fine! We can re-backfill against the new lessons we have learned using the Airtable JSON data that we've already stored.&lt;/p&gt;
&lt;p&gt;The cost? ~20,000 database records with a few extra KB of data stored against them. That's totally worth it.&lt;/p&gt;
&lt;h4&gt;
Querying JSON&lt;/h4&gt;
&lt;p&gt;PostgreSQL has a &lt;a href="https://www.postgresql.org/docs/12/functions-json.html" rel="nofollow"&gt;bunch of features&lt;/a&gt; for querying into JSON fields which I haven't really explored before. I decided to try them out.&lt;/p&gt;
&lt;p&gt;They're incredible. Here's a query that shows the callers who have made the most calls, based on extracting the &lt;code&gt;{"Reported by": {"name": "NAME"}}&lt;/code&gt; nested field from that JSON column:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt;
    jsonb_extract_path(
        airtable_json, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Reported by&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;name&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
    ) &lt;span class="pl-k"&gt;as&lt;/span&gt; name,
    &lt;span class="pl-c1"&gt;count&lt;/span&gt;(&lt;span class="pl-k"&gt;*&lt;/span&gt;) &lt;span class="pl-k"&gt;as&lt;/span&gt; n
&lt;span class="pl-k"&gt;from&lt;/span&gt; call_report
&lt;span class="pl-k"&gt;group by&lt;/span&gt; name
&lt;span class="pl-k"&gt;order by&lt;/span&gt; n &lt;span class="pl-k"&gt;desc&lt;/span&gt;;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Even more exciting... here's a query that counts the keys that have been used in ALL of the JSON returned from Airtable:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;SELECT&lt;/span&gt;
    jsonb_object_keys(airtable_json) &lt;span class="pl-k"&gt;AS&lt;/span&gt; key, &lt;span class="pl-c1"&gt;count&lt;/span&gt;(&lt;span class="pl-k"&gt;*&lt;/span&gt;)
&lt;span class="pl-k"&gt;FROM&lt;/span&gt; call_report &lt;span class="pl-k"&gt;GROUP BY&lt;/span&gt; key;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And here's what it outputs:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;JSON key&lt;/th&gt;
&lt;th&gt;Times used&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Affiliation (from Location)&lt;/td&gt;
&lt;td&gt;21589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;airtable_createdTime&lt;/td&gt;
&lt;td&gt;21589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;airtable_id&lt;/td&gt;
&lt;td&gt;21589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Appointments by phone?&lt;/td&gt;
&lt;td&gt;466&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Appointment scheduling instructions&lt;/td&gt;
&lt;td&gt;2526&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;auth0_reporter_id&lt;/td&gt;
&lt;td&gt;1583&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;auth0_reporter_name&lt;/td&gt;
&lt;td&gt;1583&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;auth0_reporter_roles&lt;/td&gt;
&lt;td&gt;1583&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Availability&lt;/td&gt;
&lt;td&gt;21589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;County (from Location)&lt;/td&gt;
&lt;td&gt;21589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Do not call until&lt;/td&gt;
&lt;td&gt;3116&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;external_reports_base_external_report_id&lt;/td&gt;
&lt;td&gt;3139&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hour&lt;/td&gt;
&lt;td&gt;21589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ID&lt;/td&gt;
&lt;td&gt;21589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Internal Notes&lt;/td&gt;
&lt;td&gt;18258&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;is_latest_report_for_location&lt;/td&gt;
&lt;td&gt;21589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;is_pending_review&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Location&lt;/td&gt;
&lt;td&gt;21589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;location_id&lt;/td&gt;
&lt;td&gt;21589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;location_latest_eva_report_time&lt;/td&gt;
&lt;td&gt;13443&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;location_latest_report_id&lt;/td&gt;
&lt;td&gt;21589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;location_latest_report_time&lt;/td&gt;
&lt;td&gt;21354&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Location Type (from Location)&lt;/td&gt;
&lt;td&gt;21588&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Name (from Location)&lt;/td&gt;
&lt;td&gt;21589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notes&lt;/td&gt;
&lt;td&gt;3359&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Number of Reports (from Location)&lt;/td&gt;
&lt;td&gt;21589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;parent_eva_report&lt;/td&gt;
&lt;td&gt;958&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;parent_external_report&lt;/td&gt;
&lt;td&gt;2410&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone&lt;/td&gt;
&lt;td&gt;82&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reported by&lt;/td&gt;
&lt;td&gt;21589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;report_id&lt;/td&gt;
&lt;td&gt;21589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Report Type&lt;/td&gt;
&lt;td&gt;21589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;soft-dropped-column: Vaccines available?&lt;/td&gt;
&lt;td&gt;15948&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;time&lt;/td&gt;
&lt;td&gt;21589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tmp_eva_flips&lt;/td&gt;
&lt;td&gt;21589&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vaccine demand&lt;/td&gt;
&lt;td&gt;733&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vaccine demand notes&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;This is amazingly useful data for the importer script that I'm writing!&lt;/p&gt;
&lt;p&gt;The query also executes in about 800ms against the cheapest PostgreSQL database server that Heroku offer - doing a deep full table scan against all 22,000 imported records.&lt;/p&gt;
&lt;p&gt;Based on how powerful is, I'm now thinking that we should go all-in on JSON in our database. Imagine if every scraper we were running dumped its full scraped JSON data into PostgreSQL - we could join arbitrary scraped data against our other tables to figure out if there are any new locations.&lt;/p&gt;
&lt;p&gt;My biggest concern about replacing Airtable is that we'll lose the amazing flexibility it's given us. I think JSON columns can help bridge that gap.&lt;/p&gt;
&lt;h4&gt;
Call targeting: the most interesting problem&lt;/h4&gt;
&lt;p&gt;Another topic that came out of my conversation with Nicholas: I had not seen quite how ingenious the way call targeting works is. This is SUCH a smart usage of Airtable!&lt;/p&gt;
&lt;p&gt;&lt;img alt="image" src="https://user-images.githubusercontent.com/9599/109249401-79fa9180-779c-11eb-9c5c-77733f69c473.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;The short version: call targeting (the logic that decides which number a volunteer should be asked to call) is powered by Airtable views, with really clever application of Airtable's filters to help build up the call lists.&lt;/p&gt;
&lt;p&gt;We need to maintain our ability to smartly target where the calls go, and ideally make it even better. This is going to be a really fun problem to solve!&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/postgresql"&gt;postgresql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca-blog"&gt;vaccinate-ca-blog&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="django"/><category term="django-admin"/><category term="postgresql"/><category term="vaccinate-ca"/><category term="vaccinate-ca-blog"/></entry><entry><title>Importing data from Airtable into Django, plus a search engine for all our code</title><link href="https://simonwillison.net/2021/Feb/24/vaccinateca-2021-02-24/#atom-tag" rel="alternate"/><published>2021-02-24T17:00:00+00:00</published><updated>2021-02-24T17:00:00+00:00</updated><id>https://simonwillison.net/2021/Feb/24/vaccinateca-2021-02-24/#atom-tag</id><summary type="html">
    &lt;p class="context"&gt;&lt;em&gt;Originally posted to my internal blog at VaccinateCA&lt;/em&gt;&lt;/p&gt;&lt;p&gt;I made &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/commits/12514c713686e640036289c0411a59af5df9d4ed"&gt;a bunch of progress&lt;/a&gt; on the Django backend prototype-that-soon-won’t-be-a-prototype today.&lt;/p&gt;
&lt;h4&gt;
Importing data from Airtable&lt;/h4&gt;
&lt;p&gt;My goal for the day was to get some real data into the prototype, imported from Airtable. I’ve now done that with the two most important tables: Locations (aka places that people can go to get the vaccine) and CallReports, created by volunteers making phone calls.&lt;/p&gt;
&lt;p&gt;I achieved this by writing two new Django management commands:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;./manage.py import_airtable_locations&lt;/code&gt; (&lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/12514c713686e640036289c0411a59af5df9d4ed/vaccinate/core/import_utils.py#L21-L71"&gt;code&lt;/a&gt;, &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/12514c713686e640036289c0411a59af5df9d4ed/vaccinate/core/test_airtable_import.py#L52-L74"&gt;test&lt;/a&gt;) populates the Django Locations model by pulling data from the Locations table in Airtable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;./manage.py import_airtable_reports&lt;/code&gt; (&lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/12514c713686e640036289c0411a59af5df9d4ed/vaccinate/core/import_utils.py#L74-L117"&gt;code&lt;/a&gt;, &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/12514c713686e640036289c0411a59af5df9d4ed/vaccinate/core/test_airtable_import.py#L77-L128"&gt;test&lt;/a&gt;) populates the Django CallReports model using data from the Reports table in Airtable.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Both of these importers follow &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/12514c713686e640036289c0411a59af5df9d4ed/vaccinate/core/management/commands/import_airtable_locations.py#L6-L28"&gt;the same pattern&lt;/a&gt;: you can point them directly at a Locations.json file you’ve already downloaded:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;./manage.py import_airtable_locations \
  --json-file=Locations.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or you can use a GitHub personal access token to have them load the data directly from our airtable-data-backup  repository, provided you have access to that.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;./manage.py import_airtable_locations \
  --github-token=xxxx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I built the GitHub token mechanism to make it easy to run this command on a server, without having to mess around uplooading JSON files first. Since the prototype is running on Heroku I can pull a fresh import into it directly by running the following command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;% heroku run bash -a vaccinateca-preview
Running bash on ⬢ vaccinateca-preview... up, run.3026 (Hobby)
~ $ vaccinate/manage.py import_airtable_locations --github-token xxx
Skipping rec0xZ5EaKnnynfDa [name=CVS Pharmacy® &amp;amp; Drug Store at 25272 Marguerite Pkwy, Mission Viejo, CA 92692], reason=No latitude
Skipping rec7nHXCuSYRR61V0 [name=None], reason=No name
Skipping rec8Xk6kn4SvAKeEm [name=None], reason=No name
Skipping recCYZZRJCRlXykun [name=None], reason=No name
Skipping recJt0iQbqmglF0XL [name=Dignity Health (Woodland)], reason=No county
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The command outputs a line for each item it fails to import. This turns out to be an extremely useful way to spot invalid data - locations with no name, latitude or county for example.&lt;/p&gt;
&lt;p&gt;The script successfully imported 7038 locations, skipping just 17.&lt;/p&gt;
&lt;p&gt;My call report importer needs a lot more work. It imported 21,029 call records, but  skipped 962. More details &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/9#issuecomment-785529714"&gt;in the GitHub issue thread&lt;/a&gt;.&lt;/p&gt;
&lt;h4&gt;
Django Admin customization&lt;/h4&gt;
&lt;p&gt;I also made some tweaks to the Django admin. Here’s a screenshot of the call records list as it stands now.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Banners_and_Alerts_and_Select_call_report_to_change___Django_site_admin" src="https://user-images.githubusercontent.com/9599/109108464-7b6a8200-76e8-11eb-8ea3-b0d102cb7a03.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;And here's the locations table:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Banners_and_Alerts_and_Select_location_to_change___Django_site_admin" src="https://user-images.githubusercontent.com/9599/109108511-9210d900-76e8-11eb-9324-6305edf858e3.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;h4&gt;
SSO with Auth0&lt;/h4&gt;
&lt;p&gt;This isn't fully working yet, but it's nearly there. I've integrated Auth0 SSO with the Django app, with the goal being that any staff member can use Auth0 to sign in to the Django Admin panel and view and modify the data there.&lt;/p&gt;
&lt;p&gt;You can try it out on the &lt;a href="https://vaccinateca-preview.herokuapp.com/" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/&lt;/a&gt; page - once it's fully working, you'll be able to sign in and then visit &lt;a href="https://vaccinateca-preview.herokuapp.com/admin/" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/admin/&lt;/a&gt; to interact with the data.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/8"&gt;the ongoing issue thread&lt;/a&gt;. I ended up creating a brand new role in Auth0 called "Vaccinate CA Staff" - I've figured out how to access those roles from the Django app when a user signs in, so I can then use membership of that role to control access to the admin panel.&lt;/p&gt;
&lt;h4&gt;
Even more ambitious goals&lt;/h4&gt;
&lt;p&gt;Since the Django backend work is going at a very healthy pace, I'm extending my ambitions for it a bit.&lt;/p&gt;
&lt;p&gt;The goal of the app is to replace Airtable as the point of truth for the data collected by our calling volunteers AND as the data source behind the public-facing &lt;a href="https://www.vaccinateca.com/" rel="nofollow"&gt;https://www.vaccinateca.com/&lt;/a&gt; site.&lt;/p&gt;
&lt;p&gt;Once I've made some improvements to the schema informed by the data importing project, I think the next step will be to spin up just enough of an API endpoint that the calling app can start writing to Django in parallel to writing to Airtable.&lt;/p&gt;
&lt;p&gt;This can be done inside the &lt;a href="https://github.com/CAVaccineInventory/help.vaccinate/blob/ba54e04a386d5ba10763331ef2f0c90946443d2f/netlify/functions/submitReport/index.js#L74"&gt;Netlify function&lt;/a&gt; used by the app. I'd like that function to continue writing to Airtable but also to write to the Django/PostgreSQL stack, wrapped in an error handler so failures there are ignored.&lt;/p&gt;
&lt;p&gt;Then we can run the Django app as a silent partner to Airtable for a few days and compare the results gathered by the two, to gain confidence before switching over from one to the other.&lt;/p&gt;
&lt;p&gt;Getting that running will be my goal for next week. If all goes well we may find we can make the full switch to the Django backend within a couple of weeks.&lt;/p&gt;
&lt;h4&gt;
Regular expression code search across all of our repos&lt;/h4&gt;
&lt;p&gt;I love code search. I particularly like being able to search code with regular expressions, and then share links to those searches with other engineers.&lt;/p&gt;
&lt;p&gt;The best code search tool I’ve ever used is &lt;a href="https://github.com/BurntSushi/ripgrep"&gt;ripgrep&lt;/a&gt;. A few months ago &lt;a href="https://simonwillison.net/2020/Nov/28/datasette-ripgrep/" rel="nofollow"&gt;I built a simple web wrapper&lt;/a&gt; around ripgrep called &lt;a href="https://datasette.io/plugins/datasette-ripgrep" rel="nofollow"&gt;datasette-ripgrep&lt;/a&gt;. This evening I deployed a copy of it against the source code from nine of our code repos:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/CAVaccineInventory/airtable-sql-science"&gt;CAVaccineInventory/airtable-sql-science&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CAVaccineInventory/gists"&gt;CAVaccineInventory/gists&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CAVaccineInventory/airtable-export"&gt;CAVaccineInventory/airtable-export&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CAVaccineInventory/data-engineering"&gt;CAVaccineInventory/data-engineering&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CAVaccineInventory/help.vaccinate"&gt;CAVaccineInventory/help.vaccinate&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CAVaccineInventory/airtableApps"&gt;CAVaccineInventory/airtableApps&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CAVaccineInventory/django.vaccinate"&gt;CAVaccineInventory/django.vaccinate&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CAVaccineInventory/site"&gt;CAVaccineInventory/site&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CAVaccineInventory/vaccinebot"&gt;CAVaccineInventory/vaccinebot&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can access the search engine here (nicer URL coming soon):&lt;/p&gt;
&lt;p&gt;&lt;a href="https://vaccinateca-ripgrep-j7hipcg4aq-uc.a.run.app/" rel="nofollow"&gt;https://vaccinateca-ripgrep-j7hipcg4aq-uc.a.run.app/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;You'll need to sign in with GitHub - I'm protecting the site using &lt;a href="https://datasette.io/plugins/datasette-auth-github" rel="nofollow"&gt;datasette-auth-github&lt;/a&gt; configured to only allow in members of the &lt;code&gt;CAVaccineInventor&lt;/code&gt; GitHub organization.&lt;/p&gt;
&lt;p&gt;Once you've signed in, try this example search:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://vaccinateca-ripgrep-j7hipcg4aq-uc.a.run.app/-/ripgrep?pattern=fetch%5C%28%7CfetchJsonFromEndpoint&amp;amp;glob=*.js" rel="nofollow"&gt;https://vaccinateca-ripgrep-j7hipcg4aq-uc.a.run.app/-/ripgrep?pattern=fetch%5C%28%7CfetchJsonFromEndpoint&amp;amp;glob=*.js&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;That runs a search for &lt;code&gt;fetch\(|fetchJsonFromEndpoint&lt;/code&gt; across all of our &lt;code&gt;*.js&lt;/code&gt; files, which shows us everywhere we are making an HTTP reuest using either &lt;code&gt;fetch()&lt;/code&gt; or our own &lt;code&gt;fetchJsonFromEndpoint()&lt;/code&gt; function.&lt;/p&gt;
&lt;p&gt;The repo for the search engine &lt;a href="https://github.com/CAVaccineInventory/vaccinateca-ripgrep"&gt;is here&lt;/a&gt;.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/airtable"&gt;airtable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca-blog"&gt;vaccinate-ca-blog&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="django"/><category term="django-admin"/><category term="airtable"/><category term="vaccinate-ca"/><category term="vaccinate-ca-blog"/></entry><entry><title>Spinning up a new Django app to act as a backend for VaccinateCA</title><link href="https://simonwillison.net/2021/Feb/23/vaccinateca-2021-02-23/#atom-tag" rel="alternate"/><published>2021-02-23T17:00:00+00:00</published><updated>2021-02-23T17:00:00+00:00</updated><id>https://simonwillison.net/2021/Feb/23/vaccinateca-2021-02-23/#atom-tag</id><summary type="html">
    &lt;p class="context"&gt;&lt;em&gt;Originally posted to my internal blog at VaccinateCA&lt;/em&gt;&lt;/p&gt;&lt;p&gt;My goal by the end of this week is to have a working proof of concept for a Django + PostgreSQL app that can replace Airtable as the principle backend for the &lt;a href="https://www.vaccinateca.com/" rel="nofollow"&gt;https://www.vaccinateca.com/&lt;/a&gt; site. This proof of concept will allow us to make a go or no-go decision and figure out what else needs to be implemented before we can start using it to track calls.&lt;/p&gt;
&lt;p&gt;I'm calling it a "prototype" and a "proof of concept", but my career has taught me that prototypes often end up going into production - so I'm building it with that in mind.&lt;/p&gt;
&lt;p&gt;Today I started building that app. The repo is currently &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate"&gt;https://github.com/CAVaccineInventory/django.vaccinate&lt;/a&gt; though we are likely to rename it soon - possibly to VIAL (for Vaccine Information Archive and Library) - Jesse is good at actually relevant codenames!&lt;/p&gt;
&lt;p&gt;Here's what I have so far:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Proof of concept for Auth0 SSO - mostly from following &lt;a href="https://auth0.com/docs/quickstart/webapp/django/01-login" rel="nofollow"&gt;their tutorial&lt;/a&gt;. You can try that out on the staging site homepage at &lt;a href="https://vaccinateca-preview.herokuapp.com/" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The very beginnings of a test suite - built using &lt;code&gt;pytest-django&lt;/code&gt;. The tests run against PostgreSQL and I had to figure out how to do that inside GitHub Actions - here's &lt;a href="https://til.simonwillison.net/github-actions/postgresq-service-container" rel="nofollow"&gt;my TIL&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;The tests run in GitHub Actions! Continous Integration - should work against pull requests too.&lt;/li&gt;
&lt;li&gt;... which means we can take the next step and go for Continuous Deployment. Every commit that passes the tests is now deployed instantly to the staging environment.&lt;/li&gt;
&lt;li&gt;The staging environment itself is currently on Heroku, because they make it ridiculously easy to setup Continuous Deployment - it's literally a checkbox in their admin panel. I wrote about this a few years ago: &lt;a href="https://simonwillison.net/2017/Oct/17/free-continuous-deployment/" rel="nofollow"&gt;How to set up world-class continuous deployment using free hosted tools&lt;/a&gt;. It's likely we'll move this to Google Cloud at some point since other VaccinateCA stuff is running there. I know how to run Continous Deployment using Google Cloud Run so that could be a good option here.&lt;/li&gt;
&lt;li&gt;Errors now get logged to &lt;a href="https://sentry.io/organizations/vaccinateca/issues/?project=5649843" rel="nofollow"&gt;a new project&lt;/a&gt; in the VaccinateCA Sentry instance.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And the biggest thing: I've implemented &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/9a6fd676be8907e67ba13ada924f5656b22357e3/vaccinate/core/models.py"&gt;a set of Django models&lt;/a&gt; against the SQL schema that has been coming together in &lt;a href="https://github.com/CAVaccineInventory/data-engineering/pull/2"&gt;this pull request&lt;/a&gt;. These are exposed in the Django Admin (just with default settings, no customization yet) in the staging environment.&lt;/p&gt;
&lt;p&gt;You can try those out by visiting &lt;a href="https://vaccinateca-preview.herokuapp.com/admin/" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/admin/&lt;/a&gt; and signing in with username &lt;code&gt;demo&lt;/code&gt; and password &lt;code&gt;demo&lt;/code&gt; (this account will be deleted the second we have any real data in the prototype).&lt;/p&gt;
&lt;p&gt;I wrote data migrations to &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/9a6fd676be8907e67ba13ada924f5656b22357e3/vaccinate/core/migrations/0002_populate_states.py"&gt;insert states&lt;/a&gt; and &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/9a6fd676be8907e67ba13ada924f5656b22357e3/vaccinate/core/migrations/0003_populate_ca_counties.py"&gt;insert counties&lt;/a&gt; - you can see the results in the admin &lt;a href="https://vaccinateca-preview.herokuapp.com/admin/core/county/" rel="nofollow"&gt;here (counties)&lt;/a&gt; and &lt;a href="https://vaccinateca-preview.herokuapp.com/admin/core/state/" rel="nofollow"&gt;here (states)&lt;/a&gt;.&lt;/p&gt;
&lt;h4&gt;
Some engineering principles&lt;/h4&gt;
&lt;p&gt;I've invested a lot of effort today in getting some fundamental things set up: a test suite, continuous integration, continuous deployment, and a detailed and up-to-date &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/9a6fd676be8907e67ba13ada924f5656b22357e3/README.md"&gt;README&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The reason I'm investing that effort now is that I know from experience that these things are incredibly valuable, and very easy to implement at the start of a project... but become massively harder the longer you delay them. Adding comprehensive tests, documentation and CI to a six month old project can take weeks. Adding them to a project that is just starting takes just a few hours.&lt;/p&gt;
&lt;p&gt;I also plan to lean &lt;strong&gt;very&lt;/strong&gt; heavily on the Django migrations system.&lt;/p&gt;
&lt;p&gt;I've worked at companies in the past where database migrations - any kind of schema change - are slow, rare and exciting. This has horrible knock-on effects: engineers will go to great lengths to avoid adding a column to a table, which can lead to a rapid acretion of technical debt.&lt;/p&gt;
&lt;p&gt;I want schema changes to be quick, common and boring. Django's migration system - especially against PostgreSQL, which can execute schema changes inside transactions - is ideally suited to this. I want to start using it agressively as early as possible, to ensure we have a culture that says "yes" to schema changes and executes them promptly and frequently.&lt;/p&gt;
&lt;h4&gt;
Next steps&lt;/h4&gt;
&lt;p&gt;I want to get some real data into the system! I'm going to lock down the security a bit more, then take some exports from Airtable, convert them to the new schema and load them into the prototype. This will allow us to really start kicking the tires on it.&lt;/p&gt;
&lt;p&gt;I'm tracking all of the work on the Django app in &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues"&gt;the issues&lt;/a&gt; for that repository.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/continuous-deployment"&gt;continuous-deployment&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/continuous-integration"&gt;continuous-integration&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/postgresql"&gt;postgresql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca-blog"&gt;vaccinate-ca-blog&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="continuous-deployment"/><category term="continuous-integration"/><category term="django"/><category term="django-admin"/><category term="postgresql"/><category term="vaccinate-ca"/><category term="vaccinate-ca-blog"/></entry><entry><title>PostgreSQL full-text search in the Django Admin</title><link href="https://simonwillison.net/2020/Jul/25/postgresql-full-text-search-django-admin/#atom-tag" rel="alternate"/><published>2020-07-25T23:05:33+00:00</published><updated>2020-07-25T23:05:33+00:00</updated><id>https://simonwillison.net/2020/Jul/25/postgresql-full-text-search-django-admin/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/til/blob/master/django/postgresql-full-text-search-admin.md"&gt;PostgreSQL full-text search in the Django Admin&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Today I figured out how to use PostgreSQL full-text search in the Django admin for my blog, using the get_search_results method on a subclass of ModelAdmin.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/postgresql"&gt;postgresql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/search"&gt;search&lt;/a&gt;&lt;/p&gt;



</summary><category term="django"/><category term="django-admin"/><category term="postgresql"/><category term="search"/></entry><entry><title>Optimizing Django Admin Paginator</title><link href="https://simonwillison.net/2018/Nov/6/optimizing-django-admin-paginator/#atom-tag" rel="alternate"/><published>2018-11-06T18:17:42+00:00</published><updated>2018-11-06T18:17:42+00:00</updated><id>https://simonwillison.net/2018/Nov/6/optimizing-django-admin-paginator/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://hakibenita.com/optimizing-the-django-admin-paginator"&gt;Optimizing Django Admin Paginator&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
The Django admin paginator uses a count(*) to calculate the total number of rows, so it knows how many pages to display. This makes it unpleasantly slow over large datasets. Haki Benita has an ingenious solution: drop in a custom paginator which uses the PostgreSQL “SET LOCAL statement_timeout TO 200” statement first, then if a timeout error is raised returns 9999999999 as the count instead. This means small tables get accurate page counts and giant tables load display in the admin within a reasonable time period.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/postgresql"&gt;postgresql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/haki-benita"&gt;haki-benita&lt;/a&gt;&lt;/p&gt;



</summary><category term="django"/><category term="django-admin"/><category term="postgresql"/><category term="haki-benita"/></entry><entry><title>Django #8936: Add view (read-only) permission to admin (closed)</title><link href="https://simonwillison.net/2018/May/17/read-only-admin/#atom-tag" rel="alternate"/><published>2018-05-17T13:40:35+00:00</published><updated>2018-05-17T13:40:35+00:00</updated><id>https://simonwillison.net/2018/May/17/read-only-admin/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://code.djangoproject.com/ticket/8936"&gt;Django #8936: Add view (read-only) permission to admin (closed)&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Opened 10 years ago. Closed 15 hours ago. I apparently filed this issue during the first DjangoCon back in September 2008, when Adrian and Jacob mentioned on-stage that they would like to see a read-only permission for the Django Admin. Thanks to Olivier Dalang from Fiji and Petr Dlouhý from Prague it’s going to be a feature shipping in Django 2.1. Open source is a beautiful thing.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/djangocon"&gt;djangocon&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/open-source"&gt;open-source&lt;/a&gt;&lt;/p&gt;



</summary><category term="django"/><category term="django-admin"/><category term="djangocon"/><category term="open-source"/></entry><entry><title>Django 2.0 released</title><link href="https://simonwillison.net/2017/Dec/2/django/#atom-tag" rel="alternate"/><published>2017-12-02T16:49:03+00:00</published><updated>2017-12-02T16:49:03+00:00</updated><id>https://simonwillison.net/2017/Dec/2/django/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.djangoproject.com/weblog/2017/dec/02/django-20-released/"&gt;Django 2.0 released&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
The first version of Django to drop support for Python 2. I’ve been running the RC on my blog for the past 5 weeks and greatly enjoying the new mobile-optimized Django admin for posting links and quotations from my phone. The new simplified URL routing syntax (an optional alternative to regular expressions) is a very welcome improvement.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;&lt;/p&gt;



</summary><category term="django"/><category term="django-admin"/></entry><entry><title>pythondotorg/admin.py</title><link href="https://simonwillison.net/2017/Nov/19/pythondotorg/#atom-tag" rel="alternate"/><published>2017-11-19T06:28:45+00:00</published><updated>2017-11-19T06:28:45+00:00</updated><id>https://simonwillison.net/2017/Nov/19/pythondotorg/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/python/pythondotorg/blob/1311231470af4ec2903e9e64b9be257599f71815/cms/admin.py"&gt;pythondotorg/admin.py&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
There are some neat tricks in the Django application that powers Python.org (built a few years ago by RevSys). Here’s how their admin app handles creator/last_modified_by user relationships.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/webology/status/931919091874574336"&gt;Jeff Triplet&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;&lt;/p&gt;



</summary><category term="django"/><category term="django-admin"/></entry><entry><title>Using “import refs” to iteratively import data into Django</title><link href="https://simonwillison.net/2017/Nov/4/import-refs/#atom-tag" rel="alternate"/><published>2017-11-04T19:17:00+00:00</published><updated>2017-11-04T19:17:00+00:00</updated><id>https://simonwillison.net/2017/Nov/4/import-refs/#atom-tag</id><summary type="html">
    &lt;p&gt;I’ve been writing a few scripts to backfill my blog with content I originally posted elsewhere. So far I’ve imported &lt;a href="https://simonwillison.net/tags/quora/"&gt;answers I posted on Quora&lt;/a&gt; (&lt;a href="https://simonwillison.net/2017/Oct/1/ship/"&gt;background&lt;/a&gt;), &lt;a href="https://simonwillison.net/tags/askmetafilter/"&gt;answers I posted on Ask MetaFilter&lt;/a&gt; and &lt;a href="https://simonwillison.net/2017/Oct/8/missing-content/"&gt;content I recovered from the Internet Archive&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I started out writing custom import scripts (like &lt;a href="https://github.com/simonw/simonwillisonblog/blob/e737be8b4228229e833fe7a9ec698f3e262cd094/blog/management/commands/import_quora.py"&gt;this Quora one&lt;/a&gt;), but I’ve now built a generalized mechanism for this which I thought was worth writing up.&lt;/p&gt;
&lt;p&gt;Any of my content imports now take the form of a JSON document, which looks something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[
  {
    &amp;quot;body&amp;quot;: &amp;quot;&amp;lt;p&amp;gt;&amp;lt;em&amp;gt;My answer to ...&amp;lt;/em&amp;gt;&amp;lt;/p&amp;gt;&amp;quot;,
    &amp;quot;tags&amp;quot;: [
      &amp;quot;backpacks&amp;quot;,
      &amp;quot;laptops&amp;quot;,
      &amp;quot;style&amp;quot;,
      &amp;quot;accessories&amp;quot;,
      &amp;quot;bags&amp;quot;
    ],
    &amp;quot;title&amp;quot;: &amp;quot;I need a new backpack&amp;quot;,
    &amp;quot;datetime&amp;quot;: &amp;quot;2005-01-16T14:08:00&amp;quot;,
    &amp;quot;import_ref&amp;quot;: &amp;quot;askmetafilter:14075&amp;quot;,
    &amp;quot;type&amp;quot;: &amp;quot;entry&amp;quot;,
    &amp;quot;slug&amp;quot;: &amp;quot;i-need-a-new-backpack&amp;quot;
  }
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two larger examples: the &lt;a href="https://gist.github.com/simonw/5a5bc1f58297d2c7d68dd7448a4d6614"&gt;missing content I extracted from the Internet Archive&lt;/a&gt;, and &lt;a href="https://gist.github.com/simonw/857572d9b36cd1e791c730790ed489ef"&gt;the answers I scraped from Ask MetaFilter&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;type&lt;/code&gt; property can be set to &lt;code&gt;entry&lt;/code&gt;, &lt;code&gt;quotation&lt;/code&gt; or &lt;code&gt;blogmark&lt;/code&gt; and specifies which type of content should be imported. The &lt;code&gt;datetime&lt;/code&gt;, &lt;code&gt;slug&lt;/code&gt; and &lt;code&gt;tags&lt;/code&gt; fields are common across all three types - the other fields differ for each type.&lt;/p&gt;
&lt;p&gt;The most interesting field here is &lt;code&gt;import_ref&lt;/code&gt;. This is optional, but if provided forms a unique reference associated with that item of content. I then use that reference in a call Django’s &lt;a href="https://docs.djangoproject.com/en/1.11/ref/models/querysets/#update-or-create"&gt;&lt;code&gt;update_or_create()&lt;/code&gt;&lt;/a&gt; method. This means I can run the same import multiple times - the first run will create objects, while subsequent runs update objects in place.&lt;/p&gt;
&lt;p&gt;The end result is that I can incrementally improve the scrapers I am writing, re-importing the resulting JSON to update previously imported records in-place. In addition to hacking on my blog, I’ve been using this pattern for some API integrations at work recently and it’s worked out very well.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;import_ref&lt;/code&gt; is defined on my models as a unique, nullable text field:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    import_ref = models.TextField(max_length=64, null=True, unique=True)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Since the Django admin doesn’t handle nullable fields well by default, I &lt;a href="https://github.com/simonw/simonwillisonblog/blob/e737be8b4228229e833fe7a9ec698f3e262cd094/blog/admin.py#L19"&gt;added &lt;code&gt;import_ref&lt;/code&gt; to my &lt;code&gt;readonly_fields&lt;/code&gt; property&lt;/a&gt; in my admin configuration to avoid accidentally setting it to a blank string when editing through the admin interface.&lt;/p&gt;
&lt;p&gt;Here’s my completed &lt;a href="https://github.com/simonw/simonwillisonblog/blob/739a8cb49cfd49da5c643e41027af04d484e2aef/blog/management/commands/import_blog_json.py"&gt;&lt;code&gt;import_blog_json&lt;/code&gt; management command&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;My workflow for importing data is now pretty streamlined. I write the scrapers in &lt;a href="https://github.com/simonw/simonwillisonblog/tree/b7b59e504b5c2f5e04ad59e83a1f4fb6f76c58da/jupyter-notebooks"&gt;a Juyter notebook&lt;/a&gt; and use that to generate a list of importable items as Python dictionaries. I run &lt;code&gt;open('/tmp/items.json').write(json.dumps(items, indent=2))&lt;/code&gt; to dump the items to a JSON file. Then I can run &lt;code&gt;./manage.py import_blog_json /tmp/items.json&lt;/code&gt; to import them into my local development environment - thanks to the &lt;code&gt;import_ref&lt;/code&gt; I can do this as many times as I like until I’m pleased with the result.&lt;/p&gt;
&lt;p&gt;Once it’s ready, I run &lt;code&gt;!cat /tmp/blah.json | pbcopy&lt;/code&gt; in Jupyter to copy the JSON to my clipboard, then paste the JSON into a new &lt;a href="https://gist.github.com/"&gt;GitHub Gist&lt;/a&gt;. I then copy the URL to that raw JSON and execute it against my production instance.&lt;/p&gt;
&lt;p&gt;Heroku tip: running &lt;code&gt;heroku run bash&lt;/code&gt; will start a bash prompt in a dyno hooked up to your application. You can then run &lt;code&gt;./manage.py ...&lt;/code&gt; commands against your production environment.&lt;/p&gt;
&lt;p&gt;So… I just have to run &lt;code&gt;heroku run bash&lt;/code&gt; followed by  &lt;code&gt;./manage.py import_blog_json https://gist.github.com/path-to-json --tag_with=askmetafilter&lt;/code&gt; and the new content will be live on my site.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;tag_with&lt;/code&gt; option allows me to specify a tag to apply to all of that imported content, useful for checking that everything worked as expected.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/scraping"&gt;scraping&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/heroku"&gt;heroku&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/jupyter"&gt;jupyter&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="django"/><category term="django-admin"/><category term="scraping"/><category term="heroku"/><category term="jupyter"/></entry><entry><title>What's new in Django 1.2 alpha 1</title><link href="https://simonwillison.net/2010/Jan/7/django/#atom-tag" rel="alternate"/><published>2010-01-07T19:31:50+00:00</published><updated>2010-01-07T19:31:50+00:00</updated><id>https://simonwillison.net/2010/Jan/7/django/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://docs.djangoproject.com/en/dev/releases/1.2-alpha-1/#what-s-new-in-django-1-2-alpha-1"&gt;What&amp;#x27;s new in Django 1.2 alpha 1&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Multiple database support, improved CSRF prevention, a messages framework (similar to the Rails “flash” feature), model validation, custom e-mail backends, template caching for much faster handling of the include and extends tags, read only fields in the admin, a better if tag and more. Very exciting release.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="http://www.djangoproject.com/weblog/2010/jan/06/12-alpha-1/"&gt;Django Weblog&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/alpha"&gt;alpha&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/csrf"&gt;csrf&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/releases"&gt;releases&lt;/a&gt;&lt;/p&gt;



</summary><category term="alpha"/><category term="csrf"/><category term="django"/><category term="django-admin"/><category term="python"/><category term="releases"/></entry><entry><title>Django 1.1 release notes</title><link href="https://simonwillison.net/2009/Jul/29/django/#atom-tag" rel="alternate"/><published>2009-07-29T09:34:04+00:00</published><updated>2009-07-29T09:34:04+00:00</updated><id>https://simonwillison.net/2009/Jul/29/django/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://docs.djangoproject.com/en/dev/releases/1.1/"&gt;Django 1.1 release notes&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Django 1.1 is out! Congratulations everyone who worked on this, it’s a fantastic release. New features include aggregate support in the ORM, proxy models, deferred fields and some really nice admin improvements. Oh, and the testing framework is now up to 10 times thanks to smart use of transactions.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="http://www.djangoproject.com/weblog/2009/jul/29/1-point-1/"&gt;Django | Weblog | Django 1.1 released&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/aggregates"&gt;aggregates&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/open-source"&gt;open-source&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/orm"&gt;orm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/releases"&gt;releases&lt;/a&gt;&lt;/p&gt;



</summary><category term="aggregates"/><category term="django"/><category term="django-admin"/><category term="open-source"/><category term="orm"/><category term="python"/><category term="releases"/></entry><entry><title>django-batchadmin</title><link href="https://simonwillison.net/2008/Sep/15/djangobatchadmin/#atom-tag" rel="alternate"/><published>2008-09-15T10:46:50+00:00</published><updated>2008-09-15T10:46:50+00:00</updated><id>https://simonwillison.net/2008/Sep/15/djangobatchadmin/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://code.google.com/p/django-batchadmin/"&gt;django-batchadmin&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Seriously classy reusable Django app that adds batch editing (multiple delete by default, with hooks to add your own custom batch actions) to the Django admin changelist screen, using best practice techniques of sub-classing ModelAdmin and hence requiring no patches to Django core itself.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="http://blog.brianbeck.com/post/50177198/batchadmin"&gt;Brian Beck&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/brian-beck"&gt;brian-beck&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/djangobatchadmin"&gt;djangobatchadmin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/modeladmin"&gt;modeladmin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;&lt;/p&gt;



</summary><category term="brian-beck"/><category term="django"/><category term="django-admin"/><category term="djangobatchadmin"/><category term="modeladmin"/><category term="python"/></entry><entry><title>Django snippets: Orderable inlines using drag and drop with jQuery UI</title><link href="https://simonwillison.net/2008/Sep/13/django/#atom-tag" rel="alternate"/><published>2008-09-13T12:19:02+00:00</published><updated>2008-09-13T12:19:02+00:00</updated><id>https://simonwillison.net/2008/Sep/13/django/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://www.djangosnippets.org/snippets/1053/"&gt;Django snippets: Orderable inlines using drag and drop with jQuery UI&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Code example from my PyCon tutorial on customising the Django admin interface.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/dragndrop"&gt;dragndrop&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/jquery"&gt;jquery&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyconuk"&gt;pyconuk&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyconuk2008"&gt;pyconuk2008&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/snippets"&gt;snippets&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sortable"&gt;sortable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/speaking"&gt;speaking&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/my-talks"&gt;my-talks&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tutorials"&gt;tutorials&lt;/a&gt;&lt;/p&gt;



</summary><category term="django"/><category term="django-admin"/><category term="dragndrop"/><category term="jquery"/><category term="pyconuk"/><category term="pyconuk2008"/><category term="python"/><category term="snippets"/><category term="sortable"/><category term="speaking"/><category term="my-talks"/><category term="tutorials"/></entry><entry><title>Django: Security fix released</title><link href="https://simonwillison.net/2008/Sep/3/django/#atom-tag" rel="alternate"/><published>2008-09-03T00:14:00+00:00</published><updated>2008-09-03T00:14:00+00:00</updated><id>https://simonwillison.net/2008/Sep/3/django/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://www.djangoproject.com/weblog/2008/sep/02/security/"&gt;Django: Security fix released&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
The Django admin used to save partially-submitted forms if your session expired, and continue the submission when you logged in. It turns out that’s actually an unblockable CSRF exploit and is hence broken as designed, so it’s now been removed. Thanks Ed Eliot and other GCap colleagues for helping me flesh out the potential attack.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/csrf"&gt;csrf&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ed-eliot"&gt;ed-eliot&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/exploit"&gt;exploit&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/gcap"&gt;gcap&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/security"&gt;security&lt;/a&gt;&lt;/p&gt;



</summary><category term="csrf"/><category term="django"/><category term="django-admin"/><category term="ed-eliot"/><category term="exploit"/><category term="gcap"/><category term="security"/></entry><entry><title>Changeset 8266 - Added ModelAdmin.save_model() and ModelAdmin.save_formset() methods</title><link href="https://simonwillison.net/2008/Aug/10/changeset/#atom-tag" rel="alternate"/><published>2008-08-10T13:17:27+00:00</published><updated>2008-08-10T13:17:27+00:00</updated><id>https://simonwillison.net/2008/Aug/10/changeset/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://code.djangoproject.com/changeset/8266"&gt;Changeset 8266 - Added ModelAdmin.save_model() and ModelAdmin.save_formset() methods&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
One of those small changes that opens up enormous possibilities—it’s now incredibly easy to customise exactly how a model is saved in the Django admin interface by over-riding the save_model method.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/admin"&gt;admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;&lt;/p&gt;



</summary><category term="admin"/><category term="django"/><category term="django-admin"/><category term="python"/></entry><entry><title>Django 1.0 alpha release notes</title><link href="https://simonwillison.net/2008/Jul/22/alpha/#atom-tag" rel="alternate"/><published>2008-07-22T06:04:29+00:00</published><updated>2008-07-22T06:04:29+00:00</updated><id>https://simonwillison.net/2008/Jul/22/alpha/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://www.djangoproject.com/documentation/release_notes_1.0_alpha/"&gt;Django 1.0 alpha release notes&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
The big features are newforms-admin, unicode everywhere, the queryset-refactor ORM improvements and auto-escaping in templates.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/alpha"&gt;alpha&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/autoescaping"&gt;autoescaping&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/newformsadmin"&gt;newformsadmin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/orm"&gt;orm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/querysetrefactor"&gt;querysetrefactor&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/unicode"&gt;unicode&lt;/a&gt;&lt;/p&gt;



</summary><category term="alpha"/><category term="autoescaping"/><category term="django"/><category term="django-admin"/><category term="newformsadmin"/><category term="orm"/><category term="python"/><category term="querysetrefactor"/><category term="unicode"/></entry><entry><title>newforms-admin branch has been merged into trunk</title><link href="https://simonwillison.net/2008/Jul/20/merge/#atom-tag" rel="alternate"/><published>2008-07-20T23:17:11+00:00</published><updated>2008-07-20T23:17:11+00:00</updated><id>https://simonwillison.net/2008/Jul/20/merge/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://groups.google.com/group/django-users/browse_thread/thread/53ace41d27dfa7d9"&gt;newforms-admin branch has been merged into trunk&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Congrats to Brian Rosner for the merge. django.newforms has been renamed to django.forms as well—1.0 grows ever closer.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/brian-rosner"&gt;brian-rosner&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/forms"&gt;forms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/merge"&gt;merge&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/newformsadmin"&gt;newformsadmin&lt;/a&gt;&lt;/p&gt;



</summary><category term="brian-rosner"/><category term="django"/><category term="django-admin"/><category term="forms"/><category term="merge"/><category term="newformsadmin"/></entry><entry><title>Django: security fix released</title><link href="https://simonwillison.net/2008/May/14/django/#atom-tag" rel="alternate"/><published>2008-05-14T07:49:22+00:00</published><updated>2008-05-14T07:49:22+00:00</updated><id>https://simonwillison.net/2008/May/14/django/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://www.djangoproject.com/weblog/2008/may/14/security/"&gt;Django: security fix released&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
XSS hole in the Admin application’s login page—updates and patches are available for trunk, 0.96, 0.95 and 0.91.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/security"&gt;security&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/xss"&gt;xss&lt;/a&gt;&lt;/p&gt;



</summary><category term="django"/><category term="django-admin"/><category term="security"/><category term="xss"/></entry><entry><title>Django admin OmniGraffle stencil</title><link href="https://simonwillison.net/2008/May/13/graffle/#atom-tag" rel="alternate"/><published>2008-05-13T17:58:04+00:00</published><updated>2008-05-13T17:58:04+00:00</updated><id>https://simonwillison.net/2008/May/13/graffle/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://csensedesign.co.uk/blog/?p=132"&gt;Django admin OmniGraffle stencil&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Alex Lee put together a beautiful stencil for OmniGraffle containing all of the common UI elements seen in the Django admin interface, as a tool for wireframing.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/alex-lee"&gt;alex-lee&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/omnigraffle"&gt;omnigraffle&lt;/a&gt;&lt;/p&gt;



</summary><category term="alex-lee"/><category term="django"/><category term="django-admin"/><category term="omnigraffle"/></entry><entry><title>Filtering foreign key choices in newforms-admin</title><link href="https://simonwillison.net/2008/Jan/6/technobabble/#atom-tag" rel="alternate"/><published>2008-01-06T20:31:52+00:00</published><updated>2008-01-06T20:31:52+00:00</updated><id>https://simonwillison.net/2008/Jan/6/technobabble/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://www.technobabble.dk/2008/jan/06/filtering-foreign-key-choices-newforms-admin/"&gt;Filtering foreign key choices in newforms-admin&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
A nice introduction to the Django newform-admin branch, including an example of how to easily implement row-level permissions.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/christian-joergensen"&gt;christian-joergensen&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/newforms"&gt;newforms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/newformsadmin"&gt;newformsadmin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;&lt;/p&gt;



</summary><category term="christian-joergensen"/><category term="django"/><category term="django-admin"/><category term="newforms"/><category term="newformsadmin"/><category term="python"/></entry><entry><title>This Week in Django podcast</title><link href="https://simonwillison.net/2008/Jan/1/django/#atom-tag" rel="alternate"/><published>2008-01-01T10:44:01+00:00</published><updated>2008-01-01T10:44:01+00:00</updated><id>https://simonwillison.net/2008/Jan/1/django/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://blog.michaeltrier.com/tags/twid"&gt;This Week in Django podcast&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Michael Trier’s been doing a really fantastic job putting together a Django podcast. The most recent episode (number 4) includes an update on the newforms-admin branch and a couple of handy tips.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/michael-trier"&gt;michael-trier&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/podcasts"&gt;podcasts&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/thisweekindjango"&gt;thisweekindjango&lt;/a&gt;&lt;/p&gt;



</summary><category term="django"/><category term="django-admin"/><category term="michael-trier"/><category term="podcasts"/><category term="python"/><category term="thisweekindjango"/></entry><entry><title>Django on the iPhone</title><link href="https://simonwillison.net/2007/Aug/19/worked/#atom-tag" rel="alternate"/><published>2007-08-19T07:58:04+00:00</published><updated>2007-08-19T07:58:04+00:00</updated><id>https://simonwillison.net/2007/Aug/19/worked/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://www.flickr.com/photos/jacobian/1160698795/"&gt;Django on the iPhone&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Jacob got it working. The next image in his photostream shows the Django admin application querying his phone’s local database of calls.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/apple"&gt;apple&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/iphone"&gt;iphone&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/jacob-kaplan-moss"&gt;jacob-kaplan-moss&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;&lt;/p&gt;



</summary><category term="apple"/><category term="django"/><category term="django-admin"/><category term="iphone"/><category term="jacob-kaplan-moss"/><category term="python"/></entry></feed>