[birds] Add much better bird importing
This commit is contained in:
0
tests/birds_tests/__init__.py
Normal file
0
tests/birds_tests/__init__.py
Normal file
70
tests/birds_tests/conftest.py
Normal file
70
tests/birds_tests/conftest.py
Normal file
@ -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,
|
||||
},
|
||||
)
|
||||
96
tests/birds_tests/test_dataclasses.py
Normal file
96
tests/birds_tests/test_dataclasses.py
Normal file
@ -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
|
||||
130
tests/birds_tests/test_importer.py
Normal file
130
tests/birds_tests/test_importer.py
Normal file
@ -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
|
||||
30
tests/birds_tests/test_models.py
Normal file
30
tests/birds_tests/test_models.py
Normal file
@ -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"
|
||||
42
tests/birds_tests/test_views.py
Normal file
42
tests/birds_tests/test_views.py
Normal file
@ -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
|
||||
@ -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,
|
||||
|
||||
@ -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>'
|
||||
|
||||
@ -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">×</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">×</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary add-sighting-row mt-2">Add bird</button>
|
||||
</div>
|
||||
@ -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"
|
||||
|
||||
21
vrobbler/templates/birds/bird_detail.html
Normal file
21
vrobbler/templates/birds/bird_detail.html
Normal 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 %}
|
||||
29
vrobbler/templates/birds/bird_list.html
Normal file
29
vrobbler/templates/birds/bird_list.html
Normal 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 %}
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user