Skip to content

Instantly share code, notes, and snippets.

@droberts-sea
Created June 1, 2026 19:40
Show Gist options
  • Select an option

  • Save droberts-sea/084bd4a0e65522f3124c876c8dff8369 to your computer and use it in GitHub Desktop.

Select an option

Save droberts-sea/084bd4a0e65522f3124c876c8dff8369 to your computer and use it in GitHub Desktop.
Gig-O-Matic trash rehearsals
# Copy this file to ".env" and fill in your gig-o-matic login.
# The script reads these automatically; .env is git-ignored by convention,
# so keep your real credentials out of version control.
GIGO_USERNAME=you@example.com
GIGO_PASSWORD=your-password
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.9"
# dependencies = ["requests", "python-dotenv"]
# ///
"""
Move canceled gig-o-matic gigs to the trash, in bulk.
Built for a specific cleanup: a recurring rehearsal series ("Practice at Gas
Works Park") for the band "Neon Brass Party" was accidentally created as a
*daily* series, then canceled. The canceled occurrences still clutter the gig
list. This script finds them and moves each to the trash.
Auth: your normal gig-o-matic username + password. (The clean JSON API would
need an API key, but that's gated behind beta-tester access, so this script
uses only your login session.)
How it works (verified against the GO3 source, github.com/gig-o-matic/GO3):
1. Logs in via the standard Django login form.
2. Scans a range of gig IDs, fetching each gig's detail page directly. We
scan by ID rather than reading a gig list because the calendar/agenda
feeds filter on per-gig and per-user flags (hide_from_calendar,
hide_canceled_gigs), so they can't be relied on to surface every canceled
gig. Hitting the detail pages sees them regardless.
3. For each page, CONFIRMS it's the right band + title and actually
"Canceled!" (gig_detail.html renders "Canceled!" only when status == 2).
Only confirmed matches are eligible -- real (Confirmed) rehearsals in the
same series are left alone.
4. Trashes each via GET /gig/<id>/trash (gig/helpers.py:gig_trash).
Safety:
* Dry-run by default -- prints exactly what it would trash, and what it
skipped (with the reason). Pass --confirm to actually do it.
* Only gigs confirmed as <band> + <title> + Canceled are trashed.
* Safe to re-run: a trashed gig's page no longer shows "Canceled!", so it
drops out of the trash list.
Permissions: trashing requires you to be a band admin/editor or the gig's
contact (band_editor_required). Otherwise the server returns 403 and the
script reports it per-gig.
Note: the page checks match the English strings "Canceled!" and the band/title
text. If your account language isn't English, pass --title / --band to match
what your gig pages actually show.
Default ID range (--from-id / --to-id) covers the known Gas Works Park series;
widen it if needed -- non-matching IDs in the range are skipped harmlessly.
Usage:
uv run gigomatic-trash-rehearsals.py # dry run
uv run gigomatic-trash-rehearsals.py --confirm # actually trash them
Credentials can come from flags, a .env file next to this script (or the
current directory), env vars (GIGO_USERNAME, GIGO_PASSWORD), or interactive
prompts -- in that order of precedence. A .env looks like:
GIGO_USERNAME=you@example.com
GIGO_PASSWORD=your-password
"""
import argparse
import getpass
import os
import re
import sys
from pathlib import Path
try:
import requests
except ImportError:
requests = None # checked in main() so --help still works without it
try:
from dotenv import load_dotenv
except ImportError:
load_dotenv = None
DEFAULT_BASE_URL = "https://www.gig-o-matic.com"
DEFAULT_BAND = "Neon Brass Party"
DEFAULT_TITLE = "Practice at Gas Works Park"
DEFAULT_FROM_ID = 14140
DEFAULT_TO_ID = 14180
# gig_detail.html renders exactly one of these for the gig's status.
STATUS_MARKERS = ["Confirmed!", "Canceled!", "Asking!", "Unconfirmed"]
def load_env_files():
"""Load .env from the script's directory, then the current directory.
Real environment variables and earlier files win (load_dotenv never
overrides an already-set variable)."""
if load_dotenv is None:
return
load_dotenv(Path(__file__).resolve().parent / ".env")
load_dotenv() # cwd / parents
def parse_args():
p = argparse.ArgumentParser(
description="Bulk-trash canceled gig-o-matic gigs.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument("--base-url", default=DEFAULT_BASE_URL)
p.add_argument("--band", default=DEFAULT_BAND,
help="Only trash gigs for this band (must appear on the gig page).")
p.add_argument("--title", default=DEFAULT_TITLE,
help="Only trash gigs with this title.")
p.add_argument("--from-id", type=int, default=DEFAULT_FROM_ID,
help="First gig ID to scan.")
p.add_argument("--to-id", type=int, default=DEFAULT_TO_ID,
help="Last gig ID to scan (inclusive).")
p.add_argument("--username", default=os.environ.get("GIGO_USERNAME"))
p.add_argument("--password", default=os.environ.get("GIGO_PASSWORD"))
p.add_argument("--confirm", action="store_true",
help="Actually trash the gigs. Without this, it's a dry run.")
return p.parse_args()
def login(session, base_url, username, password):
"""Log in via the Django auth form, leaving the session authenticated."""
login_url = f"{base_url}/accounts/login/"
resp = session.get(login_url)
resp.raise_for_status()
m = re.search(r'name=["\']csrfmiddlewaretoken["\'] value=["\']([^"\']+)["\']',
resp.text)
if not m:
sys.exit("Couldn't find the CSRF token on the login page. "
"Has the login form changed?")
resp = session.post(
login_url,
data={
"csrfmiddlewaretoken": m.group(1),
"username": username,
"password": password,
"next": "/",
},
headers={"Referer": login_url}, # Django requires a matching Referer over HTTPS
allow_redirects=False,
)
# Successful login redirects (302) away from the login page; a failed login
# re-renders the form with status 200.
if resp.status_code != 302:
sys.exit("Login failed -- check your username and password.")
def detect_status(html):
for marker in STATUS_MARKERS:
if marker in html:
return marker.rstrip("!")
return "?"
def extract_date(html):
"""Best-effort gig date label from the detail page (for display only)."""
m = re.search(r'fa-calendar.*?<div class="col-10">\s*([^<(]*?)\s*\(([^)]*)\)',
html, re.DOTALL)
if not m:
return ""
return f"{m.group(1).strip()} ({m.group(2).strip()})".strip()
def scan_range(session, base_url, from_id, to_id, band, title):
"""Fetch each gig page in the range; split title matches into trash/skip."""
to_trash, skipped = [], []
for gig_id in range(from_id, to_id + 1):
resp = session.get(f"{base_url}/gig/{gig_id}/", allow_redirects=False)
if resp.status_code != 200:
continue
html = resp.text
if (f">{title}<" not in html) and (title not in html):
continue # some other gig sitting in this ID range -- ignore it
rec = {"id": gig_id, "date": extract_date(html), "status": detect_status(html)}
band_ok = band in html
if band_ok and rec["status"] == "Canceled":
to_trash.append(rec)
else:
rec["reason"] = (f"band != '{band}'" if not band_ok
else f"status is {rec['status']}, not Canceled")
skipped.append(rec)
return to_trash, skipped
def trash_gig(session, base_url, gig_id):
"""Trash one gig. Returns (ok, message)."""
resp = session.get(f"{base_url}/gig/{gig_id}/trash",
headers={"Referer": f"{base_url}/gig/{gig_id}/"},
allow_redirects=False)
if resp.status_code == 302:
if "/accounts/login" in resp.headers.get("Location", ""):
return False, "session not authenticated (redirected to login)"
return True, "trashed"
if resp.status_code == 403:
return False, "forbidden -- you're not an editor/admin/contact for this gig"
return False, f"unexpected response {resp.status_code}"
def main():
load_env_files()
args = parse_args()
if requests is None:
sys.exit("This script needs the 'requests' library.\n"
"Run it with uv (handles deps automatically):\n"
" uv run ~/tools/gig-o/gigomatic-trash-rehearsals.py")
username = args.username or input("gig-o-matic username (email): ").strip()
password = args.password or getpass.getpass("gig-o-matic password: ")
base_url = args.base_url.rstrip("/")
session = requests.Session()
print(f"Logging in as {username}...")
login(session, base_url, username, password)
print(f"Scanning gig IDs {args.from_id}-{args.to_id} for "
f"'{args.title}' ({args.band})...\n")
to_trash, skipped = scan_range(session, base_url, args.from_id, args.to_id,
args.band, args.title)
if to_trash:
print(f"Will trash {len(to_trash)} Canceled gig(s):")
for r in to_trash:
print(f" #{r['id']:>6} {r['date'] or '(no date)'}")
if skipped:
print(f"\nLeaving {len(skipped)} matching gig(s) alone:")
for r in skipped:
print(f" #{r['id']:>6} {r['date'] or '(no date)'} -- {r['reason']}")
if not to_trash:
print("\nNothing to trash.")
return
if not args.confirm:
print("\nDRY RUN -- nothing has been changed.")
print("Re-run with --confirm to move the Canceled gigs above to the trash.")
return
print("\nTrashing...")
ok_count = 0
for r in to_trash:
ok, msg = trash_gig(session, base_url, r["id"])
print(f" [{'OK ' if ok else 'ERR'}] #{r['id']}: {msg}")
ok_count += ok
print(f"\nDone. Trashed {ok_count}/{len(to_trash)} gig(s).")
if ok_count < len(to_trash):
print("Some failed -- see messages above. 'forbidden' means you're not "
"a band admin/editor for those gigs.")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment