From c3673e267985077f1068a3d91f327837668fe92d Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Thu, 16 Jul 2026 23:43:21 -0400 Subject: [PATCH] [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 --- PROJECT.org | 17 +- vrobbler/apps/nature/__init__.py | 0 vrobbler/apps/nature/admin.py | 14 + vrobbler/apps/nature/api/__init__.py | 0 vrobbler/apps/nature/api/serializers.py | 8 + vrobbler/apps/nature/api/views.py | 9 + vrobbler/apps/nature/apps.py | 5 + vrobbler/apps/nature/importer.py | 312 ++++++++++++++++++ vrobbler/apps/nature/management/__init__.py | 0 .../nature/management/commands/__init__.py | 0 .../commands/import_from_inaturalist.py | 10 + .../apps/nature/migrations/0001_initial.py | 88 +++++ ...sobservation_iconic_taxon_name_and_more.py | 28 ++ vrobbler/apps/nature/migrations/__init__.py | 0 vrobbler/apps/nature/models.py | 101 ++++++ vrobbler/apps/nature/tasks.py | 13 + vrobbler/apps/nature/urls.py | 17 + vrobbler/apps/nature/views.py | 11 + vrobbler/apps/profiles/forms.py | 3 + ...rofile_inaturalist_auto_import_and_more.py | 28 ++ vrobbler/apps/profiles/models.py | 4 + vrobbler/apps/scrobbles/constants.py | 1 + ...3_scrobble_species_observation_and_more.py | 94 ++++++ vrobbler/apps/scrobbles/models.py | 17 +- vrobbler/settings.py | 5 + .../nature/species_observation_detail.html | 52 +++ .../nature/species_observation_list.html | 10 + vrobbler/urls.py | 4 + 28 files changed, 846 insertions(+), 5 deletions(-) create mode 100644 vrobbler/apps/nature/__init__.py create mode 100644 vrobbler/apps/nature/admin.py create mode 100644 vrobbler/apps/nature/api/__init__.py create mode 100644 vrobbler/apps/nature/api/serializers.py create mode 100644 vrobbler/apps/nature/api/views.py create mode 100644 vrobbler/apps/nature/apps.py create mode 100644 vrobbler/apps/nature/importer.py create mode 100644 vrobbler/apps/nature/management/__init__.py create mode 100644 vrobbler/apps/nature/management/commands/__init__.py create mode 100644 vrobbler/apps/nature/management/commands/import_from_inaturalist.py create mode 100644 vrobbler/apps/nature/migrations/0001_initial.py create mode 100644 vrobbler/apps/nature/migrations/0002_speciesobservation_iconic_taxon_name_and_more.py create mode 100644 vrobbler/apps/nature/migrations/__init__.py create mode 100644 vrobbler/apps/nature/models.py create mode 100644 vrobbler/apps/nature/tasks.py create mode 100644 vrobbler/apps/nature/urls.py create mode 100644 vrobbler/apps/nature/views.py create mode 100644 vrobbler/apps/profiles/migrations/0044_userprofile_inaturalist_auto_import_and_more.py create mode 100644 vrobbler/apps/scrobbles/migrations/0103_scrobble_species_observation_and_more.py create mode 100644 vrobbler/templates/nature/species_observation_detail.html create mode 100644 vrobbler/templates/nature/species_observation_list.html diff --git a/PROJECT.org b/PROJECT.org index 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-b0956e8de967 diff --git a/vrobbler/apps/nature/__init__.py b/vrobbler/apps/nature/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/nature/admin.py b/vrobbler/apps/nature/admin.py new file mode 100644 index 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__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/nature/api/serializers.py b/vrobbler/apps/nature/api/serializers.py new file mode 100644 index 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.py new file mode 100644 index 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.py new file mode 100644 index 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.py new file mode 100644 index 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_ids diff --git a/vrobbler/apps/nature/management/__init__.py b/vrobbler/apps/nature/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/nature/management/commands/__init__.py b/vrobbler/apps/nature/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/nature/management/commands/import_from_inaturalist.py b/vrobbler/apps/nature/management/commands/import_from_inaturalist.py new file mode 100644 index 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.py new file mode 100644 index 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.py new file mode 100644 index 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__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/nature/models.py b/vrobbler/apps/nature/models.py new file mode 100644 index 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 species diff --git a/vrobbler/apps/nature/tasks.py b/vrobbler/apps/nature/tasks.py new file mode 100644 index 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.py new file mode 100644 index 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//", + views.SpeciesObservationDetailView.as_view(), + name="species_observation_detail", + ), +] diff --git a/vrobbler/apps/nature/views.py b/vrobbler/apps/nature/views.py new file mode 100644 index 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 = SpeciesObservation diff --git a/vrobbler/apps/profiles/forms.py b/vrobbler/apps/profiles/forms.py index 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.py new file mode 100644 index 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.py index 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.py index 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.py new file mode 100644 index 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.py index 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.py index 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.html new file mode 100644 index 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 %} + +
+
+ {% if object.photo %} +
+ +
+ {% endif %} + {% if object.scientific_name %} +

{{ object.scientific_name }}

+ {% endif %} +

Taxon ID: {{ object.taxon_id }}

+

View on iNaturalist

+
+
+
+
+

{{ scrobbles.count }} observations

+
+
+
+

Recent observations

+
+ + + + + + + + + + + {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %} + + + + + + + {% endfor %} + +
DateLocationQualitySource
{{ scrobble.local_timestamp }}{{ scrobble.logdata.place_guess|default:"—" }}{{ scrobble.logdata.quality_grade|default:"—" }}{{ scrobble.source }}
+
+
+
+{% endblock %} diff --git a/vrobbler/templates/nature/species_observation_list.html b/vrobbler/templates/nature/species_observation_list.html new file mode 100644 index 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 %} +
+
+
{% include "_scrobblable_list.html" %}
+
+
+{% endblock %} diff --git a/vrobbler/urls.py b/vrobbler/urls.py index 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")),