ebird-api-data 0.5.1__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.
Files changed (35) hide show
  1. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/PKG-INFO +9 -7
  2. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/README.md +6 -2
  3. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/pyproject.toml +3 -5
  4. ebird_api_data-0.5.3/src/ebird/api/data/__init__.py +1 -0
  5. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/admin.py +2 -1
  6. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/loaders.py +117 -38
  7. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/management/commands/add_checklists.py +4 -3
  8. ebird_api_data-0.5.3/src/ebird/api/data/management/commands/repair_observers.py +185 -0
  9. ebird_api_data-0.5.1/src/ebird/api/data/__init__.py +0 -1
  10. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/.gitignore +0 -0
  11. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/LICENSE +0 -0
  12. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/apps.py +0 -0
  13. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/fields.py +0 -0
  14. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/locale/en/LC_MESSAGES/django.mo +0 -0
  15. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/locale/en/LC_MESSAGES/django.po +0 -0
  16. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/management/__init__.py +0 -0
  17. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/management/commands/__init__.py +0 -0
  18. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/migrations/0001_initial.py +0 -0
  19. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/migrations/0002_expand_filter.py +0 -0
  20. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/migrations/0003_remove_checklist_published_and_more.py +0 -0
  21. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/migrations/0004_remove_observer_multiple.py +0 -0
  22. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/migrations/__init__.py +0 -0
  23. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/models/__init__.py +0 -0
  24. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/models/checklist.py +0 -0
  25. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/models/country.py +0 -0
  26. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/models/county.py +0 -0
  27. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/models/location.py +0 -0
  28. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/models/observation.py +0 -0
  29. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/models/observer.py +0 -0
  30. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/models/species.py +0 -0
  31. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/models/state.py +0 -0
  32. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/templates/admin/data/species/change_list_object_tools.html +0 -0
  33. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/templates/admin/data/species/fetch_species.html +0 -0
  34. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/templates/data/widgets/translation_field.html +0 -0
  35. {ebird_api_data-0.5.1 → ebird_api_data-0.5.3}/src/ebird/api/data/widgets.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: ebird-api-data
3
- Version: 0.5.1
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.1.0
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
- scrape the web page for the checklist where the identifier, e.g. USER2743448,
169
- can be found, and so the checklist will be assigned to the correct Observer.
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
- scrape the web page for the checklist where the identifier, e.g. USER2743448,
140
- can be found, and so the checklist will be assigned to the correct Observer.
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.1"
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.1.0",
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.1"
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
- loader = APILoader(key, locales)
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 requests
19
-
20
- from bs4 import BeautifulSoup
21
- from ebird.api.requests import get_checklist, get_regions, get_taxonomy, get_visits
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 calling
74
- the API or scraping a web page. Defaults to RETRY_LIMIT if not
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 calling the eBird API or
94
- fetching a web page. After each failure, a wait is applied, which increases
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 (URLError, HTTPError) as err:
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
- logger.info("Scraping checklist: %s", checklist_identifier)
365
- response = self.call(
366
- requests.get, "https://ebird.org/checklist/%s" % checklist_identifier
367
- )
368
- content = response.content
369
- soup = BeautifulSoup(content, "lxml")
370
- attribute = "data-participant-userid"
371
- node = soup.find("span", attrs={attribute: True})
372
- identifier = node[attribute] if node else ""
373
- if not identifier:
374
- logger.error("Observer Identifier: not found")
375
- return identifier
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 scraped from the checklist web page and
382
- # so the correct "John Smith" will be used. In practice, apart from
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 = self.get_observer_identifier(data)
392
- # The observer might have changed their name.
393
- observer, created = Observer.objects.update_or_create(
394
- identifier=identifier, defaults={"name": name, "original": name}
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
- else:
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 scrape the checklist web page to get
423
- # identifier of the observer, or the eBird API when a new species
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 = get_visits(
556
- self.api_key, region, date=date, max_results=API_MAX_RESULTS
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 the web page for the checklist is scraped to extract their eBird
69
- user id.
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
- return APILoader(key, locales)
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.1"
File without changes