- notifications: round sqm_price to whole € in Telegram match messages
(was emitting raw float like "12.345614 €/m²").
- wohnungen: remove the admin-only "Bilder nachladen (N)" button. It
flickered into view whenever a freshly-scraped flat was still in
pending state, which was effectively random from the user's point of
view, and the manual backfill it triggered isn't needed anymore — new
flats are auto-enriched at scrape time. Also drops the dead helpers
it was the sole caller of: enrichment.kick_backfill,
enrichment._backfill_runner, db.flats_needing_enrichment,
db.enrichment_counts.
- lightbox: the modal didn't appear because Tailwind's Play CDN injects
its own .hidden { display: none } rule at runtime, which kept fighting
our class toggle. Switch the show/hide to inline style.display so no
external stylesheet can mask it. Single-class .lightbox now only owns
the layout — the initial-hidden state is on the element via
style="display:none".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
158 lines
5.5 KiB
Python
158 lines
5.5 KiB
Python
"""
|
|
User-level notification dispatcher.
|
|
|
|
Channels:
|
|
- 'ui' → no-op (dashboard shows the events anyway)
|
|
- 'telegram' → per-user bot token + chat id
|
|
"""
|
|
import logging
|
|
from typing import Optional
|
|
from urllib.parse import quote
|
|
|
|
import requests
|
|
|
|
import db
|
|
from settings import PUBLIC_URL
|
|
|
|
logger = logging.getLogger("web.notifications")
|
|
|
|
EventType = str # 'match' | 'apply_ok' | 'apply_fail'
|
|
|
|
|
|
def telegram_send(token: str, chat_id: str, text: str) -> tuple[bool, str]:
|
|
"""Send a Telegram message. Returns (ok, detail) — detail is the Telegram
|
|
API's `description` on failure, or a short error string, or "ok"."""
|
|
try:
|
|
r = requests.post(
|
|
f"https://api.telegram.org/bot{token}/sendMessage",
|
|
json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown",
|
|
"disable_web_page_preview": True},
|
|
timeout=10,
|
|
)
|
|
if r.ok:
|
|
return True, "ok"
|
|
try:
|
|
detail = r.json().get("description") or f"HTTP {r.status_code}"
|
|
except ValueError:
|
|
detail = f"HTTP {r.status_code}"
|
|
logger.warning("telegram send failed: %s %s", r.status_code, r.text[:200])
|
|
return False, detail
|
|
except requests.RequestException as e:
|
|
logger.warning("telegram unreachable: %s", e)
|
|
return False, str(e)
|
|
|
|
|
|
def _should_notify(notif, event: EventType) -> bool:
|
|
if event == "match":
|
|
return bool(notif["notify_on_match"])
|
|
if event == "apply_ok":
|
|
return bool(notif["notify_on_apply_success"])
|
|
if event == "apply_fail":
|
|
return bool(notif["notify_on_apply_fail"])
|
|
return False
|
|
|
|
|
|
def notify_user(user_id: int, event: EventType, *,
|
|
subject: str, body_plain: str, body_markdown: Optional[str] = None) -> None:
|
|
"""Fire a notification for one user on one event. Best-effort, non-raising."""
|
|
try:
|
|
notif = db.get_notifications(user_id)
|
|
if not notif or not _should_notify(notif, event):
|
|
return
|
|
channel = notif["channel"] or "ui"
|
|
if channel == "telegram":
|
|
token = notif["telegram_bot_token"]
|
|
chat = notif["telegram_chat_id"]
|
|
if token and chat:
|
|
telegram_send(token, chat, body_markdown or body_plain)
|
|
except Exception:
|
|
logger.exception("notify_user failed for user=%s event=%s", user_id, event)
|
|
|
|
|
|
# -- Convenience builders -----------------------------------------------------
|
|
|
|
def _address_lines(address: str) -> tuple[str, str]:
|
|
"""Split "Street Nr, PLZ, District" into (line1, line2) for the two-line
|
|
layout in notifications. Falls back gracefully for unexpected shapes."""
|
|
parts = [p.strip() for p in (address or "").split(",") if p.strip()]
|
|
if len(parts) >= 3:
|
|
return parts[0], f"{parts[1]} {', '.join(parts[2:])}"
|
|
if len(parts) == 2:
|
|
return parts[0], parts[1]
|
|
return (address or "").strip(), ""
|
|
|
|
|
|
def _gmaps_url(address: str, lat, lng) -> str:
|
|
if lat is not None and lng is not None:
|
|
return f"https://www.google.com/maps/search/?api=1&query={lat},{lng}"
|
|
return f"https://www.google.com/maps/search/?api=1&query={quote(address or '')}"
|
|
|
|
|
|
def _wbs_label(wbs: str) -> str:
|
|
w = (wbs or "").strip().lower()
|
|
if w == "erforderlich":
|
|
return "erforderlich"
|
|
if w in ("nicht erforderlich", "kein", "nein", "no", "ohne", "-", ""):
|
|
return "nicht erforderlich"
|
|
return wbs # pass through unrecognised literals
|
|
|
|
|
|
def _fmt_num(v) -> str:
|
|
return "—" if v is None else f"{v}"
|
|
|
|
|
|
def on_match(user_id: int, flat: dict) -> None:
|
|
address = (flat.get("address") or "").strip()
|
|
line1, line2 = _address_lines(address)
|
|
link = flat.get("link", "")
|
|
rent = flat.get("total_rent")
|
|
sqm_price = flat.get("sqm_price")
|
|
rooms = flat.get("rooms")
|
|
size = flat.get("size")
|
|
wbs_txt = _wbs_label(flat.get("wbs", ""))
|
|
gmaps = flat.get("address_link_gmaps") or _gmaps_url(
|
|
address, flat.get("lat"), flat.get("lng"),
|
|
)
|
|
flat_id = str(flat.get("id", ""))
|
|
deep_link = f"{PUBLIC_URL}/?flat={quote(flat_id, safe='')}"
|
|
|
|
rent_str = _fmt_num(rent)
|
|
if sqm_price:
|
|
rent_str += f" ({sqm_price:.0f} €/m²)"
|
|
|
|
body = (
|
|
f"{line1}\n{line2}\n"
|
|
f"Miete: {rent_str}\n"
|
|
f"Fläche: {_fmt_num(size)}\n"
|
|
f"Zimmer: {_fmt_num(rooms)}\n"
|
|
f"WBS: {wbs_txt}\n\n"
|
|
f"Original: {link}\n"
|
|
f"lazyflat: {deep_link}"
|
|
)
|
|
md = (
|
|
f"[{line1}]({gmaps})\n{line2}\n"
|
|
f"Miete: {rent_str}\n"
|
|
f"Fläche: {_fmt_num(size)}\n"
|
|
f"Zimmer: {_fmt_num(rooms)}\n"
|
|
f"WBS: {wbs_txt}\n\n"
|
|
f"[Zur original Anzeige]({link})\n"
|
|
f"[Zur lazyflat Seite]({deep_link})"
|
|
)
|
|
notify_user(user_id, "match", subject="[lazyflat] passende Wohnung",
|
|
body_plain=body, body_markdown=md)
|
|
|
|
|
|
def on_apply_ok(user_id: int, flat: dict, message: str) -> None:
|
|
addr = flat.get("address") or flat.get("link")
|
|
body = f"Bewerbung erfolgreich: {addr}\n{message}"
|
|
md = f"*Bewerbung erfolgreich*\n{addr}\n{message}"
|
|
notify_user(user_id, "apply_ok", subject="[lazyflat] Bewerbung OK", body_plain=body, body_markdown=md)
|
|
|
|
|
|
def on_apply_fail(user_id: int, flat: dict, message: str) -> None:
|
|
addr = flat.get("address") or flat.get("link")
|
|
body = f"Bewerbung fehlgeschlagen: {addr}\n{message}\n{PUBLIC_URL}/bewerbungen"
|
|
md = (f"*Bewerbung fehlgeschlagen*\n{addr}\n{message}\n"
|
|
f"[Bewerbungen ansehen]({PUBLIC_URL}/bewerbungen)")
|
|
notify_user(user_id, "apply_fail", subject="[lazyflat] Bewerbung fehlgeschlagen",
|
|
body_plain=body, body_markdown=md)
|