Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f189b4d66 | |||
| 1487504318 | |||
| 0655363a0d | |||
| dccc80c615 | |||
| 4f91d5b40b | |||
| cb01781615 | |||
| 1f5fada8b1 | |||
| 31888a85cb | |||
| 22d8b0787e | |||
| 8cc559752b | |||
| db3f9696fa | |||
| 407d570c82 | |||
| 033239260f | |||
| 9f854dc735 | |||
| f29272a853 | |||
| 4e56d9420a | |||
| 852a257159 |
119
PROJECT.org
119
PROJECT.org
@ -88,7 +88,7 @@ fetching and simple saving.
|
|||||||
*** Metadata sources
|
*** Metadata sources
|
||||||
**** Scraper
|
**** Scraper
|
||||||
|
|
||||||
* Backlog [0/21] :vrobbler:project:personal:
|
* Backlog [0/22] :vrobbler:project:personal:
|
||||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
||||||
@ -579,6 +579,18 @@ named constants for maintainability.
|
|||||||
- ~vrobbler/apps/scrobbles/importers/tsv.py~ (line 55) -- ="S"= completion status
|
- ~vrobbler/apps/scrobbles/importers/tsv.py~ (line 55) -- ="S"= completion status
|
||||||
|
|
||||||
|
|
||||||
|
** TODO [#A] Deduplicate BGG plays before posting :boardgames:bgg:duplication:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: e9b842bf-0049-42e7-a060-f3ebd0067d2f
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
No check for existing BGG plays before posting, which can create duplicates.
|
||||||
|
Should look up past plays by =bggeek_id= first.
|
||||||
|
|
||||||
|
File: ~vrobbler/apps/boardgames/bgg.py~ (line 117)
|
||||||
|
|
||||||
** TODO [#C] Clean up naming of =bgsplay= parsing :importers:refactoring:
|
** TODO [#C] Clean up naming of =bgsplay= parsing :importers:refactoring:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: c751dbbc-464a-4e63-9fe3-e034303f7b54
|
:ID: c751dbbc-464a-4e63-9fe3-e034303f7b54
|
||||||
@ -591,6 +603,111 @@ a helper method to create board game scrobbles given a json blob. It's
|
|||||||
independent of the email flow it was originally creatdd for
|
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 [#B] Is there way to create unique slugs for media instances :media_types:
|
||||||
|
|
||||||
|
* 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:
|
||||||
|
:ID: 8cdde5d3-0ae5-7d5a-99d2-200c86afae03
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
I have a csv file fro the uDisc disc golf scoring app that looks like:
|
||||||
|
|
||||||
|
Singles round. Note second row is the par for the course
|
||||||
|
#+begin_src csv
|
||||||
|
PlayerName,CourseName,LayoutName,StartDate,EndDate,Total,+/-,RoundRating,Hole1,Hole2,Hole3,Hole4,Hole5,Hole6,Hole7,Hole8,Hole9
|
||||||
|
Par,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,27,,,3,3,3,3,3,3,3,3,3
|
||||||
|
Colin Powell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,30,3,,2,3,4,4,3,4,3,3,4
|
||||||
|
Asa Sewell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,44,17,,5,4,4,8,5,5,4,4,5
|
||||||
|
Emma Sweet,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,41,14,,5,4,5,6,3,4,3,5,6
|
||||||
|
Jane Sewell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,44,17,,4,5,5,5,5,5,4,6,5
|
||||||
|
Nabby Sewell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,59,32,,6,6,7,7,6,7,6,6,8
|
||||||
|
Silas Sewell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,41,14,,5,5,4,5,3,5,4,4,6`
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
Teams of two or more persons. Note second row is the par for the course
|
||||||
|
#+begin_src csv
|
||||||
|
PlayerName,CourseName,LayoutName,StartDate,EndDate,Total,+/-,RoundRating,Hole1,Hole2,Hole3,Hole4,Hole5,Hole6,Hole7,Hole8,Hole9
|
||||||
|
Par,Peninsula Links,Main,2026-06-19 2322-0400,2026-06-19 2323-0400,27,,,3,3,3,3,3,3,3,3,3
|
||||||
|
Colin Powell + Asa Sewell,Peninsula Links,Main,2026-06-19 2322-0400,2026-06-19 2323-0400,29,2,,3,4,2,3,2,3,5,3,4
|
||||||
|
Emma Sweet + Jane Sewell,Peninsula Links,Main,2026-06-19 2322-0400,2026-06-19 2323-0400,28,1,,4,3,4,2,3,4,3,3,2
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
We should add a new app called discgolf that has the following data models:
|
||||||
|
|
||||||
|
- DiscGolfRound - scrobblable media + course_id, round_type (Singles, Teams)
|
||||||
|
- DiscGolfCourse - name, layoutname, number_of_holes
|
||||||
|
|
||||||
|
And the logdata for a DiscGolfOuting scrobble should have:
|
||||||
|
+ {person: {hole_number: score}, total: int}
|
||||||
|
+ {team: {name: "", people: [person, person], hole_number: score}, total: int}
|
||||||
|
+ weather
|
||||||
|
+ fun_factor (miserable, not great, so-so, good, excellent, party time)
|
||||||
|
|
||||||
|
|
||||||
|
* Version 55.6 [1/1]
|
||||||
|
** DONE [#A] Figure out why historical Lastfm imports don't work :importers:lastfm:music:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 71b18c1b-de96-6d93-20fa-de2ec0df1288
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 55.5 [1/1]
|
||||||
|
** DONE [#B] Fix bug in lastfm import for new users :importers:lastfm:music:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: d034966d-0c7f-e512-4cf8-7329c9026b6f
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 55.4 [1/1]
|
||||||
|
** DONE [#A] Tighten up the speed of startup and first request :perf:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 9ee8834c-6be2-d04b-df6d-56375504083f
|
||||||
|
:END:
|
||||||
|
|
||||||
|
|
||||||
|
* Version 55.3 [3/3]
|
||||||
|
** DONE [#C] =alt_names= feature for artists (commented out / dead code) :music:dead-code:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: e22060a2-5f7a-4f33-9056-309ecd27159c
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
File: ~vrobbler/apps/music/models.py~ (line 236)
|
||||||
|
|
||||||
|
An entire block of code for tracking alternate artist names is commented
|
||||||
|
out. The TODO questions whether it even works. Review: either implement
|
||||||
|
properly or remove the dead code.
|
||||||
|
|
||||||
|
** DONE [#A] Put chart rebuilds in a lower priority task queue :charts:tasks:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 43c90de0-fc1c-1139-dac7-9b7c82006b2e
|
||||||
|
:END:
|
||||||
|
** DONE [#A] Check for existing book scrobble and update page count :books:scrobbling:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 1a0609bc-6b16-4da4-96c1-59588229e4b4
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
File: ~vrobbler/apps/scrobbles/scrobblers.py~ (line 330)
|
||||||
|
|
||||||
|
When scrobbling a book (comic), the code doesn't check for prior scrobbles to
|
||||||
|
update reading progress. Needed for proper page-count tracking.
|
||||||
|
|
||||||
|
|
||||||
* Version 55.2 [2/2]
|
* Version 55.2 [2/2]
|
||||||
** DONE [#A] Fix bug in scrobble id in calendar view :templates:
|
** DONE [#A] Fix bug in scrobble id in calendar view :templates:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|||||||
2
Procfile
2
Procfile
@ -1,2 +1,2 @@
|
|||||||
web: python manage.py runserver 0.0.0.0:8014
|
web: python manage.py runserver 0.0.0.0:8014
|
||||||
worker: celery -A vrobbler worker -l DEBUG
|
worker: celery -A vrobbler worker -Q default,charts -l DEBUG
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "55.2"
|
version = "56.2"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
|
|||||||
0
tests/discgolf_tests/__init__.py
Normal file
0
tests/discgolf_tests/__init__.py
Normal file
63
tests/discgolf_tests/conftest.py
Normal file
63
tests/discgolf_tests/conftest.py
Normal 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
|
||||||
102
tests/discgolf_tests/test_models.py
Normal file
102
tests/discgolf_tests/test_models.py
Normal 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"
|
||||||
150
tests/discgolf_tests/test_utils.py
Normal file
150
tests/discgolf_tests/test_utils.py
Normal 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 == ""
|
||||||
58
tests/discgolf_tests/test_views.py
Normal file
58
tests/discgolf_tests/test_views.py
Normal 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()
|
||||||
@ -4,9 +4,15 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from vrobbler import context_processors
|
||||||
from vrobbler.context_processors import version_info
|
from vrobbler.context_processors import version_info
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_git_cache():
|
||||||
|
context_processors._GIT_COMMIT = None
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_request():
|
def mock_request():
|
||||||
return MagicMock()
|
return MagicMock()
|
||||||
|
|||||||
@ -0,0 +1,109 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-19 16:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("charts", "0002_chartrecord_charts_char_user_id_1adcde_idx_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("artist__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "artist"),
|
||||||
|
name="unique_chart_artist_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("album__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "album"),
|
||||||
|
name="unique_chart_album_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("track__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "track"),
|
||||||
|
name="unique_chart_track_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("tv_series__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "tv_series"),
|
||||||
|
name="unique_chart_tv_series_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("video__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "video"),
|
||||||
|
name="unique_chart_video_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("podcast__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "podcast"),
|
||||||
|
name="unique_chart_podcast_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("podcast_episode__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "podcast_episode"),
|
||||||
|
name="unique_chart_podcast_episode_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("board_game__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "board_game"),
|
||||||
|
name="unique_chart_board_game_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("trail__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "trail"),
|
||||||
|
name="unique_chart_trail_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("geo_location__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "geo_location"),
|
||||||
|
name="unique_chart_geo_location_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("food__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "food"),
|
||||||
|
name="unique_chart_food_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("book__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "book"),
|
||||||
|
name="unique_chart_book_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -2,6 +2,7 @@ import calendar
|
|||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
|
|
||||||
@ -84,6 +85,68 @@ class ChartRecord(TimeStampedModel):
|
|||||||
models.Index(fields=["user", "year", "month", "day", "album", "rank"]),
|
models.Index(fields=["user", "year", "month", "day", "album", "rank"]),
|
||||||
models.Index(fields=["user", "year", "month", "day", "tv_series", "rank"]),
|
models.Index(fields=["user", "year", "month", "day", "tv_series", "rank"]),
|
||||||
]
|
]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "artist"],
|
||||||
|
condition=Q(artist__isnull=False),
|
||||||
|
name="unique_chart_artist_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "album"],
|
||||||
|
condition=Q(album__isnull=False),
|
||||||
|
name="unique_chart_album_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "track"],
|
||||||
|
condition=Q(track__isnull=False),
|
||||||
|
name="unique_chart_track_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "tv_series"],
|
||||||
|
condition=Q(tv_series__isnull=False),
|
||||||
|
name="unique_chart_tv_series_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "video"],
|
||||||
|
condition=Q(video__isnull=False),
|
||||||
|
name="unique_chart_video_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "podcast"],
|
||||||
|
condition=Q(podcast__isnull=False),
|
||||||
|
name="unique_chart_podcast_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "podcast_episode"],
|
||||||
|
condition=Q(podcast_episode__isnull=False),
|
||||||
|
name="unique_chart_podcast_episode_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "board_game"],
|
||||||
|
condition=Q(board_game__isnull=False),
|
||||||
|
name="unique_chart_board_game_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "trail"],
|
||||||
|
condition=Q(trail__isnull=False),
|
||||||
|
name="unique_chart_trail_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "geo_location"],
|
||||||
|
condition=Q(geo_location__isnull=False),
|
||||||
|
name="unique_chart_geo_location_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "food"],
|
||||||
|
condition=Q(food__isnull=False),
|
||||||
|
name="unique_chart_food_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "book"],
|
||||||
|
condition=Q(book__isnull=False),
|
||||||
|
name="unique_chart_book_period",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def media_obj(self):
|
def media_obj(self):
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from typing import Optional
|
|||||||
import pytz
|
import pytz
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@ -186,60 +187,64 @@ def build_charts(
|
|||||||
ranks = {count: rank for rank, count in enumerate(unique_counts, start=1)}
|
ranks = {count: rank for rank, count in enumerate(unique_counts, start=1)}
|
||||||
|
|
||||||
media_field = f"{media_type}_id"
|
media_field = f"{media_type}_id"
|
||||||
records_to_create = []
|
|
||||||
records_to_update = []
|
|
||||||
|
|
||||||
existing = ChartRecord.objects.filter(
|
with transaction.atomic():
|
||||||
period_filter, user=user, **{media_field + "__isnull": False}
|
records_to_create = []
|
||||||
)
|
records_to_update = []
|
||||||
existing_by_media_id = {getattr(r, media_field): r for r in existing}
|
|
||||||
found_media_ids = set()
|
|
||||||
|
|
||||||
for result in results:
|
existing = ChartRecord.objects.select_for_update().filter(
|
||||||
media_id = result[config["values"]]
|
period_filter, user=user, **{media_field + "__isnull": False}
|
||||||
if media_id is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
found_media_ids.add(media_id)
|
|
||||||
|
|
||||||
chart_record_data = {
|
|
||||||
"user_id": user.id,
|
|
||||||
"year": year,
|
|
||||||
"month": month,
|
|
||||||
"week": week,
|
|
||||||
"day": day,
|
|
||||||
"rank": ranks[result["scrobble_count"]],
|
|
||||||
"count": result["scrobble_count"],
|
|
||||||
}
|
|
||||||
chart_record_data[media_field] = media_id
|
|
||||||
|
|
||||||
if media_id in existing_by_media_id:
|
|
||||||
existing_record = existing_by_media_id[media_id]
|
|
||||||
existing_record.rank = chart_record_data["rank"]
|
|
||||||
existing_record.count = chart_record_data["count"]
|
|
||||||
records_to_update.append(existing_record)
|
|
||||||
else:
|
|
||||||
records_to_create.append(ChartRecord(**chart_record_data))
|
|
||||||
|
|
||||||
ids_to_delete = [
|
|
||||||
r.id for r in existing if getattr(r, media_field) not in found_media_ids
|
|
||||||
]
|
|
||||||
if ids_to_delete:
|
|
||||||
ChartRecord.objects.filter(id__in=ids_to_delete).delete()
|
|
||||||
|
|
||||||
if records_to_update:
|
|
||||||
ChartRecord.objects.bulk_update(
|
|
||||||
records_to_update, ["rank", "count"], batch_size=500
|
|
||||||
)
|
)
|
||||||
|
existing_by_media_id = {getattr(r, media_field): r for r in existing}
|
||||||
|
found_media_ids = set()
|
||||||
|
|
||||||
if records_to_create:
|
for result in results:
|
||||||
ChartRecord.objects.bulk_create(records_to_create, batch_size=500)
|
media_id = result[config["values"]]
|
||||||
|
if media_id is None:
|
||||||
|
continue
|
||||||
|
|
||||||
logger.info(
|
found_media_ids.add(media_id)
|
||||||
f"Built {len(records_to_create)} new, {len(records_to_update)} updated "
|
|
||||||
f"chart records for {media_type}, period "
|
chart_record_data = {
|
||||||
f"{year}-{month or ''}-{week or ''}-{day or ''} for {user}"
|
"user_id": user.id,
|
||||||
)
|
"year": year,
|
||||||
|
"month": month,
|
||||||
|
"week": week,
|
||||||
|
"day": day,
|
||||||
|
"rank": ranks[result["scrobble_count"]],
|
||||||
|
"count": result["scrobble_count"],
|
||||||
|
}
|
||||||
|
chart_record_data[media_field] = media_id
|
||||||
|
|
||||||
|
if media_id in existing_by_media_id:
|
||||||
|
existing_record = existing_by_media_id[media_id]
|
||||||
|
existing_record.rank = chart_record_data["rank"]
|
||||||
|
existing_record.count = chart_record_data["count"]
|
||||||
|
records_to_update.append(existing_record)
|
||||||
|
else:
|
||||||
|
records_to_create.append(ChartRecord(**chart_record_data))
|
||||||
|
|
||||||
|
ids_to_delete = [
|
||||||
|
r.id
|
||||||
|
for r in existing
|
||||||
|
if getattr(r, media_field) not in found_media_ids
|
||||||
|
]
|
||||||
|
if ids_to_delete:
|
||||||
|
ChartRecord.objects.filter(id__in=ids_to_delete).delete()
|
||||||
|
|
||||||
|
if records_to_update:
|
||||||
|
ChartRecord.objects.bulk_update(
|
||||||
|
records_to_update, ["rank", "count"], batch_size=500
|
||||||
|
)
|
||||||
|
|
||||||
|
if records_to_create:
|
||||||
|
ChartRecord.objects.bulk_create(records_to_create, batch_size=500)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Built {len(records_to_create)} new, {len(records_to_update)} updated "
|
||||||
|
f"chart records for {media_type}, period "
|
||||||
|
f"{year}-{month or ''}-{week or ''}-{day or ''} for {user}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_yesterdays_charts(user, media_types: Optional[list] = None) -> None:
|
def build_yesterdays_charts(user, media_types: Optional[list] = None) -> None:
|
||||||
|
|||||||
0
vrobbler/apps/discgolf/__init__.py
Normal file
0
vrobbler/apps/discgolf/__init__.py
Normal file
14
vrobbler/apps/discgolf/admin.py
Normal file
14
vrobbler/apps/discgolf/admin.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from discgolf.models import DiscGolfCourse
|
||||||
|
from django.contrib import admin
|
||||||
|
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")
|
||||||
|
raw_id_fields = ("trail",)
|
||||||
|
search_fields = ("title", "layout_name")
|
||||||
|
inlines = [
|
||||||
|
ScrobbleInline,
|
||||||
|
]
|
||||||
0
vrobbler/apps/discgolf/api/__init__.py
Normal file
0
vrobbler/apps/discgolf/api/__init__.py
Normal file
11
vrobbler/apps/discgolf/api/serializers.py
Normal file
11
vrobbler/apps/discgolf/api/serializers.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from discgolf import models
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class DiscGolfCourseSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = models.DiscGolfCourse
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
13
vrobbler/apps/discgolf/api/views.py
Normal file
13
vrobbler/apps/discgolf/api/views.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from rest_framework import permissions, viewsets
|
||||||
|
|
||||||
|
from discgolf.api import serializers
|
||||||
|
from discgolf import models
|
||||||
|
|
||||||
|
|
||||||
|
class DiscGolfCourseViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = models.DiscGolfCourse.objects.all().order_by("-created")
|
||||||
|
serializer_class = serializers.DiscGolfCourseSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
6
vrobbler/apps/discgolf/apps.py
Normal file
6
vrobbler/apps/discgolf/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class DiscgolfConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "discgolf"
|
||||||
0
vrobbler/apps/discgolf/management/__init__.py
Normal file
0
vrobbler/apps/discgolf/management/__init__.py
Normal file
83
vrobbler/apps/discgolf/migrations/0001_initial.py
Normal file
83
vrobbler/apps/discgolf/migrations/0001_initial.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-20 04:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import taggit.managers
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||||
|
("scrobbles", "0098_scrobble_long_play_last_scrobble"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DiscGolfCourse",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True, verbose_name="created"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True, verbose_name="modified"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(
|
||||||
|
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("title", models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
("base_run_time_seconds", models.IntegerField(blank=True, null=True)),
|
||||||
|
("description", models.TextField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"layout_name",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
("number_of_holes", models.IntegerField(blank=True, null=True)),
|
||||||
|
("par_total", models.IntegerField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"genre",
|
||||||
|
taggit.managers.TaggableManager(
|
||||||
|
blank=True,
|
||||||
|
help_text="A comma-separated list of tags.",
|
||||||
|
through="scrobbles.ObjectWithGenres",
|
||||||
|
to="scrobbles.Genre",
|
||||||
|
verbose_name="Genre",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"tags",
|
||||||
|
taggit.managers.TaggableManager(
|
||||||
|
blank=True,
|
||||||
|
help_text="A comma-separated list of tags.",
|
||||||
|
through="taggit.TaggedItem",
|
||||||
|
to="taggit.Tag",
|
||||||
|
verbose_name="Tags",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-20 04:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("trails", "0009_trail_route_waypoint"),
|
||||||
|
("discgolf", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="discgolfcourse",
|
||||||
|
name="trail",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to="trails.trail",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-20 04:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("discgolf", "0002_discgolfcourse_trail"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="discgolfcourse",
|
||||||
|
name="par_per_hole",
|
||||||
|
field=models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
vrobbler/apps/discgolf/migrations/__init__.py
Normal file
0
vrobbler/apps/discgolf/migrations/__init__.py
Normal file
76
vrobbler/apps/discgolf/models.py
Normal file
76
vrobbler/apps/discgolf/models.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, TypedDict
|
||||||
|
|
||||||
|
from django.apps import apps
|
||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
|
||||||
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||||
|
|
||||||
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
class DiscGolfSingleScores(TypedDict, total=False):
|
||||||
|
person_id: int
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class DiscGolfTeamScores(TypedDict, total=False):
|
||||||
|
person_ids: list[int]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DiscGolfLogData(BaseLogData, WithPeopleLogData):
|
||||||
|
scores: Optional[dict[str, DiscGolfSingleScores | DiscGolfTeamScores]] = None
|
||||||
|
weather: Optional[str] = None
|
||||||
|
fun_factor: Optional[str] = None
|
||||||
|
course_name: Optional[str] = None
|
||||||
|
par: Optional[int] = None
|
||||||
|
round_type: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DiscGolfCourse(ScrobblableMixin):
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
layout_name = models.CharField(max_length=255, **BNULL)
|
||||||
|
number_of_holes = models.IntegerField(**BNULL)
|
||||||
|
par_total = models.IntegerField(**BNULL)
|
||||||
|
par_per_hole = models.JSONField(**BNULL)
|
||||||
|
trail = models.ForeignKey(
|
||||||
|
"trails.Trail", on_delete=models.DO_NOTHING, **BNULL
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return reverse("discgolf:course_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.title} ({self.layout_name or 'Default'})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logdata_cls(self):
|
||||||
|
return DiscGolfLogData
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self):
|
||||||
|
return self.layout_name or ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Playing", tags="golf")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_image_url(self) -> str:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create(cls, name: str, **defaults) -> "DiscGolfCourse":
|
||||||
|
course = cls.objects.filter(title=name).first()
|
||||||
|
if not course:
|
||||||
|
course = cls.objects.create(title=name, **defaults)
|
||||||
|
return course
|
||||||
|
|
||||||
|
def scrobbles(self, user_id):
|
||||||
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
return Scrobble.objects.filter(user_id=user_id, disc_golf_course=self).order_by(
|
||||||
|
"-timestamp"
|
||||||
|
)
|
||||||
14
vrobbler/apps/discgolf/urls.py
Normal file
14
vrobbler/apps/discgolf/urls.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from discgolf import views
|
||||||
|
|
||||||
|
app_name = "discgolf"
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("disc-golf/", views.DiscGolfCourseListView.as_view(), name="course_list"),
|
||||||
|
path(
|
||||||
|
"disc-golf/<slug:slug>/",
|
||||||
|
views.DiscGolfCourseDetailView.as_view(),
|
||||||
|
name="course_detail",
|
||||||
|
),
|
||||||
|
]
|
||||||
129
vrobbler/apps/discgolf/utils.py
Normal file
129
vrobbler/apps/discgolf/utils.py
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import csv
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from dateutil.parser import parse as parse_datetime
|
||||||
|
from django.utils import timezone
|
||||||
|
from people.models import Person
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_udisc_datetime(raw: str) -> datetime:
|
||||||
|
return parse_datetime(raw)
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
file_path: str, user_id: int, record_error=None
|
||||||
|
) -> list[Scrobble]:
|
||||||
|
from discgolf.models import DiscGolfCourse
|
||||||
|
|
||||||
|
new_scrobbles = []
|
||||||
|
|
||||||
|
with open(file_path, newline="", encoding="utf-8-sig") as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
rows = list(reader)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
par_row = None
|
||||||
|
player_rows = []
|
||||||
|
for row in rows:
|
||||||
|
name = row.get("PlayerName", "").strip()
|
||||||
|
if name.lower() == "par":
|
||||||
|
par_row = row
|
||||||
|
else:
|
||||||
|
player_rows.append(row)
|
||||||
|
|
||||||
|
if not par_row:
|
||||||
|
return []
|
||||||
|
|
||||||
|
course_name = par_row.get("CourseName", "").strip()
|
||||||
|
layout_name = par_row.get("LayoutName", "").strip()
|
||||||
|
start_date_raw = par_row.get("StartDate", "").strip()
|
||||||
|
start_dt = _parse_udisc_datetime(start_date_raw) if start_date_raw else timezone.now()
|
||||||
|
|
||||||
|
number_of_holes = sum(1 for k in par_row if k.startswith("Hole") and k[4:].isdigit())
|
||||||
|
par_total_str = par_row.get("Total", "").strip()
|
||||||
|
par_total = int(par_total_str) if par_total_str.isdigit() else None
|
||||||
|
|
||||||
|
par_per_hole = {}
|
||||||
|
for k, v in par_row.items():
|
||||||
|
if k.startswith("Hole") and k[4:].isdigit() and v:
|
||||||
|
hole_num = int(k[4:])
|
||||||
|
try:
|
||||||
|
par_per_hole[f"hole_{hole_num}"] = int(v)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
course, _ = DiscGolfCourse.objects.get_or_create(
|
||||||
|
title=course_name,
|
||||||
|
defaults={
|
||||||
|
"layout_name": layout_name,
|
||||||
|
"number_of_holes": number_of_holes,
|
||||||
|
"par_total": par_total,
|
||||||
|
"par_per_hole": par_per_hole or None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
is_teams = "+" in player_rows[0].get("PlayerName", "") if player_rows else False
|
||||||
|
round_type = "Teams" if is_teams else "Singles"
|
||||||
|
|
||||||
|
scores = {}
|
||||||
|
for row in player_rows:
|
||||||
|
player_name = row.get("PlayerName", "").strip()
|
||||||
|
hole_scores = {}
|
||||||
|
for k, v in row.items():
|
||||||
|
if k.startswith("Hole") and k[4:].isdigit() and v:
|
||||||
|
hole_num = int(k[4:])
|
||||||
|
try:
|
||||||
|
hole_scores[f"hole_{hole_num}"] = int(v)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
total_str = row.get("Total", "").strip()
|
||||||
|
total = int(total_str) if total_str.isdigit() else None
|
||||||
|
if total is not None:
|
||||||
|
hole_scores["total"] = total
|
||||||
|
|
||||||
|
if is_teams:
|
||||||
|
people = player_name.split("+")
|
||||||
|
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, user_id)
|
||||||
|
hole_scores["person_id"] = person.id
|
||||||
|
|
||||||
|
scores[player_name] = hole_scores
|
||||||
|
|
||||||
|
log = {
|
||||||
|
"scores": scores,
|
||||||
|
"course_name": course_name,
|
||||||
|
"par": par_total,
|
||||||
|
"round_type": round_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
scrobble_dict = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"timestamp": start_dt,
|
||||||
|
"source": "uDisc",
|
||||||
|
"playback_position_seconds": 0,
|
||||||
|
"log": log,
|
||||||
|
}
|
||||||
|
|
||||||
|
scrobble = Scrobble.create_or_update(course, user_id, scrobble_dict)
|
||||||
|
if scrobble:
|
||||||
|
new_scrobbles.append(scrobble)
|
||||||
|
ScrobbleNtfyNotification(scrobble).send()
|
||||||
|
|
||||||
|
return new_scrobbles
|
||||||
15
vrobbler/apps/discgolf/views.py
Normal file
15
vrobbler/apps/discgolf/views.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from discgolf.models import DiscGolfCourse
|
||||||
|
|
||||||
|
from scrobbles.views import (
|
||||||
|
ScrobbleableListView,
|
||||||
|
ScrobbleableDetailView,
|
||||||
|
ChartContextMixin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DiscGolfCourseListView(ScrobbleableListView):
|
||||||
|
model = DiscGolfCourse
|
||||||
|
|
||||||
|
|
||||||
|
class DiscGolfCourseDetailView(ScrobbleableDetailView, ChartContextMixin):
|
||||||
|
model = DiscGolfCourse
|
||||||
@ -1,8 +1,22 @@
|
|||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
from music.models import Artist, Album
|
from music.models import Artist, Album
|
||||||
|
|
||||||
|
CACHE_TTL = 300
|
||||||
|
|
||||||
|
|
||||||
def music_lists(request):
|
def music_lists(request):
|
||||||
|
artist_list = cache.get("music_lists_artist_list")
|
||||||
|
if artist_list is None:
|
||||||
|
artist_list = list(Artist.objects.all().only("id", "name"))
|
||||||
|
cache.set("music_lists_artist_list", artist_list, CACHE_TTL)
|
||||||
|
|
||||||
|
album_list = cache.get("music_lists_album_list")
|
||||||
|
if album_list is None:
|
||||||
|
album_list = list(Album.objects.all().only("id", "name"))
|
||||||
|
cache.set("music_lists_album_list", album_list, CACHE_TTL)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"artist_list": Artist.objects.all(),
|
"artist_list": artist_list,
|
||||||
"album_list": Album.objects.all(),
|
"album_list": album_list,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -236,19 +236,6 @@ class Artist(TimeStampedModel):
|
|||||||
)
|
)
|
||||||
artist.fix_metadata()
|
artist.fix_metadata()
|
||||||
|
|
||||||
# TODO: See if this alt_names stuff actually works or causes hard to debug problems
|
|
||||||
# If we did find our artist, but the found name is slightly differnt, record that
|
|
||||||
# if artist and alt_name:
|
|
||||||
# if not artist.alt_names:
|
|
||||||
# artist.alt_names = alt_name
|
|
||||||
# else:
|
|
||||||
# artist.alt_names += f"\\{alt_name}"
|
|
||||||
# logger.info(
|
|
||||||
# f"Add alt_name {alt_name} to artist {artist}",
|
|
||||||
# extra={"alt_name": alt_name, "artist_id": artist.id},
|
|
||||||
# )
|
|
||||||
# artist.save(update_fields=["alt_names"])
|
|
||||||
|
|
||||||
return artist
|
return artist
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from scrobbles.models import (
|
|||||||
Scrobble,
|
Scrobble,
|
||||||
ShareViewLog,
|
ShareViewLog,
|
||||||
TrailGPXImport,
|
TrailGPXImport,
|
||||||
|
UDiscCSVImport,
|
||||||
)
|
)
|
||||||
from scrobbles.mixins import Genre
|
from scrobbles.mixins import Genre
|
||||||
|
|
||||||
@ -73,6 +74,10 @@ class ScaleCSVImportAdmin(ImportBaseAdmin): ...
|
|||||||
class TrailGPXImportAdmin(ImportBaseAdmin): ...
|
class TrailGPXImportAdmin(ImportBaseAdmin): ...
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UDiscCSVImport)
|
||||||
|
class UDiscCSVImportAdmin(ImportBaseAdmin): ...
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Genre)
|
@admin.register(Genre)
|
||||||
class GenreAdmin(admin.ModelAdmin):
|
class GenreAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
@ -118,6 +123,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
"web_page",
|
"web_page",
|
||||||
"life_event",
|
"life_event",
|
||||||
"birding_location",
|
"birding_location",
|
||||||
|
"disc_golf_course",
|
||||||
"long_play_last_scrobble",
|
"long_play_last_scrobble",
|
||||||
)
|
)
|
||||||
list_filter = (
|
list_filter = (
|
||||||
@ -179,4 +185,5 @@ class FavoriteMediaAdmin(admin.ModelAdmin):
|
|||||||
"web_page",
|
"web_page",
|
||||||
"life_event",
|
"life_event",
|
||||||
"birding_location",
|
"birding_location",
|
||||||
|
"disc_golf_course",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -36,6 +36,7 @@ PLAY_AGAIN_MEDIA = {
|
|||||||
"locations": "GeoLocation",
|
"locations": "GeoLocation",
|
||||||
"videos": "Video",
|
"videos": "Video",
|
||||||
"birds": "BirdingLocation",
|
"birds": "BirdingLocation",
|
||||||
|
"discgolf": "DiscGolfCourse",
|
||||||
}
|
}
|
||||||
|
|
||||||
MEDIA_END_PADDING_SECONDS = {
|
MEDIA_END_PADDING_SECONDS = {
|
||||||
@ -73,6 +74,7 @@ MANUAL_SCROBBLE_FNS = {
|
|||||||
"-c": "manual_scrobble_book",
|
"-c": "manual_scrobble_book",
|
||||||
"-f": "manual_scrobble_food",
|
"-f": "manual_scrobble_food",
|
||||||
"-h": "manual_scrobble_twitch_channel",
|
"-h": "manual_scrobble_twitch_channel",
|
||||||
|
"-dg": "manual_scrobble_discgolf",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import pytz
|
import pytz
|
||||||
|
from django.core.cache import cache
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from scrobbles.constants import EXCLUDE_FROM_NOW_PLAYING
|
from scrobbles.constants import EXCLUDE_FROM_NOW_PLAYING
|
||||||
@ -19,6 +20,8 @@ MONTH_COLORS = [
|
|||||||
"#db7a7a", # Dec
|
"#db7a7a", # Dec
|
||||||
]
|
]
|
||||||
|
|
||||||
|
CACHE_TTL = 60
|
||||||
|
|
||||||
|
|
||||||
def month_color(request):
|
def month_color(request):
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@ -27,15 +30,25 @@ def month_color(request):
|
|||||||
|
|
||||||
def now_playing(request):
|
def now_playing(request):
|
||||||
user = request.user
|
user = request.user
|
||||||
now = timezone.now()
|
|
||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return {}
|
return {}
|
||||||
return {
|
|
||||||
"now_playing_list": Scrobble.objects.filter(
|
cache_key = f"now_playing_list_{user.id}"
|
||||||
in_progress=True,
|
now_playing_list = cache.get(cache_key)
|
||||||
is_paused=False,
|
if now_playing_list is None:
|
||||||
user=user,
|
now_playing_list = list(
|
||||||
).exclude(
|
Scrobble.objects.filter(
|
||||||
media_type__in=EXCLUDE_FROM_NOW_PLAYING,
|
in_progress=True,
|
||||||
|
is_paused=False,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
.exclude(
|
||||||
|
media_type__in=EXCLUDE_FROM_NOW_PLAYING,
|
||||||
|
)
|
||||||
|
.select_related("track", "video", "podcast_episode")
|
||||||
)
|
)
|
||||||
|
cache.set(cache_key, now_playing_list, CACHE_TTL)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"now_playing_list": now_playing_list,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ DEFAULT_RETROARCH_PATH = "var/retroarch/"
|
|||||||
DEFAULT_BGSTATS_PATH = "var/bgstats/"
|
DEFAULT_BGSTATS_PATH = "var/bgstats/"
|
||||||
DEFAULT_EBIRD_PATH = "var/ebird/"
|
DEFAULT_EBIRD_PATH = "var/ebird/"
|
||||||
DEFAULT_SCALE_PATH = "var/scale/"
|
DEFAULT_SCALE_PATH = "var/scale/"
|
||||||
|
DEFAULT_UDISC_PATH = "var/udisc/"
|
||||||
|
|
||||||
|
|
||||||
def import_from_webdav_for_all_users(
|
def import_from_webdav_for_all_users(
|
||||||
@ -48,6 +49,7 @@ def import_from_webdav_for_all_users(
|
|||||||
bgstats_count = 0
|
bgstats_count = 0
|
||||||
ebird_count = 0
|
ebird_count = 0
|
||||||
scale_count = 0
|
scale_count = 0
|
||||||
|
udisc_count = 0
|
||||||
|
|
||||||
for user_id in webdav_enabled_user_ids:
|
for user_id in webdav_enabled_user_ids:
|
||||||
client = get_webdav_client(user_id)
|
client = get_webdav_client(user_id)
|
||||||
@ -78,9 +80,13 @@ def import_from_webdav_for_all_users(
|
|||||||
)
|
)
|
||||||
logger.info("Scanning WebDAV scale for user %s", user_id)
|
logger.info("Scanning WebDAV scale for user %s", user_id)
|
||||||
scale_count += scan_webdav_for_scale(client, user_id)
|
scale_count += scan_webdav_for_scale(client, user_id)
|
||||||
|
logger.info("Scanning WebDAV udisc for user %s", user_id)
|
||||||
|
udisc_count += scan_webdav_for_udisc(
|
||||||
|
client, user_id, include_processed=include_processed
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Started %d KOReader, %d Trail GPX, %d Retroarch, %d BGStats, %d eBird, %d Scale WebDAV imports",
|
"Started %d KOReader, %d Trail GPX, %d Retroarch, %d BGStats, %d eBird, %d Scale, %d uDisc WebDAV imports",
|
||||||
ko_count,
|
ko_count,
|
||||||
gpx_count,
|
gpx_count,
|
||||||
retro_count,
|
retro_count,
|
||||||
@ -94,9 +100,10 @@ def import_from_webdav_for_all_users(
|
|||||||
"bgstats": bgstats_count,
|
"bgstats": bgstats_count,
|
||||||
"ebird": ebird_count,
|
"ebird": ebird_count,
|
||||||
"scale": scale_count,
|
"scale": scale_count,
|
||||||
|
"udisc": udisc_count,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return ko_count, gpx_count, retro_count, bgstats_count, ebird_count, scale_count
|
return ko_count, gpx_count, retro_count, bgstats_count, ebird_count, scale_count, udisc_count
|
||||||
|
|
||||||
|
|
||||||
def scan_webdav_for_koreader(
|
def scan_webdav_for_koreader(
|
||||||
@ -811,3 +818,115 @@ def scan_webdav_for_scale(webdav_client, user_id):
|
|||||||
os.unlink(tmp.name)
|
os.unlink(tmp.name)
|
||||||
|
|
||||||
return new_imports
|
return new_imports
|
||||||
|
|
||||||
|
|
||||||
|
def scan_webdav_for_udisc(webdav_client, user_id, include_processed=False):
|
||||||
|
"""Download .csv files from WebDAV var/udisc/ and queue imports for new files.
|
||||||
|
|
||||||
|
After importing, files are moved to var/udisc/processed/ so they are
|
||||||
|
not re-imported on subsequent scans unless *include_processed* is True.
|
||||||
|
"""
|
||||||
|
from scrobbles.models import UDiscCSVImport
|
||||||
|
from scrobbles.tasks import process_udisc_csv_import
|
||||||
|
|
||||||
|
udisc_path = DEFAULT_UDISC_PATH
|
||||||
|
try:
|
||||||
|
webdav_client.info(udisc_path)
|
||||||
|
except:
|
||||||
|
logger.info("No var/udisc/ directory on webdav", extra={"user_id": user_id})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
files = webdav_client.list(udisc_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Could not list var/udisc/",
|
||||||
|
extra={"user_id": user_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
processed_dir = f"{udisc_path}processed/"
|
||||||
|
try:
|
||||||
|
webdav_client.mkdir(processed_dir, recursive=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
new_imports = 0
|
||||||
|
already_imported = set(
|
||||||
|
UDiscCSVImport.objects.filter(user_id=user_id).values_list(
|
||||||
|
"original_filename", flat=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for fname in files:
|
||||||
|
fname = os.path.basename(fname)
|
||||||
|
if not fname.lower().endswith(".csv"):
|
||||||
|
continue
|
||||||
|
if fname == "processed":
|
||||||
|
continue
|
||||||
|
if fname in already_imported:
|
||||||
|
logger.debug(f"Skipping already-imported {fname}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
|
||||||
|
try:
|
||||||
|
webdav_client.download_sync(
|
||||||
|
remote_path=f"{udisc_path}{fname}", local_path=tmp.name
|
||||||
|
)
|
||||||
|
imp = UDiscCSVImport.objects.create(
|
||||||
|
user_id=user_id,
|
||||||
|
original_filename=fname,
|
||||||
|
)
|
||||||
|
with open(tmp.name, "rb") as f:
|
||||||
|
imp.csv_file.save(fname, f, save=True)
|
||||||
|
|
||||||
|
stem, ext = os.path.splitext(fname)
|
||||||
|
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||||
|
webdav_client.move(
|
||||||
|
f"{udisc_path}{fname}",
|
||||||
|
f"{processed_dir}{stem}_{ts}{ext}",
|
||||||
|
)
|
||||||
|
|
||||||
|
process_udisc_csv_import.delay(imp.id)
|
||||||
|
new_imports += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to import uDisc CSV file {fname}: {e}")
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp.name)
|
||||||
|
|
||||||
|
if include_processed:
|
||||||
|
try:
|
||||||
|
processed_files = webdav_client.list(processed_dir)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Could not list var/udisc/processed/",
|
||||||
|
extra={"user_id": user_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
return new_imports
|
||||||
|
|
||||||
|
for fname in processed_files:
|
||||||
|
fname = os.path.basename(fname)
|
||||||
|
if not fname.lower().endswith(".csv"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
|
||||||
|
try:
|
||||||
|
webdav_client.download_sync(
|
||||||
|
remote_path=f"{processed_dir}{fname}", local_path=tmp.name
|
||||||
|
)
|
||||||
|
imp = UDiscCSVImport.objects.create(
|
||||||
|
user_id=user_id,
|
||||||
|
original_filename=fname,
|
||||||
|
)
|
||||||
|
with open(tmp.name, "rb") as f:
|
||||||
|
imp.csv_file.save(fname, f, save=True)
|
||||||
|
process_udisc_csv_import.delay(imp.id)
|
||||||
|
new_imports += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to import processed uDisc CSV file {fname}: {e}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp.name)
|
||||||
|
|
||||||
|
return new_imports
|
||||||
|
|||||||
@ -0,0 +1,156 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-20 04:19
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import scrobbles.models
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("discgolf", "0001_initial"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("scrobbles", "0098_scrobble_long_play_last_scrobble"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="favoritemedia",
|
||||||
|
name="disc_golf",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="discgolf.discgolfcourse",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="disc_golf",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to="discgolf.discgolfcourse",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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"),
|
||||||
|
("DiscGolf", "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"),
|
||||||
|
("DiscGolf", "Disc golf"),
|
||||||
|
],
|
||||||
|
default="Video",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="UDiscCSVImport",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True, verbose_name="created"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True, verbose_name="modified"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("uuid", models.UUIDField(default=uuid.uuid4, editable=False)),
|
||||||
|
("processing_started", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("processed_finished", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("process_log", models.TextField(blank=True, null=True)),
|
||||||
|
("process_count", models.IntegerField(blank=True, null=True)),
|
||||||
|
("error_log", models.TextField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"csv_file",
|
||||||
|
models.FileField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
upload_to=scrobbles.models.UDiscCSVImport.get_path,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"original_filename",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
related_name="scrobbles_udisccsvimport_set",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "uDisc CSV Import",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -11,6 +11,7 @@ import pendulum
|
|||||||
import pytz
|
import pytz
|
||||||
from beers.models import Beer
|
from beers.models import Beer
|
||||||
from birds.models import BirdingLocation
|
from birds.models import BirdingLocation
|
||||||
|
from discgolf.models import DiscGolfCourse
|
||||||
from boardgames.models import BoardGame
|
from boardgames.models import BoardGame
|
||||||
from books.koreader import process_koreader_sqlite_file
|
from books.koreader import process_koreader_sqlite_file
|
||||||
from books.models import Book, BookLogData, BookPageLogData, Paper
|
from books.models import Book, BookLogData, BookPageLogData, Paper
|
||||||
@ -617,6 +618,60 @@ class EBirdCSVImport(BaseFileImportMixin):
|
|||||||
self.mark_finished()
|
self.mark_finished()
|
||||||
|
|
||||||
|
|
||||||
|
class UDiscCSVImport(BaseFileImportMixin):
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "uDisc CSV Import"
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.DO_NOTHING,
|
||||||
|
**BNULL,
|
||||||
|
related_name="scrobbles_udisccsvimport_set",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def import_type(self) -> str:
|
||||||
|
return "uDisc"
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse("scrobbles:udisc-csv-import-detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
def get_path(instance, filename):
|
||||||
|
extension = filename.split(".")[-1]
|
||||||
|
uuid = instance.uuid
|
||||||
|
return f"udisc-csv-uploads/{uuid}.{extension}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def upload_file_path(self):
|
||||||
|
if getattr(settings, "USE_S3_STORAGE"):
|
||||||
|
path = self.csv_file.url
|
||||||
|
else:
|
||||||
|
path = self.csv_file.path
|
||||||
|
return path
|
||||||
|
|
||||||
|
csv_file = models.FileField(upload_to=get_path, **BNULL)
|
||||||
|
original_filename = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
def process(self, force=False):
|
||||||
|
from discgolf.utils import import_udisc_csv
|
||||||
|
|
||||||
|
if self.processed_finished and not force:
|
||||||
|
logger.info(f"{self} already processed on {self.processed_finished}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.mark_started()
|
||||||
|
try:
|
||||||
|
scrobbles = import_udisc_csv(
|
||||||
|
self.upload_file_path, self.user_id, record_error=self.record_error
|
||||||
|
)
|
||||||
|
self.record_log(scrobbles)
|
||||||
|
except Exception as e:
|
||||||
|
self.record_error(f"Import failed: {e}")
|
||||||
|
logger.exception(f"Import failed for {self}")
|
||||||
|
finally:
|
||||||
|
self.mark_finished()
|
||||||
|
|
||||||
|
|
||||||
TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
|
TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
|
||||||
"Video": ("video",),
|
"Video": ("video",),
|
||||||
"Track": ("track", "track__artist_fk"),
|
"Track": ("track", "track__artist_fk"),
|
||||||
@ -638,6 +693,7 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
|
|||||||
"BrickSet": ("brick_set",),
|
"BrickSet": ("brick_set",),
|
||||||
"Channel": ("channel",),
|
"Channel": ("channel",),
|
||||||
"BirdingLocation": ("birding_location",),
|
"BirdingLocation": ("birding_location",),
|
||||||
|
"DiscGolfCourse": ("disc_golf_course",),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -665,6 +721,7 @@ class ScrobbleQuerySet(models.QuerySet):
|
|||||||
"mood",
|
"mood",
|
||||||
"brick_set",
|
"brick_set",
|
||||||
"birding_location",
|
"birding_location",
|
||||||
|
"disc_golf_course",
|
||||||
)
|
)
|
||||||
|
|
||||||
def with_related_for_types(self, media_types: list[str]):
|
def with_related_for_types(self, media_types: list[str]):
|
||||||
@ -715,6 +772,7 @@ class Scrobble(TimeStampedModel):
|
|||||||
BRICKSET = "BrickSet", "Brick set"
|
BRICKSET = "BrickSet", "Brick set"
|
||||||
CHANNEL = "Channel", "Channel"
|
CHANNEL = "Channel", "Channel"
|
||||||
BIRDING_LOCATION = "BirdingLocation", "Birding location"
|
BIRDING_LOCATION = "BirdingLocation", "Birding location"
|
||||||
|
DISC_GOLF = "DiscGolfCourse", "Disc golf"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def list(cls):
|
def list(cls):
|
||||||
@ -745,6 +803,9 @@ class Scrobble(TimeStampedModel):
|
|||||||
birding_location = models.ForeignKey(
|
birding_location = models.ForeignKey(
|
||||||
BirdingLocation, on_delete=models.DO_NOTHING, **BNULL
|
BirdingLocation, on_delete=models.DO_NOTHING, **BNULL
|
||||||
)
|
)
|
||||||
|
disc_golf_course = models.ForeignKey(
|
||||||
|
DiscGolfCourse, on_delete=models.DO_NOTHING, **BNULL
|
||||||
|
)
|
||||||
media_type = models.CharField(
|
media_type = models.CharField(
|
||||||
max_length=20, choices=MediaType.choices, default=MediaType.VIDEO
|
max_length=20, choices=MediaType.choices, default=MediaType.VIDEO
|
||||||
)
|
)
|
||||||
@ -1316,6 +1377,8 @@ class Scrobble(TimeStampedModel):
|
|||||||
media_obj = self.channel
|
media_obj = self.channel
|
||||||
if self.birding_location:
|
if self.birding_location:
|
||||||
media_obj = self.birding_location
|
media_obj = self.birding_location
|
||||||
|
if self.disc_golf_course:
|
||||||
|
media_obj = self.disc_golf_course
|
||||||
return media_obj
|
return media_obj
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@ -1874,6 +1937,9 @@ class FavoriteMedia(TimeStampedModel):
|
|||||||
birding_location = models.ForeignKey(
|
birding_location = models.ForeignKey(
|
||||||
BirdingLocation, on_delete=models.CASCADE, **BNULL
|
BirdingLocation, on_delete=models.CASCADE, **BNULL
|
||||||
)
|
)
|
||||||
|
disc_golf_course = models.ForeignKey(
|
||||||
|
DiscGolfCourse, on_delete=models.CASCADE, **BNULL
|
||||||
|
)
|
||||||
media_type = models.CharField(max_length=20, choices=Scrobble.MediaType.choices)
|
media_type = models.CharField(max_length=20, choices=Scrobble.MediaType.choices)
|
||||||
sent_to_mopidy = models.BooleanField(default=False)
|
sent_to_mopidy = models.BooleanField(default=False)
|
||||||
|
|
||||||
@ -1924,6 +1990,8 @@ class FavoriteMedia(TimeStampedModel):
|
|||||||
media_obj = self.channel
|
media_obj = self.channel
|
||||||
if self.birding_location:
|
if self.birding_location:
|
||||||
media_obj = self.birding_location
|
media_obj = self.birding_location
|
||||||
|
if self.disc_golf_course:
|
||||||
|
media_obj = self.disc_golf_course
|
||||||
return media_obj
|
return media_obj
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -1953,6 +2021,7 @@ class FavoriteMedia(TimeStampedModel):
|
|||||||
"Mood": "mood",
|
"Mood": "mood",
|
||||||
"BrickSet": "brick_set",
|
"BrickSet": "brick_set",
|
||||||
"BirdingLocation": "birding_location",
|
"BirdingLocation": "birding_location",
|
||||||
|
"DiscGolfCourse": "disc_golf_course",
|
||||||
}
|
}
|
||||||
|
|
||||||
fk = fk_map.get(media_type)
|
fk = fk_map.get(media_type)
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from books.models import Book, BookLogData, BookPageLogData
|
|||||||
from books.utils import parse_readcomicsonline_uri
|
from books.utils import parse_readcomicsonline_uri
|
||||||
from bricksets.models import BrickSet
|
from bricksets.models import BrickSet
|
||||||
from dateutil.parser import parse
|
from dateutil.parser import parse
|
||||||
|
from discgolf.models import DiscGolfCourse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from foods.models import Food
|
from foods.models import Food
|
||||||
from foods.sources.rscraper import RecipeScraperService
|
from foods.sources.rscraper import RecipeScraperService
|
||||||
@ -330,8 +331,6 @@ def manual_scrobble_book(
|
|||||||
|
|
||||||
source = READCOMICSONLINE_URL.replace("https://", "")
|
source = READCOMICSONLINE_URL.replace("https://", "")
|
||||||
|
|
||||||
# TODO: Check for scrobble of this book already and if so, update the page count
|
|
||||||
|
|
||||||
book = Book.find_or_create(title, url=url, enrich=True)
|
book = Book.find_or_create(title, url=url, enrich=True)
|
||||||
|
|
||||||
scrobble_dict = {
|
scrobble_dict = {
|
||||||
@ -1329,3 +1328,32 @@ def manual_scrobble_food(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return Scrobble.create_or_update(food, user_id, scrobble_dict)
|
return Scrobble.create_or_update(food, user_id, scrobble_dict)
|
||||||
|
|
||||||
|
|
||||||
|
def manual_scrobble_discgolf(
|
||||||
|
item_id: str,
|
||||||
|
user_id: int,
|
||||||
|
source: str = "Vrobbler",
|
||||||
|
action: Optional[str] = None,
|
||||||
|
):
|
||||||
|
from discgolf.models import DiscGolfCourse
|
||||||
|
|
||||||
|
course, _ = DiscGolfCourse.objects.get_or_create(title=item_id.strip())
|
||||||
|
|
||||||
|
scrobble_dict = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"timestamp": timezone.now(),
|
||||||
|
"playback_position_seconds": 0,
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[scrobblers] manual disc golf scrobble request received",
|
||||||
|
extra={
|
||||||
|
"course_id": course.id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"scrobble_dict": scrobble_dict,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return Scrobble.create_or_update(course, user_id, scrobble_dict)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
from django.db.models.signals import post_delete, post_save
|
from django.db.models.signals import post_delete, post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@ -52,6 +53,11 @@ def _update_charts_for_timestamp(user, ts):
|
|||||||
if ts is None:
|
if ts is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
lock_key = f"chart_update_{user.id}"
|
||||||
|
if not cache.add(lock_key, "locked", timeout=30):
|
||||||
|
logger.info(f"Chart update already queued for user {user.id}, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
if timezone.is_naive(ts):
|
if timezone.is_naive(ts):
|
||||||
ts = timezone.make_aware(ts)
|
ts = timezone.make_aware(ts)
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from charts.utils import (
|
|||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.cache import cache
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@ -170,6 +171,16 @@ def process_ebird_csv_import(import_id):
|
|||||||
birding_import.process()
|
birding_import.process()
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def process_udisc_csv_import(import_id):
|
||||||
|
UDiscCSVImport = apps.get_model("scrobbles", "UDiscCSVImport")
|
||||||
|
udisc_import = UDiscCSVImport.objects.filter(id=import_id).first()
|
||||||
|
if not udisc_import:
|
||||||
|
logger.warn(f"UDiscCSVImport not found with id {import_id}")
|
||||||
|
return
|
||||||
|
udisc_import.process()
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def process_scale_csv_import(import_id):
|
def process_scale_csv_import(import_id):
|
||||||
ScaleCSVImport = apps.get_model("scrobbles", "ScaleCSVImport")
|
ScaleCSVImport = apps.get_model("scrobbles", "ScaleCSVImport")
|
||||||
@ -241,6 +252,11 @@ def update_charts_for_timestamp(user_id, year, month, day, week):
|
|||||||
logger.error(f"User with id {user_id} not found")
|
logger.error(f"User with id {user_id} not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
lock_key = f"chart_update_running_{user_id}"
|
||||||
|
if not cache.add(lock_key, "locked", timeout=300):
|
||||||
|
logger.info(f"Chart update already running for user {user_id}, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
build_daily_charts(user, year, month, day, CHARTABLE_MEDIA_TYPES)
|
build_daily_charts(user, year, month, day, CHARTABLE_MEDIA_TYPES)
|
||||||
build_weekly_charts(user, year, week, CHARTABLE_MEDIA_TYPES)
|
build_weekly_charts(user, year, week, CHARTABLE_MEDIA_TYPES)
|
||||||
@ -250,6 +266,8 @@ def update_charts_for_timestamp(user_id, year, month, day, week):
|
|||||||
logger.info(f"[charts] Updated charts for {user} on {date_str}")
|
logger.info(f"[charts] Updated charts for {user} on {date_str}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[charts] Failed to update charts: {e}")
|
logger.error(f"[charts] Failed to update charts: {e}")
|
||||||
|
finally:
|
||||||
|
cache.delete(lock_key)
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
|
|||||||
@ -147,6 +147,11 @@ urlpatterns = [
|
|||||||
views.ScrobbleBirdingCSVImportDetailView.as_view(),
|
views.ScrobbleBirdingCSVImportDetailView.as_view(),
|
||||||
name="ebird-csv-import-detail",
|
name="ebird-csv-import-detail",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"imports/udisc-csv/<slug:slug>/",
|
||||||
|
views.ScrobbleUDiscCSVImportDetailView.as_view(),
|
||||||
|
name="udisc-csv-import-detail",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"long-plays/",
|
"long-plays/",
|
||||||
views.ScrobbleLongPlaysView.as_view(),
|
views.ScrobbleLongPlaysView.as_view(),
|
||||||
|
|||||||
@ -154,10 +154,11 @@ def import_lastfm_for_all_users(restart=False):
|
|||||||
last_processed = lfm_import.processed_finished
|
last_processed = lfm_import.processed_finished
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Not resuming failed LastFM import {lfm_import.id} for user {user_id}, use restart=True to restart"
|
"No existing LastFM import for user %s, "
|
||||||
"No existing LastFM import, we should start a monthly parsing of lastFm for this user going back to 2002"
|
"starting a full parse",
|
||||||
|
user_id,
|
||||||
)
|
)
|
||||||
continue
|
last_processed = None
|
||||||
|
|
||||||
lfm_client = LastFM(user=get_user_model().objects.filter(id=user_id).first())
|
lfm_client = LastFM(user=get_user_model().objects.filter(id=user_id).first())
|
||||||
|
|
||||||
|
|||||||
@ -87,6 +87,7 @@ from scrobbles.models import (
|
|||||||
ScrobbleQuerySet,
|
ScrobbleQuerySet,
|
||||||
ShareViewLog,
|
ShareViewLog,
|
||||||
TrailGPXImport,
|
TrailGPXImport,
|
||||||
|
UDiscCSVImport,
|
||||||
)
|
)
|
||||||
from scrobbles.scrobblers import *
|
from scrobbles.scrobblers import *
|
||||||
from scrobbles.tasks import (
|
from scrobbles.tasks import (
|
||||||
@ -535,6 +536,8 @@ class BaseScrobbleImportDetailView(DetailView):
|
|||||||
title = "Scale CSV Import"
|
title = "Scale CSV Import"
|
||||||
if self.model == TrailGPXImport:
|
if self.model == TrailGPXImport:
|
||||||
title = "Trail GPX Import"
|
title = "Trail GPX Import"
|
||||||
|
if self.model == UDiscCSVImport:
|
||||||
|
title = "uDisc CSV Import"
|
||||||
context_data["title"] = title
|
context_data["title"] = title
|
||||||
return context_data
|
return context_data
|
||||||
|
|
||||||
@ -571,6 +574,10 @@ class ScrobbleBirdingCSVImportDetailView(BaseScrobbleImportDetailView):
|
|||||||
model = EBirdCSVImport
|
model = EBirdCSVImport
|
||||||
|
|
||||||
|
|
||||||
|
class ScrobbleUDiscCSVImportDetailView(BaseScrobbleImportDetailView):
|
||||||
|
model = UDiscCSVImport
|
||||||
|
|
||||||
|
|
||||||
class ManualScrobbleView(FormView):
|
class ManualScrobbleView(FormView):
|
||||||
form_class = ScrobbleForm
|
form_class = ScrobbleForm
|
||||||
template_name = "scrobbles/manual_form.html"
|
template_name = "scrobbles/manual_form.html"
|
||||||
@ -1108,6 +1115,7 @@ def toggle_favorite(request, media_type, object_id):
|
|||||||
"Mood": ("moods", "Mood"),
|
"Mood": ("moods", "Mood"),
|
||||||
"BrickSet": ("bricksets", "BrickSet"),
|
"BrickSet": ("bricksets", "BrickSet"),
|
||||||
"BirdingLocation": ("birds", "BirdingLocation"),
|
"BirdingLocation": ("birds", "BirdingLocation"),
|
||||||
|
"DiscGolfCourse": ("discgolf", "DiscGolfCourse"),
|
||||||
}
|
}
|
||||||
|
|
||||||
app_label, model_name = app_model_map.get(media_type, (None, None))
|
app_label, model_name = app_model_map.get(media_type, (None, None))
|
||||||
|
|||||||
@ -1,8 +1,24 @@
|
|||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
from videos.models import Video, Series
|
from videos.models import Video, Series
|
||||||
|
|
||||||
|
CACHE_TTL = 300
|
||||||
|
|
||||||
|
|
||||||
def video_lists(request):
|
def video_lists(request):
|
||||||
|
movie_list = cache.get("video_lists_movie_list")
|
||||||
|
if movie_list is None:
|
||||||
|
movie_list = list(
|
||||||
|
Video.objects.filter(video_type=Video.VideoType.MOVIE).only("id", "title")
|
||||||
|
)
|
||||||
|
cache.set("video_lists_movie_list", movie_list, CACHE_TTL)
|
||||||
|
|
||||||
|
series_list = cache.get("video_lists_series_list")
|
||||||
|
if series_list is None:
|
||||||
|
series_list = list(Series.objects.all().only("id", "name"))
|
||||||
|
cache.set("video_lists_series_list", series_list, CACHE_TTL)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"movie_list": Video.objects.filter(video_type=Video.VideoType.MOVIE),
|
"movie_list": movie_list,
|
||||||
"series_list": Series.objects.all(),
|
"series_list": series_list,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,46 +4,55 @@ from importlib.metadata import version as get_version
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_GIT_COMMIT = None
|
||||||
|
|
||||||
|
|
||||||
def version_info(request):
|
def version_info(request):
|
||||||
|
global _GIT_COMMIT
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app_version = get_version("vrobbler")
|
app_version = get_version("vrobbler")
|
||||||
except Exception:
|
except Exception:
|
||||||
app_version = "unknown"
|
app_version = "unknown"
|
||||||
|
|
||||||
commit = os.environ.get("VROBBLER_COMMIT")
|
commit = os.environ.get("VROBBLER_COMMIT")
|
||||||
if not commit:
|
if commit:
|
||||||
# Try to import from _commit.py module first
|
return {"app_version": app_version, "git_commit": commit}
|
||||||
|
|
||||||
|
if _GIT_COMMIT is not None:
|
||||||
|
return {"app_version": app_version, "git_commit": _GIT_COMMIT}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from vrobbler._commit import commit as _commit
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
if _commit and _commit != "unknown":
|
||||||
|
_GIT_COMMIT = _commit
|
||||||
|
return {"app_version": app_version, "git_commit": _GIT_COMMIT}
|
||||||
|
|
||||||
|
commit_file = Path("/var/lib/vrobbler/commit.txt")
|
||||||
|
if commit_file.exists():
|
||||||
try:
|
try:
|
||||||
from vrobbler._commit import commit as _commit
|
commit = commit_file.read_text().strip()
|
||||||
except ImportError:
|
if commit:
|
||||||
|
_GIT_COMMIT = commit
|
||||||
|
return {"app_version": app_version, "git_commit": _GIT_COMMIT}
|
||||||
|
except OSError:
|
||||||
pass
|
pass
|
||||||
else:
|
|
||||||
if _commit and _commit != "unknown":
|
|
||||||
return {"app_version": app_version, "git_commit": _commit}
|
|
||||||
|
|
||||||
# Try to read from commit file (written during deploy)
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
commit_file = Path("/var/lib/vrobbler/commit.txt")
|
try:
|
||||||
if commit_file.exists():
|
_GIT_COMMIT = (
|
||||||
try:
|
subprocess.check_output(
|
||||||
commit = commit_file.read_text().strip()
|
["git", "rev-parse", "--short", "HEAD"],
|
||||||
if commit:
|
cwd=PROJECT_ROOT,
|
||||||
return {"app_version": app_version, "git_commit": commit}
|
stderr=subprocess.DEVNULL,
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fall back to git command
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
try:
|
|
||||||
commit = (
|
|
||||||
subprocess.check_output(
|
|
||||||
["git", "rev-parse", "--short", "HEAD"],
|
|
||||||
cwd=PROJECT_ROOT,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
.decode("utf-8")
|
|
||||||
.strip()
|
|
||||||
)
|
)
|
||||||
except (subprocess.SubprocessError, FileNotFoundError):
|
.decode("utf-8")
|
||||||
commit = "unknown"
|
.strip()
|
||||||
|
)
|
||||||
|
except (subprocess.SubprocessError, FileNotFoundError):
|
||||||
|
_GIT_COMMIT = "unknown"
|
||||||
|
|
||||||
return {"app_version": app_version, "git_commit": commit}
|
return {"app_version": app_version, "git_commit": _GIT_COMMIT}
|
||||||
|
|||||||
@ -122,6 +122,15 @@ CELERY_ACCEPT_CONTENT = ["json"]
|
|||||||
CELERY_RESULT_EXTENDED = True
|
CELERY_RESULT_EXTENDED = True
|
||||||
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
|
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
|
||||||
|
|
||||||
|
CELERY_TASK_CREATE_MISSING_QUEUES = True
|
||||||
|
CELERY_TASK_ROUTES = {
|
||||||
|
"scrobbles.tasks.update_charts_for_timestamp": {"queue": "charts"},
|
||||||
|
"scrobbles.tasks.create_yesterdays_charts": {"queue": "charts"},
|
||||||
|
"scrobbles.tasks.rebuild_weekly_charts": {"queue": "charts"},
|
||||||
|
"scrobbles.tasks.rebuild_monthly_charts": {"queue": "charts"},
|
||||||
|
"scrobbles.tasks.rebuild_yearly_charts": {"queue": "charts"},
|
||||||
|
}
|
||||||
|
|
||||||
CELERY_BEAT_SCHEDULE = {
|
CELERY_BEAT_SCHEDULE = {
|
||||||
"build-yesterdays-charts": {
|
"build-yesterdays-charts": {
|
||||||
"task": "scrobbles.tasks.create_yesterdays_charts",
|
"task": "scrobbles.tasks.create_yesterdays_charts",
|
||||||
@ -219,6 +228,7 @@ INSTALLED_APPS = [
|
|||||||
"foods",
|
"foods",
|
||||||
"lifeevents",
|
"lifeevents",
|
||||||
"moods",
|
"moods",
|
||||||
|
"discgolf",
|
||||||
"birds",
|
"birds",
|
||||||
"mathfilters",
|
"mathfilters",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
@ -352,11 +362,11 @@ USE_TZ = True
|
|||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
||||||
#
|
#
|
||||||
from storages.backends import s3boto3
|
|
||||||
|
|
||||||
USE_S3_STORAGE = os.getenv("VROBBLER_USE_S3", "False").lower() in TRUTHY
|
USE_S3_STORAGE = os.getenv("VROBBLER_USE_S3", "False").lower() in TRUTHY
|
||||||
|
|
||||||
if USE_S3_STORAGE:
|
if USE_S3_STORAGE:
|
||||||
|
from storages.backends import s3boto3
|
||||||
|
|
||||||
AWS_S3_ENDPOINT_URL = os.getenv("AWS_S3_ENDPOINT_URL", "")
|
AWS_S3_ENDPOINT_URL = os.getenv("AWS_S3_ENDPOINT_URL", "")
|
||||||
AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME", "")
|
AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME", "")
|
||||||
AWS_S3_ACCESS_KEY_ID = os.getenv("AWS_S3_ACCESS_KEY_ID")
|
AWS_S3_ACCESS_KEY_ID = os.getenv("AWS_S3_ACCESS_KEY_ID")
|
||||||
|
|||||||
44
vrobbler/templates/discgolf/discgolfcourse_detail.html
Normal file
44
vrobbler/templates/discgolf/discgolfcourse_detail.html
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{{object.title}}{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
{% if object.description %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<p>{{object.description}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if charts %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
{% include "scrobbles/_chart_links.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<h3>Last scrobbles</h3>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Date</th>
|
||||||
|
<th scope="col">Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for scrobble in scrobbles.all %}
|
||||||
|
<tr>
|
||||||
|
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
|
||||||
|
<td>{{scrobble.logdata.notes_as_str}}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
13
vrobbler/templates/discgolf/discgolfcourse_list.html
Normal file
13
vrobbler/templates/discgolf/discgolfcourse_list.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
|
||||||
|
{% block title %}Disc Golf Rounds{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% include "_scrobblable_list.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -81,6 +81,15 @@
|
|||||||
<p>No trails hiked today</p>
|
<p>No trails hiked today</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<h3><a href="{% url 'discgolf:course_list' %}">Disc Golf</a></h3>
|
||||||
|
{% if DiscGolfCourse %}
|
||||||
|
{% with scrobbles=DiscGolfCourse count=DiscGolfCourse_count time=DiscGolfCourse_time %}
|
||||||
|
{% include "scrobbles/_scrobble_table.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% else %}
|
||||||
|
<p>No courses played today</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,9 @@ from rest_framework import routers
|
|||||||
|
|
||||||
import vrobbler.apps.scrobbles.views as scrobbles_views
|
import vrobbler.apps.scrobbles.views as scrobbles_views
|
||||||
|
|
||||||
|
from vrobbler.apps.discgolf import urls as discgolf_urls
|
||||||
|
from vrobbler.apps.discgolf.api.views import DiscGolfCourseViewSet
|
||||||
|
|
||||||
from vrobbler.apps.boardgames import urls as boardgame_urls
|
from vrobbler.apps.boardgames import urls as boardgame_urls
|
||||||
from vrobbler.apps.boardgames.api.views import (
|
from vrobbler.apps.boardgames.api.views import (
|
||||||
BoardGameViewSet,
|
BoardGameViewSet,
|
||||||
@ -153,7 +156,7 @@ router.register(r"lifeevents", LifeEventViewSet)
|
|||||||
|
|
||||||
router.register(r"birds", BirdViewSet)
|
router.register(r"birds", BirdViewSet)
|
||||||
router.register(r"birding-locations", BirdingLocationViewSet)
|
router.register(r"birding-locations", BirdingLocationViewSet)
|
||||||
|
router.register(r"disc-golf-courses", DiscGolfCourseViewSet)
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("api/v1/", include(router.urls)),
|
path("api/v1/", include(router.urls)),
|
||||||
path("api/v1/auth", include("rest_framework.urls")),
|
path("api/v1/auth", include("rest_framework.urls")),
|
||||||
@ -171,6 +174,7 @@ urlpatterns = [
|
|||||||
path("", include(locations_urls, namespace="locations")),
|
path("", include(locations_urls, namespace="locations")),
|
||||||
path("", include(trails_urls, namespace="trails")),
|
path("", include(trails_urls, namespace="trails")),
|
||||||
path("", include(beers_urls, namespace="beers")),
|
path("", include(beers_urls, namespace="beers")),
|
||||||
|
path("", include(discgolf_urls, namespace="discgolf")),
|
||||||
path("", include(foods_urls, namespace="foods")),
|
path("", include(foods_urls, namespace="foods")),
|
||||||
path("", include(puzzles_urls, namespace="puzzles")),
|
path("", include(puzzles_urls, namespace="puzzles")),
|
||||||
path("", include(tasks_urls, namespace="tasks")),
|
path("", include(tasks_urls, namespace="tasks")),
|
||||||
|
|||||||
Reference in New Issue
Block a user