From 77f143299ddaa45d3690aef92138355931dcca6c Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Fri, 15 May 2026 11:36:04 -0400 Subject: [PATCH] [birds] Add birding location scrobbling --- PROJECT.org | 14 +- vrobbler/apps/birds/__init__.py | 0 vrobbler/apps/birds/admin.py | 22 +++ vrobbler/apps/birds/api/__init__.py | 0 vrobbler/apps/birds/api/serializers.py | 14 ++ vrobbler/apps/birds/api/views.py | 15 ++ vrobbler/apps/birds/apps.py | 5 + vrobbler/apps/birds/forms.py | 85 ++++++++ .../apps/birds/migrations/0001_initial.py | 144 ++++++++++++++ vrobbler/apps/birds/migrations/__init__.py | 0 vrobbler/apps/birds/models.py | 181 ++++++++++++++++++ vrobbler/apps/birds/urls.py | 27 +++ vrobbler/apps/birds/views.py | 22 +++ vrobbler/apps/scrobbles/admin.py | 2 + vrobbler/apps/scrobbles/constants.py | 1 + ...ding_location_alter_scrobble_media_type.py | 55 ++++++ vrobbler/apps/scrobbles/models.py | 10 +- vrobbler/apps/scrobbles/views.py | 2 + vrobbler/settings.py | 2 + .../birds/bird_sightings_widget.html | 46 +++++ .../birds/birdinglocation_detail.html | 43 +++++ .../templates/birds/birdinglocation_list.html | 14 ++ vrobbler/urls.py | 10 + 23 files changed, 708 insertions(+), 6 deletions(-) create mode 100644 vrobbler/apps/birds/__init__.py create mode 100644 vrobbler/apps/birds/admin.py create mode 100644 vrobbler/apps/birds/api/__init__.py create mode 100644 vrobbler/apps/birds/api/serializers.py create mode 100644 vrobbler/apps/birds/api/views.py create mode 100644 vrobbler/apps/birds/apps.py create mode 100644 vrobbler/apps/birds/forms.py create mode 100644 vrobbler/apps/birds/migrations/0001_initial.py create mode 100644 vrobbler/apps/birds/migrations/__init__.py create mode 100644 vrobbler/apps/birds/models.py create mode 100644 vrobbler/apps/birds/urls.py create mode 100644 vrobbler/apps/birds/views.py create mode 100644 vrobbler/apps/scrobbles/migrations/0076_scrobble_birding_location_alter_scrobble_media_type.py create mode 100644 vrobbler/templates/birds/bird_sightings_widget.html create mode 100644 vrobbler/templates/birds/birdinglocation_detail.html create mode 100644 vrobbler/templates/birds/birdinglocation_list.html diff --git a/PROJECT.org b/PROJECT.org index e13f313..8b7feba 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -92,7 +92,7 @@ fetching and simple saving. :LOGBOOK: CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20 :END: -* Backlog [19/39] +* Backlog [20/40] ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles: ** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement: :PROPERTIES: @@ -431,14 +431,18 @@ https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-li ** TODO [#A] When creating org-mode tasks, don't copy comments :vrobbler:bug:scrobbles:tasks: ** TODO Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment: -** DONE List only the last 20 scrobbles per category on the home page :vrobbler:project:scrobbles:templates: -:PROPERTIES: -:ID: b56402b9-5542-a10c-6c13-e12f56f2a2d1 -:END: ** TODO Fix various artist album problem with Superwolves (track with multiple artists) :vrobbler:project:music:bug:artists: :PROPERTIES: :ID: 590bc038-745f-710b-8272-4d8a3d2efa01 :END: +** DONE Add ability to track Birding sessions via BirdingLocation scrobbles :vrobbler:project:birds:feature: +:PROPERTIES: +:ID: 8200ce29-a691-5cf6-c11a-c77e3d8b64c6 +:END: +** DONE List only the last 20 scrobbles per category on the home page :vrobbler:project:scrobbles:templates: +:PROPERTIES: +:ID: b56402b9-5542-a10c-6c13-e12f56f2a2d1 +:END: ** DONE Fix display of notes so they look like stickies :vrobbler:project:personal:notes:scrobbles: :PROPERTIES: :ID: 0ace8814-e96e-fa54-28d1-c57dcb508f1e diff --git a/vrobbler/apps/birds/__init__.py b/vrobbler/apps/birds/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/birds/admin.py b/vrobbler/apps/birds/admin.py new file mode 100644 index 0000000..60f62a6 --- /dev/null +++ b/vrobbler/apps/birds/admin.py @@ -0,0 +1,22 @@ +from birds.models import Bird, BirdingLocation +from django.contrib import admin +from scrobbles.admin import ScrobbleInline + + +@admin.register(Bird) +class BirdAdmin(admin.ModelAdmin): + date_hierarchy = "created" + list_display = ("uuid", "common_name", "scientific_name", "ebird_code") + ordering = ("-created",) + search_fields = ("common_name", "scientific_name") + + +@admin.register(BirdingLocation) +class BirdingLocationAdmin(admin.ModelAdmin): + date_hierarchy = "created" + list_display = ("uuid", "title") + ordering = ("-created",) + search_fields = ("title",) + inlines = [ + ScrobbleInline, + ] diff --git a/vrobbler/apps/birds/api/__init__.py b/vrobbler/apps/birds/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/birds/api/serializers.py b/vrobbler/apps/birds/api/serializers.py new file mode 100644 index 0000000..db01319 --- /dev/null +++ b/vrobbler/apps/birds/api/serializers.py @@ -0,0 +1,14 @@ +from rest_framework import serializers +from birds.models import Bird, BirdingLocation + + +class BirdSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = Bird + fields = "__all__" + + +class BirdingLocationSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = BirdingLocation + fields = "__all__" diff --git a/vrobbler/apps/birds/api/views.py b/vrobbler/apps/birds/api/views.py new file mode 100644 index 0000000..df0e6f9 --- /dev/null +++ b/vrobbler/apps/birds/api/views.py @@ -0,0 +1,15 @@ +from rest_framework import permissions, viewsets +from birds.api import serializers +from birds import models + + +class BirdViewSet(viewsets.ModelViewSet): + queryset = models.Bird.objects.all().order_by("-created") + serializer_class = serializers.BirdSerializer + permission_classes = [permissions.IsAuthenticated] + + +class BirdingLocationViewSet(viewsets.ModelViewSet): + queryset = models.BirdingLocation.objects.all().order_by("-created") + serializer_class = serializers.BirdingLocationSerializer + permission_classes = [permissions.IsAuthenticated] diff --git a/vrobbler/apps/birds/apps.py b/vrobbler/apps/birds/apps.py new file mode 100644 index 0000000..a4428c9 --- /dev/null +++ b/vrobbler/apps/birds/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class BirdsConfig(AppConfig): + name = "birds" diff --git a/vrobbler/apps/birds/forms.py b/vrobbler/apps/birds/forms.py new file mode 100644 index 0000000..8721b8c --- /dev/null +++ b/vrobbler/apps/birds/forms.py @@ -0,0 +1,85 @@ +import json + +from birds.models import Bird, BirdSightingEntry +from django import forms + + +class BirdSightingsWidget(forms.Widget): + template_name = "birds/bird_sightings_widget.html" + + class Media: + js = ("birds/bird_sightings.js",) + + def value_from_datadict(self, data, files, name): + bird_ids = data.getlist(f"{name}_bird_id") + quantities = data.getlist(f"{name}_quantity") + notes = data.getlist(f"{name}_sighting_notes") + return { + "bird_id": bird_ids, + "quantity": quantities, + "sighting_notes": notes, + } + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + sightings = [] + if value: + if isinstance(value, str): + try: + value = json.loads(value) + except (json.JSONDecodeError, TypeError): + value = [] + for item in (value or []): + if isinstance(item, dict): + sightings.append(item) + elif isinstance(item, BirdSightingEntry): + sightings.append(item.asdict) + context["widget"]["sightings"] = sightings + context["widget"]["birds"] = Bird.objects.all().order_by("common_name") + return context + + +class BirdSightingsField(forms.Field): + widget = BirdSightingsWidget + + def clean(self, value): + if not value: + return None + result = [] + bird_ids = value.get("bird_id", []) if isinstance(value, dict) else [] + quantities = value.get("quantity", []) if isinstance(value, dict) else [] + notes_list = ( + value.get("sighting_notes", []) if isinstance(value, dict) else [] + ) + + if isinstance(bird_ids, list): + for i, bird_id in enumerate(bird_ids): + if not bird_id: + continue + try: + bird_id = int(bird_id) + quantity = int(quantities[i]) if i < len(quantities) else 1 + except (ValueError, TypeError, IndexError): + continue + note = notes_list[i] if i < len(notes_list) else "" + entry = BirdSightingEntry( + bird_id=bird_id, + quantity=quantity, + sighting_notes=note or None, + ) + result.append(entry.asdict) + elif bird_ids: + try: + bird_id = int(bird_ids) + quantity = int(quantities) if quantities else 1 + except (ValueError, TypeError): + raise forms.ValidationError("Invalid bird sighting data") + note = notes_list if notes_list else "" + entry = BirdSightingEntry( + bird_id=bird_id, + quantity=quantity, + sighting_notes=note or None, + ) + result.append(entry.asdict) + + return result if result else None diff --git a/vrobbler/apps/birds/migrations/0001_initial.py b/vrobbler/apps/birds/migrations/0001_initial.py new file mode 100644 index 0000000..74bf0b5 --- /dev/null +++ b/vrobbler/apps/birds/migrations/0001_initial.py @@ -0,0 +1,144 @@ +# Generated by Django 4.2.29 on 2026-05-15 15:05 + +from django.db import migrations, models +import django.db.models.deletion +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"), + ("locations", "0010_clean_start"), + ("scrobbles", "0075_add_channel_scrobble"), + ] + + operations = [ + migrations.CreateModel( + name="Bird", + 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 + ), + ), + ("common_name", models.CharField(max_length=255)), + ( + "scientific_name", + models.CharField(blank=True, max_length=255, null=True), + ), + ("description", models.TextField(blank=True, null=True)), + ( + "ebird_code", + models.CharField( + blank=True, db_index=True, max_length=255, null=True + ), + ), + ( + "photo", + models.ImageField(blank=True, null=True, upload_to="birds/photos/"), + ), + ], + options={ + "get_latest_by": "modified", + "abstract": False, + }, + ), + migrations.CreateModel( + name="BirdingLocation", + 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)), + ( + "ebird_hotspot_id", + models.CharField(blank=True, max_length=255, 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", + ), + ), + ( + "geo_location", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to="locations.geolocation", + ), + ), + ( + "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, + }, + ), + ] diff --git a/vrobbler/apps/birds/migrations/__init__.py b/vrobbler/apps/birds/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/birds/models.py b/vrobbler/apps/birds/models.py new file mode 100644 index 0000000..57e4d03 --- /dev/null +++ b/vrobbler/apps/birds/models.py @@ -0,0 +1,181 @@ +import logging +from dataclasses import dataclass +from functools import cached_property +from typing import Optional +from uuid import uuid4 + +from django.db import models +from django.urls import reverse +from django_extensions.db.models import TimeStampedModel +from imagekit.models import ImageSpecField +from imagekit.processors import ResizeToFit +from locations.models import GeoLocation +from scrobbles.dataclasses import BaseLogData, WithPeopleLogData +from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin + +logger = logging.getLogger(__name__) +BNULL = {"blank": True, "null": True} + + +@dataclass +class BirdSightingEntry(BaseLogData): + bird_id: Optional[int] = None + quantity: int = 1 + sighting_notes: Optional[str] = None + + @property + def bird(self) -> Optional["Bird"]: + if not self.bird_id: + return None + return Bird.objects.filter(id=self.bird_id).first() + + def __str__(self) -> str: + name = self.bird.common_name if self.bird else "Unknown" + out = f"{name} x{self.quantity}" + if self.sighting_notes: + out += f" ({self.sighting_notes})" + return out + + +@dataclass +class BirdSightingLogData(BaseLogData, WithPeopleLogData): + birds: Optional[list[BirdSightingEntry]] = None + duration_minutes: Optional[int] = None + weather: Optional[str] = None + temperature: Optional[int] = None + guide: Optional[str] = None + + _excluded_fields = {} + + @cached_property + def bird_list(self) -> str: + if self.birds: + return ", ".join( + [BirdSightingEntry(**b).__str__() for b in self.birds] + ) + return "" + + def as_html(self) -> str: + html_parts = [] + + if self.weather: + html_parts.append( + f'
Weather: {self.weather}
' + ) + + if self.temperature: + html_parts.append( + f'
Temp: {self.temperature}°
' + ) + + if self.guide: + html_parts.append( + f'
Guide: {self.guide}
' + ) + + if self.duration_minutes: + html_parts.append( + f'
Duration: {self.duration_minutes} min
' + ) + + if self.birds: + birds_html = [] + for bird_data in self.birds: + sighting = BirdSightingEntry(**bird_data) + bird_info = sighting.bird.common_name if sighting.bird else "Unknown" + extra = f" x{sighting.quantity}" + if sighting.sighting_notes: + extra += f" \u2014 {sighting.sighting_notes}" + birds_html.append( + f'
{bird_info}{extra}
' + ) + html_parts.append( + f'
{"".join(birds_html)}
' + ) + + return "".join(html_parts) + + @classmethod + def override_fields(cls) -> dict: + from birds.forms import BirdSightingsField + + fields = {} + for base in cls.mro()[1:]: + if hasattr(base, "override_fields"): + base_fields = base.override_fields() + fields.update(base_fields) + custom_fields = { + "birds": BirdSightingsField(required=False), + } + fields.update(custom_fields) + return fields + + +class Bird(TimeStampedModel): + uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) + common_name = models.CharField(max_length=255) + scientific_name = models.CharField(max_length=255, **BNULL) + description = models.TextField(**BNULL) + ebird_code = models.CharField(max_length=255, **BNULL, db_index=True) + photo = models.ImageField(upload_to="birds/photos/", **BNULL) + photo_small = ImageSpecField( + source="photo", + processors=[ResizeToFit(100, 100)], + format="JPEG", + options={"quality": 60}, + ) + photo_medium = ImageSpecField( + source="photo", + processors=[ResizeToFit(300, 300)], + format="JPEG", + options={"quality": 75}, + ) + + def __str__(self): + return self.common_name + + def get_absolute_url(self): + return reverse("birds:bird_detail", kwargs={"slug": self.uuid}) + + @classmethod + def find_or_create(cls, common_name: str) -> "Bird": + bird = cls.objects.filter(common_name__iexact=common_name).first() + if not bird: + bird = cls.objects.create(common_name=common_name) + return bird + + +class BirdingLocation(ScrobblableMixin): + description = models.TextField(**BNULL) + geo_location = models.ForeignKey( + GeoLocation, **BNULL, on_delete=models.DO_NOTHING + ) + ebird_hotspot_id = models.CharField(max_length=255, **BNULL) + + def get_absolute_url(self): + return reverse("birds:birding_location_detail", kwargs={"slug": self.uuid}) + + @property + def subtitle(self): + return "" + + @property + def strings(self) -> ScrobblableConstants: + return ScrobblableConstants(verb="Birding at", tags="bird") + + @property + def logdata_cls(self): + return BirdSightingLogData + + def primary_image_url(self) -> str: + return "" + + def fix_metadata(self) -> None: + pass + + @classmethod + def find_or_create(cls, title: str) -> "BirdingLocation": + location = cls.objects.filter(title__iexact=title).first() + if not location: + location = cls.objects.create(title=title) + return location diff --git a/vrobbler/apps/birds/urls.py b/vrobbler/apps/birds/urls.py new file mode 100644 index 0000000..4fc8fd8 --- /dev/null +++ b/vrobbler/apps/birds/urls.py @@ -0,0 +1,27 @@ +from birds import views +from django.urls import path + +app_name = "birds" + +urlpatterns = [ + path( + "birding-locations/", + views.BirdingLocationListView.as_view(), + name="birding_location_list", + ), + path( + "birding-locations//", + views.BirdingLocationDetailView.as_view(), + name="birding_location_detail", + ), + path( + "birds/", + views.BirdListView.as_view(), + name="bird_list", + ), + path( + "birds//", + views.BirdDetailView.as_view(), + name="bird_detail", + ), +] diff --git a/vrobbler/apps/birds/views.py b/vrobbler/apps/birds/views.py new file mode 100644 index 0000000..f2dc725 --- /dev/null +++ b/vrobbler/apps/birds/views.py @@ -0,0 +1,22 @@ +from birds.models import Bird, BirdingLocation +from django.views import generic +from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView + + +class BirdingLocationListView(ScrobbleableListView): + model = BirdingLocation + + +class BirdingLocationDetailView(ScrobbleableDetailView): + model = BirdingLocation + + +class BirdListView(generic.ListView): + model = Bird + paginate_by = 200 + ordering = "common_name" + + +class BirdDetailView(generic.DetailView): + model = Bird + slug_field = "uuid" diff --git a/vrobbler/apps/scrobbles/admin.py b/vrobbler/apps/scrobbles/admin.py index 8b8f5b6..275e7f9 100644 --- a/vrobbler/apps/scrobbles/admin.py +++ b/vrobbler/apps/scrobbles/admin.py @@ -31,6 +31,7 @@ class ScrobbleInline(admin.TabularInline): "beer", "web_page", "life_event", + "birding_location", "user", ) exclude = ( @@ -117,6 +118,7 @@ class ScrobbleAdmin(admin.ModelAdmin): "beer", "web_page", "life_event", + "birding_location", ) list_filter = ( "is_paused", diff --git a/vrobbler/apps/scrobbles/constants.py b/vrobbler/apps/scrobbles/constants.py index 592c2fd..7f5b343 100644 --- a/vrobbler/apps/scrobbles/constants.py +++ b/vrobbler/apps/scrobbles/constants.py @@ -29,6 +29,7 @@ PLAY_AGAIN_MEDIA = { "foods": "Food", "locations": "GeoLocation", "videos": "Video", + "birds": "BirdingLocation", } MEDIA_END_PADDING_SECONDS = { diff --git a/vrobbler/apps/scrobbles/migrations/0076_scrobble_birding_location_alter_scrobble_media_type.py b/vrobbler/apps/scrobbles/migrations/0076_scrobble_birding_location_alter_scrobble_media_type.py new file mode 100644 index 0000000..1e4021f --- /dev/null +++ b/vrobbler/apps/scrobbles/migrations/0076_scrobble_birding_location_alter_scrobble_media_type.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.29 on 2026-05-15 15:05 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("birds", "0001_initial"), + ("scrobbles", "0075_add_channel_scrobble"), + ] + + operations = [ + migrations.AddField( + model_name="scrobble", + name="birding_location", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to="birds.birdinglocation", + ), + ), + 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"), + ], + default="Video", + max_length=20, + ), + ), + ] diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py index 5102aab..1ead781 100644 --- a/vrobbler/apps/scrobbles/models.py +++ b/vrobbler/apps/scrobbles/models.py @@ -12,6 +12,7 @@ import pytz from beers.models import Beer from boardgames.models import BoardGame from books.koreader import process_koreader_sqlite_file +from birds.models import BirdingLocation from books.models import Book, BookLogData, BookPageLogData, Paper from bricksets.models import BrickSet from charts.utils import build_charts @@ -368,6 +369,7 @@ class ScrobbleQuerySet(models.QuerySet): "life_event", "mood", "brick_set", + "birding_location", ) @@ -398,6 +400,7 @@ class Scrobble(TimeStampedModel): MOOD = "Mood", "Mood" BRICKSET = "BrickSet", "Brick set" CHANNEL = "Channel", "Channel" + BIRDING_LOCATION = "BirdingLocation", "Birding location" @classmethod def list(cls): @@ -425,8 +428,11 @@ class Scrobble(TimeStampedModel): life_event = models.ForeignKey(LifeEvent, on_delete=models.DO_NOTHING, **BNULL) mood = models.ForeignKey(Mood, on_delete=models.DO_NOTHING, **BNULL) brick_set = models.ForeignKey(BrickSet, on_delete=models.DO_NOTHING, **BNULL) + birding_location = models.ForeignKey( + BirdingLocation, on_delete=models.DO_NOTHING, **BNULL + ) media_type = models.CharField( - max_length=14, choices=MediaType.choices, default=MediaType.VIDEO + max_length=20, choices=MediaType.choices, default=MediaType.VIDEO ) user = models.ForeignKey(User, blank=True, null=True, on_delete=models.DO_NOTHING) @@ -979,6 +985,8 @@ class Scrobble(TimeStampedModel): media_obj = self.food if self.channel: media_obj = self.channel + if self.birding_location: + media_obj = self.birding_location return media_obj def __str__(self): diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index 3fd0494..5e211ec 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -977,6 +977,7 @@ class ScrobbleDetailView(DetailView): "LifeEvent": "life_event", "Mood": "mood", "BrickSet": "brick_set", + "BirdingLocation": "birding_location", } def get_context_data(self, **kwargs): @@ -1197,6 +1198,7 @@ class ScrobbleSearchView(LoginRequiredMixin, TemplateView): "LifeEvent": ["life_event__title", "life_event__description"], "Mood": ["mood__title", "mood__description"], "BrickSet": ["brick_set__title", None], + "BirdingLocation": ["birding_location__title", "birding_location__description"], } def get(self, request, *args, **kwargs): diff --git a/vrobbler/settings.py b/vrobbler/settings.py index fa18941..4e77632 100644 --- a/vrobbler/settings.py +++ b/vrobbler/settings.py @@ -163,10 +163,12 @@ INSTALLED_APPS = [ "tasks", "trails", "beers", + "bgstats", "puzzles", "foods", "lifeevents", "moods", + "birds", "mathfilters", "rest_framework", "allauth", diff --git a/vrobbler/templates/birds/bird_sightings_widget.html b/vrobbler/templates/birds/bird_sightings_widget.html new file mode 100644 index 0000000..812d12a --- /dev/null +++ b/vrobbler/templates/birds/bird_sightings_widget.html @@ -0,0 +1,46 @@ +
+
+ {% for sighting in widget.sightings %} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ {% empty %} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ {% endfor %} +
+ +
diff --git a/vrobbler/templates/birds/birdinglocation_detail.html b/vrobbler/templates/birds/birdinglocation_detail.html new file mode 100644 index 0000000..2ed9c86 --- /dev/null +++ b/vrobbler/templates/birds/birdinglocation_detail.html @@ -0,0 +1,43 @@ +{% extends "base_list.html" %} +{% load static %} + +{% block title %}{{object.title}}{% endblock %} + +{% block lists %} + +
+
+ {% if object.description %} +

{{object.description|safe|linebreaks|truncatewords:160}}

+
+ {% endif %} +

{{scrobbles.count}} scrobbles

+

+ Go birding again +

+
+
+
+
+

Last scrobbles

+
+ + + + + + + + + {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %} + + + + + {% endfor %} + +
DateSightings
{{scrobble.local_timestamp}}{{scrobble.logdata.bird_list}}
+
+
+
+{% endblock %} diff --git a/vrobbler/templates/birds/birdinglocation_list.html b/vrobbler/templates/birds/birdinglocation_list.html new file mode 100644 index 0000000..ca57bce --- /dev/null +++ b/vrobbler/templates/birds/birdinglocation_list.html @@ -0,0 +1,14 @@ +{% extends "base_list.html" %} +{% load static %} + +{% block title %}Birding Locations{% endblock %} + +{% block lists %} +
+
+
+ {% include "_scrobblable_list.html" %} +
+
+
+{% endblock %} diff --git a/vrobbler/urls.py b/vrobbler/urls.py index 0b6225d..f0ae218 100644 --- a/vrobbler/urls.py +++ b/vrobbler/urls.py @@ -77,6 +77,12 @@ from vrobbler.apps.beers.api.views import ( BeerStyleViewSet, ) +from vrobbler.apps.birds import urls as birds_urls +from vrobbler.apps.birds.api.views import ( + BirdViewSet, + BirdingLocationViewSet, +) + from vrobbler.apps.puzzles import urls as puzzles_urls from vrobbler.apps.puzzles.api.views import ( PuzzleViewSet, @@ -144,6 +150,9 @@ router.register(r"puzzles", PuzzleViewSet) router.register(r"bricksets", BrickSetViewSet) router.register(r"lifeevents", LifeEventViewSet) +router.register(r"birds", BirdViewSet) +router.register(r"birding-locations", BirdingLocationViewSet) + urlpatterns = [ path("api/v1/", include(router.urls)), path("api/v1/auth", include("rest_framework.urls")), @@ -168,6 +177,7 @@ urlpatterns = [ path("", include(podcast_urls, namespace="podcasts")), path("", include(lifeevents_urls, namespace="life-events")), path("", include(moods_urls, namespace="moods")), + path("", include(birds_urls, namespace="birds")), path("", include(scrobble_urls, namespace="scrobbles")), path("", include(profiles_urls, namespace="profiles")), path("", include(people_urls, namespace="people")),