fix(chat): prevent duplicate itinerary entries on repeat add_to_itinerary calls
This commit is contained in:
@@ -62,6 +62,7 @@ OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
|||||||
OPEN_METEO_ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive"
|
OPEN_METEO_ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive"
|
||||||
OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
|
OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
|
||||||
REQUEST_HEADERS = {"User-Agent": "Voyage/1.0"}
|
REQUEST_HEADERS = {"User-Agent": "Voyage/1.0"}
|
||||||
|
LOCATION_COORD_TOLERANCE = 0.00001
|
||||||
|
|
||||||
|
|
||||||
def _build_overpass_query(latitude, longitude, radius_meters, category):
|
def _build_overpass_query(latitude, longitude, radius_meters, category):
|
||||||
@@ -341,7 +342,7 @@ def get_trip_details(user, collection_id: str | None = None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
itinerary = []
|
itinerary = []
|
||||||
for item in collection.itinerary_items.all().order_by("date", "order"):
|
for item in collection.itinerary_items.all():
|
||||||
content_obj = item.item
|
content_obj = item.item
|
||||||
itinerary.append(
|
itinerary.append(
|
||||||
{
|
{
|
||||||
@@ -474,18 +475,6 @@ def add_to_itinerary(
|
|||||||
.get(id=collection_id)
|
.get(id=collection_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
itinerary_date = date
|
itinerary_date = date
|
||||||
if not itinerary_date:
|
if not itinerary_date:
|
||||||
if collection.start_date:
|
if collection.start_date:
|
||||||
@@ -498,6 +487,62 @@ def add_to_itinerary(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return {"error": "date must be in YYYY-MM-DD format"}
|
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 = (
|
max_order = (
|
||||||
CollectionItineraryItem.objects.filter(
|
CollectionItineraryItem.objects.filter(
|
||||||
collection=collection,
|
collection=collection,
|
||||||
|
|||||||
@@ -151,6 +151,77 @@ class ChatAgentToolSharedTripAccessTests(TestCase):
|
|||||||
self.assertTrue(result.get("success"))
|
self.assertTrue(result.get("success"))
|
||||||
self.assertEqual(result["location"]["name"], "Louvre Museum")
|
self.assertEqual(result["location"]["name"], "Louvre Museum")
|
||||||
|
|
||||||
|
@patch("adventures.models.background_geocode_and_assign")
|
||||||
|
def test_add_to_itinerary_reuses_same_day_location_without_duplicate_rows(
|
||||||
|
self,
|
||||||
|
_mock_background_geocode,
|
||||||
|
):
|
||||||
|
first = add_to_itinerary(
|
||||||
|
self.owner,
|
||||||
|
collection_id=str(self.collection.id),
|
||||||
|
name="Eiffel Tower",
|
||||||
|
latitude=48.85837,
|
||||||
|
longitude=2.294481,
|
||||||
|
date="2026-06-01",
|
||||||
|
)
|
||||||
|
second = add_to_itinerary(
|
||||||
|
self.owner,
|
||||||
|
collection_id=str(self.collection.id),
|
||||||
|
name="Eiffel Tower",
|
||||||
|
latitude=48.858370001,
|
||||||
|
longitude=2.294480999,
|
||||||
|
date="2026-06-01",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(first.get("success"))
|
||||||
|
self.assertTrue(second.get("success"))
|
||||||
|
self.assertEqual(
|
||||||
|
second.get("note"),
|
||||||
|
"Location is already in the itinerary for this date",
|
||||||
|
)
|
||||||
|
self.assertEqual(first["location"]["id"], second["location"]["id"])
|
||||||
|
self.assertEqual(first["itinerary_item"]["id"], second["itinerary_item"]["id"])
|
||||||
|
self.assertEqual(Location.objects.filter(user=self.owner).count(), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
CollectionItineraryItem.objects.filter(collection=self.collection).count(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("adventures.models.background_geocode_and_assign")
|
||||||
|
def test_add_to_itinerary_allows_reusing_same_location_on_different_dates(
|
||||||
|
self,
|
||||||
|
_mock_background_geocode,
|
||||||
|
):
|
||||||
|
first = add_to_itinerary(
|
||||||
|
self.owner,
|
||||||
|
collection_id=str(self.collection.id),
|
||||||
|
name="Eiffel Tower",
|
||||||
|
latitude=48.85837,
|
||||||
|
longitude=2.294481,
|
||||||
|
date="2026-06-01",
|
||||||
|
)
|
||||||
|
second = add_to_itinerary(
|
||||||
|
self.owner,
|
||||||
|
collection_id=str(self.collection.id),
|
||||||
|
name="Eiffel Tower",
|
||||||
|
latitude=48.858370001,
|
||||||
|
longitude=2.294480999,
|
||||||
|
date="2026-06-02",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(first.get("success"))
|
||||||
|
self.assertTrue(second.get("success"))
|
||||||
|
self.assertNotIn("note", second)
|
||||||
|
self.assertEqual(first["location"]["id"], second["location"]["id"])
|
||||||
|
self.assertNotEqual(
|
||||||
|
first["itinerary_item"]["id"], second["itinerary_item"]["id"]
|
||||||
|
)
|
||||||
|
self.assertEqual(Location.objects.filter(user=self.owner).count(), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
CollectionItineraryItem.objects.filter(collection=self.collection).count(),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
@patch("adventures.models.background_geocode_and_assign")
|
@patch("adventures.models.background_geocode_and_assign")
|
||||||
def test_non_member_access_remains_denied(self, _mock_background_geocode):
|
def test_non_member_access_remains_denied(self, _mock_background_geocode):
|
||||||
trip_result = get_trip_details(
|
trip_result = get_trip_details(
|
||||||
|
|||||||
Reference in New Issue
Block a user