rename to wohnungsdidi + didi logo + footer for all + seconds-only counter
- App is now called "wohnungsdidi" everywhere user-facing (page title,
nav brand, login header, notification subjects, report filename,
FastAPI titles, log messages)
- Brand dot replaced with an image of Didi (web/static/didi.webp),
rendered as a round 2.25rem avatar in _layout + login
- "Programmiert für Annika ♥" footer now shows for every logged-in user,
not only Annika
- Count-up shows only seconds ("vor 73 s") regardless of age — no
rollover to minutes/hours
- Data continuity: DB file stays /data/lazyflat.sqlite and the Docker
volume stays lazyflat_data so the rename doesn't strand existing data
- Session cookie renamed to wohnungsdidi_session (one-time logout on
rollout)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
da180bd7c7
commit
0c18f0870a
14 changed files with 34 additions and 32 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
# lazyflat
|
# wohnungsdidi
|
||||||
|
|
||||||
Combined deployment of **flat-alert** (reliable scraper) and **flat-apply**
|
Combined deployment of **flat-alert** (reliable scraper) and **flat-apply**
|
||||||
(experimental auto‑applier) behind a single authenticated web UI.
|
(experimental auto‑applier) behind a single authenticated web UI.
|
||||||
|
|
@ -51,7 +51,7 @@ Because `apply/` is still experimental, the system is hardened around it:
|
||||||
|
|
||||||
## Deployment on Coolify
|
## Deployment on Coolify
|
||||||
|
|
||||||
1. **Create repo**: push this monorepo to `ssh://git@git.moritz.run:2222/moritz/lazyflat.git`.
|
1. **Create repo**: push this monorepo to `ssh://git@git.moritz.run:2222/moritz/wohnungsdidi.git`.
|
||||||
2. **New Coolify resource** → *Docker Compose* → point it at this repo. Coolify
|
2. **New Coolify resource** → *Docker Compose* → point it at this repo. Coolify
|
||||||
will read `docker-compose.yml` and deploy all three services on one network.
|
will read `docker-compose.yml` and deploy all three services on one network.
|
||||||
3. **Domain**: set `flat.lab.moritz.run` on the `web` service only. Coolify
|
3. **Domain**: set `flat.lab.moritz.run` on the `web` service only. Coolify
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ class FlatAlerter:
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
logger.info("starting lazyflat alert service")
|
logger.info("starting wohnungsdidi alert service")
|
||||||
alerter = FlatAlerter()
|
alerter = FlatAlerter()
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ async def lifespan(_app: FastAPI):
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan, title="lazyflat-apply", docs_url=None, redoc_url=None)
|
app = FastAPI(lifespan=lifespan, title="wohnungsdidi-apply", docs_url=None, redoc_url=None)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build: ./web
|
build: ./web
|
||||||
container_name: lazyflat-web
|
container_name: wohnungsdidi-web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
apply:
|
apply:
|
||||||
|
|
@ -26,18 +26,20 @@ services:
|
||||||
- SMTP_PORT=${SMTP_PORT:-587}
|
- SMTP_PORT=${SMTP_PORT:-587}
|
||||||
- SMTP_USERNAME=${SMTP_USERNAME:-}
|
- SMTP_USERNAME=${SMTP_USERNAME:-}
|
||||||
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
|
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
|
||||||
- SMTP_FROM=${SMTP_FROM:-lazyflat@localhost}
|
- SMTP_FROM=${SMTP_FROM:-wohnungsdidi@localhost}
|
||||||
- SMTP_STARTTLS=${SMTP_STARTTLS:-true}
|
- SMTP_STARTTLS=${SMTP_STARTTLS:-true}
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
- ANTHROPIC_MODEL=${ANTHROPIC_MODEL:-claude-haiku-4-5-20251001}
|
- ANTHROPIC_MODEL=${ANTHROPIC_MODEL:-claude-haiku-4-5-20251001}
|
||||||
volumes:
|
volumes:
|
||||||
|
# Legacy volume name — kept so Coolify reuses the existing data volume
|
||||||
|
# after the rename from lazyflat → wohnungsdidi.
|
||||||
- lazyflat_data:/data
|
- lazyflat_data:/data
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
|
|
||||||
apply:
|
apply:
|
||||||
build: ./apply
|
build: ./apply
|
||||||
container_name: lazyflat-apply
|
container_name: wohnungsdidi-apply
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
|
|
@ -51,7 +53,7 @@ services:
|
||||||
|
|
||||||
alert:
|
alert:
|
||||||
build: ./alert
|
build: ./alert
|
||||||
container_name: lazyflat-alert
|
container_name: wohnungsdidi-alert
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
web:
|
web:
|
||||||
|
|
|
||||||
10
web/app.py
10
web/app.py
|
|
@ -1,5 +1,5 @@
|
||||||
"""
|
"""
|
||||||
lazyflat web app.
|
wohnungsdidi web app.
|
||||||
|
|
||||||
Tabs:
|
Tabs:
|
||||||
- / → Wohnungen
|
- / → Wohnungen
|
||||||
|
|
@ -84,7 +84,7 @@ async def lifespan(_app: FastAPI):
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan, title="lazyflat", docs_url=None, redoc_url=None, openapi_url=None)
|
app = FastAPI(lifespan=lifespan, title="wohnungsdidi", docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
@ -712,7 +712,7 @@ def bewerbung_zip(request: Request, app_id: int):
|
||||||
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
zf.writestr(
|
zf.writestr(
|
||||||
"README.txt",
|
"README.txt",
|
||||||
f"lazyflat application report\n"
|
f"wohnungsdidi application report\n"
|
||||||
f"application_id={a['id']}\n"
|
f"application_id={a['id']}\n"
|
||||||
f"flat_id={a['flat_id']}\n"
|
f"flat_id={a['flat_id']}\n"
|
||||||
f"provider={a['provider']}\n"
|
f"provider={a['provider']}\n"
|
||||||
|
|
@ -764,7 +764,7 @@ def bewerbung_zip(request: Request, app_id: int):
|
||||||
zf.writestr(f"snapshots/{idx:02d}_{label}.html", html)
|
zf.writestr(f"snapshots/{idx:02d}_{label}.html", html)
|
||||||
|
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
filename = f"lazyflat-report-{a['id']}.zip"
|
filename = f"wohnungsdidi-report-{a['id']}.zip"
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
buf, media_type="application/zip",
|
buf, media_type="application/zip",
|
||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
|
@ -889,7 +889,7 @@ def tab_logs_export(request: Request):
|
||||||
e["ip"],
|
e["ip"],
|
||||||
])
|
])
|
||||||
body = buf.getvalue().encode("utf-8")
|
body = buf.getvalue().encode("utf-8")
|
||||||
filename = "lazyflat-protokoll"
|
filename = "wohnungsdidi-protokoll"
|
||||||
if q.get("from"): filename += f"-{q['from']}"
|
if q.get("from"): filename += f"-{q['from']}"
|
||||||
if q.get("to"): filename += f"-bis-{q['to']}"
|
if q.get("to"): filename += f"-bis-{q['to']}"
|
||||||
filename += ".csv"
|
filename += ".csv"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
"""
|
"""
|
||||||
SQLite data layer for lazyflat.
|
SQLite data layer for wohnungsdidi.
|
||||||
|
|
||||||
Multi-user: users, per-user profiles/filters/notifications/preferences.
|
Multi-user: users, per-user profiles/filters/notifications/preferences.
|
||||||
All per-user rows are 1:1 with users. Errors and forensics are retained
|
All per-user rows are 1:1 with users. Errors and forensics are retained
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ def _download_images(flat_id: str, urls: list[str], referer: str) -> int:
|
||||||
r = requests.get(
|
r = requests.get(
|
||||||
raw_url,
|
raw_url,
|
||||||
headers={"Referer": referer,
|
headers={"Referer": referer,
|
||||||
"User-Agent": "Mozilla/5.0 (lazyflat enricher)"},
|
"User-Agent": "Mozilla/5.0 (wohnungsdidi enricher)"},
|
||||||
timeout=IMAGE_TIMEOUT,
|
timeout=IMAGE_TIMEOUT,
|
||||||
stream=True,
|
stream=True,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -72,14 +72,14 @@ def on_match(user_id: int, flat: dict) -> None:
|
||||||
body = f"Neue passende Wohnung: {addr}\nMiete: {rent} €\nZimmer: {rooms}\n{link}"
|
body = f"Neue passende Wohnung: {addr}\nMiete: {rent} €\nZimmer: {rooms}\n{link}"
|
||||||
md = (f"*Neue passende Wohnung*\n[{addr}]({link})\n"
|
md = (f"*Neue passende Wohnung*\n[{addr}]({link})\n"
|
||||||
f"Miete: {rent} € · Zimmer: {rooms}\n{PUBLIC_URL}")
|
f"Miete: {rent} € · Zimmer: {rooms}\n{PUBLIC_URL}")
|
||||||
notify_user(user_id, "match", subject="[lazyflat] passende Wohnung", body_plain=body, body_markdown=md)
|
notify_user(user_id, "match", subject="[wohnungsdidi] passende Wohnung", body_plain=body, body_markdown=md)
|
||||||
|
|
||||||
|
|
||||||
def on_apply_ok(user_id: int, flat: dict, message: str) -> None:
|
def on_apply_ok(user_id: int, flat: dict, message: str) -> None:
|
||||||
addr = flat.get("address") or flat.get("link")
|
addr = flat.get("address") or flat.get("link")
|
||||||
body = f"Bewerbung erfolgreich: {addr}\n{message}"
|
body = f"Bewerbung erfolgreich: {addr}\n{message}"
|
||||||
md = f"*Bewerbung erfolgreich*\n{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)
|
notify_user(user_id, "apply_ok", subject="[wohnungsdidi] Bewerbung OK", body_plain=body, body_markdown=md)
|
||||||
|
|
||||||
|
|
||||||
def on_apply_fail(user_id: int, flat: dict, message: str) -> None:
|
def on_apply_fail(user_id: int, flat: dict, message: str) -> None:
|
||||||
|
|
@ -87,5 +87,5 @@ def on_apply_fail(user_id: int, flat: dict, message: str) -> None:
|
||||||
body = f"Bewerbung fehlgeschlagen: {addr}\n{message}\n{PUBLIC_URL}/fehler"
|
body = f"Bewerbung fehlgeschlagen: {addr}\n{message}\n{PUBLIC_URL}/fehler"
|
||||||
md = (f"*Bewerbung fehlgeschlagen*\n{addr}\n{message}\n"
|
md = (f"*Bewerbung fehlgeschlagen*\n{addr}\n{message}\n"
|
||||||
f"[Fehler ansehen]({PUBLIC_URL}/fehler)")
|
f"[Fehler ansehen]({PUBLIC_URL}/fehler)")
|
||||||
notify_user(user_id, "apply_fail", subject="[lazyflat] Bewerbung fehlgeschlagen",
|
notify_user(user_id, "apply_fail", subject="[wohnungsdidi] Bewerbung fehlgeschlagen",
|
||||||
body_plain=body, body_markdown=md)
|
body_plain=body, body_markdown=md)
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ AUTH_PASSWORD_HASH: str = _required("AUTH_PASSWORD_HASH")
|
||||||
|
|
||||||
# --- Session cookie -----------------------------------------------------------
|
# --- Session cookie -----------------------------------------------------------
|
||||||
SESSION_SECRET: str = getenv("SESSION_SECRET") or secrets.token_urlsafe(48)
|
SESSION_SECRET: str = getenv("SESSION_SECRET") or secrets.token_urlsafe(48)
|
||||||
SESSION_COOKIE_NAME: str = "lazyflat_session"
|
SESSION_COOKIE_NAME: str = "wohnungsdidi_session"
|
||||||
SESSION_MAX_AGE_SECONDS: int = int(getenv("SESSION_MAX_AGE_SECONDS", str(60 * 60 * 24 * 7)))
|
SESSION_MAX_AGE_SECONDS: int = int(getenv("SESSION_MAX_AGE_SECONDS", str(60 * 60 * 24 * 7)))
|
||||||
COOKIE_SECURE: bool = getenv("COOKIE_SECURE", "true").lower() in ("true", "1", "yes", "on")
|
COOKIE_SECURE: bool = getenv("COOKIE_SECURE", "true").lower() in ("true", "1", "yes", "on")
|
||||||
|
|
||||||
|
|
@ -43,6 +43,8 @@ ALERT_SCRAPE_INTERVAL_SECONDS: int = int(getenv("ALERT_SCRAPE_INTERVAL_SECONDS",
|
||||||
# --- Storage ------------------------------------------------------------------
|
# --- Storage ------------------------------------------------------------------
|
||||||
DATA_DIR: Path = Path(getenv("DATA_DIR", "/data"))
|
DATA_DIR: Path = Path(getenv("DATA_DIR", "/data"))
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Legacy filename — kept so existing data under /data/lazyflat.sqlite stays
|
||||||
|
# reachable across the rename to wohnungsdidi. Not user-facing.
|
||||||
DB_PATH: Path = DATA_DIR / "lazyflat.sqlite"
|
DB_PATH: Path = DATA_DIR / "lazyflat.sqlite"
|
||||||
|
|
||||||
# Retention (errors / audit / application forensics). Default 14 days.
|
# Retention (errors / audit / application forensics). Default 14 days.
|
||||||
|
|
@ -58,7 +60,7 @@ SMTP_HOST: str = getenv("SMTP_HOST", "")
|
||||||
SMTP_PORT: int = int(getenv("SMTP_PORT", "587"))
|
SMTP_PORT: int = int(getenv("SMTP_PORT", "587"))
|
||||||
SMTP_USERNAME: str = getenv("SMTP_USERNAME", "")
|
SMTP_USERNAME: str = getenv("SMTP_USERNAME", "")
|
||||||
SMTP_PASSWORD: str = getenv("SMTP_PASSWORD", "")
|
SMTP_PASSWORD: str = getenv("SMTP_PASSWORD", "")
|
||||||
SMTP_FROM: str = getenv("SMTP_FROM", "lazyflat@localhost")
|
SMTP_FROM: str = getenv("SMTP_FROM", "wohnungsdidi@localhost")
|
||||||
SMTP_STARTTLS: bool = getenv("SMTP_STARTTLS", "true").lower() in ("true", "1", "yes", "on")
|
SMTP_STARTTLS: bool = getenv("SMTP_STARTTLS", "true").lower() in ("true", "1", "yes", "on")
|
||||||
|
|
||||||
# --- App URL (used to build links in notifications) ---------------------------
|
# --- App URL (used to build links in notifications) ---------------------------
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,7 @@ function fmtCountUp(iso) {
|
||||||
const ts = Date.parse(iso);
|
const ts = Date.parse(iso);
|
||||||
if (!iso || Number.isNaN(ts)) return "—";
|
if (!iso || Number.isNaN(ts)) return "—";
|
||||||
const diff = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
const diff = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
||||||
if (diff < 60) return `vor ${diff} s`;
|
return `vor ${diff} s`;
|
||||||
if (diff < 3600) return `vor ${Math.floor(diff / 60)} min`;
|
|
||||||
return `vor ${Math.floor(diff / 3600)} h`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRelativeTimes() {
|
function updateRelativeTimes() {
|
||||||
|
|
|
||||||
BIN
web/static/didi.webp
Normal file
BIN
web/static/didi.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
|
|
@ -7,8 +7,8 @@
|
||||||
<header class="border-b border-soft bg-white/70 backdrop-blur sticky top-0 z-10">
|
<header class="border-b border-soft bg-white/70 backdrop-blur sticky top-0 z-10">
|
||||||
<div class="max-w-6xl mx-auto px-6 py-3 flex items-center justify-between">
|
<div class="max-w-6xl mx-auto px-6 py-3 flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="brand-dot"></div>
|
<img src="/static/didi.webp" alt="" class="brand-dot">
|
||||||
<h1 class="text-xl font-semibold">lazyflat</h1>
|
<h1 class="text-xl font-semibold">wohnungsdidi</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4 text-sm">
|
<div class="flex items-center gap-4 text-sm">
|
||||||
<span class="text-slate-500">{{ user.username }}{% if is_admin %} · <span class="chip chip-info">Administrator</span>{% endif %}</span>
|
<span class="text-slate-500">{{ user.username }}{% if is_admin %} · <span class="chip chip-info">Administrator</span>{% endif %}</span>
|
||||||
|
|
@ -29,9 +29,7 @@
|
||||||
<main class="max-w-6xl mx-auto px-6 py-6 space-y-6">
|
<main class="max-w-6xl mx-auto px-6 py-6 space-y-6">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
{% if user.username == 'annika' %}
|
|
||||||
<footer class="text-center text-xs text-slate-500 py-6">
|
<footer class="text-center text-xs text-slate-500 py-6">
|
||||||
Programmiert für Annika ♥
|
Programmiert für Annika ♥
|
||||||
</footer>
|
</footer>
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<title>{% block title %}lazyflat{% endblock %}</title>
|
<title>{% block title %}wohnungsdidi{% endblock %}</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
|
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
|
|
@ -123,9 +123,11 @@
|
||||||
.map-popup-actions .btn { padding: 0.35rem 0.7rem; font-size: 12px; }
|
.map-popup-actions .btn { padding: 0.35rem 0.7rem; font-size: 12px; }
|
||||||
.map-popup-actions form { margin: 0; }
|
.map-popup-actions form { margin: 0; }
|
||||||
.brand-dot {
|
.brand-dot {
|
||||||
width: 2rem; height: 2rem; border-radius: 10px;
|
width: 2.25rem; height: 2.25rem; border-radius: 9999px;
|
||||||
background: linear-gradient(135deg, #66b7f2 0%, #2f8ae0 60%, #fbd76b 100%);
|
object-fit: cover; display: block;
|
||||||
|
border: 2px solid #fff;
|
||||||
box-shadow: 0 1px 4px rgba(47, 138, 224, 0.35);
|
box-shadow: 0 1px 4px rgba(47, 138, 224, 0.35);
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
a { color: var(--primary); }
|
a { color: var(--primary); }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
<main class="flex min-h-screen items-center justify-center p-4">
|
<main class="flex min-h-screen items-center justify-center p-4">
|
||||||
<div class="card w-full max-w-sm p-8">
|
<div class="card w-full max-w-sm p-8">
|
||||||
<div class="flex items-center gap-3 mb-6">
|
<div class="flex items-center gap-3 mb-6">
|
||||||
<div class="brand-dot"></div>
|
<img src="/static/didi.webp" alt="" class="brand-dot">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold leading-tight">lazyflat</h1>
|
<h1 class="text-2xl font-semibold leading-tight">wohnungsdidi</h1>
|
||||||
<p class="text-sm text-slate-500">Anmeldung erforderlich</p>
|
<p class="text-sm text-slate-500">Anmeldung erforderlich</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue