- Python 98.3%
- Nix 1.7%
The ControlPanel credentials now live in sops-nix, so the explicit `login` subcommand is redundant. Log in on demand instead: - forcelists.ensure_session(): reuse valid cookies, else log in from $CREWSENSE_USERNAME/$CREWSENSE_PASSWORD, else prompt on a TTY, else die (guarded by isatty so the systemd unit never hangs). - `lists` self-heals the session and fails if it can't get one; `report` keeps degrading to alphabetical columns so a sops hiccup can't kill the daily email. - Reports rendered without force-list rankings now carry a loud banner (terminal !!!-box, red HTML/MIME block) warning the order is alphabetical, not the forcing order. - Move --debug-login-html onto report/lists; drop the login subcommand and its --force flag; update messages, docstrings, and README. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|---|---|---|
| src/crewsense | ||
| tests | ||
| .gitignore | ||
| discover.py | ||
| flake.lock | ||
| flake.nix | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
Force-Hold Eligibility Report
Generates a ranked list of who is eligible to be force-held onto the next shift at
an 08:00 changeover, pulled live (read-only) from the CrewSense / Vector API. Output is a
terminal table split into four force-lists: Firefighter · Medic · Captain · B1/EMS1,
each ordered by the department's forced-holdover rotation list (scraped from the
CrewSense ControlPanel — the crewsense lists subcommand).
Setup
Requires uv — uv run crewsense … in the project directory
sets up the environment automatically (uv sync to do it explicitly).
API credentials come from environment variables only (never hard-coded or passed as command-line arguments):
export CREWSENSE_CLIENT_ID=...
export CREWSENSE_CLIENT_SECRET=...
The forced-holdover rotation lists are not in the API (verified against the OpenAPI
spec and by probing undocumented endpoints — /v1/lists is the OT callback lists, without
membership). They live only in the session-authenticated ControlPanel web app, so they are
scraped. Logging in is automatic — there is no login subcommand. The ControlPanel
credentials come from the environment (never hard-coded or passed as arguments):
export CREWSENSE_USERNAME=...
export CREWSENSE_PASSWORD=...
CrewSense invalidates sessions server-side on its own schedule (observed lifetime: about
two weeks). When a run finds no working session, it logs in on the spot and saves the
session cookies (to ~/.cache/crewsense/cookies.txt; raw cookie headers are not accepted),
reusing them until they expire. The login source, in order:
CREWSENSE_USERNAME/CREWSENSE_PASSWORD— e.g. rendered into the service'sEnvironmentFileby sops-nix. This is what unattended runs (the daily systemd report) use. One attempt only; a failed attempt (rotated password, new 2FA step) is a hard error that fails the unit, never a silently unranked email.- An interactive prompt — only when those variables are absent and stdin is a TTY
(password via
getpass, never stored). This covers a one-offcrewsense listsfrom a terminal on a machine without the secrets.
crewsense lists needs the force lists, so with neither of those it fails.
crewsense report instead degrades: it still emails the eligibility table, but with
columns in alphabetical order and a loud red banner across the top saying the ordering is
not the forcing order (see below) — a sops hiccup never silently kills the daily report.
Read-only. Every data call is a GET; the only POSTs are the OAuth token exchange and the ControlPanel login (environment credentials or the TTY prompt). The API key has admin rights on a production system — do not add write calls.
Usage
uv run crewsense report # next 08:00 changeover
uv run crewsense report --date 2026-07-03 # replay that changeover's archived report
uv run crewsense report --debug # also print why each person was skipped
uv run crewsense report --who smith # dump one person's titles, quals & segments
uv run crewsense report --no-force # skip the force-list rankings
uv run crewsense report --force-json f.json # rankings from a saved `lists --json` dump
uv run crewsense report --html # email-ready HTML table on stdout
uv run crewsense report --html report.html # ... or written to a file
uv run crewsense report --mime bc@dept.gov | msmtp -t # complete email message
uv run crewsense lists # just print all four force lists
uv run crewsense lists --list 205 --json # one list, machine-readable
uv run crewsense lists --html saved.html # parse a browser-saved page (offline)
Reading the table: each entry is #N Name <capacity>h, where #N is the person's
position on that column's forced-holdover list (1 = next up to be forced; #? = eligible
but not found on the list) and capacity is the hours they can be held (up to the 48h cap).
Markers: ! = ≤8h left (limited hold), (OT) = on overtime/comp at 08:00 (sorted below
regular), (acting) = added to that column by qualification (sorted at the bottom; its
#N is the person's primary FF/Medic list position). If no force-list data is available
(login failed/unavailable, --no-force, or replaying an unranked snapshot), the #N
markers disappear, columns fall back to alphabetical order, and the report is topped with a
loud red banner warning that the ordering is alphabetical and must not be used to decide
who to force.
--html emits the same table as an HTML fragment with all styles inlined on the
elements, so it survives email clients (Gmail et al. strip <style> blocks): paste it
into an HTML email body, or send it directly as a text/html MIME part.
--mime TO goes one step further and emits a complete email message on stdout —
multipart/alternative with the plain-text terminal table as the fallback and the HTML
table as the preferred part — ready to pipe into msmtp -t (or any sendmail-compatible
MTA), which reads the recipients from the headers. --subject and --mail-from
override the defaults; without --mail-from, msmtp fills in the From header from its
own config, so a cron line stays as simple as:
0 6 * * * crewsense report --mime bc@dept.gov | msmtp -t
Report snapshots (--date)
Both inputs to a report are living state: the forced-holdover lists rotate the morning
someone is forced, and the schedule itself gets edited day-of — so a past changeover's
report can't be re-created from live data. Instead, every live run archives its final
ranked report as a JSON snapshot filed under the changeover it feeds (the last run
before each 08:00 changeover wins; a run whose force lists were unavailable never
overwrites a ranked snapshot). --date — for any date, including the current one —
replays that day's snapshot — no API credentials or ControlPanel session needed — and
errors if none exists; only a bare crewsense report runs live. This means today's
report can be re-rendered (e.g. as --html after a terminal run) without re-scraping. --html/--mime work on replays; --who/--debug/
--no-force/--force-json need live data and don't. Snapshots are kept for a year in
$STATE_DIRECTORY/snapshots (systemd) or $XDG_STATE_HOME/crewsense/snapshots,
overridable with --snapshot-dir.
Eligibility rules
A person is listed if all hold:
- Working an eligible unit right up to 08:00.
- Not already scheduled to start the next shift at 08:00.
- Under the 48h cap — consecutive real-work hours in the current work period
< 48. Only a break of 8+ hours off duty resets the period; a shorter gap (e.g. a 1h sick-leave hole in a shift) keeps it continuous but doesn't count as worked time. Capacity =48 − hours worked. - Not carrying a disqualifying shift label (
Medic Student).
Columns are force-lists per vacancy type; a person can appear on more than one:
- Firefighter / Medic / Captain — by the person's rank (from titles), not the seat worked.
- B1/EMS1 — by position: whoever staffs a
B#,EMS#, or spelled-outBattalion #unit. - Bottom of Captain: eligible FFs/medics holding the
Captainqualifier →(acting). - Bottom of B1/EMS1: eligible medics holding the
EMS 1qualifier →(acting).
Within a column: native placements first, then qualification-adds; regular before OT/comp; by forced-holdover list position within each group (unranked people last), alphabetical (by last name) as the tiebreaker. Two special rules:
- Acting adds rank by their primary list: there is no acting rotation, so multiple
(acting)entries order by the person's position on their own rank's list — a FF by the Firefighter list, a medic by the Medic list. Their#Nmarker shows that primary position. - BCs (Bxxx) are hours-eligibility only: the BC on
B1/B102/Battalion 102appears in B1/EMS1 when under 48h, but there is no BC forced-holdover list (no acting BCs), so they're listed unranked (no#marker) after the column's ranked natives.
Force-list → column mapping (by keyword in the ControlPanel list name, EMS checked
before Captain): Firefighter Forced Holdover → Firefighter, Medic Holdover list →
Medic, Captain Forced Holdover → Captain, EMS Captain Forced Holdover → B1/EMS1.
People are matched between the schedule API and the scraped lists by normalized name
(case/whitespace-insensitive).
Assumptions / rules encoded
These were confirmed against live data and are the config constants at the top of
src/crewsense/eligibility.py (force-list → column mapping in report.py) — adjust
there if the department changes.
- Eligible units: Engine
E#, MedicM#, ATAT#, LadderL#, BattalionB#/ spelled-outBattalion #, EMSEMS#. Excluded:CP*,Training/Meetings,Modified Schedule, relief/debit pools, OT sign-up pools. - 48h limit: real duty counts (line units, relief/debit, awarded OT, comp, forced holds).
Only an 8+ hour off-duty break resets the work period — a shorter gap (a sick-leave
hour, a mid-shift hole) bridges it, so the period can span more than 48h of wall clock
(live runs fetch 96h of schedule history to see all of it).
Training/Meeting hours never count — they bridge continuity but are excluded from the
total (so a meeting overlapping a full shift neither inflates nor cuts capacity).
OT sign-ups (
OT Candidate/ the OT pools) are not on duty at all and are ignored. - "Already on next shift" counts only a start at 08:00 on a real line unit. It ignores Training/Meeting blocks, OT sign-ups, and forced holds — a forced next-shift segment is the outcome the report predicts, so forced people still appear (lets you validate past shifts).
- Rank: from
/v1/users/{id}/titles(FF/Captain/Medic/BC…). BCs map to no column. - Acting/EMS captain: from
/v1/users/{id}/qualifiers— theCaptainandEMS 1qualifiers. Real captains carryCaptaintoo but are already native to the column, so they're not duplicated. (Note: a stale qualifier left on a promoted profile will still add them — keep profiles clean.) - Changeover defaults to the next 08:00 (today if run before 08:00, else tomorrow);
any
--datereplays that changeover's archived report snapshot (see above). - Ladders/B/EMS generalized to
L#/B#/EMS#, so future units (e.g.B2,EMS2) work automatically.
Layout
The pipeline is fetch → evaluate → rank → render; renderers are deliberately separate
from the data model so new outputs (daily email to the on-duty BC, a static web page)
can be added as new modules under render/ plus a subcommand, without touching the rules.
| Path | Purpose |
|---|---|
src/crewsense/api.py |
v1 API client (OAuth, /schedule, titles/qualifiers). |
src/crewsense/eligibility.py |
The rules engine + all department config constants. |
src/crewsense/forcelists.py |
ControlPanel session (auto login/cookies) + force-list scraper. |
src/crewsense/report.py |
Glue: force-list → column mapping, rankings loader, changeover resolution. |
src/crewsense/snapshots.py |
Daily report snapshots: live runs archive the final ranked report; --date replays them. |
src/crewsense/render/terminal.py |
The terminal table (future: email.py, html.py). |
src/crewsense/cli.py |
crewsense report / lists subcommands. |
discover.py |
Standalone read-only API inspector — re-run if unit / work-type / title / qualifier names change, to re-confirm the mappings. |
tests/test_force_holds.py |
Offline regression suite (no network). |
Testing
uv run tests/test_force_holds.py # exits non-zero on any failure
Runs the full eligibility/ranking logic against a synthetic schedule shaped like the real
/schedule response, with /titles and /qualifiers stubbed. Covers every edge case found
during development: the 48h cap and the 8h-break reset (sub-8h gaps like a sick-leave hour
bridge the period), prior-day OT signups, forced-onto-next-shift,
mid-shift meetings vs. meetings overlapping a full shift, the Medic Student label, the
position-based B1/EMS1 column, and the acting/EMS-captain qualification adds.
To debug a specific real person, use --who <name> (prints their titles, qualifiers, and
every schedule segment with classification tags).