diff
ignore whitespace
commit c3673e267985077f1068a3d91f327837668fe92dAuthor: Colin Powell <colin@unbl.ink>Date: Thu Jul 16 23:43:21 2026 -0400 [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 observationsNotes (sessions): { "version": 1, "commit_sha": "c3673e267985077f1068a3d91f327837668fe92d", "message": "[nature] Add iNaturalist observation importer and scrobbles\n\n- New nature app with SpeciesObservation model (scrobblable)\n- SpeciesObservationLogData for observation-specific fields\n- Atom feed parser + iNat REST API enrichment\n- Photo download and local storage\n- Celery periodic import (every 4 hours)\n- Management command: import_from_inaturalist\n- Species fields: title, scientific_name, taxon_id, iconic_taxon_name, rank, wikipedia_url\n- Log fields: observation_id, photo_urls, quality_grade, place_guess, lat/lon, uri, identifications_count\n- Updates existing scrobbles when observation data changes\n- iNaturalist user mapping on UserProfile\n- Templates, admin, API, URLs\n- Default 5 minute run time for species observations", "prompt": "Build a nature app that imports iNaturalist observations as scrobbles, using Atom feed for discovery and iNat REST API for enrichment, with photo download and periodic Celery import", "agent": "opencode", "author": "Colin Powell \u003ccolin@unbl.ink\u003e", "timestamp": "2026-07-16T23:43:21.895744356-04:00", "diff_stat": "PROJECT.org | 17 +-\n vrobbler/apps/nature/__init__.py | 0\n vrobbler/apps/nature/admin.py | 14 +\n vrobbler/apps/nature/api/__init__.py | 0\n vrobbler/apps/nature/api/serializers.py | 8 +\n vrobbler/apps/nature/api/views.py | 9 +\n vrobbler/apps/nature/apps.py | 5 +\n vrobbler/apps/nature/importer.py | 312 +++++++++++++++++++++\n vrobbler/apps/nature/management/__init__.py | 0\n .../apps/nature/management/commands/__init__.py | 0\n .../management/commands/import_from_inaturalist.py | 10 +\n vrobbler/apps/nature/migrations/0001_initial.py | 88 ++++++\n ...peciesobservation_iconic_taxon_name_and_more.py | 28 ++\n vrobbler/apps/nature/migrations/__init__.py | 0\n vrobbler/apps/nature/models.py | 101 +++++++\n vrobbler/apps/nature/tasks.py | 13 +\n vrobbler/apps/nature/urls.py | 17 ++\n vrobbler/apps/nature/views.py | 11 +\n vrobbler/apps/profiles/forms.py | 3 +\n ...userprofile_inaturalist_auto_import_and_more.py | 28 ++\n vrobbler/apps/profiles/models.py | 4 +\n vrobbler/apps/scrobbles/constants.py | 1 +\n .../0103_scrobble_species_observation_and_more.py | 94 +++++++\n vrobbler/apps/scrobbles/models.py | 17 +-\n vrobbler/settings.py | 5 +\n .../nature/species_observation_detail.html | 52 ++++\n .../templates/nature/species_observation_list.html | 10 +\n vrobbler/urls.py | 4 +\n 28 files changed, 846 insertions(+), 5 deletions(-)" }diff --git a/PROJECT.org b/PROJECT.orgindex b474e67..05e2e95 100644--- a/PROJECT.org+++ b/PROJECT.org@@ -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-b0956e8de967diff --git a/vrobbler/apps/nature/__init__.py b/vrobbler/apps/nature/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/nature/admin.py b/vrobbler/apps/nature/admin.pynew file mode 100644index 0000000..2f2b854--- /dev/null+++ b/vrobbler/apps/nature/admin.py@@ -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,+ ]diff --git a/vrobbler/apps/nature/api/__init__.py b/vrobbler/apps/nature/api/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/nature/api/serializers.py b/vrobbler/apps/nature/api/serializers.pynew file mode 100644index 0000000..bd533c1--- /dev/null+++ b/vrobbler/apps/nature/api/serializers.py@@ -0,0 +1,8 @@+from nature.models import SpeciesObservation+from rest_framework import serializers+++class SpeciesObservationSerializer(serializers.HyperlinkedModelSerializer):+ class Meta:+ model = SpeciesObservation+ fields = "__all__"diff --git a/vrobbler/apps/nature/api/views.py b/vrobbler/apps/nature/api/views.pynew file mode 100644index 0000000..a209d93--- /dev/null+++ b/vrobbler/apps/nature/api/views.py@@ -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]diff --git a/vrobbler/apps/nature/apps.py b/vrobbler/apps/nature/apps.pynew file mode 100644index 0000000..974fbd9--- /dev/null+++ b/vrobbler/apps/nature/apps.py@@ -0,0 +1,5 @@+from django.apps import AppConfig+++class NatureConfig(AppConfig):+ name = "nature"diff --git a/vrobbler/apps/nature/importer.py b/vrobbler/apps/nature/importer.pynew file mode 100644index 0000000..16ed438--- /dev/null+++ b/vrobbler/apps/nature/importer.py@@ -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_idsdiff --git a/vrobbler/apps/nature/management/__init__.py b/vrobbler/apps/nature/management/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/nature/management/commands/__init__.py b/vrobbler/apps/nature/management/commands/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/nature/management/commands/import_from_inaturalist.py b/vrobbler/apps/nature/management/commands/import_from_inaturalist.pynew file mode 100644index 0000000..d9233b1--- /dev/null+++ b/vrobbler/apps/nature/management/commands/import_from_inaturalist.py@@ -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")diff --git a/vrobbler/apps/nature/migrations/0001_initial.py b/vrobbler/apps/nature/migrations/0001_initial.pynew file mode 100644index 0000000..c97eb42--- /dev/null+++ b/vrobbler/apps/nature/migrations/0001_initial.py@@ -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,+ },+ ),+ ]diff --git a/vrobbler/apps/nature/migrations/0002_speciesobservation_iconic_taxon_name_and_more.py b/vrobbler/apps/nature/migrations/0002_speciesobservation_iconic_taxon_name_and_more.pynew file mode 100644index 0000000..b44c98d--- /dev/null+++ b/vrobbler/apps/nature/migrations/0002_speciesobservation_iconic_taxon_name_and_more.py@@ -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),+ ),+ ]diff --git a/vrobbler/apps/nature/migrations/__init__.py b/vrobbler/apps/nature/migrations/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/nature/models.py b/vrobbler/apps/nature/models.pynew file mode 100644index 0000000..654808a--- /dev/null+++ b/vrobbler/apps/nature/models.py@@ -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 speciesdiff --git a/vrobbler/apps/nature/tasks.py b/vrobbler/apps/nature/tasks.pynew file mode 100644index 0000000..82c0afd--- /dev/null+++ b/vrobbler/apps/nature/tasks.py@@ -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()diff --git a/vrobbler/apps/nature/urls.py b/vrobbler/apps/nature/urls.pynew file mode 100644index 0000000..daa5da1--- /dev/null+++ b/vrobbler/apps/nature/urls.py@@ -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",+ ),+]diff --git a/vrobbler/apps/nature/views.py b/vrobbler/apps/nature/views.pynew file mode 100644index 0000000..3e064bb--- /dev/null+++ b/vrobbler/apps/nature/views.py@@ -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 = SpeciesObservationdiff --git a/vrobbler/apps/profiles/forms.py b/vrobbler/apps/profiles/forms.pyindex 448a54e..60f4c38 100644--- a/vrobbler/apps/profiles/forms.py+++ b/vrobbler/apps/profiles/forms.py@@ -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",diff --git a/vrobbler/apps/profiles/migrations/0044_userprofile_inaturalist_auto_import_and_more.py b/vrobbler/apps/profiles/migrations/0044_userprofile_inaturalist_auto_import_and_more.pynew file mode 100644index 0000000..2b5540b--- /dev/null+++ b/vrobbler/apps/profiles/migrations/0044_userprofile_inaturalist_auto_import_and_more.py@@ -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),+ ),+ ]diff --git a/vrobbler/apps/profiles/models.py b/vrobbler/apps/profiles/models.pyindex 1ae9b69..b02254d 100644--- a/vrobbler/apps/profiles/models.py+++ b/vrobbler/apps/profiles/models.py@@ -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}" diff --git a/vrobbler/apps/scrobbles/constants.py b/vrobbler/apps/scrobbles/constants.pyindex bc85c59..4a0fbf9 100644--- a/vrobbler/apps/scrobbles/constants.py+++ b/vrobbler/apps/scrobbles/constants.py@@ -54,6 +54,7 @@ PLAY_AGAIN_MEDIA = { "channels": "Channel", "birds": "BirdingLocation", "discgolf": "DiscGolfCourse",+ "nature": "SpeciesObservation", } DRINK_MODELS = ["Beer", "Wine", "Coffee", "Drink"]diff --git a/vrobbler/apps/scrobbles/migrations/0103_scrobble_species_observation_and_more.py b/vrobbler/apps/scrobbles/migrations/0103_scrobble_species_observation_and_more.pynew file mode 100644index 0000000..34762fe--- /dev/null+++ b/vrobbler/apps/scrobbles/migrations/0103_scrobble_species_observation_and_more.py@@ -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,+ ),+ ),+ ]diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.pyindex 6ebab4a..53bfff8 100644--- a/vrobbler/apps/scrobbles/models.py+++ b/vrobbler/apps/scrobbles/models.py@@ -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):diff --git a/vrobbler/settings.py b/vrobbler/settings.pyindex d6fc699..eb09a84 100644--- a/vrobbler/settings.py+++ b/vrobbler/settings.py@@ -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",diff --git a/vrobbler/templates/nature/species_observation_detail.html b/vrobbler/templates/nature/species_observation_detail.htmlnew file mode 100644index 0000000..ae2899c--- /dev/null+++ b/vrobbler/templates/nature/species_observation_detail.html@@ -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 %}diff --git a/vrobbler/templates/nature/species_observation_list.html b/vrobbler/templates/nature/species_observation_list.htmlnew file mode 100644index 0000000..3484865--- /dev/null+++ b/vrobbler/templates/nature/species_observation_list.html@@ -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 %}diff --git a/vrobbler/urls.py b/vrobbler/urls.pyindex 894fa48..74586d8 100644--- a/vrobbler/urls.py+++ b/vrobbler/urls.py@@ -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")),