<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: vaccinate-ca</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/tags/vaccinate-ca.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2021-06-28T17:23:21+00:00</updated><author><name>Simon Willison</name></author><entry><title>Weeknotes: sqlite-utils updates, Datasette and asgi-csrf, open-sourcing VIAL</title><link href="https://simonwillison.net/2021/Jun/28/weeknotes/#atom-tag" rel="alternate"/><published>2021-06-28T17:23:21+00:00</published><updated>2021-06-28T17:23:21+00:00</updated><id>https://simonwillison.net/2021/Jun/28/weeknotes/#atom-tag</id><summary type="html">
    &lt;p&gt;Some work on &lt;code&gt;sqlite-utils&lt;/code&gt;, &lt;code&gt;asgi-csrf&lt;/code&gt;, a Datasette alpha and we open-sourced VIAL.&lt;/p&gt;
&lt;h4&gt;sqlite-utils&lt;/h4&gt;
&lt;p&gt;Last week's &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v3-10"&gt;sqlite-utils 3.10&lt;/a&gt; introduced a huge new feature: the ability to &lt;a href="https://simonwillison.net/2021/Jun/19/sqlite-utils-memory/"&gt;run joins directly against CSV and JSON files from the command-line&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I've since released &lt;a href="https://sqlite-utils.datasette.io/en/latest/changelog.html#v3-11"&gt;sqlite-utils 3.11&lt;/a&gt; and &lt;a href="https://sqlite-utils.datasette.io/en/latest/changelog.html#v3-12"&gt;3.12&lt;/a&gt;, much smaller releases.&lt;/p&gt;
&lt;p&gt;3.11 added a new &lt;code&gt;--schema&lt;/code&gt; option to the &lt;code&gt;sqlite-utils memory&lt;/code&gt; command which lets you see the schema you'll be querying for the imported data:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ curl 'https://api.github.com/users/dogsheep/repos' | \
  sqlite-utils memory - --schema
CREATE TABLE [stdin] (
   [id] INTEGER,
   [node_id] TEXT,
   [name] TEXT,
   [full_name] TEXT,
   [private] INTEGER,
   [owner] TEXT,
   [html_url] TEXT,
   [description] TEXT,
   ...
   [watchers] INTEGER,
   [default_branch] TEXT
);
CREATE VIEW t1 AS select * from [stdin];
CREATE VIEW t AS select * from [stdin];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;3.12 focused on the Python library side of the package. It adds a new method, &lt;code&gt;db.query(sql)&lt;/code&gt; which returns an iterator over Python dictionaries representing the results of a query.&lt;/p&gt;
&lt;p&gt;This was a pretty obvious missing feature of the library: the rest of &lt;code&gt;sqlite-utils&lt;/code&gt; deals with rows that are represented as dictionaries - you pass a list of Python dictionaries to &lt;code&gt;db[table_name].insert_all(list_of_dicts)&lt;/code&gt; to create a table with the correct schema, for example. But if you wanted to execute &lt;code&gt;SELECT&lt;/code&gt; queries you had to use &lt;code&gt;db.execute()&lt;/code&gt; which would return a standard library cursor object which could then return tuples if you called &lt;code&gt;.fetchall()&lt;/code&gt; on it.&lt;/p&gt;
&lt;p&gt;It was only when I started to work on an interactive Jupyter notebook tutorial for &lt;code&gt;sqlite-utils&lt;/code&gt; that I realized how weird it was not to have an equivalent method for reading data out of the database again.&lt;/p&gt;
&lt;p&gt;Here's what the new method looks like:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;Database&lt;/span&gt;(&lt;span class="pl-s1"&gt;memory&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)
&lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"dogs"&lt;/span&gt;].&lt;span class="pl-en"&gt;insert_all&lt;/span&gt;([
    {&lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Cleo"&lt;/span&gt;},
    {&lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Pancakes"&lt;/span&gt;}
])
&lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;row&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-en"&gt;query&lt;/span&gt;(&lt;span class="pl-s"&gt;"select * from dogs"&lt;/span&gt;):
    &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;row&lt;/span&gt;)
&lt;span class="pl-c"&gt;# Outputs:&lt;/span&gt;
&lt;span class="pl-c"&gt;# {'name': 'Cleo'}&lt;/span&gt;
&lt;span class="pl-c"&gt;# {'name': 'Pancakes'}&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#db-query-sql-params"&gt;Full documentation here&lt;/a&gt;.&lt;/p&gt;
&lt;h4&gt;asgi-csrf and a Datasette alpha&lt;/h4&gt;
&lt;p&gt;I'm building a custom Datasette integration for a consulting client at the moment which needs to be able to accept &lt;code&gt;POST&lt;/code&gt; form data as part of an API. Datasette has &lt;a href="https://docs.datasette.io/en/stable/internals.html#csrf-protection"&gt;CSRF protection&lt;/a&gt; but for this particular project I need to opt-out of that protection for this one endpoint.&lt;/p&gt;
&lt;p&gt;I ended up releasing &lt;a href="https://github.com/simonw/asgi-csrf/releases/tag/0.9"&gt;asgi-csrf 0.9&lt;/a&gt; with a new &lt;code&gt;skip_if_scope=&lt;/code&gt; mechanism for dynamically disabling CSRF protection based on the incoming ASGI scope. I then shipped a &lt;a href="https://github.com/simonw/datasette/releases/tag/0.58a1"&gt;Datasette 0.58a1&lt;/a&gt; alpha release with a new &lt;a href="https://docs.datasette.io/en/latest/plugin_hooks.html#plugin-hook-skip-csrf"&gt;skip_csrf(datasette, scope)&lt;/a&gt; plugin hook for plugins to take advantage of that mechanism.&lt;/p&gt;
&lt;p&gt;Expect another alpha release shortly to preview the new &lt;a href="https://github.com/simonw/datasette/issues/1384"&gt;get_metadata plugin hook&lt;/a&gt; contributed by Brandon Roberts. I've decided that alphas are the ideal way to explore new plugin hooks while they are still being developed as it lets projects &lt;code&gt;pip install&lt;/code&gt; the alpha while making it clear that the interface may not yet be fully baked.&lt;/p&gt;
&lt;h4&gt;Open-sourcing VIAL&lt;/h4&gt;
&lt;p&gt;VIAL is the project I've been working on for VaccinateCA/VaccinateTheStates - see &lt;a href="https://simonwillison.net/tags/vaccinateca/"&gt;previous posts&lt;/a&gt;. It's a Django application which powers a crowd-sourced and scraper-driven effort to catalogue all of the places in the USA that you can get the Covid vaccine - 77,000 and counting right now.&lt;/p&gt;
&lt;p&gt;We had always intended to open-source the code and now we have! &lt;a href="https://github.com/CAVaccineInventory/vial"&gt;github.com/CAVaccineInventory/vial&lt;/a&gt; is the newly-made-public repository.&lt;/p&gt;
&lt;p&gt;I still need to produce a bunch of extra documentation about VIAL, likely including a video introduction to the project. But it's great to have it out there!&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/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/sqlite-utils/releases/tag/3.12"&gt;3.12&lt;/a&gt; - (&lt;a href="https://github.com/simonw/sqlite-utils/releases"&gt;80 releases total&lt;/a&gt;) - 2021-06-25
&lt;br /&gt;Python CLI utility and library for manipulating SQLite databases&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette"&gt;datasette&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/datasette/releases/tag/0.58a1"&gt;0.58a1&lt;/a&gt; - (&lt;a href="https://github.com/simonw/datasette/releases"&gt;92 releases total&lt;/a&gt;) - 2021-06-24
&lt;br /&gt;An open source multi-tool for exploring and publishing data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/asgi-csrf"&gt;asgi-csrf&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/asgi-csrf/releases/tag/0.9"&gt;0.9&lt;/a&gt; - (&lt;a href="https://github.com/simonw/asgi-csrf/releases"&gt;17 releases total&lt;/a&gt;) - 2021-06-23
&lt;br /&gt;ASGI middleware for protecting against CSRF attacks&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/reddit/scraping-reddit-json"&gt;Scraping Reddit via their JSON API&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/csrf"&gt;csrf&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/asgi"&gt;asgi&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/weeknotes"&gt;weeknotes&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/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="csrf"/><category term="datasette"/><category term="asgi"/><category term="weeknotes"/><category term="sqlite-utils"/><category term="vaccinate-ca"/></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>Adding GeoDjango to an existing Django project</title><link href="https://simonwillison.net/2021/May/3/adding-geodjango-to-an-existing-django-project/#atom-tag" rel="alternate"/><published>2021-05-03T06:38:12+00:00</published><updated>2021-05-03T06:38:12+00:00</updated><id>https://simonwillison.net/2021/May/3/adding-geodjango-to-an-existing-django-project/#atom-tag</id><summary type="html">
    &lt;p&gt;Work on VIAL for &lt;a href="https://www.vaccinatethestates.com/"&gt;Vaccinate The States&lt;/a&gt; continues.&lt;/p&gt;
&lt;p&gt;I talked about &lt;a href="https://simonwillison.net/2021/Apr/26/vaccinate-the-states/"&gt;matching&lt;/a&gt; last week. I've been building more features to support figuring out if a newly detected location is already listed or not, with one of the most significant being the ability to search for locations within a radius of a specific point.&lt;/p&gt;
&lt;p&gt;I've experimented with a PostgreSQL/Django version of &lt;a href="https://til.simonwillison.net/postgresql/closest-locations-to-a-point"&gt;the classic cos/sin/radians query for this&lt;/a&gt; but if you're going to do this over a larger dataset it's worth using a proper spatial index for it - and &lt;a href="https://docs.djangoproject.com/en/3.2/ref/contrib/gis/"&gt;GeoDjango&lt;/a&gt; has provided tools for this since Django 1.0 in 2008!&lt;/p&gt;
&lt;p&gt;I have to admit that outside of a few prototypes I've never used GeoDjango extensively myself - partly I've not had the right project for it, and in the past I've also been put off by the difficulty involved in installing all of the components.&lt;/p&gt;
&lt;p&gt;That's a lot easier in 2021 than it was in 2008. But VIAL is a project in-flight, so here are some notes on what it took to get GeoDjango added to an existing Django project.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/alexmv"&gt;Alex Vandiver&lt;/a&gt; has been working with me on VIAL and helped figure out quite a few of these steps.&lt;/p&gt;
&lt;h4&gt;Activating PostgreSQL&lt;/h4&gt;
&lt;p&gt;The first step was to install the PostGIS PostgreSQL extension. This can be achieved using a Django migration:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;django&lt;/span&gt;.&lt;span class="pl-s1"&gt;contrib&lt;/span&gt;.&lt;span class="pl-s1"&gt;postgres&lt;/span&gt;.&lt;span class="pl-s1"&gt;operations&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;CreateExtension&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;django&lt;/span&gt;.&lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;migrations&lt;/span&gt;


&lt;span class="pl-k"&gt;class&lt;/span&gt; &lt;span class="pl-v"&gt;Migration&lt;/span&gt;(&lt;span class="pl-s1"&gt;migrations&lt;/span&gt;.&lt;span class="pl-v"&gt;Migration&lt;/span&gt;):

    &lt;span class="pl-s1"&gt;dependencies&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; [
        (&lt;span class="pl-s"&gt;"my_app"&lt;/span&gt;, &lt;span class="pl-s"&gt;"0108_previous-migration"&lt;/span&gt;),
    ]

    &lt;span class="pl-s1"&gt;operations&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; [
        &lt;span class="pl-v"&gt;CreateExtension&lt;/span&gt;(&lt;span class="pl-s"&gt;"postgis"&lt;/span&gt;),
    ]&lt;/pre&gt;
&lt;p&gt;Most good PostgreSQL hosting already makes this extension available - in our case we are using Google Cloud SQL which &lt;a href="https://cloud.google.com/sql/docs/postgres/extensions#postgresql-extensions-supported-by-cloud-sql"&gt;supports various extensions&lt;/a&gt;, including PostGIS. I use &lt;a href="https://postgresapp.com/"&gt;Postgres.app&lt;/a&gt; for my personal development environment which bundles PostGIS too.&lt;/p&gt;
&lt;p&gt;So far, so painless!&lt;/p&gt;
&lt;h4&gt;System packages needed by GeoDjango&lt;/h4&gt;
&lt;p&gt;GeoDjango &lt;a href="https://docs.djangoproject.com/en/3.2/ref/contrib/gis/install/geolibs/"&gt;needs the GEOS, GDAL and PROJ&lt;/a&gt; system libraries. Alex added these to our Dockerfile (used for our production deployments) like so:&lt;/p&gt;
&lt;div class="highlight highlight-source-dockerfile"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;RUN&lt;/span&gt; apt-get update &amp;amp;&amp;amp; apt-get install -y \
    binutils \
    gdal-bin \
    libproj-dev \
    &amp;amp;&amp;amp; rm -rf /var/lib/apt/lists/*&lt;/pre&gt;&lt;/div&gt;
&lt;h4&gt;Adding a point field to a Django model&lt;/h4&gt;
&lt;p&gt;I already had a &lt;code&gt;Location&lt;/code&gt; model, which looked something like this:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;class&lt;/span&gt; &lt;span class="pl-v"&gt;Location&lt;/span&gt;(&lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;Model&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;name&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;CharField&lt;/span&gt;()
    &lt;span class="pl-c"&gt;# ...&lt;/span&gt;
    &lt;span class="pl-s1"&gt;latitude&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;DecimalField&lt;/span&gt;(
        &lt;span class="pl-s1"&gt;max_digits&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;9&lt;/span&gt;, &lt;span class="pl-s1"&gt;decimal_places&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;5&lt;/span&gt;
    )
    &lt;span class="pl-s1"&gt;longitude&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt;.&lt;span class="pl-v"&gt;DecimalField&lt;/span&gt;(
        &lt;span class="pl-s1"&gt;max_digits&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;9&lt;/span&gt;, &lt;span class="pl-s1"&gt;decimal_places&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;5&lt;/span&gt;
    )&lt;/pre&gt;
&lt;p&gt;I made three changes to this class: I changed the base class to this:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;django&lt;/span&gt;.&lt;span class="pl-s1"&gt;contrib&lt;/span&gt;.&lt;span class="pl-s1"&gt;gis&lt;/span&gt;.&lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;models&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; &lt;span class="pl-s1"&gt;gis_models&lt;/span&gt;

&lt;span class="pl-k"&gt;class&lt;/span&gt; &lt;span class="pl-v"&gt;Location&lt;/span&gt;(&lt;span class="pl-s1"&gt;gis_models&lt;/span&gt;.&lt;span class="pl-v"&gt;Model&lt;/span&gt;):
    &lt;span class="pl-c"&gt;# ...&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;I added a &lt;code&gt;point&lt;/code&gt; column:&lt;/p&gt;
&lt;pre&gt;    &lt;span class="pl-s1"&gt;point&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;gis_models&lt;/span&gt;.&lt;span class="pl-v"&gt;PointField&lt;/span&gt;(
        &lt;span class="pl-s1"&gt;blank&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;,
        &lt;span class="pl-s1"&gt;null&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;,
        &lt;span class="pl-s1"&gt;spatial_index&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;
    )&lt;/pre&gt;
&lt;p&gt;And I set up a custom &lt;code&gt;save()&lt;/code&gt; method to populate that &lt;code&gt;point&lt;/code&gt; field with a point representing the latitude and longitude every time the object was saved:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;django&lt;/span&gt;.&lt;span class="pl-s1"&gt;contrib&lt;/span&gt;.&lt;span class="pl-s1"&gt;gis&lt;/span&gt;.&lt;span class="pl-s1"&gt;geos&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;Point&lt;/span&gt;

&lt;span class="pl-c"&gt;# ...&lt;/span&gt;

    &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;save&lt;/span&gt;(&lt;span class="pl-s1"&gt;self&lt;/span&gt;, &lt;span class="pl-c1"&gt;*&lt;/span&gt;&lt;span class="pl-s1"&gt;args&lt;/span&gt;, &lt;span class="pl-c1"&gt;**&lt;/span&gt;&lt;span class="pl-s1"&gt;kwargs&lt;/span&gt;):
        &lt;span class="pl-c"&gt;# Point is derived from latitude/longitude&lt;/span&gt;
        &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;longitude&lt;/span&gt; &lt;span class="pl-c1"&gt;and&lt;/span&gt; &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;latitude&lt;/span&gt;:
            &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;point&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;Point&lt;/span&gt;(
                &lt;span class="pl-en"&gt;float&lt;/span&gt;(&lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;longitude&lt;/span&gt;),
                &lt;span class="pl-en"&gt;float&lt;/span&gt;(&lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;latitude&lt;/span&gt;),
                &lt;span class="pl-s1"&gt;srid&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;4326&lt;/span&gt;
            )
        &lt;span class="pl-k"&gt;else&lt;/span&gt;:
            &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-s1"&gt;point&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;None&lt;/span&gt;
        &lt;span class="pl-en"&gt;super&lt;/span&gt;().&lt;span class="pl-en"&gt;save&lt;/span&gt;(&lt;span class="pl-c1"&gt;*&lt;/span&gt;&lt;span class="pl-s1"&gt;args&lt;/span&gt;, &lt;span class="pl-c1"&gt;**&lt;/span&gt;&lt;span class="pl-s1"&gt;kwargs&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;srid=4326&lt;/code&gt; ensures the point is stored using WGS84 - the most common coordinate system for latitude and longitude values across our planet.&lt;/p&gt;
&lt;p&gt;Running &lt;code&gt;./manage.py makemigrations&lt;/code&gt; identified the new &lt;code&gt;point&lt;/code&gt; Point column and created the corresponding migration for me.&lt;/p&gt;
&lt;h4&gt;Backfilling the point column with a migration&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;.save()&lt;/code&gt; method would populate &lt;code&gt;point&lt;/code&gt; for changes going forward, but I had 40,000 records that already existed which I needed to backfill. I used this migration to do that:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;django&lt;/span&gt;.&lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;migrations&lt;/span&gt;

&lt;span class="pl-k"&gt;class&lt;/span&gt; &lt;span class="pl-v"&gt;Migration&lt;/span&gt;(&lt;span class="pl-s1"&gt;migrations&lt;/span&gt;.&lt;span class="pl-v"&gt;Migration&lt;/span&gt;):

    &lt;span class="pl-s1"&gt;dependencies&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; [
        (&lt;span class="pl-s"&gt;"core"&lt;/span&gt;, &lt;span class="pl-s"&gt;"0110_location_point"&lt;/span&gt;),
    ]

    &lt;span class="pl-s1"&gt;operations&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; [
        &lt;span class="pl-s1"&gt;migrations&lt;/span&gt;.&lt;span class="pl-v"&gt;RunSQL&lt;/span&gt;(
            &lt;span class="pl-s1"&gt;sql&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"""&lt;/span&gt;
&lt;span class="pl-s"&gt;            update location&lt;/span&gt;
&lt;span class="pl-s"&gt;            set point = ST_SetSRID(&lt;/span&gt;
&lt;span class="pl-s"&gt;                ST_MakePoint(&lt;/span&gt;
&lt;span class="pl-s"&gt;                    longitude, latitude&lt;/span&gt;
&lt;span class="pl-s"&gt;                ),&lt;/span&gt;
&lt;span class="pl-s"&gt;                4326&lt;/span&gt;
&lt;span class="pl-s"&gt;            );"""&lt;/span&gt;,
            &lt;span class="pl-s1"&gt;reverse_sql&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;migrations&lt;/span&gt;.&lt;span class="pl-v"&gt;RunSQL&lt;/span&gt;.&lt;span class="pl-s1"&gt;noop&lt;/span&gt;,
        )
    ]&lt;/pre&gt;
&lt;h4&gt;latitude/longitude/radius queries&lt;/h4&gt;
&lt;p&gt;With the new &lt;code&gt;point&lt;/code&gt; column created and populated, here's the code I wrote to support simple latitude/longitude/radius queries:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;django&lt;/span&gt;.&lt;span class="pl-s1"&gt;contrib&lt;/span&gt;.&lt;span class="pl-s1"&gt;gis&lt;/span&gt;.&lt;span class="pl-s1"&gt;geos&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;Point&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;django&lt;/span&gt;.&lt;span class="pl-s1"&gt;contrib&lt;/span&gt;.&lt;span class="pl-s1"&gt;gis&lt;/span&gt;.&lt;span class="pl-s1"&gt;measure&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;Distance&lt;/span&gt;

&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;search_locations&lt;/span&gt;(&lt;span class="pl-s1"&gt;request&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;qs&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;Location&lt;/span&gt;.&lt;span class="pl-s1"&gt;objects&lt;/span&gt;.&lt;span class="pl-en"&gt;filter&lt;/span&gt;(&lt;span class="pl-s1"&gt;soft_deleted&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;False&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;latitude&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;request&lt;/span&gt;.&lt;span class="pl-v"&gt;GET&lt;/span&gt;.&lt;span class="pl-en"&gt;get&lt;/span&gt;(&lt;span class="pl-s"&gt;"latitude"&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;longitude&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;request&lt;/span&gt;.&lt;span class="pl-v"&gt;GET&lt;/span&gt;.&lt;span class="pl-en"&gt;get&lt;/span&gt;(&lt;span class="pl-s"&gt;"longitude"&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;radius&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;request&lt;/span&gt;.&lt;span class="pl-v"&gt;GET&lt;/span&gt;.&lt;span class="pl-en"&gt;get&lt;/span&gt;(&lt;span class="pl-s"&gt;"radius"&lt;/span&gt;)
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;latitude&lt;/span&gt; &lt;span class="pl-c1"&gt;and&lt;/span&gt; &lt;span class="pl-s1"&gt;longitude&lt;/span&gt; &lt;span class="pl-c1"&gt;and&lt;/span&gt; &lt;span class="pl-s1"&gt;radius&lt;/span&gt;:
        &lt;span class="pl-c"&gt;# Validate latitude/longitude/radius&lt;/span&gt;
        &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;value&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; (&lt;span class="pl-s1"&gt;latitude&lt;/span&gt;, &lt;span class="pl-s1"&gt;longitude&lt;/span&gt;, &lt;span class="pl-s1"&gt;radius&lt;/span&gt;):
            &lt;span class="pl-k"&gt;try&lt;/span&gt;:
                &lt;span class="pl-en"&gt;float&lt;/span&gt;(&lt;span class="pl-s1"&gt;value&lt;/span&gt;)
            &lt;span class="pl-k"&gt;except&lt;/span&gt; &lt;span class="pl-v"&gt;ValueError&lt;/span&gt;:
                &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-v"&gt;JsonResponse&lt;/span&gt;(
                    {&lt;span class="pl-s"&gt;"error"&lt;/span&gt;: &lt;span class="pl-s"&gt;"latitude/longitude/radius should be numbers"&lt;/span&gt;}, &lt;span class="pl-s1"&gt;status&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;400&lt;/span&gt;
                )
        &lt;span class="pl-s1"&gt;qs&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;qs&lt;/span&gt;.&lt;span class="pl-en"&gt;filter&lt;/span&gt;(
            &lt;span class="pl-s1"&gt;point__distance_lt&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;(
                &lt;span class="pl-v"&gt;Point&lt;/span&gt;(
                    &lt;span class="pl-en"&gt;float&lt;/span&gt;(&lt;span class="pl-s1"&gt;longitude&lt;/span&gt;),
                    &lt;span class="pl-en"&gt;float&lt;/span&gt;(&lt;span class="pl-s1"&gt;latitude&lt;/span&gt;)
                ),
                &lt;span class="pl-v"&gt;Distance&lt;/span&gt;(&lt;span class="pl-s1"&gt;m&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-en"&gt;float&lt;/span&gt;(&lt;span class="pl-s1"&gt;radius&lt;/span&gt;)),
            )
        )
    &lt;span class="pl-c"&gt;# ... return JSON for locations&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;In writing up these notes I realize that this isn't actually the best way to do this, because it fails to take advantage of the spatial index on that column! I've filed myself an issue to switch to the spatial-index-friendly &lt;a href="https://docs.djangoproject.com/en/3.1/ref/contrib/gis/geoquerysets/#dwithin"&gt;dwithin&lt;/a&gt; instead.&lt;/p&gt;
&lt;h4&gt;Getting CI to work&lt;/h4&gt;
&lt;p&gt;The hardest part of all of this turned out to be getting our CI suites to pass.&lt;/p&gt;
&lt;p&gt;We run CI in two places at the moment: GitHub Actions and Google Cloud Build (as part of our continuous deployment setup).&lt;/p&gt;
&lt;p&gt;The first error I hit was this one:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;psycopg2.errors.UndefinedFile: could not open extension control file "/usr/share/postgresql/13/extension/postgis.control": No such file or directory&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It turns out that's what happens when your PostgreSQL server doesn't have the PostGIS extension available.&lt;/p&gt;
&lt;p&gt;Our GitHub Actions configuration started like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;&lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Run tests&lt;/span&gt;

&lt;span class="pl-ent"&gt;on&lt;/span&gt;: &lt;span class="pl-s"&gt;[push]&lt;/span&gt;

&lt;span class="pl-ent"&gt;jobs&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;test&lt;/span&gt;:
    &lt;span class="pl-ent"&gt;runs-on&lt;/span&gt;: &lt;span class="pl-s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="pl-ent"&gt;services&lt;/span&gt;:
      &lt;span class="pl-ent"&gt;postgres&lt;/span&gt;:
        &lt;span class="pl-ent"&gt;image&lt;/span&gt;: &lt;span class="pl-s"&gt;postgres:13&lt;/span&gt;
        &lt;span class="pl-ent"&gt;env&lt;/span&gt;:
          &lt;span class="pl-ent"&gt;POSTGRES_USER&lt;/span&gt;: &lt;span class="pl-s"&gt;postgres&lt;/span&gt;
          &lt;span class="pl-ent"&gt;POSTGRES_PASSWORD&lt;/span&gt;: &lt;span class="pl-s"&gt;postgres&lt;/span&gt;
          &lt;span class="pl-ent"&gt;POSTGRES_DB&lt;/span&gt;: &lt;span class="pl-s"&gt;vaccinate&lt;/span&gt;
        &lt;span class="pl-ent"&gt;options&lt;/span&gt;:
          &lt;span class="pl-s"&gt;--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5&lt;/span&gt;
        &lt;span class="pl-ent"&gt;ports&lt;/span&gt;:
        - &lt;span class="pl-c1"&gt;5432:5432&lt;/span&gt;
    &lt;span class="pl-ent"&gt;steps&lt;/span&gt;:&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;postgres:13&lt;/code&gt; image doesn't have PostGIS. Swapping that out for &lt;code&gt;postgis/postgis:13-3.1&lt;/code&gt; fixed that (using &lt;a href="https://registry.hub.docker.com/r/postgis/postgis/"&gt;this image&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;Our Cloud Build configuration included this:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;  &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Start up a postgres for tests&lt;/span&gt;
  - &lt;span class="pl-ent"&gt;id&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;start postgres&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;gcr.io/cloud-builders/docker&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-ent"&gt;args&lt;/span&gt;:
      - &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;run&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;-d&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;--network=cloudbuild&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;-e&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;POSTGRES_HOST_AUTH_METHOD=trust&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;--name&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;vaccinate-db&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;postgres&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;

  - &lt;span class="pl-ent"&gt;id&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;test image&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;gcr.io/cloud-builders/docker&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-ent"&gt;args&lt;/span&gt;:
      - &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;run&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;-t&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;--network=cloudbuild&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;-e&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;DATABASE_URL=postgres://postgres@vaccinate-db:5432/vaccinate&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;${_IMAGE_NAME}:latest&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;pytest&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;-v&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I tried swapping out that last &lt;code&gt;postgres&lt;/code&gt; argument for &lt;code&gt;postgis/postgis:13-3.1&lt;/code&gt;, like I had with the GitHub Actions one... and it failed with this error instead:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;django.db.utils.OperationalError: could not connect to server: Connection refused&lt;/code&gt;
&lt;code&gt;Is the server running on host "vaccinate-db" (192.168.10.3) and accepting&lt;/code&gt;
&lt;code&gt;TCP/IP connections on port 5432?&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This one stumped me. Eventually Alex figured out the problem: the extra extension meant the PostgreSQL was taking slightly longer to start - something that was covered in our GitHub Actions configuration by the &lt;code&gt;pg_isready&lt;/code&gt; line. He added this step to our Cloud Build configuration:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;  - &lt;span class="pl-ent"&gt;id&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;wait for postgres&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;jwilder/dockerize&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-ent"&gt;args&lt;/span&gt;: &lt;span class="pl-s"&gt;["dockerize", "-timeout=60s", "-wait=tcp://vaccinate-db:5432"]&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;It uses &lt;a href="https://github.com/jwilder/dockerize"&gt;jwilder/dockerize&lt;/a&gt; to wait until the database container starts accepting connections on port 5432.&lt;/p&gt;
&lt;h4&gt;Next steps&lt;/h4&gt;
&lt;p&gt;Now that we have GeoDjango I'm excited to start exploring new capabilities for our software. One thing in particular that interests me is teaching VIAL to backfill the county for a location based on its latitude and longitude - the US Census provide a shapefile of county polygons which I use with Datasette and SpatiaLite in my &lt;a href="https://github.com/simonw/us-counties-datasette"&gt;simonw/us-counties-datasette&lt;/a&gt; project, so I'm confident it would work well using PostGIS instead.&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.11a0"&gt;0.11a0&lt;/a&gt; - (&lt;a href="https://github.com/simonw/django-sql-dashboard/releases"&gt;22 total releases&lt;/a&gt;) - 2021-04-26
&lt;br /&gt;Django app for building dashboards using raw SQL queries&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://github.com/simonw/til/blob/main/django/migrations-runsql-noop.md"&gt;migrations.RunSQL.noop for reversible SQL migrations&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/til/blob/main/datasette/datasette-on-replit.md"&gt;Running Datasette on Replit&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/geodjango"&gt;geodjango&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/geospatial"&gt;geospatial&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/postgresql"&gt;postgresql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/weeknotes"&gt;weeknotes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="django"/><category term="geodjango"/><category term="geospatial"/><category term="postgresql"/><category term="weeknotes"/><category term="vaccinate-ca"/></entry><entry><title>A CSV export, JSON import workflow for bulk updating our data</title><link href="https://simonwillison.net/2021/Apr/28/vaccinateca-2021-04-28/#atom-tag" rel="alternate"/><published>2021-04-28T17:00:00+00:00</published><updated>2021-04-28T17:00:00+00:00</updated><id>https://simonwillison.net/2021/Apr/28/vaccinateca-2021-04-28/#atom-tag</id><summary type="html">
    &lt;p class="context"&gt;&lt;em&gt;Originally posted to my internal blog at VaccinateCA&lt;/em&gt;&lt;/p&gt;&lt;p&gt;I just added missing counties to around 1200 of our locations using a combination of tricks, and I thought they'd make a good blog post.&lt;/p&gt;
&lt;p&gt;County is an optional field on our location model, and we sometimes import new locations without attaching a county to them.&lt;/p&gt;
&lt;h4&gt;
How big is the problem?&lt;/h4&gt;
&lt;p&gt;I ran this SQL query in &lt;a href="https://vial.calltheshots.us/dashboard/" rel="nofollow"&gt;our dashboard&lt;/a&gt; to figure out how bad the problem was:&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;state&lt;/span&gt;.&lt;span class="pl-c1"&gt;name&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; number_of_locations_with_no_county
&lt;span class="pl-k"&gt;from&lt;/span&gt;
  location &lt;span class="pl-k"&gt;join&lt;/span&gt; state &lt;span class="pl-k"&gt;on&lt;/span&gt; &lt;span class="pl-c1"&gt;state&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;location&lt;/span&gt;.&lt;span class="pl-c1"&gt;state_id&lt;/span&gt;
&lt;span class="pl-k"&gt;where&lt;/span&gt;
  &lt;span class="pl-c1"&gt;location&lt;/span&gt;.&lt;span class="pl-c1"&gt;county_id&lt;/span&gt; is &lt;span class="pl-k"&gt;null&lt;/span&gt;
&lt;span class="pl-k"&gt;group by&lt;/span&gt;
  &lt;span class="pl-c1"&gt;state&lt;/span&gt;.&lt;span class="pl-c1"&gt;name&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I got back this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name        number_of_locations_with_no_county
Arkansas    1
Arizona     90
California  1
Oregon      1113
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I decided to start with Oregon, since I knew that was my fault (I imported a bunch of data for Oregon over a month ago and clearly didn't bother with counties).&lt;/p&gt;
&lt;h4&gt;
Exporting the bad data as CSV&lt;/h4&gt;
&lt;p&gt;I used another dashboard query to export the location public ID, latitude and longitude for every location in Oregon that was missing a county:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt;
  public_id, latitude, longitude
&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; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Oregon&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
  )
  &lt;span class="pl-k"&gt;and&lt;/span&gt; county_id is &lt;span class="pl-k"&gt;null&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I exported those results as CSV, then switched into a Jupyter notebook running on my laptop.&lt;/p&gt;
&lt;h4&gt;
The full script&lt;/h4&gt;
&lt;p&gt;Here's the script I ran there:&lt;/p&gt;
&lt;div class="highlight highlight-source-python"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;csv&lt;/span&gt;
&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;httpx&lt;/span&gt;
&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;json&lt;/span&gt;

&lt;span class="pl-s1"&gt;rows&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;list&lt;/span&gt;(&lt;span class="pl-s1"&gt;csv&lt;/span&gt;.&lt;span class="pl-v"&gt;DictReader&lt;/span&gt;(&lt;span class="pl-en"&gt;open&lt;/span&gt;(&lt;span class="pl-s"&gt;"select-public-id--latitude--lof05de6.csv"&lt;/span&gt;)))

&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;county_for_point&lt;/span&gt;(&lt;span class="pl-s1"&gt;latitude&lt;/span&gt;, &lt;span class="pl-s1"&gt;longitude&lt;/span&gt;):
    &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;httpx&lt;/span&gt;.&lt;span class="pl-en"&gt;get&lt;/span&gt;(&lt;span class="pl-s"&gt;"https://us-counties.datasette.io/counties/county_for_latitude_longitude.json?_shape=array"&lt;/span&gt;, &lt;span class="pl-s1"&gt;params&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;{
        &lt;span class="pl-s"&gt;"latitude"&lt;/span&gt;: &lt;span class="pl-s1"&gt;latitude&lt;/span&gt;,
        &lt;span class="pl-s"&gt;"longitude"&lt;/span&gt;: &lt;span class="pl-s1"&gt;longitude&lt;/span&gt;,
    }).&lt;span class="pl-en"&gt;json&lt;/span&gt;()[&lt;span class="pl-c1"&gt;0&lt;/span&gt;][&lt;span class="pl-s"&gt;'county_name'&lt;/span&gt;]

&lt;span class="pl-s1"&gt;counties&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; {}

&lt;span class="pl-c"&gt;# Progress bar:&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;tqdm&lt;/span&gt;.&lt;span class="pl-s1"&gt;notebook&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;tqdm&lt;/span&gt;

&lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;row&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-en"&gt;tqdm&lt;/span&gt;(&lt;span class="pl-s1"&gt;rows&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;counties&lt;/span&gt;[&lt;span class="pl-s1"&gt;row&lt;/span&gt;[&lt;span class="pl-s"&gt;"public_id"&lt;/span&gt;]] &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;county_for_point&lt;/span&gt;(
        &lt;span class="pl-s1"&gt;row&lt;/span&gt;[&lt;span class="pl-s"&gt;"latitude"&lt;/span&gt;], &lt;span class="pl-s1"&gt;row&lt;/span&gt;[&lt;span class="pl-s"&gt;"longitude"&lt;/span&gt;]
    )

&lt;span class="pl-s1"&gt;update&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; {
  &lt;span class="pl-s"&gt;"update"&lt;/span&gt;: {
    &lt;span class="pl-s1"&gt;key&lt;/span&gt;: {
      &lt;span class="pl-s"&gt;"state"&lt;/span&gt;: &lt;span class="pl-s"&gt;"OR"&lt;/span&gt;,
      &lt;span class="pl-s"&gt;"county"&lt;/span&gt;: &lt;span class="pl-s1"&gt;value&lt;/span&gt;
    } &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;key&lt;/span&gt;, &lt;span class="pl-s1"&gt;value&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;counties&lt;/span&gt;.&lt;span class="pl-en"&gt;items&lt;/span&gt;()
  },
  &lt;span class="pl-s"&gt;"revision_comment"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Added missing counties"&lt;/span&gt;
}

&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;json&lt;/span&gt;.&lt;span class="pl-en"&gt;dumps&lt;/span&gt;(&lt;span class="pl-s1"&gt;update&lt;/span&gt;))&lt;/pre&gt;&lt;/div&gt;
&lt;h4&gt;
Finding the county for a latitude/longitude point&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;county_for_point()&lt;/code&gt; function uses my &lt;a href="https://us-counties.datasette.io/" rel="nofollow"&gt;us-counties.datasette.io&lt;/a&gt; API, which provides an API for looking up a county based on a latitude and longitude based on US Census shapefiles - source code for that is at &lt;a href="https://github.com/simonw/us-counties-datasette"&gt;simonw/us-counties-datasette&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;You can try the API out here: &lt;a href="https://us-counties.datasette.io/counties/county_for_latitude_longitude?longitude=-122.676968&amp;amp;latitude=45.266289" rel="nofollow"&gt;https://us-counties.datasette.io/counties/county_for_latitude_longitude?longitude=-122.676968&amp;amp;latitude=45.266289&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Add &lt;code&gt;.json&lt;/code&gt; and &lt;code&gt;?_shape=array&lt;/code&gt; to get the result as a JSON array:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://us-counties.datasette.io/counties/county_for_latitude_longitude.json?longitude=-122.676968&amp;amp;latitude=45.266289&amp;amp;_shape=array" rel="nofollow"&gt;https://us-counties.datasette.io/counties/county_for_latitude_longitude.json?longitude=-122.676968&amp;amp;latitude=45.266289&amp;amp;_shape=array&lt;/a&gt;&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;state_fips&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;41&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;state&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;OR&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;county_fips&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;41005&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;county_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;Clackamas&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;COUNTYNS&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;01155127&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;AFFGEOID&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;0500000US41005&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;GEOID&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;41005&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;LSAD&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;06&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;ALAND&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-c1"&gt;4845034547&lt;/span&gt;,
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;AWATER&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-c1"&gt;31873103&lt;/span&gt;
  }
]&lt;/pre&gt;&lt;/div&gt;
&lt;h4&gt;
Progress bars in Jupyter&lt;/h4&gt;
&lt;p&gt;I used &lt;a href="https://github.com/tqdm/tqdm"&gt;tqdm&lt;/a&gt; to display a progress bar inside my Jupyter notebook. It makes it easy to wrap an iterable Python object in a way that shows a progress bar as it processes the list:&lt;/p&gt;
&lt;div class="highlight highlight-source-python"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;tqdm&lt;/span&gt;.&lt;span class="pl-s1"&gt;notebook&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;tqdm&lt;/span&gt;

&lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;row&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-en"&gt;tqdm&lt;/span&gt;(&lt;span class="pl-s1"&gt;rows&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;counties&lt;/span&gt;[&lt;span class="pl-s1"&gt;row&lt;/span&gt;[&lt;span class="pl-s"&gt;"public_id"&lt;/span&gt;]] &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;county_for_point&lt;/span&gt;(
        &lt;span class="pl-s1"&gt;row&lt;/span&gt;[&lt;span class="pl-s"&gt;"latitude"&lt;/span&gt;], &lt;span class="pl-s1"&gt;row&lt;/span&gt;[&lt;span class="pl-s"&gt;"longitude"&lt;/span&gt;]
    )&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;img alt="tqdm" src="https://user-images.githubusercontent.com/9599/116505382-9afc6300-a86f-11eb-894a-ded1d22b9ab5.gif" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;h4&gt;
Building JSON to send to our API&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;/api/updateLocations&lt;/code&gt; API is &lt;a href="https://vial.calltheshots.us/api/docs#post-apiupdatelocations" rel="nofollow"&gt;documented here&lt;/a&gt;. It's a bulk update API which accepts a JSON body looking something 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;update&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;rec9Zc6A08cEWyNpR&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: {
      &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;name&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Berkeley Clinic II&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;lgzgq&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;phone_number&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;(555) 555-5551&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;revision_comment&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;New details&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;It lets you apply a partial update to a whole list of locations at once.&lt;/p&gt;
&lt;p&gt;In our case we only want to update the county (though we need to send the state too in order to correctly validate that the county name belongs to that state). My Python code to generate the update document looks like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-python"&gt;&lt;pre&gt;&lt;span class="pl-s1"&gt;update&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; {
  &lt;span class="pl-s"&gt;"update"&lt;/span&gt;: {
    &lt;span class="pl-s1"&gt;key&lt;/span&gt;: {
      &lt;span class="pl-s"&gt;"state"&lt;/span&gt;: &lt;span class="pl-s"&gt;"OR"&lt;/span&gt;,
      &lt;span class="pl-s"&gt;"county"&lt;/span&gt;: &lt;span class="pl-s1"&gt;value&lt;/span&gt;
    } &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;key&lt;/span&gt;, &lt;span class="pl-s1"&gt;value&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;counties&lt;/span&gt;.&lt;span class="pl-en"&gt;items&lt;/span&gt;()
  },
  &lt;span class="pl-s"&gt;"revision_comment"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Added missing counties"&lt;/span&gt;
}

&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;json&lt;/span&gt;.&lt;span class="pl-en"&gt;dumps&lt;/span&gt;(&lt;span class="pl-s1"&gt;update&lt;/span&gt;))&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;A pretty-printed subset of the output 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;update&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;lrzkd&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;state&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;AZ&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;county&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;Maricopa&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;lrzmg&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;state&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;AZ&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;county&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;Pima&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;lrzmr&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;state&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;AZ&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;county&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;Cochise&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;revision_comment&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;Added missing counties&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
}&lt;/pre&gt;&lt;/div&gt;
&lt;h4&gt;
Pasting that into our API explorer&lt;/h4&gt;
&lt;p&gt;Having generated this API update document, I used our interactive API explorer tool to execute the update:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://vial.calltheshots.us/api/updateLocations/debug" rel="nofollow"&gt;https://vial.calltheshots.us/api/updateLocations/debug&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Almost all of our API endpoints have an interface like this, which is mainly intended for trying things out but also works for making ad-hoc requests like this one.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Try_api_updateLocations" src="https://user-images.githubusercontent.com/9599/116505391-a3ed3480-a86f-11eb-87ed-c86d279db2cf.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;h4&gt;
Tying it together with a saved dashboard&lt;/h4&gt;
&lt;p&gt;I built a saved dashboard (using &lt;a href="https://vial.calltheshots.us/admin/django_sql_dashboard/dashboard/" rel="nofollow"&gt;the VIAL admin&lt;/a&gt;) called &lt;code&gt;locations-with-no-county&lt;/code&gt; to tie all of this together.&lt;/p&gt;
&lt;p&gt;Initially I thought the dashboard could be used by our web bankers to find locations missing a county - so I had it return ten random locations without a county, so multiple web bankers could use it at the same time with a low risk of overlapping efforts.&lt;/p&gt;
&lt;p&gt;Then I realized I could automate it myself, so that aspect of the dashboard turned out not to be useful - though we may well use it for smaller fixing jobs in the future.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://vial.calltheshots.us/dashboard/locations-with-no-county/" rel="nofollow"&gt;https://vial.calltheshots.us/dashboard/locations-with-no-county/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img alt="Ten_random_locations_with_no_county" src="https://user-images.githubusercontent.com/9599/116505489-e31b8580-a86f-11eb-91bf-4ce8765a049b.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;h4&gt;
Doing this by generating JSON entirely inside PostgreSQL&lt;/h4&gt;
&lt;p&gt;I used another variant of this trick last week to add concordance identifiers to our locations, using the &lt;a href="https://vial.calltheshots.us/api/docs#post-apiupdatelocationconcordances" rel="nofollow"&gt;/api/updateConcordanceLocations API&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I wanted to add a &lt;code&gt;walgreens:12134&lt;/code&gt; concordance identifier to every one of our locations that was a Walgreens. You can follow my progress in &lt;a href="https://github.com/CAVaccineInventory/vial/issues/375"&gt;this issue&lt;/a&gt; - I used this SQL query to identify all of our Walgreens locations by executing a PostgreSQL regular expression against the &lt;code&gt;name&lt;/code&gt; column, then generate the API update JSON using PostgreSQL's JSON functions:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;with extracted_idrefs &lt;span class="pl-k"&gt;as&lt;/span&gt; (
  &lt;span class="pl-k"&gt;select&lt;/span&gt;
    public_id, name,
    (regexp_match(name, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Walgreens.*#0*([0-9]{1,8})&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;i&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-k"&gt;as&lt;/span&gt; idref
  &lt;span class="pl-k"&gt;from&lt;/span&gt;
    location
  &lt;span class="pl-k"&gt;where&lt;/span&gt;
    regexp_match(name, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Walgreens.*#0*([0-9]{1,8})&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;i&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;) &lt;span class="pl-k"&gt;is not null&lt;/span&gt;
)
&lt;span class="pl-k"&gt;select&lt;/span&gt;
  json_build_object(
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;update&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;,
    json_object_agg(
      public_id,
      json_build_object(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;add&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, json_build_array(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;walgreens:&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;||&lt;/span&gt; idref), &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;comment&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, name)
    )
  )
&lt;span class="pl-k"&gt;from&lt;/span&gt;
  extracted_idrefs
&lt;span class="pl-k"&gt;where&lt;/span&gt;
  public_id not &lt;span class="pl-k"&gt;in&lt;/span&gt; (
    &lt;span class="pl-k"&gt;select&lt;/span&gt;
      public_id
    &lt;span class="pl-k"&gt;from&lt;/span&gt;
      location
    &lt;span class="pl-k"&gt;where&lt;/span&gt;
      id &lt;span class="pl-k"&gt;in&lt;/span&gt; (
        &lt;span class="pl-k"&gt;select&lt;/span&gt;
          location_id
        &lt;span class="pl-k"&gt;from&lt;/span&gt;
          concordance_location
        &lt;span class="pl-k"&gt;where&lt;/span&gt;
          concordanceidentifier_id &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;
              concordance_identifier
            &lt;span class="pl-k"&gt;where&lt;/span&gt;
              authority &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;walgreens&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
          )
      )
  )&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That last &lt;code&gt;where&lt;/code&gt; clause avoids generating JSON for locations that already have a &lt;code&gt;walgreens:X&lt;/code&gt; concordance identifier.&lt;/p&gt;
&lt;p&gt;The output of the query looks something 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;update&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;lrxzc&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;add&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;walgreens:10076&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;comment&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;Walgreens Co. #10076&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;lrxzd&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;add&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;walgreens:10082&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;comment&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;Walgreens Co. #10082&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;lrxzf&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;add&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;walgreens:10160&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;comment&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;Walgreens Co. #10160&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    }
  }
}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;As before, this can be executed directly using this API debugging tool: &lt;a href="https://vial.calltheshots.us/api/updateLocationConcordances/debug" rel="nofollow"&gt;https://vial.calltheshots.us/api/updateLocationConcordances/debug&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/vaccinate-ca-blog"&gt;vaccinate-ca-blog&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="postgresql"/><category term="vaccinate-ca"/><category term="vaccinate-ca-blog"/></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: The Aftermath</title><link href="https://simonwillison.net/2021/Apr/18/weeknotes-the-aftermath/#atom-tag" rel="alternate"/><published>2021-04-18T22:19:18+00:00</published><updated>2021-04-18T22:19:18+00:00</updated><id>https://simonwillison.net/2021/Apr/18/weeknotes-the-aftermath/#atom-tag</id><summary type="html">
    &lt;p&gt;Some tweets that effectively illustrate my week:&lt;/p&gt;

&lt;blockquote class="twitter-tweet"&gt;&lt;p lang="en" dir="ltr"&gt;&amp;quot;... and nothing broke!&amp;quot; - several days later I can confirm that a few things did indeed break, but thankfully nothing catastrophically so!&lt;/p&gt;&amp;#8212; Simon Willison (@simonw) &lt;a href="https://twitter.com/simonw/status/1383071748468342784?ref_src=twsrc%5Etfw"&gt;April 16, 2021&lt;/a&gt;&lt;/blockquote&gt;

&lt;blockquote class="twitter-tweet"&gt;&lt;p lang="en" dir="ltr"&gt;My resume: Migrated legacy Rails user model to external API in 30 days with no impact to customers.&lt;br /&gt;&lt;br /&gt;Reality: 90% migrated fine, 10% edge cases where I played whack-a-mole for an entire week and deploying multiple times daily. 😂 &lt;a href="https://t.co/L6Akkvr5pB"&gt;https://t.co/L6Akkvr5pB&lt;/a&gt;&lt;/p&gt;&amp;#8212; Damon Cortesi (@dacort) &lt;a href="https://twitter.com/dacort/status/1383102082291408905?ref_src=twsrc%5Etfw"&gt;April 16, 2021&lt;/a&gt;&lt;/blockquote&gt;

&lt;p&gt;Last week we &lt;a href="https://simonwillison.net/2021/Apr/12/porting-vaccinateca-to-django/"&gt;went live with VIAL&lt;/a&gt;, the replacement backend I've been building for &lt;a href="https://www.vaccinateca.com/"&gt;VaccinateCA&lt;/a&gt;. This meant we went from having no users to having a whole lot of users, and all of the edge-cases and missing details quickly started to emerge.&lt;/p&gt;
&lt;p&gt;So this week I've been almost exclusively working my way through those. Not much to report otherwise!&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/postgresql/json-extract-path"&gt;Using json_extract_path in PostgreSQL&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/cloudrun/listing-cloudbuild-files"&gt;Listing files uploaded to Cloud Build&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/django/migration-postgresql-fuzzystrmatch"&gt;Enabling the fuzzystrmatch extension in PostgreSQL with a Django migration&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.8a2"&gt;0.8a2&lt;/a&gt; - (&lt;a href="https://github.com/simonw/django-sql-dashboard/releases"&gt;17 total releases&lt;/a&gt;) - 2021-04-14
&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/weeknotes"&gt;weeknotes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;&lt;/p&gt;
    

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

</summary><category term="django"/><category term="django-admin"/><category term="migrations"/><category term="postgresql"/><category term="weeknotes"/><category term="airtable"/><category term="vaccinate-ca"/><category term="boring-technology"/></entry><entry><title>VIAL: Preparing for some collaborative testing</title><link href="https://simonwillison.net/2021/Apr/1/vaccinateca-2021-04-01/#atom-tag" rel="alternate"/><published>2021-04-01T17:00:00+00:00</published><updated>2021-04-01T17:00:00+00:00</updated><id>https://simonwillison.net/2021/Apr/1/vaccinateca-2021-04-01/#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;With the Airtable limits fast approaching, I'm going to start leaning heavily on people to help verify that VIAL can do the jobs that it needs to do.&lt;/p&gt;
&lt;p&gt;I have a few features that I need to land first, but over the weekend I'll be spinning up documents (and probably videos as well) showing how VIAL can address our core use-cases around data corrections, call scheduling and call QA.&lt;/p&gt;
&lt;p&gt;I'll be asking people to try these flows out on staging, provide feedback on what's working and what's missing, and tagging other people to encourage them to kick the tires too.&lt;/p&gt;
&lt;p&gt;My hope is that this exercise will help nail down the final steps in the roadmap needed to get VIAL to live-for-California status.&lt;/p&gt;
&lt;h4&gt;
Keeping things flexible&lt;/h4&gt;
&lt;p&gt;The biggest challenge in building VIAL has always been the need to compete with Airtable. This week I learned that it's even worse than that: it needs to compete with Google Sheets too! Our QA efforts, lead by Kim, have some extremely sophisticated Google Sheets to help evaluate our new callers.&lt;/p&gt;
&lt;p&gt;One reason Airtable and Google Sheets are such powerful tools is that they enable decentralized, permission-less problem solving. We've been taking full advantage of this, spinning up smart new features across a dispersed group of people.&lt;/p&gt;
&lt;p&gt;Switching to our own custom software will inevitably dent this freedom. Part of the challenge with VIAL is to make this dent as small as possible.&lt;/p&gt;
&lt;p&gt;Mechanisms we have in place for this include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The SQL dashboard mechanism, described in &lt;a href="https://github.com/CAVaccineInventory/simonw-internal-blog/blob/main/2021-03/2021-03-15.md#django-sql-dashboard-for-reporting"&gt;Django SQL Dashboard for reporting&lt;/a&gt; - this is aimed to replace many of the ad-hoc reports we have created in Airtable, by providing the ability for anyone with SQL knowledge to quickly create custom dashboard reports. Our organization has a far higher level of SQL proficiency than most, so even if you can't write the SQL for a report yourself you should be able to quickly find someone who can.&lt;/li&gt;
&lt;li&gt;The ability to export CSV data from both those dashboards and directly from the Django admin. This should help fill in other functionality gaps while we spin up custom code.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://github.com/CAVaccineInventory/vial/blob/main/docs/api.md#post-apiimportlocations"&gt;importLocations API&lt;/a&gt; means anyone with an API key can write scripts to import locations into VIAL. We can add more scripts like thus one as we figure out what is needed&lt;/li&gt;
&lt;li&gt;I'm also interested in doing more with tagging - our Availability Tags have worked really well already, and indízate that other mechanisms based on staff members creating custom tags could be useful too.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I also plan to experiment with JSON storage (using PostgreSQL JSONB fields) to enable us to capture new data from our frontend apps without first having to build out and deploy new backend APIs.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca-blog"&gt;vaccinate-ca-blog&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="vaccinate-ca"/><category term="vaccinate-ca-blog"/></entry><entry><title>The Airtable formulas at the heart of everything</title><link href="https://simonwillison.net/2021/Mar/23/vaccinateca-2021-03-23/#atom-tag" rel="alternate"/><published>2021-03-23T17:00:00+00:00</published><updated>2021-03-23T17:00:00+00:00</updated><id>https://simonwillison.net/2021/Mar/23/vaccinateca-2021-03-23/#atom-tag</id><summary type="html">
    &lt;p class="context"&gt;&lt;em&gt;Originally posted to my internal blog at VaccinateCA&lt;/em&gt;&lt;/p&gt;&lt;p&gt;While working on &lt;a href="https://github.com/CAVaccineInventory/vial/issues/53"&gt;building a Counties.json API endpoint&lt;/a&gt; for VIAL I realized I wasn't entirely sure how the "Total reports" and "Yeses" numbers in this piece of JSON were calculated:&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;County&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;Glenn County&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;County vaccination reservations URL&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;https://www.countyofglenn.net/news/press-release-public-health-public-information-public-notice/20210301/covid-19-vaccine-interest&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;Facebook Page&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;https://www.facebook.com/GlennCountyHHSA/&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;Notes&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;(Updated: March 22) **Eligibility**: 65+, A...&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;Total reports&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-c1"&gt;11&lt;/span&gt;,
      &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Twitter Page&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;https://twitter.com/glenncountyoes&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;Vaccine info URL&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;https://www.countyofglenn.net/dept/health-human-services/public-health/covid-19/covid-19-vaccine-information&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;Yeses&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;id&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;rec0QOd7EXzSuZZvN&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    }&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This lead down the fascinating rabbit-hole that is our Airtable formulas. Numbers like this are calculated using a combination of Airtable Rollups and Airtable Formulas, which invisibly form the heart of our entire organization.&lt;/p&gt;
&lt;p&gt;Of particular interest: the "Latest report yes?" column on Locations, currently defined like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;IF(
  OR(
    SEARCH("Vaccinating essential workers", ARRAYJOIN({Availability Info})) != "",
    SEARCH("Scheduling second dose only", ARRAYJOIN({Availability Info})) != "",
    SEARCH("Yes", ARRAYJOIN({Availability Info})) != ""
  ),
  1, 0
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here are screenshots I gathered as I followed them back to figure out how they worked:&lt;/p&gt;
&lt;p&gt;County "Yeses":&lt;/p&gt;
&lt;p&gt;&lt;img alt="Staging__CA_COVID_vaccines_copy__Counties_-_Airtable" src="https://user-images.githubusercontent.com/9599/112202953-37777980-8bcf-11eb-8092-743ca95e59a5.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;County total reports:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Staging__CA_COVID_vaccines_copy__Counties_-_Airtable" src="https://user-images.githubusercontent.com/9599/112203011-465e2c00-8bcf-11eb-9cde-ee6486058f7a.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;County percentage yes:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Staging__CA_COVID_vaccines_copy__Counties_-_Airtable" src="https://user-images.githubusercontent.com/9599/112203064-583fcf00-8bcf-11eb-9474-3737d44daa3b.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;These rollups use formulas on the Locations table:&lt;/p&gt;
&lt;p&gt;Latest report:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Staging__CA_COVID_vaccines_copy__Locations_-_Airtable" src="https://user-images.githubusercontent.com/9599/112203180-7c031500-8bcf-11eb-916e-e669140fdbff.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;Number of reports:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Staging__CA_COVID_vaccines_copy__Locations_-_Airtable" src="https://user-images.githubusercontent.com/9599/112203232-8ae9c780-8bcf-11eb-86fd-dfeff102e47e.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;Latest report yes?&lt;/p&gt;
&lt;p&gt;&lt;img alt="Staging__CA_COVID_vaccines_copy__Locations_-_Airtable" src="https://user-images.githubusercontent.com/9599/112203362-b371c180-8bcf-11eb-8c16-0ac73ba97165.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;And a bonus one: here's &lt;code&gt;is_callable_now&lt;/code&gt; which I need to better understand how our call targetting works:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;AND(
  OR(
    {Do not call until} = BLANK(),
    {Do not call until} &amp;lt; NOW()
  ),
  OR(
    {Next available to app flow} = BLANK(),
    {Next available to app flow} &amp;lt; NOW()
  ),
  NOT({is_soft_deleted}),
  NOT({do_not_call}),
  {Phone number}
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img alt="CA_COVID_vaccines__Locations_-_Airtable" src="https://user-images.githubusercontent.com/9599/112205115-b40b5780-8bd1-11eb-9e1f-c833cc8c73e9.png" style="max-width:100%;"/&gt;&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/airtable"&gt;airtable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca-blog"&gt;vaccinate-ca-blog&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="airtable"/><category term="vaccinate-ca"/><category term="vaccinate-ca-blog"/></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><entry><title>APIs for importing locations</title><link href="https://simonwillison.net/2021/Mar/9/vaccinateca-2021-03-09/#atom-tag" rel="alternate"/><published>2021-03-09T17:00:00+00:00</published><updated>2021-03-09T17:00:00+00:00</updated><id>https://simonwillison.net/2021/Mar/9/vaccinateca-2021-03-09/#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;An important aspect of the new backend is the ability to import new locations.&lt;/p&gt;
&lt;p&gt;These can come from a bunch of different sources. I've previously written Django management command scripts for this, but these are fiddly to run from a permissions perspective and don't necessarily play well with Cloud Run.&lt;/p&gt;
&lt;p&gt;So I decided to build an &lt;code&gt;/api/importLocations&lt;/code&gt; API. This can take a single or multiple locations, and can either create new locations or update existing locations based on a provided unique import reference (&lt;code&gt;import_ref&lt;/code&gt;) value.&lt;/p&gt;
&lt;p&gt;The API is documented in full here: &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/f78afbd1e5bbba4ddc4722c9a592dfd004cb143e/docs/api.md#post-apiimportlocations"&gt;https://github.com/CAVaccineInventory/django.vaccinate/blob/f78afbd1e5bbba4ddc4722c9a592dfd004cb143e/docs/api.md#post-apiimportlocations&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;You can see how it developed in &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/98"&gt;issue 98&lt;/a&gt;&lt;/p&gt;
&lt;h4&gt;
API keys&lt;/h4&gt;
&lt;p&gt;As part of building this API I needed a simple authentication mechanism. I've added a Django admin page for creating API keys, with a required description field. This can be used to create an API key for use with the new API.&lt;/p&gt;
&lt;p&gt;This mechanism differs from the JWT tokens used by the caller app API, mainly because API tokens used for import scripts need to not expire - and also don't need to be tied to specific users.&lt;/p&gt;
&lt;h4&gt;
A few more APIs&lt;/h4&gt;
&lt;p&gt;Some of the fields that can be passed to the import location API need to accept values from a specific list: &lt;code&gt;county&lt;/code&gt;, &lt;code&gt;location_type&lt;/code&gt; and &lt;code&gt;provider_type&lt;/code&gt; all have this requirement.&lt;/p&gt;
&lt;p&gt;I added APIs that return valid values for these:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://vaccinateca-preview.herokuapp.com/api/counties/CA" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/api/counties/CA&lt;/a&gt; - try other state codes too&lt;/li&gt;
&lt;li&gt;&lt;a href="https://vaccinateca-preview.herokuapp.com/api/locationTypes" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/api/locationTypes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://vaccinateca-preview.herokuapp.com/api/providerTypes" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/api/providerTypes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca-blog"&gt;vaccinate-ca-blog&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="vaccinate-ca"/><category term="vaccinate-ca-blog"/></entry><entry><title>New call queue ready to test. Also geography.</title><link href="https://simonwillison.net/2021/Mar/7/vaccinateca-2021-03-07/#atom-tag" rel="alternate"/><published>2021-03-07T17:00:00+00:00</published><updated>2021-03-07T17:00:00+00:00</updated><id>https://simonwillison.net/2021/Mar/7/vaccinateca-2021-03-07/#atom-tag</id><summary type="html">
    &lt;p class="context"&gt;&lt;em&gt;Originally posted to my internal blog at VaccinateCA&lt;/em&gt;&lt;/p&gt;&lt;p&gt;I just shipped the first working preview version of the new &lt;code&gt;/api/requestCall&lt;/code&gt; API [&lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/54"&gt;#54&lt;/a&gt;] - the API that our caller app uses to get the next location that the caller should be contacting (and lock it so that other users don't call it at the same time).&lt;/p&gt;
&lt;p&gt;I made a 1m45s video demonstrating the new API and showing how you can use the admin tools to both queue up calls and check that the call queue logic is correctly claiming calls that you are working on:&lt;/p&gt;
&lt;p&gt;&lt;img alt="requestCall" src="https://user-images.githubusercontent.com/9599/110282543-839da980-7f93-11eb-9861-7609066654af.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;Video here: &lt;a href="https://user-images.githubusercontent.com/9599/110281717-150c1c00-7f92-11eb-886f-c4d1a20f99fd.mp4" rel="nofollow"&gt;https://user-images.githubusercontent.com/9599/110281717-150c1c00-7f92-11eb-886f-c4d1a20f99fd.mp4&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Shorter version of that video: you can add locations to the "Call Requests" queue using the dropdown action menu at the top of &lt;a href="https://vaccinateca-preview.herokuapp.com/admin/core/location/" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/admin/core/location/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;You can then look at the queue at &lt;a href="https://vaccinateca-preview.herokuapp.com/admin/core/callrequest/" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/admin/core/callrequest/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Then you can request calls from that queue at &lt;a href="https://vaccinateca-preview.herokuapp.com/api/requestCall/debug" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/api/requestCall/debug&lt;/a&gt; (make sure you have a fresh JWT by logging out and in of the admin) - every call you request should show your name as having claimed it in &lt;a href="https://vaccinateca-preview.herokuapp.com/admin/core/callrequest/" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/admin/core/callrequest/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;You can also use the admin action tools on &lt;a href="https://vaccinateca-preview.herokuapp.com/admin/core/callrequest/" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/admin/core/callrequest/&lt;/a&gt; to clear any claims should you run out of test items in the queue&lt;/p&gt;
&lt;h4&gt;
Geography: the counties of Oregon&lt;/h4&gt;
&lt;p&gt;I also wrote scripts to import 263 locations that might have the vaccine in the state of Oregon - original data sourced from &lt;a href="https://vaccinefinder.org/" rel="nofollow"&gt;VaccineFinder&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;In doing so I ran into an interesting problem: the VaccineFinder data is very high quality, but the one field it was missing was county: each location has an addreess, a city, a zip code and a latitude and longitude, but no indication of which county it's in.&lt;/p&gt;
&lt;p&gt;Counties are a pretty important part of our data model. How could I best derive the counties for each of these locations?&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/76"&gt;the issue&lt;/a&gt;. I ended up going for the most fun of all of the routes: comparing the latitude/longitude points with geographic polygons representing the different counties.&lt;/p&gt;
&lt;p&gt;Thankfully I had most of the pieces for this already. A few years ago I figured out how to build &lt;a href="https://simonwillison.net/2017/Dec/12/location-time-zone-api/" rel="nofollow"&gt;a location-to-timezone API&lt;/a&gt; using shapefiles and the SpatiaLite extension for SQLite. The US Census offer a high quality shapefile package for US counties at &lt;a href="https://www.census.gov/geographies/mapping-files/time-series/geo/carto-boundary-file.html" rel="nofollow"&gt;https://www.census.gov/geographies/mapping-files/time-series/geo/carto-boundary-file.html&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;So I built a Datasette instance that can turn latitudes and longitudes into counties. You can see how I built it in my &lt;a href="https://github.com/simonw/us-counties-datasette"&gt;simonw/us-counties-datasette&lt;/a&gt; repository, but here's the API itself:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://us-counties.datasette.io/counties/county_for_latitude_longitude_with_map?longitude=-122.676968&amp;amp;latitude=45.266289" rel="nofollow"&gt;https://us-counties.datasette.io/counties/county_for_latitude_longitude_with_map?longitude=-122.676968&amp;amp;latitude=45.266289&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img alt="counties__select_STATEFP_as_state_fips__states_abbreviation_as_state__STATEFP____COUNTYFP_as_county_fips__counties_NAME_as_county_name__COUNTYNS__AFFGEOID__GEOID__LSAD__ALAND__AWATER__AsGeoJSON_geometry__as_map_from_counties_join_states_on_" src="https://user-images.githubusercontent.com/9599/110283437-ee031980-7f94-11eb-815f-205dc3d3f552.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;Add &lt;code&gt;.json&lt;/code&gt; to the path to get that back as JSON.&lt;/p&gt;
&lt;p&gt;I wrote a &lt;code&gt;./manage.py backfill_location_counties&lt;/code&gt; script (&lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/74ee004ad84544d901cfbb536a523718c4f104eb/vaccinate/core/management/commands/backfill_location_counties.py"&gt;code here&lt;/a&gt;) which loops through every location we have that is missing a county, calls that API and updates the record with the corresponding county.&lt;/p&gt;
&lt;p&gt;This raised another problem: we need &lt;code&gt;County&lt;/code&gt; records in our database for the counties of Oregon. And while we're at it we should populate the counties for all of the other states as well.&lt;/p&gt;
&lt;p&gt;Finding a comprehensive list of US counties that included their full FIPS codes was surprisingly difficult. Then I realized I already had one: the US Census shapefile I was using had all of the data we needed.&lt;/p&gt;
&lt;p&gt;I wrote &lt;a href="https://us-counties.datasette.io/counties/county_fips" rel="nofollow"&gt;a SQL view&lt;/a&gt; to transform the shapefile data into a more convenient CSV format with just the data we needed and wrote a &lt;code&gt;./manage.py import_counties&lt;/code&gt; script (&lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/74ee004ad84544d901cfbb536a523718c4f104eb/vaccinate/core/management/commands/import_counties.py"&gt;here&lt;/a&gt;) to import that CSV data directly into our database.&lt;/p&gt;
&lt;p&gt;Which almost completely worked... except it found some counties that didn't correspond to states in our &lt;code&gt;States&lt;/code&gt; table. You guessed it... Puerto Rico, Guam, American Samoa and a few other places were being disenfranchised again.&lt;/p&gt;
&lt;p&gt;I've &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/81"&gt;filed an issue&lt;/a&gt; to fix that in the future!&lt;/p&gt;
&lt;h4&gt;
Next up: test, then start making calls&lt;/h4&gt;
&lt;p&gt;I need help to test the new API: I don't have deep enough understanding of how the caller app works to know if I've messed up something important. Once we've had a few people exercise it for a while I think we're ready to point a copy of the caller app at it and start making some calls through it!&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/geospatial"&gt;geospatial&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca-blog"&gt;vaccinate-ca-blog&lt;/a&gt;&lt;/p&gt;
    

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

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

</summary><category term="django-admin"/><category term="vaccinate-ca"/><category term="vaccinate-ca-blog"/></entry><entry><title>Replaying logs to exercise the new API</title><link href="https://simonwillison.net/2021/Mar/3/vaccinateca-2021-03-03/#atom-tag" rel="alternate"/><published>2021-03-03T17:00:00+00:00</published><updated>2021-03-03T17:00:00+00:00</updated><id>https://simonwillison.net/2021/Mar/3/vaccinateca-2021-03-03/#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;22 days ago &lt;a href="https://github.com/CAVaccineInventory/help.vaccinate/commit/0946d8196c8b5332c3a21dd1cd1fbd29c27037ef"&gt;n1mmy pushed a change&lt;/a&gt; to &lt;code&gt;help.vaccinate&lt;/code&gt; which logged full details of inoming Netlify function API traffic to an Airtable database.&lt;/p&gt;
&lt;p&gt;What an asset that is! The &lt;a href="https://airtable.com/tblvSiTbFMdCxv0Bq/viwJE9fQEfeHtPScq?blocks=hide" rel="nofollow"&gt;Airtable table over here&lt;/a&gt; currently contains over 9,000 logged API calls, including the full JSON POST body, when the call was receieved and which authenticated user made the call.&lt;/p&gt;
&lt;p&gt;This morning I exported that data as CSV from Airtable, and &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/6d463148334f8b5c3f14c44561ea4b69efc08366/scripts/replay_api_logs_from_csv.py"&gt;wrote a Python script&lt;/a&gt; to replay those requests against my new imitation implementation of the API.&lt;/p&gt;
&lt;p&gt;Here's what that script looks like running against my localhost development server:&lt;/p&gt;
&lt;p&gt;&lt;img alt="import" src="https://user-images.githubusercontent.com/9599/109910446-fab60380-7c5c-11eb-9920-197d6c707853.gif" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;You can track the work &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/29"&gt;in this issue&lt;/a&gt; - the replay script helped me get to a place where every single report from the past 22 days can be safely ingested by the new API, with the exception of a tiny number of reports against locations which have since been deleted (which isn't supposed to happen - we try to soft-delete rather than full-delete things - but apparently a few deletes had slipped through).&lt;/p&gt;
&lt;h4&gt;
API logging&lt;/h4&gt;
&lt;p&gt;Since the Airtable API logs have so clearly proved their value, Jesse proposed &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/24"&gt;using the same trick&lt;/a&gt; for the Django app. I implemented that today: the full incoming request body and outgoing response are now recorded in an &lt;code&gt;ApiLog&lt;/code&gt; model in Django. You can see those in the Django admin here: &lt;a href="https://vaccinateca-preview.herokuapp.com/admin/api/apilog/" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/admin/api/apilog/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img alt="Select_api_log_to_change___Django_site_admin" src="https://user-images.githubusercontent.com/9599/109911028-2e455d80-7c5e-11eb-9644-8deefd80b580.png" style="max-width:100%;"/&gt; &lt;img alt="Change_api_log___Django_site_admin" src="https://user-images.githubusercontent.com/9599/109910821-d9094c00-7c5d-11eb-9b00-e3867dfc5796.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/6d463148334f8b5c3f14c44561ea4b69efc08366/vaccinate/api/models.py"&gt;the ORM model&lt;/a&gt; and the &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/6d463148334f8b5c3f14c44561ea4b69efc08366/vaccinate/api/utils.py"&gt;view decorator&lt;/a&gt; that logs requests.&lt;/p&gt;
&lt;h4&gt;
Unit tests for the new API&lt;/h4&gt;
&lt;p&gt;I added tests for the &lt;code&gt;submitReport&lt;/code&gt; API. The tests are driven by example JSON fixtures - so far I've created two of those, but I hope that having them in this format will make it really easy to add more as we find edge-cases in the API and expand it with new features.&lt;/p&gt;
&lt;p&gt;Those API test fixtures live in &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/tree/6d463148334f8b5c3f14c44561ea4b69efc08366/vaccinate/api/test-data/submitReport"&gt;vaccinate/api/test-data/submitReport&lt;/a&gt;. Here's &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/6d463148334f8b5c3f14c44561ea4b69efc08366/vaccinate/api/test_submit_report.py#L38-L73"&gt;the test code&lt;/a&gt; that executes them.&lt;/p&gt;
&lt;h4&gt;
Documentation for the new API&lt;/h4&gt;
&lt;p&gt;I wrote API documentation! You can &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/6d463148334f8b5c3f14c44561ea4b69efc08366/docs/api.md"&gt;find that here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Now that the API is documented I intend to update the documentation in lock-step with changes made to the API itself - using the pattern where every commit includes the change, the tests for the change AND the documentation for the change in a single unit.&lt;/p&gt;
&lt;h4&gt;
Dual-writing to Django&lt;/h4&gt;
&lt;p&gt;The combination of the replay script and the unit tests has left me feeling pretty confident that the replacement API is ready to start accepting traffic.&lt;/p&gt;
&lt;p&gt;The plan is to run the new system in parallel with Airtable for a few days to thoroughly test it and make sure it covers everything we need. Our Netlify functions offer a great place to do this, so this afternoon I submitted &lt;a href="https://github.com/CAVaccineInventory/help.vaccinate/pull/77"&gt;a pull request&lt;/a&gt; to &lt;code&gt;help.vaccinate&lt;/code&gt; to silently dual-write incoming API requests to the Django API, catching and logging any exceptions without intefering with the rest of the application flow.&lt;/p&gt;
&lt;p&gt;Testing this locally helped me identify some bugs in the way the Django app verified JWT tokens that originated with the &lt;code&gt;help.vaccinate&lt;/code&gt; application.&lt;/p&gt;
&lt;h4&gt;
Everything else&lt;/h4&gt;
&lt;p&gt;Here are &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/commits/6d463148334f8b5c3f14c44561ea4b69efc08366"&gt;my other commits from today&lt;/a&gt;.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/apis"&gt;apis&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/logging"&gt;logging&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca-blog"&gt;vaccinate-ca-blog&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="apis"/><category term="logging"/><category term="vaccinate-ca"/><category term="vaccinate-ca-blog"/></entry><entry><title>API ready for testing, first video status update</title><link href="https://simonwillison.net/2021/Mar/2/vaccinateca-2021-03-02/#atom-tag" rel="alternate"/><published>2021-03-02T17:00:00+00:00</published><updated>2021-03-02T17:00:00+00:00</updated><id>https://simonwillison.net/2021/Mar/2/vaccinateca-2021-03-02/#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;To celebrate GitHub's new support for drag-and-dropping mp4 files into Markdown, I decided to switch things around a little and include a video update with today's blog entry.&lt;/p&gt;
&lt;p&gt;Here's a demonstration of where I've got to with the Django+PostgreSQL backend project - as a 2m33s MP4 video:&lt;/p&gt;
&lt;p&gt;[&lt;strong&gt;Video omitted&lt;/strong&gt; as it includes screenshots of names and email addresses for our reporters]&lt;/p&gt;
&lt;p&gt;That's a shame, it looks like it doesn't show a preview, just a link. I'll add a screenshot to make this more visually interesting.&lt;/p&gt;
&lt;p&gt;&lt;img alt="image" src="https://user-images.githubusercontent.com/9599/109757593-f3c8bb80-7b9e-11eb-8a41-2020cdaaef0e.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;The big news today is that I have a working initial version of the &lt;code&gt;submitReport&lt;/code&gt; API, along with an API explorer for testing it out!&lt;/p&gt;
&lt;p&gt;Visit &lt;a href="https://vaccinateca-preview.herokuapp.com/api/submitReport/debug" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/api/submitReport/debug&lt;/a&gt; to try it out.&lt;/p&gt;
&lt;p&gt;You need a JWT token. The easiest way to get one of those is to log in to the &lt;a href="https://vaccinateca-preview.herokuapp.com/" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/&lt;/a&gt; site (or log out and log back in) and then visit the &lt;a href="https://vaccinateca-preview.herokuapp.com/api/submitReport/debug" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/api/submitReport/debug&lt;/a&gt; page again - this will pre-fill the JWT field with your current token.&lt;/p&gt;
&lt;p&gt;Then you can paste in example POST bodies from our logs at &lt;a href="https://airtable.com/tblvSiTbFMdCxv0Bq/viwJE9fQEfeHtPScq?blocks=hide" rel="nofollow"&gt;https://airtable.com/tblvSiTbFMdCxv0Bq/viwJE9fQEfeHtPScq?blocks=hide&lt;/a&gt; and see what happens!&lt;/p&gt;
&lt;p&gt;Here's an animated GIF demo of the API explorer in case you're too busy to watch the 2m33s video.&lt;/p&gt;
&lt;p&gt;&lt;img alt="demo" src="https://user-images.githubusercontent.com/9599/109744322-edc6e080-7b86-11eb-9cc7-d6de6c3a27e5.gif" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;More examples you can copy and paste into the demo:&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;Location&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;recaQlVkkI1rNarvx&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;Availability&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;No: may be a vaccination site in the future&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;Notes&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;March 2: recommended to check their website for new availability in a week or more&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;Internal Notes&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;/pre&gt;&lt;/div&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;Location&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;recgDrq7aQMo0M5x7&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;Appointment scheduling instructions&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;www.walgreens.com&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;Availability&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;Yes: vaccinating 65+&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;Yes: appointment required&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;Vaccinating essential workers&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;Notes&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;Check the Walgreens site regularly to see when appointments open up.&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;Internal Notes&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;/pre&gt;&lt;/div&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;Location&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;recPns4QkB3Vrh1Bp&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;Appointments by phone?&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;,
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Appointment scheduling instructions&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;(888) 909-5232 - Press 1 if you meet the criteria (65+ y/o and tier 1 workers)&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;Availability&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;Yes: vaccinating 65+&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;Yes: appointment required&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;Notes&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;Internal Notes&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;/pre&gt;&lt;/div&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;Location&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;recOUvNBttZSHegD5&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;Availability&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;No: may be a vaccination site in the future&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;Notes&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;3/2 - They do not have the vaccine at this time but they said to check the Walgreens.com site for updates.&lt;span class="pl-cce"&gt;\n&lt;/span&gt;Feb 27: not sure when they'll have the vaccines but in the meantime, it's recommended to establish yourself on their website (or smartphone app) so that you can be ready to make an appt&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;Internal Notes&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;/pre&gt;&lt;/div&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;Location&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;recKoseUfWFeK9TjB&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;Availability&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;No: will never be a vaccination site&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;Notes&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;Internal Notes&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;Press 4 for pharmacy.  They said they were at first going to had COVID vaccines, but they changed their minds.&lt;span class="pl-cce"&gt;\n&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;You can also track progress on the API in &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/25"&gt;issue 25&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Everything else I worked on today can be found &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/commits/8821e0b0b7d3b64f5910c694202114b25c46f1af"&gt;in the commit history&lt;/a&gt;. I wrote up the trick I'm using for timezones in the Django Admin in &lt;a href="https://til.simonwillison.net/django/show-timezone-in-django-admin" rel="nofollow"&gt;this TIL&lt;/a&gt;.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca-blog"&gt;vaccinate-ca-blog&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="vaccinate-ca"/><category term="vaccinate-ca-blog"/></entry><entry><title>Drawing the rest of the owl</title><link href="https://simonwillison.net/2021/Mar/1/vaccinateca-2021-03-01/#atom-tag" rel="alternate"/><published>2021-03-01T17:00:00+00:00</published><updated>2021-03-01T17:00:00+00:00</updated><id>https://simonwillison.net/2021/Mar/1/vaccinateca-2021-03-01/#atom-tag</id><summary type="html">
    &lt;p class="context"&gt;&lt;em&gt;Originally posted to my internal blog at VaccinateCA&lt;/em&gt;&lt;/p&gt;&lt;p&gt;My ambitious goal for the day was to get the new Django/PostgreSQL preview into a state where we could start sending example API requests to it from the &lt;code&gt;help.vaccinate&lt;/code&gt; app.&lt;/p&gt;
&lt;p&gt;I didn't quite get that far, but I made a lot of progress. Here are &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/commits/032af8bd88f9f91bd2705d4d9e30774423cf4639"&gt;today's commits&lt;/a&gt;. I've also created &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/milestone/1"&gt;a milestone&lt;/a&gt; for this goal, which is currently 6 issues down, 6 to go.&lt;/p&gt;
&lt;p&gt;A few highlights from today:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I renamed &lt;code&gt;CallReport&lt;/code&gt; to &lt;code&gt;Report&lt;/code&gt; (&lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/26"&gt;#26&lt;/a&gt;) after realizing that some of the "reports" we make about the vaccine status of a location do not come from a phone call - they may come from reading a trusted website, or even from a site visit.&lt;/li&gt;
&lt;li&gt;I changed &lt;code&gt;AvailabilityTag&lt;/code&gt; to have new fields for the group (yes/no/skip for the moment, with space for more in the future) and a &lt;code&gt;slug&lt;/code&gt; field for values such as &lt;code&gt;skip_call_back_later&lt;/code&gt; and &lt;code&gt;vaccinating_50_plus&lt;/code&gt; - so we don't need to send around human strings that we might want to change later (&lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/21"&gt;#21&lt;/a&gt;). Here's a screenshot of the new model:&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img alt="Select_availability_tag_to_change___Django_site_admin" src="https://user-images.githubusercontent.com/9599/109563401-90eafd80-7a94-11eb-93a7-cba5c31245e7.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I did a bunch of work on the Auth0 implementation - including reworking the model a bit (&lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/30"&gt;#30&lt;/a&gt;) and finishing the work on using it for the Django Admin (&lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/8"&gt;#8&lt;/a&gt;). Any user with the &lt;code&gt;Vaccinate CA Staff&lt;/code&gt; Auth0 role (should be everyone with an &lt;code&gt;@vaccinateca.com&lt;/code&gt; email address as-of a few days ago) can now visit &lt;a href="https://vaccinateca-preview.herokuapp.com/" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/&lt;/a&gt; and click the sign-in link to access the admin, in read-only mode. We can open up further edit permissions as we decide what things need to be editable.&lt;/li&gt;
&lt;li&gt;I finished implementing the logic for applying an &lt;code&gt;AppointmentTag&lt;/code&gt; to an imported report based on the data in the Airtable record. This was quite tricky - see issue &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/20"&gt;#20&lt;/a&gt; for blow-by-blow details of how this works.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Tomorrow morning things should get much more exciting: after some discussion on &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/25"&gt;#25&lt;/a&gt; I now know what the first version of the API will look like - essentially an imitation of the Netlify function API &lt;a href="https://github.com/CAVaccineInventory/help.vaccinate/blob/main/README.API.md"&gt;documented here&lt;/a&gt; - so I'll be building out a working version of that. Follow that issue for progress reports.&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/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca-blog"&gt;vaccinate-ca-blog&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="django"/><category term="vaccinate-ca"/><category term="vaccinate-ca-blog"/></entry><entry><title>Trying to end the pandemic a little earlier with VaccinateCA</title><link href="https://simonwillison.net/2021/Feb/28/vaccinateca/#atom-tag" rel="alternate"/><published>2021-02-28T05:40:28+00:00</published><updated>2021-02-28T05:40:28+00:00</updated><id>https://simonwillison.net/2021/Feb/28/vaccinateca/#atom-tag</id><summary type="html">
    &lt;p&gt;This week I got involved with the &lt;a href="https://www.vaccinateca.com/"&gt;VaccinateCA&lt;/a&gt; effort. We are trying to end the pandemic a little earlier, by building the most accurate database possible of vaccination locations and availability in California.&lt;/p&gt;

&lt;h4&gt;VaccinateCA&lt;/h4&gt;
&lt;p&gt;I’ve been following this project for a while through Twitter, mainly via &lt;a href="https://twitter.com/patio11"&gt;Patrick McKenzie&lt;/a&gt; - here’s &lt;a href="https://twitter.com/patio11/status/1351942635682816002"&gt;his tweet&lt;/a&gt; about the project from January 20th.&lt;/p&gt;

&lt;blockquote class="twitter-tweet"&gt;&lt;p lang="en" dir="ltr"&gt;&lt;a href="https://t.co/JrD5mb4TAN"&gt;https://t.co/JrD5mb4TAN&lt;/a&gt; calls medical professionals daily to ask who they could vaccinate and how to get in line. We publish this, covering the entire state of California, to help more people get their vaccines faster. Please tell your friends and networks.&lt;/p&gt;- Patrick McKenzie (@patio11) &lt;a href="https://twitter.com/patio11/status/1351942635682816002?ref_src=twsrc%5Etfw"&gt;January 20, 2021&lt;/a&gt;&lt;/blockquote&gt;

&lt;p&gt;The core idea is one of those things that sounds obviously correct the moment you hear it. The Covid vaccination roll-out is decentralized and pretty chaotic. VaccinateCA figured out that the best way to figure out where the vaccine is available is to call the places that are distributing it - pharmacies, hospitals, clinics - as often as possible and ask if they have any in stock, who is eligible for the shot and how people can sign up for an appointment.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.vaccinateca.com/what-weve-learned-so-far/"&gt;What We've Learned (So Far)&lt;/a&gt; by Patrick talks about lessons learned in the first 42 days of the project.&lt;/p&gt;
&lt;p&gt;There are three public-facing components to VaccinateCA:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.vaccinateca.com/"&gt;www.vaccinateca.com&lt;/a&gt; is a website to help you find available vaccines near you.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;help.vaccinateca&lt;/code&gt; is the web app used by volunteers who make calls - it provides a script and buttons to submit information gleaned from the call. If you’re interested in volunteering there’s &lt;a href="https://www.vaccinateca.com/about-us"&gt;information on the website&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;api.vaccinateca&lt;/code&gt; is the public API, which is &lt;a href="https://docs.vaccinateca.com/reference"&gt;documented here&lt;/a&gt; and is also used by the end-user facing website. It provides a full dump of collected location data, plus information on county policies and large-scale providers (pharmacy chains, health care providers).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The system currently mostly runs on &lt;a href="https://airtable.com/"&gt;Airtable&lt;/a&gt;, and takes advantage of pretty much every feature of that platform.&lt;/p&gt;
&lt;h4&gt;Why I got involved&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://twitter.com/obra"&gt;Jesse Vincent&lt;/a&gt; convinced me to get involved. It turns out to be a perfect fit for both my interests and my skills and experience.&lt;/p&gt;
&lt;p&gt;I’ve built crowdsourcing platforms before - for &lt;a href="https://simonwillison.net/2009/Dec/20/crowdsourcing/"&gt;MP’s expense reports at the Guardian&lt;/a&gt;, and then for conference and event listings with our startup, Lanyrd.&lt;/p&gt;
&lt;p&gt;VaccinateCA is a very data-heavy organization: the key goal is to build a comprehensive database of vaccine locations and availability. My background in data journalism and the last three years I’ve spent working on &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; have given me a wealth of relevant experience here.&lt;/p&gt;
&lt;p&gt;And finally… VaccinateCA are quickly running up against the limits of what you can sensibly do with Airtable - especially given Airtable’s hard limit at 100,000 records. They need to port critical tables to a custom PostgreSQL database, while maintaining as much as possible the agility that Airtable has enabled for them.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://www.djangoproject.com/"&gt;Django&lt;/a&gt; is a great fit for this kind of challenge, and I know quite a bit about both Django and using Django to quickly build robust, scalable and maintainable applications!&lt;/p&gt;
&lt;p&gt;So I spent this week starting a Django replacement for the Airtable backend used by the volunteer calling application. I hope to get to feature parity (at least as an API backend that the application can write to) in the next few days, to demonstrate that a switch-over is both possible and a good idea.&lt;/p&gt;
&lt;h4&gt;What about Datasette?&lt;/h4&gt;
&lt;p&gt;On Monday I spun up a Datasette instance at &lt;a href="https://vaccinateca.datasette.io/"&gt;vaccinateca.datasette.io&lt;/a&gt; (&lt;a href="https://github.com/simonw/vaccinate-ca-datasette/"&gt;underlying repository&lt;/a&gt;) against data from the public VaccinateCA API. The map visualization of &lt;a href="https://vaccinateca.datasette.io/vaccinateca/locations?_facet=Affiliation&amp;amp;_facet=Latest+report+yes%3F&amp;amp;_facet_array=Availability+Info"&gt;all of the locations&lt;/a&gt; instantly proved useful in helping spot locations that had incorrectly been located with latitudes and longitudes outside of California.&lt;/p&gt;
&lt;p&gt;I hope to use Datasette for a variety of tasks like this, but it shouldn’t be the core of the solution. VaccinateCA is the perfect example of a problem that needs to be solved with &lt;a href="http://boringtechnology.club/"&gt;Boring Technology&lt;/a&gt; - it needs to Just Work, and time that could be spent learning exciting new technologies needs to be spent building what’s needed as quickly, robustly and risk-free as possible.&lt;/p&gt;
&lt;p&gt;That said, I’m already starting to experiment with the new &lt;a href="https://docs.djangoproject.com/en/3.1/ref/models/fields/#django.db.models.JSONField"&gt;JSONField&lt;/a&gt; introduced in Django 3.1 - I’m hoping that a few JSON columns can help compensate for the lack of flexibility compared to Airtable, which makes it ridiculously easy for anyone to add additional columns.&lt;/p&gt;
&lt;p&gt;(To be fair JSONField has been a feature of the Django PostgreSQL Django extension since &lt;a href="https://docs.djangoproject.com/en/3.1/releases/1.9/"&gt;version 1.9 in 2015&lt;/a&gt; so it’s just about made it into the boring technology bucket by now.)&lt;/p&gt;
&lt;h4&gt;Also this week&lt;/h4&gt;
&lt;p&gt;Working on VaccinateCA has given me a chance to use some of my tools in new and interesting ways, so I got to ship a bunch of small fixes, detailed in &lt;a href="#releases-2021-feb-27"&gt;Releases this week&lt;/a&gt; below.&lt;/p&gt;
&lt;p&gt;On Friday I gave a talk at &lt;a href="https://speakeasyjs.com/"&gt;Speakeasy JS&lt;/a&gt;, "the JavaScript meetup for &lt;g-emoji class="g-emoji" alias="lab_coat" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f97c.png"&gt;🥼&lt;/g-emoji&gt; mad science, &lt;g-emoji class="g-emoji" alias="mage_man" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f9d9-2642.png"&gt;🧙‍♂️&lt;/g-emoji&gt; hacking, and &lt;g-emoji class="g-emoji" alias="test_tube" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f9ea.png"&gt;🧪&lt;/g-emoji&gt; experiments" about why "SQL in your client-side JavaScript is a great idea". The video for that &lt;a href="https://www.youtube.com/watch?v=JyOYqJGrWak"&gt;is on YouTube&lt;/a&gt; and I plan to provide a full write-up soon.&lt;/p&gt;
&lt;p&gt;I also recorded a five minute lightning talk about &lt;a href="https://simonwillison.net/2020/Oct/9/git-scraping/"&gt;Git Scraping&lt;/a&gt; for next week's &lt;a href="https://www.ire.org/training/conferences/nicar-2021/"&gt;NICAR 2021&lt;/a&gt; data journalism conference.&lt;/p&gt;
&lt;p&gt;I also made a few small cosmetic upgrades to the way tags are displayed on my blog - they now show with a rounded border and purple background, and include a count of items published with that tag. My &lt;a href="https://simonwillison.net/tags/"&gt;tags page&lt;/a&gt; is one example of where I've now applied this style.&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/til/til/sphinx_sphinx-ext-extlinks.md"&gt;Using sphinx.ext.extlinks for issue links&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/til/til/postgresql_show-schema.md"&gt;Show the SQL schema for a PostgreSQL database&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/til/til/github-actions_postgresq-service-container.md"&gt;Running tests against PostgreSQL in a service container&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/til/til/django_extra-read-only-admin-information.md"&gt;Adding extra read-only information to a Django admin change page&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/til/til/postgresql_read-only-postgresql-user.md"&gt;Granting a PostgreSQL user read-only access to some tables&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="releases-2021-feb-27"&gt;Releases this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/flatten-single-item-arrays"&gt;flatten-single-item-arrays&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/flatten-single-item-arrays/releases/tag/0.1"&gt;0.1&lt;/a&gt; - 2021-02-25
&lt;br /&gt;Given a JSON list of objects, flatten any keys which always contain single item arrays to just a single value&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette-auth-github"&gt;datasette-auth-github&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/datasette-auth-github/releases/tag/0.13.1"&gt;0.13.1&lt;/a&gt; - (&lt;a href="https://github.com/simonw/datasette-auth-github/releases"&gt;25 releases total&lt;/a&gt;) - 2021-02-25
&lt;br /&gt;Datasette plugin that authenticates users against GitHub&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette-block"&gt;datasette-block&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/datasette-block/releases/tag/0.1.1"&gt;0.1.1&lt;/a&gt; - (&lt;a href="https://github.com/simonw/datasette-block/releases"&gt;2 releases total&lt;/a&gt;) - 2021-02-25
&lt;br /&gt;Block all access to specific path prefixes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/github-contents"&gt;github-contents&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/github-contents/releases/tag/0.2"&gt;0.2&lt;/a&gt; - 2021-02-24
&lt;br /&gt;Python class for reading and writing data to a GitHub repository&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/csv-diff"&gt;csv-diff&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/csv-diff/releases/tag/1.1"&gt;1.1&lt;/a&gt; - (&lt;a href="https://github.com/simonw/csv-diff/releases"&gt;9 releases total&lt;/a&gt;) - 2021-02-23
&lt;br /&gt;Python CLI tool and library for diffing CSV and JSON files&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/sqlite-transform"&gt;sqlite-transform&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/sqlite-transform/releases/tag/0.4"&gt;0.4&lt;/a&gt; - (&lt;a href="https://github.com/simonw/sqlite-transform/releases"&gt;5 releases total&lt;/a&gt;) - 2021-02-22
&lt;br /&gt;Tool for running transformations on columns in a SQLite database&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/airtable-export"&gt;airtable-export&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/airtable-export/releases/tag/0.5"&gt;0.5&lt;/a&gt; - (&lt;a href="https://github.com/simonw/airtable-export/releases"&gt;7 releases total&lt;/a&gt;) - 2021-02-22
&lt;br /&gt;Export Airtable data to YAML, JSON or SQLite files on disk&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/crowdsourcing"&gt;crowdsourcing&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/patrick-mckenzie"&gt;patrick-mckenzie&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/covid19"&gt;covid19&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/personal-news"&gt;personal-news&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/jesse-vincent"&gt;jesse-vincent&lt;/a&gt;&lt;/p&gt;
    

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

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

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

</summary><category term="continuous-deployment"/><category term="continuous-integration"/><category term="django"/><category term="django-admin"/><category term="postgresql"/><category term="vaccinate-ca"/><category term="vaccinate-ca-blog"/></entry><entry><title>Getting started</title><link href="https://simonwillison.net/2021/Feb/22/vaccinateca-2021-02-22/#atom-tag" rel="alternate"/><published>2021-02-22T17:00:00+00:00</published><updated>2021-02-22T17:00:00+00:00</updated><id>https://simonwillison.net/2021/Feb/22/vaccinateca-2021-02-22/#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;Here we go then... I've signed up to work on this project full-time, four days a week!&lt;/p&gt;
&lt;p&gt;Several years into my time at Eventbrite I started an internal engineering blog, and it quickly proved itself to be really valuable. The great thing about an internal blog is that it gives you somewhere to post status updates on existing projects, thoughts on what you're going to work on next and even ad-hoc documentation on things that you learn along the way.&lt;/p&gt;
&lt;p&gt;I don't know if I'll post here every day, and I don't intend to spend more than fifteen minutes on each post - but I think it's a good habit to establish early on.&lt;/p&gt;
&lt;h4&gt;
What I'm going to be doing&lt;/h4&gt;
&lt;p&gt;My initial goal, as directed by Jesse, is to help move VaccinateCA beyond Airtable as quickly as possible.&lt;/p&gt;
&lt;p&gt;Airtable is an amazing product, so this is not a trivial undertaking. Every startup founder needs to watch out that they don't end up accidentally competing against an Excel spreadsheet, because the spreadsheet will probably win. Airtable is already like a spreadsheet but better.&lt;/p&gt;
&lt;p&gt;But... if we're going to scale this thing to 10x its current size we need to gain complete ownership of our data.&lt;/p&gt;
&lt;p&gt;My plan for this first week is to spin up a full fidelity Django + PostgreSQL prototype of an alternative stack, to help decide as quickly as possible if that's the right path to go. A lesson I've learned from my career is that prototypes inevitable end up in production, so I build them with that in mind - the code I write will be tested, documented and ready to become not-a-prototype with as little additional effort as possible.&lt;/p&gt;
&lt;p&gt;Why Django and PostgreSQL? Because they are &lt;a href="http://boringtechnology.club/" rel="nofollow"&gt;boring technology&lt;/a&gt;, and that's a huge bonus for this kind of project.&lt;/p&gt;
&lt;p&gt;I'm also going to be talking to the Airtable power users within the organization to make sure I understand what works, what doesn't and what the weird use-cases are for the existing stack that I might not have figured out.&lt;/p&gt;
&lt;h4&gt;
What I've done&lt;/h4&gt;
&lt;p&gt;I've started building a few things agaist the existing public API, mainly because I think by building and this is a good way to help get familiar with the high level of what already exists.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://vaccinateca.datasette.io/" rel="nofollow"&gt;https://vaccinateca.datasette.io/&lt;/a&gt; is a public (initially private) Datasette instance providing an interactive interface against the public API data. Datasette is my principle open source project at the moment - you can learn more about that at &lt;a href="https://datasette.io/" rel="nofollow"&gt;https://datasette.io/&lt;/a&gt; - and you can see how this particular Datasette works by visiting its repo at &lt;a href="https://github.com/simonw/vaccinate-ca-datasette"&gt;https://github.com/simonw/vaccinate-ca-datasette&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/CAVaccineInventory/vaccinate-ca-history"&gt;https://github.com/CAVaccineInventory/vaccinate-ca-history&lt;/a&gt; is a private repo that's doing something weird and, I hope, valuable. It uses GitHub Actions to pull the latest copy of the public API data every ten minutes and commits any changes back to its own repository. It uses &lt;a href="https://github.com/simonw/csv-diff"&gt;csv-diff&lt;/a&gt; (actually &lt;a href="https://github.com/simonw/csv-diff/issues/12"&gt;a new feature&lt;/a&gt; I just added to csv-diff) to try to generate a human-readable commit message. The key feature here becomes &lt;a href="https://github.com/CAVaccineInventory/vaccinate-ca-history/commits/main"&gt;the commit history&lt;/a&gt;, which shows at a finely grained level what got changed and when.&lt;/li&gt;
&lt;li&gt;I started this internal blog&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;
What I need to do next&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Short term: talk to people! Meet people who I'll be working with, understand what's been built so far and where everything is, use those conversations to help me decide what I should work on next.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;
Onboarding&lt;/h4&gt;
&lt;p&gt;So far I'm on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Slack&lt;/li&gt;
&lt;li&gt;Discord&lt;/li&gt;
&lt;li&gt;Airtable&lt;/li&gt;
&lt;li&gt;Auth0&lt;/li&gt;
&lt;li&gt;The GitHub organization&lt;/li&gt;
&lt;li&gt;Google account&lt;/li&gt;
&lt;li&gt;Honeycomb (via the new Google account)&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca-blog"&gt;vaccinate-ca-blog&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/boring-technology"&gt;boring-technology&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="vaccinate-ca"/><category term="vaccinate-ca-blog"/><category term="boring-technology"/></entry></feed>