From 34f9e933c06770b0842af59662d8eba7fc04269e Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 22 Feb 2026 15:15:30 +0000 Subject: [PATCH] Add debug CLI decisions, POIs, and tasks subcommands --- cli/decisions.py | 120 +++++++++++++++++ cli/pois.py | 249 +++++++++++++++++++++++++++++++++++ cli/tasks.py | 123 +++++++++++++++++ main.py | 6 + tests/unit/test_debug_cli.py | 53 ++++++++ 5 files changed, 551 insertions(+) create mode 100644 cli/decisions.py create mode 100644 cli/pois.py create mode 100644 cli/tasks.py diff --git a/cli/decisions.py b/cli/decisions.py new file mode 100644 index 0000000..460c6a1 --- /dev/null +++ b/cli/decisions.py @@ -0,0 +1,120 @@ +"""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) diff --git a/cli/pois.py b/cli/pois.py new file mode 100644 index 0000000..9039d8f --- /dev/null +++ b/cli/pois.py @@ -0,0 +1,249 @@ +"""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[str, str | float] = {} + 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) diff --git a/cli/tasks.py b/cli/tasks.py new file mode 100644 index 0000000..06bbe47 --- /dev/null +++ b/cli/tasks.py @@ -0,0 +1,123 @@ +"""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) diff --git a/main.py b/main.py index e21ef1e..eeed25c 100644 --- a/main.py +++ b/main.py @@ -472,6 +472,9 @@ def calculate_poi(poi_id: int, travel_modes: str, listing_type: str) -> None: 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 @cli.group("debug") @@ -492,6 +495,9 @@ def debug(ctx: click.Context, user_email: str, use_http: bool, json_output: bool debug.add_command(districts_group) +debug.add_command(decisions_group) +debug.add_command(pois_group) +debug.add_command(tasks_group) if __name__ == "__main__": diff --git a/tests/unit/test_debug_cli.py b/tests/unit/test_debug_cli.py index a369c16..f0a3467 100644 --- a/tests/unit/test_debug_cli.py +++ b/tests/unit/test_debug_cli.py @@ -54,3 +54,56 @@ class TestDistrictsCommand: import json data = json.loads(result.output) assert "London" in data + + +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 + + +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