[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:
17
PROJECT.org
17
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
|
||||
|
||||
0
vrobbler/apps/nature/__init__.py
Normal file
0
vrobbler/apps/nature/__init__.py
Normal file
14
vrobbler/apps/nature/admin.py
Normal file
14
vrobbler/apps/nature/admin.py
Normal 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,
|
||||
]
|
||||
0
vrobbler/apps/nature/api/__init__.py
Normal file
0
vrobbler/apps/nature/api/__init__.py
Normal file
8
vrobbler/apps/nature/api/serializers.py
Normal file
8
vrobbler/apps/nature/api/serializers.py
Normal 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__"
|
||||
9
vrobbler/apps/nature/api/views.py
Normal file
9
vrobbler/apps/nature/api/views.py
Normal 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]
|
||||
5
vrobbler/apps/nature/apps.py
Normal file
5
vrobbler/apps/nature/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NatureConfig(AppConfig):
|
||||
name = "nature"
|
||||
312
vrobbler/apps/nature/importer.py
Normal file
312
vrobbler/apps/nature/importer.py
Normal 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
|
||||
0
vrobbler/apps/nature/management/__init__.py
Normal file
0
vrobbler/apps/nature/management/__init__.py
Normal 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")
|
||||
88
vrobbler/apps/nature/migrations/0001_initial.py
Normal file
88
vrobbler/apps/nature/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/nature/migrations/__init__.py
Normal file
0
vrobbler/apps/nature/migrations/__init__.py
Normal file
101
vrobbler/apps/nature/models.py
Normal file
101
vrobbler/apps/nature/models.py
Normal 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
|
||||
13
vrobbler/apps/nature/tasks.py
Normal file
13
vrobbler/apps/nature/tasks.py
Normal 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()
|
||||
17
vrobbler/apps/nature/urls.py
Normal file
17
vrobbler/apps/nature/urls.py
Normal 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",
|
||||
),
|
||||
]
|
||||
11
vrobbler/apps/nature/views.py
Normal file
11
vrobbler/apps/nature/views.py
Normal 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
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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}"
|
||||
|
||||
|
||||
@ -54,6 +54,7 @@ PLAY_AGAIN_MEDIA = {
|
||||
"channels": "Channel",
|
||||
"birds": "BirdingLocation",
|
||||
"discgolf": "DiscGolfCourse",
|
||||
"nature": "SpeciesObservation",
|
||||
}
|
||||
|
||||
DRINK_MODELS = ["Beer", "Wine", "Coffee", "Drink"]
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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):
|
||||
|
||||
@ -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",
|
||||
|
||||
52
vrobbler/templates/nature/species_observation_detail.html
Normal file
52
vrobbler/templates/nature/species_observation_detail.html
Normal 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 %}
|
||||
10
vrobbler/templates/nature/species_observation_list.html
Normal file
10
vrobbler/templates/nature/species_observation_list.html
Normal 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 %}
|
||||
@ -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")),
|
||||
|
||||
Reference in New Issue
Block a user