Fetch stops from OpenStreetMap
Deploy PBT / deploy (push) Successful in 16s

This commit is contained in:
2026-09-10 21:44:45 +02:00
parent 0ebf13aacf
commit 2a6e9c690b
14 changed files with 666 additions and 9 deletions
View File
+24 -1
View File
@@ -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 12 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.
+73 -3
View File
@@ -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.
+31
View File
@@ -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])
+206
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+15 -4
View File
@@ -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 %}
+35
View File
@@ -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>
+4 -1
View File
@@ -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;">