Add debug CLI decisions, POIs, and tasks subcommands

This commit is contained in:
Viktor Barzin 2026-02-22 15:15:30 +00:00
parent 2196e256f4
commit 34f9e933c0
No known key found for this signature in database
GPG key ID: 0EB088298288D958
5 changed files with 551 additions and 0 deletions

120
cli/decisions.py Normal file
View file

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

249
cli/pois.py Normal file
View file

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

123
cli/tasks.py Normal file
View file

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

View file

@ -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__":

View file

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