[birds] Add much better bird importing
Some checks failed
build & deploy / test (push) Failing after 1m19s
build & deploy / build-and-deploy (push) Has been skipped

This commit is contained in:
2026-05-15 12:10:20 -04:00
parent b967c526f1
commit f160f5a7b8
15 changed files with 550 additions and 27 deletions

View File

@ -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,

View File

@ -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'<div class="birding-obs-type">Type: {self.observation_type}</div>'
)
if self.distance:
html_parts.append(
f'<div class="birding-distance">Distance: {self.distance}</div>'
)
if self.area:
html_parts.append(
f'<div class="birding-area">Area: {self.area}</div>'
)
if self.party_size:
html_parts.append(
f'<div class="birding-party">Party size: {self.party_size}</div>'
)
if self.complete_checklist is not None:
html_parts.append(
f'<div class="birding-checklist">Complete checklist: {self.complete_checklist}</div>'
)
if self.weather:
html_parts.append(
f'<div class="birding-weather">Weather: {self.weather}</div>'

View File

@ -0,0 +1,46 @@
<div class="bird-sightings-widget">
<div class="bird-sightings-list">
{% for sighting in widget.sightings %}
<div class="bird-sighting-row row mb-2">
<div class="col-md-6">
<select name="{{widget.name}}_bird_id" class="form-control">
<option value="">Select bird...</option>
{% for bird in widget.birds %}
<option value="{{bird.id}}" {% if sighting.bird_id == bird.id %}selected{% endif %}>{{bird.common_name}}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<input type="number" name="{{widget.name}}_quantity" class="form-control" value="{{sighting.quantity|default:1}}" min="1" placeholder="Qty">
</div>
<div class="col-md-3">
<input type="text" name="{{widget.name}}_sighting_notes" class="form-control" value="{{sighting.sighting_notes|default:''}}" placeholder="Notes">
</div>
<div class="col-md-1">
<button type="button" class="btn btn-sm btn-outline-danger remove-sighting">&times;</button>
</div>
</div>
{% empty %}
<div class="bird-sighting-row row mb-2">
<div class="col-md-6">
<select name="{{widget.name}}_bird_id" class="form-control">
<option value="">Select bird...</option>
{% for bird in widget.birds %}
<option value="{{bird.id}}">{{bird.common_name}}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<input type="number" name="{{widget.name}}_quantity" class="form-control" value="1" min="1" placeholder="Qty">
</div>
<div class="col-md-3">
<input type="text" name="{{widget.name}}_sighting_notes" class="form-control" value="" placeholder="Notes">
</div>
<div class="col-md-1">
<button type="button" class="btn btn-sm btn-outline-danger remove-sighting">&times;</button>
</div>
</div>
{% endfor %}
</div>
<button type="button" class="btn btn-sm btn-outline-primary add-sighting-row mt-2">Add bird</button>
</div>

View File

@ -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"

View File

@ -0,0 +1,21 @@
{% extends "base_list.html" %}
{% load static %}
{% block title %}{{object.common_name}}{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
<h2>{{object.common_name}}</h2>
{% if object.scientific_name %}
<p><em>{{object.scientific_name}}</em></p>
{% endif %}
{% if object.description %}
<p>{{object.description}}</p>
{% endif %}
{% if object.ebird_code %}
<p>eBird code: {{object.ebird_code}}</p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends "base_list.html" %}
{% load static %}
{% block title %}Birds{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Common Name</th>
<th scope="col">Scientific Name</th>
</tr>
</thead>
<tbody>
{% for bird in object_list %}
<tr>
<td><a href="{{bird.get_absolute_url}}">{{bird.common_name}}</a></td>
<td>{{bird.scientific_name|default:""}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -32,6 +32,15 @@
<p>No food today</p>
{% endif %}
<h3><a href="{% url 'birds:birding_location_list' %}">Birding Locations</a></h3>
{% if BirdingLocation %}
{% with scrobbles=BirdingLocation count=BirdingLocation_count time=BirdingLocation_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% else %}
<p>No birding today</p>
{% endif %}
<h3><a href="{% url 'lifeevents:lifeevent_list' %}">Life Events</a></h3>
{% if life_events_in_progress %}
<div class="table-responsive">

View File

@ -106,7 +106,7 @@
{% if birding_csv_imports %}
<div class="row">
<h3>Birding CSV</h3>
<h3>eBird</h3>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
@ -116,13 +116,14 @@
<th scope="col">Scrobbles</th>
</tr>
</thead>
{% for obj in birding_csv_imports %}
<tr>
<td><a href="{{obj.get_absolute_url}}">{{obj.human_start}}</a></td>
<td>{{obj.processed_finished}}</td>
<td>{{obj.process_count}}</td>
</tr>
{% endfor %}
<tbody>
{% for obj in birding_csv_imports %}
<tr>
<td><a href="{{obj.get_absolute_url}}">{{obj.human_start}}</a></td>
<td>{{obj.processed_finished}}</td>
<td>{{obj.process_count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

View File

@ -171,6 +171,18 @@
<button type="submit" class="btn btn-primary">Import</button>
</div>
</form>
<form action="{% url 'birds:csv-upload' %}" method="post" enctype="multipart/form-data">
<div class="modal-body">
{% csrf_token %}
<div class="form-group">
<label for="id_csv_file" class="col-form-label">Birding CSV file:</label>
<input type="file" name="csv_file" class="form-control" id="id_csv_file" accept=".csv" required>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Import</button>
</div>
</form>
</div>
</div>
</div>