forked from ChelseaKR/permit-bearings
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclocks.py
More file actions
206 lines (177 loc) · 6.52 KB
/
Copy pathclocks.py
File metadata and controls
206 lines (177 loc) · 6.52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
"""Conservative statutory review-clock calculations.
Gov. Code §§ 66317(a)(2) and 66335(a)(2) use a 15-business-day
completeness-notice period. The relevant agency calendar controls which
weekdays are full-day closures. A statewide holiday approximation is not an
agency calendar, so this module returns an explicit unknown state unless a
deployment supplies that calendar.
The 60-calendar-day ADU decision clock is separately conditioned on a
complete application and an existing qualifying dwelling. The helper never
silently treats the receipt date as the completion date.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Literal, cast
COMPLETENESS_BUSINESS_DAYS = 15
DECISION_CALENDAR_DAYS = 60
@dataclass(frozen=True)
class CalendarDeadline:
"""A deadline with an explicit exact-or-unknown evidence state."""
status: Literal["unknown", "exact"]
date: date | None
reason: str
def __post_init__(self) -> None:
if self.status not in ("unknown", "exact"):
raise ValueError(f"unknown deadline status {self.status!r}")
if self.status == "exact" and self.date is None:
raise ValueError("exact deadline requires a date")
if self.status == "unknown" and self.date is not None:
raise ValueError("unknown deadline cannot carry a date")
if not self.reason.strip():
raise ValueError("deadline reason cannot be blank")
def add_business_days(
start: date,
days: int,
agency_closures: set[date],
) -> date:
"""Add weekdays using an explicitly supplied agency closure calendar."""
if days < 0:
raise ValueError("days must be non-negative")
if not isinstance(agency_closures, set) or any(
not isinstance(closure, date) for closure in agency_closures
):
raise ValueError("agency_closures must be a set of dates")
current = start
remaining = days
while remaining:
current += timedelta(days=1)
if current.weekday() < 5 and current not in agency_closures:
remaining -= 1
return current
def completeness_deadline(
received: date,
agency_closures: set[date] | None = None,
) -> CalendarDeadline:
"""Return an exact deadline only with the relevant agency calendar."""
if agency_closures is None:
return CalendarDeadline(
status="unknown",
date=None,
reason=(
"Exact date requires the permitting agency's full-day closure calendar."
),
)
return CalendarDeadline(
status="exact",
date=add_business_days(
received,
COMPLETENESS_BUSINESS_DAYS,
agency_closures,
),
reason=(
"Calculated from weekends and the supplied agency full-day "
"closure calendar."
),
)
@dataclass(frozen=True)
class ClockStatus:
received: date
completeness_notice: CalendarDeadline
deemed_complete_if_silent: CalendarDeadline
decision_if_complete: CalendarDeadline
@property
def completeness_deadline(self) -> CalendarDeadline:
"""Compatibility name with an explicit deadline state."""
return self.completeness_notice
@property
def decision_deadline_if_complete(self) -> CalendarDeadline:
"""Compatibility name with an explicit deadline state."""
return self.decision_if_complete
def summary(self) -> str:
def line(label: str, deadline: CalendarDeadline) -> str:
value = (
cast(date, deadline.date).isoformat()
if deadline.status == "exact"
else "unknown"
)
return f"{label}: {value} — {deadline.reason}"
return "\n".join(
[
f"Application received: {self.received.isoformat()}",
line("Completeness notice deadline", self.completeness_notice),
line(
"Deemed complete if no timely notice",
self.deemed_complete_if_silent,
),
line(
"60-day decision deadline",
self.decision_if_complete,
),
]
)
def adu_clocks(
received: date,
agency_closures: set[date] | None = None,
*,
complete_on_receipt: bool = False,
existing_dwelling: bool = False,
) -> ClockStatus:
"""Calculate only deadlines supported by supplied project facts.
``complete_on_receipt`` must be explicitly true before the receipt date
is used as the complete-application date. A correction or resubmittal
workflow needs its recorded completion event and should calculate
``complete_date + 60 days`` directly.
"""
completeness = completeness_deadline(received, agency_closures)
if completeness.status == "exact":
completeness_date = cast(date, completeness.date)
deemed = CalendarDeadline(
status="exact",
date=completeness_date + timedelta(days=1),
reason=(
"The application is deemed complete after the timely-notice "
"deadline passes without a notice."
),
)
else:
deemed = CalendarDeadline(
status="unknown",
date=None,
reason=(
"This date cannot be shown until the completeness-notice "
"deadline is known."
),
)
if not complete_on_receipt:
decision = CalendarDeadline(
status="unknown",
date=None,
reason=(
"A recorded complete-application date is required before "
"the 60-day clock can be calculated."
),
)
elif not existing_dwelling:
decision = CalendarDeadline(
status="unknown",
date=None,
reason=(
"The encoded 60-day ADU clock applies only when an existing "
"single-family or multifamily dwelling is on the lot."
),
)
else:
decision = CalendarDeadline(
status="exact",
date=received + timedelta(days=DECISION_CALENDAR_DAYS),
reason=(
"Calculated from the asserted complete-on-receipt date and "
"existing-dwelling condition."
),
)
return ClockStatus(
received=received,
completeness_notice=completeness,
deemed_complete_if_silent=deemed,
decision_if_complete=decision,
)