diff --git a/backend/server/chat/agent_tools.py b/backend/server/chat/agent_tools.py index 16545ec4..21a97338 100644 --- a/backend/server/chat/agent_tools.py +++ b/backend/server/chat/agent_tools.py @@ -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_FORECAST_URL = "https://api.open-meteo.com/v1/forecast" REQUEST_HEADERS = {"User-Agent": "Voyage/1.0"} +LOCATION_COORD_TOLERANCE = 0.00001 def _build_overpass_query(latitude, longitude, radius_meters, category): @@ -341,7 +342,7 @@ def get_trip_details(user, collection_id: str | None = None): ) itinerary = [] - for item in collection.itinerary_items.all().order_by("date", "order"): + for item in collection.itinerary_items.all(): content_obj = item.item itinerary.append( { @@ -474,18 +475,6 @@ def add_to_itinerary( .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 if not itinerary_date: if collection.start_date: @@ -498,6 +487,62 @@ def add_to_itinerary( 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, diff --git a/backend/server/chat/tests.py b/backend/server/chat/tests.py index 60491476..98c438c3 100644 --- a/backend/server/chat/tests.py +++ b/backend/server/chat/tests.py @@ -151,6 +151,77 @@ class ChatAgentToolSharedTripAccessTests(TestCase): self.assertTrue(result.get("success")) 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") def test_non_member_access_remains_denied(self, _mock_background_geocode): trip_result = get_trip_details(