Syncing Inspector Calendars with Permit Milestones
This guide belongs to the Inspection Scheduling & Field Operations track, and shows how a permit’s lifecycle milestones become live, conflict-free events on the calendars inspectors already carry.
Permalink to this section Problem Statement and Scope
A building permit is a sequence of gated milestones: a footing inspection before concrete is poured, a framing inspection before insulation hides the studs, a rough electrical and mechanical pass, and a final inspection before a certificate of occupancy issues. Each milestone is a real appointment that must land on a real person’s calendar, at a real address, within a statutory window. When that scheduling is done by hand — a clerk phoning inspectors, a whiteboard in the field office, a shared spreadsheet nobody trusts — three failures recur.
The first is double-booking: two permits schedule the same inspector for the same 9:00 slot across town from each other, and one contractor’s crew stands idle at time-and-a-half. The second is stale calendars: an inspector reschedules on their phone, the change never propagates back to the permit system, and the office dispatches a second inspector to a job that was already moved. The third is capacity blindness: a district gets eleven final inspections queued for one Tuesday when the inspector can physically complete seven, so four contractors get a same-day cancellation and a bad review of the jurisdiction.
The people who feel these are concrete. Municipal clerks field the angry calls when a slot silently vanishes. Field inspectors lose an hour of daylight to a routing mistake. Python automation builders have to make the milestone-to-calendar mapping deterministic across CalDAV, Google Calendar, and Outlook without a vendor lock-in. And compliance officers need a defensible record that a required inspection was offered, scheduled, and either performed or documented as missed within the code-mandated interval.
The scope of this guide is the synchronization layer that sits between the permit lifecycle and the inspector’s calendar. Its inputs are a permit record with an issued status, its milestone definitions, and a roster of inspectors with working hours and daily capacity. Its outputs are calendar events written to each inspector’s provider (via the iCalendar format over CalDAV, or the Google and Outlook APIs), a two-way reconciliation loop that folds external edits back into the permit system, and an audit trail of every create, move, and cancel. Assigning which inspector gets a job by location is handled in the companion guide on routing inspectors by geographic proximity with Python; getting the times correct across offsets and daylight-saving transitions is covered in handling timezone and DST edge cases in inspection scheduling.
Permalink to this section Prerequisites
This implementation targets Python 3.10+ and treats the permit datastore as an existing service that already emits milestone events. The calendar layer is provider-agnostic: it renders to the iCalendar (.ics) wire format, then delivers over CalDAV for self-hosted or Nextcloud/iCloud calendars, or through the vendor SDKs for Google and Microsoft 365.
pip install "icalendar>=5.0" "caldav>=1.3" "recurring-ical-events>=2.2" \
"google-api-python-client>=2.130" "msgraph-sdk>=1.4" \
"pydantic>=2.6" "tenacity>=8.3" "structlog>=24.1"
Environment assumptions:
- Read access to issued permit records and their milestone definitions. The record shape should match your canonical JSON schema for building permits so a
permit_idandparcel_idmean the same thing here as everywhere else in the platform. - A per-inspector calendar target: a CalDAV principal URL and app password, or an OAuth service account with delegated calendar scope for Google/Outlook. Store these as secrets, never in the repo.
- A canonical jurisdiction time zone (an IANA key like
America/Chicago), because every event you write must be anchored to a real offset — never a naive local time. - A durable table or event bus to persist the mapping between a milestone and the calendar event UID it produced, so reconciliation has a join key.
Permalink to this section Step 1 — Model Milestones as Schedulable Intents
Do not schedule directly from a permit status change. Translate each milestone into an explicit, typed scheduling intent first — a value object that carries everything the calendar layer needs and nothing it does not. This decouples the permit lifecycle from any one calendar vendor and gives you a single object to test, audit, and route.
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum
class Milestone(str, Enum):
FOOTING = "footing"
FRAMING = "framing"
ROUGH_ELECTRICAL = "rough_electrical"
ROUGH_MECHANICAL = "rough_mechanical"
FINAL = "final"
# Typical on-site duration by milestone; drives the calendar block length.
DEFAULT_DURATION: dict[Milestone, timedelta] = {
Milestone.FOOTING: timedelta(minutes=30),
Milestone.FRAMING: timedelta(minutes=45),
Milestone.ROUGH_ELECTRICAL: timedelta(minutes=30),
Milestone.ROUGH_MECHANICAL: timedelta(minutes=30),
Milestone.FINAL: timedelta(minutes=60),
}
@dataclass(frozen=True, slots=True)
class SchedulingIntent:
permit_id: str
parcel_id: str
milestone: Milestone
requested_start: datetime # tz-aware; see the DST guide for why
address: str
lat: float
lon: float
@property
def duration(self) -> timedelta:
return DEFAULT_DURATION[self.milestone]
@property
def requested_end(self) -> datetime:
return self.requested_start + self.duration
Because the intent carries the parcel’s coordinates, it is also the natural input to inspector assignment. The frozen=True dataclass makes an intent hashable and immutable, so it can be safely used as an audit-log payload without a downstream stage mutating it.
Permalink to this section Step 2 — Enforce Capacity and Reject Conflicts Before Writing
The gate is the heart of the system: never push an event to a provider until you have confirmed the inspector has both a free slot and remaining daily capacity. Doing this check against your own scheduling table — not against the remote calendar — keeps the decision fast and authoritative, and avoids a race where two intents both read “free” from a laggy provider.
from datetime import date
class CapacityError(Exception):
"""Raised when an inspector's day is full or the slot overlaps a booking."""
@dataclass(frozen=True, slots=True)
class Booking:
inspector_id: str
start: datetime
end: datetime
def overlaps(a_start: datetime, a_end: datetime,
b_start: datetime, b_end: datetime) -> bool:
# Half-open intervals: touching edges (back-to-back) do NOT overlap.
return a_start < b_end and b_start < a_end
def admit(intent: SchedulingIntent, inspector_id: str,
day_bookings: list[Booking], daily_cap: int) -> Booking:
"""Return a Booking if the intent fits, else raise CapacityError."""
same_day = [b for b in day_bookings
if b.start.date() == intent.requested_start.date()]
if len(same_day) >= daily_cap:
raise CapacityError(
f"{inspector_id} at capacity ({daily_cap}) on "
f"{intent.requested_start.date().isoformat()}")
for b in same_day:
if overlaps(intent.requested_start, intent.requested_end, b.start, b.end):
raise CapacityError(
f"{inspector_id} double-booked at {intent.requested_start:%H:%M}")
return Booking(inspector_id, intent.requested_start, intent.requested_end)
Two subtleties earn their place here. First, using half-open intervals means a 9:00–9:30 footing and a 9:30–10:15 framing are back-to-back, not a conflict — the common off-by-one that makes a full day look overbooked. Second, the daily cap is per inspector per calendar day, evaluated in the inspector’s own local date, which is exactly where daylight-saving edges bite; the DST edge-case guide shows why intent.requested_start.date() must be computed against an aware datetime.
Permalink to this section Step 3 — Serialize a Milestone to an iCalendar VEVENT
Render to the iCalendar format rather than to a provider’s proprietary JSON. Every major provider ingests .ics, so one serializer feeds CalDAV, Google, and Outlook. The two fields that matter most for correctness are a stable UID — deterministic from the permit and milestone so the same inspection always maps to the same event — and a DTSTART bound to a TZID, never a floating local time.
from icalendar import Calendar, Event
from zoneinfo import ZoneInfo
def build_uid(intent: SchedulingIntent) -> str:
# Deterministic: re-serializing the same milestone yields the same UID,
# so an update overwrites in place instead of creating a duplicate.
return f"{intent.permit_id}.{intent.milestone.value}@municipalpermit.org"
def to_ics(intent: SchedulingIntent, inspector_id: str, tz: str) -> bytes:
zone = ZoneInfo(tz)
cal = Calendar()
cal.add("prodid", "-//municipalpermit.org//inspection-scheduler//EN")
cal.add("version", "2.0")
cal.add("method", "REQUEST") # invite semantics for two-way providers
ev = Event()
ev.add("uid", build_uid(intent))
ev.add("summary", f"{intent.milestone.value.title()} inspection · {intent.permit_id}")
ev.add("dtstart", intent.requested_start.astimezone(zone))
ev.add("dtend", intent.requested_end.astimezone(zone))
ev.add("location", intent.address)
ev.add("geo", (intent.lat, intent.lon))
ev.add("categories", ["inspection", intent.milestone.value])
ev.add("x-permit-id", intent.permit_id) # round-trips through providers
ev.add("x-inspector-id", inspector_id)
ev.add("dtstamp", datetime.now(tz=zone))
ev.add("sequence", 0) # bump on every update so clients accept the change
cal.add_component(ev)
return cal.to_ical()
The SEQUENCE property is easy to forget and expensive to omit: providers treat a re-sent invite with the same or lower sequence number as stale and silently drop your update, so every edit must increment it. The X-PERMIT-ID extension property survives the round trip through every provider, which is what lets Step 5 match a returned event back to its permit without a side table lookup.
Permalink to this section Step 4 — Deliver Over CalDAV (and the Vendor APIs)
CalDAV is the lowest-common-denominator transport and the right default for self-hosted municipal calendars. The pattern is: resolve the inspector’s calendar, then PUT the event at a URL derived from its UID so the operation is idempotent. Wrap the network call in a bounded retry — providers rate-limit and occasionally return transient 5xx during their own maintenance windows.
import caldav
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
class ProviderTransientError(Exception):
pass
@retry(stop=stop_after_attempt(4),
wait=wait_exponential(multiplier=0.5, max=8),
retry=retry_if_exception_type(ProviderTransientError))
def push_caldav(calendar_url: str, username: str, password: str,
ics: bytes, uid: str) -> str:
"""Idempotently PUT the event; return the server ETag for reconciliation."""
client = caldav.DAVClient(url=calendar_url, username=username, password=password)
calendar = client.calendar(url=calendar_url)
try:
# save_event with an explicit UID overwrites the same resource in place.
event = calendar.save_event(ics.decode("utf-8"))
except caldav.lib.error.DAVError as exc: # narrow to transient in production
raise ProviderTransientError(str(exc)) from exc
# The ETag changes whenever the event is edited anywhere — our sync token.
return event.get_property(caldav.elements.dav.GetEtag()) or ""
For Google and Outlook the transport differs but the contract is identical: send the same VEVENT (both SDKs accept iCalendar import), persist the returned resource identifier and change token, and treat the UID as the primary key. Keep the delivery adapter behind a small interface (push(ics, target) -> sync_token) so a jurisdiction that switches providers changes one class, not the pipeline. When an inspection is scheduled or moved, notifying the contractor is a separate concern handled by the automated inspection notification system, which subscribes to the same audit events this layer emits.
Permalink to this section Step 5 — Reconcile External Edits Back Into the Permit System
Two-way sync is what separates a real scheduler from a fire-and-forget exporter. Inspectors will drag events in their phone app; when they do, the permit record must learn about it. Poll each calendar for changes since your last sync token, match returned events to milestones by UID, and fold moves and cancellations back into the permit state — then re-run the capacity gate so a self-service move cannot create a hidden double-booking.
import structlog
log = structlog.get_logger("inspection.sync")
@dataclass(frozen=True, slots=True)
class RemoteChange:
uid: str
new_start: datetime | None # None => the inspector cancelled
etag: str
def reconcile(changes: list[RemoteChange],
known_etags: dict[str, str]) -> list[dict]:
"""Fold provider-side edits back into permit milestones."""
applied: list[dict] = []
for change in changes:
if known_etags.get(change.uid) == change.etag:
continue # we already have this version; skip
if change.new_start is None:
outcome = {"uid": change.uid, "action": "cancelled"}
else:
outcome = {"uid": change.uid, "action": "moved",
"new_start": change.new_start.isoformat()}
log.info("reconciled", **outcome, etag=change.etag)
known_etags[change.uid] = change.etag
applied.append(outcome)
return applied
The ETag comparison makes reconciliation idempotent: replaying the same change set is a no-op, which matters because polling windows overlap and providers occasionally re-serve an unchanged event. A cancellation coming back from the provider should not silently drop the milestone — mark it as “inspector-declined” and re-queue it for assignment, because the statutory obligation to offer the inspection still stands.
Permalink to this section Configuration Reference
| Parameter | Type | Default | Municipal-context notes |
|---|---|---|---|
jurisdiction_tz |
str |
"America/Chicago" |
IANA key; the single source of truth for local dates and DST. |
daily_capacity |
int |
8 |
Max inspections per inspector per local day; tune per district and travel load. |
slot_granularity_min |
int |
15 |
Rounds requested starts to a grid so back-to-back blocks align cleanly. |
sync_poll_seconds |
int |
300 |
Reconciliation cadence; under 5 min risks provider rate limits. |
provider |
str |
"caldav" |
caldav, google, or outlook; selected per inspector, not globally. |
ics_method |
str |
"REQUEST" |
REQUEST gives invite/RSVP semantics; PUBLISH for read-only feeds. |
retry_max_attempts |
int |
4 |
Bounded exponential backoff on transient provider errors. |
overlap_mode |
str |
"half_open" |
Half-open intervals let back-to-back slots touch without a false conflict. |
cancel_policy |
str |
"requeue" |
On a provider-side cancel, re-queue rather than drop the statutory obligation. |
Permalink to this section Error Handling and Edge Cases
Municipal calendar sync breaks in specific, recurring ways. Handle each explicitly rather than letting it surface as a stuck queue or a silent no-show.
- Duplicate events from a non-deterministic UID. If the UID is randomly generated, every re-sync creates a new event and the inspector’s day fills with ghosts. Derive the UID from
permit_id+milestone(Step 3) so an update overwrites in place. - Lost update from a stale
SEQUENCE. A provider drops your edit because the re-sent invite carried the same sequence number. IncrementSEQUENCEon every mutation and store the last value alongside the UID. - Race between two intents for one slot. Two milestones evaluate the gate concurrently and both see the slot free. Serialize per-inspector writes with a row lock or a per-inspector queue so the capacity check and the booking insert are atomic.
- DST-shifted appointment. A 2:30 AM booking on a spring-forward night, or an ambiguous 1:30 AM in fall, drifts by an hour. This is common enough to warrant its own treatment — see handling timezone and DST edge cases.
- Provider outage during a maintenance window. A CalDAV endpoint returns 503 for ten minutes. The bounded retry absorbs short blips; for longer outages, queue the intent and let a background worker drain it, so a downstream provider failure never blocks the permit lifecycle. The records this layer consumes are guarded upstream by role-based access for clerk portals, so a queued push should still carry the authenticated actor for the audit trail.
Permalink to this section Testing and Verification
Scheduling logic is exactly the kind of code that must be tested as data: enumerate the conflict and capacity cases and assert the decision, so a refactor can never quietly reintroduce a double-booking.
import pytest
from zoneinfo import ZoneInfo
CT = ZoneInfo("America/Chicago")
def _intent(hour: int, minute: int = 0) -> SchedulingIntent:
return SchedulingIntent(
permit_id="P-2200", parcel_id="0042", milestone=Milestone.FOOTING,
requested_start=datetime(2026, 7, 15, hour, minute, tzinfo=CT),
address="10 Main St", lat=41.87, lon=-87.62)
def test_back_to_back_is_not_a_conflict():
nine = Booking("insp-1", datetime(2026, 7, 15, 9, 0, tzinfo=CT),
datetime(2026, 7, 15, 9, 30, tzinfo=CT))
# 9:30 start touches the 9:30 end — half-open, so it must be admitted.
booking = admit(_intent(9, 30), "insp-1", [nine], daily_cap=8)
assert booking.start.hour == 9
def test_overlap_is_rejected():
nine = Booking("insp-1", datetime(2026, 7, 15, 9, 0, tzinfo=CT),
datetime(2026, 7, 15, 9, 30, tzinfo=CT))
with pytest.raises(CapacityError):
admit(_intent(9, 15), "insp-1", [nine], daily_cap=8)
def test_daily_cap_is_enforced():
full = [Booking("insp-1", datetime(2026, 7, 15, 8 + i, 0, tzinfo=CT),
datetime(2026, 7, 15, 8 + i, 30, tzinfo=CT)) for i in range(8)]
with pytest.raises(CapacityError):
admit(_intent(17), "insp-1", full, daily_cap=8)
def test_uid_is_deterministic():
assert build_uid(_intent(9)) == build_uid(_intent(9))
A passing conflict matrix plus a deterministic-UID assertion gives you a regression net: change the interval math or the UID scheme and the suite tells you exactly which invariant moved. Add one integration test that serializes an intent, parses it back with icalendar, and asserts the DTSTART carries a TZID — the single most common correctness bug in calendar code.
Permalink to this section Integration Notes
This synchronization layer sits between two other subsystems in the Inspection Scheduling & Field Operations track. Upstream of the capacity gate, deciding which inspector should take a job by location is the job of routing inspectors by geographic proximity with Python; that router hands this layer an inspector_id and the gate confirms the slot is free. Downstream, every create/move/cancel event this layer emits is consumed by the automated inspection notification system so contractors learn of a scheduled or moved appointment without a clerk placing a call.
The permit records that seed a milestone come from the platform’s core architecture: their shape is fixed by the JSON schema for building permits, and read access to them is scoped by role-based access for clerk portals, so the same identity that mutates a milestone appears in this layer’s audit trail. Keeping the milestone-to-UID mapping in a durable store, rather than reconstructing it from the provider, is what makes the whole loop replayable after an outage.
Permalink to this section Frequently Asked Questions
Permalink to this section Should I write to the inspector’s calendar directly or export an ICS feed they subscribe to?
Write directly when you need two-way sync and capacity enforcement — a subscribed read-only feed cannot tell you when an inspector moved an event, so double-bookings created on the phone stay invisible. Use a published ICS feed only for downstream, read-only audiences such as contractors, where the appointment is informational and the source of truth stays in the permit system.
Permalink to this section How do I stop the same milestone from creating duplicate calendar events?
Derive a deterministic UID from the permit ID and milestone, and PUT/save at a URL keyed on that UID so a re-sync overwrites the existing event in place. Pair that with an incrementing SEQUENCE so providers accept the update rather than treating it as a stale replay. Random UIDs are the number-one cause of ghost events.
Permalink to this section What is the right way to handle capacity across a district with several inspectors?
Evaluate capacity per inspector per local calendar day, and make the assignment decision — which inspector — a separate, earlier step driven by geography. That separation lets you rebalance load by reassigning intents before they reach the gate, rather than discovering an overloaded day only when the gate starts rejecting.
Permalink to this section Do I need CalDAV if all my inspectors use Google or Outlook?
No. Serialize to the iCalendar format once and deliver through the Google or Microsoft Graph SDK, which both import VEVENTs and return a change token you can use for reconciliation. CalDAV earns its place only when you run self-hosted or mixed-provider calendars and want one transport for all of them.
Permalink to this section Related
- Routing inspectors by geographic proximity with Python — decide which inspector takes each job before the capacity gate runs.
- Handling timezone and DST edge cases in inspection scheduling — keep every
DTSTARTon the correct offset across transitions. - Building automated inspection notification systems — turn the calendar events this layer writes into contractor notifications.
- Designing JSON schemas for building permits — the record shape a milestone is derived from.
- Inspection Scheduling & Field Operations — the parent track tying scheduling, routing, and notification together.