[nature] Add iNaturalist observation importer and scrobbles

- New nature app with SpeciesObservation model (scrobblable)
- SpeciesObservationLogData for observation-specific fields
- Atom feed parser + iNat REST API enrichment
- Photo download and local storage
- Celery periodic import (every 4 hours)
- Management command: import_from_inaturalist
- Species fields: title, scientific_name, taxon_id, iconic_taxon_name, rank, wikipedia_url
- Log fields: observation_id, photo_urls, quality_grade, place_guess, lat/lon, uri, identifications_count
- Updates existing scrobbles when observation data changes
- iNaturalist user mapping on UserProfile
- Templates, admin, API, URLs
- Default 5 minute run time for species observations
This commit is contained in:
2026-07-16 23:43:21 -04:00
parent 00249e3d3b
commit c3673e2679
28 changed files with 846 additions and 5 deletions

View File

@ -88,7 +88,7 @@ fetching and simple saving.
*** Metadata sources
**** Scraper
* Backlog [1/25] :vrobbler:project:personal:
* Backlog [3/26] :vrobbler:project:personal:
** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata:
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
:PROPERTIES:
@ -596,6 +596,21 @@ The Edit log form should have from top to bottom:
- Expansion ids (which should a multi-select widget of expansions for this game)
- Location (which should be a drop down of BoardGameLocations for this user)
** TODO [#B] Auto sync board game scrobbles to BGG :boardgames:
*** Description
** DONE [#B] Try adding a nature app with Observations and Species
:PROPERTIES:
:ID: 9551cf06-02b7-1bc9-3600-b0407ecc5967
:END:
*** Description
iNaturalist has a feed in atom.
** DONE [#A] Add ability to push one or sync all board game scrobbles to BGG :boardgames:
:PROPERTIES:
:ID: 1f306552-35e8-4a75-a661-b0956e8de967

View File

View File

@ -0,0 +1,14 @@
from django.contrib import admin
from nature.models import SpeciesObservation
from scrobbles.admin import ScrobbleInline
@admin.register(SpeciesObservation)
class SpeciesObservationAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("uuid", "title", "scientific_name", "taxon_id")
ordering = ("-created",)
search_fields = ("title", "scientific_name")
inlines = [
ScrobbleInline,
]

View File

View File

@ -0,0 +1,8 @@
from nature.models import SpeciesObservation
from rest_framework import serializers
class SpeciesObservationSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = SpeciesObservation
fields = "__all__"

View File

@ -0,0 +1,9 @@
from nature import models
from nature.api import serializers
from rest_framework import permissions, viewsets
class SpeciesObservationViewSet(viewsets.ModelViewSet):
queryset = models.SpeciesObservation.objects.all().order_by("-created")
serializer_class = serializers.SpeciesObservationSerializer
permission_classes = [permissions.IsAuthenticated]

View File

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

View File

@ -0,0 +1,312 @@
import logging
import re
import xml.etree.ElementTree as ET
from datetime import timedelta
import requests
from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile
from django.utils.dateparse import parse_datetime
from nature.models import SpeciesObservation, SpeciesObservationLogData
from scrobbles.models import Scrobble
logger = logging.getLogger(__name__)
User = get_user_model()
INATURALIST_ATOM_URL = "https://www.inaturalist.org/observations.atom"
INATURALIST_API_URL = "https://api.inaturalist.org/v1/observations"
ATOM_NS = "http://www.w3.org/2005/Atom"
GEORSS_NS = "http://www.georss.org/georss"
def fetch_atom_feed(username: str) -> str:
"""Fetch the Atom feed for a given iNaturalist username."""
url = (
f"{INATURALIST_ATOM_URL}?verifiable=any&page=1&spam=&place_id=any"
f"&user_id={username}&project_id="
)
response = requests.get(url, timeout=30)
response.raise_for_status()
return response.text
def parse_atom_entries(xml_text: str) -> list[dict]:
"""Parse Atom XML and extract observation entries."""
root = ET.fromstring(xml_text)
entries = []
for entry in root.findall(f"{{{ATOM_NS}}}entry"):
entry_id = entry.findtext(f"{{{ATOM_NS}}}id", "")
observation_id = entry_id.split("/")[-1] if "/" in entry_id else entry_id
title = entry.findtext(f"{{{ATOM_NS}}}title", "")
published = entry.findtext(f"{{{ATOM_NS}}}published", "")
updated = entry.findtext(f"{{{ATOM_NS}}}updated", "")
content_el = entry.find(f"{{{ATOM_NS}}}content")
photo_urls = []
if content_el is not None and content_el.text:
photo_urls = re.findall(r'src="([^"]+)"', content_el.text)
georss_point = entry.findtext(f"{{{GEORSS_NS}}}point", "")
georss_feature = entry.findtext(f"{{{GEORSS_NS}}}featureName", "")
lat, lon = None, None
if georss_point:
parts = georss_point.strip().split()
if len(parts) == 2:
lat, lon = float(parts[0]), float(parts[1])
entries.append(
{
"observation_id": int(observation_id),
"title": title,
"published": published,
"updated": updated,
"photo_urls": photo_urls,
"lat": lat,
"lon": lon,
"place_guess": georss_feature,
}
)
return entries
def fetch_observation_detail(observation_id: int) -> dict:
"""Fetch detailed observation data from the iNaturalist REST API."""
url = f"{INATURALIST_API_URL}/{observation_id}"
response = requests.get(url, timeout=30)
response.raise_for_status()
data = response.json()
if data.get("results"):
return data["results"][0]
return {}
def download_photo(url: str) -> tuple[str, bytes] | None:
"""Download a photo and return (filename, content)."""
try:
response = requests.get(url, timeout=30)
response.raise_for_status()
filename = url.split("/")[-1].split("?")[0]
if not filename.endswith((".jpg", ".jpeg", ".png", ".webp")):
filename = "observation.jpg"
return filename, response.content
except Exception:
logger.exception(f"Failed to download photo: {url}")
return None
def _update_species_photo(species: SpeciesObservation, taxon: dict) -> None:
"""Download and save the default taxon photo for a species, if needed."""
if species.photo is not None:
return
default_photo = taxon.get("default_photo", {})
photo_url = default_photo.get("url")
if not photo_url:
return
photo_url = photo_url.replace("square", "medium")
downloaded = download_photo(photo_url)
if downloaded:
filename, content = downloaded
species.photo.save(filename, ContentFile(content), save=True)
def import_observations_for_user(user) -> list[int]:
"""Import iNaturalist observations for a single user.
Returns a list of created Scrobble IDs.
"""
profile = getattr(user, "profile", None)
if not profile or not profile.inaturalist_username:
logger.info(f"No iNaturalist username for user {user}, skipping")
return []
username = profile.inaturalist_username
logger.info(f"Importing iNaturalist observations for {username}")
created_scrobble_ids = []
try:
xml_text = fetch_atom_feed(username)
entries = parse_atom_entries(xml_text)
except Exception:
logger.exception(f"Failed to fetch/parse Atom feed for {username}")
return []
for entry_data in entries:
try:
scrobble_id = _process_entry(user, entry_data)
if scrobble_id:
created_scrobble_ids.append(scrobble_id)
except Exception:
logger.exception(
f"Failed to process observation {entry_data['observation_id']}"
)
logger.info(f"Imported {len(created_scrobble_ids)} observations for {username}")
return created_scrobble_ids
def _process_entry(user, entry_data: dict) -> int | None:
"""Process a single Atom entry into a SpeciesObservation and Scrobble.
Each iNaturalist observation becomes a new Scrobble. If a Scrobble
already exists for this observation, check whether the title or
species data has changed and update in place.
Returns the Scrobble ID.
"""
observation_id = entry_data["observation_id"]
entry_title = entry_data["title"]
existing_scrobble = Scrobble.objects.filter(
species_observation__isnull=False,
user=user,
source_id=str(observation_id),
).first()
detail = fetch_observation_detail(observation_id)
species = None
taxon = detail.get("taxon", {})
if taxon:
common_name = taxon.get("preferred_common_name", entry_title)
scientific_name = taxon.get("name", "")
taxon_id = taxon.get("id")
iconic_taxon_name = taxon.get("iconic_taxon_name", "")
taxon_rank = taxon.get("rank", "")
wikipedia_url = taxon.get("wikipedia_url", "") or ""
if taxon_id and common_name:
species = SpeciesObservation.find_or_create(
common_name,
scientific_name,
taxon_id,
iconic_taxon_name=iconic_taxon_name,
rank=taxon_rank,
wikipedia_url=wikipedia_url,
)
_update_species_photo(species, taxon)
changed_fields = []
if species.title != common_name:
species.title = common_name
changed_fields.append("title")
if species.iconic_taxon_name != iconic_taxon_name:
species.iconic_taxon_name = iconic_taxon_name
changed_fields.append("iconic_taxon_name")
if species.rank != taxon_rank:
species.rank = taxon_rank
changed_fields.append("rank")
if species.wikipedia_url != wikipedia_url:
species.wikipedia_url = wikipedia_url
changed_fields.append("wikipedia_url")
if changed_fields:
species.save(update_fields=changed_fields)
quality_grade = detail.get("quality_grade", "")
place_guess = detail.get("place_guess", entry_data.get("place_guess", ""))
description = detail.get("description", "")
coords = detail.get("geojson", {}).get("coordinates")
if coords and len(coords) == 2:
lon, lat = coords[0], coords[1]
else:
lat = entry_data.get("lat")
lon = entry_data.get("lon")
observed_at = detail.get("time_observed_at") or detail.get("observed_on")
photo_url = entry_data["photo_urls"][0] if entry_data["photo_urls"] else None
all_photo_urls = entry_data.get("photo_urls", [])
if detail.get("photos"):
all_photo_urls = [
p.get("url", "").replace("square", "medium") for p in detail["photos"]
]
uri = detail.get("uri", "")
identifications_count = detail.get("identifications_count", 0)
log_data = SpeciesObservationLogData(
observation_id=observation_id,
photo_urls=all_photo_urls,
quality_grade=quality_grade,
place_guess=place_guess,
lat=lat,
lon=lon,
description=description or "",
uri=uri or "",
identifications_count=identifications_count,
)
if existing_scrobble:
changed = False
if species and existing_scrobble.species_observation_id != species.id:
existing_scrobble.species_observation = species
changed = True
if existing_scrobble.log.get("place_guess") != place_guess:
changed = True
if existing_scrobble.log.get("quality_grade") != quality_grade:
changed = True
if existing_scrobble.log.get("photo_urls") != all_photo_urls:
changed = True
if existing_scrobble.log.get("uri") != uri:
changed = True
if existing_scrobble.log.get("identifications_count") != identifications_count:
changed = True
if not changed:
logger.debug(f"Observation {observation_id} unchanged, skipping")
return existing_scrobble.id
logger.info(f"Observation {observation_id} updated, refreshing scrobble")
existing_scrobble.log = log_data.asdict
existing_scrobble.save(update_fields=["log", "species_observation"])
return existing_scrobble.id
timestamp = parse_datetime(observed_at) if observed_at else None
stop_timestamp = timestamp + timedelta(minutes=5) if timestamp else None
scrobble = Scrobble.objects.create(
user=user,
species_observation=species,
media_type=Scrobble.MediaType.SPECIES_OBSERVATION,
source="iNaturalist",
source_id=str(observation_id),
timestamp=timestamp,
stop_timestamp=stop_timestamp,
played_to_completion=True,
in_progress=False,
log=log_data.asdict,
)
if photo_url:
downloaded = download_photo(photo_url)
if downloaded:
filename, content = downloaded
scrobble.screenshot.save(filename, ContentFile(content), save=True)
return scrobble.id
def import_observations_for_all_users() -> list[int]:
"""Import iNaturalist observations for all users with auto-import enabled."""
users = User.objects.filter(
profile__inaturalist_auto_import=True,
profile__inaturalist_username__isnull=False,
).exclude(profile__inaturalist_username="")
all_scrobble_ids = []
for user in users:
scrobble_ids = import_observations_for_user(user)
all_scrobble_ids.extend(scrobble_ids)
return all_scrobble_ids

View File

@ -0,0 +1,10 @@
from django.core.management.base import BaseCommand
from nature.importer import import_observations_for_all_users
class Command(BaseCommand):
help = "Import iNaturalist observations for all users with auto-import enabled"
def handle(self, *args, **options):
count = len(import_observations_for_all_users())
self.stdout.write(f"Imported {count} observations")

View File

@ -0,0 +1,88 @@
# Generated by Django 4.2.29 on 2026-07-17 02:42
import uuid
import django_extensions.db.fields
import taggit.managers
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("scrobbles", "0102_favoritemedia_drink_scrobble_drink_and_more"),
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
]
operations = [
migrations.CreateModel(
name="SpeciesObservation",
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)),
(
"scientific_name",
models.CharField(blank=True, max_length=255, null=True),
),
("taxon_id", models.IntegerField(db_index=True, unique=True)),
(
"photo",
models.ImageField(
blank=True, null=True, upload_to="nature/species/"
),
),
(
"genre",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Genre",
),
),
(
"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,28 @@
# Generated by Django 4.2.29 on 2026-07-17 03:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("nature", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="speciesobservation",
name="iconic_taxon_name",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="speciesobservation",
name="rank",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="speciesobservation",
name="wikipedia_url",
field=models.URLField(blank=True, max_length=1024, null=True),
),
]

View File

@ -0,0 +1,101 @@
import logging
from dataclasses import dataclass
from typing import Optional
from django.db import models
from django.urls import reverse
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
@dataclass
class SpeciesObservationLogData(BaseLogData, WithPeopleLogData):
observation_id: Optional[int] = None
photo_urls: Optional[list[str]] = None
quality_grade: Optional[str] = None
place_guess: Optional[str] = None
lat: Optional[float] = None
lon: Optional[float] = None
uri: Optional[str] = None
identifications_count: Optional[int] = None
class SpeciesObservation(ScrobblableMixin):
scientific_name = models.CharField(max_length=255, **BNULL)
taxon_id = models.IntegerField(unique=True, db_index=True)
iconic_taxon_name = models.CharField(max_length=255, **BNULL)
rank = models.CharField(max_length=255, **BNULL)
wikipedia_url = models.URLField(max_length=1024, **BNULL)
photo = models.ImageField(upload_to="nature/species/", **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):
if self.scientific_name:
return f"{self.title} ({self.scientific_name})"
return self.title or str(self.uuid)
def get_absolute_url(self):
return reverse("nature:species_detail", kwargs={"slug": self.uuid})
@property
def subtitle(self):
return self.scientific_name or ""
@property
def run_time_seconds(self) -> int:
return self.base_run_time_seconds or 300
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Observing", tags="leaf")
@property
def logdata_cls(self):
return SpeciesObservationLogData
@property
def primary_image_url(self) -> str:
if self.photo:
return self.photo.url
return ""
def fix_metadata(self) -> None:
pass
@classmethod
def find_or_create(
cls,
common_name: str,
scientific_name: str,
taxon_id: int,
iconic_taxon_name: str = "",
rank: str = "",
wikipedia_url: str = "",
) -> "SpeciesObservation":
species = cls.objects.filter(taxon_id=taxon_id).first()
if not species:
species = cls.objects.create(
title=common_name,
scientific_name=scientific_name,
taxon_id=taxon_id,
iconic_taxon_name=iconic_taxon_name,
rank=rank,
wikipedia_url=wikipedia_url or "",
)
return species

View File

@ -0,0 +1,13 @@
import logging
from celery import shared_task
logger = logging.getLogger(__name__)
@shared_task
def import_from_inaturalist_all_users():
"""Import iNaturalist observations for all users."""
from nature.importer import import_observations_for_all_users
import_observations_for_all_users()

View File

@ -0,0 +1,17 @@
from django.urls import path
from nature import views
app_name = "nature"
urlpatterns = [
path(
"observations/",
views.SpeciesObservationListView.as_view(),
name="species_observation_list",
),
path(
"observations/<slug:slug>/",
views.SpeciesObservationDetailView.as_view(),
name="species_observation_detail",
),
]

View File

@ -0,0 +1,11 @@
from django.views import generic
from nature.models import SpeciesObservation
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
class SpeciesObservationListView(ScrobbleableListView):
model = SpeciesObservation
class SpeciesObservationDetailView(ScrobbleableDetailView):
model = SpeciesObservation

View File

@ -26,6 +26,9 @@ class UserProfileForm(forms.ModelForm):
"archivebox_url",
"bgg_username",
"lichess_username",
"inaturalist_username",
"inaturalist_user_id",
"inaturalist_auto_import",
"webdav_url",
"webdav_user",
"webdav_pass",

View File

@ -0,0 +1,28 @@
# Generated by Django 4.2.29 on 2026-07-17 02:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0043_add_water_reminder"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="inaturalist_auto_import",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userprofile",
name="inaturalist_user_id",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="userprofile",
name="inaturalist_username",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -154,6 +154,10 @@ class UserProfile(TimeStampedModel):
help_text="List of trend slugs the user has disabled",
)
inaturalist_username = models.CharField(max_length=255, blank=True, null=True)
inaturalist_user_id = models.IntegerField(blank=True, null=True)
inaturalist_auto_import = models.BooleanField(default=False)
def __str__(self):
return f"User profile for {self.user}"

View File

@ -54,6 +54,7 @@ PLAY_AGAIN_MEDIA = {
"channels": "Channel",
"birds": "BirdingLocation",
"discgolf": "DiscGolfCourse",
"nature": "SpeciesObservation",
}
DRINK_MODELS = ["Beer", "Wine", "Coffee", "Drink"]

View File

@ -0,0 +1,94 @@
# Generated by Django 4.2.29 on 2026-07-17 02:42
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("nature", "0001_initial"),
("scrobbles", "0102_favoritemedia_drink_scrobble_drink_and_more"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="species_observation",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="nature.speciesobservation",
),
),
migrations.AlterField(
model_name="favoritemedia",
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"),
("Wine", "Wine"),
("Coffee", "Coffee"),
("Drink", "Drink"),
("Puzzle", "Puzzle"),
("Food", "Food"),
("Task", "Task"),
("WebPage", "Web Page"),
("LifeEvent", "Life event"),
("Mood", "Mood"),
("BrickSet", "Brick set"),
("Channel", "Channel"),
("BirdingLocation", "Birding location"),
("DiscGolfCourse", "Disc golf"),
("SpeciesObservation", "Species observation"),
],
max_length=20,
),
),
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"),
("Wine", "Wine"),
("Coffee", "Coffee"),
("Drink", "Drink"),
("Puzzle", "Puzzle"),
("Food", "Food"),
("Task", "Task"),
("WebPage", "Web Page"),
("LifeEvent", "Life event"),
("Mood", "Mood"),
("BrickSet", "Brick set"),
("Channel", "Channel"),
("BirdingLocation", "Birding location"),
("DiscGolfCourse", "Disc golf"),
("SpeciesObservation", "Species observation"),
],
default="Video",
max_length=20,
),
),
]

View File

@ -9,24 +9,22 @@ from zoneinfo import ZoneInfo
import pendulum
import pytz
from drinks.models import Beer, Wine, Coffee, Drink
from birds.models import BirdingLocation
from discgolf.models import DiscGolfCourse
from boardgames.models import BoardGame
from books.koreader import process_koreader_sqlite_file
from books.models import Book, BookLogData, BookPageLogData, Paper
from bricksets.models import BrickSet
from charts.utils import build_charts
from dataclass_wizard.errors import ParseError
from discgolf.models import DiscGolfCourse
from django.conf import settings
from scrobbles.constants import Visibility
from scrobbles.sqids import encode_scrobble_share
from django.contrib.auth import get_user_model
from django.core.files import File
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django_extensions.db.models import TimeStampedModel
from drinks.models import Beer, Coffee, Drink, Wine
from foods.models import Food, FoodLogData
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
@ -34,6 +32,7 @@ from lifeevents.models import LifeEvent
from locations.models import GeoLocation
from moods.models import Mood
from music.models import Artist, Track
from nature.models import SpeciesObservation
from podcasts.models import PodcastEpisode
from profiles.utils import (
end_of_day,
@ -50,12 +49,14 @@ from scrobbles.constants import (
AUTO_FINISH_MEDIA,
LONG_PLAY_MEDIA,
MEDIA_END_PADDING_SECONDS,
Visibility,
)
from scrobbles.importers.lastfm import LastFM
from scrobbles.notifications import (
LastFmImportNtfyNotification,
ScrobbleNtfyNotification,
)
from scrobbles.sqids import encode_scrobble_share
from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
from sports.models import SportEvent
from taggit.managers import TaggableManager
@ -697,6 +698,7 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
"Channel": ("channel",),
"BirdingLocation": ("birding_location",),
"DiscGolfCourse": ("disc_golf_course",),
"SpeciesObservation": ("species_observation",),
}
@ -728,6 +730,7 @@ class ScrobbleQuerySet(models.QuerySet):
"brick_set",
"birding_location",
"disc_golf_course",
"species_observation",
)
def with_related_for_types(self, media_types: list[str]):
@ -782,6 +785,7 @@ class Scrobble(TimeStampedModel):
CHANNEL = "Channel", "Channel"
BIRDING_LOCATION = "BirdingLocation", "Birding location"
DISC_GOLF = "DiscGolfCourse", "Disc golf"
SPECIES_OBSERVATION = "SpeciesObservation", "Species observation"
@classmethod
def list(cls):
@ -818,6 +822,9 @@ class Scrobble(TimeStampedModel):
disc_golf_course = models.ForeignKey(
DiscGolfCourse, on_delete=models.DO_NOTHING, **BNULL
)
species_observation = models.ForeignKey(
SpeciesObservation, on_delete=models.DO_NOTHING, **BNULL
)
media_type = models.CharField(
max_length=20, choices=MediaType.choices, default=MediaType.VIDEO
)
@ -1402,6 +1409,8 @@ class Scrobble(TimeStampedModel):
media_obj = self.paper
if self.disc_golf_course:
media_obj = self.disc_golf_course
if self.species_observation:
media_obj = self.species_observation
return media_obj
def __str__(self):

View File

@ -198,6 +198,10 @@ CELERY_BEAT_SCHEDULE = {
"task": "scrobbles.tasks.backfill_scrobble_sentiment",
"schedule": crontab(minute="0"),
},
"import-from-inaturalist": {
"task": "nature.tasks.import_from_inaturalist_all_users",
"schedule": crontab(hour="*/4", minute=30),
},
}
INSTALLED_APPS = [
@ -242,6 +246,7 @@ INSTALLED_APPS = [
"moods",
"discgolf",
"birds",
"nature",
"mathfilters",
"rest_framework",
"allauth",

View File

@ -0,0 +1,52 @@
{% extends "base_list.html" %}
{% block title %}{{ object.title }}{% endblock %}
{% block lists %}
<div class="row">
<div class="summary">
{% if object.photo %}
<div style="float: left; margin-right: 15px; margin-bottom: 15px;">
<img src="{{ object.photo.url }}" style="max-width: 300px; max-height: 300px; object-fit: contain;">
</div>
{% endif %}
{% if object.scientific_name %}
<p><em>{{ object.scientific_name }}</em></p>
{% endif %}
<p><strong>Taxon ID:</strong> {{ object.taxon_id }}</p>
<p><a href="https://www.inaturalist.org/taxa/{{ object.taxon_id }}" target="_blank">View on iNaturalist</a></p>
<hr />
</div>
</div>
<div class="row">
<p>{{ scrobbles.count }} observations</p>
</div>
<div class="row">
<div class="col-md">
<h3>Recent observations</h3>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Location</th>
<th scope="col">Quality</th>
<th scope="col">Source</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.place_guess|default:"—" }}</td>
<td>{{ scrobble.logdata.quality_grade|default:"—" }}</td>
<td>{{ scrobble.source }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "base_list.html" %}
{% block title %}Species Observed{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">{% include "_scrobblable_list.html" %}</div>
</div>
</div>
{% endblock %}

View File

@ -43,6 +43,8 @@ from vrobbler.apps.locations import urls as locations_urls
from vrobbler.apps.locations.api.views import GeoLocationViewSet
from vrobbler.apps.moods import urls as moods_urls
from vrobbler.apps.moods.api.views import MoodViewSet
from vrobbler.apps.nature import urls as nature_urls
from vrobbler.apps.nature.api.views import SpeciesObservationViewSet
from vrobbler.apps.music import urls as music_urls
from vrobbler.apps.music.api.views import (
AlbumViewSet,
@ -148,6 +150,7 @@ router.register(r"lifeevents", LifeEventViewSet)
router.register(r"birds", BirdViewSet)
router.register(r"birding-locations", BirdingLocationViewSet)
router.register(r"disc-golf-courses", DiscGolfCourseViewSet)
router.register(r"observations", SpeciesObservationViewSet)
urlpatterns = [
path("api/v1/", include(router.urls)),
path("api/v1/auth", include("rest_framework.urls")),
@ -174,6 +177,7 @@ urlpatterns = [
path("", include(lifeevents_urls, namespace="life-events")),
path("", include(moods_urls, namespace="moods")),
path("", include(birds_urls, namespace="birds")),
path("", include(nature_urls, namespace="nature")),
path("", include(scrobble_urls, namespace="scrobbles")),
path("", include(profiles_urls, namespace="profiles")),
path("", include(people_urls, namespace="people")),