ebird-api-data 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ebird/api/data/__init__.py +1 -0
- ebird/api/data/admin.py +394 -0
- ebird/api/data/apps.py +8 -0
- ebird/api/data/fields.py +47 -0
- ebird/api/data/loaders.py +485 -0
- ebird/api/data/management/__init__.py +0 -0
- ebird/api/data/management/commands/__init__.py +0 -0
- ebird/api/data/management/commands/add_checklists.py +124 -0
- ebird/api/data/migrations/0001_initial.py +927 -0
- ebird/api/data/migrations/__init__.py +0 -0
- ebird/api/data/models/__init__.py +21 -0
- ebird/api/data/models/checklist.py +178 -0
- ebird/api/data/models/country.py +29 -0
- ebird/api/data/models/county.py +29 -0
- ebird/api/data/models/filter.py +71 -0
- ebird/api/data/models/location.py +153 -0
- ebird/api/data/models/observation.py +189 -0
- ebird/api/data/models/observer.py +48 -0
- ebird/api/data/models/species.py +118 -0
- ebird/api/data/models/state.py +29 -0
- ebird/api/data/widgets.py +46 -0
- ebird_api_data-0.1.0.dist-info/METADATA +206 -0
- ebird_api_data-0.1.0.dist-info/RECORD +25 -0
- ebird_api_data-0.1.0.dist-info/WHEEL +5 -0
- ebird_api_data-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import datetime as dt
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import random
|
|
5
|
+
import re
|
|
6
|
+
import socket
|
|
7
|
+
import string
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
from urllib.error import HTTPError, URLError
|
|
13
|
+
|
|
14
|
+
from django.db import transaction
|
|
15
|
+
from django.utils.timezone import get_default_timezone
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from bs4 import BeautifulSoup
|
|
20
|
+
from ebird.api.requests import get_checklist, get_regions, get_taxonomy, get_visits
|
|
21
|
+
from ebird.api.requests.constants import API_MAX_RESULTS
|
|
22
|
+
|
|
23
|
+
from .models import (
|
|
24
|
+
Checklist,
|
|
25
|
+
Country,
|
|
26
|
+
County,
|
|
27
|
+
Filter,
|
|
28
|
+
Location,
|
|
29
|
+
Observation,
|
|
30
|
+
Observer,
|
|
31
|
+
Species,
|
|
32
|
+
State,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# Set timeout, in seconds, for SSL socket connections
|
|
38
|
+
socket.setdefaulttimeout(30)
|
|
39
|
+
# Total number number of retries to attempt
|
|
40
|
+
RETRY_LIMIT: int = 10
|
|
41
|
+
# Time, in seconds, to wait after an API call fails
|
|
42
|
+
RETRY_WAIT: int = 2
|
|
43
|
+
# Multiplier to apply to wait time after each failed attempt
|
|
44
|
+
RETRY_MULTIPLIER: float = 2.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def str2datetime(value: str) -> dt.datetime:
|
|
48
|
+
return dt.datetime.fromisoformat(value).replace(tzinfo=get_default_timezone())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def random_word(length):
|
|
52
|
+
letters = string.ascii_lowercase
|
|
53
|
+
return "".join(random.choice(letters) for i in range(length))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class APILoader:
|
|
57
|
+
"""
|
|
58
|
+
The APILoader downloads checklists from the eBird API and saves
|
|
59
|
+
them to the database.
|
|
60
|
+
|
|
61
|
+
Arguments:
|
|
62
|
+
|
|
63
|
+
api_key: Your key to access the eBird API.
|
|
64
|
+
Your can request a key at https://ebird.org/data/download.
|
|
65
|
+
You will need an eBird account to do so.
|
|
66
|
+
|
|
67
|
+
locales: A map of Django language codes to eBird locales so the species
|
|
68
|
+
common name, family name, etc. is displayed in the language
|
|
69
|
+
selected by the user.
|
|
70
|
+
|
|
71
|
+
limit: The total number of retries that can be attempted when calling
|
|
72
|
+
the API or scraping a web page. Defaults to RETRY_LIMIT if not
|
|
73
|
+
set.
|
|
74
|
+
|
|
75
|
+
wait: The initial number of seconds to wait. Defaults to RETRY_WAIT
|
|
76
|
+
if not set.
|
|
77
|
+
|
|
78
|
+
multiplier: The multiplier to apply to the wait time after each retry.
|
|
79
|
+
Defaults to RETRY_MULTIPLIER if not set.
|
|
80
|
+
|
|
81
|
+
The eBird API limits the number of records returned to 200. When downloading
|
|
82
|
+
the visits for a given region if 200 hundred records are returned then it is
|
|
83
|
+
assumed there are more and the loader will fetch the sub-regions and download
|
|
84
|
+
the visits for each, repeating the process if necessary. To give an extreme
|
|
85
|
+
example if you download the visits for the United States, "US", then the API
|
|
86
|
+
will always return 200 results and the loader then download the visits to
|
|
87
|
+
each of the 50 states and then each of the 3143 counties. DON'T TRY THIS
|
|
88
|
+
AT HOME. Even if you don't get banned, if you melt the eBird servers, then
|
|
89
|
+
karma will ensure bad things happen to you.
|
|
90
|
+
|
|
91
|
+
The loader uses a budget for retry attempts when calling the eBird API or
|
|
92
|
+
fetching a web page. After each failure, a wait is applied, which increases
|
|
93
|
+
after each attempt. Once the total number of retries is reached, no
|
|
94
|
+
further attempts are made. You can set the number of retries, the initial
|
|
95
|
+
wait, and the multiplier when creating an APILoader object, otherwise
|
|
96
|
+
sensible defaults are used.
|
|
97
|
+
|
|
98
|
+
The default limit for retries is 10, with an initial wait of 2 seconds and
|
|
99
|
+
a multiplier also of 2. This means the loader will wait, 2, 4, 8, 16, etc.
|
|
100
|
+
seconds after each attempt. Since the loader will be run periodically,
|
|
101
|
+
using a scheduler such as cron, the retry limit is probably too high. Most
|
|
102
|
+
of the errors seen to date are timeout error when creating an SSL socket
|
|
103
|
+
connection. The are relatively rare - only one or two a week - with a
|
|
104
|
+
loader that is scheduled to run every hour. You could easily reduce the
|
|
105
|
+
limit to 3, as then, successive errors will usually mean something serious
|
|
106
|
+
is wrong with the network, or the eBird servers are overloaded, and you
|
|
107
|
+
should stop from making the situation any worse.
|
|
108
|
+
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
api_key: str,
|
|
114
|
+
locales: dict,
|
|
115
|
+
limit: int = None,
|
|
116
|
+
wait: int = None,
|
|
117
|
+
multiplier: float = None,
|
|
118
|
+
):
|
|
119
|
+
self.api_key: str = api_key
|
|
120
|
+
self.locales: dict = locales
|
|
121
|
+
self.retries: int = 0
|
|
122
|
+
self.retry_limit: int = limit if limit else RETRY_LIMIT
|
|
123
|
+
self.retry_wait: int = wait if wait else RETRY_WAIT
|
|
124
|
+
self.retry_multiplier: float = multiplier if multiplier else RETRY_MULTIPLIER
|
|
125
|
+
|
|
126
|
+
def call(self, func, *args, **kwargs):
|
|
127
|
+
wait: float = float(self.retry_wait)
|
|
128
|
+
while True:
|
|
129
|
+
try:
|
|
130
|
+
return func(*args, **kwargs)
|
|
131
|
+
except (URLError, HTTPError) as err:
|
|
132
|
+
logger.exception("Failed call #%d", self.retries)
|
|
133
|
+
self.retries += 1
|
|
134
|
+
if self.retries > self.retry_limit:
|
|
135
|
+
logger.exception("Retry limit reached")
|
|
136
|
+
raise err
|
|
137
|
+
time.sleep(wait)
|
|
138
|
+
wait *= self.retry_multiplier
|
|
139
|
+
|
|
140
|
+
def call_api(self, func, *args, **kwargs) -> dict | list:
|
|
141
|
+
return self.call(func, self.api_key, *args, **kwargs)
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def get_country(data: dict) -> Country:
|
|
145
|
+
code: str = data["countryCode"]
|
|
146
|
+
values: dict = {
|
|
147
|
+
"name": data["countryName"],
|
|
148
|
+
"place": data["countryName"],
|
|
149
|
+
}
|
|
150
|
+
country, created = Country.objects.get_or_create(code=code, defaults=values)
|
|
151
|
+
if created:
|
|
152
|
+
logger.info("Added country: %s, %s", code, values["name"])
|
|
153
|
+
return country
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def get_state(data: dict) -> State:
|
|
157
|
+
code: str = data["subnational1Code"]
|
|
158
|
+
values: dict = {
|
|
159
|
+
"name": data["subnational1Name"],
|
|
160
|
+
"place": "%s, %s" % (data["subnational1Name"], data["countryName"]),
|
|
161
|
+
}
|
|
162
|
+
state, created = State.objects.get_or_create(code=code, defaults=values)
|
|
163
|
+
if created:
|
|
164
|
+
logger.info("Added state: %s, %s", code, values["name"])
|
|
165
|
+
return state
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def get_county(data) -> County:
|
|
169
|
+
code: str = data["subnational2Code"]
|
|
170
|
+
values: dict = {
|
|
171
|
+
"name": data["subnational2Name"],
|
|
172
|
+
"place": "%s, %s, %s"
|
|
173
|
+
% (data["subnational2Name"], data["subnational1Name"], data["countryName"]),
|
|
174
|
+
}
|
|
175
|
+
county, created = County.objects.get_or_create(code=code, defaults=values)
|
|
176
|
+
if created:
|
|
177
|
+
logger.info("Added county: %s, %s", code, values["name"])
|
|
178
|
+
return county
|
|
179
|
+
|
|
180
|
+
def add_location(self, data: dict) -> Location:
|
|
181
|
+
identifier: str = data["locId"]
|
|
182
|
+
location: Location
|
|
183
|
+
|
|
184
|
+
values: dict = {
|
|
185
|
+
"identifier": identifier,
|
|
186
|
+
"name": data["name"],
|
|
187
|
+
"original": data["name"],
|
|
188
|
+
"country": self.get_country(data),
|
|
189
|
+
"state": self.get_state(data),
|
|
190
|
+
"county": None,
|
|
191
|
+
"hotspot": data["isHotspot"],
|
|
192
|
+
"latitude": Decimal(data["latitude"]),
|
|
193
|
+
"longitude": Decimal(data["longitude"]),
|
|
194
|
+
"url": "https://ebird.org/region/%s" % identifier,
|
|
195
|
+
"data": {"api": data},
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if "subnational2Code" in data:
|
|
199
|
+
values["county"] = self.get_county(data)
|
|
200
|
+
|
|
201
|
+
location = Location.objects.create(**values)
|
|
202
|
+
logger.info("Added location: %s, %s", identifier, location.name)
|
|
203
|
+
return location
|
|
204
|
+
|
|
205
|
+
def add_species(self, code: str) -> Species:
|
|
206
|
+
"""
|
|
207
|
+
Add the species with the eBird code.
|
|
208
|
+
|
|
209
|
+
Arguments:
|
|
210
|
+
code: the eBird code for the species, e.g. 'horlar' (Horned Lark).
|
|
211
|
+
|
|
212
|
+
"""
|
|
213
|
+
values: dict = {
|
|
214
|
+
"common_name": {},
|
|
215
|
+
"family_common_name": {},
|
|
216
|
+
"data": {"api": {}},
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for language, locale in self.locales.items():
|
|
220
|
+
data = self.call_api(get_taxonomy, locale=locale, species=code)[0]
|
|
221
|
+
values["taxon_order"] = int(data["taxonOrder"])
|
|
222
|
+
values["order"] = data.get("order", "")
|
|
223
|
+
values["category"] = data["category"]
|
|
224
|
+
values["family_code"] = data["familyCode"]
|
|
225
|
+
values["common_name"][language] = data["comName"]
|
|
226
|
+
values["scientific_name"] = data["sciName"]
|
|
227
|
+
values["family_common_name"][language] = data["familyComName"]
|
|
228
|
+
values["family_scientific_name"] = data["familySciName"]
|
|
229
|
+
values["data"]["api"][locale] = data
|
|
230
|
+
|
|
231
|
+
values["common_name"] = json.dumps(values["common_name"])
|
|
232
|
+
values["family_common_name"] = json.dumps(values["family_common_name"])
|
|
233
|
+
|
|
234
|
+
species = Species.objects.create(species_code=code, **values)
|
|
235
|
+
logger.info("Added species: %s, %s", code, species.get_common_name())
|
|
236
|
+
|
|
237
|
+
return species
|
|
238
|
+
|
|
239
|
+
def get_species(self, data: dict) -> Species:
|
|
240
|
+
code: str = data["speciesCode"]
|
|
241
|
+
species = Species.objects.filter(species_code=code).first()
|
|
242
|
+
if species is None:
|
|
243
|
+
species = self.add_species(code)
|
|
244
|
+
return species
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def get_urn(project_id, row: dict) -> str:
|
|
248
|
+
return f"URN:CornellLabOfOrnithology:{project_id}:{row['obsId']}"
|
|
249
|
+
|
|
250
|
+
def add_observation(self, data: dict, checklist: Checklist) -> Observation:
|
|
251
|
+
identifier: str = data["obsId"]
|
|
252
|
+
observation: Observation
|
|
253
|
+
species: Species = self.get_species(data)
|
|
254
|
+
|
|
255
|
+
values: dict = {
|
|
256
|
+
"edited": checklist.edited,
|
|
257
|
+
"published": False,
|
|
258
|
+
"checklist": checklist,
|
|
259
|
+
"country": checklist.country,
|
|
260
|
+
"state": checklist.state,
|
|
261
|
+
"county": checklist.county,
|
|
262
|
+
"location": checklist.location,
|
|
263
|
+
"observer": checklist.observer,
|
|
264
|
+
"species": species,
|
|
265
|
+
"date": checklist.date,
|
|
266
|
+
"time": checklist.time,
|
|
267
|
+
"started": checklist.started,
|
|
268
|
+
"count": 0,
|
|
269
|
+
"audio": False,
|
|
270
|
+
"photo": False,
|
|
271
|
+
"video": False,
|
|
272
|
+
"comments": "",
|
|
273
|
+
"urn": self.get_urn(checklist.project_code, data),
|
|
274
|
+
"data": {"api": data},
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if re.match(r"\d+", data["howManyStr"]):
|
|
278
|
+
values["count"] = int(data["howManyStr"])
|
|
279
|
+
|
|
280
|
+
if "mediaCounts" in data:
|
|
281
|
+
values["audio"] = "A" in data["mediaCounts"]
|
|
282
|
+
values["photo"] = "P" in data["mediaCounts"]
|
|
283
|
+
values["video"] = "V" in data["mediaCounts"]
|
|
284
|
+
|
|
285
|
+
if "comments" in data:
|
|
286
|
+
values["comments"] = data["comments"]
|
|
287
|
+
|
|
288
|
+
observation = Observation.objects.create(identifier=identifier, **values)
|
|
289
|
+
|
|
290
|
+
return observation
|
|
291
|
+
|
|
292
|
+
def get_observer_identifier(self, data) -> str:
|
|
293
|
+
identifier: str = data["subId"]
|
|
294
|
+
logger.info("Scraping checklist: %s", identifier)
|
|
295
|
+
response = self.call(
|
|
296
|
+
requests.get, "https://ebird.org/checklist/%s" % identifier
|
|
297
|
+
)
|
|
298
|
+
content = response.content
|
|
299
|
+
soup = BeautifulSoup(content, "lxml")
|
|
300
|
+
attribute = "data-participant-userid"
|
|
301
|
+
node = soup.find("span", attrs={attribute: True})
|
|
302
|
+
return node[attribute] if node else ""
|
|
303
|
+
|
|
304
|
+
def get_observer(self, data: dict) -> Observer:
|
|
305
|
+
name: str = data.get("userDisplayName", "Anonymous eBirder")
|
|
306
|
+
observer, created = Observer.objects.get_or_create(
|
|
307
|
+
original=name, defaults={"name": name}
|
|
308
|
+
)
|
|
309
|
+
if observer.multiple:
|
|
310
|
+
observer, created = Observer.objects.get_or_create(
|
|
311
|
+
identifier=self.get_observer_identifier(data),
|
|
312
|
+
defaults={"original": random_word(8), "name": name},
|
|
313
|
+
)
|
|
314
|
+
elif observer.identifier == "":
|
|
315
|
+
observer.identifier = self.get_observer_identifier(data)
|
|
316
|
+
observer.save()
|
|
317
|
+
if created:
|
|
318
|
+
logger.info("Added observer: %s", observer.name)
|
|
319
|
+
return observer
|
|
320
|
+
|
|
321
|
+
def add_checklist(self, identifier: str) -> Checklist | None:
|
|
322
|
+
"""
|
|
323
|
+
Add the checklist with the given identifier.
|
|
324
|
+
|
|
325
|
+
Arguments:
|
|
326
|
+
identifier: the eBird identifier for the checklist, e.g. "S318722167"
|
|
327
|
+
|
|
328
|
+
"""
|
|
329
|
+
logger.info("Adding checklist: %s", identifier)
|
|
330
|
+
|
|
331
|
+
# Make sure loading a checklist is an all or nothing proposition.
|
|
332
|
+
# All the data is available from the eBird API call but there can
|
|
333
|
+
# still be further calls to scrape the checklist web page to get
|
|
334
|
+
# identifier of the observer, or the eBird API when a new species
|
|
335
|
+
# is added.
|
|
336
|
+
|
|
337
|
+
with transaction.atomic():
|
|
338
|
+
data: dict = self.call_api(get_checklist, identifier)
|
|
339
|
+
identifier: str = data["subId"]
|
|
340
|
+
created: dt.datetime = str2datetime(data["creationDt"])
|
|
341
|
+
edited: dt.datetime = str2datetime(data["lastEditedDt"])
|
|
342
|
+
started: dt.datetime = str2datetime(data["obsDt"])
|
|
343
|
+
location: Location = Location.objects.get(identifier=data["locId"])
|
|
344
|
+
checklist: Checklist
|
|
345
|
+
observer: Observer = self.get_observer(data)
|
|
346
|
+
observations: list = data.pop("obs", [])
|
|
347
|
+
|
|
348
|
+
if not observer.enabled:
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
values: dict = {
|
|
352
|
+
"created": created,
|
|
353
|
+
"edited": edited,
|
|
354
|
+
"published": False,
|
|
355
|
+
"country": location.country,
|
|
356
|
+
"state": location.state,
|
|
357
|
+
"county": location.county,
|
|
358
|
+
"location": location,
|
|
359
|
+
"observer": observer,
|
|
360
|
+
"observer_count": None,
|
|
361
|
+
"species_count": data["numSpecies"],
|
|
362
|
+
"date": started.date(),
|
|
363
|
+
"time": None,
|
|
364
|
+
"started": started,
|
|
365
|
+
"protocol_code": data["protocolId"],
|
|
366
|
+
"project_code": data["projId"],
|
|
367
|
+
"duration": None,
|
|
368
|
+
"complete": data["allObsReported"],
|
|
369
|
+
"comments": "",
|
|
370
|
+
"url": "https://ebird.org/checklist/%s" % identifier,
|
|
371
|
+
"data": {"api": data},
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if data["obsTimeValid"]:
|
|
375
|
+
values["time"] = started.time()
|
|
376
|
+
|
|
377
|
+
if "numObservers" in data:
|
|
378
|
+
values["observer_count"] = int(data["numObservers"])
|
|
379
|
+
|
|
380
|
+
if duration := data.get("durationHrs"):
|
|
381
|
+
values["duration"] = int(duration * 60.0)
|
|
382
|
+
|
|
383
|
+
if data["protocolId"] == "P22":
|
|
384
|
+
# The distance travelled might not be reported.
|
|
385
|
+
if "effortDistanceKm" in data:
|
|
386
|
+
dist: str = data["effortDistanceKm"]
|
|
387
|
+
values["distance"] = round(Decimal(dist), 3)
|
|
388
|
+
elif data["protocolId"] == "P23":
|
|
389
|
+
area: str = data["effortAreaHa"]
|
|
390
|
+
values["area"] = round(Decimal(area), 3)
|
|
391
|
+
|
|
392
|
+
if "comments" in data:
|
|
393
|
+
values["comments"] = data["comments"]
|
|
394
|
+
|
|
395
|
+
checklist = Checklist.objects.create(identifier=identifier, **values)
|
|
396
|
+
|
|
397
|
+
for observation_data in observations:
|
|
398
|
+
self.add_observation(observation_data, checklist)
|
|
399
|
+
|
|
400
|
+
return checklist
|
|
401
|
+
|
|
402
|
+
def fetch_subregions(self, region: str) -> List[str]:
|
|
403
|
+
region_types: list = ["subnational1", "subnational2", None]
|
|
404
|
+
levels: int = len(region.split("-", 2))
|
|
405
|
+
region_type: Optional[str] = region_types[levels - 1]
|
|
406
|
+
|
|
407
|
+
if region_type:
|
|
408
|
+
items: list = self.call_api(get_regions, region_type, region)
|
|
409
|
+
sub_regions = [item["code"] for item in items]
|
|
410
|
+
else:
|
|
411
|
+
sub_regions = []
|
|
412
|
+
|
|
413
|
+
return sub_regions
|
|
414
|
+
|
|
415
|
+
def fetch_visits(self, region: str, date: dt.date):
|
|
416
|
+
visits = []
|
|
417
|
+
|
|
418
|
+
results: list = get_visits(
|
|
419
|
+
self.api_key, region, date=date, max_results=API_MAX_RESULTS
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
if len(results) == API_MAX_RESULTS:
|
|
423
|
+
logger.info("API result limit reached - fetching visits for subregions")
|
|
424
|
+
if sub_regions := self.fetch_subregions(region):
|
|
425
|
+
for sub_region in sub_regions:
|
|
426
|
+
logger.info("Fetching visits for sub-region: %s", sub_region)
|
|
427
|
+
visits.extend(self.fetch_visits(sub_region, date))
|
|
428
|
+
else:
|
|
429
|
+
# No more sub-regions, issue a warning and return the results
|
|
430
|
+
visits.extend(results)
|
|
431
|
+
logger.warning(
|
|
432
|
+
"Fetching visits - API limit reached: %s, %s", region, date
|
|
433
|
+
)
|
|
434
|
+
else:
|
|
435
|
+
visits.extend(results)
|
|
436
|
+
|
|
437
|
+
return visits
|
|
438
|
+
|
|
439
|
+
def add_checklists(self, region: str, date: dt.date) -> None:
|
|
440
|
+
"""
|
|
441
|
+
Add all the checklists submitted for a region for a given date.
|
|
442
|
+
|
|
443
|
+
Arguments:
|
|
444
|
+
region: The code for a national, subnational1, subnational2
|
|
445
|
+
area or hotspot identifier. For example, US, US-NY,
|
|
446
|
+
US-NY-109, or L1379126, respectively.
|
|
447
|
+
|
|
448
|
+
date: The date the observations were made.
|
|
449
|
+
|
|
450
|
+
"""
|
|
451
|
+
|
|
452
|
+
logger.info("Adding checklists: %s, %s", region, date)
|
|
453
|
+
|
|
454
|
+
visits: list[dict] = self.fetch_visits(region, date)
|
|
455
|
+
|
|
456
|
+
logger.info("Visits made: %d ", len(visits))
|
|
457
|
+
|
|
458
|
+
for visit in visits:
|
|
459
|
+
data = visit["loc"]
|
|
460
|
+
identifier = data["locId"]
|
|
461
|
+
if not Location.objects.filter(identifier=identifier).exists():
|
|
462
|
+
self.add_location(data)
|
|
463
|
+
|
|
464
|
+
added: int = 0
|
|
465
|
+
|
|
466
|
+
for visit in visits:
|
|
467
|
+
identifier = visit["subId"]
|
|
468
|
+
if not Checklist.objects.filter(identifier=identifier).exists():
|
|
469
|
+
self.add_checklist(identifier)
|
|
470
|
+
added += 1
|
|
471
|
+
|
|
472
|
+
logger.info("Checklists added: %d ", added)
|
|
473
|
+
logger.info("Adding checklists completed")
|
|
474
|
+
|
|
475
|
+
@staticmethod
|
|
476
|
+
def run_filters():
|
|
477
|
+
for filter in Filter.objects.filter(enabled=True):
|
|
478
|
+
logger.info("Applying filter: %s", filter.name)
|
|
479
|
+
count = filter.apply()
|
|
480
|
+
logger.info("Observations updated: %d", count)
|
|
481
|
+
|
|
482
|
+
@staticmethod
|
|
483
|
+
def publish():
|
|
484
|
+
Checklist.objects.filter(published=False).update(published=True)
|
|
485
|
+
Observation.objects.filter(published=False).update(published=True)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
load_api.py
|
|
3
|
+
|
|
4
|
+
A Django management command for loading observations from the eBird API.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
python manage.py add_checklists --date <date> <region>+
|
|
8
|
+
python manage.py add_checklists --days <n> <region>+
|
|
9
|
+
python manage.py add_checklists --date <date> --days <n> <region>+
|
|
10
|
+
|
|
11
|
+
Arguments:
|
|
12
|
+
--date <date> The date of the checklists to add to the database. If
|
|
13
|
+
no date is given then it defaults to today.
|
|
14
|
+
|
|
15
|
+
--days <n> The number of days to include. If not given then the number
|
|
16
|
+
of days defaults to 1. That means only checklists for the date will
|
|
17
|
+
be added.
|
|
18
|
+
|
|
19
|
+
<region> Required. One or more national, subnational1, subnational2, or hotspot
|
|
20
|
+
codes used by eBird. For example, US, US-NY, US-NY-109, L1379126
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
# Load checklists added in the past week for New York state
|
|
24
|
+
python manage.py add_checklists --days 7 US-NY
|
|
25
|
+
|
|
26
|
+
# Load checklists all checklists added on the last Big Day
|
|
27
|
+
python manage.py add_checklists --date 2024-10-05 US-NY
|
|
28
|
+
|
|
29
|
+
Notes:
|
|
30
|
+
1. The eBird API returns a maximum of 200 results. The APILoader works
|
|
31
|
+
around this by fetching checklists from sub-regions if necessary.
|
|
32
|
+
Downloading checklists once a day should be sufficient for all hotspots
|
|
33
|
+
or subnational2 areas. For large countries or places with lots of birders
|
|
34
|
+
downloads will have to be more frequent. For really large area, i.e. the
|
|
35
|
+
USA you shouldn't be using the API at all. Instead use the data from the
|
|
36
|
+
eBird Basic Dataset.
|
|
37
|
+
|
|
38
|
+
2. Why does the command only add checklists, not update existing ones?
|
|
39
|
+
The number of checklists that are updated is relatively small, typically
|
|
40
|
+
less than 1%. The problem with the eBird API is that you can only find
|
|
41
|
+
out whether a checklist has changed by downloading it. This app basically
|
|
42
|
+
mirrors the eBird database for a given region so there's a strong temptation
|
|
43
|
+
to repeatedly download everything to keep the checklists in sync. That
|
|
44
|
+
means repeatedly downloading all the checklists submitted in the past week
|
|
45
|
+
or month, or longer to pick up a few changes. That's a heavy load on the
|
|
46
|
+
eBird servers and a lot of bandwidth for relatively little gain.
|
|
47
|
+
|
|
48
|
+
The API is really a news service. For accuracy and completeness you should
|
|
49
|
+
really use the eBird Basic Dataset, which is published on the 15th of each
|
|
50
|
+
month.
|
|
51
|
+
|
|
52
|
+
3. The API has limitations. Observers are only identified by name. So if there
|
|
53
|
+
are two Juan Garcias birding in a region, then all the observations will
|
|
54
|
+
appear to belong to one person. To work around this, when an observer is
|
|
55
|
+
added the web page for the checklist is scraped to extract their eBird
|
|
56
|
+
user id.
|
|
57
|
+
|
|
58
|
+
Also the observations will not have been reviewed by moderators, so there
|
|
59
|
+
are likely to be records where the identification is incorrect.
|
|
60
|
+
|
|
61
|
+
4. You automate running the command using a scheduler such as cron. If you use
|
|
62
|
+
the absolute paths to python and the command, then you don't need to deal
|
|
63
|
+
with activating the virtual environment, for example:
|
|
64
|
+
|
|
65
|
+
# Just before midnight, add all checklists submitted for the past week
|
|
66
|
+
55 23 * * * /home/me/my-project/.venv/bin/python /home/me/my-project/manage.py add_checklists --days 7 US-NY
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
import datetime as dt
|
|
71
|
+
|
|
72
|
+
from django.conf import settings
|
|
73
|
+
from django.core.management.base import BaseCommand
|
|
74
|
+
|
|
75
|
+
from ebird.api.data.loaders import APILoader
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Command(BaseCommand):
|
|
79
|
+
help = "Add checklists from the eBird API"
|
|
80
|
+
|
|
81
|
+
def add_arguments(self, parser):
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--date",
|
|
84
|
+
type=lambda s: dt.datetime.strptime(s, "%Y-%m-%d"),
|
|
85
|
+
help="The number of previous days to add",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--days",
|
|
90
|
+
type=int,
|
|
91
|
+
help="The number of previous days to add",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"regions",
|
|
96
|
+
nargs="+",
|
|
97
|
+
type=str,
|
|
98
|
+
help="Codes for the eBird regions, e.g US-NY",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def get_loader() -> APILoader:
|
|
103
|
+
key: str = getattr(settings, "EBIRD_API_KEY")
|
|
104
|
+
locales: dict = getattr(settings, "EBIRD_LOCALES")
|
|
105
|
+
return APILoader(key, locales)
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def get_dates(days) -> list[dt.date]:
|
|
109
|
+
today: dt.date = dt.date.today()
|
|
110
|
+
return [today - dt.timedelta(days=n) for n in range(days)]
|
|
111
|
+
|
|
112
|
+
def handle(self, *args, **options):
|
|
113
|
+
date = options["date"] or dt.date.today()
|
|
114
|
+
days = options["days"] or 1
|
|
115
|
+
|
|
116
|
+
dates = [date - dt.timedelta(days=n) for n in range(days)]
|
|
117
|
+
loader = self.get_loader()
|
|
118
|
+
|
|
119
|
+
for region in options["regions"]:
|
|
120
|
+
for date in dates:
|
|
121
|
+
loader.add_checklists(region, date)
|
|
122
|
+
|
|
123
|
+
loader.run_filters()
|
|
124
|
+
loader.publish()
|