Add unit tests for station service and enhance documentation
Some checks failed
Backend CI / lint-and-test (push) Failing after 37s
Some checks failed
Backend CI / lint-and-test (push) Failing after 37s
- Introduced unit tests for the station service, covering creation, updating, and archiving of stations. - Added detailed building block view documentation outlining the architecture of the Rail Game system. - Created runtime view documentation illustrating key user interactions and system behavior. - Developed concepts documentation detailing domain models, architectural patterns, and security considerations. - Updated architecture documentation to reference new detailed sections for building block and runtime views.
This commit is contained in:
171
backend/scripts/stations_import.py
Normal file
171
backend/scripts/stations_import.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""CLI utility to import station data from OpenStreetMap."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from backend.app.core.osm_config import (
|
||||
DEFAULT_REGIONS,
|
||||
STATION_TAG_FILTERS,
|
||||
compile_overpass_filters,
|
||||
)
|
||||
|
||||
OVERPASS_ENDPOINT = "https://overpass-api.de/api/interpreter"
|
||||
|
||||
|
||||
def build_argument_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Export OSM station nodes for ingestion"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=Path("data/osm_stations.json"),
|
||||
help="Destination file for the exported station nodes (default: data/osm_stations.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--region",
|
||||
choices=[region.name for region in DEFAULT_REGIONS] + ["all"],
|
||||
default="all",
|
||||
help="Region name to export (default: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Do not fetch data; print the Overpass payload only",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def build_overpass_query(region_name: str) -> str:
|
||||
if region_name == "all":
|
||||
regions = DEFAULT_REGIONS
|
||||
else:
|
||||
regions = tuple(
|
||||
region for region in DEFAULT_REGIONS if region.name == region_name
|
||||
)
|
||||
if not regions:
|
||||
msg = f"Unknown region {region_name}. Available regions: {[region.name for region in DEFAULT_REGIONS]}"
|
||||
raise ValueError(msg)
|
||||
|
||||
filters = compile_overpass_filters(STATION_TAG_FILTERS)
|
||||
|
||||
parts = ["[out:json][timeout:90];", "("]
|
||||
for region in regions:
|
||||
parts.append(f" node{filters}\n ({region.to_overpass_arg()});")
|
||||
parts.append(")")
|
||||
parts.append("; out body; >; out skel qt;")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def perform_request(query: str) -> dict[str, Any]:
|
||||
import urllib.request
|
||||
|
||||
payload = f"data={quote_plus(query)}".encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
OVERPASS_ENDPOINT,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
payload = response.read()
|
||||
return json.loads(payload)
|
||||
|
||||
|
||||
def normalize_station_elements(
|
||||
elements: Iterable[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Convert raw Overpass nodes into StationCreate-compatible payloads."""
|
||||
|
||||
stations: list[dict[str, Any]] = []
|
||||
for element in elements:
|
||||
if element.get("type") != "node":
|
||||
continue
|
||||
|
||||
latitude = element.get("lat")
|
||||
longitude = element.get("lon")
|
||||
if latitude is None or longitude is None:
|
||||
continue
|
||||
|
||||
tags: dict[str, Any] = element.get("tags", {})
|
||||
name = tags.get("name")
|
||||
if not name:
|
||||
continue
|
||||
|
||||
raw_code = tags.get("ref") or tags.get(
|
||||
"railway:ref") or tags.get("local_ref")
|
||||
code = str(raw_code) if raw_code is not None else None
|
||||
|
||||
elevation_tag = tags.get("ele") or tags.get("elevation")
|
||||
try:
|
||||
elevation = float(
|
||||
elevation_tag) if elevation_tag is not None else None
|
||||
except (TypeError, ValueError):
|
||||
elevation = None
|
||||
|
||||
disused = str(tags.get("disused", "no")).lower() in {"yes", "true"}
|
||||
railway_status = str(tags.get("railway", "")).lower()
|
||||
abandoned = railway_status in {"abandoned", "disused"}
|
||||
is_active = not (disused or abandoned)
|
||||
|
||||
stations.append(
|
||||
{
|
||||
"osm_id": str(element.get("id")),
|
||||
"name": str(name),
|
||||
"latitude": float(latitude),
|
||||
"longitude": float(longitude),
|
||||
"code": code,
|
||||
"elevation_m": elevation,
|
||||
"is_active": is_active,
|
||||
}
|
||||
)
|
||||
|
||||
return stations
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_argument_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
query = build_overpass_query(args.region)
|
||||
|
||||
if args.dry_run:
|
||||
print(query)
|
||||
return 0
|
||||
|
||||
output_path: Path = args.output
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data = perform_request(query)
|
||||
raw_elements = data.get("elements", [])
|
||||
stations = normalize_station_elements(raw_elements)
|
||||
|
||||
payload = {
|
||||
"metadata": {
|
||||
"endpoint": OVERPASS_ENDPOINT,
|
||||
"region": args.region,
|
||||
"filters": STATION_TAG_FILTERS,
|
||||
"regions": [asdict(region) for region in DEFAULT_REGIONS],
|
||||
"raw_count": len(raw_elements),
|
||||
"station_count": len(stations),
|
||||
},
|
||||
"stations": stations,
|
||||
}
|
||||
|
||||
with output_path.open("w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, indent=2)
|
||||
|
||||
print(
|
||||
f"Normalized {len(stations)} stations from {len(raw_elements)} elements into {output_path}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
86
backend/scripts/stations_load.py
Normal file
86
backend/scripts/stations_load.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""CLI for loading normalized station JSON into the database."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from backend.app.db.session import SessionLocal
|
||||
from backend.app.models import StationCreate
|
||||
from backend.app.repositories import StationRepository
|
||||
|
||||
|
||||
def build_argument_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Load normalized station data into PostGIS"
|
||||
)
|
||||
parser.add_argument(
|
||||
"input",
|
||||
type=Path,
|
||||
help="Path to the normalized station JSON file produced by stations_import.py",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--commit/--no-commit",
|
||||
dest="commit",
|
||||
default=True,
|
||||
help="Commit the transaction (default: commit). Use --no-commit for dry runs.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_argument_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not args.input.exists():
|
||||
parser.error(f"Input file {args.input} does not exist")
|
||||
|
||||
with args.input.open("r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
|
||||
stations_data = payload.get("stations") or []
|
||||
if not isinstance(stations_data, list):
|
||||
parser.error("Invalid payload: 'stations' must be a list")
|
||||
|
||||
try:
|
||||
station_creates = _parse_station_entries(stations_data)
|
||||
except ValueError as exc:
|
||||
parser.error(str(exc))
|
||||
|
||||
created = load_stations(station_creates, commit=args.commit)
|
||||
|
||||
print(f"Loaded {created} stations from {args.input}")
|
||||
return 0
|
||||
|
||||
|
||||
def _parse_station_entries(entries: Iterable[Mapping[str, Any]]) -> list[StationCreate]:
|
||||
parsed: list[StationCreate] = []
|
||||
for entry in entries:
|
||||
try:
|
||||
parsed.append(StationCreate(**entry))
|
||||
except Exception as exc: # pragma: no cover - validated in tests
|
||||
raise ValueError(f"Invalid station entry {entry}: {exc}") from exc
|
||||
return parsed
|
||||
|
||||
|
||||
def load_stations(stations: Iterable[StationCreate], commit: bool = True) -> int:
|
||||
created = 0
|
||||
with SessionLocal() as session:
|
||||
repo = StationRepository(session)
|
||||
|
||||
for create_schema in stations:
|
||||
repo.create(create_schema)
|
||||
created += 1
|
||||
|
||||
if commit:
|
||||
session.commit()
|
||||
else:
|
||||
session.rollback()
|
||||
return created
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user