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 @@
+
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
+
+
+
+
+ | Date |
+ Sightings |
+
+
+
+ {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
+
+ | {{scrobble.local_timestamp}} |
+ {{scrobble.logdata.bird_list}} |
+
+ {% endfor %}
+
+
+
+
+
+{% 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")),