[birds] Add birding csv importer
This commit is contained in:
152
vrobbler/apps/birds/importer.py
Normal file
152
vrobbler/apps/birds/importer.py
Normal file
@ -0,0 +1,152 @@
|
||||
import csv
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from birds.models import Bird, BirdSightingEntry, BirdSightingLogData, BirdingLocation
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
LOCATION_COORDS_RE = re.compile(r"\(([\d\.\-]+),\s*([\d\.\-]+)\)")
|
||||
DURATION_RE = re.compile(r"(\d+)\s*minute")
|
||||
|
||||
|
||||
def parse_duration(duration_str):
|
||||
if not duration_str:
|
||||
return None
|
||||
match = DURATION_RE.search(duration_str)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def parse_coords(location_str):
|
||||
match = LOCATION_COORDS_RE.search(location_str)
|
||||
if match:
|
||||
return float(match.group(1)), float(match.group(2))
|
||||
return None, None
|
||||
|
||||
|
||||
def parse_timestamp(date_str, time_str):
|
||||
try:
|
||||
dt = datetime.strptime(f"{date_str} {time_str}", "%B %d, %Y %I:%M %p")
|
||||
return dt.replace(tzinfo=ZoneInfo("UTC"))
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%B %d, %Y")
|
||||
return dt.replace(tzinfo=ZoneInfo("UTC"))
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(f"Could not parse date/time: {date_str} {time_str}")
|
||||
return None
|
||||
|
||||
|
||||
def import_birding_csv(file_path, user_id):
|
||||
user = User.objects.get(id=user_id)
|
||||
new_scrobbles = []
|
||||
|
||||
with open(file_path, newline="", encoding="utf-8-sig") as f:
|
||||
reader = csv.DictReader(f)
|
||||
rows = list(reader)
|
||||
|
||||
groups = defaultdict(list)
|
||||
for row in rows:
|
||||
key = (
|
||||
row.get("Location", "").strip(),
|
||||
row.get("Observation Date", "").strip(),
|
||||
row.get("Start Time", "").strip(),
|
||||
)
|
||||
groups[key].append(row)
|
||||
|
||||
for (location_str, date_str, time_str), sighting_rows in groups.items():
|
||||
if not location_str:
|
||||
logger.warning("Skipping rows with no location")
|
||||
continue
|
||||
|
||||
timestamp = parse_timestamp(date_str, time_str)
|
||||
if not timestamp:
|
||||
continue
|
||||
|
||||
location_title = LOCATION_COORDS_RE.sub("", location_str).strip().rstrip(",").strip()
|
||||
if not location_title:
|
||||
location_title = location_str
|
||||
|
||||
location = BirdingLocation.find_or_create(location_title)
|
||||
lat, lon = parse_coords(location_str)
|
||||
if lat and lon and not location.geo_location:
|
||||
from locations.models import GeoLocation
|
||||
|
||||
geo, _ = GeoLocation.objects.get_or_create(
|
||||
lat=round(lat, 6),
|
||||
lon=round(lon, 6),
|
||||
defaults={"altitude": None},
|
||||
)
|
||||
location.geo_location = geo
|
||||
location.save(update_fields=["geo_location"])
|
||||
|
||||
first_row = sighting_rows[0]
|
||||
duration_minutes = parse_duration(first_row.get("Duration", ""))
|
||||
party_size = first_row.get("Party Size", "").strip()
|
||||
|
||||
birds_data = []
|
||||
for row in sighting_rows:
|
||||
species = row.get("Species", "").strip()
|
||||
if not species:
|
||||
continue
|
||||
count_str = row.get("Count", "1").strip()
|
||||
try:
|
||||
count = int(count_str) if count_str else 1
|
||||
except ValueError:
|
||||
count = 1
|
||||
details = row.get("Details", "").strip()
|
||||
|
||||
bird = Bird.find_or_create(species)
|
||||
entry = BirdSightingEntry(
|
||||
bird_id=bird.id, quantity=count, sighting_notes=details or None
|
||||
)
|
||||
birds_data.append(entry.asdict)
|
||||
|
||||
logdata = BirdSightingLogData(
|
||||
birds=birds_data,
|
||||
duration_minutes=duration_minutes,
|
||||
)
|
||||
|
||||
notes = []
|
||||
if party_size:
|
||||
notes.append(f"Party size: {party_size}")
|
||||
checklist_flag = first_row.get("Complete Checklist", "").strip()
|
||||
if checklist_flag:
|
||||
notes.append(f"Complete checklist: {checklist_flag}")
|
||||
|
||||
log_dict = logdata.asdict
|
||||
if notes:
|
||||
log_dict["notes"] = notes
|
||||
|
||||
scrobble = Scrobble(
|
||||
user=user,
|
||||
timestamp=timestamp,
|
||||
source="Birding CSV Import",
|
||||
birding_location=location,
|
||||
log=log_dict,
|
||||
played_to_completion=True,
|
||||
in_progress=False,
|
||||
media_type=Scrobble.MediaType.BIRDING_LOCATION,
|
||||
)
|
||||
existing = Scrobble.objects.filter(
|
||||
timestamp=timestamp,
|
||||
birding_location=location,
|
||||
user=user,
|
||||
).first()
|
||||
if existing:
|
||||
logger.debug(f"Skipping existing scrobble for {location}")
|
||||
continue
|
||||
new_scrobbles.append(scrobble)
|
||||
|
||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||
logger.info(f"Created {len(created)} birding scrobbles")
|
||||
return created
|
||||
Reference in New Issue
Block a user