2026-06-17 16:02:04 -04:00
|
|
|
|
from contextlib import asynccontextmanager
|
2026-06-16 15:07:59 -04:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Any
|
2026-07-12 14:19:58 -04:00
|
|
|
|
from datetime import date, datetime, timedelta
|
2026-06-16 15:07:59 -04:00
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import FastAPI, HTTPException
|
2026-06-17 16:02:04 -04:00
|
|
|
|
from fastapi.middleware.cors import CORSMiddleware
|
2026-06-16 15:07:59 -04:00
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
|
|
|
|
import db
|
|
|
|
|
|
from parser import CsvValidationError, parse_target_csv
|
2026-07-12 14:19:58 -04:00
|
|
|
|
|
|
|
|
|
|
# Ensure new_scheduler resolves the same DUT/REF labels as db records.
|
|
|
|
|
|
os.environ.setdefault("DUT", db.DEVICE_DUT)
|
|
|
|
|
|
os.environ.setdefault("REF", db.DEVICE_REF)
|
|
|
|
|
|
|
|
|
|
|
|
from scheduler import Scheduler, Test as SchedulerTest
|
|
|
|
|
|
from test_config import build_bundle_test_configs, build_config_rows
|
|
|
|
|
|
from test_window import get_shift_capacity_for_date, get_shift_sequence, is_off_day
|
2026-06-25 11:31:20 -04:00
|
|
|
|
from watcher import configure_result_watcher, stop_result_watcher
|
2026-06-16 15:07:59 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
APP_ROOT = Path(__file__).resolve().parent
|
2026-07-15 14:56:40 -04:00
|
|
|
|
DB_PATH = Path(os.getenv("DB_PATH", str(APP_ROOT / "scheduler.db")))
|
2026-06-16 15:07:59 -04:00
|
|
|
|
DUT = os.getenv("DUT", "CGW453").strip()
|
2026-06-17 16:02:04 -04:00
|
|
|
|
REF = os.getenv("REF", "CGW452").strip()
|
2026-06-16 15:07:59 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-12 14:19:58 -04:00
|
|
|
|
|
2026-06-16 15:07:59 -04:00
|
|
|
|
class LoadTestsRequest(BaseModel):
|
|
|
|
|
|
csv_path: str = Field(..., description="Absolute or backend-relative path to target CSV")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SaveSettingsRequest(BaseModel):
|
|
|
|
|
|
settings: dict[str, Any]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 11:31:20 -04:00
|
|
|
|
def _smb_credentials_from_settings(settings: dict[str, Any]) -> dict[str, str]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"username": str(settings.get("smbUsername") or "").strip(),
|
|
|
|
|
|
"password": str(settings.get("smbPassword") or ""),
|
|
|
|
|
|
"domain": str(settings.get("smbDomain") or "").strip(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-13 02:43:52 -04:00
|
|
|
|
def _runtime_overrides_from_settings(settings: dict[str, Any]) -> dict[str, dict[str, int]]:
|
2026-06-25 11:31:20 -04:00
|
|
|
|
def _parse_positive_int(value: Any) -> int | None:
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
|
value = value.strip()
|
|
|
|
|
|
if not value:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
parsed = int(value)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return None
|
|
|
|
|
|
return parsed if parsed > 0 else None
|
|
|
|
|
|
|
2026-07-13 02:43:52 -04:00
|
|
|
|
legacy_overrides = {
|
|
|
|
|
|
"P2P": _parse_positive_int(settings.get("p2pRuntimeMinutes")),
|
|
|
|
|
|
"COE": _parse_positive_int(settings.get("coeRuntimeMinutes")),
|
|
|
|
|
|
"P3P": _parse_positive_int(settings.get("p3pRuntimeMinutes")),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
overrides: dict[str, dict[str, int]] = {}
|
|
|
|
|
|
for device_key, field_prefix in ((db.DEVICE_DUT, "dut"), (db.DEVICE_REF, "ref")):
|
|
|
|
|
|
device_overrides: dict[str, int] = {}
|
|
|
|
|
|
for test_type, suffix in (("P2P", "P2p"), ("COE", "Coe"), ("P3P", "P3p")):
|
|
|
|
|
|
minutes = _parse_positive_int(settings.get(f"{field_prefix}{suffix}RuntimeMinutes"))
|
|
|
|
|
|
if minutes is None:
|
|
|
|
|
|
minutes = legacy_overrides[test_type]
|
|
|
|
|
|
if minutes is not None:
|
|
|
|
|
|
device_overrides[test_type] = minutes
|
|
|
|
|
|
if device_overrides:
|
|
|
|
|
|
overrides[device_key] = device_overrides
|
2026-06-25 11:31:20 -04:00
|
|
|
|
|
|
|
|
|
|
return overrides
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-16 15:07:59 -04:00
|
|
|
|
class CompileScheduleRequest(BaseModel):
|
|
|
|
|
|
start_date: str | None = Field(default=None, description="YYYY-MM-DD")
|
|
|
|
|
|
rule: str = ""
|
|
|
|
|
|
daytime_testing_today: bool = False
|
2026-07-12 18:34:28 -04:00
|
|
|
|
dual_device_weekend_start_enabled: bool = False
|
|
|
|
|
|
dual_device_weekend_start_dates: list[str] = Field(default_factory=list)
|
2026-06-16 15:07:59 -04:00
|
|
|
|
top_priority_tests: list[str] = Field(default_factory=list)
|
|
|
|
|
|
lowest_priority_tests: list[str] = Field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RemoveActiveTestsRequest(BaseModel):
|
|
|
|
|
|
test_ids: list[str] = Field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-17 16:02:04 -04:00
|
|
|
|
class SaveHolidaysRequest(BaseModel):
|
|
|
|
|
|
dates: list[str] = Field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-12 14:19:58 -04:00
|
|
|
|
SHIFT_LABELS = {
|
|
|
|
|
|
1: "12AM–9AM",
|
|
|
|
|
|
2: "9AM–5PM",
|
|
|
|
|
|
3: "5PM–12AM",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
SHIFT_BOUNDARIES = {
|
|
|
|
|
|
1: ((0, 0), (9, 0)),
|
|
|
|
|
|
2: ((9, 0), (17, 0)),
|
|
|
|
|
|
3: ((17, 0), (24, 0)),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _serialize_schedule_row(row: db.ScheduleRow) -> dict[str, Any]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"test_id": row.test_id,
|
|
|
|
|
|
"device": row.device,
|
|
|
|
|
|
"scheduled_date": row.scheduled_date,
|
|
|
|
|
|
"shift_index": row.shift_index,
|
|
|
|
|
|
"sequence_in_shift": row.sequence_in_shift,
|
|
|
|
|
|
"test_type": row.test_type,
|
|
|
|
|
|
"rotation": row.rotation,
|
|
|
|
|
|
"config": row.config,
|
|
|
|
|
|
"status": row.status,
|
|
|
|
|
|
"priority": row.priority,
|
|
|
|
|
|
"estimated_minutes": row.estimated_minutes,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _window_segment_key(day: date, shift_index: int) -> str:
|
|
|
|
|
|
return f"{day.isoformat()}::shift{shift_index}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _combine_date_time(day: date, hour: int, minute: int) -> str:
|
|
|
|
|
|
if hour == 24:
|
|
|
|
|
|
return datetime.combine(day + timedelta(days=1), datetime.min.time()).isoformat(timespec="minutes")
|
|
|
|
|
|
return datetime.combine(day, datetime.min.time()).replace(hour=hour, minute=minute).isoformat(timespec="minutes")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _window_intersects_week(segments: list[tuple[date, int]], week_start: date, week_end: date) -> bool:
|
|
|
|
|
|
for day, _shift_index in segments:
|
|
|
|
|
|
if week_start <= day < week_end:
|
|
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_schedule_windows(week_start: date, all_rows: list[db.ScheduleRow], holiday_dates: set[str]) -> list[dict[str, Any]]:
|
|
|
|
|
|
if not all_rows:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
all_dates = [datetime.strptime(row.scheduled_date, "%Y-%m-%d").date() for row in all_rows]
|
|
|
|
|
|
first_scheduled_date = min(all_dates)
|
|
|
|
|
|
last_scheduled_date = max(all_dates)
|
|
|
|
|
|
week_end = week_start + timedelta(days=7)
|
|
|
|
|
|
|
|
|
|
|
|
rows_by_segment: dict[tuple[str, int], list[dict[str, Any]]] = {}
|
|
|
|
|
|
weekday_shift2_dates: set[str] = set()
|
|
|
|
|
|
for row in all_rows:
|
|
|
|
|
|
serialized = _serialize_schedule_row(row)
|
|
|
|
|
|
rows_by_segment.setdefault((row.scheduled_date, row.shift_index), []).append(serialized)
|
|
|
|
|
|
row_date = datetime.strptime(row.scheduled_date, "%Y-%m-%d").date()
|
|
|
|
|
|
if row.shift_index == 2 and not is_off_day(row_date, holiday_dates):
|
|
|
|
|
|
weekday_shift2_dates.add(row.scheduled_date)
|
|
|
|
|
|
|
|
|
|
|
|
simulation_start = min(first_scheduled_date, week_start) - timedelta(days=7)
|
|
|
|
|
|
while is_off_day(simulation_start, holiday_dates):
|
|
|
|
|
|
simulation_start -= timedelta(days=1)
|
|
|
|
|
|
|
|
|
|
|
|
simulation_end = max(last_scheduled_date, week_end) + timedelta(days=7)
|
|
|
|
|
|
current_date = simulation_start
|
|
|
|
|
|
windows: list[dict[str, Any]] = []
|
|
|
|
|
|
seen_window_ids: set[str] = set()
|
|
|
|
|
|
|
|
|
|
|
|
while current_date <= simulation_end:
|
|
|
|
|
|
current_iso = current_date.isoformat()
|
|
|
|
|
|
|
|
|
|
|
|
if current_iso in weekday_shift2_dates:
|
|
|
|
|
|
segments = get_shift_sequence(current_date, holiday_dates, daytime_shift2_only=True)
|
|
|
|
|
|
window_id = f"{current_iso}-shift2"
|
|
|
|
|
|
if window_id not in seen_window_ids and _window_intersects_week(segments, week_start, week_end):
|
|
|
|
|
|
seen_window_ids.add(window_id)
|
|
|
|
|
|
shift_tests = [
|
|
|
|
|
|
test
|
|
|
|
|
|
for day, shift_index in segments
|
|
|
|
|
|
for test in rows_by_segment.get((day.isoformat(), shift_index), [])
|
|
|
|
|
|
]
|
|
|
|
|
|
available_runtime_minutes = sum(
|
|
|
|
|
|
get_shift_capacity_for_date(day, holiday_dates, daytime_shift2_only=True).get(shift_index, 0)
|
|
|
|
|
|
for day, shift_index in segments
|
|
|
|
|
|
)
|
|
|
|
|
|
start_hour, start_minute = SHIFT_BOUNDARIES[segments[0][1]][0]
|
|
|
|
|
|
end_hour, end_minute = SHIFT_BOUNDARIES[segments[-1][1]][1]
|
|
|
|
|
|
windows.append({
|
|
|
|
|
|
"window_id": window_id,
|
|
|
|
|
|
"window_type": "daytime",
|
|
|
|
|
|
"start_date": segments[0][0].isoformat(),
|
|
|
|
|
|
"start_shift_index": segments[0][1],
|
|
|
|
|
|
"end_date": segments[-1][0].isoformat(),
|
|
|
|
|
|
"end_shift_index": segments[-1][1],
|
|
|
|
|
|
"start_at": _combine_date_time(segments[0][0], start_hour, start_minute),
|
|
|
|
|
|
"end_at": _combine_date_time(segments[-1][0], end_hour, end_minute),
|
|
|
|
|
|
"available_runtime_minutes": available_runtime_minutes,
|
|
|
|
|
|
"estimated_runtime_minutes": sum(test["estimated_minutes"] for test in shift_tests),
|
|
|
|
|
|
"bundle_test_configs": build_bundle_test_configs(shift_tests),
|
|
|
|
|
|
"segments": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"date": day.isoformat(),
|
|
|
|
|
|
"shift_index": shift_index,
|
|
|
|
|
|
"label": SHIFT_LABELS[shift_index],
|
|
|
|
|
|
"segment_key": _window_segment_key(day, shift_index),
|
|
|
|
|
|
}
|
|
|
|
|
|
for day, shift_index in segments
|
|
|
|
|
|
],
|
|
|
|
|
|
"configRows": build_config_rows(shift_tests),
|
|
|
|
|
|
"tests": shift_tests,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
segments = get_shift_sequence(current_date, holiday_dates, daytime_shift2_only=False)
|
|
|
|
|
|
last_sequence_date = segments[-1][0]
|
|
|
|
|
|
last_shift_index = segments[-1][1]
|
|
|
|
|
|
window_kind = "offday" if is_off_day(current_date, holiday_dates) else "overnight"
|
|
|
|
|
|
window_id = f"{current_iso}-shift{segments[0][1]}"
|
|
|
|
|
|
|
|
|
|
|
|
if window_id not in seen_window_ids and _window_intersects_week(segments, week_start, week_end):
|
|
|
|
|
|
seen_window_ids.add(window_id)
|
|
|
|
|
|
window_tests = [
|
|
|
|
|
|
test
|
|
|
|
|
|
for day, shift_index in segments
|
|
|
|
|
|
for test in rows_by_segment.get((day.isoformat(), shift_index), [])
|
|
|
|
|
|
]
|
|
|
|
|
|
available_runtime_minutes = sum(
|
|
|
|
|
|
get_shift_capacity_for_date(day, holiday_dates, daytime_shift2_only=False).get(shift_index, 0)
|
|
|
|
|
|
for day, shift_index in segments
|
|
|
|
|
|
)
|
|
|
|
|
|
start_hour, start_minute = SHIFT_BOUNDARIES[segments[0][1]][0]
|
|
|
|
|
|
end_hour, end_minute = SHIFT_BOUNDARIES[segments[-1][1]][1]
|
|
|
|
|
|
windows.append({
|
|
|
|
|
|
"window_id": window_id,
|
|
|
|
|
|
"window_type": window_kind,
|
|
|
|
|
|
"start_date": segments[0][0].isoformat(),
|
|
|
|
|
|
"start_shift_index": segments[0][1],
|
|
|
|
|
|
"end_date": segments[-1][0].isoformat(),
|
|
|
|
|
|
"end_shift_index": segments[-1][1],
|
|
|
|
|
|
"start_at": _combine_date_time(segments[0][0], start_hour, start_minute),
|
|
|
|
|
|
"end_at": _combine_date_time(segments[-1][0], end_hour, end_minute),
|
|
|
|
|
|
"available_runtime_minutes": available_runtime_minutes,
|
|
|
|
|
|
"estimated_runtime_minutes": sum(test["estimated_minutes"] for test in window_tests),
|
|
|
|
|
|
"bundle_test_configs": build_bundle_test_configs(window_tests),
|
|
|
|
|
|
"segments": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"date": day.isoformat(),
|
|
|
|
|
|
"shift_index": shift_index,
|
|
|
|
|
|
"label": SHIFT_LABELS[shift_index],
|
|
|
|
|
|
"segment_key": _window_segment_key(day, shift_index),
|
|
|
|
|
|
}
|
|
|
|
|
|
for day, shift_index in segments
|
|
|
|
|
|
],
|
|
|
|
|
|
"configRows": build_config_rows(window_tests),
|
|
|
|
|
|
"tests": window_tests,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if last_shift_index in {1, 2}:
|
|
|
|
|
|
current_date = last_sequence_date
|
|
|
|
|
|
else:
|
|
|
|
|
|
current_date = last_sequence_date + timedelta(days=1)
|
|
|
|
|
|
|
|
|
|
|
|
windows.sort(key=lambda window: (window["start_at"], window["window_id"]))
|
|
|
|
|
|
return windows
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-17 16:02:04 -04:00
|
|
|
|
@asynccontextmanager
|
|
|
|
|
|
async def lifespan(application: FastAPI):
|
2026-06-16 15:07:59 -04:00
|
|
|
|
db.init_db(DB_PATH)
|
2026-06-25 11:31:20 -04:00
|
|
|
|
settings = db.read_settings(DB_PATH)
|
|
|
|
|
|
configure_result_watcher(settings)
|
|
|
|
|
|
try:
|
|
|
|
|
|
yield
|
|
|
|
|
|
finally:
|
|
|
|
|
|
stop_result_watcher()
|
2026-06-17 16:02:04 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app = FastAPI(title="Scheduler API", version="0.1.0", lifespan=lifespan)
|
|
|
|
|
|
|
|
|
|
|
|
app.add_middleware(
|
|
|
|
|
|
CORSMiddleware,
|
2026-07-13 11:24:07 -04:00
|
|
|
|
allow_origins=[
|
|
|
|
|
|
"http://localhost:5173",
|
|
|
|
|
|
"http://127.0.0.1:5173",
|
|
|
|
|
|
"http://localhost:8080",
|
|
|
|
|
|
"http://127.0.0.1:8080",
|
|
|
|
|
|
],
|
2026-06-17 16:02:04 -04:00
|
|
|
|
allow_methods=["*"],
|
|
|
|
|
|
allow_headers=["*"],
|
|
|
|
|
|
)
|
2026-06-16 15:07:59 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/health")
|
|
|
|
|
|
def health() -> dict[str, str]:
|
|
|
|
|
|
return {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/settings")
|
|
|
|
|
|
def save_settings(request: SaveSettingsRequest) -> dict[str, str]:
|
|
|
|
|
|
db.save_settings(request.settings, DB_PATH)
|
2026-06-25 11:31:20 -04:00
|
|
|
|
configure_result_watcher(db.read_settings(DB_PATH))
|
2026-06-16 15:07:59 -04:00
|
|
|
|
return {"status": "saved"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/settings")
|
|
|
|
|
|
def get_settings() -> dict[str, Any]:
|
|
|
|
|
|
return db.read_settings(DB_PATH)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/tests/load")
|
|
|
|
|
|
def load_tests(request: LoadTestsRequest) -> dict[str, Any]:
|
2026-07-13 11:24:07 -04:00
|
|
|
|
received_path = request.csv_path
|
|
|
|
|
|
|
|
|
|
|
|
# Handle absolute paths (Windows or Unix) by extracting just the filename
|
|
|
|
|
|
if "\\" in received_path or ":" in received_path or received_path.startswith("/"):
|
|
|
|
|
|
# Absolute path (Windows with backslash or drive letter, or Unix with /)
|
|
|
|
|
|
# Extract just the filename from the path
|
|
|
|
|
|
filename = received_path.replace("\\", "/").split("/")[-1]
|
|
|
|
|
|
|
|
|
|
|
|
data_dir_path = APP_ROOT / "data" / filename
|
|
|
|
|
|
if data_dir_path.exists():
|
|
|
|
|
|
csv_path = data_dir_path
|
|
|
|
|
|
else:
|
|
|
|
|
|
csv_path = APP_ROOT / filename
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Relative paths - prepend APP_ROOT
|
|
|
|
|
|
csv_path = APP_ROOT / received_path
|
2026-06-16 15:07:59 -04:00
|
|
|
|
|
2026-06-25 11:31:20 -04:00
|
|
|
|
settings = db.read_settings(DB_PATH)
|
|
|
|
|
|
smb_credentials = _smb_credentials_from_settings(settings)
|
|
|
|
|
|
runtime_overrides = _runtime_overrides_from_settings(settings)
|
|
|
|
|
|
|
2026-06-16 15:07:59 -04:00
|
|
|
|
try:
|
2026-06-25 11:31:20 -04:00
|
|
|
|
parsed = parse_target_csv(
|
|
|
|
|
|
csv_path,
|
|
|
|
|
|
smb_credentials=smb_credentials,
|
|
|
|
|
|
runtime_overrides=runtime_overrides,
|
|
|
|
|
|
)
|
2026-06-16 15:07:59 -04:00
|
|
|
|
except FileNotFoundError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
|
|
|
|
except CsvValidationError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
count = db.upsert_tests(parsed.tests, DB_PATH)
|
|
|
|
|
|
|
2026-07-12 14:19:58 -04:00
|
|
|
|
print(f"Loaded {count} tests from {csv_path}, with {len(parsed.warnings)} warnings.")
|
2026-06-16 15:07:59 -04:00
|
|
|
|
return {
|
|
|
|
|
|
"loaded_tests": count,
|
|
|
|
|
|
"warnings": parsed.warnings,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/schedule/active/remove")
|
|
|
|
|
|
def remove_active_tests(request: RemoveActiveTestsRequest) -> dict[str, Any]:
|
2026-07-12 14:19:58 -04:00
|
|
|
|
# new_scheduler does not keep global mutable active state in app lifecycle.
|
|
|
|
|
|
# Keep endpoint for compatibility with frontend calls.
|
2026-06-16 15:07:59 -04:00
|
|
|
|
return {"status": "ok", "removed": len(request.test_ids)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/schedule/compile")
|
|
|
|
|
|
def compile_schedule_endpoint(request: CompileScheduleRequest) -> dict[str, Any]:
|
|
|
|
|
|
if request.start_date:
|
|
|
|
|
|
try:
|
|
|
|
|
|
datetime.strptime(request.start_date, "%Y-%m-%d")
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="start_date must be YYYY-MM-DD") from exc
|
|
|
|
|
|
|
|
|
|
|
|
stored_tests = db.list_schedulable_tests(DB_PATH, rule=request.rule)
|
|
|
|
|
|
if not stored_tests:
|
|
|
|
|
|
version = db.create_schedule_version([], DB_PATH)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"schedule_version": version,
|
|
|
|
|
|
"scheduled_tests": 0,
|
|
|
|
|
|
"completion_date": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
scheduler_tests = [
|
|
|
|
|
|
SchedulerTest(
|
|
|
|
|
|
test_id=t.test_id,
|
|
|
|
|
|
device=t.device,
|
|
|
|
|
|
test_type=t.test_type,
|
|
|
|
|
|
rotation=t.rotation,
|
|
|
|
|
|
rx_tx=t.rx_tx,
|
|
|
|
|
|
has_coe_pair=t.has_coe_pair,
|
|
|
|
|
|
coe_pairing=t.coe_pairing or [],
|
|
|
|
|
|
config=t.config,
|
2026-06-16 16:09:54 -04:00
|
|
|
|
throttled=t.throttled,
|
2026-06-16 15:07:59 -04:00
|
|
|
|
estimated_minutes=t.estimated_minutes,
|
|
|
|
|
|
)
|
|
|
|
|
|
for t in stored_tests
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
holiday_dates = db.list_holidays(DB_PATH)
|
2026-07-12 14:19:58 -04:00
|
|
|
|
top_priority_pairs: set[tuple[str, str]] = set()
|
|
|
|
|
|
for test_id in {item.strip() for item in request.top_priority_tests if item.strip()}:
|
|
|
|
|
|
if any(t.test_id == test_id and t.device == DUT for t in stored_tests):
|
|
|
|
|
|
top_priority_pairs.add((test_id, DUT))
|
|
|
|
|
|
if any(t.test_id == test_id and t.device == REF for t in stored_tests):
|
|
|
|
|
|
top_priority_pairs.add((test_id, REF))
|
|
|
|
|
|
|
|
|
|
|
|
scheduler = Scheduler(
|
|
|
|
|
|
tests=scheduler_tests,
|
|
|
|
|
|
top_priority_tests=top_priority_pairs,
|
|
|
|
|
|
start_date=request.start_date,
|
|
|
|
|
|
holiday_dates=holiday_dates,
|
|
|
|
|
|
daytime_testing_today=request.daytime_testing_today,
|
2026-07-12 18:34:28 -04:00
|
|
|
|
dual_device_weekend_start_enabled=request.dual_device_weekend_start_enabled,
|
|
|
|
|
|
dual_device_window_start_dates=set(item.strip() for item in request.dual_device_weekend_start_dates if item.strip()),
|
2026-07-12 14:19:58 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-16 15:07:59 -04:00
|
|
|
|
try:
|
2026-07-12 14:19:58 -04:00
|
|
|
|
schedule_error = scheduler.compile_schedule()
|
|
|
|
|
|
entries = scheduler.get_schedule()
|
|
|
|
|
|
except (RuntimeError, ValueError) as exc:
|
2026-06-16 15:07:59 -04:00
|
|
|
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
|
|
|
|
|
2026-07-12 14:19:58 -04:00
|
|
|
|
completion_date = max((e.scheduled_date for e in entries), default=None)
|
|
|
|
|
|
if schedule_error:
|
|
|
|
|
|
raise HTTPException(status_code=409, detail=schedule_error)
|
|
|
|
|
|
|
2026-06-16 15:07:59 -04:00
|
|
|
|
version = db.create_schedule_version(
|
|
|
|
|
|
[(e.test_id, e.device, e.scheduled_date, e.shift_index, e.sequence_in_shift) for e in entries],
|
|
|
|
|
|
DB_PATH,
|
|
|
|
|
|
)
|
2026-06-25 11:31:20 -04:00
|
|
|
|
print(f"Schedule version {version} created with {len(entries)} entries, completion date: {completion_date}")
|
2026-06-16 15:07:59 -04:00
|
|
|
|
return {
|
|
|
|
|
|
"schedule_version": version,
|
|
|
|
|
|
"scheduled_tests": len(entries),
|
|
|
|
|
|
"completion_date": completion_date,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 11:31:20 -04:00
|
|
|
|
@app.get("/api/tests/rerun")
|
|
|
|
|
|
def get_rerun_tests() -> dict[str, Any]:
|
|
|
|
|
|
db.mark_overdue_as_rerun(DB_PATH)
|
|
|
|
|
|
tests = db.get_rerun_tests(DB_PATH)
|
|
|
|
|
|
total_minutes = sum(t["estimated_minutes"] for t in tests)
|
|
|
|
|
|
return {"tests": tests, "total_estimated_minutes": total_minutes}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-17 16:02:04 -04:00
|
|
|
|
@app.post("/api/holidays")
|
|
|
|
|
|
def save_holidays(request: SaveHolidaysRequest) -> dict[str, Any]:
|
|
|
|
|
|
dates = [d.strip() for d in request.dates if d.strip()]
|
|
|
|
|
|
db.upsert_holidays(dates, DB_PATH)
|
|
|
|
|
|
return {"status": "saved", "count": len(dates)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/holidays")
|
|
|
|
|
|
def get_holidays() -> dict[str, Any]:
|
|
|
|
|
|
return {"dates": sorted(db.list_holidays(DB_PATH))}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-15 11:18:38 -04:00
|
|
|
|
@app.get("/api/schedule/versions")
|
|
|
|
|
|
def get_schedule_versions() -> dict[str, Any]:
|
|
|
|
|
|
return {"versions": db.get_schedule_versions(DB_PATH)}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-16 15:07:59 -04:00
|
|
|
|
@app.get("/api/schedule/week")
|
2026-07-15 11:18:38 -04:00
|
|
|
|
def get_schedule_week(start: str | None = None, version: int | None = None) -> dict[str, Any]:
|
2026-06-16 15:07:59 -04:00
|
|
|
|
week_start = start or date.today().isoformat()
|
|
|
|
|
|
try:
|
|
|
|
|
|
datetime.strptime(week_start, "%Y-%m-%d")
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="start must be YYYY-MM-DD") from exc
|
2026-07-15 11:18:38 -04:00
|
|
|
|
if version is not None and version <= 0:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="version must be a positive integer")
|
2026-06-16 15:07:59 -04:00
|
|
|
|
|
2026-07-12 14:19:58 -04:00
|
|
|
|
week_start_date = datetime.strptime(week_start, "%Y-%m-%d").date()
|
2026-07-15 11:18:38 -04:00
|
|
|
|
selected_version = db.resolve_schedule_version(version, DB_PATH)
|
|
|
|
|
|
if version is not None and selected_version is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Schedule version {version} was not found")
|
|
|
|
|
|
|
|
|
|
|
|
rows = db.get_schedule_week(week_start, selected_version, DB_PATH)
|
2026-07-15 14:56:40 -04:00
|
|
|
|
|
|
|
|
|
|
completed_rows = [row for row in rows if str(row.status).strip().lower() == "completed"]
|
|
|
|
|
|
rerun_rows = [row for row in rows if str(row.status).strip().lower() == "rerun"]
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"[api] /schedule/week: completed={len(completed_rows)} rerun_required={len(rerun_rows)}"
|
|
|
|
|
|
)
|
2026-07-15 11:18:38 -04:00
|
|
|
|
|
|
|
|
|
|
all_rows = db.get_schedule_rows(selected_version, DB_PATH)
|
2026-07-13 09:49:10 -04:00
|
|
|
|
completion_date = max((row.scheduled_date for row in all_rows), default=None)
|
2026-07-12 14:19:58 -04:00
|
|
|
|
holiday_dates = db.list_holidays(DB_PATH)
|
2026-06-16 15:07:59 -04:00
|
|
|
|
return {
|
|
|
|
|
|
"start_date": week_start,
|
2026-07-15 11:18:38 -04:00
|
|
|
|
"schedule_version": selected_version,
|
2026-07-12 14:19:58 -04:00
|
|
|
|
"items": [_serialize_schedule_row(row) for row in rows],
|
2026-07-13 16:17:36 -04:00
|
|
|
|
"total_scheduled_tests": len(all_rows),
|
2026-07-13 09:49:10 -04:00
|
|
|
|
"completion_date": completion_date,
|
2026-07-12 14:19:58 -04:00
|
|
|
|
"windows": _build_schedule_windows(week_start_date, all_rows, holiday_dates),
|
2026-06-16 15:07:59 -04:00
|
|
|
|
}
|
2026-06-17 16:02:04 -04:00
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
import uvicorn
|
|
|
|
|
|
uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True)
|
|
|
|
|
|
|