[trails] Add geo-derived names, terminus and route tags to trail imports
This commit is contained in:
15
PROJECT.org
15
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 "<Start title> to <End title>" unless the start and end are the
|
||||
same (loop or out and back) in which case, it should just be the "<start
|
||||
title>". 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
|
||||
|
||||
@ -22,6 +22,18 @@ SAMPLE_GPX = os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "data", "sample_trail.gpx"
|
||||
)
|
||||
|
||||
LOOP_GPX = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<trk>
|
||||
<name>Loop Walk</name>
|
||||
<trkseg>
|
||||
<trkpt lat="40.000000" lon="-75.000000"><ele>100</ele><time>2022-01-01T10:00:00Z</time></trkpt>
|
||||
<trkpt lat="40.000100" lon="-75.000100"><ele>105</ele><time>2022-01-01T10:10:00Z</time></trkpt>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>
|
||||
"""
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -3,3 +3,6 @@ from django.apps import AppConfig
|
||||
|
||||
class TrailsConfig(AppConfig):
|
||||
name = "trails"
|
||||
|
||||
def ready(self):
|
||||
import trails.signals # noqa
|
||||
|
||||
@ -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")
|
||||
|
||||
9
vrobbler/apps/trails/signals.py
Normal file
9
vrobbler/apps/trails/signals.py
Normal file
@ -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()
|
||||
Reference in New Issue
Block a user