-
-
Save droberts-sea/084bd4a0e65522f3124c876c8dff8369 to your computer and use it in GitHub Desktop.
Gig-O-Matic trash rehearsals
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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