[birds] Add birding location scrobbling
This commit is contained in:
14
PROJECT.org
14
PROJECT.org
@ -92,7 +92,7 @@ fetching and simple saving.
|
|||||||
:LOGBOOK:
|
:LOGBOOK:
|
||||||
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
||||||
:END:
|
: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] 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:
|
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
|
||||||
:PROPERTIES:
|
: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 [#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:
|
** 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:
|
** TODO Fix various artist album problem with Superwolves (track with multiple artists) :vrobbler:project:music:bug:artists:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 590bc038-745f-710b-8272-4d8a3d2efa01
|
:ID: 590bc038-745f-710b-8272-4d8a3d2efa01
|
||||||
:END:
|
: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:
|
** DONE Fix display of notes so they look like stickies :vrobbler:project:personal:notes:scrobbles:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 0ace8814-e96e-fa54-28d1-c57dcb508f1e
|
:ID: 0ace8814-e96e-fa54-28d1-c57dcb508f1e
|
||||||
|
|||||||
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"
|
||||||
@ -31,6 +31,7 @@ class ScrobbleInline(admin.TabularInline):
|
|||||||
"beer",
|
"beer",
|
||||||
"web_page",
|
"web_page",
|
||||||
"life_event",
|
"life_event",
|
||||||
|
"birding_location",
|
||||||
"user",
|
"user",
|
||||||
)
|
)
|
||||||
exclude = (
|
exclude = (
|
||||||
@ -117,6 +118,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
"beer",
|
"beer",
|
||||||
"web_page",
|
"web_page",
|
||||||
"life_event",
|
"life_event",
|
||||||
|
"birding_location",
|
||||||
)
|
)
|
||||||
list_filter = (
|
list_filter = (
|
||||||
"is_paused",
|
"is_paused",
|
||||||
|
|||||||
@ -29,6 +29,7 @@ PLAY_AGAIN_MEDIA = {
|
|||||||
"foods": "Food",
|
"foods": "Food",
|
||||||
"locations": "GeoLocation",
|
"locations": "GeoLocation",
|
||||||
"videos": "Video",
|
"videos": "Video",
|
||||||
|
"birds": "BirdingLocation",
|
||||||
}
|
}
|
||||||
|
|
||||||
MEDIA_END_PADDING_SECONDS = {
|
MEDIA_END_PADDING_SECONDS = {
|
||||||
|
|||||||
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -12,6 +12,7 @@ import pytz
|
|||||||
from beers.models import Beer
|
from beers.models import Beer
|
||||||
from boardgames.models import BoardGame
|
from boardgames.models import BoardGame
|
||||||
from books.koreader import process_koreader_sqlite_file
|
from books.koreader import process_koreader_sqlite_file
|
||||||
|
from birds.models import BirdingLocation
|
||||||
from books.models import Book, BookLogData, BookPageLogData, Paper
|
from books.models import Book, BookLogData, BookPageLogData, Paper
|
||||||
from bricksets.models import BrickSet
|
from bricksets.models import BrickSet
|
||||||
from charts.utils import build_charts
|
from charts.utils import build_charts
|
||||||
@ -368,6 +369,7 @@ class ScrobbleQuerySet(models.QuerySet):
|
|||||||
"life_event",
|
"life_event",
|
||||||
"mood",
|
"mood",
|
||||||
"brick_set",
|
"brick_set",
|
||||||
|
"birding_location",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -398,6 +400,7 @@ class Scrobble(TimeStampedModel):
|
|||||||
MOOD = "Mood", "Mood"
|
MOOD = "Mood", "Mood"
|
||||||
BRICKSET = "BrickSet", "Brick set"
|
BRICKSET = "BrickSet", "Brick set"
|
||||||
CHANNEL = "Channel", "Channel"
|
CHANNEL = "Channel", "Channel"
|
||||||
|
BIRDING_LOCATION = "BirdingLocation", "Birding location"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def list(cls):
|
def list(cls):
|
||||||
@ -425,8 +428,11 @@ class Scrobble(TimeStampedModel):
|
|||||||
life_event = models.ForeignKey(LifeEvent, on_delete=models.DO_NOTHING, **BNULL)
|
life_event = models.ForeignKey(LifeEvent, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
mood = models.ForeignKey(Mood, 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)
|
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(
|
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)
|
user = models.ForeignKey(User, blank=True, null=True, on_delete=models.DO_NOTHING)
|
||||||
|
|
||||||
@ -979,6 +985,8 @@ class Scrobble(TimeStampedModel):
|
|||||||
media_obj = self.food
|
media_obj = self.food
|
||||||
if self.channel:
|
if self.channel:
|
||||||
media_obj = self.channel
|
media_obj = self.channel
|
||||||
|
if self.birding_location:
|
||||||
|
media_obj = self.birding_location
|
||||||
return media_obj
|
return media_obj
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
@ -977,6 +977,7 @@ class ScrobbleDetailView(DetailView):
|
|||||||
"LifeEvent": "life_event",
|
"LifeEvent": "life_event",
|
||||||
"Mood": "mood",
|
"Mood": "mood",
|
||||||
"BrickSet": "brick_set",
|
"BrickSet": "brick_set",
|
||||||
|
"BirdingLocation": "birding_location",
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
@ -1197,6 +1198,7 @@ class ScrobbleSearchView(LoginRequiredMixin, TemplateView):
|
|||||||
"LifeEvent": ["life_event__title", "life_event__description"],
|
"LifeEvent": ["life_event__title", "life_event__description"],
|
||||||
"Mood": ["mood__title", "mood__description"],
|
"Mood": ["mood__title", "mood__description"],
|
||||||
"BrickSet": ["brick_set__title", None],
|
"BrickSet": ["brick_set__title", None],
|
||||||
|
"BirdingLocation": ["birding_location__title", "birding_location__description"],
|
||||||
}
|
}
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
|
|||||||
@ -163,10 +163,12 @@ INSTALLED_APPS = [
|
|||||||
"tasks",
|
"tasks",
|
||||||
"trails",
|
"trails",
|
||||||
"beers",
|
"beers",
|
||||||
|
"bgstats",
|
||||||
"puzzles",
|
"puzzles",
|
||||||
"foods",
|
"foods",
|
||||||
"lifeevents",
|
"lifeevents",
|
||||||
"moods",
|
"moods",
|
||||||
|
"birds",
|
||||||
"mathfilters",
|
"mathfilters",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
"allauth",
|
"allauth",
|
||||||
|
|||||||
46
vrobbler/templates/birds/bird_sightings_widget.html
Normal file
46
vrobbler/templates/birds/bird_sightings_widget.html
Normal 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">×</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">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary add-sighting-row mt-2">Add bird</button>
|
||||||
|
</div>
|
||||||
43
vrobbler/templates/birds/birdinglocation_detail.html
Normal file
43
vrobbler/templates/birds/birdinglocation_detail.html
Normal 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 %}
|
||||||
14
vrobbler/templates/birds/birdinglocation_list.html
Normal file
14
vrobbler/templates/birds/birdinglocation_list.html
Normal 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 %}
|
||||||
@ -77,6 +77,12 @@ from vrobbler.apps.beers.api.views import (
|
|||||||
BeerStyleViewSet,
|
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 import urls as puzzles_urls
|
||||||
from vrobbler.apps.puzzles.api.views import (
|
from vrobbler.apps.puzzles.api.views import (
|
||||||
PuzzleViewSet,
|
PuzzleViewSet,
|
||||||
@ -144,6 +150,9 @@ router.register(r"puzzles", PuzzleViewSet)
|
|||||||
router.register(r"bricksets", BrickSetViewSet)
|
router.register(r"bricksets", BrickSetViewSet)
|
||||||
router.register(r"lifeevents", LifeEventViewSet)
|
router.register(r"lifeevents", LifeEventViewSet)
|
||||||
|
|
||||||
|
router.register(r"birds", BirdViewSet)
|
||||||
|
router.register(r"birding-locations", BirdingLocationViewSet)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("api/v1/", include(router.urls)),
|
path("api/v1/", include(router.urls)),
|
||||||
path("api/v1/auth", include("rest_framework.urls")),
|
path("api/v1/auth", include("rest_framework.urls")),
|
||||||
@ -168,6 +177,7 @@ urlpatterns = [
|
|||||||
path("", include(podcast_urls, namespace="podcasts")),
|
path("", include(podcast_urls, namespace="podcasts")),
|
||||||
path("", include(lifeevents_urls, namespace="life-events")),
|
path("", include(lifeevents_urls, namespace="life-events")),
|
||||||
path("", include(moods_urls, namespace="moods")),
|
path("", include(moods_urls, namespace="moods")),
|
||||||
|
path("", include(birds_urls, namespace="birds")),
|
||||||
path("", include(scrobble_urls, namespace="scrobbles")),
|
path("", include(scrobble_urls, namespace="scrobbles")),
|
||||||
path("", include(profiles_urls, namespace="profiles")),
|
path("", include(profiles_urls, namespace="profiles")),
|
||||||
path("", include(people_urls, namespace="people")),
|
path("", include(people_urls, namespace="people")),
|
||||||
|
|||||||
Reference in New Issue
Block a user