[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
|
*** Metadata sources
|
||||||
**** Scraper
|
**** 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] 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:
|
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
||||||
:PROPERTIES:
|
: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)
|
- 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)
|
- 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:
|
** DONE [#A] Add ability to push one or sync all board game scrobbles to BGG :boardgames:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 1f306552-35e8-4a75-a661-b0956e8de967
|
: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",
|
"archivebox_url",
|
||||||
"bgg_username",
|
"bgg_username",
|
||||||
"lichess_username",
|
"lichess_username",
|
||||||
|
"inaturalist_username",
|
||||||
|
"inaturalist_user_id",
|
||||||
|
"inaturalist_auto_import",
|
||||||
"webdav_url",
|
"webdav_url",
|
||||||
"webdav_user",
|
"webdav_user",
|
||||||
"webdav_pass",
|
"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",
|
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):
|
def __str__(self):
|
||||||
return f"User profile for {self.user}"
|
return f"User profile for {self.user}"
|
||||||
|
|
||||||
|
|||||||
@ -54,6 +54,7 @@ PLAY_AGAIN_MEDIA = {
|
|||||||
"channels": "Channel",
|
"channels": "Channel",
|
||||||
"birds": "BirdingLocation",
|
"birds": "BirdingLocation",
|
||||||
"discgolf": "DiscGolfCourse",
|
"discgolf": "DiscGolfCourse",
|
||||||
|
"nature": "SpeciesObservation",
|
||||||
}
|
}
|
||||||
|
|
||||||
DRINK_MODELS = ["Beer", "Wine", "Coffee", "Drink"]
|
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 pendulum
|
||||||
import pytz
|
import pytz
|
||||||
from drinks.models import Beer, Wine, Coffee, Drink
|
|
||||||
from birds.models import BirdingLocation
|
from birds.models import BirdingLocation
|
||||||
from discgolf.models import DiscGolfCourse
|
|
||||||
from boardgames.models import BoardGame
|
from boardgames.models import BoardGame
|
||||||
from books.koreader import process_koreader_sqlite_file
|
from books.koreader import process_koreader_sqlite_file
|
||||||
from books.models import Book, BookLogData, BookPageLogData, Paper
|
from books.models import Book, BookLogData, BookPageLogData, Paper
|
||||||
from bricksets.models import BrickSet
|
from bricksets.models import BrickSet
|
||||||
from charts.utils import build_charts
|
from charts.utils import build_charts
|
||||||
from dataclass_wizard.errors import ParseError
|
from dataclass_wizard.errors import ParseError
|
||||||
|
from discgolf.models import DiscGolfCourse
|
||||||
from django.conf import settings
|
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.contrib.auth import get_user_model
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
|
from drinks.models import Beer, Coffee, Drink, Wine
|
||||||
from foods.models import Food, FoodLogData
|
from foods.models import Food, FoodLogData
|
||||||
from imagekit.models import ImageSpecField
|
from imagekit.models import ImageSpecField
|
||||||
from imagekit.processors import ResizeToFit
|
from imagekit.processors import ResizeToFit
|
||||||
@ -34,6 +32,7 @@ from lifeevents.models import LifeEvent
|
|||||||
from locations.models import GeoLocation
|
from locations.models import GeoLocation
|
||||||
from moods.models import Mood
|
from moods.models import Mood
|
||||||
from music.models import Artist, Track
|
from music.models import Artist, Track
|
||||||
|
from nature.models import SpeciesObservation
|
||||||
from podcasts.models import PodcastEpisode
|
from podcasts.models import PodcastEpisode
|
||||||
from profiles.utils import (
|
from profiles.utils import (
|
||||||
end_of_day,
|
end_of_day,
|
||||||
@ -50,12 +49,14 @@ from scrobbles.constants import (
|
|||||||
AUTO_FINISH_MEDIA,
|
AUTO_FINISH_MEDIA,
|
||||||
LONG_PLAY_MEDIA,
|
LONG_PLAY_MEDIA,
|
||||||
MEDIA_END_PADDING_SECONDS,
|
MEDIA_END_PADDING_SECONDS,
|
||||||
|
Visibility,
|
||||||
)
|
)
|
||||||
from scrobbles.importers.lastfm import LastFM
|
from scrobbles.importers.lastfm import LastFM
|
||||||
from scrobbles.notifications import (
|
from scrobbles.notifications import (
|
||||||
LastFmImportNtfyNotification,
|
LastFmImportNtfyNotification,
|
||||||
ScrobbleNtfyNotification,
|
ScrobbleNtfyNotification,
|
||||||
)
|
)
|
||||||
|
from scrobbles.sqids import encode_scrobble_share
|
||||||
from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
|
from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
|
||||||
from sports.models import SportEvent
|
from sports.models import SportEvent
|
||||||
from taggit.managers import TaggableManager
|
from taggit.managers import TaggableManager
|
||||||
@ -697,6 +698,7 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
|
|||||||
"Channel": ("channel",),
|
"Channel": ("channel",),
|
||||||
"BirdingLocation": ("birding_location",),
|
"BirdingLocation": ("birding_location",),
|
||||||
"DiscGolfCourse": ("disc_golf_course",),
|
"DiscGolfCourse": ("disc_golf_course",),
|
||||||
|
"SpeciesObservation": ("species_observation",),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -728,6 +730,7 @@ class ScrobbleQuerySet(models.QuerySet):
|
|||||||
"brick_set",
|
"brick_set",
|
||||||
"birding_location",
|
"birding_location",
|
||||||
"disc_golf_course",
|
"disc_golf_course",
|
||||||
|
"species_observation",
|
||||||
)
|
)
|
||||||
|
|
||||||
def with_related_for_types(self, media_types: list[str]):
|
def with_related_for_types(self, media_types: list[str]):
|
||||||
@ -782,6 +785,7 @@ class Scrobble(TimeStampedModel):
|
|||||||
CHANNEL = "Channel", "Channel"
|
CHANNEL = "Channel", "Channel"
|
||||||
BIRDING_LOCATION = "BirdingLocation", "Birding location"
|
BIRDING_LOCATION = "BirdingLocation", "Birding location"
|
||||||
DISC_GOLF = "DiscGolfCourse", "Disc golf"
|
DISC_GOLF = "DiscGolfCourse", "Disc golf"
|
||||||
|
SPECIES_OBSERVATION = "SpeciesObservation", "Species observation"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def list(cls):
|
def list(cls):
|
||||||
@ -818,6 +822,9 @@ class Scrobble(TimeStampedModel):
|
|||||||
disc_golf_course = models.ForeignKey(
|
disc_golf_course = models.ForeignKey(
|
||||||
DiscGolfCourse, on_delete=models.DO_NOTHING, **BNULL
|
DiscGolfCourse, on_delete=models.DO_NOTHING, **BNULL
|
||||||
)
|
)
|
||||||
|
species_observation = models.ForeignKey(
|
||||||
|
SpeciesObservation, on_delete=models.DO_NOTHING, **BNULL
|
||||||
|
)
|
||||||
media_type = models.CharField(
|
media_type = models.CharField(
|
||||||
max_length=20, choices=MediaType.choices, default=MediaType.VIDEO
|
max_length=20, choices=MediaType.choices, default=MediaType.VIDEO
|
||||||
)
|
)
|
||||||
@ -1402,6 +1409,8 @@ class Scrobble(TimeStampedModel):
|
|||||||
media_obj = self.paper
|
media_obj = self.paper
|
||||||
if self.disc_golf_course:
|
if self.disc_golf_course:
|
||||||
media_obj = self.disc_golf_course
|
media_obj = self.disc_golf_course
|
||||||
|
if self.species_observation:
|
||||||
|
media_obj = self.species_observation
|
||||||
return media_obj
|
return media_obj
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
@ -198,6 +198,10 @@ CELERY_BEAT_SCHEDULE = {
|
|||||||
"task": "scrobbles.tasks.backfill_scrobble_sentiment",
|
"task": "scrobbles.tasks.backfill_scrobble_sentiment",
|
||||||
"schedule": crontab(minute="0"),
|
"schedule": crontab(minute="0"),
|
||||||
},
|
},
|
||||||
|
"import-from-inaturalist": {
|
||||||
|
"task": "nature.tasks.import_from_inaturalist_all_users",
|
||||||
|
"schedule": crontab(hour="*/4", minute=30),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
@ -242,6 +246,7 @@ INSTALLED_APPS = [
|
|||||||
"moods",
|
"moods",
|
||||||
"discgolf",
|
"discgolf",
|
||||||
"birds",
|
"birds",
|
||||||
|
"nature",
|
||||||
"mathfilters",
|
"mathfilters",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
"allauth",
|
"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.locations.api.views import GeoLocationViewSet
|
||||||
from vrobbler.apps.moods import urls as moods_urls
|
from vrobbler.apps.moods import urls as moods_urls
|
||||||
from vrobbler.apps.moods.api.views import MoodViewSet
|
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 import urls as music_urls
|
||||||
from vrobbler.apps.music.api.views import (
|
from vrobbler.apps.music.api.views import (
|
||||||
AlbumViewSet,
|
AlbumViewSet,
|
||||||
@ -148,6 +150,7 @@ router.register(r"lifeevents", LifeEventViewSet)
|
|||||||
router.register(r"birds", BirdViewSet)
|
router.register(r"birds", BirdViewSet)
|
||||||
router.register(r"birding-locations", BirdingLocationViewSet)
|
router.register(r"birding-locations", BirdingLocationViewSet)
|
||||||
router.register(r"disc-golf-courses", DiscGolfCourseViewSet)
|
router.register(r"disc-golf-courses", DiscGolfCourseViewSet)
|
||||||
|
router.register(r"observations", SpeciesObservationViewSet)
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("api/v1/", include(router.urls)),
|
path("api/v1/", include(router.urls)),
|
||||||
path("api/v1/auth", include("rest_framework.urls")),
|
path("api/v1/auth", include("rest_framework.urls")),
|
||||||
@ -174,6 +177,7 @@ urlpatterns = [
|
|||||||
path("", include(lifeevents_urls, namespace="life-events")),
|
path("", include(lifeevents_urls, namespace="life-events")),
|
||||||
path("", include(moods_urls, namespace="moods")),
|
path("", include(moods_urls, namespace="moods")),
|
||||||
path("", include(birds_urls, namespace="birds")),
|
path("", include(birds_urls, namespace="birds")),
|
||||||
|
path("", include(nature_urls, namespace="nature")),
|
||||||
path("", include(scrobble_urls, namespace="scrobbles")),
|
path("", include(scrobble_urls, namespace="scrobbles")),
|
||||||
path("", include(profiles_urls, namespace="profiles")),
|
path("", include(profiles_urls, namespace="profiles")),
|
||||||
path("", include(people_urls, namespace="people")),
|
path("", include(people_urls, namespace="people")),
|
||||||
|
|||||||
Reference in New Issue
Block a user