Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/flatpilot/apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ def apply_to_flat(
attachments=attachments,
submit=False,
screenshot_dir=screenshot_dir,
profile=profile,
)
return ApplyOutcome(
status="dry_run",
Expand Down Expand Up @@ -294,6 +295,7 @@ def apply_to_flat(
attachments=attachments,
submit=True,
screenshot_dir=screenshot_dir,
profile=profile,
)
except FillError as exc:
# ListingExpiredError is recorded with the ``auto_skipped:``
Expand Down
11 changes: 10 additions & 1 deletion src/flatpilot/fillers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import ClassVar, Protocol
from typing import TYPE_CHECKING, ClassVar, Protocol

from flatpilot.errors import FlatPilotError

if TYPE_CHECKING:
from flatpilot.profile import Profile


class FillError(FlatPilotError):
"""Base class for all filler errors."""
Expand Down Expand Up @@ -122,6 +125,7 @@ def fill(
*,
submit: bool,
screenshot_dir: Path | None = None,
profile: Profile | None = None,
) -> FillReport:
"""Navigate to ``listing_url``, open the contact form, fill it.

Expand All @@ -131,6 +135,11 @@ def fill(
stop at the filled-but-unsent form and return — useful for
previews.

``profile`` (optional) lets a filler read additional structured
fields off ``profile.contact_details`` for platforms whose
contact form requests them (Kleinanzeigen, FlatPilot-ic1).
Fillers that don't use it simply ignore the argument.

Implementations MUST NOT attempt to log in. Failures should
raise the most specific error class available —
:class:`NotAuthenticatedError` when the page redirects to login,
Expand Down
9 changes: 9 additions & 0 deletions src/flatpilot/fillers/kleinanzeigen.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
SelectorMissingError,
SubmitVerificationError,
)
from flatpilot.profile import Profile
from flatpilot.scrapers.kleinanzeigen import CONSENT_SELECTORS, HOST, WARMUP_URL
Comment on lines 60 to 63
from flatpilot.scrapers.session import (
DEFAULT_USER_AGENT,
Expand Down Expand Up @@ -147,7 +148,15 @@ def fill(
*,
submit: bool,
screenshot_dir: Path | None = None,
profile: Profile | None = None,
) -> FillReport:
# ``profile`` is accepted for forward compatibility with
# FlatPilot-ic1 Phase 2 (structured-field filling). Phase 1
# threads the argument through every layer so Phase 2 can land
# without further API churn. Until Phase 2, the value is unused
# here — Kleinanzeigen still relies on account-prefill for
# whatever structured fields the form exposes.
del profile
if not message.strip():
raise ValueError("message must be non-empty")
for path in attachments:
Expand Down
10 changes: 9 additions & 1 deletion src/flatpilot/fillers/wg_gesucht.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, ClassVar
from typing import TYPE_CHECKING, Any, ClassVar

if TYPE_CHECKING:
from flatpilot.profile import Profile

from playwright.sync_api import TimeoutError as PlaywrightTimeoutError

Expand Down Expand Up @@ -150,7 +153,12 @@ def fill(
*,
submit: bool,
screenshot_dir: Path | None = None,
profile: Profile | None = None,
) -> FillReport:
# WG-Gesucht uses no structured contact-form fields, so ``profile``
# is accepted for protocol compatibility (FlatPilot-ic1) and
# ignored here.
del profile
if not message.strip():
raise ValueError("message must be non-empty")
for path in attachments:
Expand Down
10 changes: 10 additions & 0 deletions src/flatpilot/profile.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@
"wbs": {
"status": "none"
},
"contact_details": {
"anrede": null,
"given_name": null,
"surname": null,
"phone": null,
"street": null,
"plz": null,
"schufa_status": null,
"household_type": null
},
"notifications": {
"telegram": {
"enabled": false,
Expand Down
24 changes: 24 additions & 0 deletions src/flatpilot/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,29 @@
IncomeCategory = Literal[100, 140, 160, 180]
EmploymentStatus = Literal["student", "employed", "self_employed", "other"]
FurnishedPref = Literal["any", "furnished", "unfurnished"]
Anrede = Literal["frau", "herr", "divers", "keine_angabe"]
SchufaStatus = Literal["available", "no", "on_request"]
HouseholdType = Literal["single", "couple", "family", "wg", "other"]


class ContactDetails(BaseModel):
"""Optional contact-form fields for platforms that ask for them.

Today only Kleinanzeigen surfaces these — and only for some landlords
(FlatPilot-ic1). Each field is independently optional; a filler that
can fill the field will, otherwise it leaves the form's existing
value (typically empty) alone. The matcher ignores this block.
"""
model_config = ConfigDict(extra="forbid")

anrede: Anrede | None = None
given_name: str | None = None
surname: str | None = None
phone: str | None = None
street: str | None = None
plz: str | None = None
schufa_status: SchufaStatus | None = None
household_type: HouseholdType | None = None


class WBS(BaseModel):
Expand Down Expand Up @@ -206,6 +229,7 @@ class Profile(BaseModel):
exclude_short_term: bool = True

wbs: WBS = Field(default_factory=WBS)
contact_details: ContactDetails = Field(default_factory=ContactDetails)
notifications: Notifications = Field(default_factory=Notifications)
attachments: Attachments = Field(default_factory=Attachments)
Comment on lines 229 to 234
auto_apply: AutoApplySettings = Field(default_factory=AutoApplySettings)
Expand Down
84 changes: 84 additions & 0 deletions src/flatpilot/wizard/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from flatpilot.matcher.distance import geocode
from flatpilot.profile import (
WBS,
ContactDetails,
EmailNotification,
EmailNotificationOverride,
Notifications,
Expand Down Expand Up @@ -493,6 +494,15 @@ def run(console: Console | None = None) -> Path | None:
out.rule("WBS (Wohnberechtigungsschein)")
wbs = _collect_wbs(out, defaults.wbs)

out.rule("Kleinanzeigen contact details (optional)")
out.print(
"[dim]These are Kleinanzeigen-specific contact-form fields some "
"landlords request. Each prompt accepts blank to skip; FlatPilot will "
"only fill fields the form actually exposes and the profile defines. "
"Other platforms ignore these.[/dim]"
)
contact_details = _collect_contact_details(out, defaults.contact_details)

out.rule("Notifications")
notifications = _collect_notifications(out, defaults.notifications)

Expand All @@ -518,6 +528,7 @@ def run(console: Console | None = None) -> Path | None:
"min_contract_months": min_contract_months,
"exclude_short_term": exclude_short_term,
"wbs": wbs,
"contact_details": contact_details,
"notifications": notifications,
}
try:
Expand Down Expand Up @@ -751,6 +762,79 @@ def _collect_wbs(out: Console, current: WBS) -> WBS:
)


def _collect_contact_details(out: Console, current: ContactDetails) -> ContactDetails:
if not Confirm.ask(
"Add / edit Kleinanzeigen contact-form details now?",
default=any(
getattr(current, f) is not None
for f in (
"anrede", "given_name", "surname", "phone",
"street", "plz", "schufa_status", "household_type",
)
),
):
return current

anrede = Prompt.ask(
"Anrede (blank = skip)",
choices=["frau", "herr", "divers", "keine_angabe", ""],
default=current.anrede or "",
show_choices=True,
show_default=False,
) or None

given_name = _prompt_optional_str(
out, "Vorname (given name; blank = skip)",
default=current.given_name or "",
)
surname = _prompt_optional_str(
out, "Nachname (surname; blank = skip)",
default=current.surname or "",
)
phone = _prompt_optional_str(
out, "Telefon (blank = skip)",
default=current.phone or "",
)
street = _prompt_optional_str(
out, "Straße / Nr. (blank = skip)",
default=current.street or "",
)
plz = _prompt_optional_str(
out, "PLZ (5-digit postal code; blank = skip)",
default=current.plz or "",
)

schufa_status = Prompt.ask(
"SCHUFA-Auskunft (blank = skip)",
choices=["available", "no", "on_request", ""],
default=current.schufa_status or "",
show_default=False,
) or None

household_type = Prompt.ask(
"Haushaltstyp (blank = skip)",
choices=["single", "couple", "family", "wg", "other", ""],
default=current.household_type or "",
show_default=False,
) or None

return ContactDetails(
anrede=anrede, # type: ignore[arg-type]
given_name=given_name,
surname=surname,
phone=phone,
street=street,
plz=plz,
schufa_status=schufa_status, # type: ignore[arg-type]
household_type=household_type, # type: ignore[arg-type]
)


def _prompt_optional_str(out: Console, prompt: str, *, default: str) -> str | None:
raw = Prompt.ask(prompt, default=default)
return raw.strip() or None


def _collect_notifications(out: Console, current: Notifications) -> Notifications:
tg_enabled = Confirm.ask("Enable Telegram?", default=current.telegram.enabled)
if tg_enabled:
Expand Down
10 changes: 8 additions & 2 deletions tests/test_apply_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,10 @@ def test_apply_to_flat_acquires_and_releases_lock_on_success(
_write_template(tmp_path)
flat_id = _insert_flat(tmp_db)

def fake_fill(self, listing_url, message, attachments, *, submit, screenshot_dir=None):
def fake_fill(
self, listing_url, message, attachments, *,
submit, screenshot_dir=None, profile=None,
):
# While the filler is running, the lock row must exist.
row = tmp_db.execute(
"SELECT pid FROM apply_locks WHERE flat_id = ?", (flat_id,)
Expand Down Expand Up @@ -313,7 +316,10 @@ def test_apply_to_flat_dry_run_does_not_touch_lock(tmp_db, tmp_path, monkeypatch
_write_template(tmp_path)
flat_id = _insert_flat(tmp_db)

def fake_fill(self, listing_url, message, attachments, *, submit, screenshot_dir=None):
def fake_fill(
self, listing_url, message, attachments, *,
submit, screenshot_dir=None, profile=None,
):
return FillReport(
platform="wg-gesucht",
listing_url=listing_url,
Expand Down
11 changes: 10 additions & 1 deletion tests/test_apply_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,18 @@ def _write_template(tmp_path: Path) -> None:
def _stub_filler(monkeypatch, *, submitted: bool = True, raises: Exception | None = None):
captured: dict = {}

def fake_fill(self, listing_url, message, attachments, *, submit, screenshot_dir=None):
def fake_fill(
self, listing_url, message, attachments, *,
submit, screenshot_dir=None, profile=None,
):
captured.update(
{
"listing_url": listing_url,
"message": message,
"attachments": attachments,
"submit": submit,
"screenshot_dir": screenshot_dir,
"profile": profile,
}
)
if raises is not None:
Expand Down Expand Up @@ -121,6 +125,11 @@ def test_apply_dry_run_writes_no_row(tmp_db, tmp_path, monkeypatch):
assert captured["submit"] is False
assert "interessiert an Bright 2-room Friedrichshain" in captured["message"]
assert tmp_db.execute("SELECT COUNT(*) FROM applications").fetchone()[0] == 0
# FlatPilot-ic1 Phase 1: apply orchestrator must thread the loaded
# Profile through to the filler so a future structured-field filler
# can read profile.contact_details without further plumbing.
from flatpilot.profile import Profile as _Profile
assert isinstance(captured["profile"], _Profile)


def test_apply_live_writes_submitted_row(tmp_db, tmp_path, monkeypatch):
Expand Down
53 changes: 53 additions & 0 deletions tests/test_contact_details_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Tests for the ContactDetails sub-model (FlatPilot-ic1 Phase 1)."""
from __future__ import annotations

import pytest
from pydantic import ValidationError

from flatpilot.profile import ContactDetails, Profile


def test_contact_details_defaults_are_all_none():
cd = ContactDetails()
for field in (
"anrede", "given_name", "surname", "phone",
"street", "plz", "schufa_status", "household_type",
):
assert getattr(cd, field) is None


def test_contact_details_accepts_full_payload():
cd = ContactDetails(
anrede="herr",
given_name="Max",
surname="Müller",
phone="+49 30 12345678",
street="Musterstraße 1",
plz="10115",
schufa_status="available",
household_type="couple",
)
assert cd.anrede == "herr"
assert cd.given_name == "Max"
assert cd.schufa_status == "available"


def test_contact_details_rejects_unknown_field():
with pytest.raises(ValidationError):
ContactDetails(anrede="herr", unknown_field=42)


def test_contact_details_rejects_invalid_anrede():
with pytest.raises(ValidationError):
ContactDetails(anrede="mister") # type: ignore[arg-type]


def test_contact_details_rejects_invalid_schufa_status():
with pytest.raises(ValidationError):
ContactDetails(schufa_status="green") # type: ignore[arg-type]


def test_profile_default_contact_details_block_is_present_and_empty():
profile = Profile.load_example()
assert profile.contact_details.anrede is None
assert profile.contact_details.surname is None
Loading
Loading