From 5643fa41fd7d2ccd030d6c6145e1434549a4d3a2 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Thu, 8 May 2025 01:20:25 -0600 Subject: [PATCH 1/6] Import cover images & external references (#838) * feat: management command for updating fields on existing Taxa from CSV * feat: try another title for example occurrence image * feat: allow manual filter for showing taxa without occurrences * feat: allow sorting by reference image for better demos --- ami/main/api/views.py | 1 + ami/main/management/commands/import_taxa.py | 32 ++- ami/main/management/commands/update_taxa.py | 252 ++++++++++++++++++++ 3 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 ami/main/management/commands/update_taxa.py diff --git a/ami/main/api/views.py b/ami/main/api/views.py index acdfdf5cb..bbf79b0a8 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -1229,6 +1229,7 @@ class TaxonViewSet(DefaultViewSet, ProjectMixin): "last_detected", "best_determination_score", "name", + "cover_image_url", ] search_fields = ["name", "parent__name"] diff --git a/ami/main/management/commands/import_taxa.py b/ami/main/management/commands/import_taxa.py index 13c8b3686..2a260354a 100644 --- a/ami/main/management/commands/import_taxa.py +++ b/ami/main/management/commands/import_taxa.py @@ -9,6 +9,7 @@ from urllib.request import urlopen from django.core.management.base import BaseCommand, CommandError # noqa +from django.db.models import Q # import progress bar from tqdm import tqdm @@ -309,13 +310,29 @@ def create_taxon(self, taxon_data: dict, root_taxon_parent: Taxon) -> tuple[set[ # Assume ranks are in order of rank if rank.name.lower() in taxon_data.keys() and taxon_data[rank.name.lower()]: name = taxon_data[rank.name.lower()] + gbif_taxon_key = taxon_data.get("gbif_taxon_key", None) rank = rank.name.upper() - logger.debug(f"Taxon found in incoming row {i}: {rank} {name}") - try: - taxon, created = Taxon.objects.get_or_create(name=name, defaults={"rank": rank}) - except (Taxon.MultipleObjectsReturned, Exception) as e: - logger.error(f"Error creating taxon {name} {rank}: {e}") - raise + logger.debug(f"Taxon found in incoming row {i}: {rank} {name} (GBIF: {gbif_taxon_key})") + # Look up existing taxon by name or gbif_taxon_key + # If the taxon already exists, use it and maybe update it + taxon = None + matches = Taxon.objects.filter(Q(name=name) | Q(gbif_taxon_key=gbif_taxon_key)) + if len(matches) > 1: + logger.error(f"Found multiple taxa with name {name} or gbif_taxon_key {gbif_taxon_key}") + raise ValueError(f"Found multiple taxa with name {name} or gbif_taxon_key {gbif_taxon_key}") + else: + taxon = matches.first() + logger.info(f"Found existing taxon {taxon}") + created = False + + if not taxon: + taxon = Taxon.objects.create( + name=name, + rank=rank, + gbif_taxon_key=gbif_taxon_key, + parent=parent_taxon, + ) + created = True taxa_in_row.append(taxon) @@ -377,6 +394,9 @@ def create_taxon(self, taxon_data: dict, root_taxon_parent: Taxon) -> tuple[set[ "common_name_en", "notes", "sort_phylogeny", + "fieldguide_id", + "cover_image_url", + "cover_image_credit", ] is_new = specific_taxon in created_taxa diff --git a/ami/main/management/commands/update_taxa.py b/ami/main/management/commands/update_taxa.py new file mode 100644 index 000000000..fcedebb7c --- /dev/null +++ b/ami/main/management/commands/update_taxa.py @@ -0,0 +1,252 @@ +import csv +import logging +import tempfile +from typing import Any +from urllib.request import urlopen + +from django.core.management.base import BaseCommand, CommandError +from django.db.models import Q +from django.utils import timezone +from tqdm import tqdm + +from ami.main.models import TaxaList, Taxon + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +def read_csv(fname: str) -> list[dict[str, Any]]: + reader = csv.DictReader(open(fname)) + taxa = [row for row in reader] + return taxa + + +def fetch_url(url: str) -> str: + """Download data from URL to a temporary file.""" + with tempfile.NamedTemporaryFile(delete=False) as tmp_file: + with urlopen(url) as response: + tmp_file.write(response.read()) + fname = tmp_file.name + logger.info(f"Downloaded taxa file to {fname}") + return fname + + +def fix_columns(taxon_data: dict[str, Any]) -> dict[str, Any]: + """ + Normalize column names by converting to lowercase, stripping whitespace, + and replacing spaces with underscores. + """ + keys_to_update = [] + for key in taxon_data.keys(): + new_key = key.lower().strip().replace(" ", "_") + if new_key != key: + logger.debug(f"Renaming {key} to {new_key}") + keys_to_update.append((key, new_key)) + + for key, new_key in keys_to_update: + taxon_data[new_key] = taxon_data.pop(key) + + return taxon_data + + +def fix_values(taxon_data: dict[str, Any]) -> dict[str, Any]: + """ + Transform values in the data: convert null values to None and convert known types. + """ + null_values = ["z_unplaced", "incertae_sedis", ""] + known_types = {"sort_phylogeny": int, "gbif_taxon_key": int, "inat_taxon_id": int} + + for key, value in taxon_data.items(): + if str(value).strip() in null_values: + logger.debug(f"Setting {key} of {taxon_data} to None") + value = None + taxon_data[key] = value + + if value and key in known_types: + logger.debug(f"Converting {key} to {known_types[key]}") + taxon_data[key] = known_types[key](value) + + return taxon_data + + +class Command(BaseCommand): + """ + Update existing taxa with new data from a CSV file. + + This command allows updating any column in the CSV file that exists in the Taxon model. + It identifies taxa by name, gbif_taxon_key, or inat_taxon_id. + + Example usage: + ``` + python manage.py update_taxa --format csv data/taxa_updates.csv + python manage.py update_taxa --format csv https://example.com/taxa_updates.csv + ``` + + Example CSV format: + ``` + name,gbif_taxon_key,cover_image_url,cover_image_credit + Epimartyria auricrinella,12345,https://example.com/image.jpg,Photographer Name + Dyseriocrania griseocapitella,12346,https://example.com/image2.jpg,Another Photographer + ``` + + You can include any column that exists in the Taxon model. + """ + + help = "Update existing taxa with new data from a CSV file." + + def add_arguments(self, parser): + parser.add_argument("taxa", type=str, help="Path or URL to taxa CSV file") + parser.add_argument( + "--format", type=str, default="csv", help="Format of taxa file (csv is the only supported format)" + ) + parser.add_argument( + "--list", + type=str, + help="Name of taxa list to assign updated taxa to. " + "If not provided, taxa will be updated but not assigned to any list.", + ) + parser.add_argument( + "--dry-run", action="store_true", help="Show what would be updated without making changes." + ) + + def handle(self, *args, **options): + fname: str = options["taxa"] + + if fname and fname.startswith("http"): + fname = fetch_url(url=fname) + + format_type = options["format"] + if format_type.lower() != "csv": + raise CommandError("Only CSV format is supported for updating taxa") + + incoming_taxa = read_csv(fname) + + # Get or create taxa list if specified + taxalist = None + if options["list"]: + list_name = options["list"] + taxalist, created = TaxaList.objects.get_or_create(name=list_name) + if created: + self.stdout.write(self.style.SUCCESS(f"Created new taxa list '{list_name}'")) + else: + self.stdout.write(f"Using existing taxa list '{list_name}' with {taxalist.taxa.count()} taxa") + + # We'll search across all taxa regardless of list assignment + taxa_queryset = Taxon.objects.all() + + dry_run = options["dry_run"] + if dry_run: + self.stdout.write(self.style.WARNING("DRY RUN - no changes will be made")) + + total_found = 0 + total_updated = 0 + not_found = [] + # Track field update statistics + field_update_stats = {} + + for i, taxon_data in enumerate(tqdm(incoming_taxa)): + num_keys_with_values = len([key for key, value in taxon_data.items() if value]) + logger.debug(f"Processing row {i} of {len(incoming_taxa)} with {num_keys_with_values} keys") + + # Skip rows with no data + if num_keys_with_values == 0: + logger.debug(f"Skipping row {i} with no data") + continue + + # Normalize column names and values + taxon_data = fix_columns(taxon_data) + taxon_data = fix_values(taxon_data) + + # First, determine how to find the taxon + id_keys = ["id", "name", "gbif_taxon_key", "inat_taxon_id", "bold_taxon_bin", "fieldguide_id"] + query = Q() + for key in id_keys: + if key in taxon_data and taxon_data[key] is not None: + query |= Q(**{key: taxon_data[key]}) + + if not query: + self.stdout.write( + self.style.WARNING(f"Row {i}: No identifier provided. Need one of: {', '.join(id_keys)}") + ) + continue + + # Find the taxon + taxon = taxa_queryset.filter(query).first() + + if not taxon: + not_found.append(taxon_data) + continue + + total_found += 1 + + # Look for fields to update + update_fields = [] + for field, value in taxon_data.items(): + # Skip identifier fields that are not None on the existing taxon instance + if field in id_keys and getattr(taxon, field) is not None: + logger.debug(f"Row {i}: Skipping identifier field '{field}' for {taxon}") + continue + + # Check if field exists on the model + if hasattr(taxon, field): + current_value = getattr(taxon, field) + if current_value != value: + if not dry_run: + setattr(taxon, field, value) + update_fields.append(field) + + # Update field statistics + if field not in field_update_stats: + field_update_stats[field] = 0 + field_update_stats[field] += 1 + + logger.debug(f"Row {i}: Updating {field} of {taxon} from '{current_value}' to '{value}'") + else: + self.stdout.write( + self.style.WARNING(f"Row {i}: Field '{field}' does not exist on Taxon model, skipping") + ) + + if update_fields: + if not dry_run: + taxon.updated_at = timezone.now() + taxon.save(update_fields=update_fields + ["updated_at"]) + # Add to the specified taxa list if provided + if taxalist: + taxalist.taxa.add(taxon) + total_updated += 1 + else: + self.stdout.write(f"Would update {taxon}: fields {', '.join(update_fields)}") + if taxalist: + self.stdout.write(f" And would add to taxa list '{taxalist.name}'") + else: + logger.debug(f"Row {i}: No fields to update for {taxon}") + + # Summary output + self.stdout.write(self.style.SUCCESS(f"Found {total_found} taxa")) + if dry_run: + self.stdout.write(self.style.SUCCESS(f"Would update {total_updated} taxa")) + if taxalist: + self.stdout.write(self.style.SUCCESS(f"Would add updated taxa to list '{taxalist.name}'")) + + # Show field update statistics + if field_update_stats: + self.stdout.write(self.style.SUCCESS("Field update statistics (would update):")) + for field, count in sorted(field_update_stats.items(), key=lambda x: x[1], reverse=True): + self.stdout.write(f" {field}: {count} taxa") + else: + self.stdout.write(self.style.SUCCESS(f"Updated {total_updated} taxa")) + if taxalist: + self.stdout.write(self.style.SUCCESS(f"Added updated taxa to list '{taxalist.name}'")) + + # Show field update statistics + if field_update_stats: + self.stdout.write(self.style.SUCCESS("Field update statistics:")) + for field, count in sorted(field_update_stats.items(), key=lambda x: x[1], reverse=True): + self.stdout.write(f" {field}: {count} taxa") + + if not_found: + self.stdout.write(self.style.WARNING(f"Could not find {len(not_found)} taxa")) + for i, data in enumerate(not_found[:5]): # Show only first 5 + self.stdout.write(f" {i+1}. {data}") + if len(not_found) > 5: + self.stdout.write(f" ... and {len(not_found) - 5} more") From 33fdfce70c4ef832b685d2b8065ba8f1b8dfa01b Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Thu, 1 May 2025 08:22:11 -0700 Subject: [PATCH 2/6] Fields for Taxon reference images (#822) * feat: add fields for Taxon cover images and Fieldguide ID * feat: add cover image fields to Taxon model for the UI --- ami/main/api/serializers.py | 7 +++++ ...e_credit_taxon_cover_image_url_and_more.py | 27 +++++++++++++++++++ ami/main/models.py | 4 +++ ui/src/data-services/models/species.ts | 17 +++++++++--- 4 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 ami/main/migrations/0072_taxon_cover_image_credit_taxon_cover_image_url_and_more.py diff --git a/ami/main/api/serializers.py b/ami/main/api/serializers.py index 61797f9f8..4bd8670fb 100644 --- a/ami/main/api/serializers.py +++ b/ami/main/api/serializers.py @@ -263,6 +263,9 @@ class Meta: "rank", "details", "gbif_taxon_key", + "fieldguide_id", + "cover_image_url", + "cover_image_credit", ] @@ -581,6 +584,7 @@ class Meta: "tags", "last_detected", "best_determination_score", + "cover_image_url", "created_at", "updated_at", ] @@ -807,6 +811,9 @@ class Meta: "tags", "last_detected", "best_determination_score", + "fieldguide_id", + "cover_image_url", + "cover_image_credit", ] diff --git a/ami/main/migrations/0072_taxon_cover_image_credit_taxon_cover_image_url_and_more.py b/ami/main/migrations/0072_taxon_cover_image_credit_taxon_cover_image_url_and_more.py new file mode 100644 index 000000000..84c6c642f --- /dev/null +++ b/ami/main/migrations/0072_taxon_cover_image_credit_taxon_cover_image_url_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.10 on 2025-09-04 01:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0071_alter_project_options"), + ] + + operations = [ + migrations.AddField( + model_name="taxon", + name="cover_image_credit", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="taxon", + name="cover_image_url", + field=models.URLField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="taxon", + name="fieldguide_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/ami/main/models.py b/ami/main/models.py index 88da476ae..553964fcb 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -2958,8 +2958,12 @@ class Taxon(BaseModel): gbif_taxon_key = models.BigIntegerField("GBIF taxon key", blank=True, null=True) bold_taxon_bin = models.CharField("BOLD taxon BIN", max_length=255, blank=True, null=True) inat_taxon_id = models.BigIntegerField("iNaturalist taxon ID", blank=True, null=True) + fieldguide_id = models.CharField(max_length=255, blank=True, null=True) # lepsai_id = models.BigIntegerField("LepsAI / Fieldguide ID", blank=True, null=True) + cover_image_url = models.URLField(max_length=255, blank=True, null=True) + cover_image_credit = models.CharField(max_length=255, blank=True, null=True) + notes = models.TextField(blank=True) projects = models.ManyToManyField("Project", related_name="taxa") diff --git a/ui/src/data-services/models/species.ts b/ui/src/data-services/models/species.ts index 9127fa0f3..1b857469c 100644 --- a/ui/src/data-services/models/species.ts +++ b/ui/src/data-services/models/species.ts @@ -53,12 +53,21 @@ export class Species extends Taxon { return `https://www.gbif.org/occurrence/gallery?advanced=1&verbatim_scientific_name=${this.name}` } + get fieldguideId(): string | null { + return this._species.fieldguide_id || null + } + get fieldguideUrl(): string | undefined { - if (!this._species.fieldguide_id) { - return undefined - } + if (!this.fieldguideId) return undefined + return `https://leps.fieldguide.ai/categories?category=${this.fieldguideId}` + } + + get coverImageUrl(): string | null { + return this._species.cover_image_url || null + } - return `https://leps.fieldguide.ai/categories?category=${this._species.fieldguide_id}` + get coverImageCredit(): string | null { + return this._species.cover_image_credit || null } get score(): number | undefined { From 5217bda98e4ffddf7f45cc3dc06aeba749231224 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 3 Sep 2025 23:27:36 -0700 Subject: [PATCH 3/6] fix: look up existing taxa by name only --- ami/main/management/commands/import_taxa.py | 29 +++++++-------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/ami/main/management/commands/import_taxa.py b/ami/main/management/commands/import_taxa.py index 2a260354a..bd732f59c 100644 --- a/ami/main/management/commands/import_taxa.py +++ b/ami/main/management/commands/import_taxa.py @@ -9,7 +9,6 @@ from urllib.request import urlopen from django.core.management.base import BaseCommand, CommandError # noqa -from django.db.models import Q # import progress bar from tqdm import tqdm @@ -313,37 +312,29 @@ def create_taxon(self, taxon_data: dict, root_taxon_parent: Taxon) -> tuple[set[ gbif_taxon_key = taxon_data.get("gbif_taxon_key", None) rank = rank.name.upper() logger.debug(f"Taxon found in incoming row {i}: {rank} {name} (GBIF: {gbif_taxon_key})") - # Look up existing taxon by name or gbif_taxon_key - # If the taxon already exists, use it and maybe update it - taxon = None - matches = Taxon.objects.filter(Q(name=name) | Q(gbif_taxon_key=gbif_taxon_key)) - if len(matches) > 1: - logger.error(f"Found multiple taxa with name {name} or gbif_taxon_key {gbif_taxon_key}") - raise ValueError(f"Found multiple taxa with name {name} or gbif_taxon_key {gbif_taxon_key}") - else: - taxon = matches.first() - logger.info(f"Found existing taxon {taxon}") - created = False - if not taxon: - taxon = Taxon.objects.create( - name=name, + # Look up existing taxon by name only, since names must be unique. + # If the taxon already exists, use it and maybe update it + taxon, created = Taxon.objects.get_or_create( + name=name, + defaults=dict( rank=rank, gbif_taxon_key=gbif_taxon_key, parent=parent_taxon, - ) - created = True - + ), + ) taxa_in_row.append(taxon) if created: logger.debug(f"Created new taxon #{taxon.id} {taxon} ({taxon.rank})") created_taxa.add(taxon) + else: + logger.debug(f"Using existing taxon #{taxon.id} {taxon} ({taxon.rank})") # Add or update the rank of the taxon based on incoming data if not taxon.rank or taxon.rank != rank: if not created: - logger.warn(f"Rank of existing {taxon} is {taxon.rank}, changing to {rank}") + logger.warning(f"Rank of existing {taxon} is {taxon.rank}, changing to {rank}") taxon.rank = rank taxon.save(update_calculated_fields=False) if not created: From 71ad6058687225bf71f58b67508054725858aa87 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 3 Sep 2025 23:27:57 -0700 Subject: [PATCH 4/6] fix: update existing genus parents if their rank is wrong, don't crash --- ami/main/models.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/ami/main/models.py b/ami/main/models.py index 553964fcb..3e746ad43 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -2738,20 +2738,30 @@ def add_genus_parents(self): Create a genus if it doesn't exist based on the scientific name of the species. This will replace any parents of a species that are not of the GENUS rank. """ - species = self.get_queryset().filter(rank="SPECIES") # , parent=None) + Taxon: "Taxon" = self.model # type: ignore + species = self.get_queryset().filter(rank=TaxonRank.SPECIES) # , parent=None) updated = [] for taxon in species: - if taxon.parent and taxon.parent.rank == "GENUS": + if taxon.parent and taxon.parent.rank == TaxonRank.GENUS: continue - genus_name = taxon.name.split()[0] - genus = self.get_queryset().filter(name=genus_name, rank="GENUS").first() - if not genus: - Taxon = self.model - genus = Taxon.objects.create(name=genus_name, rank="GENUS") - taxon.parent = genus - logger.info(f"Added parent {genus} to {taxon}") + + genus_name = taxon.name.split()[0].strip() + + # There can be only one taxon with a given name. + genus_taxon, created = Taxon.objects.get_or_create(name=genus_name, defaults={"rank": TaxonRank.GENUS}) + if created: + updated.append(genus_taxon) + elif genus_taxon.rank != TaxonRank.GENUS: + genus_taxon.rank = TaxonRank.GENUS + logger.info(f"Updating rank of existing {genus_taxon} from {genus_taxon.rank} to {TaxonRank.GENUS}") + genus_taxon.save() + updated.append(genus_taxon) + + taxon.parent = genus_taxon + logger.info(f"Added parent {genus_taxon} to {taxon}") taxon.save() updated.append(taxon) + return updated def update_display_names(self, queryset: models.QuerySet | None = None): From ffecb9d74dcdb9784d9f8ec9d277e918d6ee20cf Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 3 Sep 2025 23:34:41 -0700 Subject: [PATCH 5/6] fix: don't allow empty CSV columns to clear existing data --- ami/main/management/commands/import_taxa.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ami/main/management/commands/import_taxa.py b/ami/main/management/commands/import_taxa.py index bd732f59c..c39fef208 100644 --- a/ami/main/management/commands/import_taxa.py +++ b/ami/main/management/commands/import_taxa.py @@ -397,6 +397,11 @@ def create_taxon(self, taxon_data: dict, root_taxon_parent: Taxon) -> tuple[set[ existing_value = getattr(specific_taxon, column) incoming_value = taxon_data[column] if existing_value != incoming_value: + if incoming_value is None: + # Don't overwrite existing values with None. + # This could potentially be a command line option to allow users to clear values. + logger.debug(f"Not changing {column} of {specific_taxon} from {existing_value} to None") + continue if not is_new: logger.info( f"Changing {column} of {specific_taxon} to from {existing_value} to {incoming_value}" @@ -407,7 +412,7 @@ def create_taxon(self, taxon_data: dict, root_taxon_parent: Taxon) -> tuple[set[ specific_taxon.save(update_calculated_fields=False) if not is_new: # raise ValueError(f"TAXON DATA CHANGED for {specific_taxon}") - logger.warn(f"TAXON DATA CHANGED for existing {specific_taxon} ({specific_taxon.id})") + logger.warning(f"TAXON DATA CHANGED for existing {specific_taxon} ({specific_taxon.id})") updated_taxa.add(specific_taxon) if accepted_name: From f0638da96abbd5663ca078ed7cb5ae0538db6dcc Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 3 Sep 2025 23:56:22 -0700 Subject: [PATCH 6/6] fix: ensure all taxa in import list are added to taxa list in DB --- ami/main/management/commands/import_taxa.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ami/main/management/commands/import_taxa.py b/ami/main/management/commands/import_taxa.py index c39fef208..ffd66c5a1 100644 --- a/ami/main/management/commands/import_taxa.py +++ b/ami/main/management/commands/import_taxa.py @@ -265,9 +265,10 @@ def handle(self, *args, **options): taxon_data = fix_values(taxon_data) logger.debug(f"Parsed taxon data: {taxon_data}") if taxon_data: - created_taxa, updated_taxa = self.create_taxon(taxon_data, root_taxon_parent) + created_taxa, updated_taxa, specific_taxon = self.create_taxon(taxon_data, root_taxon_parent) taxa_to_refresh.update(created_taxa) taxa_to_refresh.update(updated_taxa) + taxalist.taxa.add(specific_taxon) if created_taxa: logger.debug(f"Created {len(created_taxa)} taxa from incoming row {i}") taxalist.taxa.add(*created_taxa) @@ -282,6 +283,7 @@ def handle(self, *args, **options): logger.info("SUMMARY:") logger.info(f"Created {total_created_taxa} total taxa") logger.info(f"Updated {total_updated_taxa} total taxa") + logger.info(f"Total taxa in list {taxalist}: {taxalist.taxa.count()}") # Ensure the root taxon still exists and has no parent root = Taxon.objects.root() @@ -293,7 +295,7 @@ def handle(self, *args, **options): for taxon in tqdm(taxa_to_refresh): taxon.save(update_calculated_fields=True) - def create_taxon(self, taxon_data: dict, root_taxon_parent: Taxon) -> tuple[set[Taxon], set[Taxon]]: + def create_taxon(self, taxon_data: dict, root_taxon_parent: Taxon) -> tuple[set[Taxon], set[Taxon], Taxon]: taxa_in_row = [] created_taxa = set() updated_taxa = set() @@ -433,4 +435,4 @@ def create_taxon(self, taxon_data: dict, root_taxon_parent: Taxon) -> tuple[set[ # - return created_taxa, updated_taxa + return created_taxa, updated_taxa, specific_taxon