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
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Serve the ``static/`` tree.
|
|
2
|
+
|
|
3
|
+
The front end is a directory of ES modules and CSS partials rather than
|
|
4
|
+
two flat files, so this resolves an arbitrary relative path — but only
|
|
5
|
+
inside STATIC_DIR, and only for an allow-listed extension. A path that
|
|
6
|
+
resolves outside the tree (``../``, an absolute path, a symlink out) is
|
|
7
|
+
a 404, not a read.
|
|
8
|
+
|
|
9
|
+
Every response carries ``Cache-Control: no-store`` (set by the request
|
|
10
|
+
loop), and index.html gets the tree's newest mtime stamped onto its
|
|
11
|
+
entry-point URLs, so the browser always pulls current code after an
|
|
12
|
+
edit even if a proxy ignores the header.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .responses import Response, not_found
|
|
21
|
+
|
|
22
|
+
STATIC_DIR = Path(__file__).resolve().parents[1] / "static"
|
|
23
|
+
|
|
24
|
+
INDEX = "index.html"
|
|
25
|
+
|
|
26
|
+
#: Extension -> Content-Type. An extension absent from this map is not
|
|
27
|
+
#: servable, which is the allow-list.
|
|
28
|
+
CONTENT_TYPES = {
|
|
29
|
+
".html": "text/html; charset=utf-8",
|
|
30
|
+
".js": "application/javascript; charset=utf-8",
|
|
31
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
32
|
+
".css": "text/css; charset=utf-8",
|
|
33
|
+
".json": "application/json; charset=utf-8",
|
|
34
|
+
".svg": "image/svg+xml",
|
|
35
|
+
".png": "image/png",
|
|
36
|
+
".ico": "image/x-icon",
|
|
37
|
+
".woff": "font/woff",
|
|
38
|
+
".woff2": "font/woff2",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#: URLs in index.html that get a ?v= cache-buster stamped on.
|
|
42
|
+
_VERSIONED = re.compile(r'((?:href|src)=")(/static/[^"?]+\.(?:css|js))(")')
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _tree_version() -> int:
|
|
46
|
+
"""Newest mtime across the served tree. One number for the whole
|
|
47
|
+
front end: a change to any module busts the entry points, which is
|
|
48
|
+
what the browser actually re-requests."""
|
|
49
|
+
newest = 0
|
|
50
|
+
for path in STATIC_DIR.rglob("*"):
|
|
51
|
+
if path.suffix in CONTENT_TYPES and path.is_file():
|
|
52
|
+
newest = max(newest, int(path.stat().st_mtime))
|
|
53
|
+
return newest
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve(rel: str) -> Path | None:
|
|
57
|
+
"""Map a URL path to a file inside STATIC_DIR, or None if it escapes
|
|
58
|
+
the tree, has a non-servable extension, or doesn't exist."""
|
|
59
|
+
rel = rel.lstrip("/")
|
|
60
|
+
if not rel:
|
|
61
|
+
rel = INDEX
|
|
62
|
+
candidate = (STATIC_DIR / rel).resolve()
|
|
63
|
+
try:
|
|
64
|
+
candidate.relative_to(STATIC_DIR.resolve())
|
|
65
|
+
except ValueError:
|
|
66
|
+
return None # traversal attempt — outside the tree
|
|
67
|
+
if candidate.suffix not in CONTENT_TYPES or not candidate.is_file():
|
|
68
|
+
return None
|
|
69
|
+
return candidate
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def serve(rel_path: str) -> Response:
|
|
73
|
+
"""Read one file out of ``static/`` and wrap it in a Response."""
|
|
74
|
+
path = _resolve(rel_path)
|
|
75
|
+
if path is None:
|
|
76
|
+
return not_found()
|
|
77
|
+
body = path.read_bytes()
|
|
78
|
+
if path.name == INDEX:
|
|
79
|
+
version = _tree_version()
|
|
80
|
+
text = _VERSIONED.sub(rf"\g<1>\g<2>?v={version}\g<3>", body.decode("utf-8"))
|
|
81
|
+
body = text.encode("utf-8")
|
|
82
|
+
return Response(status=200, body=body, content_type=CONTENT_TYPES[path.suffix])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
__all__ = ["CONTENT_TYPES", "INDEX", "STATIC_DIR", "serve"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Override mutations exposed by the web UI's drawer.
|
|
2
|
+
|
|
3
|
+
Three concerns, three modules:
|
|
4
|
+
|
|
5
|
+
rows the drawer's override table, the recommender's
|
|
6
|
+
one-click fixes, and the staged add/remove forms
|
|
7
|
+
airports list / add / remove international airport codes
|
|
8
|
+
break_time the floating mid-shift break length
|
|
9
|
+
filters write-through of the extraction-filter list
|
|
10
|
+
config_yaml the shared config.yml handle + write lock
|
|
11
|
+
|
|
12
|
+
``airports``, ``break_time`` and ``filters`` edit ``configs/config.yml`` so the
|
|
13
|
+
engine picks the change up on the next run with no manual edit step.
|
|
14
|
+
The edits are targeted line splices rather than a YAML round-trip, so
|
|
15
|
+
the file's commentary survives.
|
|
16
|
+
|
|
17
|
+
Import from here — the sub-module split is an implementation detail.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from .airports import (
|
|
23
|
+
add_intl_airport_code,
|
|
24
|
+
list_intl_airports,
|
|
25
|
+
preview_intl_airport_yaml_diff,
|
|
26
|
+
remove_intl_airport_code,
|
|
27
|
+
validate_intl_airport,
|
|
28
|
+
)
|
|
29
|
+
from .break_time import get_break_length, set_break_length
|
|
30
|
+
from .config_yaml import CONFIG_YML, config_write_lock
|
|
31
|
+
from .filters import (
|
|
32
|
+
add_extraction_filter,
|
|
33
|
+
list_extraction_filters,
|
|
34
|
+
remove_extraction_filter,
|
|
35
|
+
update_extraction_filter,
|
|
36
|
+
)
|
|
37
|
+
from .rows import (
|
|
38
|
+
add_override,
|
|
39
|
+
apply_phase_r_override,
|
|
40
|
+
clear_all_overrides,
|
|
41
|
+
delete_override,
|
|
42
|
+
update_override,
|
|
43
|
+
write_staged_override,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"CONFIG_YML",
|
|
48
|
+
"add_extraction_filter",
|
|
49
|
+
"add_intl_airport_code",
|
|
50
|
+
"add_override",
|
|
51
|
+
"apply_phase_r_override",
|
|
52
|
+
"clear_all_overrides",
|
|
53
|
+
"config_write_lock",
|
|
54
|
+
"delete_override",
|
|
55
|
+
"get_break_length",
|
|
56
|
+
"list_extraction_filters",
|
|
57
|
+
"list_intl_airports",
|
|
58
|
+
"preview_intl_airport_yaml_diff",
|
|
59
|
+
"remove_extraction_filter",
|
|
60
|
+
"remove_intl_airport_code",
|
|
61
|
+
"set_break_length",
|
|
62
|
+
"update_extraction_filter",
|
|
63
|
+
"update_override",
|
|
64
|
+
"validate_intl_airport",
|
|
65
|
+
"write_staged_override",
|
|
66
|
+
]
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""The Setup sidebar's international airport codes: list, add, remove.
|
|
2
|
+
|
|
3
|
+
Edits ``io.sv_portal.international_airport_codes`` in
|
|
4
|
+
``configs/config.yml`` so the engine picks the change up on the next run
|
|
5
|
+
with no manual edit step. Validation for an add is split out from the
|
|
6
|
+
write so the caller can preview the diff before anything touches the
|
|
7
|
+
file.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from .config_yaml import CONFIG_YML, config_write_lock
|
|
17
|
+
|
|
18
|
+
_INTL_CODE_RE = re.compile(r"^[A-Z]{3}$")
|
|
19
|
+
_INTL_HEADER_RE = re.compile(r"^\s*international_airport_codes\s*:\s*(#.*)?$")
|
|
20
|
+
_INTL_ITEM_RE = re.compile(r"^(\s*-\s+)([A-Z]{3})\s*(?:#\s*(.*))?$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _find_intl_block(lines: list[str]) -> tuple[int, list[int]]:
|
|
24
|
+
"""Locate the ``international_airport_codes`` list in config.yml.
|
|
25
|
+
|
|
26
|
+
Returns ``(header_idx, item_idxs)``: the header line and every
|
|
27
|
+
``- XXX`` item line under it, in file order. ``header_idx`` is -1
|
|
28
|
+
when the section is missing.
|
|
29
|
+
"""
|
|
30
|
+
header_idx = -1
|
|
31
|
+
item_idxs: list[int] = []
|
|
32
|
+
for i, ln in enumerate(lines):
|
|
33
|
+
if header_idx < 0:
|
|
34
|
+
if _INTL_HEADER_RE.match(ln):
|
|
35
|
+
header_idx = i
|
|
36
|
+
continue
|
|
37
|
+
if _INTL_ITEM_RE.match(ln):
|
|
38
|
+
item_idxs.append(i)
|
|
39
|
+
continue
|
|
40
|
+
# Stop scanning once we leave the list block: a line at
|
|
41
|
+
# less-or-equal indent than the header, or a new YAML key at
|
|
42
|
+
# the io.sv_portal level.
|
|
43
|
+
if ln.strip() and not ln.startswith(" " * 6):
|
|
44
|
+
break
|
|
45
|
+
return header_idx, item_idxs
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_existing_intl_codes() -> list[str]:
|
|
49
|
+
raw = yaml.safe_load(CONFIG_YML.read_text(encoding="utf-8"))
|
|
50
|
+
return list(
|
|
51
|
+
raw.get("io", {})
|
|
52
|
+
.get("sv_portal", {})
|
|
53
|
+
.get("international_airport_codes", [])
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _read_routing_drop_dep_codes() -> list[str]:
|
|
58
|
+
"""Read ``io.sv_portal.routing_drop_dep_codes`` — Gulf-3 airports
|
|
59
|
+
whose departing flights are dropped at Step 1 extraction (Phase 3,
|
|
60
|
+
2026-05-14, Change 1). Validation rejects adding these to the
|
|
61
|
+
international list (they're a different concept — dropped flights
|
|
62
|
+
can't be allocated as INTL).
|
|
63
|
+
|
|
64
|
+
Empty list when the key is missing; caller treats that as "no
|
|
65
|
+
exclusions configured."
|
|
66
|
+
"""
|
|
67
|
+
raw = yaml.safe_load(CONFIG_YML.read_text(encoding="utf-8"))
|
|
68
|
+
return list(
|
|
69
|
+
raw.get("io", {})
|
|
70
|
+
.get("sv_portal", {})
|
|
71
|
+
.get("routing_drop_dep_codes", [])
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def validate_intl_airport(
|
|
76
|
+
code: str, name: str | None = None,
|
|
77
|
+
) -> tuple[str, str]:
|
|
78
|
+
"""Sanitize + validate a proposed international-airport addition.
|
|
79
|
+
|
|
80
|
+
Returns ``(sanitized_code, sanitized_name)``. Raises ``ValueError``
|
|
81
|
+
with a user-facing message on any failure. The three checks are:
|
|
82
|
+
|
|
83
|
+
1. Code matches the IATA 3-uppercase-letter convention.
|
|
84
|
+
2. Code is not already in ``international_airport_codes``.
|
|
85
|
+
3. Code is not in ``routing_drop_dep_codes`` (Gulf-3 — AUH/DOH/DXB
|
|
86
|
+
by default). Flights departing from those airports are dropped
|
|
87
|
+
at Step 1 (Phase 3 / Change 1, 2026-05-14), so classifying them
|
|
88
|
+
as INTL would never take effect.
|
|
89
|
+
|
|
90
|
+
Pure function: does NOT touch disk. Callers compose it with
|
|
91
|
+
``_apply_intl_airport_to_yaml`` for the actual mutation, either
|
|
92
|
+
directly (the existing operator route) or via the approval chassis
|
|
93
|
+
(the new agent tool).
|
|
94
|
+
"""
|
|
95
|
+
if not isinstance(code, str):
|
|
96
|
+
raise ValueError("code must be a string")
|
|
97
|
+
raw = code.strip()
|
|
98
|
+
# Strict check BEFORE upper-casing: Phase 2 spec rejects lowercase
|
|
99
|
+
# outright (it's an API contract violation — the UI uppercases on
|
|
100
|
+
# input so a lowercase request can only come from a script). This
|
|
101
|
+
# changed 2026-05-13; previous behavior silently upper-cased.
|
|
102
|
+
if not _INTL_CODE_RE.match(raw):
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"code must be exactly 3 uppercase letters (got {code!r})"
|
|
105
|
+
)
|
|
106
|
+
code = raw # already validated as uppercase
|
|
107
|
+
existing = {c.upper() for c in _read_existing_intl_codes()}
|
|
108
|
+
if code in existing:
|
|
109
|
+
raise ValueError(f"{code} is already in the international list")
|
|
110
|
+
drop_codes = {c.upper() for c in _read_routing_drop_dep_codes()}
|
|
111
|
+
if code in drop_codes:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"{code} is in routing_drop_dep_codes (the Gulf-3 "
|
|
114
|
+
"AUH/DOH/DXB list). Flights departing from those airports "
|
|
115
|
+
"are dropped at Step 1, so adding the code to the INTL list "
|
|
116
|
+
"would have no effect. Use a different code or update "
|
|
117
|
+
"routing_drop_dep_codes directly."
|
|
118
|
+
)
|
|
119
|
+
cleaned_name = ""
|
|
120
|
+
if name:
|
|
121
|
+
cleaned_name = re.sub(r"[^\w\s\-/().,]", "", str(name)).strip()
|
|
122
|
+
return code, cleaned_name
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _apply_intl_airport_to_yaml(code: str, name: str = "") -> None:
|
|
126
|
+
"""Insert ``code`` (with optional ``name`` as a trailing comment)
|
|
127
|
+
under ``international_airport_codes`` in ``configs/config.yml``.
|
|
128
|
+
|
|
129
|
+
Pure mutation: assumes the caller has already validated. The file
|
|
130
|
+
edit is a targeted line-insertion (not a YAML round-trip) so the
|
|
131
|
+
file's commentary survives. Locates the last existing entry under
|
|
132
|
+
the list header and inserts after it, matching that entry's
|
|
133
|
+
indentation.
|
|
134
|
+
|
|
135
|
+
Raises ``ValueError`` if the section can't be found at all — that's
|
|
136
|
+
a structural problem with the config, not a validation error, and
|
|
137
|
+
callers should bubble it up.
|
|
138
|
+
"""
|
|
139
|
+
with config_write_lock:
|
|
140
|
+
text = CONFIG_YML.read_text(encoding="utf-8")
|
|
141
|
+
lines = text.splitlines(keepends=False)
|
|
142
|
+
header_idx, item_idxs = _find_intl_block(lines)
|
|
143
|
+
if header_idx < 0:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
"could not find international_airport_codes section in config.yml"
|
|
146
|
+
)
|
|
147
|
+
item_prefix = " - " # fallback if no item present yet
|
|
148
|
+
last_item_idx = -1
|
|
149
|
+
if item_idxs:
|
|
150
|
+
last_item_idx = item_idxs[-1]
|
|
151
|
+
item_prefix = _INTL_ITEM_RE.match(lines[last_item_idx]).group(1)
|
|
152
|
+
insert_at = (last_item_idx if last_item_idx >= 0 else header_idx) + 1
|
|
153
|
+
new_line = f"{item_prefix}{code}"
|
|
154
|
+
if name:
|
|
155
|
+
new_line = f"{new_line} # {name}"
|
|
156
|
+
lines.insert(insert_at, new_line)
|
|
157
|
+
CONFIG_YML.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def preview_intl_airport_yaml_diff(code: str, name: str = "") -> str:
|
|
161
|
+
"""Return a unified-diff-ish string of what ``_apply_intl_airport_to_yaml``
|
|
162
|
+
would do. Used by the agent approval preview.
|
|
163
|
+
|
|
164
|
+
Cheap: just rebuilds the would-be insert line and returns a small
|
|
165
|
+
diff string. Does NOT touch disk; safe to call inside the preview
|
|
166
|
+
builder.
|
|
167
|
+
"""
|
|
168
|
+
item_prefix = " - "
|
|
169
|
+
new_line = f"{item_prefix}{code}"
|
|
170
|
+
if name:
|
|
171
|
+
new_line = f"{new_line} # {name}"
|
|
172
|
+
return (
|
|
173
|
+
f"--- configs/config.yml (before)\n"
|
|
174
|
+
f"+++ configs/config.yml (after)\n"
|
|
175
|
+
f"@@ io.sv_portal.international_airport_codes @@\n"
|
|
176
|
+
f"+{new_line}\n"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def add_intl_airport_code(code: str, name: str | None = None) -> dict[str, str]:
|
|
181
|
+
"""Validate + mutate in one shot. Existing direct-write entry point
|
|
182
|
+
(kept for the override drawer's airport form / direct API route).
|
|
183
|
+
|
|
184
|
+
The agent tool (``src.agent.tools.add_intl_airport``) uses
|
|
185
|
+
the two underlying functions (``validate_intl_airport`` and
|
|
186
|
+
``_apply_intl_airport_to_yaml``) separately so the approval chassis
|
|
187
|
+
can preview the diff before any write happens.
|
|
188
|
+
"""
|
|
189
|
+
sanitized_code, sanitized_name = validate_intl_airport(code, name)
|
|
190
|
+
_apply_intl_airport_to_yaml(sanitized_code, sanitized_name)
|
|
191
|
+
return {"code": sanitized_code, "name": sanitized_name}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def list_intl_airports() -> list[dict[str, str]]:
|
|
195
|
+
"""Every configured international airport, in file order, as
|
|
196
|
+
``{"code", "name"}``. ``name`` is the trailing ``# comment`` the add
|
|
197
|
+
form writes, or "" when there is none.
|
|
198
|
+
|
|
199
|
+
The codes come from the parsed YAML — exactly what the engine will
|
|
200
|
+
read — and the names from the raw lines, since YAML drops comments.
|
|
201
|
+
"""
|
|
202
|
+
text = CONFIG_YML.read_text(encoding="utf-8")
|
|
203
|
+
lines = text.splitlines(keepends=False)
|
|
204
|
+
_, item_idxs = _find_intl_block(lines)
|
|
205
|
+
names: dict[str, str] = {}
|
|
206
|
+
for i in item_idxs:
|
|
207
|
+
m = _INTL_ITEM_RE.match(lines[i])
|
|
208
|
+
names.setdefault(m.group(2), (m.group(3) or "").strip())
|
|
209
|
+
codes = (
|
|
210
|
+
(yaml.safe_load(text) or {})
|
|
211
|
+
.get("io", {})
|
|
212
|
+
.get("sv_portal", {})
|
|
213
|
+
.get("international_airport_codes")
|
|
214
|
+
) or []
|
|
215
|
+
out: list[dict[str, str]] = []
|
|
216
|
+
seen: set[str] = set()
|
|
217
|
+
for c in codes:
|
|
218
|
+
code = str(c).strip().upper()
|
|
219
|
+
if not code or code in seen:
|
|
220
|
+
continue
|
|
221
|
+
seen.add(code)
|
|
222
|
+
out.append({"code": code, "name": names.get(code, "")})
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def remove_intl_airport_code(code: str) -> dict[str, str]:
|
|
227
|
+
"""Delete ``code`` from ``international_airport_codes`` in
|
|
228
|
+
``configs/config.yml``. Returns the removed ``{"code", "name"}``.
|
|
229
|
+
|
|
230
|
+
Same targeted line splice as the add path, so the file's commentary
|
|
231
|
+
survives. Raises ``ValueError`` with a user-facing message when the
|
|
232
|
+
code isn't in the list, or when it is the last one left — an empty
|
|
233
|
+
list would leave a bare ``international_airport_codes:`` key that
|
|
234
|
+
YAML reads as null.
|
|
235
|
+
"""
|
|
236
|
+
if not isinstance(code, str) or not _INTL_CODE_RE.match(code.strip()):
|
|
237
|
+
raise ValueError(f"code must be exactly 3 uppercase letters (got {code!r})")
|
|
238
|
+
code = code.strip()
|
|
239
|
+
with config_write_lock:
|
|
240
|
+
text = CONFIG_YML.read_text(encoding="utf-8")
|
|
241
|
+
lines = text.splitlines(keepends=False)
|
|
242
|
+
header_idx, item_idxs = _find_intl_block(lines)
|
|
243
|
+
if header_idx < 0:
|
|
244
|
+
raise ValueError(
|
|
245
|
+
"could not find international_airport_codes section in config.yml"
|
|
246
|
+
)
|
|
247
|
+
matches = [
|
|
248
|
+
i for i in item_idxs if _INTL_ITEM_RE.match(lines[i]).group(2) == code
|
|
249
|
+
]
|
|
250
|
+
if not matches:
|
|
251
|
+
raise ValueError(f"{code} is not in the international list")
|
|
252
|
+
if len(matches) == len(item_idxs):
|
|
253
|
+
raise ValueError(
|
|
254
|
+
f"{code} is the last international airport code. Add "
|
|
255
|
+
"another code before removing it."
|
|
256
|
+
)
|
|
257
|
+
name = (_INTL_ITEM_RE.match(lines[matches[0]]).group(3) or "").strip()
|
|
258
|
+
for i in reversed(matches):
|
|
259
|
+
del lines[i]
|
|
260
|
+
CONFIG_YML.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
261
|
+
return {"code": code, "name": name}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The Setup sidebar's shift break length.
|
|
2
|
+
|
|
3
|
+
Stored as ``break_pass.length_minutes`` in ``configs/config.yml`` and
|
|
4
|
+
read by the floating-break post-pass (``allocator/postpass_break.py``)
|
|
5
|
+
on every Allocate run. The write is a targeted line splice so the
|
|
6
|
+
file's commentary survives; a config written before the setting
|
|
7
|
+
existed gets the section appended.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from ...config import (
|
|
15
|
+
BREAK_MINUTES_HIGHEST,
|
|
16
|
+
BREAK_MINUTES_LOWEST,
|
|
17
|
+
load_config,
|
|
18
|
+
)
|
|
19
|
+
from .config_yaml import CONFIG_YML, config_write_lock
|
|
20
|
+
|
|
21
|
+
_SECTION_RE = re.compile(r"^break_pass\s*:\s*(#.*)?$")
|
|
22
|
+
_LENGTH_RE = re.compile(r"^(\s+length_minutes\s*:\s*)\d+(\s*(?:#.*)?)$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_break_length() -> int:
|
|
26
|
+
"""The break length the next Allocate run will use."""
|
|
27
|
+
return load_config(CONFIG_YML).break_pass.length_minutes
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_break_length(value: object) -> int:
|
|
31
|
+
"""Whole minutes within the allowed band, or ``ValueError`` with a
|
|
32
|
+
user-facing message."""
|
|
33
|
+
if isinstance(value, bool) or not isinstance(value, (int, str)):
|
|
34
|
+
raise ValueError("break length must be a whole number of minutes")
|
|
35
|
+
try:
|
|
36
|
+
minutes = int(str(value).strip())
|
|
37
|
+
except ValueError:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
"break length must be a whole number of minutes"
|
|
40
|
+
) from None
|
|
41
|
+
if not BREAK_MINUTES_LOWEST <= minutes <= BREAK_MINUTES_HIGHEST:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"break length must be between {BREAK_MINUTES_LOWEST} and "
|
|
44
|
+
f"{BREAK_MINUTES_HIGHEST} minutes (got {minutes})"
|
|
45
|
+
)
|
|
46
|
+
return minutes
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def set_break_length(value: object) -> int:
|
|
50
|
+
"""Validate ``value`` and write it to config.yml. Returns the saved
|
|
51
|
+
minutes."""
|
|
52
|
+
minutes = validate_break_length(value)
|
|
53
|
+
with config_write_lock:
|
|
54
|
+
lines = CONFIG_YML.read_text(encoding="utf-8").splitlines(keepends=False)
|
|
55
|
+
header_idx = next(
|
|
56
|
+
(i for i, ln in enumerate(lines) if _SECTION_RE.match(ln)), -1,
|
|
57
|
+
)
|
|
58
|
+
if header_idx < 0:
|
|
59
|
+
lines += [
|
|
60
|
+
"",
|
|
61
|
+
"# Floating mid-shift break (allocator/postpass_break.py).",
|
|
62
|
+
"# Editable from the Setup sidebar.",
|
|
63
|
+
"break_pass:",
|
|
64
|
+
f" length_minutes: {minutes}",
|
|
65
|
+
]
|
|
66
|
+
else:
|
|
67
|
+
length_idx = -1
|
|
68
|
+
for i in range(header_idx + 1, len(lines)):
|
|
69
|
+
ln = lines[i]
|
|
70
|
+
if ln.strip() and not ln[0].isspace():
|
|
71
|
+
break # next top-level key: left the section
|
|
72
|
+
if _LENGTH_RE.match(ln):
|
|
73
|
+
length_idx = i
|
|
74
|
+
break
|
|
75
|
+
if length_idx >= 0:
|
|
76
|
+
lines[length_idx] = _LENGTH_RE.sub(
|
|
77
|
+
lambda m: f"{m.group(1)}{minutes}{m.group(2)}",
|
|
78
|
+
lines[length_idx],
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
lines.insert(header_idx + 1, f" length_minutes: {minutes}")
|
|
82
|
+
CONFIG_YML.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
83
|
+
return minutes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Shared handle on ``configs/config.yml`` for the drawer's write-through
|
|
2
|
+
edits.
|
|
3
|
+
|
|
4
|
+
The edits here are targeted line splices rather than a YAML round-trip,
|
|
5
|
+
so the file's commentary survives a save. Every writer takes
|
|
6
|
+
``config_write_lock`` first — two drawer forms submitted back to back
|
|
7
|
+
land on different request threads.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
#: Absolute path, so a write works whatever CWD the server was launched from.
|
|
16
|
+
CONFIG_YML = Path(__file__).resolve().parents[3] / "configs" / "config.yml"
|
|
17
|
+
|
|
18
|
+
#: Serialises every read-modify-write against CONFIG_YML.
|
|
19
|
+
config_write_lock = threading.Lock()
|
|
20
|
+
|
|
21
|
+
__all__ = ["CONFIG_YML", "config_write_lock"]
|