From 34743127fb015be2d4ea5e9e8529fbf0fb1d3601 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 22 Feb 2026 15:17:56 +0000 Subject: [PATCH] Add debug CLI listings subcommand (list, detail, stream, refresh) --- cli/listings.py | 233 ++++++++++++++++++++++++++++++++++++++++++++++++ main.py | 2 + 2 files changed, 235 insertions(+) create mode 100644 cli/listings.py diff --git a/cli/listings.py b/cli/listings.py new file mode 100644 index 0000000..4a7cb06 --- /dev/null +++ b/cli/listings.py @@ -0,0 +1,233 @@ +"""Debug CLI — listing commands.""" +import asyncio + +import click +import httpx + +from cli._context import CliContext, get_http_headers, output, error_output +from database import engine +from models.listing import ListingType, QueryParameters +from repositories.listing_repository import ListingRepository +from services import listing_service, export_service + + +@click.group("listings") +def listings_group() -> None: + """Listing browsing and management commands.""" + pass + + +@listings_group.command("list") +@click.option("--type", "-t", "listing_type", default="RENT", type=click.Choice(["RENT", "BUY"])) +@click.option("--limit", default=10, type=int, help="Max listings to return") +@click.pass_context +def list_listings(ctx: click.Context, listing_type: str, limit: int) -> None: + """List listings from the database.""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + + try: + if cli_ctx.use_http: + resp = httpx.get( + f"{cli_ctx.api_base_url}/api/listing", + headers=get_http_headers(cli_ctx.user_email), + params={"limit": limit}, + ) + resp.raise_for_status() + data = resp.json() + else: + repository = ListingRepository(engine=engine) + result = asyncio.run(listing_service.get_listings(repository, limit=limit)) + data = { + "total_count": result.total_count, + "listings": [ + { + "id": listing.id, + "price": listing.price, + "bedrooms": listing.number_of_bedrooms, + "sqm": listing.square_meters, + "url": listing.url, + } + for listing in result.listings + ], + } + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) + + +@listings_group.command("detail") +@click.argument("listing_id", type=int) +@click.option("--type", "-t", "listing_type", default="RENT", type=click.Choice(["RENT", "BUY"])) +@click.pass_context +def listing_detail(ctx: click.Context, listing_id: int, listing_type: str) -> None: + """Get detailed information for a single listing.""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + + try: + if cli_ctx.use_http: + resp = httpx.get( + f"{cli_ctx.api_base_url}/api/listing/{listing_id}/detail", + headers=get_http_headers(cli_ctx.user_email), + params={"listing_type": listing_type}, + ) + resp.raise_for_status() + data = resp.json() + else: + repository = ListingRepository(engine=engine) + lt = ListingType(listing_type) + listings = asyncio.run(repository.get_listings(only_ids=[listing_id], listing_type=lt)) + if not listings: + error_output("Listing not found", cli_ctx.json_output) + return + listing = listings[0] + data = { + "id": listing.id, + "price": listing.price, + "bedrooms": listing.number_of_bedrooms, + "sqm": listing.square_meters, + "agency": listing.agency, + "url": listing.url, + "council_tax_band": listing.council_tax_band, + } + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) + + +@listings_group.command("stream") +@click.option("--type", "-t", "listing_type", default="RENT", type=click.Choice(["RENT", "BUY"])) +@click.option("--batch-size", default=50, type=int) +@click.option("--limit", default=None, type=int) +@click.option("--min-bedrooms", default=1, type=int) +@click.option("--max-bedrooms", default=999, type=int) +@click.option("--min-price", default=0, type=int) +@click.option("--max-price", default=10_000_000, type=int) +@click.pass_context +def stream_listings( + ctx: click.Context, + listing_type: str, + batch_size: int, + limit: int | None, + min_bedrooms: int, + max_bedrooms: int, + min_price: int, + max_price: int, +) -> None: + """Stream listings as NDJSON (mirrors the streaming API endpoint).""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + lt = ListingType[listing_type] + qp = QueryParameters( + listing_type=lt, + min_bedrooms=min_bedrooms, + max_bedrooms=max_bedrooms, + min_price=min_price, + max_price=max_price, + ) + + try: + if cli_ctx.use_http: + params: dict[str, int | str] = { + "listing_type": listing_type, + "batch_size": batch_size, + "min_bedrooms": min_bedrooms, + "max_bedrooms": max_bedrooms, + "min_price": min_price, + "max_price": max_price, + } + if limit is not None: + params["limit"] = limit + with httpx.stream( + "GET", + f"{cli_ctx.api_base_url}/api/listing_geojson/stream", + headers=get_http_headers(cli_ctx.user_email), + params=params, + ) as resp: + resp.raise_for_status() + for line in resp.iter_lines(): + if line.strip(): + print(line) + else: + result = asyncio.run( + export_service.export_to_geojson( + repository=ListingRepository(engine=engine), + query_parameters=qp, + limit=limit, + ) + ) + if cli_ctx.json_output: + output(result.data, json_mode=True) + else: + features = result.data.get("features", []) if result.data else [] + print(f"Streamed {len(features)} features") + for f in features[:5]: + props = f.get("properties", {}) + print(f" [{props.get('id')}] {props.get('displayAddress', 'N/A')} - £{props.get('price', '?')}") + if len(features) > 5: + print(f" ... and {len(features) - 5} more") + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) + + +@listings_group.command("refresh") +@click.option("--type", "-t", "listing_type", required=True, type=click.Choice(["RENT", "BUY"])) +@click.option("--min-bedrooms", default=1, type=int) +@click.option("--max-bedrooms", default=10, type=int) +@click.option("--min-price", default=0, type=int) +@click.option("--max-price", default=999_999, type=int) +@click.pass_context +def refresh_listings( + ctx: click.Context, + listing_type: str, + min_bedrooms: int, + max_bedrooms: int, + min_price: int, + max_price: int, +) -> None: + """Trigger a listing refresh (scrape from Rightmove).""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + lt = ListingType[listing_type] + qp = QueryParameters( + listing_type=lt, + min_bedrooms=min_bedrooms, + max_bedrooms=max_bedrooms, + min_price=min_price, + max_price=max_price, + ) + + try: + if cli_ctx.use_http: + resp = httpx.post( + f"{cli_ctx.api_base_url}/api/refresh_listings", + headers=get_http_headers(cli_ctx.user_email), + params={ + "listing_type": listing_type, + "min_bedrooms": min_bedrooms, + "max_bedrooms": max_bedrooms, + "min_price": min_price, + "max_price": max_price, + }, + ) + resp.raise_for_status() + data = resp.json() + else: + repository = ListingRepository(engine=engine) + result = asyncio.run( + listing_service.refresh_listings( + repository, qp, async_mode=False, + ) + ) + data = {"message": result.message, "new_count": result.new_listings_count} + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) diff --git a/main.py b/main.py index eeed25c..592e417 100644 --- a/main.py +++ b/main.py @@ -475,6 +475,7 @@ from cli.districts import districts_group from cli.decisions import decisions_group from cli.pois import pois_group from cli.tasks import tasks_group +from cli.listings import listings_group @cli.group("debug") @@ -498,6 +499,7 @@ debug.add_command(districts_group) debug.add_command(decisions_group) debug.add_command(pois_group) debug.add_command(tasks_group) +debug.add_command(listings_group) if __name__ == "__main__":