[birds] Add birding location scrobbling

This commit is contained in:
2026-05-15 11:36:04 -04:00
parent 7b7c66de8f
commit 77f143299d
23 changed files with 708 additions and 6 deletions

View File

View File

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

View File

View File

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

View File

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

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class BirdsConfig(AppConfig):
name = "birds"

View File

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

View File

@ -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,
},
),
]

View File

@ -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'<div class="birding-weather">Weather: {self.weather}</div>'
)
if self.temperature:
html_parts.append(
f'<div class="birding-temp">Temp: {self.temperature}°</div>'
)
if self.guide:
html_parts.append(
f'<div class="birding-guide">Guide: {self.guide}</div>'
)
if self.duration_minutes:
html_parts.append(
f'<div class="birding-duration">Duration: {self.duration_minutes} min</div>'
)
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'<div class="bird-sighting">{bird_info}{extra}</div>'
)
html_parts.append(
f'<div class="bird-sightings">{"".join(birds_html)}</div>'
)
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

View File

@ -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/<slug:slug>/",
views.BirdingLocationDetailView.as_view(),
name="birding_location_detail",
),
path(
"birds/",
views.BirdListView.as_view(),
name="bird_list",
),
path(
"birds/<slug:slug>/",
views.BirdDetailView.as_view(),
name="bird_detail",
),
]

View File

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

View File

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

View File

@ -29,6 +29,7 @@ PLAY_AGAIN_MEDIA = {
"foods": "Food",
"locations": "GeoLocation",
"videos": "Video",
"birds": "BirdingLocation",
}
MEDIA_END_PADDING_SECONDS = {

View File

@ -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,
),
),
]

View File

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

View File

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

View File

@ -163,10 +163,12 @@ INSTALLED_APPS = [
"tasks",
"trails",
"beers",
"bgstats",
"puzzles",
"foods",
"lifeevents",
"moods",
"birds",
"mathfilters",
"rest_framework",
"allauth",

View File

@ -0,0 +1,46 @@
<div class="bird-sightings-widget">
<div class="bird-sightings-list">
{% for sighting in widget.sightings %}
<div class="bird-sighting-row row mb-2">
<div class="col-md-6">
<select name="{{widget.name}}_bird_id" class="form-control">
<option value="">Select bird...</option>
{% for bird in widget.birds %}
<option value="{{bird.id}}" {% if sighting.bird_id == bird.id %}selected{% endif %}>{{bird.common_name}}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<input type="number" name="{{widget.name}}_quantity" class="form-control" value="{{sighting.quantity|default:1}}" min="1" placeholder="Qty">
</div>
<div class="col-md-3">
<input type="text" name="{{widget.name}}_sighting_notes" class="form-control" value="{{sighting.sighting_notes|default:''}}" placeholder="Notes">
</div>
<div class="col-md-1">
<button type="button" class="btn btn-sm btn-outline-danger remove-sighting">&times;</button>
</div>
</div>
{% empty %}
<div class="bird-sighting-row row mb-2">
<div class="col-md-6">
<select name="{{widget.name}}_bird_id" class="form-control">
<option value="">Select bird...</option>
{% for bird in widget.birds %}
<option value="{{bird.id}}">{{bird.common_name}}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<input type="number" name="{{widget.name}}_quantity" class="form-control" value="1" min="1" placeholder="Qty">
</div>
<div class="col-md-3">
<input type="text" name="{{widget.name}}_sighting_notes" class="form-control" value="" placeholder="Notes">
</div>
<div class="col-md-1">
<button type="button" class="btn btn-sm btn-outline-danger remove-sighting">&times;</button>
</div>
</div>
{% endfor %}
</div>
<button type="button" class="btn btn-sm btn-outline-primary add-sighting-row mt-2">Add bird</button>
</div>

View File

@ -0,0 +1,43 @@
{% extends "base_list.html" %}
{% load static %}
{% block title %}{{object.title}}{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
{% if object.description %}
<p>{{object.description|safe|linebreaks|truncatewords:160}}</p>
<hr />
{% endif %}
<p>{{scrobbles.count}} scrobbles</p>
<p>
<a href="{{object.start_url}}">Go birding again</a>
</p>
</div>
</div>
<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">Sightings</th>
</tr>
</thead>
<tbody>
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr>
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
<td>{{scrobble.logdata.bird_list}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "base_list.html" %}
{% load static %}
{% block title %}Birding Locations{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">
{% include "_scrobblable_list.html" %}
</div>
</div>
</div>
{% endblock %}

View File

@ -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")),