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/runner.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Runs Plan / Allocate on a background thread for the web UI.
|
|
2
|
+
|
|
3
|
+
State lives in this process, so the stages run in-thread rather than in
|
|
4
|
+
a subprocess — there is no workbook to hand off through. The UI fires
|
|
5
|
+
POST /api/run and then polls GET /api/run/status until it settles.
|
|
6
|
+
|
|
7
|
+
Run state is module-level: the server is single-process and serves one
|
|
8
|
+
assigner.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
import traceback
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import date as date_t
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
from ..state import AppState
|
|
22
|
+
|
|
23
|
+
RunState = Literal["idle", "running", "ok", "failed"]
|
|
24
|
+
|
|
25
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
26
|
+
CONFIG_PATH = REPO_ROOT / "configs" / "config.yml"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class _Run:
|
|
31
|
+
state: RunState = "idle"
|
|
32
|
+
step: str = ""
|
|
33
|
+
started_at: datetime | None = None
|
|
34
|
+
finished_at: datetime | None = None
|
|
35
|
+
error: str = ""
|
|
36
|
+
counts: dict[str, int] = field(default_factory=dict)
|
|
37
|
+
cancelled: bool = False
|
|
38
|
+
"""Set by Reset while a run is in flight. The worker checks it before
|
|
39
|
+
committing results, so a cancelled run leaves the freshly-cleared
|
|
40
|
+
state alone instead of repopulating it."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_run = _Run()
|
|
44
|
+
_lock = threading.Lock()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def status() -> dict[str, object]:
|
|
48
|
+
"""Snapshot for ``GET /api/run/status``."""
|
|
49
|
+
with _lock:
|
|
50
|
+
elapsed = None
|
|
51
|
+
if _run.started_at:
|
|
52
|
+
end = _run.finished_at or datetime.now()
|
|
53
|
+
elapsed = round((end - _run.started_at).total_seconds(), 1)
|
|
54
|
+
return {
|
|
55
|
+
"state": _run.state,
|
|
56
|
+
"step": _run.step,
|
|
57
|
+
"started_at": _run.started_at.isoformat(timespec="seconds")
|
|
58
|
+
if _run.started_at else None,
|
|
59
|
+
"finished_at": _run.finished_at.isoformat(timespec="seconds")
|
|
60
|
+
if _run.finished_at else None,
|
|
61
|
+
"elapsed_s": elapsed,
|
|
62
|
+
"error": _run.error,
|
|
63
|
+
"counts": dict(_run.counts),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_running() -> bool:
|
|
68
|
+
with _lock:
|
|
69
|
+
return _run.state == "running"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cancel() -> bool:
|
|
73
|
+
"""Ask an in-flight run to discard its results. Returns True when a
|
|
74
|
+
run was actually in flight.
|
|
75
|
+
|
|
76
|
+
The solver runs under its own ``max_seconds`` ceiling, so the thread
|
|
77
|
+
finishes on its own shortly; marking it cancelled means whatever it
|
|
78
|
+
produces is thrown away rather than written over the state the
|
|
79
|
+
operator just reset.
|
|
80
|
+
"""
|
|
81
|
+
with _lock:
|
|
82
|
+
if _run.state != "running":
|
|
83
|
+
return False
|
|
84
|
+
_run.cancelled = True
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def trigger(state: AppState, d_day: date_t, sub_command: str) -> bool:
|
|
89
|
+
"""Start a run on a background thread. Returns False when one is
|
|
90
|
+
already in flight."""
|
|
91
|
+
with _lock:
|
|
92
|
+
if _run.state == "running":
|
|
93
|
+
return False
|
|
94
|
+
_run.state = "running"
|
|
95
|
+
_run.step = sub_command
|
|
96
|
+
_run.started_at = datetime.now()
|
|
97
|
+
_run.finished_at = None
|
|
98
|
+
_run.error = ""
|
|
99
|
+
_run.counts = {}
|
|
100
|
+
_run.cancelled = False
|
|
101
|
+
|
|
102
|
+
thread = threading.Thread(
|
|
103
|
+
target=_worker, args=(state, d_day, sub_command),
|
|
104
|
+
name=f"flight-alloc-{sub_command}", daemon=True,
|
|
105
|
+
)
|
|
106
|
+
thread.start()
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _worker(state: AppState, d_day: date_t, sub_command: str) -> None:
|
|
111
|
+
counts: dict[str, int] = {}
|
|
112
|
+
error = ""
|
|
113
|
+
try:
|
|
114
|
+
counts = _dispatch(state, d_day, sub_command)
|
|
115
|
+
except FileNotFoundError as exc:
|
|
116
|
+
# A missing upload is an operator problem, not a crash — say so
|
|
117
|
+
# plainly rather than dumping a traceback into the UI.
|
|
118
|
+
error = str(exc)
|
|
119
|
+
print(f"[run] {sub_command} blocked: {error}")
|
|
120
|
+
except Exception as exc: # noqa: BLE001
|
|
121
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
122
|
+
traceback.print_exc()
|
|
123
|
+
|
|
124
|
+
with _lock:
|
|
125
|
+
cancelled = _run.cancelled
|
|
126
|
+
_run.finished_at = datetime.now()
|
|
127
|
+
_run.error = "" if cancelled else error
|
|
128
|
+
_run.counts = {} if cancelled else counts
|
|
129
|
+
_run.state = "ok" if (cancelled or not error) else "failed"
|
|
130
|
+
_run.cancelled = False
|
|
131
|
+
|
|
132
|
+
if cancelled:
|
|
133
|
+
# Results are discarded; the state the operator reset stands.
|
|
134
|
+
state.reset_results()
|
|
135
|
+
print(f"[run] {sub_command} finished but was cancelled — results dropped")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _dispatch(state: AppState, d_day: date_t, sub_command: str) -> dict[str, int]:
|
|
139
|
+
"""Run one stage and return its counts."""
|
|
140
|
+
from ..plan import run as run_plan
|
|
141
|
+
from ..step1_clean_flights import run as run_step1
|
|
142
|
+
from ..step2_extract_roster import run as run_step2
|
|
143
|
+
from ..step3_allocate_flights import run as run_allocate
|
|
144
|
+
|
|
145
|
+
if sub_command == "plan":
|
|
146
|
+
run_plan(state, d_day, CONFIG_PATH)
|
|
147
|
+
return {"FLIGHTS": len(state.allocatable_cleaned()),
|
|
148
|
+
"STAFF": len(state.availability)}
|
|
149
|
+
|
|
150
|
+
if sub_command == "step1":
|
|
151
|
+
return {k.value: v for k, v in run_step1(state, d_day, CONFIG_PATH).items()}
|
|
152
|
+
|
|
153
|
+
if sub_command == "step2":
|
|
154
|
+
return run_step2(state, d_day, CONFIG_PATH)
|
|
155
|
+
|
|
156
|
+
if sub_command in ("step3", "allocate"):
|
|
157
|
+
return run_allocate(state, d_day, CONFIG_PATH, mode_label="Allocate")
|
|
158
|
+
|
|
159
|
+
if sub_command == "all":
|
|
160
|
+
# Plan first so cleaned flights + availability are rebuilt from
|
|
161
|
+
# the uploads, then solve on top of them.
|
|
162
|
+
run_plan(state, d_day, CONFIG_PATH)
|
|
163
|
+
return run_allocate(
|
|
164
|
+
state, d_day, CONFIG_PATH,
|
|
165
|
+
append_warnings=True, mode_label="Allocate",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
raise ValueError(f"unknown run step: {sub_command!r}")
|
src/web/server.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""The request loop and the launcher.
|
|
2
|
+
|
|
3
|
+
Everything about *what* an endpoint does lives in ``api``; this module
|
|
4
|
+
only knows how to get bytes off the socket, hand them to the router,
|
|
5
|
+
and put the Response back on the wire. Adding an endpoint never touches
|
|
6
|
+
this file.
|
|
7
|
+
|
|
8
|
+
Served from stdlib http.server — no FastAPI / Flask / external deps, so
|
|
9
|
+
it fits IT allow-list constraints and ships in the same PyInstaller
|
|
10
|
+
bundle as the engine.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
import webbrowser
|
|
19
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .api import build_router
|
|
23
|
+
from .core.responses import Response, bad_request
|
|
24
|
+
from .core.router import Router
|
|
25
|
+
|
|
26
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
27
|
+
DEFAULT_PORT = 8765
|
|
28
|
+
|
|
29
|
+
# Guard against an accidental multi-hundred-MB POST filling memory.
|
|
30
|
+
MAX_UPLOAD_BYTES = 64 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("src.web")
|
|
33
|
+
|
|
34
|
+
#: Built once at import; every request thread reads from it.
|
|
35
|
+
ROUTER: Router = build_router()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Handler(BaseHTTPRequestHandler):
|
|
39
|
+
"""One instance per request. Holds no state of its own — the shared
|
|
40
|
+
state is the module-level ``STATE`` singleton the handlers reach."""
|
|
41
|
+
|
|
42
|
+
server_version = "FlightAllocWeb/2.0"
|
|
43
|
+
|
|
44
|
+
# HTTP/1.1 keep-alive. The default HTTP/1.0 + Connection: close
|
|
45
|
+
# means a new TCP connection per request, and on Windows the
|
|
46
|
+
# loopback handshake (plus Defender's inspection of it) is slow
|
|
47
|
+
# enough that 5-6 cold connections on page load are noticeable.
|
|
48
|
+
protocol_version = "HTTP/1.1"
|
|
49
|
+
|
|
50
|
+
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
|
51
|
+
# DEBUG, not INFO: the console handler is set to INFO so the
|
|
52
|
+
# operator sees what happened ([upload], [reset], ...) without a
|
|
53
|
+
# line per request. On Windows every console write goes through
|
|
54
|
+
# the console driver, and one per request bottlenecks the loop.
|
|
55
|
+
logger.debug("%s - %s", self.client_address[0], format % args)
|
|
56
|
+
|
|
57
|
+
def address_string(self) -> str:
|
|
58
|
+
# The default can do a reverse DNS lookup on the client IP,
|
|
59
|
+
# which on some Windows setups adds 30s+ per request.
|
|
60
|
+
return self.client_address[0]
|
|
61
|
+
|
|
62
|
+
# ------------ the four verbs, all the same shape ------------
|
|
63
|
+
|
|
64
|
+
def do_GET(self) -> None: # noqa: N802
|
|
65
|
+
self._handle("GET")
|
|
66
|
+
|
|
67
|
+
def do_POST(self) -> None: # noqa: N802
|
|
68
|
+
self._handle("POST")
|
|
69
|
+
|
|
70
|
+
def do_PUT(self) -> None: # noqa: N802
|
|
71
|
+
self._handle("PUT")
|
|
72
|
+
|
|
73
|
+
def do_DELETE(self) -> None: # noqa: N802
|
|
74
|
+
self._handle("DELETE")
|
|
75
|
+
|
|
76
|
+
# ------------ plumbing ------------
|
|
77
|
+
|
|
78
|
+
def _handle(self, method: str) -> None:
|
|
79
|
+
try:
|
|
80
|
+
body = self._read_body()
|
|
81
|
+
except ValueError as exc:
|
|
82
|
+
self._send(bad_request(str(exc)))
|
|
83
|
+
return
|
|
84
|
+
self._send(ROUTER.dispatch(method, self.path, self.headers, body))
|
|
85
|
+
|
|
86
|
+
def _read_body(self) -> bytes:
|
|
87
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
88
|
+
if length <= 0:
|
|
89
|
+
return b""
|
|
90
|
+
if length > MAX_UPLOAD_BYTES:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"payload too large ({length / 1e6:.0f} MB); "
|
|
93
|
+
f"limit is {MAX_UPLOAD_BYTES / 1e6:.0f} MB"
|
|
94
|
+
)
|
|
95
|
+
return self.rfile.read(length)
|
|
96
|
+
|
|
97
|
+
def _send(self, response: Response) -> None:
|
|
98
|
+
try:
|
|
99
|
+
self.send_response(response.status)
|
|
100
|
+
self.send_header("Content-Type", response.content_type)
|
|
101
|
+
self.send_header("Content-Length", str(len(response.body)))
|
|
102
|
+
self.send_header("Cache-Control", "no-store")
|
|
103
|
+
for key, value in response.headers.items():
|
|
104
|
+
self.send_header(key, value)
|
|
105
|
+
self.end_headers()
|
|
106
|
+
self.wfile.write(response.body)
|
|
107
|
+
except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError):
|
|
108
|
+
# Browser bailed mid-response — common when the operator
|
|
109
|
+
# navigates or hits Reset while a poll is in flight. Don't
|
|
110
|
+
# flood stderr with the stack trace.
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class WebServer(ThreadingHTTPServer):
|
|
115
|
+
"""Threading so one slow request (a Reset, an in-flight poll) doesn't
|
|
116
|
+
block the others — a single page load fires 5-6 API calls in
|
|
117
|
+
parallel. ``daemon_threads`` so a hung handler doesn't keep the
|
|
118
|
+
process alive after Ctrl+C."""
|
|
119
|
+
|
|
120
|
+
daemon_threads = True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def configure_logging(level: int = logging.INFO) -> None:
|
|
124
|
+
"""Send this package's log to stdout.
|
|
125
|
+
|
|
126
|
+
The operational lines ([upload], [reset], [staged-form], ...) are the
|
|
127
|
+
only feedback an operator gets in the launcher window, so they need a
|
|
128
|
+
handler. Per-request access logs sit at DEBUG and stay off.
|
|
129
|
+
"""
|
|
130
|
+
if logger.handlers:
|
|
131
|
+
return
|
|
132
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
133
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
134
|
+
logger.addHandler(handler)
|
|
135
|
+
logger.setLevel(level)
|
|
136
|
+
logger.propagate = False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def serve(
|
|
140
|
+
host: str = DEFAULT_HOST,
|
|
141
|
+
port: int = DEFAULT_PORT,
|
|
142
|
+
*,
|
|
143
|
+
open_browser: bool = True,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Run the server in the foreground. Ctrl+C exits cleanly."""
|
|
146
|
+
configure_logging()
|
|
147
|
+
from ..state import STATE
|
|
148
|
+
restored = STATE.load_persisted_rosters()
|
|
149
|
+
if restored:
|
|
150
|
+
logger.info("[rosters] restored %d saved roster file(s)", restored)
|
|
151
|
+
server = WebServer((host, port), Handler)
|
|
152
|
+
url = f"http://{host}:{port}/"
|
|
153
|
+
print(f"Flight Allocation console - {url}")
|
|
154
|
+
print(" Upload today's flight schedule from the dashboard;")
|
|
155
|
+
print(" the two roster files live in the Setup sidebar and are")
|
|
156
|
+
print(" remembered between runs (saved under data/rosters/).")
|
|
157
|
+
print(" Ctrl+C to stop.")
|
|
158
|
+
logger.debug("routes: %s", ", ".join(ROUTER.routes))
|
|
159
|
+
|
|
160
|
+
if open_browser:
|
|
161
|
+
threading.Timer(0.6, lambda: webbrowser.open(url)).start()
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
server.serve_forever()
|
|
165
|
+
except KeyboardInterrupt:
|
|
166
|
+
print("\nstopping...")
|
|
167
|
+
finally:
|
|
168
|
+
server.server_close()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def main(argv: list[str] | None = None) -> int:
|
|
172
|
+
import argparse
|
|
173
|
+
|
|
174
|
+
p = argparse.ArgumentParser(prog="flight-alloc-web")
|
|
175
|
+
p.add_argument("--host", default=DEFAULT_HOST)
|
|
176
|
+
p.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
177
|
+
p.add_argument("--no-browser", action="store_true",
|
|
178
|
+
help="do not auto-open the browser")
|
|
179
|
+
args = p.parse_args(argv)
|
|
180
|
+
serve(host=args.host, port=args.port, open_browser=not args.no_browser)
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
sys.exit(main())
|
src/zc_store.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""On-disk Zone Controller list, one list per operating date.
|
|
2
|
+
|
|
3
|
+
The rosters no longer carry ``/ZC`` in their cells; the assigner picks
|
|
4
|
+
the Zone Controllers on the dashboard instead. That choice has to
|
|
5
|
+
outlive a restart and has to be *updatable day to day* rather than
|
|
6
|
+
re-entered from scratch every morning, so it is kept here.
|
|
7
|
+
|
|
8
|
+
Rules
|
|
9
|
+
-----
|
|
10
|
+
* Each date has its own list. Editing a date never touches another one.
|
|
11
|
+
* A date with no list of its own **inherits** the list of the most
|
|
12
|
+
recent earlier date ("carried over"). The assigner opens the day,
|
|
13
|
+
sees yesterday's ZCs pre-filled, and only adds / removes what changed.
|
|
14
|
+
* The list a day is actually planned with is frozen onto that day (see
|
|
15
|
+
``freeze``), so later edits to an earlier date cannot rewrite history.
|
|
16
|
+
* A date whose list was deliberately emptied stays empty — it does not
|
|
17
|
+
fall back to an older date.
|
|
18
|
+
* Removing someone also records them as *removed* for that date only.
|
|
19
|
+
That is what lets the assigner take a ZC off who was marked ``/ZC`` in
|
|
20
|
+
the roster file: they are not in the saved list, so without the record
|
|
21
|
+
there would be nothing to say "not a ZC today". Removals never carry
|
|
22
|
+
over, because a roster ``/ZC`` is a fact about one specific day.
|
|
23
|
+
|
|
24
|
+
Layout::
|
|
25
|
+
|
|
26
|
+
data/zc_lists.json
|
|
27
|
+
{"2026-09-19": {"names": ["NAME ONE", "NAME TWO"],
|
|
28
|
+
"removed": ["NAME THREE"]}, ...}
|
|
29
|
+
|
|
30
|
+
(An older file that stores a bare list per date is still read.)
|
|
31
|
+
|
|
32
|
+
Names are stored as displayed and matched case- and whitespace-
|
|
33
|
+
insensitively, the same way override rows match names.
|
|
34
|
+
|
|
35
|
+
Persistence is best-effort, like ``roster_store``: if the folder is
|
|
36
|
+
read-only the lists keep working from memory and a warning is logged.
|
|
37
|
+
Override the location with ``FLIGHT_ALLOC_DATA_DIR``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import json
|
|
43
|
+
import logging
|
|
44
|
+
import os
|
|
45
|
+
import re
|
|
46
|
+
import tempfile
|
|
47
|
+
import threading
|
|
48
|
+
from datetime import date as date_t
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
from .roster_store import data_dir
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger("src.web")
|
|
54
|
+
|
|
55
|
+
_lock = threading.RLock()
|
|
56
|
+
_cache: dict[str, dict[str, list[str]]] | None = None
|
|
57
|
+
_cache_path: Path | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _path() -> Path:
|
|
61
|
+
return data_dir() / "zc_lists.json"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _norm(name: str) -> str:
|
|
65
|
+
return re.sub(r"\s+", " ", str(name or "")).strip().upper()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _clean(names: list[str]) -> list[str]:
|
|
69
|
+
"""Trim, drop blanks, drop duplicates (case-insensitive), keep order."""
|
|
70
|
+
seen: set[str] = set()
|
|
71
|
+
out: list[str] = []
|
|
72
|
+
for n in names:
|
|
73
|
+
display = re.sub(r"\s+", " ", str(n or "")).strip()
|
|
74
|
+
key = _norm(display)
|
|
75
|
+
if not key or key in seen:
|
|
76
|
+
continue
|
|
77
|
+
seen.add(key)
|
|
78
|
+
out.append(display)
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load() -> dict[str, dict[str, list[str]]]:
|
|
83
|
+
"""The whole store, read from disk once per path (and again if
|
|
84
|
+
``FLIGHT_ALLOC_DATA_DIR`` changes)."""
|
|
85
|
+
global _cache, _cache_path
|
|
86
|
+
path = _path()
|
|
87
|
+
with _lock:
|
|
88
|
+
if _cache is not None and _cache_path == path:
|
|
89
|
+
return _cache
|
|
90
|
+
data: dict[str, dict[str, list[str]]] = {}
|
|
91
|
+
if path.exists():
|
|
92
|
+
try:
|
|
93
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
94
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
95
|
+
logger.warning("[zc] cannot read %s: %s", path, exc)
|
|
96
|
+
raw = {}
|
|
97
|
+
if isinstance(raw, dict):
|
|
98
|
+
for k, v in raw.items():
|
|
99
|
+
try:
|
|
100
|
+
date_t.fromisoformat(str(k))
|
|
101
|
+
except ValueError:
|
|
102
|
+
continue
|
|
103
|
+
if isinstance(v, list): # older format
|
|
104
|
+
data[str(k)] = {"names": _clean([str(x) for x in v]), "removed": []}
|
|
105
|
+
elif isinstance(v, dict):
|
|
106
|
+
data[str(k)] = {
|
|
107
|
+
"names": _clean([str(x) for x in v.get("names", [])]),
|
|
108
|
+
"removed": _clean([str(x) for x in v.get("removed", [])]),
|
|
109
|
+
}
|
|
110
|
+
_cache, _cache_path = data, path
|
|
111
|
+
return data
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _write(data: dict[str, dict[str, list[str]]]) -> None:
|
|
115
|
+
"""Atomic replace, so a crash mid-write can't leave a torn file."""
|
|
116
|
+
path = _path()
|
|
117
|
+
try:
|
|
118
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
|
120
|
+
try:
|
|
121
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
122
|
+
json.dump(dict(sorted(data.items())), fh, indent=2)
|
|
123
|
+
os.replace(tmp, path)
|
|
124
|
+
except OSError:
|
|
125
|
+
try:
|
|
126
|
+
os.unlink(tmp)
|
|
127
|
+
except OSError:
|
|
128
|
+
pass
|
|
129
|
+
raise
|
|
130
|
+
except OSError as exc:
|
|
131
|
+
logger.warning(
|
|
132
|
+
"[zc] could not persist the ZC list (%s) — it stays in memory only",
|
|
133
|
+
exc,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def effective(d: date_t) -> tuple[list[str], str, date_t | None]:
|
|
138
|
+
"""The list to use for ``d``.
|
|
139
|
+
|
|
140
|
+
Returns ``(names, source, carried_from)`` where ``source`` is
|
|
141
|
+
``"saved"`` (the date has its own list), ``"carried"`` (inherited
|
|
142
|
+
from ``carried_from``) or ``"none"`` (nothing saved on or before
|
|
143
|
+
``d``).
|
|
144
|
+
"""
|
|
145
|
+
with _lock:
|
|
146
|
+
data = _load()
|
|
147
|
+
own = data.get(d.isoformat())
|
|
148
|
+
if own is not None:
|
|
149
|
+
return list(own["names"]), "saved", None
|
|
150
|
+
earlier = [k for k in data if k < d.isoformat()]
|
|
151
|
+
if earlier:
|
|
152
|
+
latest = max(earlier)
|
|
153
|
+
return list(data[latest]["names"]), "carried", date_t.fromisoformat(latest)
|
|
154
|
+
return [], "none", None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def removed(d: date_t) -> list[str]:
|
|
158
|
+
"""People explicitly taken off ZC on ``d`` (this date only, never
|
|
159
|
+
carried over)."""
|
|
160
|
+
with _lock:
|
|
161
|
+
own = _load().get(d.isoformat())
|
|
162
|
+
return list(own["removed"]) if own else []
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def set_list(
|
|
166
|
+
d: date_t, names: list[str], *, removed_names: list[str] | None = None,
|
|
167
|
+
) -> list[str]:
|
|
168
|
+
"""Save ``names`` as the list for ``d`` (an empty list is a real,
|
|
169
|
+
deliberate answer and is kept). ``removed_names`` replaces the day's
|
|
170
|
+
removal record; left as ``None`` the existing one is kept, minus
|
|
171
|
+
anyone who is being put on the list."""
|
|
172
|
+
with _lock:
|
|
173
|
+
data = _load()
|
|
174
|
+
cleaned = _clean(names)
|
|
175
|
+
on_list = {_norm(n) for n in cleaned}
|
|
176
|
+
base = data.get(d.isoformat(), {}).get("removed", []) if removed_names is None else removed_names
|
|
177
|
+
data[d.isoformat()] = {
|
|
178
|
+
"names": cleaned,
|
|
179
|
+
"removed": [n for n in _clean(base) if _norm(n) not in on_list],
|
|
180
|
+
}
|
|
181
|
+
_write(data)
|
|
182
|
+
return list(cleaned)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def add(d: date_t, name: str) -> list[str]:
|
|
186
|
+
"""Add one person to ``d``'s list, starting from what ``d`` already
|
|
187
|
+
resolves to (so a carried-over list is seeded, then saved)."""
|
|
188
|
+
with _lock:
|
|
189
|
+
names, _, _ = effective(d)
|
|
190
|
+
return set_list(d, names + [name])
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def remove(d: date_t, name: str) -> list[str]:
|
|
194
|
+
"""Take one person off ``d``'s list, and record them as removed for
|
|
195
|
+
that date so a roster ``/ZC`` for them is overridden too."""
|
|
196
|
+
with _lock:
|
|
197
|
+
names, _, _ = effective(d)
|
|
198
|
+
key = _norm(name)
|
|
199
|
+
return set_list(
|
|
200
|
+
d, [n for n in names if _norm(n) != key],
|
|
201
|
+
removed_names=removed(d) + [name],
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def freeze(d: date_t) -> bool:
|
|
206
|
+
"""Pin an inherited list onto ``d`` itself. Called when the list is
|
|
207
|
+
actually applied to a plan, so tomorrow's carry-over starts from
|
|
208
|
+
what today really ran with. Returns True when something was written."""
|
|
209
|
+
with _lock:
|
|
210
|
+
names, source, _ = effective(d)
|
|
211
|
+
if source != "carried":
|
|
212
|
+
return False
|
|
213
|
+
set_list(d, names)
|
|
214
|
+
return True
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _reset_for_tests() -> None:
|
|
218
|
+
"""Drop the in-memory cache (tests point the store at a temp dir)."""
|
|
219
|
+
global _cache, _cache_path
|
|
220
|
+
with _lock:
|
|
221
|
+
_cache, _cache_path = None, None
|