1658 lines
57 KiB
Python
1658 lines
57 KiB
Python
import json
|
|
import inspect
|
|
import logging
|
|
from datetime import date as date_cls, datetime
|
|
|
|
import requests
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.db import models
|
|
from django.db.models import Q
|
|
from django.utils import timezone
|
|
|
|
from adventures.models import (
|
|
Collection,
|
|
CollectionItineraryItem,
|
|
Lodging,
|
|
Location,
|
|
Transportation,
|
|
Visit,
|
|
)
|
|
from adventures.utils.itinerary import reorder_itinerary_items
|
|
from adventures.utils.weather import fetch_daily_temperature
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_REGISTERED_TOOLS = {}
|
|
_TOOL_SCHEMAS = []
|
|
|
|
|
|
def agent_tool(name: str, description: str, parameters: dict):
|
|
"""Decorator to register a function as an agent tool."""
|
|
|
|
def decorator(func):
|
|
_REGISTERED_TOOLS[name] = func
|
|
|
|
required = [k for k, v in parameters.items() if v.get("required", False)]
|
|
props = {
|
|
k: {kk: vv for kk, vv in v.items() if kk != "required"}
|
|
for k, v in parameters.items()
|
|
}
|
|
|
|
schema = {
|
|
"type": "function",
|
|
"function": {
|
|
"name": name,
|
|
"description": description,
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": props,
|
|
"required": required,
|
|
},
|
|
},
|
|
}
|
|
_TOOL_SCHEMAS.append(schema)
|
|
|
|
return func
|
|
|
|
return decorator
|
|
|
|
|
|
def get_tool_schemas() -> list:
|
|
"""Return all registered tool schemas for LLM."""
|
|
return _TOOL_SCHEMAS.copy()
|
|
|
|
|
|
def get_registered_tools() -> dict:
|
|
"""Return all registered tool functions."""
|
|
return _REGISTERED_TOOLS.copy()
|
|
|
|
|
|
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
|
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
|
OPEN_METEO_ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive"
|
|
OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
|
|
REQUEST_HEADERS = {"User-Agent": "Voyage/1.0"}
|
|
LOCATION_COORD_TOLERANCE = 0.00001
|
|
|
|
|
|
def _get_accessible_collection(user, collection_id: str):
|
|
return (
|
|
Collection.objects.filter(Q(user=user) | Q(shared_with=user))
|
|
.distinct()
|
|
.get(id=collection_id)
|
|
)
|
|
|
|
|
|
def _normalize_date_input(value):
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, date_cls):
|
|
return value
|
|
|
|
raw = str(value).strip()
|
|
if not raw:
|
|
return None
|
|
|
|
try:
|
|
return date_cls.fromisoformat(raw[:10])
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _normalize_datetime_input(value):
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, datetime):
|
|
return value
|
|
|
|
raw = str(value).strip()
|
|
if not raw:
|
|
return None
|
|
|
|
parsed = None
|
|
try:
|
|
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
parsed = None
|
|
|
|
if parsed is None:
|
|
parsed_date = _normalize_date_input(raw)
|
|
if parsed_date is None:
|
|
return None
|
|
parsed = datetime.combine(parsed_date, datetime.min.time())
|
|
|
|
if timezone.is_naive(parsed):
|
|
parsed = timezone.make_aware(parsed, timezone.get_current_timezone())
|
|
|
|
return parsed
|
|
|
|
|
|
def _parse_float(value):
|
|
if value is None or value == "":
|
|
return None
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _serialize_lodging(lodging: Lodging):
|
|
return {
|
|
"id": str(lodging.id),
|
|
"name": lodging.name,
|
|
"type": lodging.type,
|
|
"check_in": lodging.check_in.isoformat() if lodging.check_in else None,
|
|
"check_out": lodging.check_out.isoformat() if lodging.check_out else None,
|
|
"location": lodging.location or "",
|
|
"latitude": float(lodging.latitude) if lodging.latitude is not None else None,
|
|
"longitude": float(lodging.longitude)
|
|
if lodging.longitude is not None
|
|
else None,
|
|
}
|
|
|
|
|
|
def _serialize_transportation(transportation: Transportation):
|
|
return {
|
|
"id": str(transportation.id),
|
|
"name": transportation.name,
|
|
"type": transportation.type,
|
|
"date": transportation.date.isoformat() if transportation.date else None,
|
|
"end_date": transportation.end_date.isoformat()
|
|
if transportation.end_date
|
|
else None,
|
|
"from_location": transportation.from_location or "",
|
|
"to_location": transportation.to_location or "",
|
|
"origin_latitude": float(transportation.origin_latitude)
|
|
if transportation.origin_latitude is not None
|
|
else None,
|
|
"origin_longitude": float(transportation.origin_longitude)
|
|
if transportation.origin_longitude is not None
|
|
else None,
|
|
"destination_latitude": float(transportation.destination_latitude)
|
|
if transportation.destination_latitude is not None
|
|
else None,
|
|
"destination_longitude": float(transportation.destination_longitude)
|
|
if transportation.destination_longitude is not None
|
|
else None,
|
|
}
|
|
|
|
|
|
def _build_overpass_query(latitude, longitude, radius_meters, category):
|
|
if category == "food":
|
|
node_filter = '["amenity"~"restaurant|cafe|bar|fast_food"]'
|
|
elif category == "lodging":
|
|
node_filter = '["tourism"~"hotel|hostel|guest_house|motel|apartment"]'
|
|
else:
|
|
node_filter = '["tourism"~"attraction|museum|viewpoint|gallery|theme_park"]'
|
|
|
|
return f"""
|
|
[out:json][timeout:25];
|
|
(
|
|
node{node_filter}(around:{int(radius_meters)},{latitude},{longitude});
|
|
way{node_filter}(around:{int(radius_meters)},{latitude},{longitude});
|
|
relation{node_filter}(around:{int(radius_meters)},{latitude},{longitude});
|
|
);
|
|
out center 20;
|
|
"""
|
|
|
|
|
|
def _parse_address(tags):
|
|
if not tags:
|
|
return ""
|
|
if tags.get("addr:full"):
|
|
return tags["addr:full"]
|
|
street = tags.get("addr:street", "")
|
|
house = tags.get("addr:housenumber", "")
|
|
city = (
|
|
tags.get("addr:city") or tags.get("addr:town") or tags.get("addr:village") or ""
|
|
)
|
|
parts = [f"{street} {house}".strip(), city]
|
|
return ", ".join([p for p in parts if p])
|
|
|
|
|
|
@agent_tool(
|
|
name="search_places",
|
|
description=(
|
|
"Search for places of interest near a location. "
|
|
"Required: provide a non-empty 'location' string (city, neighborhood, or address). "
|
|
"Use category='food' for restaurants/dining, category='tourism' for attractions, "
|
|
"and category='lodging' for hotels/stays."
|
|
),
|
|
parameters={
|
|
"location": {
|
|
"type": "string",
|
|
"description": "Location name or address to search near",
|
|
"required": True,
|
|
},
|
|
"category": {
|
|
"type": "string",
|
|
"enum": ["tourism", "food", "lodging"],
|
|
"description": "Place type: food (restaurants/dining), tourism (attractions), lodging (hotels/stays)",
|
|
},
|
|
"radius": {
|
|
"type": "number",
|
|
"description": "Search radius in km (default 10)",
|
|
},
|
|
},
|
|
)
|
|
def search_places(
|
|
user,
|
|
location: str | None = None,
|
|
category: str = "tourism",
|
|
radius: float = 10,
|
|
):
|
|
try:
|
|
location_name = location
|
|
if not location_name:
|
|
return {"error": "location is required"}
|
|
|
|
category = category or "tourism"
|
|
radius_km = float(radius or 10)
|
|
radius_meters = max(500, min(int(radius_km * 1000), 50000))
|
|
|
|
geocode_resp = requests.get(
|
|
NOMINATIM_URL,
|
|
params={"q": location_name, "format": "json", "limit": 1},
|
|
headers=REQUEST_HEADERS,
|
|
timeout=10,
|
|
)
|
|
geocode_resp.raise_for_status()
|
|
geocode_data = geocode_resp.json()
|
|
if not geocode_data:
|
|
return {"error": f"Could not geocode location: {location_name}"}
|
|
|
|
base_lat = float(geocode_data[0]["lat"])
|
|
base_lon = float(geocode_data[0]["lon"])
|
|
query = _build_overpass_query(base_lat, base_lon, radius_meters, category)
|
|
|
|
overpass_resp = requests.post(
|
|
OVERPASS_URL,
|
|
data={"data": query},
|
|
headers=REQUEST_HEADERS,
|
|
timeout=20,
|
|
)
|
|
overpass_resp.raise_for_status()
|
|
overpass_data = overpass_resp.json()
|
|
|
|
results = []
|
|
for item in (overpass_data.get("elements") or [])[:20]:
|
|
tags = item.get("tags") or {}
|
|
name = tags.get("name")
|
|
if not name:
|
|
continue
|
|
|
|
latitude = item.get("lat")
|
|
longitude = item.get("lon")
|
|
if latitude is None or longitude is None:
|
|
center = item.get("center") or {}
|
|
latitude = center.get("lat")
|
|
longitude = center.get("lon")
|
|
|
|
if latitude is None or longitude is None:
|
|
continue
|
|
|
|
results.append(
|
|
{
|
|
"name": name,
|
|
"address": _parse_address(tags),
|
|
"latitude": latitude,
|
|
"longitude": longitude,
|
|
"category": category,
|
|
}
|
|
)
|
|
|
|
if len(results) >= 5:
|
|
break
|
|
|
|
return {
|
|
"location": location_name,
|
|
"category": category,
|
|
"results": results,
|
|
}
|
|
except requests.HTTPError as exc:
|
|
if exc.response is not None and exc.response.status_code == 429:
|
|
return {"error": f"Places API request failed: {exc}", "retryable": False}
|
|
return {"error": f"Places API request failed: {exc}"}
|
|
except requests.RequestException as exc:
|
|
return {"error": f"Places API request failed: {exc}"}
|
|
except (TypeError, ValueError) as exc:
|
|
return {"error": f"Invalid search parameters: {exc}"}
|
|
except Exception:
|
|
logger.exception("search_places failed")
|
|
return {"error": "An unexpected error occurred during place search"}
|
|
|
|
|
|
@agent_tool(
|
|
name="list_trips",
|
|
description="List the user's trip collections with dates and descriptions",
|
|
parameters={},
|
|
)
|
|
def list_trips(user):
|
|
try:
|
|
collections = Collection.objects.filter(user=user).prefetch_related("locations")
|
|
trips = []
|
|
for collection in collections:
|
|
trips.append(
|
|
{
|
|
"id": str(collection.id),
|
|
"name": collection.name,
|
|
"start_date": collection.start_date.isoformat()
|
|
if collection.start_date
|
|
else None,
|
|
"end_date": collection.end_date.isoformat()
|
|
if collection.end_date
|
|
else None,
|
|
"description": collection.description or "",
|
|
"location_count": collection.locations.count(),
|
|
}
|
|
)
|
|
return {"trips": trips}
|
|
except Exception:
|
|
logger.exception("list_trips failed")
|
|
return {"error": "An unexpected error occurred while listing trips"}
|
|
|
|
|
|
@agent_tool(
|
|
name="web_search",
|
|
description=(
|
|
"Search the web for current travel information. "
|
|
"Required: provide a non-empty 'query' string describing exactly what to look up. "
|
|
"Use when you need up-to-date info that may not be in training data."
|
|
),
|
|
parameters={
|
|
"query": {
|
|
"type": "string",
|
|
"description": "The search query (e.g., 'best restaurants Paris 2024', 'weather Tokyo March')",
|
|
"required": True,
|
|
},
|
|
"location_context": {
|
|
"type": "string",
|
|
"description": "Optional location to bias search results (e.g., 'Paris, France')",
|
|
},
|
|
},
|
|
)
|
|
def web_search(user, query: str, location_context: str | None = None) -> dict:
|
|
"""
|
|
Search the web for current information about destinations, events, prices, etc.
|
|
|
|
Args:
|
|
user: The user making the request (for auth/logging)
|
|
query: The search query
|
|
location_context: Optional location to bias results
|
|
|
|
Returns:
|
|
dict with 'results' list or 'error' string
|
|
"""
|
|
if not query:
|
|
return {"error": "query is required", "results": []}
|
|
|
|
try:
|
|
from duckduckgo_search import DDGS # type: ignore[import-not-found]
|
|
|
|
full_query = query
|
|
if location_context:
|
|
full_query = f"{query} {location_context}"
|
|
|
|
with DDGS() as ddgs:
|
|
results = list(ddgs.text(full_query, max_results=5))
|
|
|
|
formatted = []
|
|
for result in results:
|
|
formatted.append(
|
|
{
|
|
"title": result.get("title", ""),
|
|
"snippet": result.get("body", ""),
|
|
"url": result.get("href", ""),
|
|
}
|
|
)
|
|
|
|
return {"results": formatted}
|
|
|
|
except ImportError:
|
|
return {
|
|
"error": "Web search is not available (duckduckgo-search not installed)",
|
|
"results": [],
|
|
"retryable": False,
|
|
}
|
|
except Exception as exc:
|
|
error_str = str(exc).lower()
|
|
if "rate" in error_str or "limit" in error_str:
|
|
return {
|
|
"error": "Search rate limit reached. Please wait a moment and try again.",
|
|
"results": [],
|
|
}
|
|
logger.error("Web search error: %s", exc)
|
|
return {"error": "Web search failed. Please try again.", "results": []}
|
|
|
|
|
|
@agent_tool(
|
|
name="get_trip_details",
|
|
description="Get full details of a trip including all itinerary items, locations, transportation, and lodging",
|
|
parameters={
|
|
"collection_id": {
|
|
"type": "string",
|
|
"description": "UUID of the collection/trip",
|
|
"required": True,
|
|
}
|
|
},
|
|
)
|
|
def get_trip_details(user, collection_id: str | None = None):
|
|
try:
|
|
if not collection_id:
|
|
return {"error": "collection_id is required"}
|
|
|
|
collection = (
|
|
Collection.objects.filter(Q(user=user) | Q(shared_with=user))
|
|
.distinct()
|
|
.prefetch_related(
|
|
"locations",
|
|
"transportation_set",
|
|
"lodging_set",
|
|
"itinerary_items__content_type",
|
|
)
|
|
.get(id=collection_id)
|
|
)
|
|
|
|
itinerary = []
|
|
for item in collection.itinerary_items.all():
|
|
content_obj = item.item
|
|
itinerary.append(
|
|
{
|
|
"id": str(item.id),
|
|
"date": item.date.isoformat() if item.date else None,
|
|
"order": item.order,
|
|
"is_global": item.is_global,
|
|
"content_type": item.content_type.model,
|
|
"object_id": str(item.object_id),
|
|
"name": getattr(content_obj, "name", ""),
|
|
}
|
|
)
|
|
|
|
return {
|
|
"trip": {
|
|
"id": str(collection.id),
|
|
"name": collection.name,
|
|
"description": collection.description or "",
|
|
"start_date": collection.start_date.isoformat()
|
|
if collection.start_date
|
|
else None,
|
|
"end_date": collection.end_date.isoformat()
|
|
if collection.end_date
|
|
else None,
|
|
"locations": [
|
|
{
|
|
"id": str(location.id),
|
|
"name": location.name,
|
|
"description": location.description or "",
|
|
"location": location.location or "",
|
|
"latitude": float(location.latitude)
|
|
if location.latitude is not None
|
|
else None,
|
|
"longitude": float(location.longitude)
|
|
if location.longitude is not None
|
|
else None,
|
|
}
|
|
for location in collection.locations.all()
|
|
],
|
|
"transportation": [
|
|
{
|
|
"id": str(t.id),
|
|
"name": t.name,
|
|
"type": t.type,
|
|
"date": t.date.isoformat() if t.date else None,
|
|
"end_date": t.end_date.isoformat() if t.end_date else None,
|
|
}
|
|
for t in collection.transportation_set.all()
|
|
],
|
|
"lodging": [
|
|
{
|
|
"id": str(l.id),
|
|
"name": l.name,
|
|
"type": l.type,
|
|
"check_in": l.check_in.isoformat() if l.check_in else None,
|
|
"check_out": l.check_out.isoformat() if l.check_out else None,
|
|
"location": l.location or "",
|
|
}
|
|
for l in collection.lodging_set.all()
|
|
],
|
|
"itinerary": itinerary,
|
|
}
|
|
}
|
|
except Collection.DoesNotExist:
|
|
return {
|
|
"error": "collection_id is required and must reference a trip you can access"
|
|
}
|
|
except Exception:
|
|
logger.exception("get_trip_details failed")
|
|
return {"error": "An unexpected error occurred while fetching trip details"}
|
|
|
|
|
|
@agent_tool(
|
|
name="add_to_itinerary",
|
|
description="Add a new location to a trip's itinerary on a specific date",
|
|
parameters={
|
|
"collection_id": {
|
|
"type": "string",
|
|
"description": "UUID of the collection/trip",
|
|
"required": True,
|
|
},
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Name of the location",
|
|
"required": True,
|
|
},
|
|
"description": {
|
|
"type": "string",
|
|
"description": "Description of why to visit",
|
|
},
|
|
"latitude": {
|
|
"type": "number",
|
|
"description": "Latitude coordinate",
|
|
"required": True,
|
|
},
|
|
"longitude": {
|
|
"type": "number",
|
|
"description": "Longitude coordinate",
|
|
"required": True,
|
|
},
|
|
"date": {
|
|
"type": "string",
|
|
"description": "Date in YYYY-MM-DD format",
|
|
},
|
|
"location_address": {
|
|
"type": "string",
|
|
"description": "Full address of the location",
|
|
},
|
|
},
|
|
)
|
|
def add_to_itinerary(
|
|
user,
|
|
collection_id: str | None = None,
|
|
name: str | None = None,
|
|
latitude: float | None = None,
|
|
longitude: float | None = None,
|
|
description: str | None = None,
|
|
date: str | None = None,
|
|
location_address: str | None = None,
|
|
):
|
|
try:
|
|
if not collection_id or not name or latitude is None or longitude is None:
|
|
return {
|
|
"error": "collection_id, name, latitude, and longitude are required"
|
|
}
|
|
|
|
collection = (
|
|
Collection.objects.filter(Q(user=user) | Q(shared_with=user))
|
|
.distinct()
|
|
.get(id=collection_id)
|
|
)
|
|
|
|
itinerary_date = date
|
|
if not itinerary_date:
|
|
if collection.start_date:
|
|
itinerary_date = collection.start_date.isoformat()
|
|
else:
|
|
itinerary_date = date_cls.today().isoformat()
|
|
|
|
try:
|
|
itinerary_date_obj = date_cls.fromisoformat(itinerary_date)
|
|
except ValueError:
|
|
return {"error": "date must be in YYYY-MM-DD format"}
|
|
|
|
latitude_min = latitude - LOCATION_COORD_TOLERANCE
|
|
latitude_max = latitude + LOCATION_COORD_TOLERANCE
|
|
longitude_min = longitude - LOCATION_COORD_TOLERANCE
|
|
longitude_max = longitude + LOCATION_COORD_TOLERANCE
|
|
|
|
location = (
|
|
Location.objects.filter(
|
|
user=user,
|
|
name=name,
|
|
latitude__gte=latitude_min,
|
|
latitude__lte=latitude_max,
|
|
longitude__gte=longitude_min,
|
|
longitude__lte=longitude_max,
|
|
)
|
|
.order_by("created_at")
|
|
.first()
|
|
)
|
|
|
|
if location is None:
|
|
location = Location.objects.create(
|
|
user=user,
|
|
name=name,
|
|
latitude=latitude,
|
|
longitude=longitude,
|
|
description=description or "",
|
|
location=location_address or "",
|
|
)
|
|
|
|
collection.locations.add(location)
|
|
content_type = ContentType.objects.get_for_model(Location)
|
|
|
|
existing_item = CollectionItineraryItem.objects.filter(
|
|
collection=collection,
|
|
content_type=content_type,
|
|
object_id=location.id,
|
|
date=itinerary_date_obj,
|
|
is_global=False,
|
|
).first()
|
|
|
|
if existing_item:
|
|
return {
|
|
"success": True,
|
|
"note": "Location is already in the itinerary for this date",
|
|
"location": {
|
|
"id": str(location.id),
|
|
"name": location.name,
|
|
"latitude": float(location.latitude),
|
|
"longitude": float(location.longitude),
|
|
},
|
|
"itinerary_item": {
|
|
"id": str(existing_item.id),
|
|
"date": itinerary_date_obj.isoformat(),
|
|
"order": existing_item.order,
|
|
},
|
|
}
|
|
|
|
max_order = (
|
|
CollectionItineraryItem.objects.filter(
|
|
collection=collection,
|
|
date=itinerary_date_obj,
|
|
is_global=False,
|
|
).aggregate(models.Max("order"))["order__max"]
|
|
or 0
|
|
)
|
|
|
|
itinerary_item = CollectionItineraryItem.objects.create(
|
|
collection=collection,
|
|
content_type=content_type,
|
|
object_id=location.id,
|
|
date=itinerary_date_obj,
|
|
order=max_order + 1,
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"location": {
|
|
"id": str(location.id),
|
|
"name": location.name,
|
|
"latitude": float(location.latitude),
|
|
"longitude": float(location.longitude),
|
|
},
|
|
"itinerary_item": {
|
|
"id": str(itinerary_item.id),
|
|
"date": itinerary_date_obj.isoformat(),
|
|
"order": itinerary_item.order,
|
|
},
|
|
}
|
|
except Collection.DoesNotExist:
|
|
return {"error": "Trip not found"}
|
|
except Exception:
|
|
logger.exception("add_to_itinerary failed")
|
|
return {"error": "An unexpected error occurred while adding to itinerary"}
|
|
|
|
|
|
@agent_tool(
|
|
name="move_itinerary_item",
|
|
description="Move or reorder an existing itinerary item to another day/order in a trip",
|
|
parameters={
|
|
"collection_id": {
|
|
"type": "string",
|
|
"description": "UUID of the collection/trip",
|
|
"required": True,
|
|
},
|
|
"itinerary_item_id": {
|
|
"type": "string",
|
|
"description": "UUID of the itinerary item to move",
|
|
"required": True,
|
|
},
|
|
"date": {
|
|
"type": "string",
|
|
"description": "Target date in YYYY-MM-DD format",
|
|
"required": True,
|
|
},
|
|
"order": {
|
|
"type": "number",
|
|
"description": "Optional zero-based position for the target day",
|
|
},
|
|
},
|
|
)
|
|
def move_itinerary_item(
|
|
user,
|
|
collection_id: str | None = None,
|
|
itinerary_item_id: str | None = None,
|
|
date: str | None = None,
|
|
order: int | None = None,
|
|
):
|
|
try:
|
|
if not collection_id or not itinerary_item_id or not date:
|
|
return {"error": "collection_id, itinerary_item_id, and date are required"}
|
|
|
|
collection = _get_accessible_collection(user, collection_id)
|
|
target_date = _normalize_date_input(date)
|
|
if target_date is None:
|
|
return {"error": "date must be in YYYY-MM-DD format"}
|
|
|
|
itinerary_item = CollectionItineraryItem.objects.filter(
|
|
collection=collection,
|
|
id=itinerary_item_id,
|
|
).first()
|
|
if itinerary_item is None:
|
|
return {"error": "Itinerary item not found"}
|
|
|
|
desired_order = None
|
|
if order is not None:
|
|
try:
|
|
desired_order = int(order)
|
|
except (TypeError, ValueError):
|
|
return {"error": "order must be numeric"}
|
|
desired_order = max(0, desired_order)
|
|
|
|
source_date = itinerary_item.date
|
|
target_items = list(
|
|
CollectionItineraryItem.objects.filter(
|
|
collection=collection,
|
|
date=target_date,
|
|
is_global=False,
|
|
)
|
|
.exclude(id=itinerary_item.id)
|
|
.order_by("order")
|
|
)
|
|
|
|
insert_at = len(target_items)
|
|
if desired_order is not None:
|
|
insert_at = min(desired_order, len(target_items))
|
|
|
|
updates = []
|
|
for idx, item in enumerate(target_items):
|
|
if idx == insert_at:
|
|
updates.append(
|
|
{
|
|
"id": str(itinerary_item.id),
|
|
"date": target_date,
|
|
"order": insert_at,
|
|
"is_global": False,
|
|
}
|
|
)
|
|
updates.append(
|
|
{
|
|
"id": str(item.id),
|
|
"date": target_date,
|
|
"order": idx + (1 if idx >= insert_at else 0),
|
|
"is_global": False,
|
|
}
|
|
)
|
|
|
|
if insert_at == len(target_items):
|
|
updates.append(
|
|
{
|
|
"id": str(itinerary_item.id),
|
|
"date": target_date,
|
|
"order": insert_at,
|
|
"is_global": False,
|
|
}
|
|
)
|
|
|
|
if source_date and source_date != target_date:
|
|
remaining_source = CollectionItineraryItem.objects.filter(
|
|
collection=collection,
|
|
date=source_date,
|
|
is_global=False,
|
|
).exclude(id=itinerary_item.id)
|
|
for idx, item in enumerate(remaining_source.order_by("order")):
|
|
updates.append(
|
|
{
|
|
"id": str(item.id),
|
|
"date": source_date,
|
|
"order": idx,
|
|
"is_global": False,
|
|
}
|
|
)
|
|
|
|
updated_items = reorder_itinerary_items(user, updates)
|
|
moved_item = next(
|
|
(item for item in updated_items if str(item.id) == str(itinerary_item.id)),
|
|
itinerary_item,
|
|
)
|
|
moved_date = _normalize_date_input(getattr(moved_item, "date", None))
|
|
return {
|
|
"success": True,
|
|
"itinerary_item": {
|
|
"id": str(moved_item.id),
|
|
"date": moved_date.isoformat() if moved_date else None,
|
|
"order": moved_item.order,
|
|
},
|
|
"source_date": source_date.isoformat() if source_date else None,
|
|
"target_date": target_date.isoformat(),
|
|
}
|
|
except Collection.DoesNotExist:
|
|
return {"error": "Trip not found"}
|
|
except Exception:
|
|
logger.exception("move_itinerary_item failed")
|
|
return {"error": "An unexpected error occurred while moving itinerary item"}
|
|
|
|
|
|
@agent_tool(
|
|
name="remove_itinerary_item",
|
|
description="Remove an itinerary item from a trip day",
|
|
parameters={
|
|
"collection_id": {
|
|
"type": "string",
|
|
"description": "UUID of the collection/trip",
|
|
"required": True,
|
|
},
|
|
"itinerary_item_id": {
|
|
"type": "string",
|
|
"description": "UUID of the itinerary item to remove",
|
|
"required": True,
|
|
},
|
|
},
|
|
)
|
|
def remove_itinerary_item(
|
|
user,
|
|
collection_id: str | None = None,
|
|
itinerary_item_id: str | None = None,
|
|
):
|
|
try:
|
|
if not collection_id or not itinerary_item_id:
|
|
return {"error": "collection_id and itinerary_item_id are required"}
|
|
|
|
collection = _get_accessible_collection(user, collection_id)
|
|
itinerary_item = CollectionItineraryItem.objects.filter(
|
|
collection=collection,
|
|
id=itinerary_item_id,
|
|
).first()
|
|
if itinerary_item is None:
|
|
return {"error": "Itinerary item not found"}
|
|
|
|
object_type = itinerary_item.content_type.model
|
|
deleted_visit_count = 0
|
|
|
|
if object_type == "location" and itinerary_item.date:
|
|
location = Location.objects.filter(id=itinerary_item.object_id).first()
|
|
if location:
|
|
visits = Visit.objects.filter(
|
|
location=location,
|
|
start_date__date=itinerary_item.date,
|
|
)
|
|
deleted_visit_count = visits.count()
|
|
visits.delete()
|
|
|
|
itinerary_item.delete()
|
|
|
|
return {
|
|
"success": True,
|
|
"removed_itinerary_item_id": itinerary_item_id,
|
|
"removed_object_type": object_type,
|
|
"deleted_visit_count": deleted_visit_count,
|
|
}
|
|
except Collection.DoesNotExist:
|
|
return {"error": "Trip not found"}
|
|
except Exception:
|
|
logger.exception("remove_itinerary_item failed")
|
|
return {"error": "An unexpected error occurred while removing itinerary item"}
|
|
|
|
|
|
@agent_tool(
|
|
name="update_location_details",
|
|
description="Update itinerary-relevant details for a location in a trip",
|
|
parameters={
|
|
"collection_id": {
|
|
"type": "string",
|
|
"description": "UUID of the collection/trip",
|
|
"required": True,
|
|
},
|
|
"location_id": {
|
|
"type": "string",
|
|
"description": "UUID of the location",
|
|
"required": True,
|
|
},
|
|
"name": {"type": "string", "description": "Updated location name"},
|
|
"description": {
|
|
"type": "string",
|
|
"description": "Updated location description",
|
|
},
|
|
"location": {"type": "string", "description": "Updated address/location text"},
|
|
"latitude": {"type": "number", "description": "Updated latitude"},
|
|
"longitude": {"type": "number", "description": "Updated longitude"},
|
|
},
|
|
)
|
|
def update_location_details(
|
|
user,
|
|
collection_id: str | None = None,
|
|
location_id: str | None = None,
|
|
name: str | None = None,
|
|
description: str | None = None,
|
|
location: str | None = None,
|
|
latitude: float | None = None,
|
|
longitude: float | None = None,
|
|
):
|
|
try:
|
|
if not collection_id or not location_id:
|
|
return {"error": "collection_id and location_id are required"}
|
|
|
|
collection = _get_accessible_collection(user, collection_id)
|
|
location_obj = collection.locations.filter(id=location_id).first()
|
|
if location_obj is None:
|
|
return {"error": "Location not found in this trip"}
|
|
|
|
updated_fields = []
|
|
if isinstance(name, str) and name.strip():
|
|
location_obj.name = name.strip()
|
|
updated_fields.append("name")
|
|
if description is not None:
|
|
location_obj.description = str(description)
|
|
updated_fields.append("description")
|
|
if location is not None:
|
|
location_obj.location = str(location)
|
|
updated_fields.append("location")
|
|
|
|
parsed_lat = _parse_float(latitude)
|
|
parsed_lon = _parse_float(longitude)
|
|
if latitude is not None and parsed_lat is None:
|
|
return {"error": "latitude must be numeric"}
|
|
if longitude is not None and parsed_lon is None:
|
|
return {"error": "longitude must be numeric"}
|
|
if latitude is not None:
|
|
location_obj.latitude = parsed_lat
|
|
updated_fields.append("latitude")
|
|
if longitude is not None:
|
|
location_obj.longitude = parsed_lon
|
|
updated_fields.append("longitude")
|
|
|
|
if not updated_fields:
|
|
return {"error": "At least one field to update is required"}
|
|
|
|
location_obj.save(update_fields=updated_fields)
|
|
|
|
return {
|
|
"success": True,
|
|
"location": {
|
|
"id": str(location_obj.id),
|
|
"name": location_obj.name,
|
|
"description": location_obj.description or "",
|
|
"location": location_obj.location or "",
|
|
"latitude": float(location_obj.latitude)
|
|
if location_obj.latitude is not None
|
|
else None,
|
|
"longitude": float(location_obj.longitude)
|
|
if location_obj.longitude is not None
|
|
else None,
|
|
},
|
|
}
|
|
except Collection.DoesNotExist:
|
|
return {"error": "Trip not found"}
|
|
except Exception:
|
|
logger.exception("update_location_details failed")
|
|
return {"error": "An unexpected error occurred while updating location"}
|
|
|
|
|
|
@agent_tool(
|
|
name="add_lodging",
|
|
description="Add a lodging stay to a trip and optionally add it to itinerary day",
|
|
parameters={
|
|
"collection_id": {
|
|
"type": "string",
|
|
"description": "UUID of the collection/trip",
|
|
"required": True,
|
|
},
|
|
"name": {"type": "string", "description": "Lodging name", "required": True},
|
|
"type": {
|
|
"type": "string",
|
|
"description": "Lodging type (hotel, hostel, resort, bnb, campground, cabin, apartment, house, villa, motel, other)",
|
|
},
|
|
"location": {"type": "string", "description": "Address or location text"},
|
|
"check_in": {
|
|
"type": "string",
|
|
"description": "Check-in datetime or date (ISO format)",
|
|
},
|
|
"check_out": {
|
|
"type": "string",
|
|
"description": "Check-out datetime or date (ISO format)",
|
|
},
|
|
"latitude": {"type": "number", "description": "Latitude"},
|
|
"longitude": {"type": "number", "description": "Longitude"},
|
|
"itinerary_date": {
|
|
"type": "string",
|
|
"description": "Optional day in YYYY-MM-DD to add this lodging to itinerary",
|
|
},
|
|
},
|
|
)
|
|
def add_lodging(
|
|
user,
|
|
collection_id: str | None = None,
|
|
name: str | None = None,
|
|
type: str | None = None,
|
|
location: str | None = None,
|
|
check_in: str | None = None,
|
|
check_out: str | None = None,
|
|
latitude: float | None = None,
|
|
longitude: float | None = None,
|
|
itinerary_date: str | None = None,
|
|
):
|
|
try:
|
|
if not collection_id or not name:
|
|
return {"error": "collection_id and name are required"}
|
|
|
|
collection = _get_accessible_collection(user, collection_id)
|
|
|
|
parsed_check_in = _normalize_datetime_input(check_in)
|
|
parsed_check_out = _normalize_datetime_input(check_out)
|
|
if check_in and parsed_check_in is None:
|
|
return {"error": "check_in must be a valid ISO date or datetime"}
|
|
if check_out and parsed_check_out is None:
|
|
return {"error": "check_out must be a valid ISO date or datetime"}
|
|
|
|
parsed_lat = _parse_float(latitude)
|
|
parsed_lon = _parse_float(longitude)
|
|
if latitude is not None and parsed_lat is None:
|
|
return {"error": "latitude must be numeric"}
|
|
if longitude is not None and parsed_lon is None:
|
|
return {"error": "longitude must be numeric"}
|
|
|
|
lodging = Lodging.objects.create(
|
|
user=collection.user,
|
|
collection=collection,
|
|
name=name,
|
|
type=(type or "other"),
|
|
location=location or "",
|
|
check_in=parsed_check_in,
|
|
check_out=parsed_check_out,
|
|
latitude=parsed_lat,
|
|
longitude=parsed_lon,
|
|
)
|
|
|
|
itinerary_item = None
|
|
if itinerary_date:
|
|
itinerary_day = _normalize_date_input(itinerary_date)
|
|
if itinerary_day is None:
|
|
return {"error": "itinerary_date must be in YYYY-MM-DD format"}
|
|
|
|
max_order = (
|
|
CollectionItineraryItem.objects.filter(
|
|
collection=collection,
|
|
date=itinerary_day,
|
|
is_global=False,
|
|
).aggregate(models.Max("order"))["order__max"]
|
|
or 0
|
|
)
|
|
itinerary_item = CollectionItineraryItem.objects.create(
|
|
collection=collection,
|
|
content_type=ContentType.objects.get_for_model(Lodging),
|
|
object_id=lodging.id,
|
|
date=itinerary_day,
|
|
order=max_order + 1,
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"lodging": _serialize_lodging(lodging),
|
|
"itinerary_item": {
|
|
"id": str(itinerary_item.id),
|
|
"date": itinerary_item.date.isoformat() if itinerary_item else None,
|
|
"order": itinerary_item.order if itinerary_item else None,
|
|
}
|
|
if itinerary_item
|
|
else None,
|
|
}
|
|
except Collection.DoesNotExist:
|
|
return {"error": "Trip not found"}
|
|
except Exception:
|
|
logger.exception("add_lodging failed")
|
|
return {"error": "An unexpected error occurred while adding lodging"}
|
|
|
|
|
|
@agent_tool(
|
|
name="update_lodging",
|
|
description="Update lodging details for an existing trip lodging item",
|
|
parameters={
|
|
"collection_id": {
|
|
"type": "string",
|
|
"description": "UUID of the collection/trip",
|
|
"required": True,
|
|
},
|
|
"lodging_id": {
|
|
"type": "string",
|
|
"description": "UUID of the lodging",
|
|
"required": True,
|
|
},
|
|
"name": {"type": "string", "description": "Updated lodging name"},
|
|
"type": {"type": "string", "description": "Updated lodging type"},
|
|
"location": {"type": "string", "description": "Updated location text"},
|
|
"check_in": {
|
|
"type": "string",
|
|
"description": "Updated check-in datetime/date (ISO)",
|
|
},
|
|
"check_out": {
|
|
"type": "string",
|
|
"description": "Updated check-out datetime/date (ISO)",
|
|
},
|
|
"latitude": {"type": "number", "description": "Updated latitude"},
|
|
"longitude": {"type": "number", "description": "Updated longitude"},
|
|
},
|
|
)
|
|
def update_lodging(
|
|
user,
|
|
collection_id: str | None = None,
|
|
lodging_id: str | None = None,
|
|
name: str | None = None,
|
|
type: str | None = None,
|
|
location: str | None = None,
|
|
check_in: str | None = None,
|
|
check_out: str | None = None,
|
|
latitude: float | None = None,
|
|
longitude: float | None = None,
|
|
):
|
|
try:
|
|
if not collection_id or not lodging_id:
|
|
return {"error": "collection_id and lodging_id are required"}
|
|
|
|
collection = _get_accessible_collection(user, collection_id)
|
|
lodging = Lodging.objects.filter(id=lodging_id, collection=collection).first()
|
|
if lodging is None:
|
|
return {"error": "Lodging not found"}
|
|
|
|
updates = []
|
|
if isinstance(name, str) and name.strip():
|
|
lodging.name = name.strip()
|
|
updates.append("name")
|
|
if isinstance(type, str) and type.strip():
|
|
lodging.type = type.strip()
|
|
updates.append("type")
|
|
if location is not None:
|
|
lodging.location = str(location)
|
|
updates.append("location")
|
|
|
|
parsed_check_in = _normalize_datetime_input(check_in)
|
|
parsed_check_out = _normalize_datetime_input(check_out)
|
|
if check_in is not None and parsed_check_in is None:
|
|
return {"error": "check_in must be a valid ISO date or datetime"}
|
|
if check_out is not None and parsed_check_out is None:
|
|
return {"error": "check_out must be a valid ISO date or datetime"}
|
|
if check_in is not None:
|
|
lodging.check_in = parsed_check_in
|
|
updates.append("check_in")
|
|
if check_out is not None:
|
|
lodging.check_out = parsed_check_out
|
|
updates.append("check_out")
|
|
|
|
parsed_lat = _parse_float(latitude)
|
|
parsed_lon = _parse_float(longitude)
|
|
if latitude is not None and parsed_lat is None:
|
|
return {"error": "latitude must be numeric"}
|
|
if longitude is not None and parsed_lon is None:
|
|
return {"error": "longitude must be numeric"}
|
|
if latitude is not None:
|
|
lodging.latitude = parsed_lat
|
|
updates.append("latitude")
|
|
if longitude is not None:
|
|
lodging.longitude = parsed_lon
|
|
updates.append("longitude")
|
|
|
|
if not updates:
|
|
return {"error": "At least one field to update is required"}
|
|
|
|
lodging.save(update_fields=updates)
|
|
return {"success": True, "lodging": _serialize_lodging(lodging)}
|
|
except Collection.DoesNotExist:
|
|
return {"error": "Trip not found"}
|
|
except Exception:
|
|
logger.exception("update_lodging failed")
|
|
return {"error": "An unexpected error occurred while updating lodging"}
|
|
|
|
|
|
@agent_tool(
|
|
name="remove_lodging",
|
|
description="Remove a lodging record from a trip",
|
|
parameters={
|
|
"collection_id": {
|
|
"type": "string",
|
|
"description": "UUID of the collection/trip",
|
|
"required": True,
|
|
},
|
|
"lodging_id": {
|
|
"type": "string",
|
|
"description": "UUID of the lodging",
|
|
"required": True,
|
|
},
|
|
},
|
|
)
|
|
def remove_lodging(
|
|
user,
|
|
collection_id: str | None = None,
|
|
lodging_id: str | None = None,
|
|
):
|
|
try:
|
|
if not collection_id or not lodging_id:
|
|
return {"error": "collection_id and lodging_id are required"}
|
|
|
|
collection = _get_accessible_collection(user, collection_id)
|
|
lodging = Lodging.objects.filter(id=lodging_id, collection=collection).first()
|
|
if lodging is None:
|
|
return {"error": "Lodging not found"}
|
|
|
|
itinerary_deleted = CollectionItineraryItem.objects.filter(
|
|
collection=collection,
|
|
content_type=ContentType.objects.get_for_model(Lodging),
|
|
object_id=lodging.id,
|
|
).delete()[0]
|
|
lodging.delete()
|
|
return {
|
|
"success": True,
|
|
"removed_lodging_id": lodging_id,
|
|
"removed_itinerary_items": itinerary_deleted,
|
|
}
|
|
except Collection.DoesNotExist:
|
|
return {"error": "Trip not found"}
|
|
except Exception:
|
|
logger.exception("remove_lodging failed")
|
|
return {"error": "An unexpected error occurred while removing lodging"}
|
|
|
|
|
|
@agent_tool(
|
|
name="add_transportation",
|
|
description="Add transportation to a trip and optionally add it to itinerary day",
|
|
parameters={
|
|
"collection_id": {
|
|
"type": "string",
|
|
"description": "UUID of the collection/trip",
|
|
"required": True,
|
|
},
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Transportation name",
|
|
"required": True,
|
|
},
|
|
"type": {
|
|
"type": "string",
|
|
"description": "Transportation type (car, plane, train, bus, boat, bike, walking, other)",
|
|
"required": True,
|
|
},
|
|
"date": {
|
|
"type": "string",
|
|
"description": "Departure datetime/date (ISO)",
|
|
},
|
|
"end_date": {
|
|
"type": "string",
|
|
"description": "Arrival datetime/date (ISO)",
|
|
},
|
|
"from_location": {"type": "string", "description": "Origin location text"},
|
|
"to_location": {"type": "string", "description": "Destination location text"},
|
|
"origin_latitude": {"type": "number", "description": "Origin latitude"},
|
|
"origin_longitude": {"type": "number", "description": "Origin longitude"},
|
|
"destination_latitude": {
|
|
"type": "number",
|
|
"description": "Destination latitude",
|
|
},
|
|
"destination_longitude": {
|
|
"type": "number",
|
|
"description": "Destination longitude",
|
|
},
|
|
"itinerary_date": {
|
|
"type": "string",
|
|
"description": "Optional day in YYYY-MM-DD to add this transportation to itinerary",
|
|
},
|
|
},
|
|
)
|
|
def add_transportation(
|
|
user,
|
|
collection_id: str | None = None,
|
|
name: str | None = None,
|
|
type: str | None = None,
|
|
date: str | None = None,
|
|
end_date: str | None = None,
|
|
from_location: str | None = None,
|
|
to_location: str | None = None,
|
|
origin_latitude: float | None = None,
|
|
origin_longitude: float | None = None,
|
|
destination_latitude: float | None = None,
|
|
destination_longitude: float | None = None,
|
|
itinerary_date: str | None = None,
|
|
):
|
|
try:
|
|
if not collection_id or not name or not type:
|
|
return {"error": "collection_id, name, and type are required"}
|
|
|
|
collection = _get_accessible_collection(user, collection_id)
|
|
|
|
parsed_date = _normalize_datetime_input(date)
|
|
parsed_end_date = _normalize_datetime_input(end_date)
|
|
if date and parsed_date is None:
|
|
return {"error": "date must be a valid ISO date or datetime"}
|
|
if end_date and parsed_end_date is None:
|
|
return {"error": "end_date must be a valid ISO date or datetime"}
|
|
|
|
parsed_origin_lat = _parse_float(origin_latitude)
|
|
parsed_origin_lon = _parse_float(origin_longitude)
|
|
parsed_destination_lat = _parse_float(destination_latitude)
|
|
parsed_destination_lon = _parse_float(destination_longitude)
|
|
if origin_latitude is not None and parsed_origin_lat is None:
|
|
return {"error": "origin_latitude must be numeric"}
|
|
if origin_longitude is not None and parsed_origin_lon is None:
|
|
return {"error": "origin_longitude must be numeric"}
|
|
if destination_latitude is not None and parsed_destination_lat is None:
|
|
return {"error": "destination_latitude must be numeric"}
|
|
if destination_longitude is not None and parsed_destination_lon is None:
|
|
return {"error": "destination_longitude must be numeric"}
|
|
|
|
transportation = Transportation.objects.create(
|
|
user=collection.user,
|
|
collection=collection,
|
|
name=name,
|
|
type=type,
|
|
date=parsed_date,
|
|
end_date=parsed_end_date,
|
|
from_location=from_location or "",
|
|
to_location=to_location or "",
|
|
origin_latitude=parsed_origin_lat,
|
|
origin_longitude=parsed_origin_lon,
|
|
destination_latitude=parsed_destination_lat,
|
|
destination_longitude=parsed_destination_lon,
|
|
)
|
|
|
|
itinerary_item = None
|
|
if itinerary_date:
|
|
itinerary_day = _normalize_date_input(itinerary_date)
|
|
if itinerary_day is None:
|
|
return {"error": "itinerary_date must be in YYYY-MM-DD format"}
|
|
|
|
max_order = (
|
|
CollectionItineraryItem.objects.filter(
|
|
collection=collection,
|
|
date=itinerary_day,
|
|
is_global=False,
|
|
).aggregate(models.Max("order"))["order__max"]
|
|
or 0
|
|
)
|
|
itinerary_item = CollectionItineraryItem.objects.create(
|
|
collection=collection,
|
|
content_type=ContentType.objects.get_for_model(Transportation),
|
|
object_id=transportation.id,
|
|
date=itinerary_day,
|
|
order=max_order + 1,
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"transportation": _serialize_transportation(transportation),
|
|
"itinerary_item": {
|
|
"id": str(itinerary_item.id),
|
|
"date": itinerary_item.date.isoformat() if itinerary_item else None,
|
|
"order": itinerary_item.order if itinerary_item else None,
|
|
}
|
|
if itinerary_item
|
|
else None,
|
|
}
|
|
except Collection.DoesNotExist:
|
|
return {"error": "Trip not found"}
|
|
except Exception:
|
|
logger.exception("add_transportation failed")
|
|
return {"error": "An unexpected error occurred while adding transportation"}
|
|
|
|
|
|
@agent_tool(
|
|
name="update_transportation",
|
|
description="Update details for an existing transportation item",
|
|
parameters={
|
|
"collection_id": {
|
|
"type": "string",
|
|
"description": "UUID of the collection/trip",
|
|
"required": True,
|
|
},
|
|
"transportation_id": {
|
|
"type": "string",
|
|
"description": "UUID of the transportation",
|
|
"required": True,
|
|
},
|
|
"name": {"type": "string", "description": "Updated transportation name"},
|
|
"type": {"type": "string", "description": "Updated transportation type"},
|
|
"date": {"type": "string", "description": "Updated departure datetime/date"},
|
|
"end_date": {
|
|
"type": "string",
|
|
"description": "Updated arrival datetime/date",
|
|
},
|
|
"from_location": {
|
|
"type": "string",
|
|
"description": "Updated origin location text",
|
|
},
|
|
"to_location": {
|
|
"type": "string",
|
|
"description": "Updated destination location text",
|
|
},
|
|
"origin_latitude": {"type": "number", "description": "Updated origin latitude"},
|
|
"origin_longitude": {
|
|
"type": "number",
|
|
"description": "Updated origin longitude",
|
|
},
|
|
"destination_latitude": {
|
|
"type": "number",
|
|
"description": "Updated destination latitude",
|
|
},
|
|
"destination_longitude": {
|
|
"type": "number",
|
|
"description": "Updated destination longitude",
|
|
},
|
|
},
|
|
)
|
|
def update_transportation(
|
|
user,
|
|
collection_id: str | None = None,
|
|
transportation_id: str | None = None,
|
|
name: str | None = None,
|
|
type: str | None = None,
|
|
date: str | None = None,
|
|
end_date: str | None = None,
|
|
from_location: str | None = None,
|
|
to_location: str | None = None,
|
|
origin_latitude: float | None = None,
|
|
origin_longitude: float | None = None,
|
|
destination_latitude: float | None = None,
|
|
destination_longitude: float | None = None,
|
|
):
|
|
try:
|
|
if not collection_id or not transportation_id:
|
|
return {"error": "collection_id and transportation_id are required"}
|
|
|
|
collection = _get_accessible_collection(user, collection_id)
|
|
transportation = Transportation.objects.filter(
|
|
id=transportation_id,
|
|
collection=collection,
|
|
).first()
|
|
if transportation is None:
|
|
return {"error": "Transportation not found"}
|
|
|
|
updates = []
|
|
if isinstance(name, str) and name.strip():
|
|
transportation.name = name.strip()
|
|
updates.append("name")
|
|
if isinstance(type, str) and type.strip():
|
|
transportation.type = type.strip()
|
|
updates.append("type")
|
|
if from_location is not None:
|
|
transportation.from_location = str(from_location)
|
|
updates.append("from_location")
|
|
if to_location is not None:
|
|
transportation.to_location = str(to_location)
|
|
updates.append("to_location")
|
|
|
|
parsed_date = _normalize_datetime_input(date)
|
|
parsed_end_date = _normalize_datetime_input(end_date)
|
|
if date is not None and parsed_date is None:
|
|
return {"error": "date must be a valid ISO date or datetime"}
|
|
if end_date is not None and parsed_end_date is None:
|
|
return {"error": "end_date must be a valid ISO date or datetime"}
|
|
if date is not None:
|
|
transportation.date = parsed_date
|
|
updates.append("date")
|
|
if end_date is not None:
|
|
transportation.end_date = parsed_end_date
|
|
updates.append("end_date")
|
|
|
|
parsed_origin_lat = _parse_float(origin_latitude)
|
|
parsed_origin_lon = _parse_float(origin_longitude)
|
|
parsed_destination_lat = _parse_float(destination_latitude)
|
|
parsed_destination_lon = _parse_float(destination_longitude)
|
|
if origin_latitude is not None and parsed_origin_lat is None:
|
|
return {"error": "origin_latitude must be numeric"}
|
|
if origin_longitude is not None and parsed_origin_lon is None:
|
|
return {"error": "origin_longitude must be numeric"}
|
|
if destination_latitude is not None and parsed_destination_lat is None:
|
|
return {"error": "destination_latitude must be numeric"}
|
|
if destination_longitude is not None and parsed_destination_lon is None:
|
|
return {"error": "destination_longitude must be numeric"}
|
|
if origin_latitude is not None:
|
|
transportation.origin_latitude = parsed_origin_lat
|
|
updates.append("origin_latitude")
|
|
if origin_longitude is not None:
|
|
transportation.origin_longitude = parsed_origin_lon
|
|
updates.append("origin_longitude")
|
|
if destination_latitude is not None:
|
|
transportation.destination_latitude = parsed_destination_lat
|
|
updates.append("destination_latitude")
|
|
if destination_longitude is not None:
|
|
transportation.destination_longitude = parsed_destination_lon
|
|
updates.append("destination_longitude")
|
|
|
|
if not updates:
|
|
return {"error": "At least one field to update is required"}
|
|
|
|
transportation.save(update_fields=updates)
|
|
return {
|
|
"success": True,
|
|
"transportation": _serialize_transportation(transportation),
|
|
}
|
|
except Collection.DoesNotExist:
|
|
return {"error": "Trip not found"}
|
|
except Exception:
|
|
logger.exception("update_transportation failed")
|
|
return {"error": "An unexpected error occurred while updating transportation"}
|
|
|
|
|
|
@agent_tool(
|
|
name="remove_transportation",
|
|
description="Remove transportation from a trip",
|
|
parameters={
|
|
"collection_id": {
|
|
"type": "string",
|
|
"description": "UUID of the collection/trip",
|
|
"required": True,
|
|
},
|
|
"transportation_id": {
|
|
"type": "string",
|
|
"description": "UUID of the transportation",
|
|
"required": True,
|
|
},
|
|
},
|
|
)
|
|
def remove_transportation(
|
|
user,
|
|
collection_id: str | None = None,
|
|
transportation_id: str | None = None,
|
|
):
|
|
try:
|
|
if not collection_id or not transportation_id:
|
|
return {"error": "collection_id and transportation_id are required"}
|
|
|
|
collection = _get_accessible_collection(user, collection_id)
|
|
transportation = Transportation.objects.filter(
|
|
id=transportation_id,
|
|
collection=collection,
|
|
).first()
|
|
if transportation is None:
|
|
return {"error": "Transportation not found"}
|
|
|
|
itinerary_deleted = CollectionItineraryItem.objects.filter(
|
|
collection=collection,
|
|
content_type=ContentType.objects.get_for_model(Transportation),
|
|
object_id=transportation.id,
|
|
).delete()[0]
|
|
transportation.delete()
|
|
return {
|
|
"success": True,
|
|
"removed_transportation_id": transportation_id,
|
|
"removed_itinerary_items": itinerary_deleted,
|
|
}
|
|
except Collection.DoesNotExist:
|
|
return {"error": "Trip not found"}
|
|
except Exception:
|
|
logger.exception("remove_transportation failed")
|
|
return {"error": "An unexpected error occurred while removing transportation"}
|
|
|
|
|
|
@agent_tool(
|
|
name="get_weather",
|
|
description="Get temperature/weather data for a location on specific dates",
|
|
parameters={
|
|
"latitude": {"type": "number", "description": "Latitude", "required": True},
|
|
"longitude": {
|
|
"type": "number",
|
|
"description": "Longitude",
|
|
"required": True,
|
|
},
|
|
"dates": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"description": "List of dates in YYYY-MM-DD format",
|
|
"required": True,
|
|
},
|
|
},
|
|
)
|
|
def get_weather(user, latitude=None, longitude=None, dates=None):
|
|
try:
|
|
raw_latitude = latitude
|
|
raw_longitude = longitude
|
|
if raw_latitude is None or raw_longitude is None:
|
|
return {"error": "latitude and longitude are required"}
|
|
|
|
latitude = float(raw_latitude)
|
|
longitude = float(raw_longitude)
|
|
dates = dates or []
|
|
|
|
if not isinstance(dates, list) or not dates:
|
|
return {"error": "dates is required"}
|
|
|
|
results = [
|
|
fetch_daily_temperature(
|
|
date=date_value, latitude=latitude, longitude=longitude
|
|
)
|
|
for date_value in dates
|
|
]
|
|
return {
|
|
"latitude": latitude,
|
|
"longitude": longitude,
|
|
"results": results,
|
|
}
|
|
except (TypeError, ValueError):
|
|
return {"error": "latitude and longitude must be numeric"}
|
|
except Exception:
|
|
logger.exception("get_weather failed")
|
|
return {"error": "An unexpected error occurred while fetching weather data"}
|
|
|
|
|
|
def execute_tool(tool_name, user, **kwargs):
|
|
if tool_name not in _REGISTERED_TOOLS:
|
|
return {"error": f"Unknown tool: {tool_name}"}
|
|
|
|
tool_fn = _REGISTERED_TOOLS[tool_name]
|
|
|
|
sig = inspect.signature(tool_fn)
|
|
allowed = set(sig.parameters.keys()) - {"user"}
|
|
filtered_kwargs = {k: v for k, v in kwargs.items() if k in allowed}
|
|
|
|
try:
|
|
return tool_fn(user=user, **filtered_kwargs)
|
|
except Exception:
|
|
logger.exception("Tool %s failed", tool_name)
|
|
return {"error": "Tool execution failed"}
|
|
|
|
|
|
AGENT_TOOLS = get_tool_schemas()
|
|
|
|
|
|
def serialize_tool_result(result):
|
|
try:
|
|
return json.dumps(result)
|
|
except TypeError:
|
|
return json.dumps({"error": "Tool returned non-serializable data"})
|