flight-alloc 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flight_alloc-0.0.1.dist-info/METADATA +11 -0
- flight_alloc-0.0.1.dist-info/RECORD +71 -0
- flight_alloc-0.0.1.dist-info/WHEEL +5 -0
- flight_alloc-0.0.1.dist-info/entry_points.txt +2 -0
- flight_alloc-0.0.1.dist-info/top_level.txt +1 -0
- src/__init__.py +0 -0
- src/allocator/__init__.py +6 -0
- src/allocator/caps.py +513 -0
- src/allocator/eligibility.py +302 -0
- src/allocator/greedy_fallback.py +106 -0
- src/allocator/invariants.py +124 -0
- src/allocator/p2f_priority.py +142 -0
- src/allocator/pair_validation.py +108 -0
- src/allocator/pairings.py +554 -0
- src/allocator/postpass_break.py +366 -0
- src/allocator/postpass_intl.py +483 -0
- src/allocator/postpass_p2f.py +723 -0
- src/allocator/postpass_rebalance.py +244 -0
- src/allocator/postsolve.py +549 -0
- src/allocator/recommender.py +348 -0
- src/allocator/windows.py +377 -0
- src/cli.py +41 -0
- src/config.py +168 -0
- src/greedy_fallback.py +102 -0
- src/io/__init__.py +0 -0
- src/io/export.py +270 -0
- src/io/export_xml.py +66 -0
- src/io/readers.py +1048 -0
- src/io/roster_library.py +89 -0
- src/plan.py +192 -0
- src/recommender_staffing.py +329 -0
- src/roster_store.py +159 -0
- src/schemas.py +1244 -0
- src/solver/__init__.py +0 -0
- src/solver/allocator_cpsat.py +1412 -0
- src/staged_overrides.py +468 -0
- src/state.py +494 -0
- src/step1_clean_flights.py +286 -0
- src/step2_extract_roster.py +316 -0
- src/step3_allocate_flights.py +1639 -0
- src/web/__init__.py +47 -0
- src/web/__main__.py +9 -0
- src/web/api/__init__.py +56 -0
- src/web/api/export.py +37 -0
- src/web/api/inputs.py +122 -0
- src/web/api/override_rows.py +138 -0
- src/web/api/pages.py +30 -0
- src/web/api/readbacks.py +72 -0
- src/web/api/recommender.py +72 -0
- src/web/api/runs.py +102 -0
- src/web/api/settings.py +201 -0
- src/web/api/zc.py +117 -0
- src/web/core/__init__.py +5 -0
- src/web/core/responses.py +91 -0
- src/web/core/router.py +167 -0
- src/web/core/static_files.py +85 -0
- src/web/overrides/__init__.py +66 -0
- src/web/overrides/airports.py +261 -0
- src/web/overrides/break_time.py +83 -0
- src/web/overrides/config_yaml.py +21 -0
- src/web/overrides/filters.py +187 -0
- src/web/overrides/rows.py +110 -0
- src/web/readback/__init__.py +67 -0
- src/web/readback/common.py +68 -0
- src/web/readback/dashboard.py +83 -0
- src/web/readback/planning.py +335 -0
- src/web/readback/session.py +158 -0
- src/web/readback/tables.py +163 -0
- src/web/runner.py +168 -0
- src/web/server.py +185 -0
- src/zc_store.py +221 -0
src/web/api/runs.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Starting a Plan / Allocate / Reset, and polling what's in flight.
|
|
2
|
+
|
|
3
|
+
A run is kicked off on a worker thread and the UI polls
|
|
4
|
+
``/api/run/status`` — the POST returns as soon as the run is accepted,
|
|
5
|
+
so a slow solve never holds a request open.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from datetime import date as date_t
|
|
12
|
+
|
|
13
|
+
from ...state import STATE
|
|
14
|
+
from .. import runner
|
|
15
|
+
from ..core.responses import Response, bad_request, conflict, json_response
|
|
16
|
+
from ..core.router import Request, Router
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("src.web")
|
|
19
|
+
|
|
20
|
+
#: Sub-commands ``/api/run`` will accept.
|
|
21
|
+
RUN_STEPS = {"all", "plan", "step1", "step2", "step3", "reset"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_date(raw: str | None) -> date_t | None:
|
|
25
|
+
if not raw:
|
|
26
|
+
return None
|
|
27
|
+
try:
|
|
28
|
+
return date_t.fromisoformat(raw)
|
|
29
|
+
except ValueError:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def register(router: Router) -> None:
|
|
34
|
+
|
|
35
|
+
@router.get("/api/run/status")
|
|
36
|
+
def status(_req: Request) -> Response:
|
|
37
|
+
return json_response(runner.status())
|
|
38
|
+
|
|
39
|
+
@router.post("/api/run")
|
|
40
|
+
def run(req: Request) -> Response:
|
|
41
|
+
body = req.json()
|
|
42
|
+
step = body.get("step") if isinstance(body.get("step"), str) else "all"
|
|
43
|
+
if step not in RUN_STEPS:
|
|
44
|
+
return bad_request(f"step={step!r} not allowed")
|
|
45
|
+
if step == "reset":
|
|
46
|
+
return _reset()
|
|
47
|
+
|
|
48
|
+
d_day = parse_date(body.get("date"))
|
|
49
|
+
if d_day is None:
|
|
50
|
+
return bad_request("body.date required (YYYY-MM-DD)")
|
|
51
|
+
|
|
52
|
+
missing = STATE.missing_inputs()
|
|
53
|
+
if missing:
|
|
54
|
+
from ...state import INPUT_LABELS
|
|
55
|
+
names = ", ".join(INPUT_LABELS[k] for k in missing)
|
|
56
|
+
return conflict({
|
|
57
|
+
"error": f"upload the missing input file(s) first: {names}",
|
|
58
|
+
"missing": missing,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
# Belt-and-suspenders: the dashboard is expected to grey out
|
|
62
|
+
# Allocate until P2F handlers are sorted (see plan.js /
|
|
63
|
+
# handlers.js), but a direct API call — or a stale tab — could
|
|
64
|
+
# still reach here. Only blocks when Plan has already run for
|
|
65
|
+
# this date (same date requested) with a real gap; never blocks
|
|
66
|
+
# the "all" step's own Plan phase when nothing's been planned
|
|
67
|
+
# yet, since that will surface the same gap in the Warnings tab
|
|
68
|
+
# after the solve.
|
|
69
|
+
if step in ("all", "step3") and d_day == STATE.run_date:
|
|
70
|
+
from .. import readback
|
|
71
|
+
handlers_state = readback.read_handlers(STATE)
|
|
72
|
+
if handlers_state.get("plan_has_run") and not handlers_state.get("ready"):
|
|
73
|
+
missing_shifts = handlers_state.get("missing_p2f_shifts", [])
|
|
74
|
+
return conflict({
|
|
75
|
+
"error": (
|
|
76
|
+
"P2F handler nomination missing or invalid for "
|
|
77
|
+
f"shift(s): {', '.join(missing_shifts)}. Fix it in "
|
|
78
|
+
"the Override drawer, then Plan again before "
|
|
79
|
+
"Allocate."
|
|
80
|
+
),
|
|
81
|
+
"missing_p2f_shifts": missing_shifts,
|
|
82
|
+
"issues": handlers_state.get("issues", []),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
STATE.run_date = d_day
|
|
86
|
+
if not runner.trigger(STATE, d_day, step):
|
|
87
|
+
return conflict({"error": "another run is in progress"})
|
|
88
|
+
return json_response({"started": True, "step": step})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _reset() -> Response:
|
|
92
|
+
runner.cancel()
|
|
93
|
+
cleared = STATE.reset_results()
|
|
94
|
+
# The drawer promises "defaults restore on Reset" for the
|
|
95
|
+
# per-(shift, role) bands, so clear those too.
|
|
96
|
+
from ...allocator import caps as _caps
|
|
97
|
+
_caps.reset_overrides()
|
|
98
|
+
logger.info("[reset] cleared %s", cleared)
|
|
99
|
+
return json_response({"reset": True, "cleared": cleared})
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
__all__ = ["RUN_STEPS", "parse_date", "register"]
|
src/web/api/settings.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Drawer settings that write through past the session.
|
|
2
|
+
|
|
3
|
+
Shift limits are per-iteration and cleared by Reset. The airport-code,
|
|
4
|
+
break-length and extraction-filter edits go into ``configs/config.yml``, and
|
|
5
|
+
the per-staff flight caps into ``configs/shift_limits.json``, so they
|
|
6
|
+
outlive the process and the engine sees them on the next run.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .. import overrides as override_ops
|
|
12
|
+
from .. import runner
|
|
13
|
+
from ..core.responses import (
|
|
14
|
+
Response,
|
|
15
|
+
bad_request,
|
|
16
|
+
conflict,
|
|
17
|
+
json_response,
|
|
18
|
+
server_error,
|
|
19
|
+
)
|
|
20
|
+
from ..core.router import Request, Router
|
|
21
|
+
|
|
22
|
+
#: Shift codes and roles a band override may name.
|
|
23
|
+
BAND_SHIFTS = ("M", "A", "N", "M1", "A1")
|
|
24
|
+
BAND_ROLES = ("STAFF", "ZC")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def register(router: Router) -> None:
|
|
28
|
+
|
|
29
|
+
@router.post("/api/shift_limits")
|
|
30
|
+
def shift_limits(req: Request) -> Response:
|
|
31
|
+
"""Drawer "Edit shift limits": a per-iteration band override for
|
|
32
|
+
one (shift, role) bucket. Cleared by Reset."""
|
|
33
|
+
body = req.json()
|
|
34
|
+
shift = body.get("shift")
|
|
35
|
+
role = body.get("role")
|
|
36
|
+
try:
|
|
37
|
+
min_v = int(body.get("min"))
|
|
38
|
+
target_v = int(body.get("target"))
|
|
39
|
+
max_v = int(body.get("max"))
|
|
40
|
+
except (TypeError, ValueError):
|
|
41
|
+
return bad_request("min, target, max must be integers")
|
|
42
|
+
if shift not in BAND_SHIFTS:
|
|
43
|
+
allowed = "/".join(BAND_SHIFTS)
|
|
44
|
+
return bad_request(f"shift must be {allowed}, got {shift!r}")
|
|
45
|
+
if role not in BAND_ROLES:
|
|
46
|
+
allowed = " or ".join(BAND_ROLES)
|
|
47
|
+
return bad_request(f"role must be {allowed}, got {role!r}")
|
|
48
|
+
from ...allocator import caps as _caps
|
|
49
|
+
from ...schemas import Role as _Role
|
|
50
|
+
try:
|
|
51
|
+
saved = _caps.set_iteration_band(
|
|
52
|
+
shift, _Role.STAFF if role == "STAFF" else _Role.ZC,
|
|
53
|
+
min_v=min_v, target_v=target_v, max_v=max_v,
|
|
54
|
+
)
|
|
55
|
+
except ValueError as exc:
|
|
56
|
+
return bad_request(str(exc))
|
|
57
|
+
return json_response({"saved": saved})
|
|
58
|
+
|
|
59
|
+
def _break_payload(minutes: int) -> dict[str, int]:
|
|
60
|
+
from ...config import BREAK_MINUTES_HIGHEST, BREAK_MINUTES_LOWEST
|
|
61
|
+
return {
|
|
62
|
+
"length_minutes": minutes,
|
|
63
|
+
"min": BREAK_MINUTES_LOWEST,
|
|
64
|
+
"max": BREAK_MINUTES_HIGHEST,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@router.get("/api/break_length")
|
|
68
|
+
def get_break_length(_req: Request) -> Response:
|
|
69
|
+
"""Setup sidebar "Shift break length" — the value the next
|
|
70
|
+
Allocate run will use, plus the allowed range."""
|
|
71
|
+
try:
|
|
72
|
+
minutes = override_ops.get_break_length()
|
|
73
|
+
except Exception as exc: # noqa: BLE001
|
|
74
|
+
return server_error(exc, context="get_break_length failed")
|
|
75
|
+
return json_response(_break_payload(minutes))
|
|
76
|
+
|
|
77
|
+
@router.post("/api/break_length")
|
|
78
|
+
def set_break_length(req: Request) -> Response:
|
|
79
|
+
"""Writes ``break_pass.length_minutes`` to configs/config.yml so
|
|
80
|
+
the next Allocate run uses it."""
|
|
81
|
+
try:
|
|
82
|
+
minutes = override_ops.set_break_length(
|
|
83
|
+
req.json().get("length_minutes"),
|
|
84
|
+
)
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
return bad_request(str(exc))
|
|
87
|
+
return json_response(_break_payload(minutes))
|
|
88
|
+
|
|
89
|
+
def _caps_payload() -> dict[str, object]:
|
|
90
|
+
from ...allocator import caps as _caps
|
|
91
|
+
overrides = _caps.snapshot_iteration_overrides()["shift_role"]
|
|
92
|
+
return {
|
|
93
|
+
"caps": _caps.default_caps(),
|
|
94
|
+
"overrides": {key: band["max"] for key, band in overrides.items()},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@router.get("/api/hard_caps")
|
|
98
|
+
def get_hard_caps(_req: Request) -> Response:
|
|
99
|
+
"""Setup sidebar "Max flights per staff" table — the saved cap
|
|
100
|
+
per shift and role, plus the cap of any per-run shift-limits
|
|
101
|
+
override that shadows one until Reset."""
|
|
102
|
+
return json_response(_caps_payload())
|
|
103
|
+
|
|
104
|
+
@router.post("/api/hard_caps")
|
|
105
|
+
def set_hard_caps(req: Request) -> Response:
|
|
106
|
+
"""Writes the caps to configs/shift_limits.json. Refused while a
|
|
107
|
+
run is in flight, since the engine reads the caps all the way
|
|
108
|
+
through one."""
|
|
109
|
+
caps = req.json().get("caps")
|
|
110
|
+
if not isinstance(caps, dict):
|
|
111
|
+
return bad_request("body.caps must be an object of {shift: {role: cap}}")
|
|
112
|
+
if runner.is_running():
|
|
113
|
+
return conflict({"error": "a run is in progress — wait for it to finish"})
|
|
114
|
+
from ...allocator import caps as _caps
|
|
115
|
+
try:
|
|
116
|
+
notes = _caps.set_default_caps(caps)
|
|
117
|
+
except ValueError as exc:
|
|
118
|
+
return bad_request(str(exc))
|
|
119
|
+
return json_response({**_caps_payload(), "notes": notes})
|
|
120
|
+
|
|
121
|
+
@router.get("/api/extraction_filters")
|
|
122
|
+
def list_filters(_req: Request) -> Response:
|
|
123
|
+
try:
|
|
124
|
+
filters = override_ops.list_extraction_filters()
|
|
125
|
+
except Exception as exc: # noqa: BLE001
|
|
126
|
+
return server_error(exc, context="list_extraction_filters failed")
|
|
127
|
+
return json_response({"filters": filters})
|
|
128
|
+
|
|
129
|
+
@router.get("/api/intl_airports")
|
|
130
|
+
def list_airports(_req: Request) -> Response:
|
|
131
|
+
"""Setup sidebar's international airport list, as configured in
|
|
132
|
+
configs/config.yml."""
|
|
133
|
+
try:
|
|
134
|
+
airports = override_ops.list_intl_airports()
|
|
135
|
+
except Exception as exc: # noqa: BLE001
|
|
136
|
+
return server_error(exc, context="list_intl_airports failed")
|
|
137
|
+
return json_response({"airports": airports})
|
|
138
|
+
|
|
139
|
+
@router.post("/api/intl_airports")
|
|
140
|
+
def add_airport(req: Request) -> Response:
|
|
141
|
+
"""Setup sidebar "Add international airport code" — writes through
|
|
142
|
+
to configs/config.yml so the engine sees it on the next run."""
|
|
143
|
+
body = req.json()
|
|
144
|
+
code = body.get("code")
|
|
145
|
+
name = body.get("name")
|
|
146
|
+
if not isinstance(code, str):
|
|
147
|
+
return bad_request("body.code must be a string")
|
|
148
|
+
try:
|
|
149
|
+
payload = override_ops.add_intl_airport_code(
|
|
150
|
+
code, name if isinstance(name, str) else None,
|
|
151
|
+
)
|
|
152
|
+
except ValueError as exc:
|
|
153
|
+
return bad_request(str(exc))
|
|
154
|
+
return json_response({
|
|
155
|
+
"added": payload, "airports": override_ops.list_intl_airports(),
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
@router.post("/api/intl_airports/remove")
|
|
159
|
+
def remove_airport(req: Request) -> Response:
|
|
160
|
+
"""Setup sidebar's remove button on an airport chip — deletes the
|
|
161
|
+
code from configs/config.yml; the next run treats flights from
|
|
162
|
+
that airport as domestic."""
|
|
163
|
+
code = req.json().get("code")
|
|
164
|
+
if not isinstance(code, str):
|
|
165
|
+
return bad_request("body.code must be a string")
|
|
166
|
+
try:
|
|
167
|
+
removed = override_ops.remove_intl_airport_code(code)
|
|
168
|
+
except ValueError as exc:
|
|
169
|
+
return bad_request(str(exc))
|
|
170
|
+
return json_response({
|
|
171
|
+
"removed": removed, "airports": override_ops.list_intl_airports(),
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
@router.post("/api/extraction_filters/add")
|
|
175
|
+
def add_filter(req: Request) -> Response:
|
|
176
|
+
try:
|
|
177
|
+
norm = override_ops.add_extraction_filter(req.json())
|
|
178
|
+
except ValueError as exc:
|
|
179
|
+
return bad_request(str(exc))
|
|
180
|
+
except Exception as exc: # noqa: BLE001
|
|
181
|
+
return server_error(exc, context="add_extraction_filter failed")
|
|
182
|
+
return json_response({
|
|
183
|
+
"filter": norm, "filters": override_ops.list_extraction_filters(),
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
@router.post("/api/extraction_filters/remove")
|
|
187
|
+
def remove_filter(req: Request) -> Response:
|
|
188
|
+
try:
|
|
189
|
+
idx = int(req.json().get("index", 0))
|
|
190
|
+
except (TypeError, ValueError):
|
|
191
|
+
return bad_request("body.index must be an integer (1-based)")
|
|
192
|
+
try:
|
|
193
|
+
removed = override_ops.remove_extraction_filter(idx)
|
|
194
|
+
except IndexError as exc:
|
|
195
|
+
return bad_request(str(exc))
|
|
196
|
+
return json_response({
|
|
197
|
+
"removed": removed, "filters": override_ops.list_extraction_filters(),
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
__all__ = ["BAND_ROLES", "BAND_SHIFTS", "register"]
|
src/web/api/zc.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Zone Controller list: read, add, remove — one list per date.
|
|
2
|
+
|
|
3
|
+
The list lives in ``zc_store`` (saved on disk, carried over from the
|
|
4
|
+
previous day until edited). Every change is applied to the working data
|
|
5
|
+
straight away, so the dashboard reflects it before Allocate is clicked;
|
|
6
|
+
the next Plan re-applies it from the store.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from datetime import date as date_t
|
|
13
|
+
|
|
14
|
+
from ... import zc_store
|
|
15
|
+
from ...state import STATE
|
|
16
|
+
from .. import runner
|
|
17
|
+
from ..core.responses import Response, bad_request, conflict, json_response
|
|
18
|
+
from ..core.router import Request, Router
|
|
19
|
+
from .runs import parse_date
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("src.web")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _payload(d_day: date_t, **extra: object) -> dict[str, object]:
|
|
25
|
+
names, source, carried_from = zc_store.effective(d_day)
|
|
26
|
+
return {
|
|
27
|
+
"date": d_day.isoformat(),
|
|
28
|
+
"names": names,
|
|
29
|
+
# saved = this date has its own list
|
|
30
|
+
# carried = inherited from an earlier date until edited
|
|
31
|
+
# none = nothing saved on or before this date
|
|
32
|
+
"source": source,
|
|
33
|
+
"carried_from": carried_from.isoformat() if carried_from else None,
|
|
34
|
+
**extra,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _rebuild(d_day: date_t) -> dict[str, object]:
|
|
39
|
+
"""Re-derive today's availability from the rosters and re-apply the
|
|
40
|
+
staged rows plus the ZC list.
|
|
41
|
+
|
|
42
|
+
A rebuild (rather than patching roles in place) is what makes
|
|
43
|
+
*removing* a ZC work: the roster row is the only place that still
|
|
44
|
+
knows whether that person is STAFF or AM. Only done when the date
|
|
45
|
+
being edited is the one that has been planned; otherwise the list is
|
|
46
|
+
simply saved and applied by the next Plan for that date.
|
|
47
|
+
"""
|
|
48
|
+
with STATE.lock:
|
|
49
|
+
planned = bool(STATE.availability) and STATE.run_date == d_day
|
|
50
|
+
if not planned:
|
|
51
|
+
return {"applied": False, "reason": "not planned yet"}
|
|
52
|
+
|
|
53
|
+
from ...step2_extract_roster import run as run_step2
|
|
54
|
+
|
|
55
|
+
# step2 replaces state.warnings; the ones on screen belong to the
|
|
56
|
+
# last Plan / Allocate, so put them back afterwards.
|
|
57
|
+
with STATE.lock:
|
|
58
|
+
saved_warnings = list(STATE.warnings)
|
|
59
|
+
try:
|
|
60
|
+
counts = run_step2(STATE, d_day, runner.CONFIG_PATH)
|
|
61
|
+
except Exception as exc: # noqa: BLE001 — list is saved; say why it didn't apply
|
|
62
|
+
logger.warning("[zc] roster re-read failed: %s", exc)
|
|
63
|
+
return {"applied": False, "reason": f"{type(exc).__name__}: {exc}"}
|
|
64
|
+
finally:
|
|
65
|
+
with STATE.lock:
|
|
66
|
+
STATE.warnings = saved_warnings
|
|
67
|
+
if counts.get("ABORTED"):
|
|
68
|
+
return {"applied": False, "reason": "roster check failed — see warnings"}
|
|
69
|
+
return {"applied": True}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _date_or_error(raw: str | None) -> tuple[date_t | None, Response | None]:
|
|
73
|
+
d_day = parse_date(raw)
|
|
74
|
+
if d_day is None:
|
|
75
|
+
return None, bad_request("date required (YYYY-MM-DD)")
|
|
76
|
+
return d_day, None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def register(router: Router) -> None:
|
|
80
|
+
|
|
81
|
+
@router.get("/api/zc")
|
|
82
|
+
def get_zc(req: Request) -> Response:
|
|
83
|
+
d_day, err = _date_or_error((req.query.get("date") or [""])[0])
|
|
84
|
+
if err:
|
|
85
|
+
return err
|
|
86
|
+
return json_response(_payload(d_day))
|
|
87
|
+
|
|
88
|
+
@router.post("/api/zc/add")
|
|
89
|
+
def add_zc(req: Request) -> Response:
|
|
90
|
+
body = req.json()
|
|
91
|
+
d_day, err = _date_or_error(body.get("date"))
|
|
92
|
+
if err:
|
|
93
|
+
return err
|
|
94
|
+
name = str(body.get("name") or "").strip()
|
|
95
|
+
if not name:
|
|
96
|
+
return bad_request("name required")
|
|
97
|
+
if runner.is_running():
|
|
98
|
+
return conflict({"error": "a run is in progress — wait for it to finish"})
|
|
99
|
+
zc_store.add(d_day, name)
|
|
100
|
+
return json_response(_payload(d_day, **_rebuild(d_day)))
|
|
101
|
+
|
|
102
|
+
@router.post("/api/zc/remove")
|
|
103
|
+
def remove_zc(req: Request) -> Response:
|
|
104
|
+
body = req.json()
|
|
105
|
+
d_day, err = _date_or_error(body.get("date"))
|
|
106
|
+
if err:
|
|
107
|
+
return err
|
|
108
|
+
name = str(body.get("name") or "").strip()
|
|
109
|
+
if not name:
|
|
110
|
+
return bad_request("name required")
|
|
111
|
+
if runner.is_running():
|
|
112
|
+
return conflict({"error": "a run is in progress — wait for it to finish"})
|
|
113
|
+
zc_store.remove(d_day, name)
|
|
114
|
+
return json_response(_payload(d_day, **_rebuild(d_day)))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
__all__ = ["register"]
|
src/web/core/__init__.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""What a handler hands back.
|
|
2
|
+
|
|
3
|
+
A handler returns a ``Response``; the request loop is what knows how to
|
|
4
|
+
put it on the wire. Keeping the two apart means a handler is a plain
|
|
5
|
+
function of ``Request -> Response``, testable without a socket.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import traceback
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
JSON_CONTENT_TYPE = "application/json"
|
|
16
|
+
XLSX_CONTENT_TYPE = (
|
|
17
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
18
|
+
)
|
|
19
|
+
XML_CONTENT_TYPE = "application/xml"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class Response:
|
|
24
|
+
"""One HTTP response. ``headers`` carries anything beyond the
|
|
25
|
+
Content-Type / Content-Length / Cache-Control the sender always
|
|
26
|
+
sets — a Content-Disposition on a download, for instance."""
|
|
27
|
+
|
|
28
|
+
status: int = 200
|
|
29
|
+
body: bytes = b""
|
|
30
|
+
content_type: str = JSON_CONTENT_TYPE
|
|
31
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def json_response(payload: Any, status: int = 200) -> Response:
|
|
35
|
+
return Response(
|
|
36
|
+
status=status,
|
|
37
|
+
body=json.dumps(payload, default=str).encode("utf-8"),
|
|
38
|
+
content_type=JSON_CONTENT_TYPE,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def bad_request(message: str) -> Response:
|
|
43
|
+
return json_response({"error": message}, status=400)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def not_found(message: str = "not found") -> Response:
|
|
47
|
+
return json_response({"error": message}, status=404)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def conflict(payload: dict[str, Any]) -> Response:
|
|
51
|
+
return json_response(payload, status=409)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def server_error(exc: BaseException, *, context: str = "") -> Response:
|
|
55
|
+
"""500 with the exception text and traceback in the body.
|
|
56
|
+
|
|
57
|
+
Sending it as JSON rather than letting the exception bubble matters:
|
|
58
|
+
an escaped exception closes the connection mid-response and the
|
|
59
|
+
browser shows a bare ERR_EMPTY_RESPONSE with nothing to act on.
|
|
60
|
+
"""
|
|
61
|
+
label = f"{context}: " if context else ""
|
|
62
|
+
return json_response(
|
|
63
|
+
{
|
|
64
|
+
"error": f"{label}{type(exc).__name__}: {exc}",
|
|
65
|
+
"traceback": traceback.format_exc(),
|
|
66
|
+
},
|
|
67
|
+
status=500,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def attachment(body: bytes, content_type: str, filename: str) -> Response:
|
|
72
|
+
return Response(
|
|
73
|
+
status=200,
|
|
74
|
+
body=body,
|
|
75
|
+
content_type=content_type,
|
|
76
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
"JSON_CONTENT_TYPE",
|
|
82
|
+
"Response",
|
|
83
|
+
"XLSX_CONTENT_TYPE",
|
|
84
|
+
"XML_CONTENT_TYPE",
|
|
85
|
+
"attachment",
|
|
86
|
+
"bad_request",
|
|
87
|
+
"conflict",
|
|
88
|
+
"json_response",
|
|
89
|
+
"not_found",
|
|
90
|
+
"server_error",
|
|
91
|
+
]
|
src/web/core/router.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""A pattern -> handler table, so adding an endpoint is a registration
|
|
2
|
+
rather than another branch in a growing if/elif chain.
|
|
3
|
+
|
|
4
|
+
Patterns are literal paths with optional ``{name}`` segments:
|
|
5
|
+
|
|
6
|
+
@router.get("/api/dashboard")
|
|
7
|
+
def dashboard(req): ...
|
|
8
|
+
|
|
9
|
+
@router.post("/api/inputs/{kind}")
|
|
10
|
+
def upload(req): ... # req.params["kind"]
|
|
11
|
+
|
|
12
|
+
@router.get("/static/{path*}")
|
|
13
|
+
def asset(req): ... # trailing * spans "/" too
|
|
14
|
+
|
|
15
|
+
Literal routes are matched by dict lookup; only the handful of
|
|
16
|
+
parameterised ones fall through to the regex list, so dispatch cost
|
|
17
|
+
doesn't grow with the number of plain endpoints.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import urllib.parse
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any, Callable
|
|
27
|
+
|
|
28
|
+
from .responses import Response, not_found, server_error
|
|
29
|
+
|
|
30
|
+
#: A route handler. Takes the request, returns the response.
|
|
31
|
+
Handler = Callable[["Request"], Response]
|
|
32
|
+
|
|
33
|
+
#: ``{name}`` matches one path segment; ``{name*}`` matches the rest of
|
|
34
|
+
#: the path, slashes included.
|
|
35
|
+
_PARAM_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)(\*?)\}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class Request:
|
|
40
|
+
"""One inbound request, already parsed enough for a handler to use.
|
|
41
|
+
|
|
42
|
+
``body`` is read by the request loop before dispatch so a handler
|
|
43
|
+
never touches the socket — it is the raw bytes for an upload and
|
|
44
|
+
the JSON source for everything else.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
method: str
|
|
48
|
+
path: str
|
|
49
|
+
headers: Any = None
|
|
50
|
+
body: bytes = b""
|
|
51
|
+
params: dict[str, str] = field(default_factory=dict)
|
|
52
|
+
query: dict[str, list[str]] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
def json(self) -> dict[str, Any]:
|
|
55
|
+
"""The body as a dict. A malformed or non-object body reads as
|
|
56
|
+
``{}`` — handlers validate the fields they need and return a
|
|
57
|
+
400 naming the missing one, which is a better error than a
|
|
58
|
+
generic parse failure."""
|
|
59
|
+
if not self.body:
|
|
60
|
+
return {}
|
|
61
|
+
try:
|
|
62
|
+
data = json.loads(self.body.decode("utf-8"))
|
|
63
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
64
|
+
return {}
|
|
65
|
+
return data if isinstance(data, dict) else {}
|
|
66
|
+
|
|
67
|
+
def header(self, name: str, default: str = "") -> str:
|
|
68
|
+
if self.headers is None:
|
|
69
|
+
return default
|
|
70
|
+
return self.headers.get(name, default) or default
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _compile(pattern: str) -> re.Pattern[str]:
|
|
74
|
+
parts: list[str] = []
|
|
75
|
+
last = 0
|
|
76
|
+
for m in _PARAM_RE.finditer(pattern):
|
|
77
|
+
parts.append(re.escape(pattern[last:m.start()]))
|
|
78
|
+
body = ".+" if m.group(2) else "[^/]+"
|
|
79
|
+
parts.append(f"(?P<{m.group(1)}>{body})")
|
|
80
|
+
last = m.end()
|
|
81
|
+
parts.append(re.escape(pattern[last:]))
|
|
82
|
+
return re.compile("^" + "".join(parts) + "$")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Router:
|
|
86
|
+
"""Route table. One instance per server; built once at start-up."""
|
|
87
|
+
|
|
88
|
+
def __init__(self) -> None:
|
|
89
|
+
self._literal: dict[tuple[str, str], Handler] = {}
|
|
90
|
+
self._patterned: list[tuple[str, re.Pattern[str], Handler]] = []
|
|
91
|
+
|
|
92
|
+
# -- registration ------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def add(self, method: str, pattern: str, handler: Handler) -> None:
|
|
95
|
+
method = method.upper()
|
|
96
|
+
if _PARAM_RE.search(pattern):
|
|
97
|
+
self._patterned.append((method, _compile(pattern), handler))
|
|
98
|
+
else:
|
|
99
|
+
self._literal[(method, pattern)] = handler
|
|
100
|
+
|
|
101
|
+
def route(self, method: str, pattern: str) -> Callable[[Handler], Handler]:
|
|
102
|
+
def decorator(fn: Handler) -> Handler:
|
|
103
|
+
self.add(method, pattern, fn)
|
|
104
|
+
return fn
|
|
105
|
+
return decorator
|
|
106
|
+
|
|
107
|
+
def get(self, pattern: str) -> Callable[[Handler], Handler]:
|
|
108
|
+
return self.route("GET", pattern)
|
|
109
|
+
|
|
110
|
+
def post(self, pattern: str) -> Callable[[Handler], Handler]:
|
|
111
|
+
return self.route("POST", pattern)
|
|
112
|
+
|
|
113
|
+
def put(self, pattern: str) -> Callable[[Handler], Handler]:
|
|
114
|
+
return self.route("PUT", pattern)
|
|
115
|
+
|
|
116
|
+
def delete(self, pattern: str) -> Callable[[Handler], Handler]:
|
|
117
|
+
return self.route("DELETE", pattern)
|
|
118
|
+
|
|
119
|
+
# -- dispatch ----------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def resolve(self, method: str, path: str) -> tuple[Handler, dict[str, str]] | None:
|
|
122
|
+
handler = self._literal.get((method, path))
|
|
123
|
+
if handler is not None:
|
|
124
|
+
return handler, {}
|
|
125
|
+
for route_method, regex, fn in self._patterned:
|
|
126
|
+
if route_method != method:
|
|
127
|
+
continue
|
|
128
|
+
m = regex.match(path)
|
|
129
|
+
if m:
|
|
130
|
+
return fn, m.groupdict()
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
def dispatch(self, method: str, raw_path: str, headers: Any, body: bytes) -> Response:
|
|
134
|
+
"""Find the handler for this request and run it.
|
|
135
|
+
|
|
136
|
+
Any exception a handler lets escape becomes a 500 carrying the
|
|
137
|
+
traceback, so one broken endpoint can't take the console down.
|
|
138
|
+
"""
|
|
139
|
+
parsed = urllib.parse.urlparse(raw_path)
|
|
140
|
+
match = self.resolve(method, parsed.path)
|
|
141
|
+
if match is None:
|
|
142
|
+
return not_found()
|
|
143
|
+
handler, params = match
|
|
144
|
+
request = Request(
|
|
145
|
+
method=method,
|
|
146
|
+
path=parsed.path,
|
|
147
|
+
headers=headers,
|
|
148
|
+
body=body,
|
|
149
|
+
params=params,
|
|
150
|
+
query=urllib.parse.parse_qs(parsed.query),
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
return handler(request)
|
|
154
|
+
except Exception as exc: # noqa: BLE001 — see docstring
|
|
155
|
+
return server_error(exc, context=f"{method} {parsed.path}")
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def routes(self) -> list[str]:
|
|
159
|
+
"""``"GET /api/dashboard"`` for each registered route — handy in
|
|
160
|
+
a log line at start-up and when checking a new blueprint
|
|
161
|
+
actually registered."""
|
|
162
|
+
literal = [f"{m} {p}" for (m, p) in self._literal]
|
|
163
|
+
patterned = [f"{m} {r.pattern}" for (m, r, _) in self._patterned]
|
|
164
|
+
return sorted(literal + patterned)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
__all__ = ["Handler", "Request", "Router"]
|