Push centrally-managed holidays (exceptions) and semester dates (events) to CUNY Alma IZ calendars via the Open Hours API.
Why this exists: Alma's NZ-to-IZ calendar distribution is additive — it adds entries but never removes old ones. When a date changes, you end up with duplicates in every IZ. This script replaces NZ distribution with direct API updates that cleanly replace your entries without touching each campus's opening hours.
- You define your events and exceptions in
calendar_entries.json - The script reads the config and, for each IZ:
- GETs the institution-level calendar
- Removes existing entries that match your config entries (by date or description)
- Adds the current versions of your entries
- Leaves opening hours untouched (WEEK entries are never modified)
- PUTs the updated calendar back
- Dry-run by default — use
--applyto commit changes
cd alma-calendar-sync
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txtCopy the example and fill in your keys:
cp api_keys.csv.example api_keys.csvEach IZ needs an API key with "Configuration - Production Read/Write" permission.
Copy the example and edit your dates:
cp calendar_entries.json.example calendar_entries.jsonSee Config File Format below.
python3 src/sync_calendar.pypython3 src/sync_calendar.py --applypython3 src/sync_calendar.py --iz 01CUNY_QC
python3 src/sync_calendar.py --iz 01CUNY_QC --apply--apply Actually push changes (default: dry-run)
--iz CODE Process only this IZ code (e.g., 01CUNY_QC)
--config PATH Path to calendar_entries.json (default: ./calendar_entries.json)
--keys PATH Path to api_keys.csv (default: ./api_keys.csv)
calendar_entries.json has three sections:
Define which IZs belong to which calendar group. An IZ can be in one group.
"groups": {
"default": {
"description": "Most CUNY schools",
"iz_codes": ["01CUNY_BB", "01CUNY_BC", ...]
},
"alt_calendar": {
"description": "Guttman, Kingsborough, LaGuardia",
"iz_codes": ["01CUNY_NC", "01CUNY_KB", "01CUNY_LG"]
}
}"exceptions": [
{
"desc": "Independence Day",
"from_date": "2026-07-03",
"to_date": "2026-07-03",
"status": "CLOSED",
"applies_to": "all"
}
]applies_to:"all"for every IZ, or a group name like"default"or"alt_calendar"status: Always"CLOSED"for full-day closures- Don't put the year in
desc. The date is already shown alongside the description in both Alma and Primo VE, so"Labor Day"reads the same as"Labor Day 2026". Yearless names also help the script auto-sweep last year's entries via description match when you roll to a new academic year.
"events": [
{
"desc": "End of Spring Semester",
"from_date": "2026-06-16",
"to_date": "2026-06-16",
"applies_to": "default"
}
]Events are informational dates that appear on the calendar but don't affect open/closed status.
Use retired_entries for past entries you previously pushed but no longer want anywhere. The script removes them but never re-adds them.
"retired_entries": [
{
"desc": "Memorial Day 2026",
"from_date": "2026-05-25",
"to_date": "2026-05-25"
}
]Matching is strict: desc AND from_date AND to_date must all match exactly. This protects any campus-created entries that happen to share a description. Once every IZ is cleaned up, you can delete the entry from this section.
When the script decides which existing entries to remove, it uses three rules:
- Description match — if an existing entry has the same description as any active config entry, it gets removed
- Exact date match — if an existing entry's date range is identical to an active config entry's date range, it gets removed
- Retired exact match — if
desc+from_date+to_dateall match aretired_entriesrow, it gets removed (and not re-added)
This handles both cases:
- You change the name of a holiday but not the date → the old entry is caught by exact date match
- You move a date (e.g., semester end June 15 → June 16) → the old entry is caught by description match
Anything that doesn't match any rule is left alone (campus-managed entries, opening hours). Match rule #2 is intentionally exact (not overlap), so long-running campus entries like "Summer Hours" (6/1–8/19) aren't swept just because a single-day config entry falls inside them.
- Dry-run by default — nothing changes unless you pass
--apply - Backup before every PUT — saved to
data/backups/ - Verification after PUT — confirms WEEK entries weren't lost
- Abort on error — stops processing remaining IZs if one fails
Each IZ needs an API key with: Configuration - Production Read/Write
Generate keys at: https://developers.exlibrisgroup.com/