Compare commits

...

7 Commits
56.0 ... 56.3

Author SHA1 Message Date
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
16 changed files with 503 additions and 23 deletions

View File

@ -604,6 +604,24 @@ independent of the email flow it was originally creatdd for
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
* 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.3"
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

@ -71,6 +71,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

@ -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

@ -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

@ -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 %}