<?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-sql-dashboard</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/tags/django-sql-dashboard.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2025-04-28T01:22:27+00:00</updated><author><name>Simon Willison</name></author><entry><title>New dashboard: alt text for all my images</title><link href="https://simonwillison.net/2025/Apr/28/dashboard-alt-text/#atom-tag" rel="alternate"/><published>2025-04-28T01:22:27+00:00</published><updated>2025-04-28T01:22:27+00:00</updated><id>https://simonwillison.net/2025/Apr/28/dashboard-alt-text/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://simonwillison.net/dashboard/alt-text/"&gt;New dashboard: alt text for all my images&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I got curious today about how I'd been using alt text for images on my blog, and realized that since I have &lt;a href="https://django-sql-dashboard.datasette.io/"&gt;Django SQL Dashboard&lt;/a&gt; running on this site and PostgreSQL is capable of &lt;a href="https://stackoverflow.com/questions/1732348/regex-match-open-tags-except-xhtml-self-contained-tags/1732454#1732454"&gt;parsing HTML with regular expressions&lt;/a&gt; I could probably find out using a SQL query.&lt;/p&gt;
&lt;p&gt;I pasted &lt;a href="https://simonwillison.net/dashboard/schema/"&gt;my PostgreSQL schema&lt;/a&gt; into Claude and gave it a pretty long prompt:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Give this PostgreSQL schema I want a query that returns all of my images and their alt text. Images are sometimes stored as HTML image tags and other times stored in markdown.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;blog_quotation.quotation&lt;/code&gt;, &lt;code&gt;blog_note.body&lt;/code&gt; both contain markdown. &lt;code&gt;blog_blogmark.commentary&lt;/code&gt; has markdown if &lt;code&gt;use_markdown&lt;/code&gt; is true or HTML otherwise. &lt;code&gt;blog_entry.body&lt;/code&gt; is always HTML&lt;/p&gt;
&lt;p&gt;Write me a SQL query to extract all of my images and their alt tags using regular expressions. In HTML documents it should look for either &lt;code&gt;&amp;lt;img .* src="..." .* alt="..."&lt;/code&gt; or &lt;code&gt;&amp;lt;img alt="..." .* src="..."&lt;/code&gt; (images may be self-closing XHTML style in some places). In Markdown they will always be &lt;code&gt;![alt text](url)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;I want the resulting table to have three columns: URL, alt_text, src - the URL column needs to be constructed as e.g. &lt;code&gt;/2025/Feb/2/slug&lt;/code&gt; for a record where created is on 2nd feb 2025 and the &lt;code&gt;slug&lt;/code&gt; column contains &lt;code&gt;slug&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Use CTEs and unions where appropriate&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It almost got it right on the first go, and with &lt;a href="https://claude.ai/share/e3b996d3-b480-436d-aa40-9caa7609474f"&gt;a couple of follow-up prompts&lt;/a&gt; I had the query I wanted. I also added the option to &lt;a href="https://simonwillison.net/dashboard/alt-text/?search=pelican"&gt;search&lt;/a&gt; my alt text / image URLs, which has already helped me hunt down and fix a few old images on expired domain names. Here's a copy of &lt;a href="https://gist.github.com/simonw/5b44a662354e124e33cc1d4704cdb91a"&gt;the finished 100 line SQL query&lt;/a&gt;.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/accessibility"&gt;accessibility&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/alt-text"&gt;alt-text&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/postgresql"&gt;postgresql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sql"&gt;sql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-sql-dashboard"&gt;django-sql-dashboard&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/claude"&gt;claude&lt;/a&gt;&lt;/p&gt;



</summary><category term="accessibility"/><category term="alt-text"/><category term="postgresql"/><category term="sql"/><category term="ai"/><category term="django-sql-dashboard"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="claude"/></entry><entry><title>Dashboard: Tools</title><link href="https://simonwillison.net/2024/Oct/21/dashboard-tools/#atom-tag" rel="alternate"/><published>2024-10-21T03:33:41+00:00</published><updated>2024-10-21T03:33:41+00:00</updated><id>https://simonwillison.net/2024/Oct/21/dashboard-tools/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://simonwillison.net/dashboard/tools/"&gt;Dashboard: Tools&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I used &lt;a href="https://django-sql-dashboard.datasette.io/"&gt;Django SQL Dashboard&lt;/a&gt; to spin up a dashboard that shows all of the URLs to my &lt;a href="https://tools.simonwillison.net/"&gt;tools.simonwillison.net&lt;/a&gt; site that I've shared on my blog so far. It uses this (Claude assisted) regular expression in a PostgreSQL SQL query:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select distinct&lt;/span&gt; &lt;span class="pl-k"&gt;on&lt;/span&gt; (tool_url)
    unnest(regexp_matches(
        body,
        &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;(https://tools&lt;span class="pl-cce"&gt;\.&lt;/span&gt;simonwillison&lt;span class="pl-cce"&gt;\.&lt;/span&gt;net/[^&amp;lt;"&lt;span class="pl-cce"&gt;\s&lt;/span&gt;)]+)&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;g&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
    )) &lt;span class="pl-k"&gt;as&lt;/span&gt; tool_url,
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;https://simonwillison.net/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; left(type, &lt;span class="pl-c1"&gt;1&lt;/span&gt;) &lt;span class="pl-k"&gt;||&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;span class="pl-k"&gt;||&lt;/span&gt; id &lt;span class="pl-k"&gt;as&lt;/span&gt; blog_url,
    title,
    &lt;span class="pl-k"&gt;date&lt;/span&gt;(created) &lt;span class="pl-k"&gt;as&lt;/span&gt; created
&lt;span class="pl-k"&gt;from&lt;/span&gt; content&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;I've been really enjoying having a static hosting platform (it's GitHub Pages serving my &lt;a href="https://github.com/simonw/tools"&gt;simonw/tools&lt;/a&gt; repo) that I can use to quickly deploy little HTML+JavaScript interactive tools and demos.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&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/sql"&gt;sql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tools"&gt;tools&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-sql-dashboard"&gt;django-sql-dashboard&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;&lt;/p&gt;



</summary><category term="javascript"/><category term="postgresql"/><category term="projects"/><category term="sql"/><category term="tools"/><category term="django-sql-dashboard"/><category term="ai-assisted-programming"/></entry><entry><title>Blogmarks that use markdown</title><link href="https://simonwillison.net/2024/Apr/25/blogmarks-that-use-markdown/#atom-tag" rel="alternate"/><published>2024-04-25T04:34:18+00:00</published><updated>2024-04-25T04:34:18+00:00</updated><id>https://simonwillison.net/2024/Apr/25/blogmarks-that-use-markdown/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://simonwillison.net/dashboard/blogmarks-that-use-markdown/"&gt;Blogmarks that use markdown&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I needed to attach a correction to an older blogmark (my 20-year old name for short-form links with commentary on my blog) today - but the commentary field has always been text, not HTML, so I didn't have a way to add the necessary link.&lt;/p&gt;
&lt;p&gt;This motivated me to finally add optional &lt;strong&gt;Markdown&lt;/strong&gt; support for blogmarks to my blog's custom Django CMS. I then went through and added inline code markup to a bunch of different older posts, and built this Django SQL Dashboard to keep track of which posts I had updated.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/blogging"&gt;blogging&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/markdown"&gt;markdown&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-sql-dashboard"&gt;django-sql-dashboard&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/site-upgrades"&gt;site-upgrades&lt;/a&gt;&lt;/p&gt;



</summary><category term="blogging"/><category term="projects"/><category term="markdown"/><category term="django-sql-dashboard"/><category term="site-upgrades"/></entry><entry><title>My blog's year archive pages now have tag clouds</title><link href="https://simonwillison.net/2024/Jan/4/year-tag-clouds/#atom-tag" rel="alternate"/><published>2024-01-04T21:02:51+00:00</published><updated>2024-01-04T21:02:51+00:00</updated><id>https://simonwillison.net/2024/Jan/4/year-tag-clouds/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://simonwillison.net/2007/"&gt;My blog&amp;#x27;s year archive pages now have tag clouds&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Inspired by the tag cloud I used in my recent 2023 AI roundup post, I decided to add a tag cloud to the top of every one of my archive-by-year pages showing what topics I had spent the most time with that year.&lt;/p&gt;

&lt;p&gt;I already had old code for this, so I pasted it into GPT-4 along with an example of the output of my JSON endpoint from Django SQL Dashboard and had it do most of the work for me.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://github.com/simonw/simonwillisonblog/issues/391"&gt;simonwillisonblog/issues/391&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-sql-dashboard"&gt;django-sql-dashboard&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/chatgpt"&gt;chatgpt&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;&lt;/p&gt;



</summary><category term="projects"/><category term="ai"/><category term="django-sql-dashboard"/><category term="chatgpt"/><category term="llms"/></entry><entry><title>Everything new in Datasette since January, plus Django SQL Dashboard</title><link href="https://simonwillison.net/2021/Aug/10/everything-new-in-datasette/#atom-tag" rel="alternate"/><published>2021-08-10T01:28:43+00:00</published><updated>2021-08-10T01:28:43+00:00</updated><id>https://simonwillison.net/2021/Aug/10/everything-new-in-datasette/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://datasette.substack.com/p/everything-new-in-datasette-since"&gt;Everything new in Datasette since January, plus Django SQL Dashboard&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I sent out the first Datasette newsletter since late January this year, covering everything that’s new in Datasette and sqlite-utils this year and introducing Django SQL Dashboard.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite-utils"&gt;sqlite-utils&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="datasette"/><category term="sqlite-utils"/><category term="django-sql-dashboard"/></entry><entry><title>Django SQL Dashboard 1.0</title><link href="https://simonwillison.net/2021/Jul/6/django-sql-dashboard/#atom-tag" rel="alternate"/><published>2021-07-06T02:19:57+00:00</published><updated>2021-07-06T02:19:57+00:00</updated><id>https://simonwillison.net/2021/Jul/6/django-sql-dashboard/#atom-tag</id><summary type="html">
    &lt;p&gt;Earlier this week I released Django SQL Dashboard 1.0. I also just released 1.0.1, with a bug fix for PostgreSQL 10 contributed by Ryan Cheley.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://django-sql-dashboard.datasette.io/"&gt;Django SQL Dashboard&lt;/a&gt; is my tool for adding a read-only, bookmarkable SQL querying interface to any Django plus PostgreSQL project. I &lt;a href="https://simonwillison.net/2021/May/10/django-sql-dashboard/"&gt;introduced it with a demo video&lt;/a&gt; back in May.&lt;/p&gt;
&lt;h4&gt;Django SQL Dashboard column menu&lt;/h4&gt;
&lt;p&gt;The big new feature &lt;a href=""&gt;in 1.0&lt;/a&gt; (aside from the confident version number) is a new contextual menu against each column from the results. This is best demonstrated by an animated demo:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Animated demo showing how the cog menu lets you change sort orders and count distinct values" src="https://static.simonwillison.net/static/2021/cog-demo.gif" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;The new cog action menu is &lt;a href="https://simonwillison.net/2020/Oct/2/datasette-column-actions/"&gt;inspired by Datasette&lt;/a&gt; (and reuses the same icon). It works a little differently though: since Django SQL Dashboard only ever deals with SQL queries (unlike Datasette which often shows results directly from a filtered table) the options in the cog menu work by rewriting the original SQL.&lt;/p&gt;
&lt;p&gt;This turns out to be pretty easy, because PostgreSQL (and every other modern SQL implementation) supports nested queries. So if your original SQL query is:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; entries
&lt;span class="pl-k"&gt;where&lt;/span&gt; category &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;python&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;You can sort by a column like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; (
  &lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; entries
  &lt;span class="pl-k"&gt;where&lt;/span&gt; category &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;python&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
) &lt;span class="pl-k"&gt;as&lt;/span&gt; results
&lt;span class="pl-k"&gt;order by&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;title&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And you can count unique values like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;title&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;, &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; (
  &lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; entries
  &lt;span class="pl-k"&gt;where&lt;/span&gt; category &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;python&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
) &lt;span class="pl-k"&gt;as&lt;/span&gt; results
&lt;span class="pl-k"&gt;group by&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;title&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &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;My initial implementation of this feature did &lt;a href="https://github.com/simonw/django-sql-dashboard/issues/135"&gt;have one flaw&lt;/a&gt;: if you sorted by a column, then sorted by another column, it would nest the queries multiple times - so you could end up with something like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; (
  &lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; (
    &lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; blog_blogmark
  ) &lt;span class="pl-k"&gt;as&lt;/span&gt; results &lt;span class="pl-k"&gt;order by&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;link_title&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
) &lt;span class="pl-k"&gt;as&lt;/span&gt; results &lt;span class="pl-k"&gt;order by&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;link_url&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;desc&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I'm confident the query optimizer reduces this to the minimum amount of work, but it doesn't look very pretty. I realized that since I generate most of the SQL I could catch this using a pretty dumb regular expression:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;_sort_re&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;re&lt;/span&gt;.&lt;span class="pl-en"&gt;compile&lt;/span&gt;(
    &lt;span class="pl-s"&gt;'(^.*) order by "[^"]+"( desc)?$'&lt;/span&gt;, &lt;span class="pl-s1"&gt;re&lt;/span&gt;.&lt;span class="pl-v"&gt;DOTALL&lt;/span&gt;
)&lt;/pre&gt;
&lt;p&gt;If that regular expression matches, I can extract the first group and append a new &lt;code&gt;order by&lt;/code&gt; to it. If it doesn't match I can fall back to wrapping the entire query.&lt;/p&gt;
&lt;h4&gt;Testing against multiple PostgreSQL versions&lt;/h4&gt;
&lt;p&gt;The &lt;a href="https://github.com/simonw/django-sql-dashboard/releases/tag/1.0.1"&gt;1.0.1 release&lt;/a&gt; fixed a bug that only showed up in PostgreSQL 10, spotted (and fixed) by Ryan Cheley.&lt;/p&gt;
&lt;p&gt;Django SQL Dashboard runs its tests against a temporary PostgreSQL server, which is spun up using the &lt;a href="https://pypi.org/project/testing.postgresql/"&gt;testing.postgresql&lt;/a&gt; library.&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://github.com/actions/virtual-environments/blob/main/images/linux/Ubuntu2004-README.md"&gt;default GitHub Actions runner&lt;/a&gt; ships with a PostgreSQL 13 server binary just waiting for you to run it. But how can I also run the tests against previous versions?&lt;/p&gt;
&lt;p&gt;I ended up following &lt;a href="https://www.postgresql.org/download/linux/ubuntu/"&gt;these instructions&lt;/a&gt; provided by PostgreSQL on installing different versions on Ubuntu. I combined these into &lt;a href="https://github.com/simonw/django-sql-dashboard/blob/1.0.1/.github/workflows/test.yml"&gt;a GitHub Actions matrix build&lt;/a&gt;, so now every commit to Django SQL Dashboard is tested sixteen times(!), against every combination of Python 3.6, 3.7, 3.8 and 3.9 and PostgreSQL 10, 11, 12 and 13. I wrote up the pattern I used in &lt;a href="https://til.simonwillison.net/github-actions/different-postgresql-versions"&gt;this TIL&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/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/sql"&gt;sql&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/github-actions"&gt;github-actions&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="django"/><category term="postgresql"/><category term="projects"/><category term="sql"/><category term="datasette"/><category term="weeknotes"/><category term="github-actions"/><category term="django-sql-dashboard"/></entry><entry><title>PAGNIs: Probably Are Gonna Need Its</title><link href="https://simonwillison.net/2021/Jul/1/pagnis/#atom-tag" rel="alternate"/><published>2021-07-01T19:13:58+00:00</published><updated>2021-07-01T19:13:58+00:00</updated><id>https://simonwillison.net/2021/Jul/1/pagnis/#atom-tag</id><summary type="html">
    &lt;p&gt;Luke Page has a great post up with &lt;a href="https://lukeplant.me.uk/blog/posts/yagni-exceptions/"&gt;his list of YAGNI exceptions&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;YAGNI - You Ain't Gonna Need It - is a rule that says you shouldn't add a feature just because it might be useful in the future - only write code when it solves a direct problem.&lt;/p&gt;
&lt;p&gt;When should you over-ride YAGNI? When the cost of adding something later is so dramatically expensive compared with the cost of adding it early on that it's worth taking the risk. On when you know from experience that an initial investment will pay off many times over.&lt;/p&gt;
&lt;p&gt;Lukes's exceptions to YAGNI are well chosen: things like logging, API versioning, created_at timestamps and a bias towards "store multiple X for a user" (a many-to-many relationship) if there's any inkling that the system may need to support more than one.&lt;/p&gt;
&lt;p&gt;Because I like attempting to coin phrases, I propose we call these &lt;strong&gt;PAGNIs&lt;/strong&gt; - short for &lt;strong&gt;Probably Are Gonna Need Its&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Here are some of mine.&lt;/p&gt;
&lt;h4&gt;A kill-switch for your mobile apps&lt;/h4&gt;
&lt;p&gt;If you're building a mobile app that talks to your API, make sure to ship a kill-switch: a mechanism by which you can cause older versions of the application to show a "you must upgrade to continue using this application" screen when the app starts up.&lt;/p&gt;
&lt;p&gt;In an ideal world, you'll never use this ability: you'll continue to build new features to the app and make backwards-compatible changes to the API forever, such that ancient app versions keep working and new app versions get to do new things.&lt;/p&gt;
&lt;p&gt;But... sometimes that simply isn't possible. You might discover a security hole in the design of the application or API that can only be fixed by breaking backwards-compatibility - or maybe you're still maintaining a v1 API from five years ago to support a mobile application version that's only still installed by 30 users, and you'd like to not have to maintain double the amount of API code.&lt;/p&gt;
&lt;p&gt;You can't add a kill-switch retroactively to apps that have already been deployed!&lt;/p&gt;
&lt;p&gt;&lt;a href="https://twitter.com/myunderpants/status/1410655652867809281"&gt;Apparently Firebase offers this&lt;/a&gt; to many Android apps, but if you're writing for iOS you need to provide this yourself.&lt;/p&gt;
&lt;h4&gt;Automated deploys&lt;/h4&gt;
&lt;p&gt;Nothing kills a side project like coming back to it in six months time and having to figure out how to deploy it again. Thanks to &lt;a href="https://simonwillison.net/tags/githubactions/"&gt;GitHub Actions&lt;/a&gt; and hosting providers like Google Cloud Run, Vercel, Heroku and Netlify setting up automated deployments is way easier now than it used to be. I have enough examples now that getting automated deployments working for a new project usually only takes a few minutes, and it pays off instantly.&lt;/p&gt;
&lt;h4&gt;Continuous Integration (and a test framework)&lt;/h4&gt;
&lt;p&gt;Similar to automated deployment in that GitHub Actions (and Circle CI and Travis before it) make this much less painful to setup than it used to be.&lt;/p&gt;
&lt;p&gt;Introducing a test framework to an existing project can be extremely painful. Introducing it at the very start is easy - and it sets a precedent that code should be tested from day one.&lt;/p&gt;
&lt;p&gt;These days I'm all about &lt;a href="https://simonwillison.net/tags/pytest/"&gt;pytest&lt;/a&gt;, and I have various cookiecutter templates (&lt;a href="https://github.com/simonw/datasette-plugin"&gt;datasette-plugin&lt;/a&gt;, &lt;a href="https://github.com/simonw/click-app"&gt;click-app&lt;/a&gt;, &lt;a href="https://github.com/simonw/python-lib"&gt;python-lib&lt;/a&gt;) that configure it on my new projects (with a passing test) out of the box.&lt;/p&gt;
&lt;p&gt;(Honestly, at this point in my career I consider continuous integration a DAGNI - Definitely Are Gonna Need It.)&lt;/p&gt;
&lt;p&gt;One particularly worthwhile trick is making sure the tests can spin up their own isolated test databases - another thing which is pretty easy to setup early (Django does this for you) and harder to add later on. I extend that to other external data stores - I once put a significant amount of effort into setting up a mechanism for running tests against Elasticsearch and clearing out the data again afterwards, and it paid off multiple times over.&lt;/p&gt;
&lt;p&gt;Even better: &lt;strong&gt;continuous deployment&lt;/strong&gt;! When the tests pass, deploy. If you have automated deployment setup already adding this is pretty easy, and doing it from the very start of a project sets a strong cultural expectation that no-one will land code to the &lt;code&gt;main&lt;/code&gt; branch until it's in a production-ready state and covered by unit tests.&lt;/p&gt;
&lt;p&gt;(If continuous deployment to production is too scary for your project, a valuable middle-ground is continuous deployment to a staging environment. Having everyone on your team able to interact with a live demo of your current main branch is a huge group productivity boost.)&lt;/p&gt;
&lt;h4&gt;API pagination&lt;/h4&gt;
&lt;p&gt;Never build an API endpoint that isn't paginated. Any time you think "there will never be enough items in this list for it to be worth pagination" one of your users will prove you wrong.&lt;/p&gt;
&lt;p&gt;This can be as simple as shipping an API which, even though it only returns a single page, has hard-coded JSON that looks like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;{
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;results&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;id&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-c1"&gt;1&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-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;One&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;id&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-c1"&gt;2&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-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Two&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;id&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-c1"&gt;3&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-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Three&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;next_url&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-c1"&gt;null&lt;/span&gt;
}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;But make sure you leave space for the pagination information! You'll regret it if you don't.&lt;/p&gt;
&lt;h4&gt;Detailed API logs&lt;/h4&gt;
&lt;p&gt;This is a trick I learned &lt;a href="https://simonwillison.net/2021/Apr/12/porting-vaccinateca-to-django/#value-of-api-logs"&gt;while porting VaccinateCA to Django&lt;/a&gt;. If you are building an API, having a mechanism that provides detailed logs - including the POST bodies passed to the API - is invaluable.&lt;/p&gt;
&lt;p&gt;It's an inexpensive way of maintaining a complete record of what happened with your application - invaluable for debugging, but also for tricks like replaying past API traffic against a new implementation under test.&lt;/p&gt;
&lt;p&gt;Logs like these may become infeasible at scale, but for a new project they'll probably add up to just a few MBs a day - and they're easy to prune or switch off later on if you need to.&lt;/p&gt;
&lt;p&gt;VIAL uses &lt;a href="https://github.com/CAVaccineInventory/vial/blob/a0780e27c39018b66f95278ce18eda5968c325f8/vaccinate/api/utils.py#L86"&gt;a Django view decorator&lt;/a&gt; to log these directly to a PostgreSQL table. We've been running this for a few months and it's now our largest table, but it's still only around 2GB - easily worth it for the productivity boost it gives us.&lt;/p&gt;
&lt;p&gt;(Don't log any sensitive data that you wouldn't want your development team having access to while debugging a problem. This may require clever redaction, or you can avoid logging specific endpoints entirely. Also: don't log authentication tokens that could be used to imitate users: decode them and log the user identifier instead.)&lt;/p&gt;
&lt;h4&gt;A bookmarkable interface for executing read-only SQL queries against your database&lt;/h4&gt;
&lt;p&gt;This one is very much exposing my biases (I just released &lt;a href="https://django-sql-dashboard.datasette.io/"&gt;Django SQL Dashboard 1.0&lt;/a&gt; which provides exactly this for Django+PosgreSQL projects) but having used this for the past few months I can't see myself going back. Using bookmarked SQL queries to inform the implementation of new features is an incredible productivity boost. Here's &lt;a href="https://github.com/CAVaccineInventory/vial/issues/528"&gt;an issue I worked on&lt;/a&gt; recently with 18 comments linking to illustrative SQL queries.&lt;/p&gt;
&lt;p&gt;(On further thought: this isn't actually a great example of a PAGNI because it's not particularly hard to add this to a project at a later date.)&lt;/p&gt;
&lt;h4&gt;Driving down the cost&lt;/h4&gt;
&lt;p&gt;One trick with all of these things is that while they may seem quite expensive to implement, they get dramatically cheaper as you gain experience and gather more tools for helping put them into practice.&lt;/p&gt;
&lt;p&gt;Any of the ideas I've shown here could take an engineering team weeks (if not months) to add to an existing project - but with the right tooling they can represent just an hour (or less) work at the start of a project. And they'll pay themselves off many, many times over in the future.&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/definitions"&gt;definitions&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/software-engineering"&gt;software-engineering&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;a href="https://simonwillison.net/tags/github-actions"&gt;github-actions&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-sql-dashboard"&gt;django-sql-dashboard&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/yagni"&gt;yagni&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pagni"&gt;pagni&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="continuous-deployment"/><category term="continuous-integration"/><category term="definitions"/><category term="software-engineering"/><category term="testing"/><category term="pytest"/><category term="github-actions"/><category term="django-sql-dashboard"/><category term="yagni"/><category term="pagni"/></entry><entry><title>Django SQL Dashboard 1.0</title><link href="https://simonwillison.net/2021/Jul/1/django-sql-dashboard-10/#atom-tag" rel="alternate"/><published>2021-07-01T17:44:38+00:00</published><updated>2021-07-01T17:44:38+00:00</updated><id>https://simonwillison.net/2021/Jul/1/django-sql-dashboard-10/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://django-sql-dashboard.datasette.io/"&gt;Django SQL Dashboard 1.0&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
As part of my ongoing attempt to be braver about 1.0 releases (crucial if you want to do semantic versioning properly) I’ve released version 1.0 of Django SQL Dashboard, my Datasette-inspired app for Django that adds an interface for running read-only, bookmarkable SQL queries against a PostgreSQL database. The new version adds a column cog menu providing shortcuts for changing the sort order, counting distinct values and performing a group-by/count against column values.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/simonw/status/1410652580095483908"&gt;@simonw&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/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sql"&gt;sql&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="django"/><category term="projects"/><category term="sql"/><category term="django-sql-dashboard"/></entry><entry><title>Weeknotes: Velma, more Django SQL Dashboard</title><link href="https://simonwillison.net/2021/May/18/weeknotes-velma/#atom-tag" rel="alternate"/><published>2021-05-18T00:36:20+00:00</published><updated>2021-05-18T00:36:20+00:00</updated><id>https://simonwillison.net/2021/May/18/weeknotes-velma/#atom-tag</id><summary type="html">
    &lt;p&gt;Matching locations for Vaccinate The States, fun with GeoJSON and more improvements to Django SQL Dashboard.&lt;/p&gt;
&lt;h4&gt;Velma&lt;/h4&gt;
&lt;p&gt;I described &lt;a href="https://simonwillison.net/2021/Apr/26/vaccinate-the-states/"&gt;a few weeks ago&lt;/a&gt; part of the process we've been using to build &lt;a href="https://www.vaccinatethestates.com/"&gt;Vaccinate The States&lt;/a&gt; - a map of every COVID vaccine location in the USA (now at just over 70,000 markers and counting).&lt;/p&gt;
&lt;p&gt;Short version: we have scrapers and data ingesters for a whole bunch of different sources (see the &lt;a href="https://github.com/CAVaccineInventory/vaccine-feed-ingest"&gt;vaccine-feed-ingest&lt;/a&gt; repository).&lt;/p&gt;
&lt;p&gt;Part of the challenge here is how to deal with duplicates - with multiple sources of data, chances are high that the same location will show up in more than on of our input feeds.&lt;/p&gt;
&lt;p&gt;So in the past weeks we've been building a new tool code-named Velma to help handle this. It shows our volunteers a freshly scraped location and asks them to either match it to one of our existing locations (based on automated suggestions) or use it to create a brand new location in our database.&lt;/p&gt;

&lt;p&gt;&lt;img style="max-width: 100%" src="https://static.simonwillison.net/static/2021/velma.png" alt="An interface showing a location and a potential match" /&gt;&lt;/p&gt;

&lt;p&gt;I've been working exclusively on the backend APIs for Velma: APIs that return new scraped data and accept and process the human matching decisions from our volunteers.&lt;/p&gt;
&lt;p&gt;This week we've been expanding Velma to also cover merging potential duplicate locations within our existing corpus, so I've been building out the APIs for that effort as well.&lt;/p&gt;
&lt;p&gt;I've also been working on new export code for making our entire set of locations available to partners and interested outside developers. We hope to launch that fully in the next few days.&lt;/p&gt;
&lt;h4&gt;geojson-to-sqlite&lt;/h4&gt;
&lt;p&gt;One of the export formats we are working with is GeoJSON. I have a tool called &lt;a href="https://datasette.io/tools/geojson-to-sqlite"&gt;geojson-to-sqlite&lt;/a&gt; which I released last year: this week I released an &lt;a href="https://github.com/simonw/geojson-to-sqlite/releases/tag/0.3"&gt;updated version&lt;/a&gt; with the ability to create SpatiaLite indexes and a &lt;code&gt;--nl&lt;/code&gt; option for consuming newline-delimited GeoJSON, &lt;a href="https://github.com/simonw/geojson-to-sqlite/pull/13"&gt;contributed by Chris Amico&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I've also been experimenting with SpatiaLite's KNN mechanism using &lt;code&gt;geojson-to-sqlite&lt;/code&gt; to load in data - here's &lt;a href="https://til.simonwillison.net/spatialite/knn"&gt;a TIL&lt;/a&gt; showing how to use those tools together.&lt;/p&gt;
&lt;h4&gt;Django SQL Dashboard&lt;/h4&gt;
&lt;p&gt;I &lt;a href="https://simonwillison.net/2021/May/10/django-sql-dashboard/"&gt;released the first non-alpha version&lt;/a&gt; of this last week and it's started to gain some traction: I've heard from a few people who are trying it out on their projects and it seems to work, so that's good!&lt;/p&gt;
&lt;p&gt;I released &lt;a href="https://github.com/simonw/django-sql-dashboard/releases/tag/0.14"&gt;version 0.14&lt;/a&gt; yesterday with a bunch of fixes based on feedback from users, plus a security fix that closes a hole where users without the &lt;code&gt;execute_sql&lt;/code&gt; permission but with access to the Django Admin could modify the SQL in saved dashboards and hence execute their own custom queries.&lt;/p&gt;
&lt;p&gt;I also made a bunch of improvements to the documentation, including adding screenshots and demo links &lt;a href="https://django-sql-dashboard.datasette.io/en/latest/widgets.html"&gt;to the widgets page&lt;/a&gt;.&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/wikipedia/page-stats-api"&gt;The Wikipedia page stats API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/vega/bar-chart-ordering"&gt;Vega-Lite bar charts in the same order as the data&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/django/enabling-gin-index"&gt;Enabling a gin index for faster LIKE queries&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/spatialite/knn"&gt;KNN queries with SpatiaLite&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/django/migration-using-cte"&gt;Django data migration using a PostgreSQL CTE&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/geojson-to-sqlite"&gt;geojson-to-sqlite&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/geojson-to-sqlite/releases/tag/0.3"&gt;0.3&lt;/a&gt; - (&lt;a href="https://github.com/simonw/geojson-to-sqlite/releases"&gt;6 releases total&lt;/a&gt;) - 2021-05-17
&lt;br /&gt;CLI tool for converting GeoJSON files to SQLite (with SpatiaLite)&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.14"&gt;0.14&lt;/a&gt; - (&lt;a href="https://github.com/simonw/django-sql-dashboard/releases"&gt;28 releases total&lt;/a&gt;) - 2021-05-16
&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/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/geojson"&gt;geojson&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="projects"/><category term="geojson"/><category term="weeknotes"/><category term="vaccinate-ca"/><category term="django-sql-dashboard"/></entry><entry><title>Django SQL Dashboard</title><link href="https://simonwillison.net/2021/May/10/django-sql-dashboard/#atom-tag" rel="alternate"/><published>2021-05-10T15:39:23+00:00</published><updated>2021-05-10T15:39:23+00:00</updated><id>https://simonwillison.net/2021/May/10/django-sql-dashboard/#atom-tag</id><summary type="html">
    &lt;p&gt;I've released the first non-alpha version of &lt;a href="https://github.com/simonw/django-sql-dashboard"&gt;Django SQL Dashboard&lt;/a&gt;, which provides an interface for running arbitrary read-only SQL queries directly against a PostgreSQL database, protected by the Django authentication scheme. It can also be used to create saved dashboards that can be published or shared internally.&lt;/p&gt;
&lt;p&gt;I started building this tool &lt;a href="https://simonwillison.net/2021/Mar/14/weeknotes/"&gt;back in March&lt;/a&gt; as part of my work to port VaccinateCA away from Airtable &lt;a href="https://simonwillison.net/2021/Apr/12/porting-vaccinateca-to-django/"&gt;to a custom Django backend&lt;/a&gt;. One of the  strengths of Airtable is that it allows ad-hoc data exploration and reporting, and I wanted to provide an alternative to that for the new Django backend.&lt;/p&gt;
&lt;p&gt;I also wanted to try out some new ideas for &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt;, which doesn't (yet) work with PostgreSQL.&lt;/p&gt;
&lt;h4&gt;First, a demo&lt;/h4&gt;
&lt;p&gt;I recorded this three minute video demo introducing the software, using my blog's database as an example.&lt;/p&gt;

&lt;iframe style="max-width: 100%" width="560" height="315" src="https://www.youtube-nocookie.com/embed/ausrmMZkPEY" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen="allowfullscreen"&gt; &lt;/iframe&gt;

&lt;p&gt;In the video I run the following SQL queries to explore the many-to-many table that maps tags to my blog entries:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; blog_entry_tags;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The table starts out looking like this - not particularly interesting:&lt;/p&gt;
&lt;p&gt;&lt;img alt="id	entry_id	tag_id 5043	379	1931 5044	379	2118 5045	379	119 " src="https://static.simonwillison.net/static/2021/blog_tags_1.png" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Then I run this query to join it against the &lt;code&gt;blog_tag&lt;/code&gt; table and get the details of each tag:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; blog_entry_tags
&lt;span class="pl-k"&gt;join&lt;/span&gt; blog_tag &lt;span class="pl-k"&gt;on&lt;/span&gt; &lt;span class="pl-c1"&gt;blog_tag&lt;/span&gt;.&lt;span class="pl-c1"&gt;id&lt;/span&gt; &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;blog_entry_tags&lt;/span&gt;.&lt;span class="pl-c1"&gt;tag_id&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;img alt="id	entry_id	tag_id	id	tag 5043	379	1931	1931	ie6 5044	379	2118	2118	jeffreyzeldman 5045	379	119	119	alistapart 5046	1533	2432	2432	lugradiolive " src="https://static.simonwillison.net/static/2021/blog_tags_3.png" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;This is a bit more useful. I then click on the "count" link at the top of that "tag" column. This constructs a SQL query for me that uses a &lt;code&gt;count(*)&lt;/code&gt; and &lt;code&gt;group by&lt;/code&gt; to return a count of each value in that 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; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;tag&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;, &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; (
  &lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; blog_entry_tags
  &lt;span class="pl-k"&gt;join&lt;/span&gt; blog_tag &lt;span class="pl-k"&gt;on&lt;/span&gt; &lt;span class="pl-c1"&gt;blog_tag&lt;/span&gt;.&lt;span class="pl-c1"&gt;id&lt;/span&gt; &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;blog_entry_tags&lt;/span&gt;.&lt;span class="pl-c1"&gt;tag_id&lt;/span&gt;
) &lt;span class="pl-k"&gt;as&lt;/span&gt; results &lt;span class="pl-k"&gt;group by&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;tag&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &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;&lt;img alt="tag	n quora	1003 webdevelopment	166 startups	157 conferences	152 datasette	110" src="https://static.simonwillison.net/static/2021/blog_tags_4.png" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Then I demonstrate some of the default widget visualizations that come with Django SQL Dashboard. If I rewrite the query to return columns called &lt;code&gt;bar_label&lt;/code&gt; and &lt;code&gt;bar_quantity&lt;/code&gt; the tool will render the results as a bar chart:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;tag&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; bar_label, &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; bar_quantity &lt;span class="pl-k"&gt;from&lt;/span&gt; (
  &lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; blog_entry_tags
  &lt;span class="pl-k"&gt;join&lt;/span&gt; blog_tag &lt;span class="pl-k"&gt;on&lt;/span&gt; &lt;span class="pl-c1"&gt;blog_tag&lt;/span&gt;.&lt;span class="pl-c1"&gt;id&lt;/span&gt; &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;blog_entry_tags&lt;/span&gt;.&lt;span class="pl-c1"&gt;tag_id&lt;/span&gt;
) &lt;span class="pl-k"&gt;as&lt;/span&gt; results
&lt;span class="pl-k"&gt;group by&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;tag&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;order by&lt;/span&gt; bar_quantity &lt;span class="pl-k"&gt;desc&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;img alt="The query shows its results as a bar chart" src="https://static.simonwillison.net/static/2021/blog_tags_5.png" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Next, I demonstrate a similar trick that instead produces a word cloud by aliasing the columns to &lt;code&gt;wordcloud_word&lt;/code&gt; and &lt;code&gt;wordcloud_count&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;tag&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; wordcloud_word, &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; wordcloud_count &lt;span class="pl-k"&gt;from&lt;/span&gt; (
  &lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; blog_entry_tags
  &lt;span class="pl-k"&gt;join&lt;/span&gt; blog_tag &lt;span class="pl-k"&gt;on&lt;/span&gt; &lt;span class="pl-c1"&gt;blog_tag&lt;/span&gt;.&lt;span class="pl-c1"&gt;id&lt;/span&gt; &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;blog_entry_tags&lt;/span&gt;.&lt;span class="pl-c1"&gt;tag_id&lt;/span&gt;
) &lt;span class="pl-k"&gt;as&lt;/span&gt; results
&lt;span class="pl-k"&gt;group by&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;tag&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;order by&lt;/span&gt; wordcloud_count &lt;span class="pl-k"&gt;desc&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;img alt="My top tags, visualized as a word cloud" src="https://static.simonwillison.net/static/2021/blog_tags_6.png" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Finally, I show how that query can be turned into a saved dashboard and made available to the public. Here's the saved dashboard I created in the video:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://simonwillison.net/dashboard/tag-cloud/"&gt;https://simonwillison.net/dashboard/tag-cloud/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;This illustrates a key idea underlying both Django SQL dashboard and Datasette: &lt;strong&gt;a complete application can be defined as a SQL query!&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Much of the work we do as web application developers can be boiled down to constructing a SQL query and hooking it up to output to a web page. If you can safely execute SQL queries from page query strings this means you can build custom applications that exist entirely as bookmarkable URLs.&lt;/p&gt;
&lt;p&gt;My &lt;a href="https://simonwillison.net/2021/Jan/24/drawing-shapes-spatialite/"&gt;draw-a-shape-on-a-map application&lt;/a&gt; for searching mini parks in California from a few months ago is another example of this pattern in action.&lt;/p&gt;
&lt;h4&gt;Custom widgets&lt;/h4&gt;
&lt;p&gt;Building new custom widgets for this tool is extremely easy - hence the word cloud widget which I actually built specially for this demo. All you need to provide is a single Django template file.&lt;/p&gt;
&lt;p&gt;If your widget is going to respond to returned columns &lt;code&gt;wordcloud_word&lt;/code&gt; and &lt;code&gt;wordcloud_count&lt;/code&gt; the name of that template is those columns, sorted alphabetically and joined with hyphens:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;wordcloud_count-wordcloud_word.html&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Place that in a &lt;code&gt;django_sql_dashboard/widgets&lt;/code&gt; template directory and the new widget will be ready to use. Here's the &lt;a href="https://github.com/simonw/django-sql-dashboard/blob/0.13/django_sql_dashboard/templates/django_sql_dashboard/widgets/wordcloud_count-wordcloud_word.html"&gt;full implementation&lt;/a&gt; of the word cloud widget.&lt;/p&gt;
&lt;h4&gt;Named parameter support&lt;/h4&gt;
&lt;p&gt;This is a feature I lifted &lt;a href="https://docs.datasette.io/en/stable/sql_queries.html#named-parameters"&gt;directly from Datasette&lt;/a&gt;. You can construct SQL queries that look like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; blog_entry &lt;span class="pl-k"&gt;where&lt;/span&gt; id &lt;span class="pl-k"&gt;=&lt;/span&gt; %(id)s&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This uses psycopg2 syntax for named parameters. The value will be correctly quoted and escaped, so this is a useful tool for avoiding SQL injection attacks.&lt;/p&gt;
&lt;p&gt;Djang SQL Dashboard spots these parameters and turns them into form fields. Here's what that looks like in the interface:&lt;/p&gt;
&lt;p&gt;&lt;img alt="A form asking for the ID parameter for that query" src="https://static.simonwillison.net/static/2021/sql_dashboard_by_id.png" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;These forms submit using GET, so the result can be bookmarked. Here's a saved dashboard you can use to retrieve the details of any of my blog entries by their numeric ID:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://simonwillison.net/dashboard/blog-entry-by-id/?id=7991"&gt;https://simonwillison.net/dashboard/blog-entry-by-id/?id=7991&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;You can include multiple SQL parameters on a single dashboard, and any form parameters will be made available to all of those queries.&lt;/p&gt;
&lt;p&gt;This means you can build dashboards that run multiple queries against the same arguments. Imagine for example you want to build a report about a specific user's activity across multiple tables - you can accept their user ID as a parameter, then display the output of multiple queries (including custom visualizations) that each refer to that parameter.&lt;/p&gt;
&lt;h4&gt;Export through copy and paste&lt;/h4&gt;
&lt;p&gt;I love copy and paste as a mechanism for exporting data from a system. Django SQL Dashboard embraces this in a couple of ways:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Results from SQL queries can be copied out as TSV from an expandable textarea below the table - up to 1,000 rows. I like this format because you can paste it directly into Google Sheets or Excel to get the data correctly split into cells.&lt;/li&gt;
&lt;li&gt;Any time JSON is returned as a value from PostgreSQL, a "copy to clipboard" icon is shown next to the JSON. I use this a lot: both for JSON stored in PostgreSQL as well as the output from &lt;a href="https://www.postgresql.org/docs/13/functions-aggregate.html"&gt;JSON aggregation functions&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Export all query results as CSV/TSV&lt;/h4&gt;
&lt;p&gt;This comes up a lot at Vaccinate CA: we do a lot of data analysis where we need to work with other tools or send data to partners, and having a way to export the full set of results for a query (rather than truncating at the first thousand to avoid crashing the user's browser) was a frequent need. - Django SQL Dashboard provides this option using a combination of Django's &lt;a href="https://docs.djangoproject.com/en/3.2/ref/request-response/#django.http.StreamingHttpResponse"&gt;streaming HTTP response&lt;/a&gt; mechanism and PostgreSQL &lt;a href="https://www.psycopg.org/docs/usage.html#server-side-cursors"&gt;server-side cursors&lt;/a&gt; to efficiently stream large amounts of data without running out of resources.&lt;/p&gt;
&lt;h4&gt;A complex example: searching code examples across my blog&lt;/h4&gt;
&lt;p&gt;I decided to see how far I could push PostgreSQL.&lt;/p&gt;
&lt;p&gt;I often include code in my blog entries - examples that are wrapped in a &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt; tag. Within that tag I sometimes apply syntax highlighting (a bunch of &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; elements).&lt;/p&gt;
&lt;p&gt;It turns out I've included code snippets in 134 different blog entries:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; &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; blog_entry &lt;span class="pl-k"&gt;where&lt;/span&gt; body ~ &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;pre&amp;gt;.*&amp;lt;pre&amp;gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Can I use regular expressions in PostgreSQL to extract just the code examples, clean them up (removing those spans, reversing HTML entity encoding) and then provide simple search across the text of those examples, all in one query?&lt;/p&gt;
&lt;p&gt;It turns out I can!&lt;/p&gt;
&lt;p&gt;Here's a saved dashboard you can use to execute searches against just the contents of those &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt; tags across every entry on my blog:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://simonwillison.net/dashboard/code-examples/?search=select"&gt;https://simonwillison.net/dashboard/code-examples/?search=select&lt;/a&gt;&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;with results_stripped &lt;span class="pl-k"&gt;as&lt;/span&gt; (
  &lt;span class="pl-k"&gt;select&lt;/span&gt; id, title,
    replace(replace(replace(replace(replace(regexp_replace(
      (regexp_matches(body, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;pre&amp;gt;(.*?)&amp;lt;/pre&amp;gt;&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;g&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;))[&lt;span class="pl-c1"&gt;1&lt;/span&gt;],
        E&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;[^&amp;gt;]+&amp;gt;&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;&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;gi&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;&amp;amp;quot&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; chr(&lt;span class="pl-c1"&gt;59&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;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;amp;gt&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; chr(&lt;span class="pl-c1"&gt;59&lt;/span&gt;), &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;gt;&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;&amp;amp;lt&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; chr(&lt;span class="pl-c1"&gt;59&lt;/span&gt;), &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;&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;&amp;amp;#039&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; chr(&lt;span class="pl-c1"&gt;59&lt;/span&gt;), chr(&lt;span class="pl-c1"&gt;39&lt;/span&gt;)), &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;amp;amp&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; chr(&lt;span class="pl-c1"&gt;59&lt;/span&gt;), &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;amp;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
    ) &lt;span class="pl-k"&gt;as&lt;/span&gt; code
  &lt;span class="pl-k"&gt;from&lt;/span&gt;
    blog_entry
  &lt;span class="pl-k"&gt;where&lt;/span&gt;
    body ~ &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;pre&amp;gt;.*&amp;lt;pre&amp;gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
)
&lt;span class="pl-k"&gt;select&lt;/span&gt; id, title, code, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;https://simonwillison.net/e/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; id &lt;span class="pl-k"&gt;as&lt;/span&gt; link &lt;span class="pl-k"&gt;from&lt;/span&gt; results_stripped
&lt;span class="pl-k"&gt;where&lt;/span&gt; code &lt;span class="pl-k"&gt;like&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;span class="pl-k"&gt;||&lt;/span&gt; %(search)s &lt;span class="pl-k"&gt;||&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;span class="pl-k"&gt;limit&lt;/span&gt; &lt;span class="pl-c1"&gt;10&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;There's a lot going on here. The key component is this bit:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;regexp_matches(body, '&amp;lt;pre&amp;gt;(.*?)&amp;lt;/pre&amp;gt;', 'g'))[1]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;a href="https://www.postgresql.org/docs/13/functions-matching.html#FUNCTIONS-POSIX-REGEXP"&gt;regexp_matches() function&lt;/a&gt;, with the &lt;code&gt;'g'&lt;/code&gt; flag, returns every match for the given regular expression. As part of a larger &lt;code&gt;select&lt;/code&gt; query this means that if the expression matches three times you'll get back three rows in the output (in this case with duplicate &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;title&lt;/code&gt; columns) - which is what I want here.&lt;/p&gt;
&lt;p&gt;It's wrapped in a terrifying nest of extra functions. These serve two purposes: they strip out any nested HTML tags, and the un-escape the &lt;code&gt;&amp;amp;quot;&lt;/code&gt;, &lt;code&gt;&amp;amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;amp;amp;&lt;/code&gt; and &lt;code&gt;&amp;amp;#039;&lt;/code&gt; HTML entities. I did this as a nested block of &lt;code&gt;replace()&lt;/code&gt; functions - there's probably a neater solution here.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;chr(59)&lt;/code&gt; bits are a hack: Django SQL Dashboard disallows the &lt;code&gt;;&lt;/code&gt; character to ensure people can't execute multiple SQL queries - which could be used to work around some of the per-transaction protective settings applied by the tool.&lt;/p&gt;
&lt;p&gt;But I need to search-and-replace &lt;code&gt;&amp;amp;quot;&lt;/code&gt; - so I use this pattern to include the semicolon:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;replace(text, '&amp;amp;quot' || chr(59), '"')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Where &lt;code&gt;||&lt;/code&gt; is the PostgreSQL string concatenation operator.&lt;/p&gt;
&lt;p&gt;The search itself is constructed like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;where code like '%%' || %(search)s || '%%'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This constructs a like query against &lt;code&gt;'%your-search-term%'&lt;/code&gt; - the double percentage sign escaping is needed because &lt;code&gt;%&lt;/code&gt; has a special meaning here (it's part of the &lt;code&gt;%(search)s&lt;/code&gt; named parameter).&lt;/p&gt;
&lt;p&gt;One last trick: the final output of the query is produced by this:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; id, title, code, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;https://simonwillison.net/e/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; id &lt;span class="pl-k"&gt;as&lt;/span&gt; link &lt;span class="pl-k"&gt;from&lt;/span&gt; results_stripped&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;results_stripped&lt;/code&gt; is a &lt;a href="https://www.postgresql.org/docs/13/queries-with.html"&gt;CTE&lt;/a&gt; defined earlier - I usually try to wrap up complex weird stuff like those nested &lt;code&gt;replace()&lt;/code&gt; calls in a CTE so I can write a simple final query.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;'https://simonwillison.net/e/' || id as link&lt;/code&gt; bit here concatenates together a URL that links to my entry based on its ID. My blog uses &lt;code&gt;/yyyy/Mon/slug/&lt;/code&gt; URLs but generating these from a SQL query against the &lt;code&gt;created&lt;/code&gt; column was a little fussy, so I &lt;a href="https://github.com/simonw/simonwillisonblog/commit/771878920bbcf46eee3f34d22b2dc35340da07f9"&gt;added /e/ID redirecting URLs&lt;/a&gt; to make generating links in dashboard queries easier.&lt;/p&gt;
&lt;h4&gt;Future plans&lt;/h4&gt;
&lt;p&gt;Django SQL Dashboard has already proved itself invaluable for my current project. I imagine I'll be using it for every Django project I build going forward - being able to query the database like this, create ad-hoc visualizations and then link to them is a huge productivity boost.&lt;/p&gt;
&lt;p&gt;The bigger question is how it overlaps with &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Datasette has been SQLite-only since I started the project three and a half years ago - because I know that building a database abstraction layer is a huge additional commitment and, for Datasette's initial purpose of helping publish read-only data, it didn't feel necessary.&lt;/p&gt;
&lt;p&gt;I have a growing suspicion that getting Datasette to work against PostgreSQL (and other database backends) in addition to SQLite is less work than I had originally thought.&lt;/p&gt;
&lt;p&gt;Datasette is also built on top of ASGI. Django 3.0 &lt;a href="https://docs.djangoproject.com/en/3.2/releases/3.0/#asgi-support"&gt;introduced ASGI support&lt;/a&gt;, so it's now possible to host ASGI applications like Datasette as part of a unified Django application.&lt;/p&gt;
&lt;p&gt;So it's possible that the future of Django SQL Dashboard will be for Datasette to eventually make it obsolete.&lt;/p&gt;
&lt;p&gt;That doesn't stop it from being extremely useful today. If you try it out I'd love to hear from you! I'm also keen to see people start to expand it for their own projects, especially via the &lt;a href="https://django-sql-dashboard.datasette.io/en/latest/widgets.html#custom-widgets"&gt;custom widgets&lt;/a&gt; mechanism.&lt;/p&gt;
&lt;p&gt;Let me know if you try it out!&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/javascript/scroll-to-form-if-errors"&gt;Scroll page to form if there are errors&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.12"&gt;0.12&lt;/a&gt; - (&lt;a href="https://github.com/simonw/django-sql-dashboard/releases"&gt;22 releases total&lt;/a&gt;) - 2021-05-08
&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/databases"&gt;databases&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&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/sql"&gt;sql&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/django-sql-dashboard"&gt;django-sql-dashboard&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="databases"/><category term="django"/><category term="postgresql"/><category term="projects"/><category term="sql"/><category term="datasette"/><category term="weeknotes"/><category term="django-sql-dashboard"/></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>Weeknotes: django-sql-dashboard widgets</title><link href="https://simonwillison.net/2021/Mar/21/django-sql-dashboard-widgets/#atom-tag" rel="alternate"/><published>2021-03-21T05:50:25+00:00</published><updated>2021-03-21T05:50:25+00:00</updated><id>https://simonwillison.net/2021/Mar/21/django-sql-dashboard-widgets/#atom-tag</id><summary type="html">
    &lt;p&gt;A few small releases this week, for &lt;code&gt;django-sql-dashboard&lt;/code&gt;, &lt;code&gt;datasette-auth-passwords&lt;/code&gt; and &lt;code&gt;datasette-publish-vercel&lt;/code&gt;.&lt;/p&gt;
&lt;h4&gt;django-sql-dashboard widgets and permissions&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/django-sql-dashboard"&gt;django-sql-dashboard&lt;/a&gt;, my subset-of-Datasette-for-Django-and-PostgreSQL continues to come together.&lt;/p&gt;
&lt;p&gt;New this week: widgets and permissions.&lt;/p&gt;
&lt;p&gt;To recap: this Django app borrows some ideas from Datasette: it encourages you to create a read-only PostgreSQL user and grant authenticated users the ability to run one or more raw SQL queries directly against your database.&lt;/p&gt;
&lt;p&gt;You can execute more than one SQL query and combine them into a saved dashboard, which will then show multiple tables containing the results.&lt;/p&gt;
&lt;p&gt;This week I added support for dashboard widgets. You can construct SQL queries to return specific column patterns which will then be rendered on the page in different ways.&lt;/p&gt;
&lt;p&gt;There are four widgets at the moment: "big number", bar chart, HTML and Markdown.&lt;/p&gt;
&lt;p&gt;Big number is the simplest: define a SQL query that returns two columns called &lt;code&gt;label&lt;/code&gt; and &lt;code&gt;big_number&lt;/code&gt; and the dashboard will display that result as a big number:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Entries&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; label, &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; big_number &lt;span class="pl-k"&gt;from&lt;/span&gt; blog_entry;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;img alt="Entries: 2804 - an example of a big number display" src="https://static.simonwillison.net/static/2021/dashboard-big-number.png" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Bar chart is more sophisticated: return columns named &lt;code&gt;bar_label&lt;/code&gt; and &lt;code&gt;bar_quantity&lt;/code&gt; to display a bar chart of the results:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt;
  to_char(date_trunc(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;month&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, created), &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;YYYY-MM&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;) &lt;span class="pl-k"&gt;as&lt;/span&gt; bar_label,
  &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; bar_quantity
&lt;span class="pl-k"&gt;from&lt;/span&gt;
  blog_entry
&lt;span class="pl-k"&gt;group by&lt;/span&gt;
  bar_label
&lt;span class="pl-k"&gt;order by&lt;/span&gt;
  &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;desc&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;img alt="A bar chart showing the result of that query" src="https://static.simonwillison.net/static/2021/dashboard-bar-chart.png" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;HTML and Markdown are simpler: they display the rendered HTML or Markdown, after filtering it through the Bleach library to strip any harmful elements or scripts.&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt;
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;## Ten most recent blogmarks (of &lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; 
  &lt;span class="pl-k"&gt;||&lt;/span&gt; &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;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt; total)&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-k"&gt;as&lt;/span&gt; markdown &lt;span class="pl-k"&gt;from&lt;/span&gt; blog_blogmark;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I'm running the dashboard application on this blog, and I've set up &lt;a href="https://simonwillison.net/dashboard/example-dashboard/"&gt;an example dashboard&lt;/a&gt; here that illustrates the different types of widget.&lt;/p&gt;
&lt;p&gt;&lt;img alt="An example dashboard with several different widgets" src="https://static.simonwillison.net/static/2021/dashboard-example.png" style="max-width:100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Defining custom widgets is easy: take the column names you would like to respond to, sort them alphabetically, join them with hyphens and create a custom widget in a template file with that name.&lt;/p&gt;
&lt;p&gt;So if you wanted to build a widget that looks for &lt;code&gt;label&lt;/code&gt; and &lt;code&gt;geojson&lt;/code&gt; columns and renders that data on a &lt;a href="https://leafletjs.com/"&gt;Leaflet map&lt;/a&gt;, you would create a &lt;code&gt;geojson-label.html&lt;/code&gt; template and drop it into your Django &lt;code&gt;templates/django-sql-dashboard/widgets&lt;/code&gt; folder. See &lt;a href="https://django-sql-dashboard.readthedocs.io/en/latest/widgets.html#custom-widgets"&gt;the custom widgets documentation&lt;/a&gt; for details.&lt;/p&gt;
&lt;p&gt;Which reminds me: I decided a README wasn't quite enough space for documentation here, so I started a &lt;a href="https://django-sql-dashboard.readthedocs.io/"&gt;Read The Docs documentation site&lt;/a&gt; for the project.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://docs.datasette.io/"&gt;Datasette&lt;/a&gt; and &lt;a href="https://sqlite-utils.datasette.io/"&gt;sqlite-utils&lt;/a&gt; both use &lt;a href="https://www.sphinx-doc.org/"&gt;Sphinx&lt;/a&gt; and &lt;a href="https://docutils.sourceforge.io/rst.html"&gt;reStructuredText&lt;/a&gt; for their documentation.&lt;/p&gt;
&lt;p&gt;For &lt;code&gt;django-sql-dashboard&lt;/code&gt; I've decided to try out Sphinx and Markdown instead, using &lt;a href="https://myst-parser.readthedocs.io/"&gt;MyST&lt;/a&gt; - a Markdown flavour and parser for Sphinx.&lt;/p&gt;
&lt;p&gt;I picked this because I want to add inline help to &lt;code&gt;django-sql-dashboard&lt;/code&gt;, and since it ships with Markdown as a dependency already (to power the Markdown widget) my hope is that using Markdown for the documentation will allow me to ship some of the user-facing docs as part of the application itself. But it's also a fun excuse to try out MyST, which so far is working exactly as advertised.&lt;/p&gt;
&lt;p&gt;I've seen people in the past avoid Sphinx entirely because they preferred Markdown to reStructuredText, so MyST feels like an important addition to the Python documentation ecosystem.&lt;/p&gt;
&lt;h4&gt;HTTP Basic authentication&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://datasette.io/plugins/datasette-auth-passwords"&gt;datasette-auth-passwords&lt;/a&gt; implements password-based authentication to Datasette. The plugin defaults to providing a username and password login form which sets a signed cookie identifying the current user.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/datasette-auth-passwords/releases/tag/0.4"&gt;Version 0.4&lt;/a&gt; introduces &lt;a href="https://github.com/simonw/datasette-auth-passwords/issues/15"&gt;optional support&lt;/a&gt; for HTTP Basic authentication instead - where the user's browser handles the authentication prompt.&lt;/p&gt;
&lt;p&gt;Basic auth has some disadvantages - most notably that it doesn't support logout without the user entirely closing down their browser. But it's useful for a number of reasons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It's easy to protect every resource on a website with it - including static assets. Adding &lt;code&gt;"http_basic_auth": true&lt;/code&gt; to your plugin configuration adds this protection, covering all of Datasette's resources.&lt;/li&gt;
&lt;li&gt;It's much easier to authenticate with from automated scripts. &lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;roquests&lt;/code&gt; and &lt;code&gt;httpx&lt;/code&gt; all have simple built-in support for passing basic authentication usernames and passwords, which makes it a useful target for scripting - without having to install an additional authentication plugin such as &lt;a href="https://datasette.io/plugins/datasette-auth-tokens"&gt;datasette-auth-tokens&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I'm continuing to flesh out authentication options for Datasette, and adding this to &lt;code&gt;datasette-auth-passwords&lt;/code&gt; is one of those small improvements that should pay off long into the future.&lt;/p&gt;
&lt;h4&gt;A fix for datasette-publish-vercel&lt;/h4&gt;
&lt;p&gt;Datasette instances published to &lt;a href="https://vercel.com/"&gt;Vercel&lt;/a&gt; using the &lt;a href="https://datasette.io/plugins/datasette-publish-vercel"&gt;datasette-publish-vercel&lt;/a&gt; have previously been affected by an obscure Vercel bug: &lt;a href="https://github.com/vercel/vercel/issues/5575"&gt;characters such as + in the query string&lt;/a&gt; were being lost due to Vercel unescaping encoded characters before the request got to the Python application server.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://vercel.com/changelog/correcting-request-urls-with-python-serverless-functions"&gt;Vercel fixed this&lt;/a&gt; earlier this month, and the latest release of &lt;code&gt;datasette-publish-vercel&lt;/code&gt; includes their fix by switching to the new &lt;code&gt;@vercel/python&lt;/code&gt; builder. Thanks &lt;a href="https://twitter.com/styfle"&gt;@styfle&lt;/a&gt; from Vercel for shepherding this fix through!&lt;/p&gt;
&lt;h4&gt;New photos on Niche Museums&lt;/h4&gt;
&lt;p&gt;My Niche Museums project has been in hiberation since the start of the pandemic. Now that vaccines are rolling out it feels like there might be an end to this thing, so I've started thinking about my museum hobby again.&lt;/p&gt;
&lt;p&gt;I added some new photos to the site today - on the entries for &lt;a href="https://www.niche-museums.com/17"&gt;Novelty Automation&lt;/a&gt;, &lt;a href="https://www.niche-museums.com/21"&gt;DEVIL-ish Little Things&lt;/a&gt;, &lt;a href="https://www.niche-museums.com/24"&gt;Evergreen Aviation &amp;amp; Space Museum&lt;/a&gt; and &lt;a href="https://www.niche-museums.com/33"&gt;California State Capitol Dioramas&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Hopefully someday soon I'll get to visit and add an entirely new museum!&lt;/p&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.4a1"&gt;0.4a1&lt;/a&gt; - (&lt;a href="https://github.com/simonw/django-sql-dashboard/releases"&gt;10 releases total&lt;/a&gt;) - 2021-03-21
&lt;br /&gt;Django app for building dashboards using raw SQL queries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette-publish-vercel"&gt;datasette-publish-vercel&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/datasette-publish-vercel/releases/tag/0.9.2"&gt;0.9.2&lt;/a&gt; - (&lt;a href="https://github.com/simonw/datasette-publish-vercel/releases"&gt;14 releases total&lt;/a&gt;) - 2021-03-20
&lt;br /&gt;Datasette plugin for publishing data using Vercel&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette-auth-passwords"&gt;datasette-auth-passwords&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/datasette-auth-passwords/releases/tag/0.4"&gt;0.4&lt;/a&gt; - (&lt;a href="https://github.com/simonw/datasette-auth-passwords/releases"&gt;9 releases total&lt;/a&gt;) - 2021-03-19
&lt;br /&gt;Datasette plugin for authentication using passwords&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/authentication"&gt;authentication&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/dashboard"&gt;dashboard&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django"&gt;django&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/zeit-now"&gt;zeit-now&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/weeknotes"&gt;weeknotes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-sql-dashboard"&gt;django-sql-dashboard&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sphinx-docs"&gt;sphinx-docs&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="authentication"/><category term="dashboard"/><category term="django"/><category term="postgresql"/><category term="projects"/><category term="zeit-now"/><category term="weeknotes"/><category term="django-sql-dashboard"/><category term="sphinx-docs"/></entry><entry><title>VIAL is now live, plus django-sql-dashboard</title><link href="https://simonwillison.net/2021/Mar/15/vaccinateca-2021-03-15/#atom-tag" rel="alternate"/><published>2021-03-15T17:00:00+00:00</published><updated>2021-03-15T17:00:00+00:00</updated><id>https://simonwillison.net/2021/Mar/15/vaccinateca-2021-03-15/#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;Our new Django backend has now officially graduated from preview mode! We've been running it to collect caller reports for Oregon for over a week now, and today we finally &lt;a href="https://github.com/CAVaccineInventory/vial/milestone/5"&gt;turned off the old Heroku app&lt;/a&gt; and promoted &lt;a href="https://vial.calltheshots.us/" rel="nofollow"&gt;https://vial.calltheshots.us/&lt;/a&gt; to be the place that our caller app writes to.&lt;/p&gt;
&lt;p&gt;We also have &lt;a href="https://vial-staging.calltheshots.us/" rel="nofollow"&gt;https://vial-staging.calltheshots.us/&lt;/a&gt; as a staging environment.&lt;/p&gt;
&lt;p&gt;Calls made in California are still being logged directly to Airtable. The next big milestone for VIAL will be replacing Airtable for our California calls. That's going to be my focus for this week.&lt;/p&gt;
&lt;h4&gt;
django-sql-dashboard for reporting&lt;/h4&gt;
&lt;p&gt;This weekend I span up a new package which acts as a Django-based imitation of (and research playground for) my &lt;a href="https://datasette.io" rel="nofollow"&gt;Datasette&lt;/a&gt; project.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/django-sql-dashboard"&gt;django-sql-dashboard&lt;/a&gt; takes some of the key ideas from Datasette - the ability to use raw SQL queries in a safe, read-only, time limited environment to build bookmarkable interfaces - and turns it into a dashboard for applications written in Django and running against PostgreSQL.&lt;/p&gt;
&lt;p&gt;It's part of my ongoing goal to replace Airtable while avoiding the loss of the flexibility that has made Airtable so valuable to us.&lt;/p&gt;
&lt;p&gt;If you are a staff user, you can access it at &lt;a href="https://vial.calltheshots.us/dashboard/" rel="nofollow"&gt;https://vial.calltheshots.us/dashboard/&lt;/a&gt;. The default interface lets you start running PostgreSQL SQL queries against a subset of our database tables:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Django_SQL_Dashboard" src="https://user-images.githubusercontent.com/9599/111263111-8b3bfe80-85e2-11eb-8592-eb44afb0d84c.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;It borrows a number of key features from Datasette. Firstly, queries can be bookmarked and shared. Any time you submit new queries you get back a URL with signed parameters, which you can share with other staff users. The page in the screenshot is at &lt;a href="https://vial.calltheshots.us/dashboard/?sql=InNlbGVjdCBjb3VudCgqKSBmcm9tIGxvY2F0aW9uIg:1lM2mA:0uepYBAAxILOU_-jOrm12grM965gk83KazIAKUtgMmw&amp;amp;sql=InNlbGVjdCAqIGZyb20gbG9jYXRpb24i:1lM2mA:_tVz6msUDzgWYpVYZeZ7jgwo8YuoTLzTJCBploNucTY" rel="nofollow"&gt;https://vial.calltheshots.us/dashboard/?sql=InNlbGVjdCBjb3VudCgqKSBmcm9tIGxvY2F0aW9uIg:1lM2mA:0uepYBAAxILOU_-jOrm12grM965gk83KazIAKUtgMmw&amp;amp;sql=InNlbGVjdCAqIGZyb20gbG9jYXRpb24i:1lM2mA:_tVz6msUDzgWYpVYZeZ7jgwo8YuoTLzTJCBploNucTY&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Secondly, you can use named parameters in your queries - &lt;code&gt;select * from location where state_id = (select id from state where name = %(state)s)&lt;/code&gt; for example - and the dashboard will extract those parameters out and turn them into form fields.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://vial.calltheshots.us/dashboard/?sql=InNlbGVjdCAqIGZyb20gbG9jYXRpb24gd2hlcmUgc3RhdGVfaWQgPSAoc2VsZWN0IGlkIGZyb20gc3RhdGUgd2hlcmUgbmFtZSA9ICUoc3RhdGUpcyki%3A1lM2p5%3AwxWUfq1Vf6FkEPjCerWsEm7ljkyPEbhDVjsorHZ2bt4&amp;amp;state=Oregon" rel="nofollow"&gt;https://vial.calltheshots.us/dashboard/?sql=InNlbGVjdCAqIGZyb20gbG9jYXRpb24gd2hlcmUgc3RhdGVfaWQgPSAoc2VsZWN0IGlkIGZyb20gc3RhdGUgd2hlcmUgbmFtZSA9ICUoc3RhdGUpcyki%3A1lM2p5%3AwxWUfq1Vf6FkEPjCerWsEm7ljkyPEbhDVjsorHZ2bt4&amp;amp;state=Oregon&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img alt="Django_SQL_Dashboard_and_New_File" src="https://user-images.githubusercontent.com/9599/111263328-eb32a500-85e2-11eb-8021-94f15c19b71c.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;Unlike Datasette, it allows multiple SQL queries to be embedded in the same page. This means you can build quite complex dashboards, all sharing the same user-configurable parameter.&lt;/p&gt;
&lt;p&gt;The most exciting feature though is &lt;a href="https://github.com/simonw/django-sql-dashboard/tree/0.3a1#widgets"&gt;custom widgets&lt;/a&gt;. If you write a SQL query that returns columns with specific names, custom widgets will kick in to render that data in formats other than a table.&lt;/p&gt;
&lt;p&gt;This is best illustrated by the dashboard I built here: &lt;a href="https://vial.calltheshots.us/dashboard/numbers-by-state/?state_name=California" rel="nofollow"&gt;https://vial.calltheshots.us/dashboard/numbers-by-state/?state_name=California&lt;/a&gt; - this is an example of a "stored dashboard" which has been written to the database.&lt;/p&gt;
&lt;p&gt;&lt;img alt="image" src="https://user-images.githubusercontent.com/9599/111263809-b4a95a00-85e3-11eb-8cdd-0f13392e6861.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;Note how the following SQL query is rendered as a "big number":&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Number of locations in &lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; %(state_name)s &lt;span class="pl-k"&gt;as&lt;/span&gt; label,
&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; big_number
&lt;span class="pl-k"&gt;from&lt;/span&gt; location &lt;span class="pl-k"&gt;where&lt;/span&gt; state_id &lt;span class="pl-k"&gt;=&lt;/span&gt; (&lt;span class="pl-k"&gt;select&lt;/span&gt; id &lt;span class="pl-k"&gt;from&lt;/span&gt; state &lt;span class="pl-k"&gt;where&lt;/span&gt; name &lt;span class="pl-k"&gt;=&lt;/span&gt; %(state_name)s)&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And the bar chart at the bottom of the page is rendered automatically for this SQL query, because it returns columns called &lt;code&gt;bar_quantity&lt;/code&gt; and &lt;code&gt;bar_label&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; &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; bar_quantity,
to_char(created_at, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;YYYY-MM-DD&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;) &lt;span class="pl-k"&gt;as&lt;/span&gt; bar_label
&lt;span class="pl-k"&gt;from&lt;/span&gt; reports
&lt;span class="pl-k"&gt;where&lt;/span&gt; &lt;span class="pl-c1"&gt;reports&lt;/span&gt;.&lt;span class="pl-c1"&gt;location_id&lt;/span&gt; &lt;span class="pl-k"&gt;in&lt;/span&gt;
(&lt;span class="pl-k"&gt;select&lt;/span&gt; id &lt;span class="pl-k"&gt;from&lt;/span&gt; location &lt;span class="pl-k"&gt;where&lt;/span&gt; state_id &lt;span class="pl-k"&gt;=&lt;/span&gt; (&lt;span class="pl-k"&gt;select&lt;/span&gt; id &lt;span class="pl-k"&gt;from&lt;/span&gt; state &lt;span class="pl-k"&gt;where&lt;/span&gt; name &lt;span class="pl-k"&gt;=&lt;/span&gt; %(state_name)s))
&lt;span class="pl-k"&gt;group by&lt;/span&gt; to_char(created_at, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;YYYY-MM-DD&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;)&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;You can even create SQL statements that return dynamically concatenated markdown or HTML (run through &lt;a href="https://github.com/mozilla/bleach"&gt;Bleach&lt;/a&gt; to avoid any nasty XSS problems).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select '# Chart: number of reports filed per day in ' || %(state_name)s as markdown
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This ability to construct dashboards by saving a list of SQL queries feels really powerful to me - it takes some of the best aspects of Datasette (which currently only works against SQLite databases) and makes them available to us within our Django/PostgreSQL app, protected by the Django authentication mechanism.&lt;/p&gt;
&lt;p&gt;You can follow ongoing developement of &lt;code&gt;django-sql-dashboard&lt;/code&gt; in the issues at &lt;a href="https://github.com/simonw/django-sql-dashboard/issues"&gt;https://github.com/simonw/django-sql-dashboard/issues&lt;/a&gt;&lt;/p&gt;
    
        &lt;p&gt;Tags: &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/django-sql-dashboard"&gt;django-sql-dashboard&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="postgresql"/><category term="vaccinate-ca"/><category term="django-sql-dashboard"/><category term="vaccinate-ca-blog"/></entry><entry><title>Weeknotes: tableau-to-sqlite, django-sql-dashboard</title><link href="https://simonwillison.net/2021/Mar/14/weeknotes/#atom-tag" rel="alternate"/><published>2021-03-14T07:35:18+00:00</published><updated>2021-03-14T07:35:18+00:00</updated><id>https://simonwillison.net/2021/Mar/14/weeknotes/#atom-tag</id><summary type="html">
    &lt;p&gt;This week I started a limited production run of my new backend for Vaccinate CA calling, built a &lt;code&gt;tableau-to-sqlite&lt;/code&gt; import tool and started working on a subset of Datasette for PostgreSQL and Django called &lt;code&gt;django-sql-dashboard&lt;/code&gt;.&lt;/p&gt;
&lt;h4&gt;Vaccinate CA backend progress&lt;/h4&gt;
&lt;p&gt;My key project at the moment is building out a new Django-powered backend for the &lt;a href="https://www.vaccinateca.com/"&gt;Vaccinate CA&lt;/a&gt; call reporting application - where real human beings constantly call pharmacies and medical sites around California to build a comprehensive guide to where the Covid vaccine is available.&lt;/p&gt;
&lt;p&gt;As of this week, the new backend is running for a subset of the overall call volume. It's exciting! It's also a reminder that the single hardest piece of logic in any crowdsourcing-style application is the logic that gives a human being their next task. I'm continuing to evolve that logic, which is somewhat harder when the system I'm modifying is actively being used.&lt;/p&gt;
&lt;h4 id="tableau-to-sqlite"&gt;tableau-to-sqlite&lt;/h4&gt;
&lt;p&gt;The Vaccinate CA project is constantly on the lookout for new sources of data that might indicate locations that have the vaccine. Some of this data is locked up in Tableau dashboards, which are &lt;a href="https://www.notion.so/Parsing-Tableau-Dashboards-101-4a5d58aaefee48f2b5440456ca72b733"&gt;notoriously tricky&lt;/a&gt; to scrape.&lt;/p&gt;
&lt;p&gt;When faced with problems like this, I frequently turn to GitHub code search: I'll find a unique looking token in the data I'm trying to wrangle and run searches to see if anyone on GitHub has written code to handle it.&lt;/p&gt;
&lt;p&gt;In doing so, I came across &lt;a href="https://github.com/bertrandmartel/tableau-scraping/"&gt;Tableau Scraper&lt;/a&gt; - an open source Python library by Bertrand Martel which does a fantastic job of turning a Tableau dashboard into a Pandas DataFrame.&lt;/p&gt;
&lt;p&gt;Writing a Pandas DataFrame to a SQLite database is a one-liner: &lt;code&gt;df.to_sql("table-name", sqlite3.connect(db_path))&lt;/code&gt;. So I spun up a quick command-line wrapper around the &lt;code&gt;TableuaScraper&lt;/code&gt; class called &lt;a href="https://github.com/simonw/tableau-to-sqlite"&gt;tableau-to-sqlite&lt;/a&gt; which lets you do the following:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;% tableau-to-sqlite tableau.db https://results.mo.gov/t/COVID19/views/VaccinationsDashboard/Vaccinations &lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Considering how much valuable data is trapped in government Tableau dashboards I'm really excited to point this tool at more sources. The README includes tips on &lt;a href="https://github.com/simonw/tableau-to-sqlite#get-the-data-as-json-or-csv"&gt;combining this with sqlite-utils&lt;/a&gt; to get a CSV or JSON export which can then be tracked using &lt;a href="https://simonwillison.net/2020/Oct/9/git-scraping/"&gt;Git scraping&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="django-sql-dashboard"&gt;django-sql-dashboard&lt;/h4&gt;
&lt;p&gt;I'm continuing to ponder the idea of getting &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; to talk to PostgreSQL in addition to SQLite, but in the meantime I have a growing Django application that runs against PostgreSQL and a desire to build some quick dashboards against it.&lt;/p&gt;
&lt;p&gt;One of Datasette's key features is the ability to bookmark a read-only SQL query and share that link with other people. It's SQL injection attacks repurposed as a feature, and it's proved to be incredibly useful over the past few years.&lt;/p&gt;
&lt;p&gt;Here's an example from &lt;a href="https://twitter.com/simonw/status/1370395183360086022"&gt;earlier this week&lt;/a&gt; where I wanted to see &lt;a href="https://github-to-sqlite.dogsheep.net/github?sql=select%0D%0A++json_object%28%0D%0A++++%27label%27%2C+repos.full_name+%7C%7C+%27+%23%27+%7C%7C+issues.number%2C%0D%0A++++%27href%27%2C+%27https%3A%2F%2Fgithub.com%2F%27+%7C%7C+repos.full_name+%7C%7C+%27%2Fissues%2F%27+%7C%7C+issues.number%0D%0A++%29+as+link%2C%0D%0A++strftime%28%27%25s%27%2Cissues.closed_at%29+-+strftime%28%27%25s%27%2Cissues.created_at%29+as+duration_open_in_seconds%2C%0D%0A++issues.number+as+issue_number%2C%0D%0A++issues.title%2C%0D%0A++users.login%2C%0D%0A++issues.closed_at%2C%0D%0A++issues.created_at%2C%0D%0A++issues.body%2C%0D%0A++issues.type%0D%0Afrom%0D%0A++issues+join+repos+on+issues.repo+%3D+repos.id%0D%0A++join+users+on+issues.user+%3D+users.id%0D%0A++where+issues.closed_at+is+not+null+and+duration_open_in_seconds+%3C+CAST%28%3Amax_duration_in_seconds+AS+INTEGER%29%0D%0Aorder+by%0D%0A++issues.closed_at+desc&amp;amp;max_duration_in_seconds=60"&gt;how many GitHub issues I had opened and then closed within 60 seconds&lt;/a&gt;. The answer is 17!&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/django-sql-dashboard"&gt;django-sql-dashboard&lt;/a&gt; is my highly experimental exploration of what that idea looks like against a PostgreSQL database, wrapped inside a Django application&lt;/p&gt;
&lt;p&gt;The key idea is to support executing read-only PostgreSQL statements with a strict timelimit (set using PostgreSQL's &lt;code&gt;statement_timeout&lt;/code&gt; setting, &lt;a href="https://github.com/simonw/django-sql-dashboard/issues/17"&gt;described here&lt;/a&gt;). Users can execute SQL directly, bookmark and share queries and save them to a database table in order to construct persistent dashboards.&lt;/p&gt;
&lt;p&gt;It's very early days for the project yet, and I'm still not 100% convinced it's a good idea, but early signs are very promising.&lt;/p&gt;
&lt;p&gt;A fun feature is that it lets you have more than one SQL query on the same page. Here's what it looks like running against my blog's database, showing a count query and the months in which I wrote the most blog entries:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Two SQL queries are shown: select count() from blog_entry; and select to_char(date_trunc('month', created), 'Mon YYYY') as month, count() from blog_entry group by month order by count(*) desc" src="https://static.simonwillison.net/static/2021/Django_SQL_Dashboard.png" style="max-width:100%;" /&gt;&lt;/p&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/dogsheep/hacker-news-to-sqlite"&gt;hacker-news-to-sqlite&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/dogsheep/hacker-news-to-sqlite/releases/tag/0.4"&gt;0.4&lt;/a&gt; - (&lt;a href="https://github.com/dogsheep/hacker-news-to-sqlite/releases"&gt;5 releases total&lt;/a&gt;) - 2021-03-13
&lt;br /&gt;Create a SQLite database containing data pulled from Hacker News&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.1a3"&gt;0.1a3&lt;/a&gt; - (&lt;a href="https://github.com/simonw/django-sql-dashboard/releases"&gt;3 releases total&lt;/a&gt;) - 2021-03-13
&lt;br /&gt;Django app for building dashboards using raw SQL queries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette-ripgrep"&gt;datasette-ripgrep&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/datasette-ripgrep/releases/tag/0.7"&gt;0.7&lt;/a&gt; - (&lt;a href="https://github.com/simonw/datasette-ripgrep/releases"&gt;11 releases total&lt;/a&gt;) - 2021-03-11
&lt;br /&gt;Web interface for searching your code using ripgrep, built as a Datasette plugin&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/tableau-to-sqlite"&gt;tableau-to-sqlite&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/tableau-to-sqlite/releases/tag/0.2"&gt;0.2&lt;/a&gt; - (&lt;a href="https://github.com/simonw/tableau-to-sqlite/releases"&gt;3 releases total&lt;/a&gt;) - 2021-03-11
&lt;br /&gt;Fetch data from Tableau into a SQLite database&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;TIL this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&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;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/jq/flatten-nested-json-objects-jq"&gt;Flattening nested JSON objects with jq&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/jq/convert-no-decimal-point-latitude-jq"&gt;Converting no-decimal-point latitudes and longitudes using jq&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/django/almost-facet-counts-django-admin"&gt;How to almost get facet counts in the Django admin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/datasette/issues-open-for-less-than-x-seconds"&gt;Querying for GitHub issues open for less than 60 seconds&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/sqlite/utc-items-on-thursday-in-pst"&gt;Querying for items stored in UTC that were created on a Thursday in PST&lt;/a&gt;&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/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/sql"&gt;sql&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="django"/><category term="postgresql"/><category term="projects"/><category term="sql"/><category term="weeknotes"/><category term="vaccinate-ca"/><category term="django-sql-dashboard"/></entry></feed>