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.
Files changed (71) hide show
  1. flight_alloc-0.0.1.dist-info/METADATA +11 -0
  2. flight_alloc-0.0.1.dist-info/RECORD +71 -0
  3. flight_alloc-0.0.1.dist-info/WHEEL +5 -0
  4. flight_alloc-0.0.1.dist-info/entry_points.txt +2 -0
  5. flight_alloc-0.0.1.dist-info/top_level.txt +1 -0
  6. src/__init__.py +0 -0
  7. src/allocator/__init__.py +6 -0
  8. src/allocator/caps.py +513 -0
  9. src/allocator/eligibility.py +302 -0
  10. src/allocator/greedy_fallback.py +106 -0
  11. src/allocator/invariants.py +124 -0
  12. src/allocator/p2f_priority.py +142 -0
  13. src/allocator/pair_validation.py +108 -0
  14. src/allocator/pairings.py +554 -0
  15. src/allocator/postpass_break.py +366 -0
  16. src/allocator/postpass_intl.py +483 -0
  17. src/allocator/postpass_p2f.py +723 -0
  18. src/allocator/postpass_rebalance.py +244 -0
  19. src/allocator/postsolve.py +549 -0
  20. src/allocator/recommender.py +348 -0
  21. src/allocator/windows.py +377 -0
  22. src/cli.py +41 -0
  23. src/config.py +168 -0
  24. src/greedy_fallback.py +102 -0
  25. src/io/__init__.py +0 -0
  26. src/io/export.py +270 -0
  27. src/io/export_xml.py +66 -0
  28. src/io/readers.py +1048 -0
  29. src/io/roster_library.py +89 -0
  30. src/plan.py +192 -0
  31. src/recommender_staffing.py +329 -0
  32. src/roster_store.py +159 -0
  33. src/schemas.py +1244 -0
  34. src/solver/__init__.py +0 -0
  35. src/solver/allocator_cpsat.py +1412 -0
  36. src/staged_overrides.py +468 -0
  37. src/state.py +494 -0
  38. src/step1_clean_flights.py +286 -0
  39. src/step2_extract_roster.py +316 -0
  40. src/step3_allocate_flights.py +1639 -0
  41. src/web/__init__.py +47 -0
  42. src/web/__main__.py +9 -0
  43. src/web/api/__init__.py +56 -0
  44. src/web/api/export.py +37 -0
  45. src/web/api/inputs.py +122 -0
  46. src/web/api/override_rows.py +138 -0
  47. src/web/api/pages.py +30 -0
  48. src/web/api/readbacks.py +72 -0
  49. src/web/api/recommender.py +72 -0
  50. src/web/api/runs.py +102 -0
  51. src/web/api/settings.py +201 -0
  52. src/web/api/zc.py +117 -0
  53. src/web/core/__init__.py +5 -0
  54. src/web/core/responses.py +91 -0
  55. src/web/core/router.py +167 -0
  56. src/web/core/static_files.py +85 -0
  57. src/web/overrides/__init__.py +66 -0
  58. src/web/overrides/airports.py +261 -0
  59. src/web/overrides/break_time.py +83 -0
  60. src/web/overrides/config_yaml.py +21 -0
  61. src/web/overrides/filters.py +187 -0
  62. src/web/overrides/rows.py +110 -0
  63. src/web/readback/__init__.py +67 -0
  64. src/web/readback/common.py +68 -0
  65. src/web/readback/dashboard.py +83 -0
  66. src/web/readback/planning.py +335 -0
  67. src/web/readback/session.py +158 -0
  68. src/web/readback/tables.py +163 -0
  69. src/web/runner.py +168 -0
  70. src/web/server.py +185 -0
  71. 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
@@ -0,0 +1,9 @@
1
+ """``python -m src.web`` entry point."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+
6
+ from .server import main
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
@@ -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"]
@@ -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"]