[birds] Add birding location scrobbling
This commit is contained in:
0
vrobbler/apps/birds/__init__.py
Normal file
0
vrobbler/apps/birds/__init__.py
Normal file
22
vrobbler/apps/birds/admin.py
Normal file
22
vrobbler/apps/birds/admin.py
Normal 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,
|
||||
]
|
||||
0
vrobbler/apps/birds/api/__init__.py
Normal file
0
vrobbler/apps/birds/api/__init__.py
Normal file
14
vrobbler/apps/birds/api/serializers.py
Normal file
14
vrobbler/apps/birds/api/serializers.py
Normal 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__"
|
||||
15
vrobbler/apps/birds/api/views.py
Normal file
15
vrobbler/apps/birds/api/views.py
Normal 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]
|
||||
5
vrobbler/apps/birds/apps.py
Normal file
5
vrobbler/apps/birds/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BirdsConfig(AppConfig):
|
||||
name = "birds"
|
||||
85
vrobbler/apps/birds/forms.py
Normal file
85
vrobbler/apps/birds/forms.py
Normal 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
|
||||
144
vrobbler/apps/birds/migrations/0001_initial.py
Normal file
144
vrobbler/apps/birds/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/birds/migrations/__init__.py
Normal file
0
vrobbler/apps/birds/migrations/__init__.py
Normal file
181
vrobbler/apps/birds/models.py
Normal file
181
vrobbler/apps/birds/models.py
Normal 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
|
||||
27
vrobbler/apps/birds/urls.py
Normal file
27
vrobbler/apps/birds/urls.py
Normal 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",
|
||||
),
|
||||
]
|
||||
22
vrobbler/apps/birds/views.py
Normal file
22
vrobbler/apps/birds/views.py
Normal 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"
|
||||
Reference in New Issue
Block a user