Next alpha stage commit
This commit is contained in:
418
app/main.py
418
app/main.py
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
@@ -24,8 +26,17 @@ from app.dataset_search import search_datasets
|
||||
from app.db import engine, get_db, init_db
|
||||
from app.db_lock import DatabaseWriteBusy, database_write_lock, database_write_status
|
||||
from app.geofabrik import create_geofabrik_source, geofabrik_catalog
|
||||
from app.gtfs_diff import diff_run_payload, gtfs_update_diff_detail, gtfs_update_diff_summary, latest_gtfs_update_diffs
|
||||
from app.gtfs_storage import scheduled_stop_ids
|
||||
from app.harmonization import GTFS_QA_NOTE_PREFIX, gtfs_harmonization_feed_detail, gtfs_harmonization_inventory
|
||||
from app.harmonization import (
|
||||
GTFS_QA_NOTE_PREFIX,
|
||||
gtfs_harmonization_feed_detail,
|
||||
gtfs_harmonization_inventory,
|
||||
gtfs_harmonized_snapshot,
|
||||
gtfs_harmonized_snapshot_diagnostics,
|
||||
gtfs_qa_review_payload,
|
||||
list_active_harmonized_snapshot_routes,
|
||||
)
|
||||
from app.itineraries import generate_itineraries, itinerary_payload, recent_itineraries, set_itinerary_saved, set_leg_locked
|
||||
from app.journey import find_journeys, nearest_scheduled_stops, search_scheduled_stops
|
||||
from app.journey_search import cancel_journey_search, journey_search_payload, start_journey_search
|
||||
@@ -37,6 +48,8 @@ from app.jobs import (
|
||||
cancel_job,
|
||||
create_dataset_delete_job,
|
||||
create_address_index_rebuild_job,
|
||||
create_gtfs_harmonized_snapshot_job,
|
||||
create_map_gtfs_review_job,
|
||||
create_maintenance_job,
|
||||
create_osm_relabel_job,
|
||||
create_route_matching_job,
|
||||
@@ -69,6 +82,7 @@ from app.models import (
|
||||
ItineraryLeg,
|
||||
Job,
|
||||
MatchRule,
|
||||
MapGtfsReviewItem,
|
||||
OsmFeature,
|
||||
PipelineRun,
|
||||
RouteMatch,
|
||||
@@ -106,7 +120,21 @@ from app.source_catalog import (
|
||||
source_catalog_rows,
|
||||
source_catalog_summary,
|
||||
)
|
||||
from app.worker_supervisor import queue_worker_status, start_queue_workers, stop_queue_workers
|
||||
from app.worker_supervisor import (
|
||||
queue_worker_status,
|
||||
restart_queue_workers,
|
||||
start_queue_workers,
|
||||
stop_configured_queue_workers,
|
||||
stop_queue_workers,
|
||||
)
|
||||
from app.workbench import (
|
||||
generate_map_gtfs_review_items,
|
||||
list_map_gtfs_review_items,
|
||||
map_gtfs_review_summary,
|
||||
record_route_match_decision,
|
||||
review_item_payload,
|
||||
update_map_gtfs_review_item,
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
@@ -125,6 +153,7 @@ async def lifespan(_app: FastAPI):
|
||||
app = FastAPI(title="Mobility Workbench", version="0.1.0", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
|
||||
STATIC_ASSET_VERSION = "20260706-bike-mode"
|
||||
MAP_FEATURE_LIMIT = 5000
|
||||
MAP_FEATURE_LIMIT_MAX = 20000
|
||||
|
||||
@@ -146,6 +175,12 @@ class GtfsFeedReviewUpdate(BaseModel):
|
||||
license: Optional[str] = None
|
||||
review_status: Optional[str] = None
|
||||
review_note: Optional[str] = None
|
||||
can_import: Optional[str] = None
|
||||
can_derive: Optional[str] = None
|
||||
can_redistribute: Optional[str] = None
|
||||
requires_attribution: Optional[str] = None
|
||||
commercial_restrictions: Optional[str] = None
|
||||
authority_level: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
@@ -221,6 +256,11 @@ class JobPriorityRequest(BaseModel):
|
||||
priority: int
|
||||
|
||||
|
||||
class WorkbenchReviewUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
def write_endpoint(operation: str):
|
||||
def decorator(fn):
|
||||
@wraps(fn)
|
||||
@@ -249,7 +289,7 @@ async def database_operational_error_handler(_request: Request, exc: Operational
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(request=request, name="index.html")
|
||||
return templates.TemplateResponse(request=request, name="index.html", context={"static_version": STATIC_ASSET_VERSION})
|
||||
|
||||
|
||||
@app.get("/api/sources")
|
||||
@@ -623,6 +663,21 @@ def queue_route_matching(priority: int = 0, db: Session = Depends(get_db)) -> di
|
||||
return job_payload(job)
|
||||
|
||||
|
||||
@app.post("/api/jobs/map-gtfs-review")
|
||||
@write_endpoint("queue Map/GTFS review generation")
|
||||
def queue_map_gtfs_review(
|
||||
source_id: Optional[int] = None,
|
||||
dataset_id: Optional[int] = None,
|
||||
limit: int = 1000,
|
||||
priority: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
job = create_map_gtfs_review_job(db, source_id=source_id, dataset_id=dataset_id, limit=limit, priority=priority)
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
return job_payload(job)
|
||||
|
||||
|
||||
@app.post("/api/jobs/osm-relabel")
|
||||
@write_endpoint("queue OSM relabeling")
|
||||
def queue_osm_relabel(
|
||||
@@ -658,6 +713,28 @@ def list_jobs(
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/workers/start")
|
||||
def start_workers_endpoint() -> dict:
|
||||
started = start_queue_workers(force=True)
|
||||
return _worker_control_payload("start", started=started)
|
||||
|
||||
|
||||
@app.post("/api/workers/stop")
|
||||
def stop_workers_endpoint() -> dict:
|
||||
stopped = stop_configured_queue_workers()
|
||||
return _worker_control_payload("stop", stopped=stopped)
|
||||
|
||||
|
||||
@app.post("/api/workers/restart")
|
||||
def restart_workers_endpoint() -> dict:
|
||||
result = restart_queue_workers()
|
||||
return _worker_control_payload(
|
||||
"restart",
|
||||
stopped=result.get("stopped", []),
|
||||
started=result.get("started", []),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/jobs/revision")
|
||||
def get_jobs_revision(
|
||||
response: Response,
|
||||
@@ -870,6 +947,152 @@ def gtfs_harmonization_inventory_endpoint(db: Session = Depends(get_db)) -> dict
|
||||
return gtfs_harmonization_inventory(db)
|
||||
|
||||
|
||||
@app.get("/api/harmonization/gtfs/snapshot")
|
||||
def gtfs_harmonized_snapshot_endpoint(db: Session = Depends(get_db)) -> dict:
|
||||
return gtfs_harmonized_snapshot(db)
|
||||
|
||||
|
||||
@app.get("/api/harmonization/gtfs/snapshot/routes")
|
||||
def gtfs_harmonized_snapshot_routes_endpoint(
|
||||
role: Optional[str] = None,
|
||||
dataset_id: Optional[int] = None,
|
||||
source_id: Optional[int] = None,
|
||||
shadowed_by_dataset_id: Optional[int] = None,
|
||||
mode: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
if role is not None and role not in {"included", "shadowed", "excluded"}:
|
||||
raise HTTPException(status_code=400, detail="role must be included, shadowed, or excluded")
|
||||
return list_active_harmonized_snapshot_routes(
|
||||
db,
|
||||
role=role,
|
||||
dataset_id=dataset_id,
|
||||
source_id=source_id,
|
||||
shadowed_by_dataset_id=shadowed_by_dataset_id,
|
||||
mode=mode,
|
||||
q=q,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/harmonization/gtfs/snapshot/routes.csv")
|
||||
def gtfs_harmonized_snapshot_routes_csv_endpoint(
|
||||
role: Optional[str] = None,
|
||||
dataset_id: Optional[int] = None,
|
||||
source_id: Optional[int] = None,
|
||||
shadowed_by_dataset_id: Optional[int] = None,
|
||||
mode: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
limit: int = 10000,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
if role is not None and role not in {"included", "shadowed", "excluded"}:
|
||||
raise HTTPException(status_code=400, detail="role must be included, shadowed, or excluded")
|
||||
payload = list_active_harmonized_snapshot_routes(
|
||||
db,
|
||||
role=role,
|
||||
dataset_id=dataset_id,
|
||||
source_id=source_id,
|
||||
shadowed_by_dataset_id=shadowed_by_dataset_id,
|
||||
mode=mode,
|
||||
q=q,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
max_limit=100_000,
|
||||
)
|
||||
output = io.StringIO()
|
||||
fieldnames = [
|
||||
"snapshot_id",
|
||||
"gtfs_route_id",
|
||||
"dataset_id",
|
||||
"source_id",
|
||||
"source_name",
|
||||
"route_id",
|
||||
"route_ref",
|
||||
"route_name",
|
||||
"mode",
|
||||
"operator",
|
||||
"overlap_key",
|
||||
"role",
|
||||
"reason",
|
||||
"shadowed_by_gtfs_route_id",
|
||||
"shadowed_by_dataset_id",
|
||||
"shadowed_by_source_id",
|
||||
]
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for route in payload.get("routes", []):
|
||||
writer.writerow(route)
|
||||
snapshot = payload.get("snapshot") or {}
|
||||
filename = f"gtfs_harmonized_snapshot_routes_{snapshot.get('id') or 'none'}.csv"
|
||||
return Response(
|
||||
content=output.getvalue(),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/harmonization/gtfs/diffs")
|
||||
def list_gtfs_update_diffs(
|
||||
source_id: Optional[int] = None,
|
||||
limit: int = 20,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return {
|
||||
"summary": gtfs_update_diff_summary(db),
|
||||
"diffs": [diff_run_payload(diff) for diff in latest_gtfs_update_diffs(db, source_id=source_id, limit=limit)],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/harmonization/gtfs/diffs/{diff_id}")
|
||||
def get_gtfs_update_diff(diff_id: int, item_limit: int = 200, db: Session = Depends(get_db)) -> dict:
|
||||
detail = gtfs_update_diff_detail(db, diff_id, item_limit=item_limit)
|
||||
if detail is None:
|
||||
raise HTTPException(status_code=404, detail="GTFS update diff not found")
|
||||
return detail
|
||||
|
||||
|
||||
@app.get("/api/workbench/gtfs-workflow")
|
||||
def gtfs_workflow_status_endpoint(db: Session = Depends(get_db)) -> dict:
|
||||
inventory = gtfs_harmonization_inventory(db)
|
||||
snapshot_diagnostics = inventory.get("snapshot_diagnostics") or gtfs_harmonized_snapshot_diagnostics(db)
|
||||
snapshot = inventory["snapshot"]
|
||||
snapshot_summary = snapshot.get("summary") or {}
|
||||
snapshot_route_preview = (
|
||||
list_active_harmonized_snapshot_routes(db, role="shadowed", limit=8)
|
||||
if snapshot.get("persisted") and int(snapshot_summary.get("snapshot_route_rows") or 0) > 0
|
||||
else {"snapshot": None, "summary": {}, "routes": []}
|
||||
)
|
||||
review = map_gtfs_review_summary(db)
|
||||
diff_summary = gtfs_update_diff_summary(db)
|
||||
latest_diffs = [diff_run_payload(diff) for diff in latest_gtfs_update_diffs(db, limit=5)]
|
||||
jobs = latest_jobs(db, limit=8)
|
||||
return {
|
||||
"inventory_summary": inventory["summary"],
|
||||
"snapshot": snapshot,
|
||||
"snapshot_diagnostics": snapshot_diagnostics,
|
||||
"snapshot_route_preview": snapshot_route_preview,
|
||||
"review_summary": review,
|
||||
"diff_summary": diff_summary,
|
||||
"latest_diffs": latest_diffs,
|
||||
"jobs": [job_payload(job) for job in jobs],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/jobs/gtfs-harmonized-snapshot")
|
||||
@write_endpoint("queue harmonized GTFS snapshot build")
|
||||
def queue_gtfs_harmonized_snapshot(activate: bool = True, priority: int = 0, db: Session = Depends(get_db)) -> dict:
|
||||
job = create_gtfs_harmonized_snapshot_job(db, activate=activate, priority=priority)
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
return job_payload(job)
|
||||
|
||||
|
||||
@app.get("/api/harmonization/gtfs/sources/{source_id}")
|
||||
def gtfs_harmonization_feed_detail_endpoint(source_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
detail = gtfs_harmonization_feed_detail(db, source_id)
|
||||
@@ -886,15 +1109,48 @@ def update_gtfs_harmonization_feed_review(source_id: int, payload: GtfsFeedRevie
|
||||
raise HTTPException(status_code=404, detail="GTFS source not found")
|
||||
if payload.review_status is not None and payload.review_status not in {"unreviewed", "approved", "needs_review", "blocked", "rejected"}:
|
||||
raise HTTPException(status_code=400, detail="review_status must be unreviewed, approved, needs_review, blocked, or rejected")
|
||||
license_flags = {
|
||||
"can_import": payload.can_import,
|
||||
"can_derive": payload.can_derive,
|
||||
"can_redistribute": payload.can_redistribute,
|
||||
"requires_attribution": payload.requires_attribution,
|
||||
"commercial_restrictions": payload.commercial_restrictions,
|
||||
}
|
||||
for field, value in license_flags.items():
|
||||
if value is not None and value not in {"unknown", "yes", "no"}:
|
||||
raise HTTPException(status_code=400, detail=f"{field} must be unknown, yes, or no")
|
||||
if payload.authority_level is not None and payload.authority_level not in {
|
||||
"unknown",
|
||||
"national_official",
|
||||
"regional_authority",
|
||||
"operator",
|
||||
"aggregator",
|
||||
"mirror",
|
||||
"secondary_discovery",
|
||||
}:
|
||||
raise HTTPException(status_code=400, detail="authority_level is not valid")
|
||||
if payload.license is not None:
|
||||
source.license = _truncate(payload.license, 255)
|
||||
if payload.enabled is not None:
|
||||
source.enabled = bool(payload.enabled)
|
||||
if payload.review_status is not None or payload.review_note is not None:
|
||||
existing_review = gtfs_qa_review_payload(source.notes)
|
||||
review_fields_present = (
|
||||
payload.review_status is not None
|
||||
or payload.review_note is not None
|
||||
or any(value is not None for value in license_flags.values())
|
||||
or payload.authority_level is not None
|
||||
)
|
||||
if review_fields_present:
|
||||
source.notes = _upsert_gtfs_qa_note(
|
||||
source.notes,
|
||||
status=payload.review_status or "unreviewed",
|
||||
note=payload.review_note or "",
|
||||
status=payload.review_status or existing_review["status"],
|
||||
note=payload.review_note if payload.review_note is not None else existing_review["note"],
|
||||
can_import=payload.can_import or existing_review["can_import"],
|
||||
can_derive=payload.can_derive or existing_review["can_derive"],
|
||||
can_redistribute=payload.can_redistribute or existing_review["can_redistribute"],
|
||||
requires_attribution=payload.requires_attribution or existing_review["requires_attribution"],
|
||||
commercial_restrictions=payload.commercial_restrictions or existing_review["commercial_restrictions"],
|
||||
authority_level=payload.authority_level or existing_review["authority_level"],
|
||||
)
|
||||
db.commit()
|
||||
detail = gtfs_harmonization_feed_detail(db, source_id)
|
||||
@@ -903,6 +1159,84 @@ def update_gtfs_harmonization_feed_review(source_id: int, payload: GtfsFeedRevie
|
||||
return detail
|
||||
|
||||
|
||||
@app.get("/api/workbench/map-gtfs/summary")
|
||||
def map_gtfs_workbench_summary_endpoint(db: Session = Depends(get_db)) -> dict:
|
||||
return map_gtfs_review_summary(db)
|
||||
|
||||
|
||||
@app.get("/api/workbench/map-gtfs/review-items")
|
||||
def list_map_gtfs_workbench_review_items(
|
||||
queue: Optional[str] = None,
|
||||
status: Optional[str] = "open",
|
||||
severity: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
source_id: Optional[int] = None,
|
||||
dataset_id: Optional[int] = None,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
items = list_map_gtfs_review_items(
|
||||
db,
|
||||
queue=queue,
|
||||
status=status,
|
||||
severity=severity,
|
||||
mode=mode,
|
||||
q=q,
|
||||
source_id=source_id,
|
||||
dataset_id=dataset_id,
|
||||
limit=limit,
|
||||
)
|
||||
return {
|
||||
"summary": map_gtfs_review_summary(db),
|
||||
"filters": {
|
||||
"queue": queue,
|
||||
"status": status,
|
||||
"severity": severity,
|
||||
"mode": mode,
|
||||
"q": q,
|
||||
"source_id": source_id,
|
||||
"dataset_id": dataset_id,
|
||||
},
|
||||
"items": [review_item_payload(item) for item in items],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/workbench/map-gtfs/review-items/{item_id}")
|
||||
def get_map_gtfs_workbench_review_item(item_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
item = db.get(MapGtfsReviewItem, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="review item not found")
|
||||
return review_item_payload(item)
|
||||
|
||||
|
||||
@app.post("/api/workbench/map-gtfs/review-items/generate")
|
||||
@write_endpoint("generate Map/GTFS review queue")
|
||||
def generate_map_gtfs_workbench_review_items(
|
||||
source_id: Optional[int] = None,
|
||||
dataset_id: Optional[int] = None,
|
||||
limit: int = 1000,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
result = generate_map_gtfs_review_items(db, source_id=source_id, dataset_id=dataset_id, limit=limit)
|
||||
db.commit()
|
||||
return {"summary": map_gtfs_review_summary(db), "result": result}
|
||||
|
||||
|
||||
@app.patch("/api/workbench/map-gtfs/review-items/{item_id}")
|
||||
@write_endpoint("update Map/GTFS review item")
|
||||
def update_map_gtfs_workbench_review_item(item_id: int, payload: WorkbenchReviewUpdate, db: Session = Depends(get_db)) -> dict:
|
||||
try:
|
||||
item = update_map_gtfs_review_item(db, item_id, status=payload.status, note=payload.note)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
status_code = 404 if detail == "review item not found" else 400
|
||||
raise HTTPException(status_code=status_code, detail=detail) from exc
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return review_item_payload(item)
|
||||
|
||||
|
||||
@app.get("/api/matches")
|
||||
def list_matches(
|
||||
status: Optional[str] = None,
|
||||
@@ -933,6 +1267,7 @@ def accept_match(match_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
match.rule_source = "manual"
|
||||
match.updated_at = datetime.now(timezone.utc)
|
||||
_persist_match_rule(db, match, "accept_match")
|
||||
record_route_match_decision(db, match, note="Accepted from route match review.")
|
||||
db.commit()
|
||||
return {"id": match.id, "status": match.status}
|
||||
|
||||
@@ -947,6 +1282,7 @@ def reject_match(match_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
match.rule_source = "manual"
|
||||
match.updated_at = datetime.now(timezone.utc)
|
||||
_persist_match_rule(db, match, "reject_match")
|
||||
record_route_match_decision(db, match, note="Rejected from route match review.")
|
||||
db.commit()
|
||||
return {"id": match.id, "status": match.status}
|
||||
|
||||
@@ -1030,6 +1366,7 @@ def accept_match_candidate(match_id: int, osm_feature_id: str, db: Session = Dep
|
||||
match.reasons_json = json.dumps(reasons, separators=(",", ":"))
|
||||
match.updated_at = datetime.now(timezone.utc)
|
||||
_persist_match_rule(db, match, "accept_match")
|
||||
record_route_match_decision(db, match, note="Accepted from route candidate review.")
|
||||
db.commit()
|
||||
db.refresh(match)
|
||||
return {"id": match.id, "status": match.status, "confidence": match.confidence, "match": match_row(match)}
|
||||
@@ -1423,6 +1760,7 @@ def journey_nearest_location(
|
||||
source_ids=_csv_ints(source_id, "source_id"),
|
||||
limit=1,
|
||||
radius_m=max(5, min(float(stop_radius_m), 120)),
|
||||
grouped=False,
|
||||
)
|
||||
location = stops[0] if stops else None
|
||||
if location is not None:
|
||||
@@ -1509,7 +1847,8 @@ def journey_search(
|
||||
|
||||
@app.post("/api/journey/searches")
|
||||
def start_progressive_journey_search(payload: JourneySearchRequest) -> dict:
|
||||
mode = payload.mode if payload.mode in {"transit", "walk", "drive", "car"} else "transit"
|
||||
requested_mode = str(payload.mode or "transit").lower()
|
||||
mode = "bike" if requested_mode in {"bike", "bicycle", "cycle", "cycling"} else requested_mode if requested_mode in {"transit", "walk", "drive", "car"} else "transit"
|
||||
ranking = payload.ranking if payload.ranking in {"recommended", "earliest_arrival", "duration", "fewest_transfers"} else "recommended"
|
||||
try:
|
||||
return start_journey_search(
|
||||
@@ -1978,6 +2317,29 @@ def _json_object_value(value: object, key: str) -> object:
|
||||
return value.get(key) if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _worker_control_payload(action: str, **groups: list[object]) -> dict:
|
||||
return {
|
||||
"action": action,
|
||||
"operations": {
|
||||
name: [_worker_handle_payload(handle) for handle in handles]
|
||||
for name, handles in groups.items()
|
||||
},
|
||||
"workers": queue_worker_status(),
|
||||
}
|
||||
|
||||
|
||||
def _worker_handle_payload(handle: object) -> dict:
|
||||
return {
|
||||
"index": getattr(handle, "index", None),
|
||||
"worker_id": getattr(handle, "worker_id", None),
|
||||
"pid": getattr(handle, "pid", None),
|
||||
"status": getattr(handle, "status", None),
|
||||
"pid_file": str(getattr(handle, "pid_file", "")),
|
||||
"log_file": str(getattr(handle, "log_file", "")),
|
||||
"started_by_server": bool(getattr(handle, "started_by_server", False)),
|
||||
}
|
||||
|
||||
|
||||
def _job_queue_revision_payload(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -2018,19 +2380,51 @@ def _set_etag(response: Response, revision: str) -> None:
|
||||
response.headers["ETag"] = f'W/"{revision}"'
|
||||
|
||||
|
||||
def _upsert_gtfs_qa_note(notes: str | None, *, status: str, note: str) -> str | None:
|
||||
def _upsert_gtfs_qa_note(
|
||||
notes: str | None,
|
||||
*,
|
||||
status: str,
|
||||
note: str,
|
||||
can_import: str | None = None,
|
||||
can_derive: str | None = None,
|
||||
can_redistribute: str | None = None,
|
||||
requires_attribution: str | None = None,
|
||||
commercial_restrictions: str | None = None,
|
||||
authority_level: str | None = None,
|
||||
) -> str | None:
|
||||
status_text = (status or "unreviewed").strip() or "unreviewed"
|
||||
note_text = " ".join((note or "").strip().split())
|
||||
updated_at = datetime.now(timezone.utc).isoformat()
|
||||
marker = f"{GTFS_QA_NOTE_PREFIX} status={status_text}; updated_at={updated_at}"
|
||||
if note_text:
|
||||
marker = f"{marker}; note={note_text}"
|
||||
payload = {
|
||||
"status": status_text,
|
||||
"updated_at": updated_at,
|
||||
"note": note_text,
|
||||
"can_import": can_import or "unknown",
|
||||
"can_derive": can_derive or "unknown",
|
||||
"can_redistribute": can_redistribute or "unknown",
|
||||
"requires_attribution": requires_attribution or "unknown",
|
||||
"commercial_restrictions": commercial_restrictions or "unknown",
|
||||
"authority_level": authority_level or "unknown",
|
||||
}
|
||||
marker = f"{GTFS_QA_NOTE_PREFIX} {json.dumps(payload, sort_keys=True, separators=(',', ':'))}"
|
||||
preserved = [
|
||||
line
|
||||
for line in str(notes or "").splitlines()
|
||||
if line.strip() and not line.startswith(GTFS_QA_NOTE_PREFIX)
|
||||
]
|
||||
if status_text != "unreviewed" or note_text:
|
||||
has_decision = (
|
||||
status_text != "unreviewed"
|
||||
or note_text
|
||||
or any(payload[key] != "unknown" for key in [
|
||||
"can_import",
|
||||
"can_derive",
|
||||
"can_redistribute",
|
||||
"requires_attribution",
|
||||
"commercial_restrictions",
|
||||
"authority_level",
|
||||
])
|
||||
)
|
||||
if has_decision:
|
||||
preserved.insert(0, marker)
|
||||
return "\n".join(preserved) or None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user