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