Compare commits

..

9 Commits
56.0 ... 56.4

Author SHA1 Message Date
c001248d1b [release] Bump to version 56.4
All checks were successful
build / test (push) Successful in 2m0s
deploy / test (push) Successful in 1m59s
deploy / build-and-deploy (push) Successful in 49s
- Add ability to do reverse address lookup on lat-long pairs
- Add address fields to GeoLocation
- Add better detail template for Disc Golf Courses
2026-06-21 01:04:57 -04:00
f1c777d4ef [discgolf] Add trail maps and addresses
Some checks failed
build / test (push) Has been cancelled
2026-06-21 01:02:43 -04:00
931488c288 [release] Bump to version 56.3
Some checks failed
build / test (push) Has been cancelled
deploy / test (push) Successful in 1m58s
deploy / build-and-deploy (push) Successful in 32s
- Fix bug in importer script from discgolf being added
2026-06-20 01:12:33 -04:00
ab897fd848 [importers] Just fix a smol bug 2026-06-20 01:12:14 -04:00
4f189b4d66 [release] Bump to version 56.2
Some checks failed
build / test (push) Has been cancelled
deploy / build-and-deploy (push) Has been cancelled
deploy / test (push) Has been cancelled
- Fix bug in creating people when importing course plays
2026-06-20 01:10:53 -04:00
1487504318 [discgolf] Fix bug in creating people 2026-06-20 01:10:34 -04:00
0655363a0d [release] Bump to version 56.1
All checks were successful
build / test (push) Successful in 2m4s
deploy / test (push) Successful in 2m0s
deploy / build-and-deploy (push) Successful in 49s
- Add tests to discgolf app
2026-06-20 00:55:35 -04:00
dccc80c615 [discgolf] Fix tests and naming scheme
Some checks failed
build / test (push) Has been cancelled
2026-06-20 00:55:16 -04:00
4f91d5b40b [tests] Fix broken birding tests 2026-06-20 00:42:46 -04:00
27 changed files with 824 additions and 25 deletions

View File

@ -88,7 +88,7 @@ fetching and simple saving.
*** Metadata sources
**** Scraper
* Backlog [0/22] :vrobbler:project:personal:
* Backlog [0/23] :vrobbler:project:personal:
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
:PROPERTIES:
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
@ -604,6 +604,39 @@ independent of the email flow it was originally creatdd for
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
** TODO [#C] What would it look like to add an MCP server to expose scrobbles and media items? :mcpserver:feature:
* Version 56.4 [3/3]
** DONE [#B] Add ability to do reverse address lookup on lat-long pairs :geolocations:feature:
:PROPERTIES:
:ID: 86c071ff-7638-41ba-6b65-1382df1cb5aa
:END:
** DONE [#B] Add address fields to GeoLocation :addresses:geolocation:
:PROPERTIES:
:ID: a55ae508-07ab-ccdd-e453-846bd3fca6fb
:END:
** DONE [#B] Add better detail template for Disc Golf Courses :discgolf:templates:
:PROPERTIES:
:ID: 12cee67c-f723-0fa3-848a-cbc6e4d65fc3
:END:
* Version 56.3 [1/1]
** DONE [#A] Fix bug in importer script from discgolf being added :bug:
:PROPERTIES:
:ID: c3733f96-18f1-eef8-f5d9-edaf97e35623
:END:
* Version 56.2 [1/1]
** DONE [#A] Fix bug in creating people when importing course plays :discgolf:bug:
:PROPERTIES:
:ID: 255e9886-098b-39ae-1077-25e43223660e
:END:
* Version 56.1 [1/1]
** DONE [#A] Add tests to discgolf app :discgolf:tests:
:PROPERTIES:
:ID: 28e8344e-c3cf-19af-ce1c-cb821d4fcb5f
:END:
* Version 56.0 [1/1]
** DONE [#B] Add DiscGolf as a scrobbleable media :discgolf:
:PROPERTIES:

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "56.0"
version = "56.4"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]

View File

View File

@ -0,0 +1,63 @@
import tempfile
import pytest
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.fixture
def user(db):
return User.objects.create(email="golfer@example.com")
@pytest.fixture
def udisc_singles_csv_content():
return """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total
Par,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,3,3,9
Alice,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",4,2,3,9
Bob,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,4,5,12
"""
@pytest.fixture
def udisc_singles_csv_file(udisc_singles_csv_content):
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
) as f:
f.write(udisc_singles_csv_content)
return f.name
@pytest.fixture
def udisc_teams_csv_content():
return """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total
Par,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,3,3,9
Alice+Bob,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",4,2,3,9
Charlie+Diana,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,4,5,12
"""
@pytest.fixture
def udisc_teams_csv_file(udisc_teams_csv_content):
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
) as f:
f.write(udisc_teams_csv_content)
return f.name
@pytest.fixture
def udisc_csv_no_par_content():
return """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total
Alice,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",4,2,3,9
"""
@pytest.fixture
def udisc_csv_no_par_file(udisc_csv_no_par_content):
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
) as f:
f.write(udisc_csv_no_par_content)
return f.name

View File

@ -0,0 +1,102 @@
from discgolf.models import DiscGolfCourse, DiscGolfLogData
from scrobbles.dataclasses import BaseLogData
class TestDiscGolfCourseModel:
def test_create_course(self, db):
course = DiscGolfCourse.objects.create(
title="Maple Hill",
layout_name="Mountains",
number_of_holes=18,
par_total=54,
par_per_hole={"hole_1": 3, "hole_2": 3},
)
assert course.uuid is not None
assert str(course) == "Maple Hill (Mountains)"
assert course.subtitle == "Mountains"
def test_subtitle_fallback(self, db):
course = DiscGolfCourse.objects.create(title="Maple Hill")
assert course.subtitle == ""
def test_logdata_cls(self, db):
course = DiscGolfCourse.objects.create(title="Maple Hill")
assert course.logdata_cls is DiscGolfLogData
assert issubclass(course.logdata_cls, BaseLogData)
def test_strings(self, db):
course = DiscGolfCourse.objects.create(title="Maple Hill")
assert course.strings.verb == "Playing"
assert course.strings.tags == "golf"
def test_primary_image_url(self, db):
course = DiscGolfCourse.objects.create(title="Maple Hill")
assert course.primary_image_url == ""
def test_get_absolute_url(self, db):
course = DiscGolfCourse.objects.create(title="Maple Hill")
url = course.get_absolute_url()
assert str(course.uuid) in url
assert url.startswith("/disc-golf/")
def test_find_or_create_new(self, db):
course = DiscGolfCourse.find_or_create(
"New Course", layout_name="Default"
)
assert course.title == "New Course"
assert course.layout_name == "Default"
def test_find_or_create_existing(self, db):
created = DiscGolfCourse.objects.create(
title="Existing", layout_name="Alpha"
)
found = DiscGolfCourse.find_or_create("Existing", layout_name="Beta")
assert found.id == created.id
assert found.layout_name == "Alpha"
def test_scrobbles_method(self, db, user):
from datetime import datetime
import pytz
from scrobbles.models import Scrobble
course = DiscGolfCourse.objects.create(title="Maple Hill")
dt1 = datetime(2026, 6, 15, 14, 0, 0, tzinfo=pytz.UTC)
dt2 = datetime(2026, 6, 14, 14, 0, 0, tzinfo=pytz.UTC)
s1 = Scrobble.objects.create(
user=user,
disc_golf_course=course,
media_type=Scrobble.MediaType.DISC_GOLF,
timestamp=dt1,
)
s2 = Scrobble.objects.create(
user=user,
disc_golf_course=course,
media_type=Scrobble.MediaType.DISC_GOLF,
timestamp=dt2,
)
qs = course.scrobbles(user.id)
assert list(qs) == [s1, s2]
class TestDiscGolfLogData:
def test_basic_logdata(self):
data = DiscGolfLogData()
assert data.scores is None
assert data.weather is None
assert data.fun_factor is None
assert data.course_name is None
def test_logdata_with_scores(self):
data = DiscGolfLogData(
scores={"Alice": {"person_id": 1, "total": 9}},
weather="Sunny",
fun_factor="High",
course_name="Maple Hill",
par=9,
round_type="Singles",
)
assert data.scores["Alice"]["total"] == 9
assert data.weather == "Sunny"
assert data.round_type == "Singles"

View File

@ -0,0 +1,150 @@
from unittest.mock import patch
from discgolf.models import DiscGolfCourse
from discgolf.utils import _parse_udisc_datetime, import_udisc_csv
from people.models import Person
from scrobbles.models import Scrobble
class TestParserHelpers:
def test_parse_udisc_datetime(self):
dt = _parse_udisc_datetime("Jun 15, 2026 10:00 AM")
assert dt is not None
assert dt.year == 2026
assert dt.month == 6
assert dt.day == 15
assert dt.hour == 10
assert dt.minute == 0
def test_parse_udisc_datetime_date_only(self):
dt = _parse_udisc_datetime("Jun 15, 2026")
assert dt is not None
assert dt.year == 2026
class TestImportUdiscCSV:
def test_import_singles_creates_course(self, user, udisc_singles_csv_file):
import_udisc_csv(udisc_singles_csv_file, user.id)
course = DiscGolfCourse.objects.filter(title="Maple Hill").first()
assert course is not None
assert course.layout_name == "Mountains"
assert course.number_of_holes == 3
assert course.par_total == 9
assert course.par_per_hole == {"hole_1": 3, "hole_2": 3, "hole_3": 3}
def test_import_singles_creates_scrobble(self, user, udisc_singles_csv_file):
import_udisc_csv(udisc_singles_csv_file, user.id)
assert Scrobble.objects.filter(source="uDisc").count() == 1
def test_import_singles_logdata(self, user, udisc_singles_csv_file):
import_udisc_csv(udisc_singles_csv_file, user.id)
scrobble = Scrobble.objects.filter(source="uDisc").first()
log = scrobble.log
assert log["course_name"] == "Maple Hill"
assert log["par"] == 9
assert log["round_type"] == "Singles"
assert "Alice" in log["scores"]
assert "Bob" in log["scores"]
assert log["scores"]["Alice"]["total"] == 9
assert log["scores"]["Bob"]["total"] == 12
def test_import_singles_creates_people(self, user, udisc_singles_csv_file):
import_udisc_csv(udisc_singles_csv_file, user.id)
assert Person.objects.filter(name="Alice").exists()
assert Person.objects.filter(name="Bob").exists()
def test_import_teams_creates_scrobble(self, user, udisc_teams_csv_file):
import_udisc_csv(udisc_teams_csv_file, user.id)
assert Scrobble.objects.filter(source="uDisc").count() == 1
def test_import_teams_logdata(self, user, udisc_teams_csv_file):
import_udisc_csv(udisc_teams_csv_file, user.id)
scrobble = Scrobble.objects.filter(source="uDisc").first()
assert scrobble.log["round_type"] == "Teams"
alice_bob = scrobble.log["scores"]["Alice+Bob"]
assert "person_ids" in alice_bob
assert len(alice_bob["person_ids"]) == 2
def test_import_creates_team_people(self, user, udisc_teams_csv_file):
import_udisc_csv(udisc_teams_csv_file, user.id)
assert Person.objects.filter(name="Alice").exists()
assert Person.objects.filter(name="Bob").exists()
assert Person.objects.filter(name="Charlie").exists()
assert Person.objects.filter(name="Diana").exists()
def test_import_teams_par_per_hole(self, user, udisc_teams_csv_file):
import_udisc_csv(udisc_teams_csv_file, user.id)
course = DiscGolfCourse.objects.get(title="Maple Hill")
assert course.par_per_hole == {"hole_1": 3, "hole_2": 3, "hole_3": 3}
def test_import_no_par_returns_empty(self, user, udisc_csv_no_par_file):
result = import_udisc_csv(udisc_csv_no_par_file, user.id)
assert result == []
def test_import_empty_csv(self, user, db):
import tempfile
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
) as f:
f.write("PlayerName,CourseName,LayoutName,StartDate,Hole1,Total\n")
path = f.name
result = import_udisc_csv(path, user.id)
assert result == []
def test_import_idempotent(self, user, udisc_singles_csv_file):
import_udisc_csv(udisc_singles_csv_file, user.id)
import_udisc_csv(udisc_singles_csv_file, user.id)
assert DiscGolfCourse.objects.filter(title="Maple Hill").count() == 1
assert Scrobble.objects.filter(source="uDisc").count() == 2
def test_import_course_defaults_only_on_create(
self, user, udisc_singles_csv_file
):
import_udisc_csv(udisc_singles_csv_file, user.id)
course = DiscGolfCourse.objects.get(title="Maple Hill")
assert course.layout_name == "Mountains"
course.layout_name = "Updated"
course.save()
import_udisc_csv(udisc_singles_csv_file, user.id)
course.refresh_from_db()
assert course.layout_name == "Updated"
@patch("discgolf.utils.ScrobbleNtfyNotification")
def test_import_sends_notification(self, mock_notification_class, user, udisc_singles_csv_file):
import_udisc_csv(udisc_singles_csv_file, user.id)
mock_notification_class.assert_called_once()
mock_notification_class.return_value.send.assert_called_once()
def test_import_hole_scores_per_player(self, user, udisc_singles_csv_file):
import_udisc_csv(udisc_singles_csv_file, user.id)
scrobble = Scrobble.objects.filter(source="uDisc").first()
alice = scrobble.log["scores"]["Alice"]
assert alice["hole_1"] == 4
assert alice["hole_2"] == 2
assert alice["hole_3"] == 3
bob = scrobble.log["scores"]["Bob"]
assert bob["hole_1"] == 3
assert bob["hole_2"] == 4
assert bob["hole_3"] == 5
def test_import_record_error_on_bad_data(self, user, db):
import tempfile
content = """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total
Par,,Mountains,"Jun 15, 2026 10:00 AM",3,3,3,9
"""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
) as f:
f.write(content)
path = f.name
errors = []
result = import_udisc_csv(path, user.id, record_error=errors.append)
assert len(result) == 1
course = DiscGolfCourse.objects.first()
assert course.title == ""

View File

@ -0,0 +1,58 @@
from datetime import datetime
import pytz
from django.contrib.auth import get_user_model
from django.test import Client
from django.urls import reverse
from discgolf.models import DiscGolfCourse
from scrobbles.models import Scrobble
User = get_user_model()
class TestDiscGolfCourseViews:
def _make_scrobble(self, user, course):
dt = datetime(2026, 6, 15, 14, 0, 0, tzinfo=pytz.UTC)
return Scrobble.objects.create(
user=user,
disc_golf_course=course,
media_type=Scrobble.MediaType.DISC_GOLF,
timestamp=dt,
)
def test_course_list_anonymous(self, db, user):
course = DiscGolfCourse.objects.create(title="Maple Hill")
self._make_scrobble(user, course)
client = Client()
response = client.get(reverse("discgolf:course_list"))
assert response.status_code == 200
def test_course_list_shows_course(self, db, user):
course = DiscGolfCourse.objects.create(title="Maple Hill")
self._make_scrobble(user, course)
client = Client()
response = client.get(reverse("discgolf:course_list"))
assert response.status_code == 200
assert "Maple Hill" in response.content.decode()
def test_course_detail_anonymous(self, db, user):
course = DiscGolfCourse.objects.create(title="Maple Hill")
self._make_scrobble(user, course)
client = Client()
response = client.get(
reverse("discgolf:course_detail", kwargs={"slug": course.uuid})
)
assert response.status_code == 200
def test_course_detail_shows_course(self, db, user):
course = DiscGolfCourse.objects.create(
title="Maple Hill", layout_name="Mountains"
)
self._make_scrobble(user, course)
client = Client()
response = client.get(
reverse("discgolf:course_detail", kwargs={"slug": course.uuid})
)
assert response.status_code == 200
assert "Maple Hill" in response.content.decode()

View File

@ -6,7 +6,7 @@ from scrobbles.admin import ScrobbleInline
@admin.register(DiscGolfCourse)
class DiscGolfCourseAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("title", "layout_name", "number_of_holes", "par_total")
list_display = ("title", "layout_name", "number_of_holes", "par_total", "pdga_slug", "udisc_id")
raw_id_fields = ("trail",)
search_fields = ("title", "layout_name")
inlines = [

View File

@ -3,6 +3,9 @@ from rest_framework import serializers
class DiscGolfCourseSerializer(serializers.HyperlinkedModelSerializer):
pdga_link = serializers.ReadOnlyField()
udisc_link = serializers.ReadOnlyField()
class Meta:
model = models.DiscGolfCourse
fields = "__all__"

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.29 on 2026-06-21 04:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("discgolf", "0003_discgolfcourse_par_per_hole"),
]
operations = [
migrations.AddField(
model_name="discgolfcourse",
name="pdga_slug",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="discgolfcourse",
name="udisc_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -39,6 +39,20 @@ class DiscGolfCourse(ScrobblableMixin):
trail = models.ForeignKey(
"trails.Trail", on_delete=models.DO_NOTHING, **BNULL
)
pdga_slug = models.CharField(max_length=255, **BNULL)
udisc_id = models.CharField(max_length=255, **BNULL)
@property
def pdga_link(self) -> str:
if self.pdga_slug:
return f"https://www.pdga.com/course-directory/course/{self.pdga_slug}/"
return ""
@property
def udisc_link(self) -> str:
if self.udisc_id:
return f"https://udisc.com/courses/{self.udisc_id}/"
return ""
def get_absolute_url(self) -> str:
return reverse("discgolf:course_detail", kwargs={"slug": self.uuid})
@ -71,6 +85,6 @@ class DiscGolfCourse(ScrobblableMixin):
def scrobbles(self, user_id):
Scrobble = apps.get_model("scrobbles", "Scrobble")
return Scrobble.objects.filter(user_id=user_id, disc_golf=self).order_by(
return Scrobble.objects.filter(user_id=user_id, disc_golf_course=self).order_by(
"-timestamp"
)

View File

@ -15,9 +15,12 @@ def _parse_udisc_datetime(raw: str) -> datetime:
return parse_datetime(raw)
def _resolve_player(name: str) -> Person:
person, _ = Person.objects.get_or_create(name=name.strip())
return person
def _resolve_player(name: str, user_id: int) -> Person:
name = name.strip()
existing = Person.objects.filter(name=name, created_by_id=user_id).first()
if existing:
return existing
return Person.objects.create(name=name, created_by_id=user_id)
def import_udisc_csv(
@ -95,10 +98,10 @@ def import_udisc_csv(
if is_teams:
people = player_name.split("+")
person_ids = [_resolve_player(p.strip()).id for p in people]
person_ids = [_resolve_player(p.strip(), user_id).id for p in people]
hole_scores["person_ids"] = person_ids
else:
person = _resolve_player(player_name)
person = _resolve_player(player_name, user_id)
hole_scores["person_id"] = person.id
scores[player_name] = hole_scores

View File

@ -1,3 +1,5 @@
from django.apps import apps
from discgolf.models import DiscGolfCourse
from scrobbles.views import (
@ -13,3 +15,18 @@ class DiscGolfCourseListView(ScrobbleableListView):
class DiscGolfCourseDetailView(ScrobbleableDetailView, ChartContextMixin):
model = DiscGolfCourse
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
Scrobble = apps.get_model("scrobbles", "Scrobble")
context["trail_gpx_url"] = None
latest = (
Scrobble.objects.filter(
trail=self.object.trail, gpx_file__isnull=False
)
.order_by("-timestamp")
.first()
)
if latest and latest.gpx_file:
context["trail_gpx_url"] = latest.gpx_file.url
return context

View File

@ -1,4 +1,7 @@
import time
from django.contrib import admin
from django.http import HttpRequest
from locations.models import GeoLocation
@ -14,9 +17,29 @@ class GeoLocationAdmin(admin.ModelAdmin):
"lon",
"title",
"altitude",
"city",
"state_province",
"country",
)
ordering = ("-created",)
search_fields = ("title",)
actions = ["reverse_geocode_selected"]
inlines = [
ScrobbleInline,
]
@admin.action(description="Reverse geocode selected locations")
def reverse_geocode_selected(self, request: HttpRequest, queryset):
updated = 0
errors = 0
for i, location in enumerate(queryset.iterator()):
if location.reverse_geocode():
updated += 1
else:
errors += 1
if i < queryset.count() - 1:
time.sleep(1.1)
msg = f"Reverse geocoded {updated} locations"
if errors:
msg += f", {errors} failed"
self.message_user(request, msg)

View File

@ -0,0 +1,38 @@
# Generated by Django 4.2.29 on 2026-06-21 04:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("locations", "0010_clean_start"),
]
operations = [
migrations.AddField(
model_name="geolocation",
name="city",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="geolocation",
name="country",
field=models.CharField(blank=True, max_length=100, null=True),
),
migrations.AddField(
model_name="geolocation",
name="postal_code",
field=models.CharField(blank=True, max_length=20, null=True),
),
migrations.AddField(
model_name="geolocation",
name="state_province",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="geolocation",
name="street",
field=models.TextField(blank=True, null=True),
),
]

View File

@ -45,6 +45,11 @@ class GeoLocation(ScrobblableMixin):
truncated_lat = models.FloatField(**BNULL)
truncated_lon = models.FloatField(**BNULL)
altitude = models.FloatField(**BNULL)
street = models.TextField(**BNULL)
city = models.CharField(max_length=255, **BNULL)
state_province = models.CharField(max_length=255, **BNULL)
postal_code = models.CharField(max_length=20, **BNULL)
country = models.CharField(max_length=100, **BNULL)
class Meta:
unique_together = [["lat", "lon", "altitude"]]
@ -55,6 +60,11 @@ class GeoLocation(ScrobblableMixin):
return f"{self.lat} x {self.lon}"
@property
def display_address(self) -> str:
parts = [self.street, self.city, self.state_province, self.postal_code, self.country]
return ", ".join(p for p in parts if p)
def get_absolute_url(self):
return reverse("locations:geolocation_detail", kwargs={"slug": self.uuid})
@ -121,6 +131,17 @@ class GeoLocation(ScrobblableMixin):
return fetch_current_weather(self.lat, self.lon)
def reverse_geocode(self) -> bool:
from locations.utils import reverse_geocode
result = reverse_geocode(self.lat, self.lon)
if result is None:
return False
for field, value in result.items():
setattr(self, field, value)
self.save(update_fields=list(result.keys()))
return True
def loc_diff(self, old_lat_lon: tuple) -> tuple:
return (
abs(Decimal(old_lat_lon[0]) - Decimal(self.lat)),

View File

@ -201,6 +201,50 @@ def detect_movement(
return result
NOMINATIM_URL = "https://nominatim.openstreetmap.org/reverse"
USER_AGENT = "Vrobbler/1.0 (https://github.com/secstate/vrobbler)"
def reverse_geocode(lat: float, lon: float) -> Optional[dict]:
"""Reverse geocode lat/lon to an address using Nominatim.
Returns a dict with address fields, or None on failure.
Nominatim usage policy: max 1 request per second.
"""
params = {
"lat": lat,
"lon": lon,
"format": "json",
}
headers = {"User-Agent": USER_AGENT}
try:
resp = requests.get(NOMINATIM_URL, params=params, headers=headers, timeout=10)
resp.raise_for_status()
except requests.RequestException as e:
logger.warning("Failed to reverse geocode %s,%s: %s", lat, lon, e)
return None
data = resp.json()
if "error" in data:
logger.warning("Nominatim error for %s,%s: %s", lat, lon, data["error"])
return None
address = data.get("address", {})
return {
"street": address.get("road")
or address.get("pedestrian")
or address.get("footway"),
"city": address.get("city")
or address.get("town")
or address.get("village")
or address.get("hamlet"),
"state_province": address.get("state"),
"postal_code": address.get("postcode"),
"country": address.get("country"),
}
NWS_URL = "https://forecast.weather.gov/MapClick.php"

View File

@ -123,7 +123,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
"web_page",
"life_event",
"birding_location",
"disc_golf",
"disc_golf_course",
"long_play_last_scrobble",
)
list_filter = (
@ -185,5 +185,5 @@ class FavoriteMediaAdmin(admin.ModelAdmin):
"web_page",
"life_event",
"birding_location",
"disc_golf",
"disc_golf_course",
)

View File

@ -36,7 +36,7 @@ PLAY_AGAIN_MEDIA = {
"locations": "GeoLocation",
"videos": "Video",
"birds": "BirdingLocation",
"discgolf": "DiscGolf",
"discgolf": "DiscGolfCourse",
}
MEDIA_END_PADDING_SECONDS = {

View File

@ -93,6 +93,7 @@ def import_from_webdav_for_all_users(
bgstats_count,
ebird_count,
scale_count,
udisc_count,
extra={
"koreader": ko_count,
"trail_gpx": gpx_count,

View File

@ -0,0 +1,84 @@
# Generated by Django 4.2.29 on 2026-06-20 04:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0099_favoritemedia_disc_golf_scrobble_disc_golf_and_more"),
]
operations = [
migrations.RenameField(
model_name="favoritemedia",
old_name="disc_golf",
new_name="disc_golf_course",
),
migrations.RenameField(
model_name="scrobble",
old_name="disc_golf",
new_name="disc_golf_course",
),
migrations.AlterField(
model_name="favoritemedia",
name="media_type",
field=models.CharField(
choices=[
("Video", "Video"),
("Track", "Track"),
("PodcastEpisode", "Podcast episode"),
("SportEvent", "Sport event"),
("Book", "Book"),
("Paper", "Paper"),
("VideoGame", "Video game"),
("BoardGame", "Board game"),
("GeoLocation", "GeoLocation"),
("Trail", "Trail"),
("Beer", "Beer"),
("Puzzle", "Puzzle"),
("Food", "Food"),
("Task", "Task"),
("WebPage", "Web Page"),
("LifeEvent", "Life event"),
("Mood", "Mood"),
("BrickSet", "Brick set"),
("Channel", "Channel"),
("BirdingLocation", "Birding location"),
("DiscGolfCourse", "Disc golf"),
],
max_length=20,
),
),
migrations.AlterField(
model_name="scrobble",
name="media_type",
field=models.CharField(
choices=[
("Video", "Video"),
("Track", "Track"),
("PodcastEpisode", "Podcast episode"),
("SportEvent", "Sport event"),
("Book", "Book"),
("Paper", "Paper"),
("VideoGame", "Video game"),
("BoardGame", "Board game"),
("GeoLocation", "GeoLocation"),
("Trail", "Trail"),
("Beer", "Beer"),
("Puzzle", "Puzzle"),
("Food", "Food"),
("Task", "Task"),
("WebPage", "Web Page"),
("LifeEvent", "Life event"),
("Mood", "Mood"),
("BrickSet", "Brick set"),
("Channel", "Channel"),
("BirdingLocation", "Birding location"),
("DiscGolfCourse", "Disc golf"),
],
default="Video",
max_length=20,
),
),
]

View File

@ -693,7 +693,7 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
"BrickSet": ("brick_set",),
"Channel": ("channel",),
"BirdingLocation": ("birding_location",),
"DiscGolf": ("disc_golf",),
"DiscGolfCourse": ("disc_golf_course",),
}
@ -721,7 +721,7 @@ class ScrobbleQuerySet(models.QuerySet):
"mood",
"brick_set",
"birding_location",
"disc_golf",
"disc_golf_course",
)
def with_related_for_types(self, media_types: list[str]):
@ -771,7 +771,8 @@ class Scrobble(TimeStampedModel):
MOOD = "Mood", "Mood"
BRICKSET = "BrickSet", "Brick set"
CHANNEL = "Channel", "Channel"
DISC_GOLF = "DiscGolf", "Disc golf"
BIRDING_LOCATION = "BirdingLocation", "Birding location"
DISC_GOLF = "DiscGolfCourse", "Disc golf"
@classmethod
def list(cls):
@ -802,7 +803,7 @@ class Scrobble(TimeStampedModel):
birding_location = models.ForeignKey(
BirdingLocation, on_delete=models.DO_NOTHING, **BNULL
)
disc_golf = models.ForeignKey(
disc_golf_course = models.ForeignKey(
DiscGolfCourse, on_delete=models.DO_NOTHING, **BNULL
)
media_type = models.CharField(
@ -1376,8 +1377,8 @@ class Scrobble(TimeStampedModel):
media_obj = self.channel
if self.birding_location:
media_obj = self.birding_location
if self.disc_golf:
media_obj = self.disc_golf
if self.disc_golf_course:
media_obj = self.disc_golf_course
return media_obj
def __str__(self):
@ -1936,7 +1937,7 @@ class FavoriteMedia(TimeStampedModel):
birding_location = models.ForeignKey(
BirdingLocation, on_delete=models.CASCADE, **BNULL
)
disc_golf = models.ForeignKey(
disc_golf_course = models.ForeignKey(
DiscGolfCourse, on_delete=models.CASCADE, **BNULL
)
media_type = models.CharField(max_length=20, choices=Scrobble.MediaType.choices)
@ -1989,8 +1990,8 @@ class FavoriteMedia(TimeStampedModel):
media_obj = self.channel
if self.birding_location:
media_obj = self.birding_location
if self.disc_golf:
media_obj = self.disc_golf
if self.disc_golf_course:
media_obj = self.disc_golf_course
return media_obj
@classmethod
@ -2020,7 +2021,7 @@ class FavoriteMedia(TimeStampedModel):
"Mood": "mood",
"BrickSet": "brick_set",
"BirdingLocation": "birding_location",
"DiscGolf": "disc_golf",
"DiscGolfCourse": "disc_golf_course",
}
fk = fk_map.get(media_type)

View File

@ -10,6 +10,7 @@ from scrobbles.tasks import (
add_favorite_to_mopidy_playlist,
CHARTABLE_MEDIA_TYPES,
remove_favorite_from_mopidy_playlist,
reverse_geocode_geolocation,
SCROBBLES_WITHOUT_CHARTS,
update_charts_for_timestamp,
)
@ -86,6 +87,31 @@ def add_tags_from_task_title(sender, instance, **kwargs):
instance.tags.add(tag)
@receiver(post_save, sender=Scrobble)
def reverse_geocode_on_scrobble_creation(sender, instance, created, **kwargs):
if not created:
return
if not instance.geo_location_id:
logger.info(
"Skipping reverse geocode: scrobble %s has no geo_location",
instance.id,
)
return
if instance.geo_location.postal_code:
logger.info(
"Skipping reverse geocode: geo_location %s already has postal_code %s",
instance.geo_location_id,
instance.geo_location.postal_code,
)
return
logger.info(
"Enqueuing reverse geocode for geo_location %s",
instance.geo_location_id,
)
reverse_geocode_geolocation.delay(instance.geo_location_id)
@receiver(post_save, sender=FavoriteMedia)
def add_to_mopidy_playlist_on_favorite(sender, instance, created, **kwargs):
if not created:

View File

@ -726,3 +726,41 @@ def add_scrobble_to_mopidy_monthly_playlist(scrobble_id):
break
add_track_to_mopidy_monthly_playlist(scrobble)
@shared_task
def reverse_geocode_geolocation(geo_location_id):
from locations.models import GeoLocation
location = GeoLocation.objects.filter(id=geo_location_id).first()
if not location:
logger.info(
"Skipping reverse geocode: geo_location %s not found",
geo_location_id,
)
return
if location.postal_code:
logger.info(
"Skipping reverse geocode: geo_location %s already has postal_code %s",
geo_location_id,
location.postal_code,
)
return
logger.info(
"Reverse geocoding geo_location %s (%s, %s)",
geo_location_id,
location.lat,
location.lon,
)
if location.reverse_geocode():
logger.info(
"Reverse geocode succeeded for geo_location %s: %s",
geo_location_id,
location.display_address,
)
else:
logger.warning(
"Reverse geocode failed for geo_location %s",
geo_location_id,
)

View File

@ -1115,7 +1115,7 @@ def toggle_favorite(request, media_type, object_id):
"Mood": ("moods", "Mood"),
"BrickSet": ("bricksets", "BrickSet"),
"BirdingLocation": ("birds", "BirdingLocation"),
"DiscGolf": ("discgolf", "DiscGolfCourse"),
"DiscGolfCourse": ("discgolf", "DiscGolfCourse"),
}
app_label, model_name = app_model_map.get(media_type, (None, None))

View File

@ -3,6 +3,15 @@
{% block title %}{{object.title}}{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
#map {
height: 400px;
}
</style>
{% endblock %}
{% block lists %}
{% if object.description %}
<div class="row">
@ -11,6 +20,30 @@
</div>
</div>
{% endif %}
{% if trail_gpx_url %}
<div class="row">
<div class="col-md mb-3">
<div id="map"></div>
</div>
</div>
{% endif %}
<div class="row">
<div class="col-md">
{% if object.trail.trailhead_location.display_address %}
<p>{{ object.trail.trailhead_location.display_address }}</p>
{% endif %}
{% if object.pdga_link or object.udisc_link %}
<p>
{% if object.pdga_link %}
<a href="{{ object.pdga_link }}" target="_blank">PDGA</a>
{% endif %}
{% if object.udisc_link %}
<a href="{{ object.udisc_link }}" target="_blank">uDisc</a>
{% endif %}
</p>
{% endif %}
</div>
</div>
{% if charts %}
<div class="row">
<div class="col-md">
@ -42,3 +75,27 @@
</div>
</div>
{% endblock %}
{% block extra_js %}
{{ block.super }}
{% if trail_gpx_url %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet-gpx@2.2.0/gpx.min.js"></script>
<script>
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
referrerPolicy: 'origin'
}).addTo(map);
var gpx = new L.GPX("{{ trail_gpx_url|escapejs }}", {
async: true,
polyline_options: { color: '#e74c3c' }
});
gpx.on('loaded', function(e) {
map.fitBounds(e.target.getBounds());
});
gpx.addTo(map);
</script>
{% endif %}
{% endblock %}

View File

@ -82,8 +82,8 @@
{% endif %}
<h3><a href="{% url 'discgolf:course_list' %}">Disc Golf</a></h3>
{% if DiscGolf %}
{% with scrobbles=DiscGolf count=DiscGolf_count time=DiscGolf_time %}
{% if DiscGolfCourse %}
{% with scrobbles=DiscGolfCourse count=DiscGolfCourse_count time=DiscGolfCourse_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% else %}