ebird-api-data 0.5.2__tar.gz → 0.5.3__tar.gz
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-0.5.2 → ebird_api_data-0.5.3}/PKG-INFO +8 -6
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/README.md +6 -2
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/pyproject.toml +3 -5
- ebird_api_data-0.5.3/src/ebird/api/data/__init__.py +1 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/admin.py +2 -1
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/loaders.py +117 -38
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/management/commands/add_checklists.py +4 -3
- ebird_api_data-0.5.3/src/ebird/api/data/management/commands/repair_observers.py +185 -0
- ebird_api_data-0.5.2/src/ebird/api/data/__init__.py +0 -1
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/.gitignore +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/LICENSE +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/apps.py +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/fields.py +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/locale/en/LC_MESSAGES/django.mo +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/locale/en/LC_MESSAGES/django.po +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/management/__init__.py +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/management/commands/__init__.py +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/migrations/0001_initial.py +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/migrations/0002_expand_filter.py +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/migrations/0003_remove_checklist_published_and_more.py +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/migrations/0004_remove_observer_multiple.py +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/migrations/__init__.py +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/models/__init__.py +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/models/checklist.py +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/models/country.py +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/models/county.py +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/models/location.py +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/models/observation.py +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/models/observer.py +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/models/species.py +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/models/state.py +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/templates/admin/data/species/change_list_object_tools.html +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/templates/admin/data/species/fetch_species.html +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/templates/data/widgets/translation_field.html +0 -0
- {ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/widgets.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ebird-api-data
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.3
|
|
4
4
|
Summary: eBird API Data is a reusable Django app for loading data from eBird into a database.
|
|
5
5
|
Author-email: Stuart MacKay <smackay@flagstonesoftware.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -21,10 +21,8 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
21
21
|
Classifier: Topic :: Scientific/Engineering
|
|
22
22
|
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
|
-
Requires-Dist: beautifulsoup4>=4.13.3
|
|
25
24
|
Requires-Dist: django>=4.2
|
|
26
|
-
Requires-Dist: ebird-api-requests>=4.
|
|
27
|
-
Requires-Dist: lxml>=6.0.0
|
|
25
|
+
Requires-Dist: ebird-api-requests>=4.3.1
|
|
28
26
|
Description-Content-Type: text/markdown
|
|
29
27
|
|
|
30
28
|
# eBird API Data
|
|
@@ -165,8 +163,12 @@ be assigned to them. In practice, most people ensure their name is unique by
|
|
|
165
163
|
adding middle initial's etc. However it is a problem with "Anonymous eBirder".
|
|
166
164
|
The solution is to add two Observer records with the name "Anonymous eBirder",
|
|
167
165
|
and a fake identifier, e.g. ANON1, ANON2, etc. That way the loader will first
|
|
168
|
-
|
|
169
|
-
|
|
166
|
+
fetch the Top 100 observers for the checklist location, on the date of the
|
|
167
|
+
checklist, where the identifier, e.g. USER2743448, can be found, and so the
|
|
168
|
+
checklist will be assigned to the correct Observer. If the identifier cannot
|
|
169
|
+
be found, e.g. two observers with the same name submitted checklists at the
|
|
170
|
+
same location on the same day, the checklist is assigned to the first
|
|
171
|
+
Observer added with that name.
|
|
170
172
|
Use this workaround for any other observers you encounter that share the same
|
|
171
173
|
name. This won't work retroactively, but then you can delete the checklists,
|
|
172
174
|
and manually reload them.
|
|
@@ -136,8 +136,12 @@ be assigned to them. In practice, most people ensure their name is unique by
|
|
|
136
136
|
adding middle initial's etc. However it is a problem with "Anonymous eBirder".
|
|
137
137
|
The solution is to add two Observer records with the name "Anonymous eBirder",
|
|
138
138
|
and a fake identifier, e.g. ANON1, ANON2, etc. That way the loader will first
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
fetch the Top 100 observers for the checklist location, on the date of the
|
|
140
|
+
checklist, where the identifier, e.g. USER2743448, can be found, and so the
|
|
141
|
+
checklist will be assigned to the correct Observer. If the identifier cannot
|
|
142
|
+
be found, e.g. two observers with the same name submitted checklists at the
|
|
143
|
+
same location on the same day, the checklist is assigned to the first
|
|
144
|
+
Observer added with that name.
|
|
141
145
|
Use this workaround for any other observers you encounter that share the same
|
|
142
146
|
name. This won't work retroactively, but then you can delete the checklists,
|
|
143
147
|
and manually reload them.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ebird-api-data"
|
|
3
|
-
version = "0.5.
|
|
3
|
+
version = "0.5.3"
|
|
4
4
|
description = "eBird API Data is a reusable Django app for loading data from eBird into a database."
|
|
5
5
|
keywords = ["checklists", "observations", "species", "Django", "eBird", "models"]
|
|
6
6
|
license = "MIT"
|
|
@@ -11,10 +11,8 @@ authors = [
|
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
requires-python = ">=3.10"
|
|
13
13
|
dependencies = [
|
|
14
|
-
"beautifulsoup4>=4.13.3",
|
|
15
14
|
"django>=4.2",
|
|
16
|
-
"ebird-api-requests>=4.
|
|
17
|
-
"lxml>=6.0.0",
|
|
15
|
+
"ebird-api-requests>=4.3.1",
|
|
18
16
|
]
|
|
19
17
|
classifiers = [
|
|
20
18
|
"Development Status :: 5 - Production/Stable",
|
|
@@ -64,7 +62,7 @@ dev-dependencies = [
|
|
|
64
62
|
]
|
|
65
63
|
|
|
66
64
|
[tool.bumpversion]
|
|
67
|
-
current_version = "0.5.
|
|
65
|
+
current_version = "0.5.3"
|
|
68
66
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
69
67
|
serialize = ["{major}.{minor}.{patch}"]
|
|
70
68
|
ignore_missing_version = false
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.3"
|
|
@@ -303,7 +303,8 @@ class FetchSpeciesView(PermissionRequiredMixin, generic.FormView):
|
|
|
303
303
|
species_code = form.cleaned_data["species_code"]
|
|
304
304
|
key: str = getattr(settings, "EBIRD_API_KEY")
|
|
305
305
|
locales: dict = getattr(settings, "EBIRD_LOCALES")
|
|
306
|
-
|
|
306
|
+
rate_limit: float | None = getattr(settings, "EBIRD_RATE_LIMIT", None)
|
|
307
|
+
loader = APILoader(key, locales, rate_limit=rate_limit)
|
|
307
308
|
species = loader.add_species(species_code)
|
|
308
309
|
messages.add_message(
|
|
309
310
|
self.request,
|
|
@@ -15,10 +15,15 @@ from urllib.error import HTTPError, URLError
|
|
|
15
15
|
from django.db import transaction
|
|
16
16
|
from django.utils.timezone import get_default_timezone
|
|
17
17
|
|
|
18
|
-
import
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
from ebird.api.requests import (
|
|
19
|
+
RateLimitError,
|
|
20
|
+
get_checklist,
|
|
21
|
+
get_regions,
|
|
22
|
+
get_taxonomy,
|
|
23
|
+
get_top_100,
|
|
24
|
+
get_visits,
|
|
25
|
+
set_rate_limit,
|
|
26
|
+
)
|
|
22
27
|
from ebird.api.requests.constants import API_MAX_RESULTS
|
|
23
28
|
|
|
24
29
|
from .models import (
|
|
@@ -42,6 +47,8 @@ RETRY_LIMIT: int = 10
|
|
|
42
47
|
RETRY_WAIT: int = 2
|
|
43
48
|
# Multiplier to apply to wait time after each failed attempt
|
|
44
49
|
RETRY_MULTIPLIER: float = 2.0
|
|
50
|
+
# Minimum time, in seconds, between requests to the eBird API
|
|
51
|
+
RATE_LIMIT: float = 1.0
|
|
45
52
|
|
|
46
53
|
|
|
47
54
|
def str2datetime(value: str) -> dt.datetime:
|
|
@@ -70,9 +77,9 @@ class APILoader:
|
|
|
70
77
|
common name, family name, etc. is displayed in the language
|
|
71
78
|
selected by the user.
|
|
72
79
|
|
|
73
|
-
limit: The total number of retries that can be attempted when
|
|
74
|
-
|
|
75
|
-
set.
|
|
80
|
+
limit: The total number of retries that can be attempted when the
|
|
81
|
+
connection to the eBird API fails. Defaults to RETRY_LIMIT if
|
|
82
|
+
not set.
|
|
76
83
|
|
|
77
84
|
wait: The initial number of seconds to wait. Defaults to RETRY_WAIT
|
|
78
85
|
if not set.
|
|
@@ -80,6 +87,10 @@ class APILoader:
|
|
|
80
87
|
multiplier: The multiplier to apply to the wait time after each retry.
|
|
81
88
|
Defaults to RETRY_MULTIPLIER if not set.
|
|
82
89
|
|
|
90
|
+
rate_limit: The minimum time, in seconds, between requests to the
|
|
91
|
+
eBird API. Defaults to RATE_LIMIT if not set. Zero disables
|
|
92
|
+
rate limiting.
|
|
93
|
+
|
|
83
94
|
The eBird API limits the number of records returned to 200. When downloading
|
|
84
95
|
the visits for a given region if 200 hundred records are returned then it is
|
|
85
96
|
assumed there are more and the loader will fetch the sub-regions and download
|
|
@@ -90,9 +101,9 @@ class APILoader:
|
|
|
90
101
|
AT HOME. Even if you don't get banned, if you melt the eBird servers, then
|
|
91
102
|
karma will ensure bad things happen to you.
|
|
92
103
|
|
|
93
|
-
The loader uses a budget for retry attempts when
|
|
94
|
-
|
|
95
|
-
after each attempt. Once the total number of retries is reached, no
|
|
104
|
+
The loader uses a budget for retry attempts when the connection to the
|
|
105
|
+
eBird API fails, e.g. a timeout. After each failure, a wait is applied,
|
|
106
|
+
which increases after each attempt. Once the total number of retries is reached, no
|
|
96
107
|
further attempts are made. You can set the number of retries, the initial
|
|
97
108
|
wait, and the multiplier when creating an APILoader object, otherwise
|
|
98
109
|
sensible defaults are used.
|
|
@@ -108,6 +119,19 @@ class APILoader:
|
|
|
108
119
|
is wrong with the network, or the eBird servers are overloaded, and you
|
|
109
120
|
should stop from making the situation any worse.
|
|
110
121
|
|
|
122
|
+
Errors returned by the eBird API, e.g. HTTP 500 Internal Server Error,
|
|
123
|
+
are not retried by the loader since ebird-api-requests already retries
|
|
124
|
+
the request, with exponential back-off, before raising an HTTPError.
|
|
125
|
+
|
|
126
|
+
The eBird API also limits how quickly requests can be made. The loader
|
|
127
|
+
sets a rate limit, using set_rate_limit() from ebird-api-requests, so
|
|
128
|
+
there is a minimum interval between requests. The limit applies to all
|
|
129
|
+
the requests made by the process. If the API responds with HTTP 429 Too
|
|
130
|
+
Many Requests, ebird-api-requests waits, retries the request and slows
|
|
131
|
+
down the rate of subsequent requests. If the request still fails, the
|
|
132
|
+
loader stops rather than adding to the load on the eBird servers. Any
|
|
133
|
+
missing checklists will be added the next time the loader is run.
|
|
134
|
+
|
|
111
135
|
"""
|
|
112
136
|
|
|
113
137
|
def __init__(
|
|
@@ -117,6 +141,7 @@ class APILoader:
|
|
|
117
141
|
limit: int = None,
|
|
118
142
|
wait: int = None,
|
|
119
143
|
multiplier: float = None,
|
|
144
|
+
rate_limit: float = None,
|
|
120
145
|
):
|
|
121
146
|
self.api_key: str = api_key
|
|
122
147
|
self.locales: dict = locales
|
|
@@ -124,13 +149,21 @@ class APILoader:
|
|
|
124
149
|
self.retry_limit: int = limit if limit else RETRY_LIMIT
|
|
125
150
|
self.retry_wait: int = wait if wait else RETRY_WAIT
|
|
126
151
|
self.retry_multiplier: float = multiplier if multiplier else RETRY_MULTIPLIER
|
|
152
|
+
self.rate_limit: float = RATE_LIMIT if rate_limit is None else rate_limit
|
|
153
|
+
set_rate_limit(self.rate_limit)
|
|
127
154
|
|
|
128
155
|
def call(self, func, *args, **kwargs):
|
|
129
156
|
wait: float = float(self.retry_wait)
|
|
130
157
|
while True:
|
|
131
158
|
try:
|
|
132
159
|
return func(*args, **kwargs)
|
|
133
|
-
except
|
|
160
|
+
except RateLimitError:
|
|
161
|
+
logger.exception("Rate limit exceeded")
|
|
162
|
+
raise
|
|
163
|
+
except HTTPError:
|
|
164
|
+
# ebird-api-requests has already retried the request.
|
|
165
|
+
raise
|
|
166
|
+
except (URLError, TimeoutError) as err:
|
|
134
167
|
logger.exception("Failed call #%d", self.retries)
|
|
135
168
|
self.retries += 1
|
|
136
169
|
if self.retries > self.retry_limit:
|
|
@@ -359,27 +392,55 @@ class APILoader:
|
|
|
359
392
|
)
|
|
360
393
|
return observation
|
|
361
394
|
|
|
362
|
-
def get_observer_identifier(self, data) -> str:
|
|
395
|
+
def get_observer_identifier(self, data: dict) -> str:
|
|
396
|
+
# The checklist only contains the observer's name. The only place the
|
|
397
|
+
# eBird API returns the observer's identifier, e.g. USER59111, is the
|
|
398
|
+
# Top 100 for a region or location. Fetching the Top 100 for the
|
|
399
|
+
# location, on the date of the checklist, returns a short list, so
|
|
400
|
+
# the name will almost always be unique. The userId field is not
|
|
401
|
+
# documented, so it might be removed at some point.
|
|
363
402
|
checklist_identifier: str = data["subId"]
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
403
|
+
name: str = data.get("userDisplayName", "Anonymous eBirder")
|
|
404
|
+
location: str = data["locId"]
|
|
405
|
+
date: dt.date = str2datetime(data["obsDt"]).date()
|
|
406
|
+
|
|
407
|
+
entries: list = self.call_api(get_top_100, location, date)
|
|
408
|
+
identifiers: set = {
|
|
409
|
+
entry["userId"]
|
|
410
|
+
for entry in entries
|
|
411
|
+
if entry.get("userDisplayName") == name and entry.get("userId")
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if len(identifiers) == 1:
|
|
415
|
+
return identifiers.pop()
|
|
416
|
+
|
|
417
|
+
if identifiers:
|
|
418
|
+
logger.warning(
|
|
419
|
+
"Checklist %s: observer %s is not unique at %s on %s",
|
|
420
|
+
checklist_identifier,
|
|
421
|
+
name,
|
|
422
|
+
location,
|
|
423
|
+
date,
|
|
424
|
+
)
|
|
425
|
+
else:
|
|
426
|
+
logger.warning(
|
|
427
|
+
"Checklist %s: identifier for observer %s not found",
|
|
428
|
+
checklist_identifier,
|
|
429
|
+
name,
|
|
430
|
+
)
|
|
431
|
+
return ""
|
|
432
|
+
|
|
433
|
+
@staticmethod
|
|
434
|
+
def generate_observer_identifier() -> str:
|
|
435
|
+
# Use a prefix which will not clash with eBird's USER identifiers.
|
|
436
|
+
return "UNKNOWN-%s" % random_word(7).upper()
|
|
376
437
|
|
|
377
438
|
def get_observer(self, data: dict) -> Observer:
|
|
378
439
|
# In theory, there is a serious problem here, as observers can have
|
|
379
440
|
# the same name. There is no way to resolve this except to prime the
|
|
380
441
|
# database with multiple Observers sharing the same name. That will
|
|
381
|
-
# force the identifier to be
|
|
382
|
-
#
|
|
442
|
+
# force the identifier to be fetched from the eBird API and so the
|
|
443
|
+
# correct "John Smith" will be used. In practice, apart from
|
|
383
444
|
# Anonymous eBirder, this is not a problem. Most observers want their
|
|
384
445
|
# name to be unique (so they show up in the leader boards), and so they
|
|
385
446
|
# will add middle initials, etc. so the names are generally unique.
|
|
@@ -388,21 +449,40 @@ class APILoader:
|
|
|
388
449
|
number_of_observers = Observer.objects.filter(original=name).count()
|
|
389
450
|
|
|
390
451
|
if number_of_observers == 0:
|
|
391
|
-
identifier
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
452
|
+
if identifier := self.get_observer_identifier(data):
|
|
453
|
+
# The observer might have changed their name.
|
|
454
|
+
observer, created = Observer.objects.update_or_create(
|
|
455
|
+
identifier=identifier, defaults={"name": name, "original": name}
|
|
456
|
+
)
|
|
457
|
+
else:
|
|
458
|
+
identifier = self.generate_observer_identifier()
|
|
459
|
+
observer = Observer.objects.create(
|
|
460
|
+
identifier=identifier, name=name, original=name
|
|
461
|
+
)
|
|
462
|
+
created = True
|
|
396
463
|
elif number_of_observers == 1:
|
|
397
464
|
observer = Observer.objects.get(original=name)
|
|
398
465
|
identifier = observer.identifier
|
|
399
466
|
created = False
|
|
400
|
-
|
|
401
|
-
identifier = self.get_observer_identifier(data)
|
|
467
|
+
elif identifier := self.get_observer_identifier(data):
|
|
402
468
|
observer, created = Observer.objects.get_or_create(
|
|
403
469
|
identifier=identifier,
|
|
404
470
|
defaults={"name": name, "original": name},
|
|
405
471
|
)
|
|
472
|
+
else:
|
|
473
|
+
# There is no way to tell which observer submitted the checklist
|
|
474
|
+
# so use the first one added.
|
|
475
|
+
observer = (
|
|
476
|
+
Observer.objects.filter(original=name).order_by("created").first()
|
|
477
|
+
)
|
|
478
|
+
identifier = observer.identifier
|
|
479
|
+
created = False
|
|
480
|
+
logger.warning(
|
|
481
|
+
"Checklist %s: assigned to observer %s (%s)",
|
|
482
|
+
data["subId"],
|
|
483
|
+
name,
|
|
484
|
+
identifier,
|
|
485
|
+
)
|
|
406
486
|
|
|
407
487
|
if created:
|
|
408
488
|
logger.info("Observer added: %s (%s)", name, identifier)
|
|
@@ -419,9 +499,8 @@ class APILoader:
|
|
|
419
499
|
"""
|
|
420
500
|
# Make sure loading a checklist is an all or nothing proposition.
|
|
421
501
|
# All the data is available from the eBird API call but there can
|
|
422
|
-
# still be further calls to
|
|
423
|
-
#
|
|
424
|
-
# is added.
|
|
502
|
+
# still be further calls to the eBird API to get the identifier of
|
|
503
|
+
# the observer, or when a new species is added.
|
|
425
504
|
|
|
426
505
|
with transaction.atomic():
|
|
427
506
|
data: dict = self.call_api(get_checklist, identifier)
|
|
@@ -552,8 +631,8 @@ class APILoader:
|
|
|
552
631
|
def fetch_visits(self, region: str, date: dt.date):
|
|
553
632
|
visits = []
|
|
554
633
|
|
|
555
|
-
results: list =
|
|
556
|
-
|
|
634
|
+
results: list = self.call_api(
|
|
635
|
+
get_visits, region, date=date, max_results=API_MAX_RESULTS
|
|
557
636
|
)
|
|
558
637
|
|
|
559
638
|
if len(results) == API_MAX_RESULTS:
|
|
@@ -65,8 +65,8 @@ Notes:
|
|
|
65
65
|
3. The API has limitations. Observers are only identified by name. So if there
|
|
66
66
|
are two Juan Garcias birding in a region, then all the observations will
|
|
67
67
|
appear to belong to one person. To work around this, when an observer is
|
|
68
|
-
added
|
|
69
|
-
|
|
68
|
+
added, their eBird user id is found by fetching the Top 100 observers for
|
|
69
|
+
the location, on the date of the checklist.
|
|
70
70
|
|
|
71
71
|
Also the observations will not have been reviewed by moderators, so there
|
|
72
72
|
are likely to be records where the identification is incorrect.
|
|
@@ -121,7 +121,8 @@ class Command(BaseCommand):
|
|
|
121
121
|
def get_loader() -> APILoader:
|
|
122
122
|
key: str = getattr(settings, "EBIRD_API_KEY")
|
|
123
123
|
locales: dict = getattr(settings, "EBIRD_LOCALES")
|
|
124
|
-
|
|
124
|
+
rate_limit: float | None = getattr(settings, "EBIRD_RATE_LIMIT", None)
|
|
125
|
+
return APILoader(key, locales, rate_limit=rate_limit)
|
|
125
126
|
|
|
126
127
|
def handle(self, *args, **options):
|
|
127
128
|
date = options["date"] or dt.date.today()
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""
|
|
2
|
+
repair_observers.py
|
|
3
|
+
|
|
4
|
+
A Django management command for repairing checklists assigned to an
|
|
5
|
+
Observer by mistake.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python manage.py repair_observers [--identifier <identifier>] [--dry-run]
|
|
9
|
+
|
|
10
|
+
Arguments:
|
|
11
|
+
--identifier <identifier> The identifier of the Observer to repair. If
|
|
12
|
+
not given then it defaults to the empty string.
|
|
13
|
+
|
|
14
|
+
--dry-run Show what would be changed, without changing the database.
|
|
15
|
+
|
|
16
|
+
Examples:
|
|
17
|
+
# Repair the checklists assigned to the Observer with an empty identifier
|
|
18
|
+
python manage.py repair_observers
|
|
19
|
+
|
|
20
|
+
# Show which observers the checklists would be assigned to
|
|
21
|
+
python manage.py repair_observers --dry-run
|
|
22
|
+
|
|
23
|
+
Notes:
|
|
24
|
+
1. Up to version 0.5.2, the loader scraped the checklist web page to get
|
|
25
|
+
the observer's eBird user id. Since eBird started blocking bots, the
|
|
26
|
+
identifier scraped was always empty, so every new observer was saved
|
|
27
|
+
to the same Observer record, with an empty identifier. The record was
|
|
28
|
+
renamed each time a new observer was added.
|
|
29
|
+
|
|
30
|
+
2. To repair the checklists, the name of each observer is fetched from the
|
|
31
|
+
list of visits for the checklist's state, on the date of the checklist.
|
|
32
|
+
That takes far fewer calls to the eBird API than fetching each checklist.
|
|
33
|
+
If the checklist cannot be found then it is fetched directly. The
|
|
34
|
+
observer is then found, or added, in the same way as when the checklist
|
|
35
|
+
is loaded, and the checklist and its observations are reassigned.
|
|
36
|
+
|
|
37
|
+
3. Once all the checklists have been reassigned, the Observer record is
|
|
38
|
+
deleted. If any checklists could not be repaired, e.g. they were deleted
|
|
39
|
+
from eBird, then they remain assigned to the Observer, and the command
|
|
40
|
+
can be run again.
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
import datetime as dt
|
|
45
|
+
import logging
|
|
46
|
+
|
|
47
|
+
from urllib.error import HTTPError
|
|
48
|
+
|
|
49
|
+
from django.conf import settings
|
|
50
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
51
|
+
from django.db import transaction
|
|
52
|
+
|
|
53
|
+
from ebird.api.requests import get_checklist
|
|
54
|
+
|
|
55
|
+
from ebird.api.data.loaders import APILoader
|
|
56
|
+
from ebird.api.data.models import Checklist, Observer
|
|
57
|
+
|
|
58
|
+
logger = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
# The name given to the Observer being repaired, so the loader does not
|
|
61
|
+
# assign checklists to it when looking up observers by name.
|
|
62
|
+
REPAIR_NAME = "Observer being repaired"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Command(BaseCommand):
|
|
66
|
+
help = "Reassign checklists assigned to an Observer by mistake"
|
|
67
|
+
|
|
68
|
+
def add_arguments(self, parser):
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--identifier",
|
|
71
|
+
type=str,
|
|
72
|
+
default="",
|
|
73
|
+
help="The identifier of the Observer to repair. Defaults to ''.",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--dry-run",
|
|
78
|
+
action="store_true",
|
|
79
|
+
help="Show what would be changed, without changing the database.",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def get_loader() -> APILoader:
|
|
84
|
+
key: str = settings.EBIRD_API_KEY
|
|
85
|
+
locales: dict = settings.EBIRD_LOCALES
|
|
86
|
+
rate_limit: float | None = getattr(settings, "EBIRD_RATE_LIMIT", None)
|
|
87
|
+
return APILoader(key, locales, rate_limit=rate_limit)
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def get_names(loader: APILoader, region: str, date: dt.date) -> dict[str, str]:
|
|
91
|
+
visits: list[dict] = loader.fetch_visits(region, date)
|
|
92
|
+
return {
|
|
93
|
+
visit["subId"]: visit.get("userDisplayName", "Anonymous eBirder")
|
|
94
|
+
for visit in visits
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def get_name(loader: APILoader, identifier: str) -> str | None:
|
|
99
|
+
try:
|
|
100
|
+
data: dict = loader.call_api(get_checklist, identifier)
|
|
101
|
+
except HTTPError as err:
|
|
102
|
+
logger.warning("Checklist %s: could not be fetched, %s", identifier, err)
|
|
103
|
+
return None
|
|
104
|
+
return data.get("userDisplayName", "Anonymous eBirder")
|
|
105
|
+
|
|
106
|
+
def handle(self, *args, **options):
|
|
107
|
+
identifier: str = options["identifier"]
|
|
108
|
+
dry_run: bool = options["dry_run"]
|
|
109
|
+
|
|
110
|
+
merged: Observer | None = Observer.objects.filter(identifier=identifier).first()
|
|
111
|
+
|
|
112
|
+
if merged is None:
|
|
113
|
+
raise CommandError("Observer '%s' does not exist" % identifier)
|
|
114
|
+
|
|
115
|
+
checklists = (
|
|
116
|
+
Checklist.objects.filter(observer=merged)
|
|
117
|
+
.select_related("state", "location")
|
|
118
|
+
.order_by("date", "identifier")
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
logger.info(
|
|
122
|
+
"Observer '%s' (%s): %d checklists to repair",
|
|
123
|
+
identifier,
|
|
124
|
+
merged.name,
|
|
125
|
+
checklists.count(),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
loader: APILoader = self.get_loader()
|
|
129
|
+
names: dict[tuple, dict[str, str]] = {}
|
|
130
|
+
repaired: int = 0
|
|
131
|
+
skipped: int = 0
|
|
132
|
+
|
|
133
|
+
with transaction.atomic():
|
|
134
|
+
# Rename the Observer so the loader does not match it by name.
|
|
135
|
+
merged.name = REPAIR_NAME
|
|
136
|
+
merged.original = REPAIR_NAME
|
|
137
|
+
merged.save()
|
|
138
|
+
|
|
139
|
+
for checklist in checklists:
|
|
140
|
+
key = (checklist.state.code, checklist.date)
|
|
141
|
+
if key not in names:
|
|
142
|
+
names[key] = self.get_names(loader, *key)
|
|
143
|
+
|
|
144
|
+
name = names[key].get(checklist.identifier)
|
|
145
|
+
if name is None:
|
|
146
|
+
name = self.get_name(loader, checklist.identifier)
|
|
147
|
+
|
|
148
|
+
if name is None:
|
|
149
|
+
skipped += 1
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
data: dict = {
|
|
153
|
+
"subId": checklist.identifier,
|
|
154
|
+
"userDisplayName": name,
|
|
155
|
+
"locId": checklist.location.identifier,
|
|
156
|
+
"obsDt": checklist.started.strftime("%Y-%m-%d %H:%M"),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
observer: Observer = loader.get_observer(data)
|
|
160
|
+
|
|
161
|
+
if observer.pk == merged.pk:
|
|
162
|
+
skipped += 1
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
checklist.observer = observer
|
|
166
|
+
checklist.save()
|
|
167
|
+
checklist.observations.update(observer=observer)
|
|
168
|
+
repaired += 1
|
|
169
|
+
|
|
170
|
+
logger.info(
|
|
171
|
+
"Checklist %s: assigned to %s (%s)",
|
|
172
|
+
checklist.identifier,
|
|
173
|
+
observer.name,
|
|
174
|
+
observer.identifier,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if not merged.checklists.exists() and not merged.observations.exists():
|
|
178
|
+
merged.delete()
|
|
179
|
+
logger.info("Observer '%s': deleted", identifier)
|
|
180
|
+
|
|
181
|
+
logger.info("Repaired %d checklists, skipped %d", repaired, skipped)
|
|
182
|
+
|
|
183
|
+
if dry_run:
|
|
184
|
+
transaction.set_rollback(True)
|
|
185
|
+
logger.info("Dry run: no changes were saved")
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.5.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/locale/en/LC_MESSAGES/django.mo
RENAMED
|
File without changes
|
{ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/locale/en/LC_MESSAGES/django.po
RENAMED
|
File without changes
|
|
File without changes
|
{ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/management/commands/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{ebird_api_data-0.5.2 → ebird_api_data-0.5.3}/src/ebird/api/data/migrations/0002_expand_filter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|