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/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Local web UI for the Flight Allocation system.
|
|
2
|
+
|
|
3
|
+
Served from stdlib http.server — no FastAPI / Flask / external deps, so
|
|
4
|
+
it fits IT allow-list constraints and ships in the same PyInstaller
|
|
5
|
+
bundle as the engine.
|
|
6
|
+
|
|
7
|
+
The dashboard is the only input surface: the operator uploads the day's
|
|
8
|
+
three files there, runs Plan / Allocate, reviews the result, and
|
|
9
|
+
downloads an .xlsx if they need one to send on.
|
|
10
|
+
|
|
11
|
+
Tabs:
|
|
12
|
+
Dashboard Inputs, run status, headline stats, roster, charts
|
|
13
|
+
Allocations Per-flight rows with shift / INTL / redistribution cues
|
|
14
|
+
Unallocated What didn't fit, with per-flight recommendations
|
|
15
|
+
Workload Per-staff load against the preferred / cap bands
|
|
16
|
+
Pairs Pair map per boundary
|
|
17
|
+
Warnings Severity-badged engine warnings
|
|
18
|
+
Override Slide-out drawer, reachable from any tab
|
|
19
|
+
|
|
20
|
+
Entry point: ``python -m src.web`` (binds 127.0.0.1:8765 by default).
|
|
21
|
+
|
|
22
|
+
Layout
|
|
23
|
+
------
|
|
24
|
+
Server::
|
|
25
|
+
|
|
26
|
+
server.py the request loop and the launcher — no endpoint logic
|
|
27
|
+
core/ transport: Response, the Router, static file serving
|
|
28
|
+
api/ one module per concern, each exposing register(router)
|
|
29
|
+
readback/ state -> the JSON shapes the UI reads
|
|
30
|
+
overrides/ the drawer's mutations, incl. config.yml write-through
|
|
31
|
+
runner.py runs a stage on a worker thread
|
|
32
|
+
|
|
33
|
+
Front end (``static/``)::
|
|
34
|
+
|
|
35
|
+
index.html a shell with three mount points; views bring their markup
|
|
36
|
+
js/core/ dom, api, store (held payloads), shared constants
|
|
37
|
+
js/ui/ tab registry, layout mounting, the modals
|
|
38
|
+
js/views/ one module per tab, registered in views/index.js
|
|
39
|
+
js/charts/ one module per dashboard chart
|
|
40
|
+
js/panels/ dashboard blocks with their own load cycle
|
|
41
|
+
js/drawer/ the Override drawer and its sections
|
|
42
|
+
css/ partials, listed in styles.css
|
|
43
|
+
|
|
44
|
+
Adding an endpoint is a handler in the ``api`` module that owns the
|
|
45
|
+
concern. Adding a tab is a module in ``js/views/`` plus a line in its
|
|
46
|
+
registry. Neither ``server.py`` nor ``index.html`` needs to change.
|
|
47
|
+
"""
|
src/web/__main__.py
ADDED
src/web/api/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The console's HTTP API, one module per concern.
|
|
2
|
+
|
|
3
|
+
readbacks GET endpoints that only serialise state
|
|
4
|
+
inputs upload / list / remove the three source files
|
|
5
|
+
runs start Plan / Allocate / Reset, poll what's running
|
|
6
|
+
override_rows the drawer's rows and staged add/remove forms
|
|
7
|
+
recommender turn a suggested fix into an override row
|
|
8
|
+
settings shift bands, airport codes, extraction filters
|
|
9
|
+
zc the per-date Zone Controller list
|
|
10
|
+
export the .xlsx download
|
|
11
|
+
pages index.html and the static/ tree
|
|
12
|
+
|
|
13
|
+
Each module exposes ``register(router)``. To add an endpoint, add a
|
|
14
|
+
handler to the module that owns the concern — or add a module here and
|
|
15
|
+
list it in ``BLUEPRINTS``. Nothing else needs to change.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from ..core.router import Router
|
|
21
|
+
from . import (
|
|
22
|
+
export,
|
|
23
|
+
inputs,
|
|
24
|
+
override_rows,
|
|
25
|
+
pages,
|
|
26
|
+
readbacks,
|
|
27
|
+
recommender,
|
|
28
|
+
runs,
|
|
29
|
+
settings,
|
|
30
|
+
zc,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
#: Registered in order. Literal routes are a dict lookup, so order only
|
|
34
|
+
#: matters for the parameterised ones — keep the most specific first.
|
|
35
|
+
BLUEPRINTS = (
|
|
36
|
+
readbacks,
|
|
37
|
+
inputs,
|
|
38
|
+
runs,
|
|
39
|
+
override_rows,
|
|
40
|
+
recommender,
|
|
41
|
+
settings,
|
|
42
|
+
zc,
|
|
43
|
+
export,
|
|
44
|
+
pages,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_router() -> Router:
|
|
49
|
+
"""Assemble the full route table. Called once at start-up."""
|
|
50
|
+
router = Router()
|
|
51
|
+
for blueprint in BLUEPRINTS:
|
|
52
|
+
blueprint.register(router)
|
|
53
|
+
return router
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = ["BLUEPRINTS", "build_router"]
|
src/web/api/export.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Rendering the current state as an .xlsx download."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ...state import STATE
|
|
6
|
+
from ..core.responses import (
|
|
7
|
+
XLSX_CONTENT_TYPE,
|
|
8
|
+
XML_CONTENT_TYPE,
|
|
9
|
+
Response,
|
|
10
|
+
attachment,
|
|
11
|
+
server_error,
|
|
12
|
+
)
|
|
13
|
+
from ..core.router import Request, Router
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register(router: Router) -> None:
|
|
17
|
+
|
|
18
|
+
@router.get("/api/export.xlsx")
|
|
19
|
+
def export(_req: Request) -> Response:
|
|
20
|
+
from ...io.export import build_workbook, filename_for
|
|
21
|
+
try:
|
|
22
|
+
data = build_workbook(STATE)
|
|
23
|
+
except Exception as exc: # noqa: BLE001
|
|
24
|
+
return server_error(exc, context="export failed")
|
|
25
|
+
return attachment(data, XLSX_CONTENT_TYPE, filename_for(STATE))
|
|
26
|
+
|
|
27
|
+
@router.get("/api/export/allocations.xml")
|
|
28
|
+
def export_allocations_xml(_req: Request) -> Response:
|
|
29
|
+
from ...io.export_xml import build_allocations_xml, filename_for_xml
|
|
30
|
+
try:
|
|
31
|
+
data = build_allocations_xml(STATE)
|
|
32
|
+
except Exception as exc: # noqa: BLE001
|
|
33
|
+
return server_error(exc, context="allocations xml export failed")
|
|
34
|
+
return attachment(data, XML_CONTENT_TYPE, filename_for_xml(STATE))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = ["register"]
|
src/web/api/inputs.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""The three input files: upload, list, remove.
|
|
2
|
+
|
|
3
|
+
The daily flight schedule is held in the server's memory for the
|
|
4
|
+
session. The two period rosters are also kept on disk (``roster_store``)
|
|
5
|
+
and reloaded at start-up: they are uploaded about once a month, several
|
|
6
|
+
can be held at once, and the one covering the allocation date is used.
|
|
7
|
+
Nothing is ever written back to the source workbooks.
|
|
8
|
+
|
|
9
|
+
Which slot a file appears in is decided by its cadence, not by this
|
|
10
|
+
module: the daily flight schedule sits on the dashboard and the two
|
|
11
|
+
period rosters sit in the Setup sidebar. See ``INPUT_CADENCE`` in
|
|
12
|
+
``state.py``. Every endpoint here answers with the same shape as
|
|
13
|
+
``GET /api/inputs`` so the UI has one payload to render from.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import urllib.parse
|
|
20
|
+
from datetime import date as date_t
|
|
21
|
+
|
|
22
|
+
from ...config import load_config
|
|
23
|
+
from ...io.roster_library import roster_dates
|
|
24
|
+
from ...state import INPUT_KINDS, SETUP_INPUT_KINDS, STATE
|
|
25
|
+
from .. import readback
|
|
26
|
+
from ..readback.common import CONFIG_PATH
|
|
27
|
+
from ..core.responses import Response, bad_request, json_response
|
|
28
|
+
from ..core.router import Request, Router
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("src.web")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def register(router: Router) -> None:
|
|
34
|
+
|
|
35
|
+
@router.post("/api/inputs/{kind}")
|
|
36
|
+
def upload(req: Request) -> Response:
|
|
37
|
+
kind = req.params["kind"]
|
|
38
|
+
if kind not in INPUT_KINDS:
|
|
39
|
+
return bad_request(
|
|
40
|
+
f"unknown input {kind!r}; expected one of {list(INPUT_KINDS)}"
|
|
41
|
+
)
|
|
42
|
+
data = req.body
|
|
43
|
+
if not data:
|
|
44
|
+
return bad_request("empty upload")
|
|
45
|
+
# An .xlsx is a zip; anything else would blow up deep inside
|
|
46
|
+
# openpyxl on the next run, so reject it at the door.
|
|
47
|
+
if not data.startswith(b"PK"):
|
|
48
|
+
return bad_request(
|
|
49
|
+
"that doesn't look like an .xlsx file — re-save it as "
|
|
50
|
+
"Excel Workbook (.xlsx) and upload again"
|
|
51
|
+
)
|
|
52
|
+
filename = urllib.parse.unquote(
|
|
53
|
+
req.header("X-Filename") or f"{kind}.xlsx"
|
|
54
|
+
)
|
|
55
|
+
if kind in SETUP_INPUT_KINDS:
|
|
56
|
+
return _upload_roster(kind, filename, data)
|
|
57
|
+
up = STATE.set_input(kind, filename, data)
|
|
58
|
+
logger.info("[upload] %s: %s (%.0f KB)", kind, up.filename, len(data) / 1024)
|
|
59
|
+
return json_response({
|
|
60
|
+
"uploaded": up.as_json(),
|
|
61
|
+
**readback.read_inputs(STATE),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
@router.delete("/api/inputs/{kind}")
|
|
65
|
+
def remove(req: Request) -> Response:
|
|
66
|
+
STATE.clear_input(req.params["kind"])
|
|
67
|
+
return json_response(readback.read_inputs(STATE))
|
|
68
|
+
|
|
69
|
+
@router.delete("/api/inputs/{kind}/{file_id}")
|
|
70
|
+
def remove_one(req: Request) -> Response:
|
|
71
|
+
"""Drop one stored roster, keeping the others."""
|
|
72
|
+
kind = req.params["kind"]
|
|
73
|
+
if kind not in SETUP_INPUT_KINDS:
|
|
74
|
+
return bad_request(f"{kind!r} does not hold multiple files")
|
|
75
|
+
if not STATE.remove_roster(kind, req.params["file_id"]):
|
|
76
|
+
return bad_request("no such roster")
|
|
77
|
+
logger.info("[roster] removed %s %s", kind, req.params["file_id"])
|
|
78
|
+
return json_response(readback.read_inputs(STATE))
|
|
79
|
+
|
|
80
|
+
@router.get("/api/rosters/coverage")
|
|
81
|
+
def coverage(req: Request) -> Response:
|
|
82
|
+
"""Does the stored roster set cover ``?date=`` (default: the
|
|
83
|
+
session's run date, else the server's today) and the day after?"""
|
|
84
|
+
raw = (req.query.get("date") or [""])[0]
|
|
85
|
+
try:
|
|
86
|
+
day = date_t.fromisoformat(raw) if raw else (
|
|
87
|
+
STATE.run_date or date_t.today()
|
|
88
|
+
)
|
|
89
|
+
except ValueError:
|
|
90
|
+
return bad_request(f"bad date {raw!r}; expected YYYY-MM-DD")
|
|
91
|
+
return json_response(STATE.roster_coverage(day))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _upload_roster(kind: str, filename: str, data: bytes) -> Response:
|
|
95
|
+
"""Validate a period roster at the door, then keep it.
|
|
96
|
+
|
|
97
|
+
Parsing here means a wrong file (no ID column, duplicate ids, no
|
|
98
|
+
date columns) is refused with the reader's own message right away,
|
|
99
|
+
instead of surfacing as a failed Plan days later. The dates it
|
|
100
|
+
covers are what later picks it for an allocation date.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
dates = roster_dates(kind, data, load_config(CONFIG_PATH))
|
|
104
|
+
except Exception as exc: # noqa: BLE001 — reader errors are user-facing
|
|
105
|
+
return bad_request(f"could not read {filename}: {exc}")
|
|
106
|
+
if not dates:
|
|
107
|
+
return bad_request(
|
|
108
|
+
f"{filename} has no date columns (expected headers like "
|
|
109
|
+
"'Mon,31Aug'). Is this the right file?"
|
|
110
|
+
)
|
|
111
|
+
rf = STATE.add_roster(kind, filename, data, dates)
|
|
112
|
+
logger.info(
|
|
113
|
+
"[upload] %s: %s (%d days, %s to %s)",
|
|
114
|
+
kind, rf.filename, len(rf.dates), rf.start, rf.end,
|
|
115
|
+
)
|
|
116
|
+
return json_response({
|
|
117
|
+
"uploaded": {"kind": kind, **rf.as_json()},
|
|
118
|
+
**readback.read_inputs(STATE),
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
__all__ = ["register"]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""The Override drawer's write surface: the row table, the staged
|
|
2
|
+
add/remove forms, and the apply pass that materialises staged rows onto
|
|
3
|
+
the data sheets.
|
|
4
|
+
|
|
5
|
+
Every mutation here is session-scoped — rows live in ``STATE`` until
|
|
6
|
+
the server restarts. Reset keeps them on purpose, so an assigner can
|
|
7
|
+
re-run without retyping.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
from ...state import STATE
|
|
15
|
+
from .. import overrides as override_ops
|
|
16
|
+
from ..core.responses import (
|
|
17
|
+
Response,
|
|
18
|
+
bad_request,
|
|
19
|
+
json_response,
|
|
20
|
+
server_error,
|
|
21
|
+
)
|
|
22
|
+
from ..core.router import Request, Router
|
|
23
|
+
from .runs import parse_date
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("src.web")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _row_index(req: Request) -> int | None:
|
|
29
|
+
"""The ``{index}`` path param as an int, or None if it isn't one."""
|
|
30
|
+
try:
|
|
31
|
+
return int(req.params["index"])
|
|
32
|
+
except (KeyError, TypeError, ValueError):
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def register(router: Router) -> None:
|
|
37
|
+
|
|
38
|
+
@router.post("/api/overrides")
|
|
39
|
+
def add_row(req: Request) -> Response:
|
|
40
|
+
values = req.json().get("values")
|
|
41
|
+
if not isinstance(values, list):
|
|
42
|
+
return bad_request("body.values must be a list of strings")
|
|
43
|
+
index = override_ops.add_override(STATE, [str(v) for v in values])
|
|
44
|
+
return json_response({"index": index})
|
|
45
|
+
|
|
46
|
+
@router.put("/api/overrides/{index}")
|
|
47
|
+
def update_row(req: Request) -> Response:
|
|
48
|
+
row_index = _row_index(req)
|
|
49
|
+
if row_index is None:
|
|
50
|
+
return bad_request(f"row index must be an integer, got {req.params['index']!r}")
|
|
51
|
+
body = req.json()
|
|
52
|
+
values = body.get("values")
|
|
53
|
+
if not isinstance(values, list):
|
|
54
|
+
return bad_request("body.values must be a list of strings")
|
|
55
|
+
try:
|
|
56
|
+
override_ops.update_override(
|
|
57
|
+
STATE, row_index, [str(v) for v in values],
|
|
58
|
+
)
|
|
59
|
+
except IndexError as exc:
|
|
60
|
+
return bad_request(str(exc))
|
|
61
|
+
|
|
62
|
+
# Re-apply staged rows after an edit so add_flight / remove_flight
|
|
63
|
+
# / add_staff / remove_staff / change_role / sick take effect on
|
|
64
|
+
# the data immediately; the dashboard refresh then shows it.
|
|
65
|
+
applied: dict[str, int] = {}
|
|
66
|
+
d_day = parse_date(body.get("date"))
|
|
67
|
+
if d_day is not None:
|
|
68
|
+
from ... import staged_overrides as _so
|
|
69
|
+
applied = _so.apply_staged_overrides(STATE, d_day)
|
|
70
|
+
return json_response({"updated": True, "applied": applied})
|
|
71
|
+
|
|
72
|
+
@router.delete("/api/overrides/{index}")
|
|
73
|
+
def delete_row(req: Request) -> Response:
|
|
74
|
+
row_index = _row_index(req)
|
|
75
|
+
if row_index is None:
|
|
76
|
+
return bad_request(f"row index must be an integer, got {req.params['index']!r}")
|
|
77
|
+
try:
|
|
78
|
+
override_ops.delete_override(STATE, row_index)
|
|
79
|
+
except IndexError as exc:
|
|
80
|
+
return bad_request(str(exc))
|
|
81
|
+
return json_response({"deleted": True})
|
|
82
|
+
|
|
83
|
+
@router.post("/api/overrides/apply_staged")
|
|
84
|
+
def apply_staged(req: Request) -> Response:
|
|
85
|
+
d_day = parse_date(req.json().get("date"))
|
|
86
|
+
if d_day is None:
|
|
87
|
+
return bad_request("body.date required (YYYY-MM-DD)")
|
|
88
|
+
from ... import staged_overrides as _so
|
|
89
|
+
try:
|
|
90
|
+
counts = _so.apply_staged_overrides(STATE, d_day)
|
|
91
|
+
except Exception as exc: # noqa: BLE001
|
|
92
|
+
return server_error(exc, context="apply_staged failed")
|
|
93
|
+
return json_response({"applied": counts})
|
|
94
|
+
|
|
95
|
+
@router.post("/api/staged_form/{op}")
|
|
96
|
+
def staged_form(req: Request) -> Response:
|
|
97
|
+
"""Drawer forms: add_flight / remove_flight / add_staff /
|
|
98
|
+
remove_staff. Writes the override row AND applies it immediately
|
|
99
|
+
so the dashboard reflects the projected state on next refresh.
|
|
100
|
+
|
|
101
|
+
``ops_date`` is the day being planned and must be ISO. It is
|
|
102
|
+
kept separate from the row's own ``date`` column, which a
|
|
103
|
+
remove_flight row carries as MM/DD — overloading one key for
|
|
104
|
+
both meant the MM/DD value was parsed as the ops date and every
|
|
105
|
+
remove_flight submission was rejected. ``date`` is still read as
|
|
106
|
+
a fallback so an older client keeps working.
|
|
107
|
+
"""
|
|
108
|
+
from ...schemas import STAGED_FORM_OVERRIDE_TYPES
|
|
109
|
+
op_type = req.params["op"]
|
|
110
|
+
valid = {t.value for t in STAGED_FORM_OVERRIDE_TYPES}
|
|
111
|
+
if op_type not in valid:
|
|
112
|
+
return bad_request(
|
|
113
|
+
f"unknown staged form op: {op_type!r}. valid: {sorted(valid)}"
|
|
114
|
+
)
|
|
115
|
+
body = req.json()
|
|
116
|
+
d_day = parse_date(body.get("ops_date")) or parse_date(body.get("date"))
|
|
117
|
+
if d_day is None:
|
|
118
|
+
return bad_request("body.ops_date required (YYYY-MM-DD)")
|
|
119
|
+
payload = {
|
|
120
|
+
k: str(v).strip() for k, v in body.items()
|
|
121
|
+
if k != "ops_date" and isinstance(v, (str, int, float)) and str(v).strip()
|
|
122
|
+
}
|
|
123
|
+
try:
|
|
124
|
+
index = override_ops.write_staged_override(STATE, op_type, payload)
|
|
125
|
+
from ... import staged_overrides as _so
|
|
126
|
+
applied = _so.apply_staged_overrides(STATE, d_day)
|
|
127
|
+
except Exception as exc: # noqa: BLE001
|
|
128
|
+
logger.exception("staged form %s failed", op_type)
|
|
129
|
+
return server_error(exc, context=f"{op_type} failed")
|
|
130
|
+
logger.info(
|
|
131
|
+
"[staged-form] %s: row %s written, applied=%s", op_type, index, applied,
|
|
132
|
+
)
|
|
133
|
+
return json_response({
|
|
134
|
+
"ok": True, "index": index, "op": op_type, "applied": applied,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
__all__ = ["register"]
|
src/web/api/pages.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""The page itself: index.html and everything under ``static/``.
|
|
2
|
+
|
|
3
|
+
Registered last, and the wildcard is scoped to ``/static/`` rather than
|
|
4
|
+
``/``, so an unmatched ``/api/...`` path still returns a JSON 404
|
|
5
|
+
instead of being answered with HTML.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ..core import static_files
|
|
11
|
+
from ..core.responses import Response
|
|
12
|
+
from ..core.router import Request, Router
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register(router: Router) -> None:
|
|
16
|
+
|
|
17
|
+
@router.get("/")
|
|
18
|
+
def index(_req: Request) -> Response:
|
|
19
|
+
return static_files.serve(static_files.INDEX)
|
|
20
|
+
|
|
21
|
+
@router.get("/index.html")
|
|
22
|
+
def index_html(_req: Request) -> Response:
|
|
23
|
+
return static_files.serve(static_files.INDEX)
|
|
24
|
+
|
|
25
|
+
@router.get("/static/{path*}")
|
|
26
|
+
def asset(req: Request) -> Response:
|
|
27
|
+
return static_files.serve(req.params["path"])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = ["register"]
|
src/web/api/readbacks.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""GET endpoints that only serialise state — no mutation, no side
|
|
2
|
+
effects.
|
|
3
|
+
|
|
4
|
+
The bulk of them are a straight ``path -> readback function`` mapping,
|
|
5
|
+
so they're registered from a table: a new tab needs a readback function
|
|
6
|
+
and one line here, not a new handler.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ...state import STATE
|
|
12
|
+
from .. import readback
|
|
13
|
+
from ..core.responses import Response, json_response
|
|
14
|
+
from ..core.router import Request, Router
|
|
15
|
+
|
|
16
|
+
#: Endpoints whose handler is exactly ``fn(STATE)``.
|
|
17
|
+
STATE_READBACKS = {
|
|
18
|
+
"/api/inputs": readback.read_inputs,
|
|
19
|
+
"/api/server_date": readback.read_server_date,
|
|
20
|
+
"/api/dashboard": readback.read_dashboard,
|
|
21
|
+
"/api/allocations": readback.read_allocations,
|
|
22
|
+
"/api/workload": readback.read_workload,
|
|
23
|
+
"/api/pairs": readback.read_pairs,
|
|
24
|
+
"/api/warnings": readback.read_warnings,
|
|
25
|
+
"/api/unallocated": readback.read_unallocated,
|
|
26
|
+
"/api/recommendations": readback.read_recommendations,
|
|
27
|
+
"/api/overrides": readback.read_overrides,
|
|
28
|
+
"/api/plan": readback.read_plan_summary,
|
|
29
|
+
"/api/handlers": readback.read_handlers,
|
|
30
|
+
"/api/staffing": readback.read_staffing,
|
|
31
|
+
"/api/staff_names": readback.read_staff_names,
|
|
32
|
+
"/api/roster_full": readback.read_roster_full,
|
|
33
|
+
"/api/sv_portal/columns": readback.read_sv_portal_columns,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def register(router: Router) -> None:
|
|
38
|
+
for path, fn in STATE_READBACKS.items():
|
|
39
|
+
router.add("GET", path, _state_handler(fn))
|
|
40
|
+
|
|
41
|
+
@router.get("/api/shift_limits")
|
|
42
|
+
def shift_limits(_req: Request) -> Response:
|
|
43
|
+
return json_response(readback.read_shift_limits())
|
|
44
|
+
|
|
45
|
+
@router.get("/api/override_types")
|
|
46
|
+
def override_types(_req: Request) -> Response:
|
|
47
|
+
"""The canonical list of user-facing override types plus the
|
|
48
|
+
per-type relevant columns, so the drawer always matches the
|
|
49
|
+
backend enum and renders only the cells a row type cares about.
|
|
50
|
+
Single source of truth lives in schemas."""
|
|
51
|
+
from ...schemas import (
|
|
52
|
+
OVERRIDE_TYPE_RELEVANT_COLS,
|
|
53
|
+
OVERRIDE_TYPES_FOR_UI,
|
|
54
|
+
OverrideType,
|
|
55
|
+
)
|
|
56
|
+
return json_response({
|
|
57
|
+
"types": [t.value for t in OVERRIDE_TYPES_FOR_UI],
|
|
58
|
+
"relevant_cols": {
|
|
59
|
+
t.value: list(OVERRIDE_TYPE_RELEVANT_COLS[t])
|
|
60
|
+
for t in OverrideType
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _state_handler(fn):
|
|
66
|
+
def handler(_req: Request) -> Response:
|
|
67
|
+
return json_response(fn(STATE))
|
|
68
|
+
handler.__name__ = getattr(fn, "__name__", "readback")
|
|
69
|
+
return handler
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
__all__ = ["STATE_READBACKS", "register"]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Turning an unallocated flight's suggested fix into an override row.
|
|
2
|
+
|
|
3
|
+
One at a time from the Unallocated tab's chips, or all of them at once
|
|
4
|
+
when a bad day leaves a list too long to click through.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from ...state import STATE
|
|
12
|
+
from .. import overrides as override_ops
|
|
13
|
+
from .. import readback
|
|
14
|
+
from ..core.responses import Response, bad_request, json_response
|
|
15
|
+
from ..core.router import Request, Router
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("src.web")
|
|
18
|
+
|
|
19
|
+
#: How many per-flight failures to quote back in the apply_all response.
|
|
20
|
+
MAX_SAMPLE_ERRORS = 5
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def register(router: Router) -> None:
|
|
24
|
+
|
|
25
|
+
@router.post("/api/recommender/apply")
|
|
26
|
+
def apply_one(req: Request) -> Response:
|
|
27
|
+
body = req.json()
|
|
28
|
+
kind = body.get("kind") if isinstance(body.get("kind"), str) else None
|
|
29
|
+
payload = body.get("payload")
|
|
30
|
+
if not kind or not isinstance(payload, dict):
|
|
31
|
+
return bad_request("body must have kind:str and payload:dict")
|
|
32
|
+
try:
|
|
33
|
+
index = override_ops.apply_phase_r_override(STATE, kind, payload)
|
|
34
|
+
except (ValueError, KeyError) as exc:
|
|
35
|
+
return bad_request(str(exc))
|
|
36
|
+
return json_response({"index": index, "kind": kind})
|
|
37
|
+
|
|
38
|
+
@router.post("/api/recommender/apply_all")
|
|
39
|
+
def apply_all(_req: Request) -> Response:
|
|
40
|
+
"""For each unallocated flight, take the LEAST intrusive
|
|
41
|
+
suggestion and write it as an override — one click instead of
|
|
42
|
+
dozens when a bad day leaves a long unallocated list."""
|
|
43
|
+
recs = readback.read_recommendations(STATE).get("recommendations", [])
|
|
44
|
+
applied = failed = 0
|
|
45
|
+
errs: list[str] = []
|
|
46
|
+
for rec in recs:
|
|
47
|
+
suggestions = rec.get("suggestions") or []
|
|
48
|
+
if not suggestions:
|
|
49
|
+
continue
|
|
50
|
+
best = min(suggestions, key=lambda s: s.get("intrusiveness", 100))
|
|
51
|
+
kind = best.get("type")
|
|
52
|
+
payload = best.get("payload") or {}
|
|
53
|
+
if not kind or not isinstance(payload, dict):
|
|
54
|
+
failed += 1
|
|
55
|
+
continue
|
|
56
|
+
try:
|
|
57
|
+
override_ops.apply_phase_r_override(STATE, kind, payload)
|
|
58
|
+
applied += 1
|
|
59
|
+
except Exception as exc: # noqa: BLE001 — one bad row shouldn't
|
|
60
|
+
failed += 1 # abandon the rest of the list
|
|
61
|
+
if len(errs) < MAX_SAMPLE_ERRORS:
|
|
62
|
+
errs.append(f"{rec.get('flt', '?')}: {exc!r}")
|
|
63
|
+
logger.info(
|
|
64
|
+
"[apply-all] applied=%s failed=%s total=%s", applied, failed, len(recs),
|
|
65
|
+
)
|
|
66
|
+
return json_response({
|
|
67
|
+
"applied": applied, "failed": failed,
|
|
68
|
+
"total": len(recs), "sample_errors": errs,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
__all__ = ["MAX_SAMPLE_ERRORS", "register"]
|