This commit is contained in:
@@ -14,10 +14,33 @@ export PBT_SECRET_KEY="ein-langer-zufaelliger-string"
|
|||||||
python app.py # Testlauf auf Port 5000
|
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
|
`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).
|
(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
|
## Produktivbetrieb mit systemd + Gunicorn
|
||||||
|
|
||||||
1. Projekt nach `/opt/pbt` kopieren, virtuelle Umgebung dort anlegen (s. oben).
|
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 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 (
|
from flask_login import (
|
||||||
LoginManager,
|
LoginManager,
|
||||||
login_user,
|
login_user,
|
||||||
@@ -10,7 +10,8 @@ from flask_login import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from config import Config
|
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 = LoginManager()
|
||||||
login_manager.login_view = "login"
|
login_manager.login_view = "login"
|
||||||
@@ -27,6 +28,7 @@ def create_app():
|
|||||||
db.create_all()
|
db.create_all()
|
||||||
|
|
||||||
register_routes(app)
|
register_routes(app)
|
||||||
|
register_cli(app)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +99,70 @@ def register_routes(app):
|
|||||||
)
|
)
|
||||||
return render_template("dashboard.html", fahrten=fahrten)
|
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"])
|
@app.route("/fahrt/neu", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def neue_fahrt():
|
def neue_fahrt():
|
||||||
@@ -106,8 +172,12 @@ def register_routes(app):
|
|||||||
transport_mode=request.form["transport_mode"],
|
transport_mode=request.form["transport_mode"],
|
||||||
line=request.form.get("line", "").strip(),
|
line=request.form.get("line", "").strip(),
|
||||||
origin=request.form["origin"].strip(),
|
origin=request.form["origin"].strip(),
|
||||||
|
origin_stop_id=resolve_stop_id(request.form.get("origin_stop_id")),
|
||||||
destination=request.form["destination"].strip(),
|
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"]),
|
rating=int(request.form["rating"]),
|
||||||
comment=request.form.get("comment", "").strip(),
|
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):
|
class Trip(db.Model):
|
||||||
__tablename__ = "trips"
|
__tablename__ = "trips"
|
||||||
|
|
||||||
@@ -44,10 +69,16 @@ class Trip(db.Model):
|
|||||||
|
|
||||||
transport_mode = db.Column(db.String(50), nullable=False)
|
transport_mode = db.Column(db.String(50), nullable=False)
|
||||||
line = db.Column(db.String(50))
|
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 = 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 = 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)
|
trip_date = db.Column(db.Date, nullable=False, default=date.today)
|
||||||
rating = db.Column(db.Integer, nullable=False) # 1-5
|
rating = db.Column(db.Integer, nullable=False) # 1-5
|
||||||
comment = db.Column(db.Text)
|
comment = db.Column(db.Text)
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, server_default=db.func.now())
|
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">
|
<input type="text" id="line" name="line" placeholder="z.B. S3, Bus 10">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field combo">
|
||||||
<label for="origin">Von<span class="required">*</span></label>
|
<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>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field combo">
|
||||||
<label for="destination">Nach<span class="required">*</span></label>
|
<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>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -53,3 +61,6 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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, 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; }
|
th { color: var(--text-muted); font-weight: 600; }
|
||||||
.stars { color: #e3b341; white-space: nowrap; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -158,5 +188,10 @@
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
{% block content %}{% endblock %}
|
{% 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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -26,7 +26,10 @@
|
|||||||
<td>{{ f.trip_date.strftime("%d.%m.%Y") }}</td>
|
<td>{{ f.trip_date.strftime("%d.%m.%Y") }}</td>
|
||||||
<td>{{ f.transport_mode }}</td>
|
<td>{{ f.transport_mode }}</td>
|
||||||
<td>{{ f.line or "–" }}</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 class="stars">{{ "★" * f.rating }}{{ "☆" * (5 - f.rating) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="{{ url_for('fahrt_loeschen', fahrt_id=f.id) }}" onsubmit="return confirm('Fahrt wirklich löschen?');" style="margin: 0;">
|
<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