diff --git a/PROJECT.org b/PROJECT.org index b27d375..30edb44 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -612,6 +612,21 @@ favorited media objects. *** Description As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction +** DONE [#B] Trail scrobbles without names should try to use trial heads loc :trails:metadata: +:PROPERTIES: +:ID: 632c3d8d-d084-462b-a485-3affb494fc57 +:END: + +*** Description + +Currenlty when a trail does not have a title yet, the name `hiking` or `walking` +is used from the activity type. Instead, the geoloc for the start and end of the +trail should be looked up in the GeoLocation table and the title should be a +combination of " to " unless the start and end are the +same (loop or out and back) in which case, it should just be the "". In cases where there is no GeoLocation, revert to the usual behavior of +activity type. + ** TODO [#A] Update how board game scrobbles work :boardgames: *** Description diff --git a/tests/trails_tests/test_gpx_importer.py b/tests/trails_tests/test_gpx_importer.py index 10c6428..71c00ca 100644 --- a/tests/trails_tests/test_gpx_importer.py +++ b/tests/trails_tests/test_gpx_importer.py @@ -22,6 +22,18 @@ SAMPLE_GPX = os.path.join( os.path.dirname(__file__), "..", "..", "data", "sample_trail.gpx" ) +LOOP_GPX = """ + + + Loop Walk + + 100 + 105 + + + +""" + @pytest.fixture def user(db): @@ -80,6 +92,13 @@ class TestImportTrailGPX: assert trail.trailhead_location is not None assert round(trail.trailhead_location.lat, 6) == 34.190598 + def test_sets_terminus(self, user, sample_gpx_path): + import_trail_gpx(sample_gpx_path, user.id) + trail = Trail.objects.filter(title="Morning Run ⛅").first() + assert trail.trail_terminus_location is not None + assert round(trail.trail_terminus_location.lat, 6) == 34.187565 + assert round(trail.trail_terminus_location.lon, 6) == -118.847091 + def test_creates_scrobble(self, user, sample_gpx_path): import_trail_gpx(sample_gpx_path, user.id) assert Scrobble.objects.filter(source="GPX Import").count() == 1 @@ -110,6 +129,59 @@ class TestImportTrailGPX: scrobble = Scrobble.objects.filter(source="GPX Import").first() assert scrobble.trail.id == trail.id + def test_lookup_existing_trail_sets_terminus(self, user, sample_gpx_path): + geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015) + trail = Trail.objects.create(title="Existing Trail", trailhead_location=geo) + import_trail_gpx(sample_gpx_path, user.id) + trail.refresh_from_db() + assert trail.trail_terminus_location is not None + assert round(trail.trail_terminus_location.lat, 6) == 34.187565 + assert round(trail.trail_terminus_location.lon, 6) == -118.847091 + + def test_new_trail_uses_geo_titles(self, user, sample_gpx_path): + GeoLocation.objects.create( + lat=34.190598, lon=-118.844015, title="Start Trailhead" + ) + GeoLocation.objects.create( + lat=34.187565, lon=-118.847091, title="End Trailhead" + ) + import_trail_gpx(sample_gpx_path, user.id) + trail = Trail.objects.get(title="Start Trailhead to End Trailhead") + assert trail.trailhead_location.title == "Start Trailhead" + assert trail.trail_terminus_location.title == "End Trailhead" + + def test_new_trail_loop_uses_single_geo_title(self, user, sample_gpx_path): + GeoLocation.objects.create( + lat=34.190598, lon=-118.844015, title="Coral Canyon Loop" + ) + GeoLocation.objects.create( + lat=34.187565, lon=-118.847091, title="Coral Canyon Loop" + ) + import_trail_gpx(sample_gpx_path, user.id) + assert Trail.objects.filter(title="Coral Canyon Loop").exists() + + def test_new_trail_falls_back_to_track_name_without_geo_titles( + self, user, sample_gpx_path + ): + import_trail_gpx(sample_gpx_path, user.id) + assert Trail.objects.filter(title="Morning Run ⛅").exists() + + def test_tags_point_to_point_trail_and_scrobble(self, user, sample_gpx_path): + import_trail_gpx(sample_gpx_path, user.id) + trail = Trail.objects.get(title="Morning Run ⛅") + assert set(trail.tags.names()) == {"point-to-point"} + scrobble = Scrobble.objects.get(trail=trail) + assert set(scrobble.tags.names()) == {"point-to-point"} + + def test_tags_out_and_back_trail_and_scrobble(self, user, tmp_path): + gpx_path = tmp_path / "loop.gpx" + gpx_path.write_text(LOOP_GPX) + import_trail_gpx(str(gpx_path), user.id) + trail = Trail.objects.get(title="Loop Walk") + assert set(trail.tags.names()) == {"out-and-back"} + scrobble = Scrobble.objects.get(trail=trail) + assert set(scrobble.tags.names()) == {"out-and-back"} + def test_dedup(self, user, sample_gpx_path): import_trail_gpx(sample_gpx_path, user.id) import_trail_gpx(sample_gpx_path, user.id) @@ -201,6 +273,85 @@ class TestFindRouteWaypoint: assert find_route_waypoint([]) is None +class TestDeriveDefaultTitle: + def test_returns_none_without_geo(self, db): + assert Trail.derive_default_title(None, None) is None + no_title = GeoLocation.objects.create(lat=1.0, lon=2.0) + assert Trail.derive_default_title(None, no_title) is None + + def test_returns_none_without_geo_titles(self, db): + start = GeoLocation.objects.create(lat=1.0, lon=2.0) + end = GeoLocation.objects.create(lat=3.0, lon=4.0) + assert Trail.derive_default_title(start, end) is None + + def test_point_to_point(self, db): + start = GeoLocation.objects.create(lat=1.0, lon=2.0, title="Start Point") + end = GeoLocation.objects.create(lat=3.0, lon=4.0, title="End Point") + assert Trail.derive_default_title(start, end) == "Start Point to End Point" + + def test_same_location(self, db): + start = GeoLocation.objects.create(lat=1.0, lon=2.0, title="The Trailhead") + end = GeoLocation.objects.create(lat=1.0, lon=2.0, title="The Trailhead") + assert Trail.derive_default_title(start, end) == "The Trailhead" + + def test_loop_within_tolerance(self, db): + start = GeoLocation.objects.create(lat=1.0, lon=2.0, title="The Loop") + end = GeoLocation.objects.create(lat=1.0005, lon=2.0005, title="The Loop End") + assert Trail.derive_default_title(start, end) == "The Loop" + + +class TestRouteTag: + def test_point_to_point(self, db): + start = GeoLocation.objects.create(lat=1.0, lon=2.0) + end = GeoLocation.objects.create(lat=3.0, lon=4.0) + trail = Trail.objects.create( + title="Point to Point", + trailhead_location=start, + trail_terminus_location=end, + ) + assert trail.route_tag == "point-to-point" + + def test_out_and_back_same_location(self, db): + start = GeoLocation.objects.create(lat=1.0, lon=2.0) + trail = Trail.objects.create( + title="Out and Back", + trailhead_location=start, + trail_terminus_location=start, + ) + assert trail.route_tag == "out-and-back" + + def test_out_and_back_within_jitter(self, db): + start = GeoLocation.objects.create(lat=1.0, lon=2.0) + end = GeoLocation.objects.create(lat=1.0005, lon=2.0005) + trail = Trail.objects.create( + title="Near Loop", trailhead_location=start, trail_terminus_location=end + ) + assert trail.route_tag == "out-and-back" + + def test_none_without_terminus(self, db): + start = GeoLocation.objects.create(lat=1.0, lon=2.0) + trail = Trail.objects.create(title="No Terminus", trailhead_location=start) + assert trail.route_tag is None + + def test_signal_tags_on_create(self, db): + start = GeoLocation.objects.create(lat=1.0, lon=2.0) + end = GeoLocation.objects.create(lat=3.0, lon=4.0) + trail = Trail.objects.create( + title="Auto Tagged", trailhead_location=start, trail_terminus_location=end + ) + assert set(Trail.objects.get(pk=trail.pk).tags.names()) == {"point-to-point"} + + def test_signal_updates_tag_when_terminus_changes(self, db): + start = GeoLocation.objects.create(lat=1.0, lon=2.0) + end = GeoLocation.objects.create(lat=3.0, lon=4.0) + trail = Trail.objects.create( + title="Changed Route", trailhead_location=start, trail_terminus_location=end + ) + trail.trail_terminus_location = start + trail.save(update_fields=["trail_terminus_location"]) + assert set(Trail.objects.get(pk=trail.pk).tags.names()) == {"out-and-back"} + + class TestFindByTrailhead: def test_exact_match(self, db): geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015) diff --git a/vrobbler/apps/scrobbles/importers/trail_gpx.py b/vrobbler/apps/scrobbles/importers/trail_gpx.py index e1f1408..4b107cb 100644 --- a/vrobbler/apps/scrobbles/importers/trail_gpx.py +++ b/vrobbler/apps/scrobbles/importers/trail_gpx.py @@ -237,7 +237,7 @@ def import_trail_gpx(file_path, user_id, original_filename=None): return [] first_lat, first_lon, _, first_time = points[0] - _, _, _, last_time = points[-1] + last_lat, last_lon, _, last_time = points[-1] if first_time is None: logger.warning(f"No timestamps in {file_path}") @@ -251,6 +251,12 @@ def import_trail_gpx(file_path, user_id, original_filename=None): defaults={"altitude": None}, ) + terminus_geo, _ = GeoLocation.objects.get_or_create( + lat=round(last_lat, 6), + lon=round(last_lon, 6), + defaults={"altitude": None}, + ) + trail = Trail.find_by_trailhead( first_lat, first_lon, route_lat=route_pt[0] if route_pt else None, @@ -259,11 +265,16 @@ def import_trail_gpx(file_path, user_id, original_filename=None): ) if not trail: trail = Trail.objects.create( - title=track_name, + title=Trail.derive_default_title(geo, terminus_geo) or track_name, trailhead_location=geo, + trail_terminus_location=terminus_geo, route_lat=route_pt[0] if route_pt else None, route_lon=route_pt[1] if route_pt else None, ) + elif not trail.trail_terminus_location: + trail.trail_terminus_location = terminus_geo + trail.save(update_fields=["trail_terminus_location"]) + trail.sync_route_tags() timestamp = first_time stop_timestamp = last_time @@ -329,7 +340,10 @@ def import_trail_gpx(file_path, user_id, original_filename=None): new_scrobbles.append(scrobble) created = Scrobble.objects.bulk_create(new_scrobbles) + route_tag = trail.route_tag logger.info(f"Created {len(created)} trail scrobbles") for scrobble in created: ScrobbleNtfyNotification(scrobble).send() + if route_tag: + scrobble.tags.add(route_tag) return created diff --git a/vrobbler/apps/scrobbles/signals.py b/vrobbler/apps/scrobbles/signals.py index 5971c70..22856b8 100644 --- a/vrobbler/apps/scrobbles/signals.py +++ b/vrobbler/apps/scrobbles/signals.py @@ -87,6 +87,17 @@ def add_tags_from_task_title(sender, instance, **kwargs): instance.tags.add(tag) +@receiver(post_save, sender=Scrobble) +def add_trail_route_tags(sender, instance, **kwargs): + if instance.media_type != Scrobble.MediaType.TRAIL: + return + if not instance.trail_id: + return + tag = instance.trail.route_tag + if tag: + instance.tags.add(tag) + + @receiver(post_save, sender=Scrobble) def reverse_geocode_on_scrobble_creation(sender, instance, created, **kwargs): if not created: diff --git a/vrobbler/apps/trails/apps.py b/vrobbler/apps/trails/apps.py index 2173f7c..f025feb 100644 --- a/vrobbler/apps/trails/apps.py +++ b/vrobbler/apps/trails/apps.py @@ -3,3 +3,6 @@ from django.apps import AppConfig class TrailsConfig(AppConfig): name = "trails" + + def ready(self): + import trails.signals # noqa diff --git a/vrobbler/apps/trails/models.py b/vrobbler/apps/trails/models.py index 73b2ffe..0da7935 100644 --- a/vrobbler/apps/trails/models.py +++ b/vrobbler/apps/trails/models.py @@ -12,6 +12,10 @@ from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin BNULL = {"blank": True, "null": True} +# Max distance in meters between two points still considered "the same spot", +# e.g. an out-and-back/loop end point near its start, or a trailhead/route match. +SAME_SPOT_TOLERANCE_M = 100 + def haversine(lat1, lon1, lat2, lon2): R = 6371000 @@ -21,6 +25,19 @@ def haversine(lat1, lon1, lat2, lon2): return R * 2 * asin(sqrt(a)) +def geo_locations_are_same_spot(start_geo, end_geo) -> bool: + if not start_geo or not end_geo: + return False + if start_geo.pk == end_geo.pk: + return True + if start_geo.title and end_geo.title and start_geo.title == end_geo.title: + return True + if start_geo.lat is None or end_geo.lat is None: + return False + distance = haversine(start_geo.lat, start_geo.lon, end_geo.lat, end_geo.lon) + return distance <= SAME_SPOT_TOLERANCE_M + + @dataclass class TrailLogData(BaseLogData, WithPeopleLogData): effort: Optional[str] = None @@ -36,6 +53,10 @@ class TrailLogData(BaseLogData, WithPeopleLogData): class Trail(ScrobblableMixin): + ROUTE_TAG_OUT_AND_BACK = "out-and-back" + ROUTE_TAG_POINT_TO_POINT = "point-to-point" + ROUTE_TAGS = (ROUTE_TAG_OUT_AND_BACK, ROUTE_TAG_POINT_TO_POINT) + class PrincipalType(models.TextChoices): WOODS = "WOODS" ROAD = "ROAD" @@ -96,7 +117,38 @@ class Trail(ScrobblableMixin): return trail @classmethod - def find_by_trailhead(cls, lat, lon, tolerance_m=100, route_lat=None, route_lon=None): + def derive_default_title(cls, start_geo, end_geo) -> Optional[str]: + if not start_geo or not end_geo: + return None + if not start_geo.title or not end_geo.title: + return None + if geo_locations_are_same_spot(start_geo, end_geo): + return start_geo.title + return f"{start_geo.title} to {end_geo.title}" + + @property + def route_tag(self) -> Optional[str]: + if not self.trailhead_location or not self.trail_terminus_location: + return None + if geo_locations_are_same_spot( + self.trailhead_location, self.trail_terminus_location + ): + return self.ROUTE_TAG_OUT_AND_BACK + return self.ROUTE_TAG_POINT_TO_POINT + + def sync_route_tags(self): + tag = self.route_tag + if not tag: + return + self.tags.add(tag) + for other in self.ROUTE_TAGS: + if other != tag: + self.tags.remove(other) + + @classmethod + def find_by_trailhead( + cls, lat, lon, tolerance_m=SAME_SPOT_TOLERANCE_M, route_lat=None, route_lon=None + ): candidates = cls.objects.filter( trailhead_location__isnull=False, ).select_related("trailhead_location") diff --git a/vrobbler/apps/trails/signals.py b/vrobbler/apps/trails/signals.py new file mode 100644 index 0000000..b946226 --- /dev/null +++ b/vrobbler/apps/trails/signals.py @@ -0,0 +1,9 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from trails.models import Trail + + +@receiver(post_save, sender=Trail) +def sync_trail_route_tags(sender, instance, **kwargs): + instance.sync_route_tags()