Personal web app for tracking restaurants I've visited. Browse, filter, and share recommendations.
Stack: Django, HTMX, Bulma, SQLite. Deployed via Docker on a single EC2 instance.
Staff users (is_staff=True) can edit restaurants directly from the public site without bouncing back to the Django admin. Anonymous and non-staff visitors don't see any edit affordances; edit URLs redirect them to /admin/login/.
To log in, visit /admin/login/ (or click any edit link while logged out) and sign in with your superuser/staff credentials. After login the restaurant detail page shows an Edit button next to the existing Admin link.
The edit page at /<city>/<int:pk>/edit/ covers the high-frequency operations:
- Toggle pinned status (one-click HTMX toggle)
- Edit rating (1–10, or clear to make it a wishlist entry)
- Edit comments (Markdown supported)
- Add/edit/delete visits (date + notes per row)
- Upload photos (drag-and-drop multiple files at once), edit captions, reorder via drag-and-drop, delete
Everything else stays in Django admin (/admin/): creating/deleting restaurants, editing cuisine/type/address/website/Michelin status/tags, hidden/closed flags, City and Tag CRUD, and bulk attribute-fetch buttons. The Admin link on the detail page is one click away.
uv run manage.py runserver # dev server at localhost:8000
uv run manage.py makemigrations # after model changes
uv run manage.py migrate # apply migrationsExternal lookups go through a small abstraction in restaurants/sources.py: each source is a callable (Probe) -> dict | None, registered in the SOURCES list. fetch_all merges results field by field, with the first source returning a non-empty value winning.
In the admin add/change form, click the Fetch attributes button after entering a name and city. A panel appears showing each fetchable field as current vs proposed; click Apply on a row (or Apply all) to fill the form input, then review and Save normally. Saves never trigger an automatic fetch — every value comes from an explicit click. The button consults all registered sources (Google Places + Michelin).
Bulk command for live external sources (Google Places only — Michelin is handled separately, see below):
uv run manage.py fetch_all_data # backfill restaurants missing any live-source field
uv run manage.py fetch_all_data --city dublin # only a specific city
uv run manage.py fetch_all_data --force # overwrite existing data with fresh API values
uv run manage.py fetch_all_data --all # include all restaurants, not just those missing datamichelin_status is intentionally not part of fetch_all_data because Michelin updates are infrequent and reviewed separately — see the Michelin section below.
Fills address, website, Google Maps link, and Google rating from the Google Places API. Requires GOOGLE_PLACES_API_KEY environment variable (set in ansible/secrets.yml for production).
uv run manage.py fetch_google_places_data # backfill restaurants missing any Places field
uv run manage.py fetch_google_places_data --city dublin # only a specific city
uv run manage.py fetch_google_places_data --force # overwrite existing data with fresh API values
uv run manage.py fetch_google_places_data --all # include all restaurants, not just those missing dataAlso available as admin actions: "Fetch Google Places data" (backfill) and "Re-fetch Google Places data (overwrite)".
Fills michelin_status from a local copy of the Michelin guide CSV. Matching is fuzzy (handles accents, capitalization, and missing words like "Bloom" vs "Bloom Brasserie") with lat/lon proximity as a tiebreaker.
Data source: download michelin_my_maps.csv from the Kaggle Michelin guide dataset and place it at data/michelin_my_maps.csv. The file is gitignored. Credits to the dataset author and Michelin.
The path is configurable via the MICHELIN_CSV_PATH env var (default: data/michelin_my_maps.csv relative to the project root). In production the container bind-mounts the host CSV at /app/data/michelin_my_maps.csv and sets the env var accordingly.
Local refresh:
# 1. Re-download the CSV from Kaggle, replace data/michelin_my_maps.csv.
# 2. Review the diff (dry-run by default):
uv run manage.py update_michelin_data
uv run manage.py update_michelin_data --city dublin # scope to one city
# 3. Apply the changes:
uv run manage.py update_michelin_data --applyThe diff prints one line per restaurant: no change, WOULD CHANGE: <current> → <proposed>, or no CSV match (useful for spotting demotions — manually fix these).
Production refresh: replace the local CSV, then ./deploy.sh (Ansible uploads the CSV iff it changed; no container restart). Then SSH and apply against the prod DB:
ssh ubuntu@<elastic-ip>
sudo docker exec -it restaurants python manage.py update_michelin_data # review
sudo docker exec -it restaurants python manage.py update_michelin_data --apply # applyAlso available as admin actions: "Update Michelin status" (skip rows whose status is already set) and "Re-fetch Michelin status (overwrite)".
To add a new source, implement a (Probe) -> dict | None callable in restaurants/, append it to SOURCES in sources.py, and extend FETCHABLE_FIELDS if it surfaces new columns — the admin button picks it up automatically. If the source should also run in fetch_all_data, add it to LIVE_SOURCES too.
Thumbnails are generated automatically on photo upload. To generate thumbnails for existing photos:
uv run manage.py generate_thumbnails # only photos missing a thumbnail
uv run manage.py generate_thumbnails --force # regenerate all thumbnailsEXIF metadata (GPS location, timestamps, etc.) is automatically stripped from photos on upload. To strip metadata from previously uploaded photos:
uv run manage.py strip_exifCloudflare (SSL) -> Elastic IP -> nginx (host) -> Docker container (gunicorn + Django)
Data (SQLite DB, media, static files) lives on a separate EBS volume at /opt/restaurants/.
- Terraform >= 1.0
- uv (for running Ansible)
- AWS CLI configured with credentials
- An SSH key pair in AWS
cd terraform
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars with your key_name
terraform init
terraform applyPoint your domain's A record to the elastic IP in Cloudflare (proxied). Flexible SSL mode works because nginx hardcodes X-Forwarded-Proto: https so Django sees the correct scheme regardless of the origin-side connection.
First-time setup:
# 1. Create inventory with your server's IP and SSH key
cp ansible/inventory.ini.example ansible/inventory.ini
# Edit ansible/inventory.ini with the correct host and SSH key path
# 2. Create secrets file (pick one)
cp ansible/secrets.yml.example ansible/secrets.yml
# Edit ansible/secrets.yml — generate a random django_secret_key and add your Google Places API key
# OR: skip this step and deploy.sh will generate one with a random Django key (but no Places key)Then deploy:
./deploy.shBoth ansible/inventory.ini and ansible/secrets.yml are gitignored.
Push to main -- GitHub Actions builds and pushes the Docker image to GHCR. Then re-run:
./deploy.sh# Copy your local database to the server
scp db.sqlite3 ubuntu@<elastic-ip>:~/db.sqlite3
ssh ubuntu@<elastic-ip> "sudo mv ~/db.sqlite3 /opt/restaurants/db.sqlite3 && sudo systemctl restart restaurants"
# Create a superuser
ssh ubuntu@<elastic-ip>
sudo docker exec -it restaurants python manage.py createsuperuserSSH into the server and use docker exec to run Django management commands inside the running container:
ssh ubuntu@<elastic-ip>
sudo docker exec -it restaurants python manage.py <command>