map debug + coord backfill, remove email channel, countdown label

- Surface "X/Y passende Wohnungen mit Koordinaten" on the Karte view +
  admin-only "Koordinaten nachladen" button (POST /actions/backfill-coords)
  that geocodes missing flats via Google Maps directly from the web container
- Add googlemaps dep + GMAPS_API_KEY env to web service
- Light console.log in map.js ("rendering N/M markers", "building Leaflet…")
  so the browser DevTools shows what's happening
- Drop e-mail channel from notifications UI, notify dispatcher, and _alert_status;
  coerce legacy 'email' channel rows back to 'ui' on save
- Countdown said "Aktualisierung läuft…" next to "nächste Aktualisierung" →
  shortened to "aktualisiere…"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
EiSiMo 2026-04-21 13:42:21 +02:00
parent 51b6b02b24
commit 0c58242ce7
11 changed files with 149 additions and 58 deletions

View file

@ -28,6 +28,7 @@ services:
- SMTP_PASSWORD=${SMTP_PASSWORD:-} - SMTP_PASSWORD=${SMTP_PASSWORD:-}
- SMTP_FROM=${SMTP_FROM:-lazyflat@localhost} - SMTP_FROM=${SMTP_FROM:-lazyflat@localhost}
- SMTP_STARTTLS=${SMTP_STARTTLS:-true} - SMTP_STARTTLS=${SMTP_STARTTLS:-true}
- GMAPS_API_KEY=${GMAPS_API_KEY:-}
volumes: volumes:
- lazyflat_data:/data - lazyflat_data:/data
expose: expose:

View file

@ -33,6 +33,7 @@ except Exception:
BERLIN_TZ = timezone.utc BERLIN_TZ = timezone.utc
import db import db
import geocode
import notifications import notifications
import retention import retention
from apply_client import ApplyClient, _row_to_profile from apply_client import ApplyClient, _row_to_profile
@ -220,9 +221,9 @@ def _has_filters(f) -> bool:
def _alert_status(notifications_row) -> tuple[str, str]: def _alert_status(notifications_row) -> tuple[str, str]:
"""Return (label, chip_kind) for the user's alarm (notification) setup. """Return (label, chip_kind) for the user's alarm (notification) setup.
'aktiv' only if a real push channel (telegram/email) is configured with 'aktiv' only if a real push channel is configured with credentials.
credentials. 'ui' is not a real alarm the dashboard already shows 'ui' is not a real alarm the dashboard already shows matches when you
matches when you happen to be looking. happen to be looking.
""" """
if not notifications_row: if not notifications_row:
return "nicht eingerichtet", "warn" return "nicht eingerichtet", "warn"
@ -231,10 +232,6 @@ def _alert_status(notifications_row) -> tuple[str, str]:
if notifications_row["telegram_bot_token"] and notifications_row["telegram_chat_id"]: if notifications_row["telegram_bot_token"] and notifications_row["telegram_chat_id"]:
return "aktiv (Telegram)", "ok" return "aktiv (Telegram)", "ok"
return "unvollständig", "warn" return "unvollständig", "warn"
if ch == "email":
if notifications_row["email_address"]:
return "aktiv (E-Mail)", "ok"
return "unvollständig", "warn"
return "nicht eingerichtet", "warn" return "nicht eingerichtet", "warn"
@ -405,6 +402,12 @@ def _wohnungen_context(user) -> dict:
flats_view.append({"row": f, "last": last}) flats_view.append({"row": f, "last": last})
rejected_view = db.rejected_flats(uid) rejected_view = db.rejected_flats(uid)
matched_count = len(flats_view)
matched_with_coords = sum(
1 for item in flats_view
if item["row"]["lat"] is not None and item["row"]["lng"] is not None
)
matched_without_coords = matched_count - matched_with_coords
allowed, reason = _manual_apply_allowed() allowed, reason = _manual_apply_allowed()
alert_label, alert_chip = _alert_status(notif_row) alert_label, alert_chip = _alert_status(notif_row)
@ -426,6 +429,10 @@ def _wohnungen_context(user) -> dict:
"flats": flats_view, "flats": flats_view,
"rejected_flats": rejected_view, "rejected_flats": rejected_view,
"map_points": map_points, "map_points": map_points,
"map_matched_total": matched_count,
"map_matched_with_coords": matched_with_coords,
"map_matched_without_coords": matched_without_coords,
"gmaps_available": bool(geocode._get_client() is not None),
"has_filters": _has_filters(filters_row), "has_filters": _has_filters(filters_row),
"alert_label": alert_label, "alert_label": alert_label,
"alert_chip": alert_chip, "alert_chip": alert_chip,
@ -551,6 +558,31 @@ async def action_reject(
return _wohnungen_partial_or_redirect(request, user) return _wohnungen_partial_or_redirect(request, user)
@app.post("/actions/backfill-coords")
async def action_backfill_coords(
request: Request,
csrf: str = Form(...),
admin=Depends(require_admin),
):
require_csrf(admin["id"], csrf)
rows = db.flats_missing_coords(limit=500)
total = len(rows)
resolved = 0
skipped = 0
for row in rows:
coords = geocode.geocode(row["address"])
if coords is None:
skipped += 1
continue
db.set_flat_coords(row["id"], coords[0], coords[1])
resolved += 1
summary = f"{resolved}/{total} geocoded, {skipped} übersprungen"
logger.info("coord backfill: %s", summary)
db.log_audit(admin["username"], "coords.backfill", summary,
user_id=admin["id"], ip=client_ip(request))
return _wohnungen_partial_or_redirect(request, admin)
@app.post("/actions/unreject") @app.post("/actions/unreject")
async def action_unreject( async def action_unreject(
request: Request, request: Request,
@ -854,11 +886,14 @@ async def action_notifications(request: Request, user=Depends(require_user)):
form = await request.form() form = await request.form()
require_csrf(user["id"], form.get("csrf", "")) require_csrf(user["id"], form.get("csrf", ""))
def _b(n): return 1 if form.get(n, "").lower() in ("on", "true", "1", "yes") else 0 def _b(n): return 1 if form.get(n, "").lower() in ("on", "true", "1", "yes") else 0
channel = form.get("channel", "ui")
if channel not in ("ui", "telegram"):
channel = "ui"
db.update_notifications(user["id"], { db.update_notifications(user["id"], {
"channel": form.get("channel", "ui"), "channel": channel,
"telegram_bot_token": form.get("telegram_bot_token", ""), "telegram_bot_token": form.get("telegram_bot_token", ""),
"telegram_chat_id": form.get("telegram_chat_id", ""), "telegram_chat_id": form.get("telegram_chat_id", ""),
"email_address": form.get("email_address", ""), "email_address": "",
"notify_on_match": _b("notify_on_match"), "notify_on_match": _b("notify_on_match"),
"notify_on_apply_success": _b("notify_on_apply_success"), "notify_on_apply_success": _b("notify_on_apply_success"),
"notify_on_apply_fail": _b("notify_on_apply_fail"), "notify_on_apply_fail": _b("notify_on_apply_fail"),

View file

@ -439,6 +439,35 @@ def get_flat(flat_id: str) -> Optional[sqlite3.Row]:
return _conn.execute("SELECT * FROM flats WHERE id = ?", (flat_id,)).fetchone() return _conn.execute("SELECT * FROM flats WHERE id = ?", (flat_id,)).fetchone()
def flats_missing_coords(limit: int = 500) -> list[sqlite3.Row]:
return list(_conn.execute(
"""SELECT id, address FROM flats
WHERE (lat IS NULL OR lng IS NULL) AND address IS NOT NULL AND address != ''
ORDER BY discovered_at DESC LIMIT ?""",
(limit,),
).fetchall())
def set_flat_coords(flat_id: str, lat: float, lng: float) -> None:
with _lock:
_conn.execute(
"UPDATE flats SET lat = ?, lng = ? WHERE id = ?",
(lat, lng, flat_id),
)
def count_flats_coords() -> tuple[int, int]:
"""Return (total_flats, with_coords)."""
row = _conn.execute(
"SELECT COUNT(*) AS total, "
" SUM(CASE WHEN lat IS NOT NULL AND lng IS NOT NULL THEN 1 ELSE 0 END) AS geo "
"FROM flats"
).fetchone()
total = int(row["total"] or 0)
geo = int(row["geo"] or 0)
return total, geo
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Applications # Applications
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

46
web/geocode.py Normal file
View file

@ -0,0 +1,46 @@
"""Thin Google-Maps geocoding wrapper used only for the admin coord backfill.
Runtime geocoding during scraping happens in the alert service. This module
exists so the web service can lazy-fix flats that were scraped before the
lat/lng migration (or where the alert run skipped geocoding).
"""
import logging
from typing import Optional
from settings import GMAPS_API_KEY
logger = logging.getLogger("web.geocode")
_client = None
def _get_client():
global _client
if _client is not None:
return _client
if not GMAPS_API_KEY:
return None
try:
import googlemaps
_client = googlemaps.Client(key=GMAPS_API_KEY)
return _client
except Exception as e:
logger.warning("googlemaps client init failed: %s", e)
return None
def geocode(address: str) -> Optional[tuple[float, float]]:
if not address:
return None
gm = _get_client()
if gm is None:
return None
try:
res = gm.geocode(f"{address}, Berlin, Germany")
if not res:
return None
loc = res[0]["geometry"]["location"]
return float(loc["lat"]), float(loc["lng"])
except Exception as e:
logger.warning("geocode failed for %r: %s", address, e)
return None

View file

@ -4,25 +4,14 @@ User-level notification dispatcher.
Channels: Channels:
- 'ui' no-op (dashboard shows the events anyway) - 'ui' no-op (dashboard shows the events anyway)
- 'telegram' per-user bot token + chat id - 'telegram' per-user bot token + chat id
- 'email' system SMTP (one outbox, per-user recipient)
""" """
import logging import logging
import smtplib
from email.mime.text import MIMEText
from typing import Optional from typing import Optional
import requests import requests
import db import db
from settings import ( from settings import PUBLIC_URL
PUBLIC_URL,
SMTP_FROM,
SMTP_HOST,
SMTP_PASSWORD,
SMTP_PORT,
SMTP_STARTTLS,
SMTP_USERNAME,
)
logger = logging.getLogger("web.notifications") logger = logging.getLogger("web.notifications")
@ -46,28 +35,6 @@ def _telegram_send(token: str, chat_id: str, text: str) -> bool:
return False return False
def _email_send(recipient: str, subject: str, body: str) -> bool:
if not SMTP_HOST or not recipient:
logger.info("email skipped (SMTP_HOST=%r recipient=%r)", SMTP_HOST, recipient)
return False
try:
msg = MIMEText(body, _charset="utf-8")
msg["Subject"] = subject
msg["From"] = SMTP_FROM
msg["To"] = recipient
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15) as s:
if SMTP_STARTTLS:
s.starttls()
if SMTP_USERNAME:
s.login(SMTP_USERNAME, SMTP_PASSWORD)
s.send_message(msg)
logger.info("email sent to %s", recipient)
return True
except Exception as e:
logger.warning("email send failed: %s", e)
return False
def _should_notify(notif, event: EventType) -> bool: def _should_notify(notif, event: EventType) -> bool:
if event == "match": if event == "match":
return bool(notif["notify_on_match"]) return bool(notif["notify_on_match"])
@ -86,17 +53,11 @@ def notify_user(user_id: int, event: EventType, *,
if not notif or not _should_notify(notif, event): if not notif or not _should_notify(notif, event):
return return
channel = notif["channel"] or "ui" channel = notif["channel"] or "ui"
if channel == "ui":
return
if channel == "telegram": if channel == "telegram":
token = notif["telegram_bot_token"] token = notif["telegram_bot_token"]
chat = notif["telegram_chat_id"] chat = notif["telegram_chat_id"]
if token and chat: if token and chat:
_telegram_send(token, chat, body_markdown or body_plain) _telegram_send(token, chat, body_markdown or body_plain)
elif channel == "email":
addr = notif["email_address"]
if addr:
_email_send(addr, subject, body_plain)
except Exception: except Exception:
logger.exception("notify_user failed for user=%s event=%s", user_id, event) logger.exception("notify_user failed for user=%s event=%s", user_id, event)

View file

@ -6,3 +6,4 @@ itsdangerous==2.2.0
python-multipart==0.0.17 python-multipart==0.0.17
python-dotenv==1.0.1 python-dotenv==1.0.1
requests==2.32.5 requests==2.32.5
googlemaps==4.10.0

View file

@ -63,3 +63,6 @@ SMTP_STARTTLS: bool = getenv("SMTP_STARTTLS", "true").lower() in ("true", "1", "
# --- App URL (used to build links in notifications) --------------------------- # --- App URL (used to build links in notifications) ---------------------------
PUBLIC_URL: str = getenv("PUBLIC_URL", "https://flat.lab.moritz.run") PUBLIC_URL: str = getenv("PUBLIC_URL", "https://flat.lab.moritz.run")
# --- Google Maps (used for one-shot coord backfill via admin action) ----------
GMAPS_API_KEY: str = getenv("GMAPS_API_KEY", "")

View file

@ -18,7 +18,7 @@ function fmtCountdown(iso) {
const ts = Date.parse(iso); const ts = Date.parse(iso);
if (!iso || Number.isNaN(ts)) return "—"; if (!iso || Number.isNaN(ts)) return "—";
const secs = Math.floor((ts - Date.now()) / 1000); const secs = Math.floor((ts - Date.now()) / 1000);
if (secs <= 0) return "Aktualisierung läuft…"; if (secs <= 0) return "aktualisiere…";
if (secs < 60) return `in ${secs} s`; if (secs < 60) return `in ${secs} s`;
if (secs < 3600) return `in ${Math.floor(secs / 60)} min`; if (secs < 3600) return `in ${Math.floor(secs / 60)} min`;
return `in ${Math.floor(secs / 3600)} h`; return `in ${Math.floor(secs / 3600)} h`;

View file

@ -39,6 +39,8 @@ function renderMarkers(data) {
const fp = fingerprintOf(data); const fp = fingerprintOf(data);
if (fp === currentFingerprint) return; if (fp === currentFingerprint) return;
currentFingerprint = fp; currentFingerprint = fp;
const geo = data.filter((f) => typeof f.lat === "number" && typeof f.lng === "number").length;
console.log(`[lazyflat.map] rendering ${geo}/${data.length} markers`);
if (markerLayer) { if (markerLayer) {
markerLayer.clearLayers(); markerLayer.clearLayers();
@ -70,6 +72,7 @@ function renderMarkers(data) {
} }
function buildMap(el) { function buildMap(el) {
console.log(`[lazyflat.map] building Leaflet instance (container ${el.clientWidth}×${el.clientHeight})`);
mapInstance = L.map(el, { mapInstance = L.map(el, {
zoomControl: false, zoomControl: false,
scrollWheelZoom: false, scrollWheelZoom: false,

View file

@ -11,7 +11,6 @@
<select class="input" name="channel"> <select class="input" name="channel">
<option value="ui" {% if notifications.channel == 'ui' %}selected{% endif %}>Nur im Dashboard (kein Push)</option> <option value="ui" {% if notifications.channel == 'ui' %}selected{% endif %}>Nur im Dashboard (kein Push)</option>
<option value="telegram" {% if notifications.channel == 'telegram' %}selected{% endif %}>Telegram</option> <option value="telegram" {% if notifications.channel == 'telegram' %}selected{% endif %}>Telegram</option>
<option value="email" {% if notifications.channel == 'email' %}selected{% endif %}>E-Mail</option>
</select> </select>
</div> </div>
@ -26,12 +25,6 @@
<input class="input" name="telegram_chat_id" value="{{ notifications.telegram_chat_id }}" placeholder="987654321"> <input class="input" name="telegram_chat_id" value="{{ notifications.telegram_chat_id }}" placeholder="987654321">
</div> </div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">E-Mail Adresse</label>
<input class="input" type="email" name="email_address" value="{{ notifications.email_address }}"
placeholder="du@example.com">
</div>
<div class="border-t border-soft pt-4 space-y-2"> <div class="border-t border-soft pt-4 space-y-2">
<label class="flex items-center gap-2"> <label class="flex items-center gap-2">
<input type="checkbox" name="notify_on_match" {% if notifications.notify_on_match %}checked{% endif %}> <input type="checkbox" name="notify_on_match" {% if notifications.notify_on_match %}checked{% endif %}>

View file

@ -97,7 +97,26 @@
</section> </section>
<!-- Karte (Leaflet-Container bleibt über HTMX-Swaps hinweg erhalten) --> <!-- Karte (Leaflet-Container bleibt über HTMX-Swaps hinweg erhalten) -->
<section class="view-map"> <section class="view-map space-y-2">
<div class="card p-3 text-xs text-slate-600 flex items-center justify-between gap-3 flex-wrap">
<div>
{{ map_matched_with_coords }} / {{ map_matched_total }} passende Wohnungen mit Koordinaten
{% if map_matched_without_coords %}
· <span class="chip chip-warn">{{ map_matched_without_coords }} ohne Koordinaten</span>
{% endif %}
</div>
{% if is_admin and map_matched_without_coords %}
<form method="post" action="/actions/backfill-coords"
hx-post="/actions/backfill-coords" hx-target="#wohnungen-body" hx-swap="outerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button class="btn btn-ghost text-xs" type="submit"
{% if not gmaps_available %}disabled title="GMAPS_API_KEY fehlt"{% endif %}
hx-confirm="Fehlende Koordinaten jetzt über Google Maps nachladen?">
Koordinaten nachladen
</button>
</form>
{% endif %}
</div>
<div class="card p-3"> <div class="card p-3">
<div id="flats-map" hx-preserve="true"></div> <div id="flats-map" hx-preserve="true"></div>
</div> </div>