From f160f5a7b8505d8f030d416e2f5f7695da35d70e Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Fri, 15 May 2026 12:10:20 -0400 Subject: [PATCH] [birds] Add much better bird importing --- tests/birds_tests/__init__.py | 0 tests/birds_tests/conftest.py | 70 ++++++++++ tests/birds_tests/test_dataclasses.py | 96 +++++++++++++ tests/birds_tests/test_importer.py | 130 ++++++++++++++++++ tests/birds_tests/test_models.py | 30 ++++ tests/birds_tests/test_views.py | 42 ++++++ vrobbler/apps/birds/importer.py | 43 +++--- vrobbler/apps/birds/models.py | 30 ++++ .../birds/bird_sightings_widget.html | 46 +++++++ vrobbler/apps/birds/views.py | 2 +- vrobbler/templates/birds/bird_detail.html | 21 +++ vrobbler/templates/birds/bird_list.html | 29 ++++ .../templates/scrobbles/_last_scrobbles.html | 9 ++ vrobbler/templates/scrobbles/import_list.html | 17 +-- .../templates/scrobbles/scrobble_list.html | 12 ++ 15 files changed, 550 insertions(+), 27 deletions(-) create mode 100644 tests/birds_tests/__init__.py create mode 100644 tests/birds_tests/conftest.py create mode 100644 tests/birds_tests/test_dataclasses.py create mode 100644 tests/birds_tests/test_importer.py create mode 100644 tests/birds_tests/test_models.py create mode 100644 tests/birds_tests/test_views.py create mode 100644 vrobbler/apps/birds/templates/birds/bird_sightings_widget.html create mode 100644 vrobbler/templates/birds/bird_detail.html create mode 100644 vrobbler/templates/birds/bird_list.html diff --git a/tests/birds_tests/__init__.py b/tests/birds_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/birds_tests/conftest.py b/tests/birds_tests/conftest.py new file mode 100644 index 0000000..d71b0b1 --- /dev/null +++ b/tests/birds_tests/conftest.py @@ -0,0 +1,70 @@ +import tempfile + +import pytest +from birds.models import ( + Bird, + BirdSightingEntry, + BirdSightingLogData, + BirdingLocation, +) +from django.contrib.auth import get_user_model +from scrobbles.models import Scrobble + +User = get_user_model() + + +@pytest.fixture +def user(db): + return User.objects.create(email="birder@example.com") + + +@pytest.fixture +def bird(db): + return Bird.objects.create( + common_name="Northern Cardinal", + scientific_name="Cardinalis cardinalis", + ) + + +@pytest.fixture +def birding_location(db): + return BirdingLocation.objects.create(title="Test Park") + + +@pytest.fixture +def birding_csv_content(): + return """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details +Canada Goose,6,Test Park,Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species, +Northern Cardinal,2,Test Park,Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,At the feeder +""" + + +@pytest.fixture +def birding_csv_file(birding_csv_content): + with tempfile.NamedTemporaryFile( + mode="w", suffix=".csv", delete=False, encoding="utf-8" + ) as f: + f.write(birding_csv_content) + return f.name + + +@pytest.fixture +def scrobble_with_sightings(user, birding_location, bird): + return Scrobble.objects.create( + user=user, + birding_location=birding_location, + media_type=Scrobble.MediaType.BIRDING_LOCATION, + timestamp="2026-05-10 16:15:00+00:00", + played_to_completion=True, + log={ + "birds": [ + BirdSightingEntry( + bird_id=bird.id, quantity=2, sighting_notes="At the feeder" + ).asdict + ], + "duration_minutes": 9, + "observation_type": "Stationary", + "party_size": 4, + "complete_checklist": True, + }, + ) diff --git a/tests/birds_tests/test_dataclasses.py b/tests/birds_tests/test_dataclasses.py new file mode 100644 index 0000000..a11566f --- /dev/null +++ b/tests/birds_tests/test_dataclasses.py @@ -0,0 +1,96 @@ +from birds.models import BirdSightingEntry, BirdSightingLogData + + +class TestBirdSightingEntry: + def test_create_entry(self, db, bird): + entry = BirdSightingEntry(bird_id=bird.id, quantity=3) + assert entry.bird_id == bird.id + assert entry.quantity == 3 + assert entry.sighting_notes is None + + def test_entry_default_quantity(self, db, bird): + entry = BirdSightingEntry(bird_id=bird.id) + assert entry.quantity == 1 + + def test_entry_str(self, db, bird): + entry = BirdSightingEntry( + bird_id=bird.id, quantity=2, sighting_notes="in the tree" + ) + expected = f"{bird.common_name} x2 (in the tree)" + assert str(entry) == expected + + def test_entry_str_no_notes(self, db, bird): + entry = BirdSightingEntry(bird_id=bird.id, quantity=1) + expected = f"{bird.common_name} x1" + assert str(entry) == expected + + def test_entry_bird_property(self, db, bird): + entry = BirdSightingEntry(bird_id=bird.id) + assert entry.bird == bird + + def test_entry_bird_property_none(self, db): + entry = BirdSightingEntry(bird_id=None) + assert entry.bird is None + + def test_entry_asdict(self, db, bird): + entry = BirdSightingEntry( + bird_id=bird.id, quantity=4, sighting_notes="flying south" + ) + d = entry.asdict + assert d["bird_id"] == bird.id + assert d["quantity"] == 4 + assert d["sighting_notes"] == "flying south" + + +class TestBirdSightingLogData: + def test_empty_logdata(self): + logdata = BirdSightingLogData() + assert logdata.birds is None + assert logdata.duration_minutes is None + assert logdata.observation_type is None + assert logdata.party_size is None + assert logdata.complete_checklist is None + + def test_with_birds(self, db, bird): + entry = BirdSightingEntry(bird_id=bird.id, quantity=2).asdict + logdata = BirdSightingLogData( + birds=[entry], + duration_minutes=15, + observation_type="Traveling", + party_size=3, + complete_checklist=True, + ) + assert len(logdata.birds) == 1 + assert logdata.duration_minutes == 15 + assert logdata.observation_type == "Traveling" + assert logdata.party_size == 3 + assert logdata.complete_checklist is True + + def test_bird_list_property(self, db, bird): + entry = BirdSightingEntry(bird_id=bird.id, quantity=2).asdict + logdata = BirdSightingLogData(birds=[entry]) + assert bird.common_name in logdata.bird_list + + def test_bird_list_empty(self): + logdata = BirdSightingLogData() + assert logdata.bird_list == "" + + def test_as_html_with_all_fields(self, db, bird): + entry = BirdSightingEntry(bird_id=bird.id, quantity=2).asdict + logdata = BirdSightingLogData( + birds=[entry], + observation_type="Stationary", + distance="2 km", + area="Woodland", + party_size=4, + complete_checklist=True, + weather="Sunny", + ) + html = logdata.as_html() + assert "Stationary" in html + assert "2 km" in html + assert "Woodland" in html + assert "Party size: 4" in html + assert "Complete checklist: True" in html + assert "Sunny" in html + assert bird.common_name in html diff --git a/tests/birds_tests/test_importer.py b/tests/birds_tests/test_importer.py new file mode 100644 index 0000000..54c317d --- /dev/null +++ b/tests/birds_tests/test_importer.py @@ -0,0 +1,130 @@ +import pytest +from birds.importer import ( + import_birding_csv, + parse_bool, + parse_coords, + parse_duration, + parse_int, + parse_timestamp, +) +from birds.models import Bird, BirdingLocation, BirdingCSVImport +from scrobbles.models import Scrobble + + +class TestParserHelpers: + def test_parse_duration(self): + assert parse_duration("9 minute(s)") == 9 + assert parse_duration("120 minute(s)") == 120 + assert parse_duration("") is None + assert parse_duration(None) is None + assert parse_duration("not a duration") is None + + def test_parse_coords(self): + loc = "Some Place, US (44.384, -68.805)" + lat, lon = parse_coords(loc) + assert lat == 44.384 + assert lon == -68.805 + + def test_parse_coords_no_match(self): + loc = "Some Place, US" + lat, lon = parse_coords(loc) + assert lat is None + assert lon is None + + def test_parse_timestamp(self): + dt = parse_timestamp("May 10, 2026", "4:15 PM") + assert dt is not None + assert dt.year == 2026 + assert dt.month == 5 + assert dt.day == 10 + assert dt.hour == 16 + assert dt.minute == 15 + + def test_parse_timestamp_no_time(self): + dt = parse_timestamp("May 10, 2026", "") + assert dt is not None + assert dt.year == 2026 + + def test_parse_timestamp_invalid(self): + assert parse_timestamp("not a date", "") is None + + def test_parse_bool(self): + assert parse_bool("true") is True + assert parse_bool("True") is True + assert parse_bool("yes") is True + assert parse_bool("1") is True + assert parse_bool("false") is False + assert parse_bool("") is None + assert parse_bool(None) is None + + def test_parse_int(self): + assert parse_int("42") == 42 + assert parse_int("") is None + assert parse_int(None) is None + assert parse_int("not a number") is None + + +class TestImportBirdingCSV: + def test_import_creates_birds(self, user, birding_csv_file): + import_birding_csv(birding_csv_file, user.id) + assert Bird.objects.filter(common_name="Canada Goose").exists() + assert Bird.objects.filter(common_name="Northern Cardinal").exists() + + def test_import_creates_location(self, user, birding_csv_file): + import_birding_csv(birding_csv_file, user.id) + assert BirdingLocation.objects.filter(title="Test Park").exists() + + def test_import_creates_scrobble(self, user, birding_csv_file): + import_birding_csv(birding_csv_file, user.id) + assert Scrobble.objects.filter( + source="Birding CSV Import" + ).count() == 1 + + def test_import_logdata_fields(self, user, birding_csv_file): + import_birding_csv(birding_csv_file, user.id) + scrobble = Scrobble.objects.filter(source="Birding CSV Import").first() + log = scrobble.log + assert log["duration_minutes"] == 9 + assert log["observation_type"] == "Stationary" + assert log["party_size"] == 4 + assert log["complete_checklist"] is True + assert len(log["birds"]) == 2 + + def test_import_sighting_details(self, user, birding_csv_file): + import_birding_csv(birding_csv_file, user.id) + scrobble = Scrobble.objects.filter(source="Birding CSV Import").first() + birds = scrobble.log["birds"] + cardinal = next(b for b in birds if b["quantity"] == 2) + assert cardinal["sighting_notes"] == "At the feeder" + + def test_import_idempotent(self, user, birding_csv_file): + import_birding_csv(birding_csv_file, user.id) + import_birding_csv(birding_csv_file, user.id) + assert Scrobble.objects.filter( + source="Birding CSV Import" + ).count() == 1 + + def test_import_bird_quantities(self, user, birding_csv_file): + import_birding_csv(birding_csv_file, user.id) + scrobble = Scrobble.objects.filter(source="Birding CSV Import").first() + birds = scrobble.log["birds"] + goose = next(b for b in birds if b["quantity"] == 6) + assert goose is not None + + +class TestBirdingCSVImportModel: + def test_create_import_model(self, db, user): + imp = BirdingCSVImport.objects.create(user=user) + assert imp.uuid is not None + assert imp.import_type == "Birding CSV" + assert "Birding" in str(imp) + + @pytest.mark.django_db(transaction=True) + def test_process_via_model(self, user, birding_csv_file): + imp = BirdingCSVImport.objects.create(user=user) + with open(birding_csv_file, "rb") as f: + imp.csv_file.save("test.csv", f, save=True) + imp.process() + imp.refresh_from_db() + assert imp.process_count == 1 + assert imp.processed_finished is not None diff --git a/tests/birds_tests/test_models.py b/tests/birds_tests/test_models.py new file mode 100644 index 0000000..c980065 --- /dev/null +++ b/tests/birds_tests/test_models.py @@ -0,0 +1,30 @@ +import pytest +from birds.models import Bird + + +class TestBirdModel: + def test_create_bird(self, db): + bird = Bird.objects.create(common_name="Blue Jay") + assert bird.common_name == "Blue Jay" + assert bird.uuid is not None + assert str(bird) == "Blue Jay" + + def test_find_or_create_new(self, db): + bird = Bird.find_or_create("American Robin") + assert bird.common_name == "American Robin" + + def test_find_or_create_existing(self, db, bird): + result = Bird.find_or_create("Northern Cardinal") + assert result.id == bird.id + assert result.common_name == "Northern Cardinal" + + def test_find_or_create_case_insensitive(self, db, bird): + result = Bird.find_or_create("northern cardinal") + assert result.id == bird.id + + def test_bird_str(self, db): + bird = Bird.objects.create(common_name="Mourning Dove") + assert str(bird) == "Mourning Dove" + + def test_bird_scientific_name(self, db, bird): + assert bird.scientific_name == "Cardinalis cardinalis" diff --git a/tests/birds_tests/test_views.py b/tests/birds_tests/test_views.py new file mode 100644 index 0000000..5fb30f4 --- /dev/null +++ b/tests/birds_tests/test_views.py @@ -0,0 +1,42 @@ +from birds.models import BirdingCSVImport +from django.contrib.auth import get_user_model +from django.test import Client +from django.urls import reverse + +User = get_user_model() + + +class TestBirdingLocationViews: + def test_birding_location_list_anonymous(self, db): + client = Client() + response = client.get(reverse("birds:birding_location_list")) + assert response.status_code == 200 + + def test_bird_list_anonymous(self, db): + client = Client() + response = client.get(reverse("birds:bird_list")) + assert response.status_code == 200 + + +class TestBirdingCSVImportViews: + def test_upload_view_requires_login(self, db): + client = Client() + response = client.get(reverse("birds:csv-upload")) + assert response.status_code == 302 + + def test_import_detail_view_requires_login(self, db): + client = Client() + response = client.get( + reverse("birds:csv_import_detail", kwargs={"slug": "00000000-0000-0000-0000-000000000001"}) + ) + assert response.status_code == 302 + + def test_import_detail_authenticated(self, db): + user = User.objects.create(email="birder@example.com") + client = Client() + client.force_login(user) + imp = BirdingCSVImport.objects.create(user=user) + response = client.get( + reverse("birds:csv_import_detail", kwargs={"slug": imp.uuid}) + ) + assert response.status_code == 200 diff --git a/vrobbler/apps/birds/importer.py b/vrobbler/apps/birds/importer.py index d9f9b09..38451e1 100644 --- a/vrobbler/apps/birds/importer.py +++ b/vrobbler/apps/birds/importer.py @@ -46,6 +46,21 @@ def parse_timestamp(date_str, time_str): return None +def parse_bool(value): + if not value: + return None + return value.strip().lower() in ("true", "yes", "1") + + +def parse_int(value): + if not value: + return None + try: + return int(value.strip()) + except (ValueError, TypeError): + return None + + def import_birding_csv(file_path, user_id): user = User.objects.get(id=user_id) new_scrobbles = [] @@ -72,7 +87,9 @@ def import_birding_csv(file_path, user_id): if not timestamp: continue - location_title = LOCATION_COORDS_RE.sub("", location_str).strip().rstrip(",").strip() + location_title = ( + LOCATION_COORDS_RE.sub("", location_str).strip().rstrip(",").strip() + ) if not location_title: location_title = location_str @@ -90,19 +107,13 @@ def import_birding_csv(file_path, user_id): 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 + count = parse_int(row.get("Count")) or 1 details = row.get("Details", "").strip() bird = Bird.find_or_create(species) @@ -113,19 +124,15 @@ def import_birding_csv(file_path, user_id): logdata = BirdSightingLogData( birds=birds_data, - duration_minutes=duration_minutes, + duration_minutes=parse_duration(first_row.get("Duration", "")), + observation_type=first_row.get("Observation Type", "").strip() or None, + distance=first_row.get("Distance", "").strip() or None, + area=first_row.get("Area", "").strip() or None, + party_size=parse_int(first_row.get("Party Size")), + complete_checklist=parse_bool(first_row.get("Complete Checklist")), ) - 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, diff --git a/vrobbler/apps/birds/models.py b/vrobbler/apps/birds/models.py index 07be040..9a4a26d 100644 --- a/vrobbler/apps/birds/models.py +++ b/vrobbler/apps/birds/models.py @@ -47,6 +47,11 @@ class BirdSightingEntry(BaseLogData): class BirdSightingLogData(BaseLogData, WithPeopleLogData): birds: Optional[list[BirdSightingEntry]] = None duration_minutes: Optional[int] = None + observation_type: Optional[str] = None + distance: Optional[str] = None + area: Optional[str] = None + party_size: Optional[int] = None + complete_checklist: Optional[bool] = None weather: Optional[str] = None temperature: Optional[int] = None guide: Optional[str] = None @@ -64,6 +69,31 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData): def as_html(self) -> str: html_parts = [] + if self.observation_type: + html_parts.append( + f'
Type: {self.observation_type}
' + ) + + if self.distance: + html_parts.append( + f'
Distance: {self.distance}
' + ) + + if self.area: + html_parts.append( + f'
Area: {self.area}
' + ) + + if self.party_size: + html_parts.append( + f'
Party size: {self.party_size}
' + ) + + if self.complete_checklist is not None: + html_parts.append( + f'
Complete checklist: {self.complete_checklist}
' + ) + if self.weather: html_parts.append( f'
Weather: {self.weather}
' diff --git a/vrobbler/apps/birds/templates/birds/bird_sightings_widget.html b/vrobbler/apps/birds/templates/birds/bird_sightings_widget.html new file mode 100644 index 0000000..812d12a --- /dev/null +++ b/vrobbler/apps/birds/templates/birds/bird_sightings_widget.html @@ -0,0 +1,46 @@ +
+
+ {% for sighting in widget.sightings %} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ {% empty %} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ {% endfor %} +
+ +
diff --git a/vrobbler/apps/birds/views.py b/vrobbler/apps/birds/views.py index 0a82d98..0bc1333 100644 --- a/vrobbler/apps/birds/views.py +++ b/vrobbler/apps/birds/views.py @@ -45,7 +45,7 @@ class BirdingCSVImportCreateView( return HttpResponseRedirect(self.request.META.get("HTTP_REFERER")) -class BirdingCSVImportDetailView(generic.DetailView): +class BirdingCSVImportDetailView(LoginRequiredMixin, generic.DetailView): model = BirdingCSVImport slug_field = "uuid" template_name = "scrobbles/import_detail.html" diff --git a/vrobbler/templates/birds/bird_detail.html b/vrobbler/templates/birds/bird_detail.html new file mode 100644 index 0000000..4e22681 --- /dev/null +++ b/vrobbler/templates/birds/bird_detail.html @@ -0,0 +1,21 @@ +{% extends "base_list.html" %} +{% load static %} + +{% block title %}{{object.common_name}}{% endblock %} + +{% block lists %} +
+
+

{{object.common_name}}

+ {% if object.scientific_name %} +

{{object.scientific_name}}

+ {% endif %} + {% if object.description %} +

{{object.description}}

+ {% endif %} + {% if object.ebird_code %} +

eBird code: {{object.ebird_code}}

+ {% endif %} +
+
+{% endblock %} diff --git a/vrobbler/templates/birds/bird_list.html b/vrobbler/templates/birds/bird_list.html new file mode 100644 index 0000000..0a629d8 --- /dev/null +++ b/vrobbler/templates/birds/bird_list.html @@ -0,0 +1,29 @@ +{% extends "base_list.html" %} +{% load static %} + +{% block title %}Birds{% endblock %} + +{% block lists %} +
+
+
+ + + + + + + + + {% for bird in object_list %} + + + + + {% endfor %} + +
Common NameScientific Name
{{bird.common_name}}{{bird.scientific_name|default:""}}
+
+
+
+{% endblock %} diff --git a/vrobbler/templates/scrobbles/_last_scrobbles.html b/vrobbler/templates/scrobbles/_last_scrobbles.html index 51f38c2..067b7f1 100644 --- a/vrobbler/templates/scrobbles/_last_scrobbles.html +++ b/vrobbler/templates/scrobbles/_last_scrobbles.html @@ -32,6 +32,15 @@

No food today

{% endif %} +

Birding Locations

+ {% if BirdingLocation %} + {% with scrobbles=BirdingLocation count=BirdingLocation_count time=BirdingLocation_time %} + {% include "scrobbles/_scrobble_table.html" %} + {% endwith %} + {% else %} +

No birding today

+ {% endif %} +

Life Events

{% if life_events_in_progress %}
diff --git a/vrobbler/templates/scrobbles/import_list.html b/vrobbler/templates/scrobbles/import_list.html index bff0298..6d2e268 100644 --- a/vrobbler/templates/scrobbles/import_list.html +++ b/vrobbler/templates/scrobbles/import_list.html @@ -106,7 +106,7 @@ {% if birding_csv_imports %}
-

Birding CSV

+

eBird

@@ -116,13 +116,14 @@ - {% for obj in birding_csv_imports %} - - - - - - {% endfor %} + + {% for obj in birding_csv_imports %} + + + + + + {% endfor %}
Scrobbles
{{obj.human_start}}{{obj.processed_finished}}{{obj.process_count}}
{{obj.human_start}}{{obj.processed_finished}}{{obj.process_count}}
diff --git a/vrobbler/templates/scrobbles/scrobble_list.html b/vrobbler/templates/scrobbles/scrobble_list.html index 468a4cd..6199ac5 100644 --- a/vrobbler/templates/scrobbles/scrobble_list.html +++ b/vrobbler/templates/scrobbles/scrobble_list.html @@ -171,6 +171,18 @@
+
+ + +