This commit is contained in:
@@ -14,10 +14,33 @@ export PBT_SECRET_KEY="ein-langer-zufaelliger-string"
|
||||
python app.py # Testlauf auf Port 5000
|
||||
```
|
||||
|
||||
Beim ersten Start legt die App die Tabellen (`users`, `fahrten`) automatisch per
|
||||
Beim ersten Start legt die App die Tabellen (`users`, `trips`, `stops`) automatisch per
|
||||
`db.create_all()` an – dafür braucht der User `pbt_app` `CREATE`-Rechte in der DB
|
||||
(sollte durch `GRANT ALL PRIVILEGES ON DATABASE pbt TO pbt_app;` bereits gegeben sein).
|
||||
|
||||
## Haltestellen-Lookup
|
||||
|
||||
Die Felder „Von" und „Nach" haben eine Autovervollständigung aus allen
|
||||
österreichischen Haltestellen. Die Daten stammen aus OpenStreetMap
|
||||
(© OpenStreetMap-Mitwirkende, ODbL) und liegen als Snapshot im Repo
|
||||
(`data/stops_at.csv.gz`, ~37 000 Haltestellen).
|
||||
|
||||
Nach dem Anlegen der Tabellen den Snapshot in die DB laden:
|
||||
|
||||
```bash
|
||||
flask import-stops # FLASK_APP=app.py bzw. im Projektverzeichnis
|
||||
```
|
||||
|
||||
Der Import ist idempotent (Upsert) und braucht kein Internet. Beim Deploy per
|
||||
systemd einmalig ausführen, danach nur wenn der Snapshot aktualisiert wurde.
|
||||
|
||||
Snapshot neu von OpenStreetMap holen (dauert 1–2 min, braucht Netzugang):
|
||||
|
||||
```bash
|
||||
python scripts/fetch_stops.py # überschreibt data/stops_at.csv.gz
|
||||
flask import-stops
|
||||
```
|
||||
|
||||
## Produktivbetrieb mit systemd + Gunicorn
|
||||
|
||||
1. Projekt nach `/opt/pbt` kopieren, virtuelle Umgebung dort anlegen (s. oben).
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
from datetime import date
|
||||
|
||||
from flask import Flask, render_template, redirect, url_for, request, flash
|
||||
from flask import Flask, render_template, redirect, url_for, request, flash, jsonify
|
||||
from flask_login import (
|
||||
LoginManager,
|
||||
login_user,
|
||||
@@ -10,7 +10,8 @@ from flask_login import (
|
||||
)
|
||||
|
||||
from config import Config
|
||||
from models import db, User, Trip, VERKEHRSMITTEL_OPTIONEN
|
||||
from models import db, User, Trip, Stop, VERKEHRSMITTEL_OPTIONEN
|
||||
from stops_import import normalize_name, register_cli
|
||||
|
||||
login_manager = LoginManager()
|
||||
login_manager.login_view = "login"
|
||||
@@ -27,6 +28,7 @@ def create_app():
|
||||
db.create_all()
|
||||
|
||||
register_routes(app)
|
||||
register_cli(app)
|
||||
return app
|
||||
|
||||
|
||||
@@ -97,6 +99,70 @@ def register_routes(app):
|
||||
)
|
||||
return render_template("dashboard.html", fahrten=fahrten)
|
||||
|
||||
@app.route("/api/stops")
|
||||
@login_required
|
||||
def api_stops():
|
||||
"""Autocomplete for the origin/destination fields."""
|
||||
query = request.args.get("q", "").strip()
|
||||
if len(query) < 2:
|
||||
return jsonify([])
|
||||
|
||||
needle = normalize_name(query).replace("\\", "\\\\")
|
||||
needle = needle.replace("%", r"\%").replace("_", r"\_")
|
||||
order = (db.func.length(Stop.name), Stop.name)
|
||||
|
||||
results = (
|
||||
Stop.query.filter(Stop.name_normalized.like(needle + "%", escape="\\"))
|
||||
.order_by(*order)
|
||||
.limit(8)
|
||||
.all()
|
||||
)
|
||||
if len(results) < 8:
|
||||
seen = {stop.id for stop in results}
|
||||
for stop in (
|
||||
Stop.query.filter(
|
||||
Stop.name_normalized.like("%" + needle + "%", escape="\\")
|
||||
)
|
||||
.order_by(*order)
|
||||
.limit(20)
|
||||
.all()
|
||||
):
|
||||
if stop.id not in seen:
|
||||
results.append(stop)
|
||||
if len(results) >= 8:
|
||||
break
|
||||
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"id": stop.id,
|
||||
"name": stop.name,
|
||||
"type": stop.stop_type,
|
||||
"municipality": stop.municipality,
|
||||
"lat": stop.latitude,
|
||||
"lon": stop.longitude,
|
||||
}
|
||||
for stop in results
|
||||
]
|
||||
)
|
||||
|
||||
def parse_trip_date(raw_value):
|
||||
raw_value = (raw_value or "").strip()
|
||||
try:
|
||||
return date.fromisoformat(raw_value) if raw_value else date.today()
|
||||
except ValueError:
|
||||
return date.today()
|
||||
|
||||
def resolve_stop_id(raw_id):
|
||||
"""Return a valid Stop.id for a submitted value, or None (free text)."""
|
||||
if not raw_id:
|
||||
return None
|
||||
try:
|
||||
stop = db.session.get(Stop, int(raw_id))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return stop.id if stop is not None else None
|
||||
|
||||
@app.route("/fahrt/neu", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def neue_fahrt():
|
||||
@@ -106,8 +172,12 @@ def register_routes(app):
|
||||
transport_mode=request.form["transport_mode"],
|
||||
line=request.form.get("line", "").strip(),
|
||||
origin=request.form["origin"].strip(),
|
||||
origin_stop_id=resolve_stop_id(request.form.get("origin_stop_id")),
|
||||
destination=request.form["destination"].strip(),
|
||||
trip_date=request.form.get("trip_date") or date.today(),
|
||||
destination_stop_id=resolve_stop_id(
|
||||
request.form.get("destination_stop_id")
|
||||
),
|
||||
trip_date=parse_trip_date(request.form.get("trip_date")),
|
||||
rating=int(request.form["rating"]),
|
||||
comment=request.form.get("comment", "").strip(),
|
||||
)
|
||||
|
||||
Binary file not shown.
@@ -36,6 +36,31 @@ VERKEHRSMITTEL_OPTIONEN = [
|
||||
]
|
||||
|
||||
|
||||
class Stop(db.Model):
|
||||
"""A boardable public-transport stop ("Haltestelle") in Austria.
|
||||
|
||||
Sourced from OpenStreetMap; see scripts/fetch_stops.py and stops_import.py.
|
||||
"""
|
||||
|
||||
__tablename__ = "stops"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
osm_type = db.Column(db.String(8), nullable=False)
|
||||
osm_id = db.Column(db.BigInteger, nullable=False)
|
||||
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
# lowercased, accent-folded copy of name for diacritic-insensitive search
|
||||
name_normalized = db.Column(db.String(200), nullable=False, index=True)
|
||||
stop_type = db.Column(db.String(20), nullable=False, default="other")
|
||||
municipality = db.Column(db.String(120))
|
||||
latitude = db.Column(db.Float, nullable=False)
|
||||
longitude = db.Column(db.Float, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("osm_type", "osm_id", name="uq_stops_osm"),
|
||||
)
|
||||
|
||||
|
||||
class Trip(db.Model):
|
||||
__tablename__ = "trips"
|
||||
|
||||
@@ -44,10 +69,16 @@ class Trip(db.Model):
|
||||
|
||||
transport_mode = db.Column(db.String(50), nullable=False)
|
||||
line = db.Column(db.String(50))
|
||||
# free-text label as entered; *_stop_id links to a known stop when matched
|
||||
origin = db.Column(db.String(120), nullable=False)
|
||||
origin_stop_id = db.Column(db.Integer, db.ForeignKey("stops.id"))
|
||||
destination = db.Column(db.String(120), nullable=False)
|
||||
destination_stop_id = db.Column(db.Integer, db.ForeignKey("stops.id"))
|
||||
trip_date = db.Column(db.Date, nullable=False, default=date.today)
|
||||
rating = db.Column(db.Integer, nullable=False) # 1-5
|
||||
comment = db.Column(db.Text)
|
||||
|
||||
created_at = db.Column(db.DateTime, server_default=db.func.now())
|
||||
|
||||
origin_stop = db.relationship("Stop", foreign_keys=[origin_stop_id])
|
||||
destination_stop = db.relationship("Stop", foreign_keys=[destination_stop_id])
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch all Austrian public-transport stops from OpenStreetMap (Overpass API)
|
||||
and write a deduplicated snapshot to data/stops_at.csv.gz.
|
||||
|
||||
Data © OpenStreetMap contributors, licensed under the ODbL.
|
||||
|
||||
Run this only when you want to refresh the snapshot; the import into the
|
||||
database reads the checked-in CSV and needs no network access.
|
||||
|
||||
python scripts/fetch_stops.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import gzip
|
||||
import json
|
||||
import sys
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
OUT_PATH = Path(__file__).resolve().parent.parent / "data" / "stops_at.csv.gz"
|
||||
|
||||
OVERPASS_ENDPOINTS = [
|
||||
"https://overpass-api.de/api/interpreter",
|
||||
"https://overpass.kumi.systems/api/interpreter",
|
||||
"https://overpass.private.coffee/api/interpreter",
|
||||
]
|
||||
|
||||
# Named nodes in Austria that represent a boardable stop ("Haltestelle").
|
||||
OVERPASS_QUERY = """
|
||||
[out:json][timeout:600];
|
||||
area["ISO3166-1"="AT"][admin_level=2]->.at;
|
||||
(
|
||||
node(area.at)["highway"="bus_stop"]["name"];
|
||||
node(area.at)["railway"="tram_stop"]["name"];
|
||||
node(area.at)["railway"="station"]["name"];
|
||||
node(area.at)["railway"="halt"]["name"];
|
||||
node(area.at)["public_transport"="station"]["name"];
|
||||
node(area.at)["amenity"="bus_station"]["name"];
|
||||
);
|
||||
out body;
|
||||
"""
|
||||
|
||||
CSV_FIELDS = [
|
||||
"osm_type",
|
||||
"osm_id",
|
||||
"name",
|
||||
"stop_type",
|
||||
"municipality",
|
||||
"latitude",
|
||||
"longitude",
|
||||
]
|
||||
|
||||
|
||||
def normalize_name(value: str) -> str:
|
||||
"""Lowercase, fold accents and ß so search is diacritic-insensitive.
|
||||
|
||||
The same function lives in stops_import.py; keep them in sync.
|
||||
"""
|
||||
value = value.strip().lower().replace("ß", "ss")
|
||||
decomposed = unicodedata.normalize("NFKD", value)
|
||||
return "".join(ch for ch in decomposed if not unicodedata.combining(ch))
|
||||
|
||||
|
||||
def classify(tags: dict) -> str:
|
||||
if tags.get("station") == "subway" or tags.get("subway") == "yes":
|
||||
return "subway"
|
||||
if tags.get("railway") in {"station", "halt"}:
|
||||
return "train"
|
||||
if tags.get("railway") == "tram_stop" or tags.get("tram") == "yes":
|
||||
return "tram"
|
||||
if tags.get("amenity") == "bus_station":
|
||||
return "bus_station"
|
||||
if tags.get("highway") == "bus_stop" or tags.get("bus") == "yes":
|
||||
return "bus"
|
||||
if tags.get("public_transport") == "station":
|
||||
return "other"
|
||||
return "other"
|
||||
|
||||
|
||||
def municipality_of(tags: dict) -> str:
|
||||
for key in ("addr:city", "is_in:municipality", "is_in:city", "is_in"):
|
||||
value = tags.get(key)
|
||||
if value:
|
||||
return value.split(",")[0].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def fetch_raw() -> dict:
|
||||
body = urllib.parse.urlencode({"data": OVERPASS_QUERY}).encode()
|
||||
last_error: Exception | None = None
|
||||
for endpoint in OVERPASS_ENDPOINTS:
|
||||
try:
|
||||
print(f"Querying {endpoint} ...", file=sys.stderr)
|
||||
req = urllib.request.Request(
|
||||
endpoint, data=body, headers={"User-Agent": "pbt-stop-import/1.0"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=600) as resp:
|
||||
return json.load(resp)
|
||||
except Exception as exc: # noqa: BLE001 - try the next mirror
|
||||
last_error = exc
|
||||
print(f" failed: {exc}", file=sys.stderr)
|
||||
raise SystemExit(f"All Overpass endpoints failed; last error: {last_error}")
|
||||
|
||||
|
||||
TYPE_RANK = {"subway": 5, "train": 4, "bus_station": 3, "tram": 2, "bus": 1, "other": 0}
|
||||
|
||||
# Nodes with the same name closer than this are treated as one stop (OSM keeps a
|
||||
# separate node per direction for most bus stops, ~20-60 m apart).
|
||||
MERGE_METERS = 250.0
|
||||
|
||||
|
||||
def _dist_m(a: dict, b: dict) -> float:
|
||||
import math
|
||||
|
||||
lat1, lon1, lat2, lon2 = map(
|
||||
math.radians,
|
||||
(a["latitude"], a["longitude"], b["latitude"], b["longitude"]),
|
||||
)
|
||||
dlat, dlon = lat2 - lat1, lon2 - lon1
|
||||
h = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
|
||||
return 2 * 6_371_000 * math.asin(min(1.0, math.sqrt(h)))
|
||||
|
||||
|
||||
def build_rows(payload: dict) -> list[dict]:
|
||||
# Collect candidate nodes grouped by normalized name.
|
||||
by_name: dict[str, list[dict]] = {}
|
||||
for element in payload.get("elements", []):
|
||||
if element.get("type") != "node":
|
||||
continue
|
||||
tags = element.get("tags", {})
|
||||
name = (tags.get("name") or "").strip()
|
||||
lat = element.get("lat")
|
||||
lon = element.get("lon")
|
||||
if not name or lat is None or lon is None:
|
||||
continue
|
||||
node = {
|
||||
"osm_type": "node",
|
||||
"osm_id": int(element["id"]),
|
||||
"name": name,
|
||||
"stop_type": classify(tags),
|
||||
"municipality": municipality_of(tags),
|
||||
"latitude": round(float(lat), 6),
|
||||
"longitude": round(float(lon), 6),
|
||||
}
|
||||
by_name.setdefault(normalize_name(name), []).append(node)
|
||||
|
||||
rows: list[dict] = []
|
||||
for nodes in by_name.values():
|
||||
# Greedy single-link clustering within the name group.
|
||||
clusters: list[list[dict]] = []
|
||||
for node in sorted(nodes, key=lambda n: n["osm_id"]):
|
||||
for cluster in clusters:
|
||||
if any(_dist_m(node, member) <= MERGE_METERS for member in cluster):
|
||||
cluster.append(node)
|
||||
break
|
||||
else:
|
||||
clusters.append([node])
|
||||
|
||||
for cluster in clusters:
|
||||
lead = max(
|
||||
cluster,
|
||||
key=lambda n: (TYPE_RANK[n["stop_type"]], n["municipality"] != "", -n["osm_id"]),
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"osm_type": "node",
|
||||
# lowest id in the cluster -> stable identity across refreshes
|
||||
"osm_id": min(n["osm_id"] for n in cluster),
|
||||
"name": lead["name"],
|
||||
"stop_type": lead["stop_type"],
|
||||
"municipality": lead["municipality"]
|
||||
or next((n["municipality"] for n in cluster if n["municipality"]), ""),
|
||||
"latitude": round(sum(n["latitude"] for n in cluster) / len(cluster), 6),
|
||||
"longitude": round(sum(n["longitude"] for n in cluster) / len(cluster), 6),
|
||||
}
|
||||
)
|
||||
|
||||
rows.sort(key=lambda r: (normalize_name(r["name"]), r["osm_id"]))
|
||||
return rows
|
||||
|
||||
|
||||
def main() -> None:
|
||||
payload = fetch_raw()
|
||||
rows = build_rows(payload)
|
||||
if len(rows) < 10_000:
|
||||
raise SystemExit(
|
||||
f"Only {len(rows)} stops parsed - refusing to overwrite the snapshot. "
|
||||
"Overpass probably returned a partial result; try again later."
|
||||
)
|
||||
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with gzip.open(OUT_PATH, "wt", newline="", encoding="utf-8") as fh:
|
||||
writer = csv.DictWriter(fh, fieldnames=CSV_FIELDS)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
by_type: dict[str, int] = {}
|
||||
for row in rows:
|
||||
by_type[row["stop_type"]] = by_type.get(row["stop_type"], 0) + 1
|
||||
print(f"Wrote {len(rows)} stops to {OUT_PATH}", file=sys.stderr)
|
||||
print(f" by type: {by_type}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/* Autocomplete for the origin/destination fields, backed by /api/stops.
|
||||
Progressive enhancement: without JS the inputs stay plain free-text fields.
|
||||
A picked stop fills the sibling hidden <input> with its id; editing the text
|
||||
afterwards clears it again, so unmatched entries save as free text. */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var TYPE_LABEL = {
|
||||
bus: "Bus",
|
||||
tram: "Straßenbahn",
|
||||
subway: "U-Bahn",
|
||||
train: "Zug",
|
||||
bus_station: "Busbahnhof",
|
||||
other: "Haltestelle",
|
||||
};
|
||||
|
||||
function debounce(fn, ms) {
|
||||
var t;
|
||||
return function () {
|
||||
var args = arguments, self = this;
|
||||
clearTimeout(t);
|
||||
t = setTimeout(function () { fn.apply(self, args); }, ms);
|
||||
};
|
||||
}
|
||||
|
||||
function setup(input) {
|
||||
var wrap = input.closest(".combo");
|
||||
var hidden = wrap.querySelector(input.dataset.stopTarget);
|
||||
var hint = wrap.querySelector("[data-stop-hint]");
|
||||
if (!hidden) return;
|
||||
|
||||
var list = document.createElement("div");
|
||||
list.className = "combo-list";
|
||||
list.hidden = true;
|
||||
wrap.appendChild(list);
|
||||
|
||||
var items = [];
|
||||
var active = -1;
|
||||
var lastQuery = "";
|
||||
|
||||
function clearMatch() {
|
||||
hidden.value = "";
|
||||
if (hint) { hint.textContent = ""; hint.classList.remove("matched"); }
|
||||
}
|
||||
|
||||
function markMatch(stop) {
|
||||
hidden.value = String(stop.id);
|
||||
if (hint) {
|
||||
hint.textContent =
|
||||
"✓ " + stop.name + (stop.municipality ? ", " + stop.municipality : "");
|
||||
hint.classList.add("matched");
|
||||
}
|
||||
}
|
||||
|
||||
function close() { list.hidden = true; active = -1; }
|
||||
|
||||
function render() {
|
||||
list.innerHTML = "";
|
||||
if (!items.length) {
|
||||
var empty = document.createElement("div");
|
||||
empty.className = "combo-empty";
|
||||
empty.textContent =
|
||||
"Keine Haltestelle gefunden – Eingabe wird als Freitext gespeichert.";
|
||||
list.appendChild(empty);
|
||||
list.hidden = false;
|
||||
return;
|
||||
}
|
||||
items.forEach(function (stop, i) {
|
||||
var opt = document.createElement("div");
|
||||
opt.className = "combo-option" + (i === active ? " active" : "");
|
||||
var name = document.createElement("span");
|
||||
name.className = "name";
|
||||
name.textContent = stop.name;
|
||||
opt.appendChild(name);
|
||||
if (stop.municipality) {
|
||||
var muni = document.createElement("span");
|
||||
muni.className = "muni";
|
||||
muni.textContent = stop.municipality;
|
||||
opt.appendChild(muni);
|
||||
}
|
||||
var kind = document.createElement("span");
|
||||
kind.className = "kind";
|
||||
kind.textContent = TYPE_LABEL[stop.type] || "";
|
||||
opt.appendChild(kind);
|
||||
opt.addEventListener("mousedown", function (e) {
|
||||
e.preventDefault();
|
||||
choose(i);
|
||||
});
|
||||
list.appendChild(opt);
|
||||
});
|
||||
list.hidden = false;
|
||||
}
|
||||
|
||||
function choose(i) {
|
||||
var stop = items[i];
|
||||
if (!stop) return;
|
||||
input.value = stop.name;
|
||||
markMatch(stop);
|
||||
close();
|
||||
}
|
||||
|
||||
var search = debounce(function () {
|
||||
var q = input.value.trim();
|
||||
lastQuery = q;
|
||||
if (q.length < 2) { items = []; close(); return; }
|
||||
fetch("/api/stops?q=" + encodeURIComponent(q))
|
||||
.then(function (r) { return r.ok ? r.json() : []; })
|
||||
.then(function (data) {
|
||||
if (input.value.trim() !== lastQuery) return;
|
||||
items = data;
|
||||
active = -1;
|
||||
render();
|
||||
var exact = data.filter(function (s) {
|
||||
return s.name.toLowerCase() === lastQuery.toLowerCase();
|
||||
})[0];
|
||||
if (exact) { markMatch(exact); } else { clearMatch(); }
|
||||
})
|
||||
.catch(function () { items = []; close(); });
|
||||
}, 180);
|
||||
|
||||
input.setAttribute("autocomplete", "off");
|
||||
input.addEventListener("input", function () { clearMatch(); search(); });
|
||||
input.addEventListener("focus", function () { if (items.length) render(); });
|
||||
input.addEventListener("blur", function () { setTimeout(close, 120); });
|
||||
input.addEventListener("keydown", function (e) {
|
||||
if (list.hidden) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
active = Math.min(active + 1, items.length - 1);
|
||||
render();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
active = Math.max(active - 1, 0);
|
||||
render();
|
||||
} else if (e.key === "Enter") {
|
||||
if (active >= 0) { e.preventDefault(); choose(active); }
|
||||
} else if (e.key === "Escape") {
|
||||
close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-stop-input]").forEach(setup);
|
||||
})();
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
"""Load the checked-in Austrian stop snapshot into the ``stops`` table.
|
||||
|
||||
The snapshot (data/stops_at.csv.gz) is produced by scripts/fetch_stops.py from
|
||||
OpenStreetMap data (© OpenStreetMap contributors, ODbL). Importing needs no
|
||||
network access.
|
||||
|
||||
flask import-stops # import the checked-in snapshot
|
||||
flask import-stops --file X # import an alternative CSV(.gz)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import gzip
|
||||
import io
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from flask import current_app
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
from models import Stop, Trip, db
|
||||
|
||||
SNAPSHOT_PATH = Path(__file__).resolve().parent / "data" / "stops_at.csv.gz"
|
||||
|
||||
|
||||
def normalize_name(value: str) -> str:
|
||||
"""Lowercase, fold accents and ß for diacritic-insensitive search.
|
||||
|
||||
Mirrors scripts/fetch_stops.py:normalize_name - keep the two in sync.
|
||||
"""
|
||||
value = value.strip().lower().replace("ß", "ss")
|
||||
decomposed = unicodedata.normalize("NFKD", value)
|
||||
return "".join(ch for ch in decomposed if not unicodedata.combining(ch))
|
||||
|
||||
|
||||
def _open_csv(path: Path) -> io.TextIOBase:
|
||||
if str(path).endswith(".gz"):
|
||||
return gzip.open(path, "rt", newline="", encoding="utf-8")
|
||||
return open(path, "rt", newline="", encoding="utf-8")
|
||||
|
||||
|
||||
def import_stops(path: Path | str = SNAPSHOT_PATH) -> dict[str, int]:
|
||||
"""Upsert every row of the snapshot into ``stops`` by (osm_type, osm_id).
|
||||
|
||||
Stops that vanished from the snapshot are removed unless a trip still
|
||||
references them, so foreign keys from ``trips`` stay valid.
|
||||
"""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise click.ClickException(
|
||||
f"Snapshot not found: {path}\nRun scripts/fetch_stops.py first."
|
||||
)
|
||||
|
||||
with _open_csv(path) as fh:
|
||||
rows = list(csv.DictReader(fh))
|
||||
if not rows:
|
||||
raise click.ClickException(f"{path} contains no rows.")
|
||||
|
||||
existing = {(s.osm_type, s.osm_id): s for s in Stop.query.all()}
|
||||
seen: set[tuple[str, int]] = set()
|
||||
inserted = updated = 0
|
||||
|
||||
for row in rows:
|
||||
key = (row["osm_type"], int(row["osm_id"]))
|
||||
seen.add(key)
|
||||
name = row["name"].strip()
|
||||
fields = dict(
|
||||
name=name,
|
||||
name_normalized=normalize_name(name),
|
||||
stop_type=row["stop_type"] or "other",
|
||||
municipality=(row.get("municipality") or "").strip() or None,
|
||||
latitude=float(row["latitude"]),
|
||||
longitude=float(row["longitude"]),
|
||||
)
|
||||
stop = existing.get(key)
|
||||
if stop is None:
|
||||
db.session.add(Stop(osm_type=key[0], osm_id=key[1], **fields))
|
||||
inserted += 1
|
||||
else:
|
||||
changed = False
|
||||
for attr, value in fields.items():
|
||||
if getattr(stop, attr) != value:
|
||||
setattr(stop, attr, value)
|
||||
changed = True
|
||||
updated += changed
|
||||
|
||||
referenced = {
|
||||
sid
|
||||
for (sid,) in db.session.query(Trip.origin_stop_id).distinct()
|
||||
if sid is not None
|
||||
} | {
|
||||
sid
|
||||
for (sid,) in db.session.query(Trip.destination_stop_id).distinct()
|
||||
if sid is not None
|
||||
}
|
||||
removed = 0
|
||||
for key, stop in existing.items():
|
||||
if key not in seen and stop.id not in referenced:
|
||||
db.session.delete(stop)
|
||||
removed += 1
|
||||
|
||||
db.session.commit()
|
||||
return {
|
||||
"total": len(rows),
|
||||
"inserted": inserted,
|
||||
"updated": updated,
|
||||
"removed": removed,
|
||||
}
|
||||
|
||||
|
||||
@click.command("import-stops")
|
||||
@click.option(
|
||||
"--file",
|
||||
"file_path",
|
||||
type=click.Path(exists=True, dir_okay=False),
|
||||
default=None,
|
||||
help="CSV(.gz) to import instead of the checked-in snapshot.",
|
||||
)
|
||||
@with_appcontext
|
||||
def import_stops_command(file_path: str | None) -> None:
|
||||
"""Import Austrian public-transport stops into the database."""
|
||||
stats = import_stops(file_path or SNAPSHOT_PATH)
|
||||
click.echo(
|
||||
"Stops imported: "
|
||||
f"{stats['total']} in snapshot, "
|
||||
f"{stats['inserted']} new, {stats['updated']} updated, "
|
||||
f"{stats['removed']} removed. "
|
||||
f"Total now: {Stop.query.count()}."
|
||||
)
|
||||
|
||||
|
||||
def register_cli(app) -> None:
|
||||
app.cli.add_command(import_stops_command)
|
||||
@@ -18,14 +18,22 @@
|
||||
<input type="text" id="line" name="line" placeholder="z.B. S3, Bus 10">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field combo">
|
||||
<label for="origin">Von<span class="required">*</span></label>
|
||||
<input type="text" id="origin" name="origin" required>
|
||||
<input type="text" id="origin" name="origin" required
|
||||
placeholder="Haltestelle suchen …"
|
||||
data-stop-input data-stop-target="#origin_stop_id">
|
||||
<input type="hidden" id="origin_stop_id" name="origin_stop_id">
|
||||
<p class="field-hint" data-stop-hint></p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field combo">
|
||||
<label for="destination">Nach<span class="required">*</span></label>
|
||||
<input type="text" id="destination" name="destination" required>
|
||||
<input type="text" id="destination" name="destination" required
|
||||
placeholder="Haltestelle suchen …"
|
||||
data-stop-input data-stop-target="#destination_stop_id">
|
||||
<input type="hidden" id="destination_stop_id" name="destination_stop_id">
|
||||
<p class="field-hint" data-stop-hint></p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -53,3 +61,6 @@
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='stops.js') }}" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -134,6 +134,36 @@
|
||||
th, td { text-align: left; padding: 0.6rem 0.4rem; border-bottom: 1px solid var(--panel-border); font-size: 0.9rem; }
|
||||
th { color: var(--text-muted); font-weight: 600; }
|
||||
.stars { color: #e3b341; white-space: nowrap; }
|
||||
.stop-dot { color: var(--success); font-size: 0.7rem; vertical-align: middle; }
|
||||
|
||||
.combo { position: relative; display: flex; flex-direction: column; }
|
||||
.combo-list {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 6px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.combo-option {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.combo-option.active, .combo-option:hover { background: rgba(74, 158, 255, 0.15); }
|
||||
.combo-option .muni { color: var(--text-muted); font-size: 0.8rem; }
|
||||
.combo-option .kind { margin-left: auto; font-size: 0.75rem; color: var(--text-muted); white-space: nowrap; }
|
||||
.combo-empty { padding: 0.5rem 0.7rem; font-size: 0.85rem; color: var(--text-muted); }
|
||||
.field-hint { font-size: 0.75rem; color: var(--text-muted); margin-top: 0.3rem; min-height: 1em; }
|
||||
.field-hint.matched { color: var(--success); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -158,5 +188,10 @@
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<footer style="margin-top: 3rem; text-align: center; font-size: 0.75rem; color: var(--text-muted);">
|
||||
Haltestellendaten © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>-Mitwirkende (ODbL)
|
||||
</footer>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
<td>{{ f.trip_date.strftime("%d.%m.%Y") }}</td>
|
||||
<td>{{ f.transport_mode }}</td>
|
||||
<td>{{ f.line or "–" }}</td>
|
||||
<td>{{ f.origin }} → {{ f.destination }}</td>
|
||||
<td>
|
||||
{{ f.origin }}{% if f.origin_stop %}<span class="stop-dot" title="Verifizierte Haltestelle"> ●</span>{% endif %}
|
||||
→ {{ f.destination }}{% if f.destination_stop %}<span class="stop-dot" title="Verifizierte Haltestelle"> ●</span>{% endif %}
|
||||
</td>
|
||||
<td class="stars">{{ "★" * f.rating }}{{ "☆" * (5 - f.rating) }}</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('fahrt_loeschen', fahrt_id=f.id) }}" onsubmit="return confirm('Fahrt wirklich löschen?');" style="margin: 0;">
|
||||
|
||||
Reference in New Issue
Block a user