Add debug CLI listings subcommand (list, detail, stream, refresh)

This commit is contained in:
Viktor Barzin 2026-02-22 15:17:56 +00:00
parent 34f9e933c0
commit 34743127fb
No known key found for this signature in database
GPG key ID: 0EB088298288D958
2 changed files with 235 additions and 0 deletions

233
cli/listings.py Normal file
View file

@ -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)

View file

@ -475,6 +475,7 @@ from cli.districts import districts_group
from cli.decisions import decisions_group from cli.decisions import decisions_group
from cli.pois import pois_group from cli.pois import pois_group
from cli.tasks import tasks_group from cli.tasks import tasks_group
from cli.listings import listings_group
@cli.group("debug") @cli.group("debug")
@ -498,6 +499,7 @@ debug.add_command(districts_group)
debug.add_command(decisions_group) debug.add_command(decisions_group)
debug.add_command(pois_group) debug.add_command(pois_group)
debug.add_command(tasks_group) debug.add_command(tasks_group)
debug.add_command(listings_group)
if __name__ == "__main__": if __name__ == "__main__":