Files
vrobbler/vrobbler/apps/trails/models.py
2026-05-21 18:49:39 -04:00

123 lines
3.9 KiB
Python

from dataclasses import dataclass
from math import asin, cos, radians, sin, sqrt
from typing import Optional
from django.apps import apps
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from locations.models import GeoLocation
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True}
def haversine(lat1, lon1, lat2, lon2):
R = 6371000
dlat = radians(lat2 - lat1)
dlon = radians(lon2 - lon1)
a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2
return R * 2 * asin(sqrt(a))
@dataclass
class TrailLogData(BaseLogData, WithPeopleLogData):
effort: Optional[str] = None
difficulty: Optional[str] = None
distance_km: Optional[float] = None
elevation_gain_m: Optional[float] = None
moving_time_seconds: Optional[int] = None
activity_type: Optional[str] = None
avg_speed_kmh: Optional[float] = None
avg_heartrate: Optional[int] = None
max_heartrate: Optional[int] = None
calories: Optional[int] = None
class Trail(ScrobblableMixin):
class PrincipalType(models.TextChoices):
WOODS = "WOODS"
ROAD = "ROAD"
BEACH = "BEACH"
MOUNTAIN = "MOUNTAIN"
class ActivityType(models.TextChoices):
WALK = "WALK"
HIKE = "HIKE"
RUN = "RUN"
BIKE = "BIKE"
description = models.TextField(**BNULL)
trailhead_location = models.ForeignKey(
GeoLocation,
related_name="trailheads",
on_delete=models.DO_NOTHING,
**BNULL,
)
trail_terminus_location = models.ForeignKey(
GeoLocation,
related_name="trail_termini",
on_delete=models.DO_NOTHING,
**BNULL,
)
route_lat = models.FloatField(**BNULL)
route_lon = models.FloatField(**BNULL)
strava_id = models.CharField(max_length=255, **BNULL)
trailforks_id = models.CharField(max_length=255, **BNULL)
alltrails_id = models.CharField(max_length=255, **BNULL)
gaiagps_id = models.CharField(max_length=255, **BNULL)
principal_type = models.CharField(
max_length=10, choices=PrincipalType.choices, **BNULL
)
default_activity_type = models.CharField(
max_length=10, choices=ActivityType.choices, **BNULL
)
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("trails:trail_detail", kwargs={"slug": self.uuid})
@property
def logdata_cls(self):
return TrailLogData
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Moving", tags="runner")
@classmethod
def find_or_create(cls, title: str) -> "Trail":
trail = cls.objects.filter(title__iexact=title).first()
if not trail:
trail = cls.objects.create(title=title)
return trail
@classmethod
def find_by_trailhead(cls, lat, lon, tolerance_m=100, route_lat=None, route_lon=None):
candidates = cls.objects.filter(
trailhead_location__isnull=False,
).select_related("trailhead_location")
best = None
best_dist = float("inf")
for trail in candidates:
loc = trail.trailhead_location
d = haversine(lat, lon, loc.lat, loc.lon)
if d >= best_dist or d > tolerance_m:
continue
if route_lat is not None and trail.route_lat is not None:
route_dist = haversine(route_lat, route_lon, trail.route_lat, trail.route_lon)
if route_dist > tolerance_m:
continue
best = trail
best_dist = d
return best
def scrobbles(self, user_id):
Scrobble = apps.get_model("scrobbles", "Scrobble")
return Scrobble.objects.filter(user_id=user_id, life_event=self).order_by(
"-timestamp"
)