1215 lines
40 KiB
Markdown
1215 lines
40 KiB
Markdown
|
|
# Debug CLI Implementation Plan
|
||
|
|
|
||
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||
|
|
|
||
|
|
**Goal:** Add a `debug` command group to the CLI that mirrors all web UI interactions, with switchable direct/HTTP execution modes and user impersonation.
|
||
|
|
|
||
|
|
**Architecture:** New `cli/` package with one module per domain (listings, decisions, pois, tasks, districts). A shared `_context.py` handles user identity resolution, HTTP client with self-signed JWT, and output formatting. The `debug` group is registered in `main.py`.
|
||
|
|
|
||
|
|
**Tech Stack:** Click (existing), httpx (existing), PyJWT (existing), no new dependencies.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 1: Create `cli/` package with shared context
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `cli/__init__.py`
|
||
|
|
- Create: `cli/_context.py`
|
||
|
|
|
||
|
|
**Step 1: Create `cli/__init__.py`**
|
||
|
|
|
||
|
|
```python
|
||
|
|
"""Debug CLI package — mirrors web UI interactions for debugging."""
|
||
|
|
```
|
||
|
|
|
||
|
|
**Step 2: Create `cli/_context.py` with CliContext, user resolution, JWT minting, and output helpers**
|
||
|
|
|
||
|
|
```python
|
||
|
|
"""Shared context for the debug CLI."""
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import sys
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from datetime import datetime, timedelta, timezone
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import jwt
|
||
|
|
|
||
|
|
from database import engine
|
||
|
|
from repositories.user_repository import UserRepository
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class CliContext:
|
||
|
|
"""Shared state passed through Click context."""
|
||
|
|
user_email: str
|
||
|
|
use_http: bool
|
||
|
|
json_output: bool
|
||
|
|
api_base_url: str
|
||
|
|
|
||
|
|
|
||
|
|
def resolve_user_id(email: str) -> int:
|
||
|
|
"""Get or create a database user by email. Returns the DB user ID."""
|
||
|
|
user_repo = UserRepository(engine)
|
||
|
|
db_user = user_repo.get_user_by_email(email)
|
||
|
|
if db_user is None:
|
||
|
|
db_user = user_repo.create_user(email)
|
||
|
|
if db_user.id is None:
|
||
|
|
raise RuntimeError(f"Failed to resolve user ID for {email}")
|
||
|
|
return db_user.id
|
||
|
|
|
||
|
|
|
||
|
|
def mint_jwt(email: str) -> str:
|
||
|
|
"""Create a self-signed JWT for HTTP mode (passkey-style HS256 token)."""
|
||
|
|
secret = os.getenv("JWT_SECRET", "change-me-in-production")
|
||
|
|
issuer = os.getenv("JWT_ISSUER", "wrongmove")
|
||
|
|
algorithm = os.getenv("JWT_ALGORITHM", "HS256")
|
||
|
|
payload = {
|
||
|
|
"sub": email,
|
||
|
|
"email": email,
|
||
|
|
"name": email,
|
||
|
|
"iss": issuer,
|
||
|
|
"iat": datetime.now(timezone.utc),
|
||
|
|
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
|
||
|
|
}
|
||
|
|
return jwt.encode(payload, secret, algorithm=algorithm)
|
||
|
|
|
||
|
|
|
||
|
|
def get_http_headers(email: str) -> dict[str, str]:
|
||
|
|
"""Build HTTP headers with Authorization bearer token."""
|
||
|
|
token = mint_jwt(email)
|
||
|
|
return {
|
||
|
|
"Authorization": f"Bearer {token}",
|
||
|
|
"Content-Type": "application/json",
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def output(data: Any, json_mode: bool) -> None:
|
||
|
|
"""Print data in JSON or human-readable format."""
|
||
|
|
if json_mode:
|
||
|
|
print(json.dumps(data, indent=2, default=str))
|
||
|
|
elif isinstance(data, list):
|
||
|
|
if not data:
|
||
|
|
print("No results.")
|
||
|
|
return
|
||
|
|
# Print as simple table if list of dicts
|
||
|
|
if isinstance(data[0], dict):
|
||
|
|
keys = list(data[0].keys())
|
||
|
|
print(" ".join(f"{k:<20}" for k in keys))
|
||
|
|
print(" ".join("-" * 20 for _ in keys))
|
||
|
|
for row in data:
|
||
|
|
print(" ".join(f"{str(row.get(k, '')):<20}" for k in keys))
|
||
|
|
else:
|
||
|
|
for item in data:
|
||
|
|
print(f" {item}")
|
||
|
|
elif isinstance(data, dict):
|
||
|
|
for k, v in data.items():
|
||
|
|
print(f" {k}: {v}")
|
||
|
|
else:
|
||
|
|
print(data)
|
||
|
|
|
||
|
|
|
||
|
|
def error_output(message: str, json_mode: bool) -> None:
|
||
|
|
"""Print an error message."""
|
||
|
|
if json_mode:
|
||
|
|
print(json.dumps({"error": message}), file=sys.stderr)
|
||
|
|
else:
|
||
|
|
print(f"Error: {message}", file=sys.stderr)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cli/__init__.py cli/_context.py
|
||
|
|
git commit -m "Add cli/ package with shared debug context"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 2: Create `cli/districts.py`
|
||
|
|
|
||
|
|
This is the simplest subcommand — good to wire up the full pattern first.
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `cli/districts.py`
|
||
|
|
|
||
|
|
**Step 1: Create `cli/districts.py`**
|
||
|
|
|
||
|
|
```python
|
||
|
|
"""Debug CLI — district commands."""
|
||
|
|
import click
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
from cli._context import CliContext, get_http_headers, output, error_output
|
||
|
|
from services import district_service
|
||
|
|
|
||
|
|
|
||
|
|
@click.group("districts")
|
||
|
|
def districts_group() -> None:
|
||
|
|
"""District management commands."""
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
@districts_group.command("list")
|
||
|
|
@click.pass_context
|
||
|
|
def list_districts(ctx: click.Context) -> None:
|
||
|
|
"""List all available districts."""
|
||
|
|
cli_ctx: CliContext = ctx.obj["cli_ctx"]
|
||
|
|
|
||
|
|
try:
|
||
|
|
if cli_ctx.use_http:
|
||
|
|
resp = httpx.get(
|
||
|
|
f"{cli_ctx.api_base_url}/api/get_districts",
|
||
|
|
headers=get_http_headers(cli_ctx.user_email),
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
data = resp.json()
|
||
|
|
else:
|
||
|
|
data = district_service.get_all_districts()
|
||
|
|
|
||
|
|
if cli_ctx.json_output:
|
||
|
|
output(data, json_mode=True)
|
||
|
|
else:
|
||
|
|
print(f"Available districts ({len(data)}):")
|
||
|
|
for name in sorted(data.keys()):
|
||
|
|
print(f" {name}: {data[name]}")
|
||
|
|
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)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Step 2: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cli/districts.py
|
||
|
|
git commit -m "Add debug CLI districts subcommand"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 3: Register `debug` group in `main.py`
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `main.py` (add imports and debug group at bottom, before `if __name__`)
|
||
|
|
|
||
|
|
**Step 1: Add the debug group and register all subcommand groups**
|
||
|
|
|
||
|
|
Add to `main.py` after the existing commands and before `if __name__ == "__main__":`:
|
||
|
|
|
||
|
|
```python
|
||
|
|
from cli._context import CliContext
|
||
|
|
from cli.districts import districts_group
|
||
|
|
|
||
|
|
|
||
|
|
@cli.group("debug")
|
||
|
|
@click.option("--user-email", "-u", required=True, help="Email of user to impersonate")
|
||
|
|
@click.option("--http", "use_http", is_flag=True, default=False, help="Use HTTP requests instead of direct service calls")
|
||
|
|
@click.option("--json", "json_output", is_flag=True, default=False, help="Output in JSON format")
|
||
|
|
@click.option("--api-url", default="http://localhost:8000", help="API base URL for HTTP mode")
|
||
|
|
@click.pass_context
|
||
|
|
def debug(ctx: click.Context, user_email: str, use_http: bool, json_output: bool, api_url: str) -> None:
|
||
|
|
"""Debug CLI — mirrors web UI interactions with superuser access."""
|
||
|
|
ctx.ensure_object(dict)
|
||
|
|
ctx.obj["cli_ctx"] = CliContext(
|
||
|
|
user_email=user_email,
|
||
|
|
use_http=use_http,
|
||
|
|
json_output=json_output,
|
||
|
|
api_base_url=api_url,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
debug.add_command(districts_group)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Step 2: Write test for debug group registration**
|
||
|
|
|
||
|
|
Create `tests/unit/test_debug_cli.py`:
|
||
|
|
|
||
|
|
```python
|
||
|
|
"""Tests for the debug CLI."""
|
||
|
|
from unittest.mock import MagicMock, patch
|
||
|
|
|
||
|
|
from click.testing import CliRunner
|
||
|
|
|
||
|
|
from main import cli
|
||
|
|
|
||
|
|
|
||
|
|
class TestDebugGroup:
|
||
|
|
"""Tests for the debug command group."""
|
||
|
|
|
||
|
|
@patch("main.engine", new_callable=MagicMock)
|
||
|
|
def test_debug_help_shows_subcommands(self, mock_engine: MagicMock) -> None:
|
||
|
|
runner = CliRunner()
|
||
|
|
result = runner.invoke(cli, ["debug", "--help"])
|
||
|
|
assert result.exit_code == 0
|
||
|
|
assert "districts" in result.output
|
||
|
|
|
||
|
|
@patch("main.engine", new_callable=MagicMock)
|
||
|
|
def test_debug_requires_user_email(self, mock_engine: MagicMock) -> None:
|
||
|
|
runner = CliRunner()
|
||
|
|
result = runner.invoke(cli, ["debug", "districts", "list"])
|
||
|
|
assert result.exit_code != 0
|
||
|
|
assert "user-email" in result.output.lower() or "Missing" in result.output
|
||
|
|
|
||
|
|
|
||
|
|
class TestDistrictsCommand:
|
||
|
|
"""Tests for debug districts subcommand."""
|
||
|
|
|
||
|
|
@patch("cli.districts.district_service.get_all_districts")
|
||
|
|
@patch("main.engine", new_callable=MagicMock)
|
||
|
|
def test_list_districts_direct(
|
||
|
|
self, mock_engine: MagicMock, mock_get: MagicMock
|
||
|
|
) -> None:
|
||
|
|
mock_get.return_value = {"London": "R1", "Camden": "R2"}
|
||
|
|
runner = CliRunner()
|
||
|
|
result = runner.invoke(
|
||
|
|
cli, ["debug", "-u", "test@example.com", "districts", "list"]
|
||
|
|
)
|
||
|
|
assert result.exit_code == 0
|
||
|
|
assert "London" in result.output
|
||
|
|
|
||
|
|
@patch("cli.districts.district_service.get_all_districts")
|
||
|
|
@patch("main.engine", new_callable=MagicMock)
|
||
|
|
def test_list_districts_json(
|
||
|
|
self, mock_engine: MagicMock, mock_get: MagicMock
|
||
|
|
) -> None:
|
||
|
|
mock_get.return_value = {"London": "R1"}
|
||
|
|
runner = CliRunner()
|
||
|
|
result = runner.invoke(
|
||
|
|
cli, ["debug", "-u", "test@example.com", "--json", "districts", "list"]
|
||
|
|
)
|
||
|
|
assert result.exit_code == 0
|
||
|
|
import json
|
||
|
|
data = json.loads(result.output)
|
||
|
|
assert "London" in data
|
||
|
|
```
|
||
|
|
|
||
|
|
**Step 3: Run tests**
|
||
|
|
|
||
|
|
Run: `pytest tests/unit/test_debug_cli.py -v`
|
||
|
|
Expected: All pass
|
||
|
|
|
||
|
|
**Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add main.py tests/unit/test_debug_cli.py
|
||
|
|
git commit -m "Register debug group in main.py with districts subcommand"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 4: Create `cli/decisions.py`
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `cli/decisions.py`
|
||
|
|
|
||
|
|
**Step 1: Create `cli/decisions.py`**
|
||
|
|
|
||
|
|
```python
|
||
|
|
"""Debug CLI — decision commands (like/dislike)."""
|
||
|
|
import click
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
from cli._context import CliContext, get_http_headers, output, error_output, resolve_user_id
|
||
|
|
from database import engine
|
||
|
|
from repositories.decision_repository import DecisionRepository
|
||
|
|
from services import decision_service
|
||
|
|
|
||
|
|
|
||
|
|
@click.group("decisions")
|
||
|
|
def decisions_group() -> None:
|
||
|
|
"""Like/dislike decision commands."""
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
@decisions_group.command("set")
|
||
|
|
@click.argument("listing_id", type=int)
|
||
|
|
@click.argument("decision", type=click.Choice(["liked", "disliked"]))
|
||
|
|
@click.option("--type", "-t", "listing_type", required=True, type=click.Choice(["RENT", "BUY"]))
|
||
|
|
@click.pass_context
|
||
|
|
def set_decision(ctx: click.Context, listing_id: int, decision: str, listing_type: str) -> None:
|
||
|
|
"""Set a like/dislike decision for a listing."""
|
||
|
|
cli_ctx: CliContext = ctx.obj["cli_ctx"]
|
||
|
|
|
||
|
|
try:
|
||
|
|
if cli_ctx.use_http:
|
||
|
|
resp = httpx.put(
|
||
|
|
f"{cli_ctx.api_base_url}/api/decisions/{listing_id}",
|
||
|
|
headers=get_http_headers(cli_ctx.user_email),
|
||
|
|
json={"decision": decision, "listing_type": listing_type},
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
data = resp.json()
|
||
|
|
else:
|
||
|
|
user_id = resolve_user_id(cli_ctx.user_email)
|
||
|
|
repo = DecisionRepository(engine)
|
||
|
|
result = decision_service.set_decision(repo, user_id, listing_id, listing_type, decision)
|
||
|
|
data = {
|
||
|
|
"listing_id": result.listing_id,
|
||
|
|
"listing_type": result.listing_type,
|
||
|
|
"decision": result.decision,
|
||
|
|
"created_at": str(result.created_at),
|
||
|
|
"updated_at": str(result.updated_at),
|
||
|
|
}
|
||
|
|
|
||
|
|
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)
|
||
|
|
|
||
|
|
|
||
|
|
@decisions_group.command("list")
|
||
|
|
@click.pass_context
|
||
|
|
def list_decisions(ctx: click.Context) -> None:
|
||
|
|
"""List all decisions for the user."""
|
||
|
|
cli_ctx: CliContext = ctx.obj["cli_ctx"]
|
||
|
|
|
||
|
|
try:
|
||
|
|
if cli_ctx.use_http:
|
||
|
|
resp = httpx.get(
|
||
|
|
f"{cli_ctx.api_base_url}/api/decisions",
|
||
|
|
headers=get_http_headers(cli_ctx.user_email),
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
data = resp.json()
|
||
|
|
else:
|
||
|
|
user_id = resolve_user_id(cli_ctx.user_email)
|
||
|
|
repo = DecisionRepository(engine)
|
||
|
|
decisions = decision_service.get_user_decisions(repo, user_id)
|
||
|
|
data = [
|
||
|
|
{
|
||
|
|
"listing_id": d.listing_id,
|
||
|
|
"listing_type": d.listing_type,
|
||
|
|
"decision": d.decision,
|
||
|
|
"created_at": str(d.created_at),
|
||
|
|
"updated_at": str(d.updated_at),
|
||
|
|
}
|
||
|
|
for d in decisions
|
||
|
|
]
|
||
|
|
|
||
|
|
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)
|
||
|
|
|
||
|
|
|
||
|
|
@decisions_group.command("remove")
|
||
|
|
@click.argument("listing_id", type=int)
|
||
|
|
@click.option("--type", "-t", "listing_type", required=True, type=click.Choice(["RENT", "BUY"]))
|
||
|
|
@click.pass_context
|
||
|
|
def remove_decision(ctx: click.Context, listing_id: int, listing_type: str) -> None:
|
||
|
|
"""Remove a decision (un-like/un-dislike)."""
|
||
|
|
cli_ctx: CliContext = ctx.obj["cli_ctx"]
|
||
|
|
|
||
|
|
try:
|
||
|
|
if cli_ctx.use_http:
|
||
|
|
resp = httpx.delete(
|
||
|
|
f"{cli_ctx.api_base_url}/api/decisions/{listing_id}",
|
||
|
|
headers=get_http_headers(cli_ctx.user_email),
|
||
|
|
params={"listing_type": listing_type},
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
data = resp.json()
|
||
|
|
else:
|
||
|
|
user_id = resolve_user_id(cli_ctx.user_email)
|
||
|
|
repo = DecisionRepository(engine)
|
||
|
|
deleted = decision_service.remove_decision(repo, user_id, listing_id, listing_type)
|
||
|
|
if not deleted:
|
||
|
|
error_output("Decision not found", cli_ctx.json_output)
|
||
|
|
return
|
||
|
|
data = {"success": True}
|
||
|
|
|
||
|
|
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)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Step 2: Register in `main.py`**
|
||
|
|
|
||
|
|
Add import `from cli.decisions import decisions_group` and `debug.add_command(decisions_group)`.
|
||
|
|
|
||
|
|
**Step 3: Add tests to `tests/unit/test_debug_cli.py`**
|
||
|
|
|
||
|
|
```python
|
||
|
|
class TestDecisionsCommand:
|
||
|
|
"""Tests for debug decisions subcommand."""
|
||
|
|
|
||
|
|
@patch("cli.decisions.decision_service.set_decision")
|
||
|
|
@patch("cli.decisions.resolve_user_id", return_value=1)
|
||
|
|
@patch("main.engine", new_callable=MagicMock)
|
||
|
|
def test_set_decision_direct(
|
||
|
|
self, mock_engine: MagicMock, mock_resolve: MagicMock, mock_set: MagicMock
|
||
|
|
) -> None:
|
||
|
|
from datetime import datetime
|
||
|
|
mock_set.return_value = MagicMock(
|
||
|
|
listing_id=123, listing_type="RENT", decision="liked",
|
||
|
|
created_at=datetime(2025, 1, 1), updated_at=datetime(2025, 1, 1),
|
||
|
|
)
|
||
|
|
runner = CliRunner()
|
||
|
|
result = runner.invoke(
|
||
|
|
cli, ["debug", "-u", "test@example.com", "decisions", "set", "123", "liked", "-t", "RENT"]
|
||
|
|
)
|
||
|
|
assert result.exit_code == 0
|
||
|
|
mock_set.assert_called_once()
|
||
|
|
|
||
|
|
@patch("cli.decisions.decision_service.get_user_decisions")
|
||
|
|
@patch("cli.decisions.resolve_user_id", return_value=1)
|
||
|
|
@patch("main.engine", new_callable=MagicMock)
|
||
|
|
def test_list_decisions_direct(
|
||
|
|
self, mock_engine: MagicMock, mock_resolve: MagicMock, mock_list: MagicMock
|
||
|
|
) -> None:
|
||
|
|
mock_list.return_value = []
|
||
|
|
runner = CliRunner()
|
||
|
|
result = runner.invoke(
|
||
|
|
cli, ["debug", "-u", "test@example.com", "decisions", "list"]
|
||
|
|
)
|
||
|
|
assert result.exit_code == 0
|
||
|
|
```
|
||
|
|
|
||
|
|
**Step 4: Run tests**
|
||
|
|
|
||
|
|
Run: `pytest tests/unit/test_debug_cli.py -v`
|
||
|
|
Expected: All pass
|
||
|
|
|
||
|
|
**Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cli/decisions.py main.py tests/unit/test_debug_cli.py
|
||
|
|
git commit -m "Add debug CLI decisions subcommand (set, list, remove)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 5: Create `cli/pois.py`
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `cli/pois.py`
|
||
|
|
|
||
|
|
**Step 1: Create `cli/pois.py`**
|
||
|
|
|
||
|
|
```python
|
||
|
|
"""Debug CLI — POI commands."""
|
||
|
|
import click
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
from cli._context import CliContext, get_http_headers, output, error_output, resolve_user_id
|
||
|
|
from database import engine
|
||
|
|
from models.listing import ListingType
|
||
|
|
from repositories.poi_repository import POIRepository
|
||
|
|
from services import poi_service, task_service
|
||
|
|
|
||
|
|
|
||
|
|
@click.group("pois")
|
||
|
|
def pois_group() -> None:
|
||
|
|
"""Point of Interest management commands."""
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
@pois_group.command("list")
|
||
|
|
@click.pass_context
|
||
|
|
def list_pois(ctx: click.Context) -> None:
|
||
|
|
"""List all POIs for the user."""
|
||
|
|
cli_ctx: CliContext = ctx.obj["cli_ctx"]
|
||
|
|
|
||
|
|
try:
|
||
|
|
if cli_ctx.use_http:
|
||
|
|
resp = httpx.get(
|
||
|
|
f"{cli_ctx.api_base_url}/api/poi",
|
||
|
|
headers=get_http_headers(cli_ctx.user_email),
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
data = resp.json()
|
||
|
|
else:
|
||
|
|
user_id = resolve_user_id(cli_ctx.user_email)
|
||
|
|
repo = POIRepository(engine)
|
||
|
|
pois = poi_service.get_user_pois(repo, user_id)
|
||
|
|
data = [
|
||
|
|
{
|
||
|
|
"id": p.id,
|
||
|
|
"name": p.name,
|
||
|
|
"address": p.address,
|
||
|
|
"latitude": p.latitude,
|
||
|
|
"longitude": p.longitude,
|
||
|
|
"created_at": str(p.created_at),
|
||
|
|
}
|
||
|
|
for p in pois
|
||
|
|
]
|
||
|
|
|
||
|
|
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)
|
||
|
|
|
||
|
|
|
||
|
|
@pois_group.command("create")
|
||
|
|
@click.option("--name", required=True, help="POI name")
|
||
|
|
@click.option("--address", required=True, help="Address")
|
||
|
|
@click.option("--lat", required=True, type=float, help="Latitude")
|
||
|
|
@click.option("--lon", required=True, type=float, help="Longitude")
|
||
|
|
@click.pass_context
|
||
|
|
def create_poi(ctx: click.Context, name: str, address: str, lat: float, lon: float) -> None:
|
||
|
|
"""Create a new POI."""
|
||
|
|
cli_ctx: CliContext = ctx.obj["cli_ctx"]
|
||
|
|
|
||
|
|
try:
|
||
|
|
if cli_ctx.use_http:
|
||
|
|
resp = httpx.post(
|
||
|
|
f"{cli_ctx.api_base_url}/api/poi",
|
||
|
|
headers=get_http_headers(cli_ctx.user_email),
|
||
|
|
json={"name": name, "address": address, "latitude": lat, "longitude": lon},
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
data = resp.json()
|
||
|
|
else:
|
||
|
|
user_id = resolve_user_id(cli_ctx.user_email)
|
||
|
|
repo = POIRepository(engine)
|
||
|
|
result = poi_service.create_poi(repo, user_id, name, address, lat, lon)
|
||
|
|
data = {
|
||
|
|
"id": result.poi.id,
|
||
|
|
"name": result.poi.name,
|
||
|
|
"address": result.poi.address,
|
||
|
|
"latitude": result.poi.latitude,
|
||
|
|
"longitude": result.poi.longitude,
|
||
|
|
}
|
||
|
|
|
||
|
|
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)
|
||
|
|
|
||
|
|
|
||
|
|
@pois_group.command("update")
|
||
|
|
@click.argument("poi_id", type=int)
|
||
|
|
@click.option("--name", default=None, help="New name")
|
||
|
|
@click.option("--address", default=None, help="New address")
|
||
|
|
@click.option("--lat", default=None, type=float, help="New latitude")
|
||
|
|
@click.option("--lon", default=None, type=float, help="New longitude")
|
||
|
|
@click.pass_context
|
||
|
|
def update_poi(ctx: click.Context, poi_id: int, name: str | None, address: str | None, lat: float | None, lon: float | None) -> None:
|
||
|
|
"""Update an existing POI."""
|
||
|
|
cli_ctx: CliContext = ctx.obj["cli_ctx"]
|
||
|
|
|
||
|
|
try:
|
||
|
|
if cli_ctx.use_http:
|
||
|
|
body: dict = {}
|
||
|
|
if name is not None:
|
||
|
|
body["name"] = name
|
||
|
|
if address is not None:
|
||
|
|
body["address"] = address
|
||
|
|
if lat is not None:
|
||
|
|
body["latitude"] = lat
|
||
|
|
if lon is not None:
|
||
|
|
body["longitude"] = lon
|
||
|
|
resp = httpx.put(
|
||
|
|
f"{cli_ctx.api_base_url}/api/poi/{poi_id}",
|
||
|
|
headers=get_http_headers(cli_ctx.user_email),
|
||
|
|
json=body,
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
data = resp.json()
|
||
|
|
else:
|
||
|
|
user_id = resolve_user_id(cli_ctx.user_email)
|
||
|
|
repo = POIRepository(engine)
|
||
|
|
result = poi_service.update_poi(repo, poi_id, user_id, name, address, lat, lon)
|
||
|
|
if result is None:
|
||
|
|
error_output("POI not found", cli_ctx.json_output)
|
||
|
|
return
|
||
|
|
data = {
|
||
|
|
"id": result.poi.id,
|
||
|
|
"name": result.poi.name,
|
||
|
|
"address": result.poi.address,
|
||
|
|
"latitude": result.poi.latitude,
|
||
|
|
"longitude": result.poi.longitude,
|
||
|
|
}
|
||
|
|
|
||
|
|
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)
|
||
|
|
|
||
|
|
|
||
|
|
@pois_group.command("delete")
|
||
|
|
@click.argument("poi_id", type=int)
|
||
|
|
@click.pass_context
|
||
|
|
def delete_poi(ctx: click.Context, poi_id: int) -> None:
|
||
|
|
"""Delete a POI."""
|
||
|
|
cli_ctx: CliContext = ctx.obj["cli_ctx"]
|
||
|
|
|
||
|
|
try:
|
||
|
|
if cli_ctx.use_http:
|
||
|
|
resp = httpx.delete(
|
||
|
|
f"{cli_ctx.api_base_url}/api/poi/{poi_id}",
|
||
|
|
headers=get_http_headers(cli_ctx.user_email),
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
data = resp.json()
|
||
|
|
else:
|
||
|
|
user_id = resolve_user_id(cli_ctx.user_email)
|
||
|
|
repo = POIRepository(engine)
|
||
|
|
deleted = poi_service.delete_poi(repo, poi_id, user_id)
|
||
|
|
if not deleted:
|
||
|
|
error_output("POI not found", cli_ctx.json_output)
|
||
|
|
return
|
||
|
|
data = {"success": True}
|
||
|
|
|
||
|
|
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)
|
||
|
|
|
||
|
|
|
||
|
|
@pois_group.command("calculate")
|
||
|
|
@click.argument("poi_id", type=int)
|
||
|
|
@click.option("--travel-modes", required=True, help="Comma-separated: WALK,BICYCLE,TRANSIT")
|
||
|
|
@click.option("--type", "-t", "listing_type", required=True, type=click.Choice(["RENT", "BUY"]))
|
||
|
|
@click.pass_context
|
||
|
|
def calculate_distances(ctx: click.Context, poi_id: int, travel_modes: str, listing_type: str) -> None:
|
||
|
|
"""Trigger distance calculation for a POI."""
|
||
|
|
cli_ctx: CliContext = ctx.obj["cli_ctx"]
|
||
|
|
modes = [m.strip().upper() for m in travel_modes.split(",")]
|
||
|
|
lt = ListingType[listing_type]
|
||
|
|
|
||
|
|
try:
|
||
|
|
if cli_ctx.use_http:
|
||
|
|
resp = httpx.post(
|
||
|
|
f"{cli_ctx.api_base_url}/api/poi/{poi_id}/calculate",
|
||
|
|
headers=get_http_headers(cli_ctx.user_email),
|
||
|
|
json={"travel_modes": modes, "listing_type": listing_type},
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
data = resp.json()
|
||
|
|
else:
|
||
|
|
result = poi_service.trigger_calculation(
|
||
|
|
poi_id=poi_id,
|
||
|
|
travel_modes=modes,
|
||
|
|
listing_type=lt,
|
||
|
|
user_email=cli_ctx.user_email,
|
||
|
|
)
|
||
|
|
if result.task_id:
|
||
|
|
task_service.add_task_for_user(cli_ctx.user_email, result.task_id)
|
||
|
|
data = {"task_id": result.task_id or "", "message": result.message}
|
||
|
|
|
||
|
|
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)
|
||
|
|
|
||
|
|
|
||
|
|
@pois_group.command("distances")
|
||
|
|
@click.argument("listing_id", type=int)
|
||
|
|
@click.option("--type", "-t", "listing_type", default="RENT", type=click.Choice(["RENT", "BUY"]))
|
||
|
|
@click.pass_context
|
||
|
|
def get_distances(ctx: click.Context, listing_id: int, listing_type: str) -> None:
|
||
|
|
"""Get POI distances for a specific listing."""
|
||
|
|
cli_ctx: CliContext = ctx.obj["cli_ctx"]
|
||
|
|
lt = ListingType[listing_type]
|
||
|
|
|
||
|
|
try:
|
||
|
|
if cli_ctx.use_http:
|
||
|
|
resp = httpx.get(
|
||
|
|
f"{cli_ctx.api_base_url}/api/poi/distances",
|
||
|
|
headers=get_http_headers(cli_ctx.user_email),
|
||
|
|
params={"listing_id": listing_id, "listing_type": listing_type},
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
data = resp.json()
|
||
|
|
else:
|
||
|
|
user_id = resolve_user_id(cli_ctx.user_email)
|
||
|
|
repo = POIRepository(engine)
|
||
|
|
distances = poi_service.get_distances_for_listing(repo, listing_id, lt, user_id)
|
||
|
|
data = [
|
||
|
|
{
|
||
|
|
"poi_id": d.poi_id,
|
||
|
|
"travel_mode": d.travel_mode,
|
||
|
|
"duration_seconds": d.duration_seconds,
|
||
|
|
"distance_meters": d.distance_meters,
|
||
|
|
}
|
||
|
|
for d in distances
|
||
|
|
]
|
||
|
|
|
||
|
|
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)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Step 2: Register in `main.py`**
|
||
|
|
|
||
|
|
Add import `from cli.pois import pois_group` and `debug.add_command(pois_group)`.
|
||
|
|
|
||
|
|
**Step 3: Add tests**
|
||
|
|
|
||
|
|
```python
|
||
|
|
class TestPOIsCommand:
|
||
|
|
"""Tests for debug pois subcommand."""
|
||
|
|
|
||
|
|
@patch("cli.pois.poi_service.get_user_pois")
|
||
|
|
@patch("cli.pois.resolve_user_id", return_value=1)
|
||
|
|
@patch("main.engine", new_callable=MagicMock)
|
||
|
|
def test_list_pois_empty(
|
||
|
|
self, mock_engine: MagicMock, mock_resolve: MagicMock, mock_pois: MagicMock
|
||
|
|
) -> None:
|
||
|
|
mock_pois.return_value = []
|
||
|
|
runner = CliRunner()
|
||
|
|
result = runner.invoke(
|
||
|
|
cli, ["debug", "-u", "test@example.com", "pois", "list"]
|
||
|
|
)
|
||
|
|
assert result.exit_code == 0
|
||
|
|
assert "No results" in result.output
|
||
|
|
```
|
||
|
|
|
||
|
|
**Step 4: Run tests and commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
pytest tests/unit/test_debug_cli.py -v
|
||
|
|
git add cli/pois.py main.py tests/unit/test_debug_cli.py
|
||
|
|
git commit -m "Add debug CLI POI subcommand (list, create, update, delete, calculate, distances)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 6: Create `cli/tasks.py`
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `cli/tasks.py`
|
||
|
|
|
||
|
|
**Step 1: Create `cli/tasks.py`**
|
||
|
|
|
||
|
|
```python
|
||
|
|
"""Debug CLI — task management commands."""
|
||
|
|
import click
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
from cli._context import CliContext, get_http_headers, output, error_output
|
||
|
|
from services import task_service
|
||
|
|
|
||
|
|
|
||
|
|
@click.group("tasks")
|
||
|
|
def tasks_group() -> None:
|
||
|
|
"""Background task management commands."""
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
@tasks_group.command("status")
|
||
|
|
@click.argument("task_id")
|
||
|
|
@click.pass_context
|
||
|
|
def task_status(ctx: click.Context, task_id: str) -> None:
|
||
|
|
"""Get the status of a background task."""
|
||
|
|
cli_ctx: CliContext = ctx.obj["cli_ctx"]
|
||
|
|
|
||
|
|
try:
|
||
|
|
if cli_ctx.use_http:
|
||
|
|
resp = httpx.get(
|
||
|
|
f"{cli_ctx.api_base_url}/api/task_status",
|
||
|
|
headers=get_http_headers(cli_ctx.user_email),
|
||
|
|
params={"task_id": task_id},
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
data = resp.json()
|
||
|
|
else:
|
||
|
|
status = task_service.get_task_status(task_id)
|
||
|
|
data = {
|
||
|
|
"task_id": status.task_id,
|
||
|
|
"status": status.status,
|
||
|
|
"progress": status.progress,
|
||
|
|
"processed": status.processed,
|
||
|
|
"total": status.total,
|
||
|
|
"message": status.message,
|
||
|
|
"error": status.error,
|
||
|
|
}
|
||
|
|
|
||
|
|
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)
|
||
|
|
|
||
|
|
|
||
|
|
@tasks_group.command("list")
|
||
|
|
@click.pass_context
|
||
|
|
def list_tasks(ctx: click.Context) -> None:
|
||
|
|
"""List all task IDs for the user."""
|
||
|
|
cli_ctx: CliContext = ctx.obj["cli_ctx"]
|
||
|
|
|
||
|
|
try:
|
||
|
|
if cli_ctx.use_http:
|
||
|
|
resp = httpx.get(
|
||
|
|
f"{cli_ctx.api_base_url}/api/tasks_for_user",
|
||
|
|
headers=get_http_headers(cli_ctx.user_email),
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
data = resp.json()
|
||
|
|
else:
|
||
|
|
data = task_service.get_user_tasks(cli_ctx.user_email)
|
||
|
|
|
||
|
|
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)
|
||
|
|
|
||
|
|
|
||
|
|
@tasks_group.command("cancel")
|
||
|
|
@click.argument("task_id")
|
||
|
|
@click.pass_context
|
||
|
|
def cancel_task(ctx: click.Context, task_id: str) -> None:
|
||
|
|
"""Cancel a running task."""
|
||
|
|
cli_ctx: CliContext = ctx.obj["cli_ctx"]
|
||
|
|
|
||
|
|
try:
|
||
|
|
if cli_ctx.use_http:
|
||
|
|
resp = httpx.post(
|
||
|
|
f"{cli_ctx.api_base_url}/api/cancel_task",
|
||
|
|
headers=get_http_headers(cli_ctx.user_email),
|
||
|
|
params={"task_id": task_id},
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
data = resp.json()
|
||
|
|
else:
|
||
|
|
task_service.cancel_task(task_id, user_email=cli_ctx.user_email)
|
||
|
|
data = {"success": True, "message": "Task cancelled"}
|
||
|
|
|
||
|
|
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)
|
||
|
|
|
||
|
|
|
||
|
|
@tasks_group.command("clear")
|
||
|
|
@click.pass_context
|
||
|
|
def clear_tasks(ctx: click.Context) -> None:
|
||
|
|
"""Clear all tasks for the user."""
|
||
|
|
cli_ctx: CliContext = ctx.obj["cli_ctx"]
|
||
|
|
|
||
|
|
try:
|
||
|
|
if cli_ctx.use_http:
|
||
|
|
resp = httpx.post(
|
||
|
|
f"{cli_ctx.api_base_url}/api/clear_all_tasks",
|
||
|
|
headers=get_http_headers(cli_ctx.user_email),
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
data = resp.json()
|
||
|
|
else:
|
||
|
|
count = task_service.clear_all_tasks(cli_ctx.user_email)
|
||
|
|
data = {"success": True, "count": count, "message": f"Cleared {count} tasks"}
|
||
|
|
|
||
|
|
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)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Step 2: Register in `main.py`**
|
||
|
|
|
||
|
|
Add import `from cli.tasks import tasks_group` and `debug.add_command(tasks_group)`.
|
||
|
|
|
||
|
|
**Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cli/tasks.py main.py
|
||
|
|
git commit -m "Add debug CLI tasks subcommand (status, list, cancel, clear)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 7: Create `cli/listings.py`
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `cli/listings.py`
|
||
|
|
|
||
|
|
**Step 1: Create `cli/listings.py`**
|
||
|
|
|
||
|
|
```python
|
||
|
|
"""Debug CLI — listing commands."""
|
||
|
|
import asyncio
|
||
|
|
import json
|
||
|
|
|
||
|
|
import click
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
from cli._context import CliContext, get_http_headers, output, error_output, resolve_user_id
|
||
|
|
from database import engine
|
||
|
|
from models.listing import ListingType, QueryParameters
|
||
|
|
from repositories.listing_repository import ListingRepository
|
||
|
|
from services import listing_service, export_service, task_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": l.id,
|
||
|
|
"price": l.price,
|
||
|
|
"bedrooms": l.number_of_bedrooms,
|
||
|
|
"sqm": l.square_meters,
|
||
|
|
"url": l.url,
|
||
|
|
}
|
||
|
|
for l 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 = {
|
||
|
|
"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)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Step 2: Register in `main.py`**
|
||
|
|
|
||
|
|
Add import `from cli.listings import listings_group` and `debug.add_command(listings_group)`.
|
||
|
|
|
||
|
|
**Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cli/listings.py main.py
|
||
|
|
git commit -m "Add debug CLI listings subcommand (list, detail, stream, refresh)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 8: Final wiring, run full test suite, commit
|
||
|
|
|
||
|
|
**Step 1: Verify `main.py` imports are complete**
|
||
|
|
|
||
|
|
The final imports section and registration at the bottom of `main.py` should look like:
|
||
|
|
|
||
|
|
```python
|
||
|
|
from cli._context import CliContext
|
||
|
|
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
|
||
|
|
|
||
|
|
# ... debug group definition ...
|
||
|
|
|
||
|
|
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)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Step 2: Run full test suite**
|
||
|
|
|
||
|
|
Run: `pytest tests/ -v --tb=short`
|
||
|
|
Expected: All existing tests still pass, plus new debug CLI tests
|
||
|
|
|
||
|
|
**Step 3: Final commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A
|
||
|
|
git commit -m "Complete debug CLI with all subcommands"
|
||
|
|
```
|