multi-user: users, per-user profiles/filters/notifications, tab UI, apply forensics
* DB: users + user_profiles/filters/notifications/preferences; applications gets user_id + forensics_json + profile_snapshot_json; new errors table with 14d retention; schema versioning via MIGRATIONS list * auth: password hashes in DB (argon2); env vars seed first admin; per-user sessions; CSRF bound to user id * apply: personal info/WBS moved out of env into the request body; providers take an ApplyContext with Profile + submit_forms; full Playwright recorder (step log, console, page errors, network, screenshots, final HTML) * web: five top-level tabs (Wohnungen/Bewerbungen/Logs/Fehler/Einstellungen); settings sub-tabs profil/filter/benachrichtigungen/account/benutzer; per-user matching, auto-apply and notifications (UI/Telegram/SMTP); red auto-apply switch on Wohnungen tab; forensics detail view for bewerbungen and fehler; retention background thread Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e663386a19
commit
c630b500ef
36 changed files with 2763 additions and 1113 deletions
130
web/notifications.py
Normal file
130
web/notifications.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""
|
||||
User-level notification dispatcher.
|
||||
|
||||
Channels:
|
||||
- 'ui' → no-op (dashboard shows the events anyway)
|
||||
- 'telegram' → per-user bot token + chat id
|
||||
- 'email' → system SMTP (one outbox, per-user recipient)
|
||||
"""
|
||||
import logging
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
import db
|
||||
from settings import (
|
||||
PUBLIC_URL,
|
||||
SMTP_FROM,
|
||||
SMTP_HOST,
|
||||
SMTP_PASSWORD,
|
||||
SMTP_PORT,
|
||||
SMTP_STARTTLS,
|
||||
SMTP_USERNAME,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("web.notifications")
|
||||
|
||||
EventType = str # 'match' | 'apply_ok' | 'apply_fail'
|
||||
|
||||
|
||||
def _telegram_send(token: str, chat_id: str, text: str) -> bool:
|
||||
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 not r.ok:
|
||||
logger.warning("telegram send failed: %s %s", r.status_code, r.text[:200])
|
||||
return False
|
||||
return True
|
||||
except requests.RequestException as e:
|
||||
logger.warning("telegram unreachable: %s", e)
|
||||
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:
|
||||
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 == "ui":
|
||||
return
|
||||
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)
|
||||
elif channel == "email":
|
||||
addr = notif["email_address"]
|
||||
if addr:
|
||||
_email_send(addr, subject, body_plain)
|
||||
except Exception:
|
||||
logger.exception("notify_user failed for user=%s event=%s", user_id, event)
|
||||
|
||||
|
||||
# -- Convenience builders -----------------------------------------------------
|
||||
|
||||
def on_match(user_id: int, flat: dict) -> None:
|
||||
addr = flat.get("address") or flat.get("link")
|
||||
rent = flat.get("total_rent")
|
||||
rooms = flat.get("rooms")
|
||||
link = flat.get("link", "")
|
||||
body = f"Neue passende Wohnung: {addr}\nMiete: {rent} €\nZimmer: {rooms}\n{link}"
|
||||
md = (f"*Neue passende Wohnung*\n[{addr}]({link})\n"
|
||||
f"Miete: {rent} € · Zimmer: {rooms}\n{PUBLIC_URL}")
|
||||
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}/fehler"
|
||||
md = (f"*Bewerbung fehlgeschlagen*\n{addr}\n{message}\n"
|
||||
f"[Fehler ansehen]({PUBLIC_URL}/fehler)")
|
||||
notify_user(user_id, "apply_fail", subject="[lazyflat] Bewerbung fehlgeschlagen",
|
||||
body_plain=body, body_markdown=md)
|
||||
Loading…
Add table
Add a link
Reference in a new issue